diff --git a/README.md b/README.md index f52072ce..434e61aa 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,9 @@ python examples/resolve-and-execute.py `resolve-and-execute.py` will still show the ranked providers plus any machine-readable recovery handoff Rhumb already identified. Set `RHUMB_API_KEY` only when you want to continue into estimate and execute. +For the current Rhumb-managed P0 launch catalog, see +[`docs/RESOLVE-LAUNCH-CATALOG-2026-06-04.md`](docs/RESOLVE-LAUNCH-CATALOG-2026-06-04.md). + --- ## Docs @@ -221,6 +224,7 @@ python examples/resolve-and-execute.py - [AN Score Methodology](docs/AN-SCORE-V2-SPEC.md) — scoring dimensions, weights, and rubrics - [Architecture](docs/ARCHITECTURE.md) — scoring engine design - [API Reference](docs/API.md) — endpoint details +- [Resolve Launch Catalog](docs/RESOLVE-LAUNCH-CATALOG-2026-06-04.md) — current P0 Rhumb-managed capability contract - [Repo Boundary](docs/REPO-BOUNDARY.md) — what stays public here vs. what lives in the private ops workspace - [Security Policy](SECURITY.md) — vulnerability reporting and security architecture diff --git a/docs/API.md b/docs/API.md index 182eeafe..e9574a78 100644 --- a/docs/API.md +++ b/docs/API.md @@ -152,6 +152,142 @@ curl "https://api.rhumb.dev/v1/capabilities/search.query/resolve" curl "https://api.rhumb.dev/v1/capabilities/search.query/execute/estimate?credential_mode=rhumb_managed" ``` +## Resolve v2 first-call proof: `search.query` + +Use this path when you want the boring first successful Rhumb-managed official API call with one governed key and no provider credential setup: + +`search -> resolve -> estimate -> execute -> receipt -> verify` + +Requirements: + +- a funded governed Rhumb API key in `RHUMB_API_KEY`, sent as `X-Rhumb-Key`; +- no Brave/provider key, wallet, browser login, Agent Vault, or BYOK setup; +- curl with `--fail-with-body` so typed 401/402 JSON remains visible when a step fails. If your curl is too old, rerun the same command with `-sS` and without `--fail-with-body` to print the body. + +Copy-paste flow: + +```bash +set -euo pipefail + +: "${RHUMB_API_KEY:?Set RHUMB_API_KEY to a funded governed Rhumb key}" +RHUMB_API_BASE="${RHUMB_API_BASE:-https://api.rhumb.dev}" + +curlf() { + curl --fail-with-body -sS "$@" +} + +# 1) Search for the capability slug. +curlf \ + -X GET \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + "$RHUMB_API_BASE/v2/capabilities?search=web%20research&limit=5" + +# 2) Resolve the Rhumb-managed route. This is a read/preflight step. +curlf \ + -X GET \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + "$RHUMB_API_BASE/v2/capabilities/search.query/resolve?credential_mode=rhumb_managed" \ + | tee /tmp/rhumb-first-call-resolve.json + +# 3) Estimate the concrete execution rail before spend. +curlf \ + -X GET \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + "$RHUMB_API_BASE/v2/capabilities/search.query/execute/estimate?provider=brave-search-api&credential_mode=rhumb_managed" \ + | tee /tmp/rhumb-first-call-estimate.json + +# 4) Execute one bounded read-only official API search. +curlf \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + "$RHUMB_API_BASE/v2/capabilities/search.query/execute" \ + -d '{ + "credential_mode": "rhumb_managed", + "interface": "first-call-quickstart", + "parameters": {"query": "agent-native tool access", "numResults": 3}, + "policy": {"max_cost_usd": 0.05, "provider_preference": ["brave-search-api"]} + }' \ + | tee /tmp/rhumb-first-call-execute.json + +RECEIPT_ID="$( + python3 - <<'PY' +import json +from pathlib import Path + +payload = json.loads(Path('/tmp/rhumb-first-call-execute.json').read_text()) +data = payload.get('data') if isinstance(payload, dict) and 'data' in payload else payload +receipt_id = ( + data.get('receipt_id') + or (data.get('_rhumb_v2') or {}).get('receipt_id') + or (data.get('_rhumb') or {}).get('receipt_id') +) +if not receipt_id: + raise SystemExit('No receipt_id in execute response') +print(receipt_id) +PY +)" +echo "receipt_id=$RECEIPT_ID" + +# 5) Fetch the full receipt row. +curlf \ + -X GET \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + "$RHUMB_API_BASE/v2/receipts/$RECEIPT_ID" \ + | tee /tmp/rhumb-first-call-receipt.json + +# 6) Verify the compact receipt evidence. `verified` means the receipt row, +# hashes, route facts, route-plan hash, status, and explanation linkage are present; +# it does not claim provider-side content truth beyond returned-payload hashing. +curlf \ + -X POST \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + "$RHUMB_API_BASE/v2/receipts/$RECEIPT_ID/verify" \ + | tee /tmp/rhumb-first-call-verify.json + +python3 - <<'PY' +import json +from pathlib import Path + +payload = json.loads(Path('/tmp/rhumb-first-call-verify.json').read_text()) +data = payload.get('data') if isinstance(payload, dict) and 'data' in payload else payload +status = data.get('verifier_status') +if status != 'verified': + raise SystemExit(f"receipt verifier_status={status!r}; expected 'verified'") +compact = data.get('compact_receipt') or {} +route = compact.get('route') or {} +print('verified receipt:', data.get('receipt_id')) +print('provider:', route.get('provider_id')) +print('route_id:', route.get('route_id')) +print('manifest_digest:', route.get('manifest_digest')) +PY +``` + +Expected proof fields: + +- selected provider: `brave-search-api` unless policy or availability selects another executable official API route; +- substrate/provenance/source risk: `official_api` / `rhumb_managed` / `verified_low` for the seeded Brave route; +- receipt and verifier: `receipt_id`, `compact_receipt`, `route_plan.route_plan_id_hash`, and `verifier_status=verified`; +- cost/usage: budget fields in the compact receipt and billing events in `/v2/billing/events`. + +Diagnostics that should stay typed: + +- missing key: `missing_credentials` / missing `X-Rhumb-Key` guidance; +- invalid key: `invalid_rhumb_key` / invalid governed key guidance; +- valid but unfunded or over-budget key: `payment_required` with `auth_handoff` and retry/setup URLs such as `/auth/login` or `/payments/agent`. + +For the hosted operator artifact, run the same flow through the dogfood harness after confirming the key is funded: + +```bash +python3 scripts/resolve_v2_dogfood.py \ + --profile pedro \ + --force-provider-preference \ + --json-out artifacts/resolve-v2-first-call-.json \ + --summary-only +``` + +The harness stores `first_call_proof` and fails if the Layer 2 receipt verifier does not return `verified`. + ## Resolve hint contract `GET /v1/capabilities/{capability_id}/resolve` now returns an `execute_hint` block that is meant to answer the first-success question directly: diff --git a/docs/RESOLVE-LAUNCH-CATALOG-2026-06-04.md b/docs/RESOLVE-LAUNCH-CATALOG-2026-06-04.md new file mode 100644 index 00000000..89e13094 --- /dev/null +++ b/docs/RESOLVE-LAUNCH-CATALOG-2026-06-04.md @@ -0,0 +1,88 @@ +# Resolve Launch Catalog + +This is the current P0 Rhumb-managed capability surface for agents using a Rhumb API key. The goal is a small public catalog that works before agents wire their own provider accounts. + +## Agent Contract + +Use this loop: + +1. Discover the capability ID. +2. Resolve the managed route. +3. Estimate the route before spend. +4. Execute only bounded, allowed capabilities. +5. Store the execution receipt. + +```bash +API="https://api.rhumb.dev/v1" + +curl "$API/capabilities/search.query/resolve?credential_mode=rhumb_managed" + +curl "$API/capabilities/search.query/execute/estimate?credential_mode=rhumb_managed" \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" + +curl -X POST "$API/capabilities/search.query/execute" \ + -H "X-Rhumb-Key: $RHUMB_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "credential_mode": "rhumb_managed", + "provider": "exa", + "body": {"query": "agent native API capability routing", "numResults": 3} + }' +``` + +For MCP, use the same order: + +```text +resolve_capability({"capability":"search.query","credential_mode":"rhumb_managed"}) +estimate_capability({"capability_id":"search.query","credential_mode":"rhumb_managed"}) +execute_capability({ + "capability_id":"search.query", + "credential_mode":"rhumb_managed", + "provider":"exa", + "body":{"query":"agent native API capability routing","numResults":3} +}) +``` + +## P0 Capabilities + +| Capability | Preferred provider | Status | Execution policy | +| --- | --- | --- | --- | +| `search.query` | `exa` | Launch | Safe read execution | +| `scrape.extract` | `firecrawl` | Launch | Safe one-page public extract | +| `ai.generate_text` | `openai` | Launch | Bounded short text generation | +| `ai.generate_image` | `openai` | Launch | Bounded one-image generation, compact evidence only | +| `data.enrich_person` | `people-data-labs` | Launch with proof substitute | Resolve + estimate only until a consented fixture exists | +| `data.enrich_company` | `apollo` | Launch | Rhumb-owned company-domain fixture | +| `geo.lookup` | `ipinfo` | Launch | Public resolver IP fixture only | +| `maps.places_search` | `google-places` | Launch | Public place-search fixture | + +## Deferred Surfaces + +`email.send` is a real capability but remains gated because it sends externally. It needs an owned verified sender, one-message cap, idempotency, approval policy, and receipt review before public managed execution. + +`sendgrid` is a valid pass-through provider for `email.send`, but hosted Resolve currently marks it non-callable because no `RHUMB_CREDENTIAL_SENDGRID_API_KEY` is configured. This is not a launch blocker because `email.send` is deferred as an external-write surface; do not treat any email provider as part of the P0 managed launch catalog until the email-send gates above are complete. + +`email.verify` is deferred because no managed verification provider is configured. Add Emailable, ZeroBounce, NeverBounce, Kickbox, or another approved provider before advertising it as managed. + +## Error Contract + +If `resolve` returns no executable managed provider, agents should use the machine-readable recovery fields: + +- `recovery_hint.resolve_url` +- `recovery_hint.credential_modes_url` +- `recovery_hint.supported_provider_slugs` +- `recovery_hint.setup_handoff` + +If `execute` returns auth or funding errors, agents should stop and surface the returned handoff rather than switching to a browser, scraping credentials, or trying a public write. + +## Proof Command + +Run the current proof harness with: + +```bash +python3 scripts/resolve_launch_catalog_smoke.py \ + --execute-safe \ + --out artifacts/resolve-launch-catalog-smoke.json +``` + +The artifact is redacted by design: it stores statuses, provider IDs, execution IDs, receipt IDs, hashes, and compact response shape. It does not store raw provider keys, raw generated image bytes, or full upstream payloads. diff --git a/packages/api/migrations/0165_index_command_manifests.sql b/packages/api/migrations/0165_index_command_manifests.sql new file mode 100644 index 00000000..01409e05 --- /dev/null +++ b/packages/api/migrations/0165_index_command_manifests.sql @@ -0,0 +1,60 @@ +-- Migration 0165: Durable Index command manifest storage +-- +-- PP-1/PP-2 durable hosted storage for command-level route manifests and +-- evidence packets. This table intentionally stores full manifest/evidence JSON +-- alongside indexed route taxonomy columns so Resolve can query route truth +-- without depending on private fixture registries. + +BEGIN; + +CREATE TABLE IF NOT EXISTS index_command_manifests ( + route_id TEXT PRIMARY KEY, + manifest_id TEXT NOT NULL, + manifest_version TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + service_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + capability_id TEXT NOT NULL, + substrate TEXT NOT NULL, + provenance_origin TEXT NOT NULL, + source_risk TEXT NOT NULL, + side_effect_class TEXT NOT NULL, + promotion_state TEXT, + review_status TEXT, + evidence_packet_id TEXT, + evidence_packet_digest TEXT, + evidence_expires_at TIMESTAMPTZ, + public_claim_boundary TEXT NOT NULL, + manifest_json JSONB NOT NULL, + evidence_packet_json JSONB, + owner TEXT, + reviewer TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_capability + ON index_command_manifests (capability_id, route_id); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_provider + ON index_command_manifests (capability_id, provider_id); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_taxonomy + ON index_command_manifests (substrate, provenance_origin, source_risk); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_manifest_digest + ON index_command_manifests (manifest_digest); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_evidence_digest + ON index_command_manifests (evidence_packet_digest) + WHERE evidence_packet_digest IS NOT NULL; + +ALTER TABLE index_command_manifests ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS index_command_manifests_read ON index_command_manifests; +CREATE POLICY index_command_manifests_read ON index_command_manifests + FOR SELECT + USING (true); + +COMMIT; diff --git a/packages/api/migrations/0166_index_command_manifest_seed.sql b/packages/api/migrations/0166_index_command_manifest_seed.sql new file mode 100644 index 00000000..18e82866 --- /dev/null +++ b/packages/api/migrations/0166_index_command_manifest_seed.sql @@ -0,0 +1,275 @@ +-- Migration 0166: Seed hosted Index command manifest storage +-- +-- Deterministic PP-2 fixture seed for index_command_manifests. These rows +-- preserve manifest/evidence digests and public claim boundaries exactly; +-- they do not claim live production execution. Evidence columns are filled +-- only where an Index evidence packet fixture already exists. + +BEGIN; + +INSERT INTO index_command_manifests ( + route_id, + manifest_id, + manifest_version, + manifest_digest, + service_id, + provider_id, + capability_id, + substrate, + provenance_origin, + source_risk, + side_effect_class, + promotion_state, + review_status, + evidence_packet_id, + evidence_packet_digest, + evidence_expires_at, + public_claim_boundary, + manifest_json, + evidence_packet_json, + owner, + reviewer, + expires_at +) +VALUES + ( + 'route_calendar_freebusy_generated_adapter_v1', + 'manifest_generated_calendar_freebusy_fixture_v1', + '2026-05-19.1', + 'sha256:bc8b9916a1122e58d66baf8ddd50c3b6ee0b4734a467211f77868f004657fbb5', + 'google-calendar', + 'generated-calendar-adapter', + 'calendar.freebusy', + 'generated_adapter', + 'rhumb_generated', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only generated adapter candidate; Rhumb must not describe it as approved by the vendor or production ready before evidence, sandbox, and review gates pass.', + '{"artifact_allowlist":["generated-calendar-adapter@fixture-digest-placeholder"],"auth_mode":"agent_vault","cache_behavior":"none","capability_id":"calendar.freebusy","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["calendar_availability"],"dry_run_supported":false,"evidence_refs":["evidence:route_calendar_freebusy_generated_adapter_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:bc8b9916a1122e58d66baf8ddd50c3b6ee0b4734a467211f77868f004657fbb5","manifest_id":"manifest_generated_calendar_freebusy_fixture_v1","manifest_version":"2026-05-19.1","network_allowlist":["www.googleapis.com"],"owner":"Pedro","process_allowlist":["python"],"promotion_state":"fixture_only","provenance_origin":"rhumb_generated","provider_id":"generated-calendar-adapter","public_claim_boundary":"Fixture-only generated adapter candidate; Rhumb must not describe it as approved by the vendor or production ready before evidence, sandbox, and review gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_calendar_freebusy_generated_adapter_v1","route_name":"calendar.freebusy via generated adapter fixture","sandbox_profile_class":"generated_adapter_readonly_fixture","service_id":"google-calendar","side_effect_class":"read","source_risk":"community_unverified","substrate":"generated_adapter","tests":["test_manifest_route_calendar_freebusy_generated_adapter_v1"],"vendor":"Google Calendar"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_crm_contact_lookup_salesforce_api_official_api_v1', + 'manifest_crm_contact_lookup_salesforce_api_official_api_v1', + '2026-05-19.1', + 'sha256:b36192fc5873116ede939d10cfd6ad0a5b9daf2512515371f5f791c555b4e031', + 'salesforce', + 'salesforce-api', + 'crm.contact.lookup', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the crm.contact.lookup managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"crm.contact.lookup","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["crm_contact"],"dry_run_supported":false,"evidence_refs":["evidence:route_crm_contact_lookup_salesforce_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:b36192fc5873116ede939d10cfd6ad0a5b9daf2512515371f5f791c555b4e031","manifest_id":"manifest_crm_contact_lookup_salesforce_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.salesforce.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"salesforce-api","public_claim_boundary":"Rhumb can represent the crm.contact.lookup managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["salesforce-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_crm_contact_lookup_salesforce_api_official_api_v1","route_name":"crm.contact.lookup via salesforce-api official API","service_id":"salesforce","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_crm_contact_lookup_salesforce_api_official_api_v1"],"vendor":"salesforce"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_customer_retrieve_stripe_sdk_code_mode_v1', + 'manifest_stripe_customer_retrieve_sdk_code_v1', + '2026-05-19.1', + 'sha256:af34bda7f48ec62fe7c070dd1045829ddd3d6320578593a7c9438f22b6f9f8b8', + 'stripe', + 'stripe-sdk', + 'customer.retrieve', + 'sdk_code_mode', + 'vendor_official', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only route describing a pinned official Stripe SDK read path after code-mode sandbox gates pass.', + '{"artifact_allowlist":["stripe-python@pinned-digest-placeholder"],"auth_mode":"agent_vault","cache_behavior":"none","capability_id":"customer.retrieve","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["customer_profile"],"dry_run_supported":false,"evidence_refs":["evidence:route_customer_retrieve_stripe_sdk_code_mode_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:af34bda7f48ec62fe7c070dd1045829ddd3d6320578593a7c9438f22b6f9f8b8","manifest_id":"manifest_stripe_customer_retrieve_sdk_code_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.stripe.com"],"owner":"Pedro","process_allowlist":["python"],"promotion_state":"fixture_only","provenance_origin":"vendor_official","provider_id":"stripe-sdk","public_claim_boundary":"Fixture-only route describing a pinned official Stripe SDK read path after code-mode sandbox gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_customer_retrieve_stripe_sdk_code_mode_v1","route_name":"customer.retrieve via official SDK code-mode fixture","sandbox_profile_class":"sdk_code_readonly_network_stripe","service_id":"stripe","side_effect_class":"read","source_risk":"community_unverified","substrate":"sdk_code_mode","tests":["test_manifest_route_customer_retrieve_stripe_sdk_code_mode_v1"],"vendor":"Stripe"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_email_verify_emailable_api_official_api_v1', + 'manifest_email_verify_emailable_api_official_api_v1', + '2026-05-19.1', + 'sha256:51b416b5febdf2ebfa50df20959e5f055f9ff058e546151445bcef45023d15eb', + 'emailable', + 'emailable-api', + 'email.verify', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the email.verify managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"email.verify","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["email_address"],"dry_run_supported":false,"evidence_refs":["evidence:route_email_verify_emailable_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:51b416b5febdf2ebfa50df20959e5f055f9ff058e546151445bcef45023d15eb","manifest_id":"manifest_email_verify_emailable_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.emailable.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"emailable-api","public_claim_boundary":"Rhumb can represent the email.verify managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["emailable-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_email_verify_emailable_api_official_api_v1","route_name":"email.verify via emailable-api official API","service_id":"emailable","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_email_verify_emailable_api_official_api_v1"],"vendor":"emailable"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_file_search_official_mcp_v1', + 'manifest_filesystem_search_official_mcp_v1', + '2026-05-19.1', + 'sha256:01f0bedd335144423455a1a0cd94c59937db9153e5d2956c6e5f2798b4dfa5fc', + 'filesystem', + 'filesystem-mcp', + 'file.search', + 'official_mcp', + 'vendor_official', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only route describing a pinned read-only MCP filesystem search path after sandbox and consent gates pass.', + '{"artifact_allowlist":["mcp-filesystem@pinned-digest-placeholder"],"auth_mode":"none","cache_behavior":"none","capability_id":"file.search","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["local_file_metadata"],"dry_run_supported":false,"evidence_refs":["evidence:route_file_search_official_mcp_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":["/tmp/rhumb-fixtures/read-only"],"manifest_digest":"sha256:01f0bedd335144423455a1a0cd94c59937db9153e5d2956c6e5f2798b4dfa5fc","manifest_id":"manifest_filesystem_search_official_mcp_v1","manifest_version":"2026-05-19.1","network_allowlist":["none"],"owner":"Pedro","process_allowlist":["node"],"promotion_state":"fixture_only","provenance_origin":"vendor_official","provider_id":"filesystem-mcp","public_claim_boundary":"Fixture-only route describing a pinned read-only MCP filesystem search path after sandbox and consent gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_file_search_official_mcp_v1","route_name":"file.search via official MCP server fixture","sandbox_profile_class":"mcp_readonly_filesystem_fixture","service_id":"filesystem","side_effect_class":"read","source_risk":"community_unverified","substrate":"official_mcp","tests":["test_manifest_route_file_search_official_mcp_v1"],"vendor":"Model Context Protocol"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_search_query_brave_search_api_official_api_v1', + 'manifest_search_query_brave_search_api_official_api_v1', + '2026-05-19.1', + 'sha256:500330f51d0cc678ab9eb1427858d116df15c4c047c1aadb39c465ac343dade2', + 'brave-search', + 'brave-search-api', + 'search.query', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + 'current', + 'evidence_search_query_brave_search_api_official_api_2026_05_19', + 'sha256:d8976c9766905eaa2c08987205f056ce24520665874bb2dab48461a3e7561c2a', + '2026-08-17T00:00:00Z'::timestamptz, + 'Rhumb can represent the search.query managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"search.query","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["web_search_query"],"dry_run_supported":false,"evidence_refs":["evidence:route_search_query_brave_search_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:500330f51d0cc678ab9eb1427858d116df15c4c047c1aadb39c465ac343dade2","manifest_id":"manifest_search_query_brave_search_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.brave-search.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"brave-search-api","public_claim_boundary":"Rhumb can represent the search.query managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["brave-search-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_search_query_brave_search_api_official_api_v1","route_name":"search.query via brave-search-api official API","service_id":"brave-search","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_search_query_brave_search_api_official_api_v1"],"vendor":"brave-search"}'::jsonb, + '{"an_score_input_refs":["scores:service:brave-search"],"capability_id":"search.query","credential_modes":["rhumb_managed"],"evidence_expires_at":"2026-08-17T00:00:00Z","evidence_packet_digest":"sha256:d8976c9766905eaa2c08987205f056ce24520665874bb2dab48461a3e7561c2a","evidence_packet_id":"evidence_search_query_brave_search_api_official_api_2026_05_19","freshness_checked_at":"2026-05-19T17:05:18Z","manifest_digest":"sha256:e84b79fa73722472c846e4b7f352b9d444adae73edb91150a51aee8329d3524a","manifest_id":"manifest_search_query_brave_search_api_official_api_v1","manifest_version":"2026-05-19.1","owner":"Pedro","promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"brave-search-api","public_claim_boundary":"Rhumb can route read-only web search queries through a Rhumb-managed Brave Search API route when the caller has a valid governed Rhumb key and available credit.","resolve_separation_note":"This packet supports route trust. Resolve still decides per-user auth, budget, policy, and risk at request time.","review_status":"current","reviewer":"Pedro","reviews":[{"reviewed_at":"2026-05-19T17:05:18Z","reviewer":"Pedro","scope":"schema_and_source_fixture; live first-call receipt lands in PP-9","verdict":"current_for_core_fixture"}],"route_id":"route_search_query_brave_search_api_official_api_v1","runtime_witnesses":[{"capability_id":"search.query","kind":"planned_first_call_fixture","provider_id":"brave-search-api","status":"pending_PP-9_live_receipt"}],"service_id":"brave-search","side_effect_class":"read","source_risk":"verified_low","sources":[{"kind":"vendor_docs","observed_at":"2026-05-19T17:05:18Z","observed_title":"Documentation - Brave Search API","url":"https://api-dashboard.search.brave.com/app/documentation/web-search/get-started"},{"kind":"rhumb_contract","observed_at":"2026-05-19T17:05:18Z","url":"docs/INDEX-RESOLVE-VNEXT-PRD-ROADMAP-2026-05-19.md#core-searchquery-index-fixture"}],"substrate":"official_api"}'::jsonb, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_support_ticket_create_zendesk_api_official_api_v1', + 'manifest_support_ticket_create_zendesk_api_official_api_v1', + '2026-05-19.1', + 'sha256:e274aa5195cf74e98cf66eed3e683b80a3670045a17fe5503d3a68bed2cae7ce', + 'zendesk', + 'zendesk-api', + 'support.ticket.create', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'write', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the support.ticket.create managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"support.ticket.create","confirmation_policy":"dry_run_then_confirm","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["support_ticket"],"dry_run_supported":true,"evidence_refs":["evidence:route_support_ticket_create_zendesk_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:e274aa5195cf74e98cf66eed3e683b80a3670045a17fe5503d3a68bed2cae7ce","manifest_id":"manifest_support_ticket_create_zendesk_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.zendesk.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"zendesk-api","public_claim_boundary":"Rhumb can represent the support.ticket.create managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["zendesk-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_support_ticket_create_zendesk_api_official_api_v1","route_name":"support.ticket.create via zendesk-api official API","service_id":"zendesk","side_effect_class":"write","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_support_ticket_create_zendesk_api_official_api_v1"],"vendor":"zendesk"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_warehouse_query_snowflake_api_official_api_v1', + 'manifest_warehouse_query_snowflake_api_official_api_v1', + '2026-05-19.1', + 'sha256:bf89210fbb4d4286de046a17715fa98e2ac1643e552947e99dedd265ae393220', + 'snowflake', + 'snowflake-api', + 'warehouse.query', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the warehouse.query managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"warehouse.query","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["warehouse_query"],"dry_run_supported":false,"evidence_refs":["evidence:route_warehouse_query_snowflake_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:bf89210fbb4d4286de046a17715fa98e2ac1643e552947e99dedd265ae393220","manifest_id":"manifest_warehouse_query_snowflake_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.snowflake.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"snowflake-api","public_claim_boundary":"Rhumb can represent the warehouse.query managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["snowflake-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_warehouse_query_snowflake_api_official_api_v1","route_name":"warehouse.query via snowflake-api official API","service_id":"snowflake","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_warehouse_query_snowflake_api_official_api_v1"],"vendor":"snowflake"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_workflow_run_list_github_cli_v1', + 'manifest_github_workflow_list_official_cli_v1', + '2026-05-19.1', + 'sha256:4bd0fa611792130576998c6c7bb3de695d6554a212bb2f151798d60713b20b76', + 'github', + 'github-cli', + 'workflow_run.list', + 'official_cli', + 'vendor_official', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only route describing how a pinned official GitHub CLI could list workflow runs after non-native sandbox gates pass.', + '{"artifact_allowlist":["gh@pinned-digest-placeholder"],"auth_mode":"agent_vault","cache_behavior":"none","capability_id":"workflow_run.list","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["workflow_metadata"],"dry_run_supported":false,"evidence_refs":["evidence:route_workflow_run_list_github_cli_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:4bd0fa611792130576998c6c7bb3de695d6554a212bb2f151798d60713b20b76","manifest_id":"manifest_github_workflow_list_official_cli_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.github.com"],"owner":"Pedro","process_allowlist":["gh"],"promotion_state":"fixture_only","provenance_origin":"vendor_official","provider_id":"github-cli","public_claim_boundary":"Fixture-only route describing how a pinned official GitHub CLI could list workflow runs after non-native sandbox gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_workflow_run_list_github_cli_v1","route_name":"workflow_run.list via official GitHub CLI","sandbox_profile_class":"cli_readonly_network_github","service_id":"github","side_effect_class":"read","source_risk":"community_unverified","substrate":"official_cli","tests":["test_manifest_route_workflow_run_list_github_cli_v1"],"vendor":"GitHub"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ) +ON CONFLICT (route_id) DO UPDATE SET + manifest_id = EXCLUDED.manifest_id, + manifest_version = EXCLUDED.manifest_version, + manifest_digest = EXCLUDED.manifest_digest, + service_id = EXCLUDED.service_id, + provider_id = EXCLUDED.provider_id, + capability_id = EXCLUDED.capability_id, + substrate = EXCLUDED.substrate, + provenance_origin = EXCLUDED.provenance_origin, + source_risk = EXCLUDED.source_risk, + side_effect_class = EXCLUDED.side_effect_class, + promotion_state = EXCLUDED.promotion_state, + review_status = EXCLUDED.review_status, + evidence_packet_id = EXCLUDED.evidence_packet_id, + evidence_packet_digest = EXCLUDED.evidence_packet_digest, + evidence_expires_at = EXCLUDED.evidence_expires_at, + public_claim_boundary = EXCLUDED.public_claim_boundary, + manifest_json = EXCLUDED.manifest_json, + evidence_packet_json = EXCLUDED.evidence_packet_json, + owner = EXCLUDED.owner, + reviewer = EXCLUDED.reviewer, + expires_at = EXCLUDED.expires_at, + updated_at = NOW(); + +COMMIT; diff --git a/packages/api/migrations/0167_route_plan_state.sql b/packages/api/migrations/0167_route_plan_state.sql new file mode 100644 index 00000000..7bbe45bd --- /dev/null +++ b/packages/api/migrations/0167_route_plan_state.sql @@ -0,0 +1,21 @@ +-- PP-16: Durable route-plan nonce, replay, and revocation state + +CREATE TABLE IF NOT EXISTS route_plan_state ( + nonce_hash TEXT PRIMARY KEY, + route_plan_id_hash TEXT, + state TEXT NOT NULL DEFAULT 'claimed', + claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revocation_reason TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (state IN ('claimed', 'revoked')) +); + +CREATE INDEX IF NOT EXISTS idx_route_plan_state_expires + ON route_plan_state (expires_at); + +CREATE INDEX IF NOT EXISTS idx_route_plan_state_state + ON route_plan_state (state); + +ALTER TABLE route_plan_state ENABLE ROW LEVEL SECURITY; diff --git a/packages/api/migrations/0168_execution_receipts_route_facts.sql b/packages/api/migrations/0168_execution_receipts_route_facts.sql new file mode 100644 index 00000000..9107ac21 --- /dev/null +++ b/packages/api/migrations/0168_execution_receipts_route_facts.sql @@ -0,0 +1,23 @@ +-- PP-6: route-fact and compact-verification fields on execution receipts + +ALTER TABLE execution_receipts + ADD COLUMN IF NOT EXISTS route_id TEXT, + ADD COLUMN IF NOT EXISTS service_id TEXT, + ADD COLUMN IF NOT EXISTS substrate TEXT, + ADD COLUMN IF NOT EXISTS provenance_origin TEXT, + ADD COLUMN IF NOT EXISTS source_risk TEXT, + ADD COLUMN IF NOT EXISTS manifest_digest TEXT, + ADD COLUMN IF NOT EXISTS evidence_packet_digest TEXT, + ADD COLUMN IF NOT EXISTS route_plan_id_hash TEXT, + ADD COLUMN IF NOT EXISTS route_explanation_id TEXT, + ADD COLUMN IF NOT EXISTS stop_condition TEXT, + ADD COLUMN IF NOT EXISTS retryable BOOLEAN, + ADD COLUMN IF NOT EXISTS next_recommended_action TEXT; + +CREATE INDEX IF NOT EXISTS idx_receipts_route_id_created + ON execution_receipts (route_id, created_at DESC) + WHERE route_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_receipts_manifest_digest + ON execution_receipts (manifest_digest) + WHERE manifest_digest IS NOT NULL; diff --git a/packages/api/requirements-dev.txt b/packages/api/requirements-dev.txt index fb360434..709574e5 100644 --- a/packages/api/requirements-dev.txt +++ b/packages/api/requirements-dev.txt @@ -1,6 +1,7 @@ -r requirements.txt pytest==8.3.3 -pytest-httpx==0.36.0 +pytest-asyncio==0.24.0 +pytest-httpx==0.34.0 black==24.8.0 ruff==0.6.9 mypy==1.11.2 diff --git a/packages/api/routes/proxy.py b/packages/api/routes/proxy.py index 0359e9f1..0705454c 100644 --- a/packages/api/routes/proxy.py +++ b/packages/api/routes/proxy.py @@ -61,6 +61,7 @@ router = APIRouter(tags=["proxy"]) admin_router = APIRouter(tags=["schema-admin"]) + # Back-compat shim for existing imports in capability execution routes. def normalize_slug(slug: str) -> str: """Resolve a canonical/public slug to its proxy-layer equivalent.""" @@ -211,6 +212,69 @@ def normalize_slug(slug: str) -> str: "schema_alert_mode": "breaking_only", "timeout_threshold_ms": 30000, # Audio processing can be slow }, + "openai": { + "domain": "api.openai.com", + "auth_type": "api_key", + "rate_limit": "500/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": 30000, + }, + "groq": { + "domain": "api.groq.com", + "auth_type": "api_key", + "rate_limit": "500/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": 30000, + }, + "cohere": { + "domain": "api.cohere.com", + "auth_type": "api_key", + "rate_limit": "500/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": 30000, + }, + "elevenlabs": { + "domain": "api.elevenlabs.io", + "auth_type": "api_key", + "rate_limit": "120/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": 30000, + }, + "resend": { + "domain": "api.resend.com", + "auth_type": "api_key", + "rate_limit": "120/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": DEFAULT_TIMEOUT_THRESHOLD_MS, + }, + "postmark": { + "domain": "api.postmarkapp.com", + "auth_type": "api_key", + "rate_limit": "500/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": DEFAULT_TIMEOUT_THRESHOLD_MS, + }, + "google-maps": { + "domain": "maps.googleapis.com", + "auth_type": "api_key", + "rate_limit": "300/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": DEFAULT_TIMEOUT_THRESHOLD_MS, + }, + "google-places": { + "domain": "maps.googleapis.com", + "auth_type": "api_key", + "rate_limit": "300/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": DEFAULT_TIMEOUT_THRESHOLD_MS, + }, + "perplexity": { + "domain": "api.perplexity.ai", + "auth_type": "api_key", + "rate_limit": "120/min", + "schema_alert_mode": "breaking_only", + "timeout_threshold_ms": 30000, + }, } @@ -399,10 +463,7 @@ def _public_proxy_service_name(service: str | None) -> str | None: def _valid_public_proxy_service_names() -> list[str]: """Return public proxy service ids accepted by proxy/admin path surfaces.""" - public_names = { - _public_proxy_service_name(service) or service - for service in SERVICE_REGISTRY - } + public_names = {_public_proxy_service_name(service) or service for service in SERVICE_REGISTRY} return sorted(name for name in public_names if name) @@ -557,9 +618,7 @@ def _validated_proxy_request_service(service: str) -> str: ) -_VALID_PROXY_METHODS = frozenset( - {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} -) +_VALID_PROXY_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}) def _validated_proxy_request_method(method: str) -> str: @@ -626,15 +685,9 @@ def _validated_proxy_request_payload(payload: Any) -> ProxyRequest: detail="Provide a JSON object with service, method, and path fields.", ) - service = _validated_proxy_request_service( - _required_proxy_string_field(payload, "service") - ) - method = _validated_proxy_request_method( - _required_proxy_string_field(payload, "method") - ) - path = _validated_proxy_request_path( - _required_proxy_string_field(payload, "path") - ) + service = _validated_proxy_request_service(_required_proxy_string_field(payload, "service")) + method = _validated_proxy_request_method(_required_proxy_string_field(payload, "method")) + path = _validated_proxy_request_path(_required_proxy_string_field(payload, "path")) body = _validated_proxy_mapping_field(payload, "body") params = _validated_proxy_mapping_field(payload, "params") headers = _validated_proxy_mapping_field(payload, "headers") @@ -857,7 +910,9 @@ async def proxy_request( try: request = _validated_proxy_request_payload(body) - public_request_service = _public_proxy_service_name(request.service) or str(request.service).strip() + public_request_service = ( + _public_proxy_service_name(request.service) or str(request.service).strip() + ) normalized_rhumb_key = str(x_rhumb_key or "").strip() if not normalized_rhumb_key: @@ -973,9 +1028,7 @@ async def proxy_request( detail=f"Credential unavailable for '{public_request_service}'", ) from e finally: - timings["credential_inject_ms"] = ( - time.perf_counter() - credential_start - ) * 1000 + timings["credential_inject_ms"] = (time.perf_counter() - credential_start) * 1000 pool_start = time.perf_counter() client = await pool.acquire( @@ -1401,14 +1454,16 @@ async def list_schema_alerts( "service": _public_proxy_service_name(alert.service), "endpoint": alert.endpoint, "severity": alert.severity, - "change_detail": { - **alert.change_detail, - "service": _public_proxy_service_name( - alert.change_detail.get("service") - ), - } - if isinstance(alert.change_detail, dict) - else alert.change_detail, + "change_detail": ( + { + **alert.change_detail, + "service": _public_proxy_service_name( + alert.change_detail.get("service") + ), + } + if isinstance(alert.change_detail, dict) + else alert.change_detail + ), "alert_sent_at": ( alert.alert_sent_at.isoformat() if alert.alert_sent_at else None ), diff --git a/packages/api/routes/receipts_v2.py b/packages/api/routes/receipts_v2.py index 9435b134..0b0714d3 100644 --- a/packages/api/routes/receipts_v2.py +++ b/packages/api/routes/receipts_v2.py @@ -4,6 +4,7 @@ GET /v2/receipts/{receipt_id}/explanation — Get routing explanation for receipt GET /v2/receipts — Query receipts with filters GET /v2/receipts/chain/verify — Verify chain integrity +POST /v2/receipts/{receipt_id}/verify — Verify one receipt """ from __future__ import annotations @@ -25,6 +26,117 @@ _VALID_RECEIPT_STATUSES = frozenset({"success", "failure", "timeout", "rejected"}) +def _compact_receipt_from_row(receipt: dict[str, Any]) -> dict[str, Any]: + """Build PP-6 compact receipt facts from the full stored receipt row.""" + + status = str(receipt.get("status") or "unknown") + retryable = bool(receipt.get("retryable")) + return { + "mode": "compact", + "status": status, + "typed_error": receipt.get("error_code"), + "stop_condition": receipt.get("stop_condition"), + "retryable": retryable, + "receipt_id": receipt.get("receipt_id"), + "route_explanation_id": receipt.get("route_explanation_id") + or receipt.get("explanation_id"), + "route": { + "capability_id": receipt.get("capability_id"), + "service_id": receipt.get("service_id"), + "provider_id": receipt.get("provider_id"), + "route_id": receipt.get("route_id"), + "substrate": receipt.get("substrate"), + "provenance_origin": receipt.get("provenance_origin"), + "source_risk": receipt.get("source_risk"), + "manifest_digest": receipt.get("manifest_digest"), + "evidence_packet_digest": receipt.get("evidence_packet_digest"), + }, + "route_plan": { + "route_plan_id_hash": receipt.get("route_plan_id_hash"), + }, + "budget": { + "provider_cost_usd": receipt.get("provider_cost_usd"), + "total_cost_usd": receipt.get("total_cost_usd"), + "credits_deducted": receipt.get("credits_deducted"), + }, + "next_recommended_action": receipt.get("next_recommended_action") + or ( + "fetch_or_verify_receipt" if status == "success" else "inspect_error_and_resolve_again" + ), + } + + +def _verify_single_receipt(receipt: dict[str, Any], explanation_available: bool) -> dict[str, Any]: + """Return PP-6 single-receipt verifier output. + + This verifies what the stored row can prove locally: issuer/shape, + chain-material presence, request/output hash presence, route-plan binding + by token hash, redaction posture, receipt status, and explanation lookup. + It intentionally does not claim provider-side truth beyond returned payload + hashing or global chain inclusion outside this receipt row. + """ + + receipt_hash = receipt.get("receipt_hash") + chain_sequence = receipt.get("chain_sequence") + request_hash = receipt.get("request_hash") + response_hash = receipt.get("response_hash") + route_plan_hash = receipt.get("route_plan_id_hash") + status = receipt.get("status") + status_ok = status in _VALID_RECEIPT_STATUSES + checks = { + "issuer_format_valid": bool(str(receipt.get("receipt_id") or "").startswith("rcpt_")), + "receipt_hash_present": bool(receipt_hash), + "chain_material_present": bool(receipt_hash and chain_sequence is not None), + "request_hash_present": bool(request_hash), + "response_hash_present": bool(response_hash), + "route_plan_hash_present": bool(route_plan_hash), + "route_facts_present": bool(receipt.get("route_id") and receipt.get("manifest_digest")), + "redaction_compact_mode": True, + "status_known": status_ok, + "explanation_available": explanation_available, + } + return { + "receipt_id": receipt.get("receipt_id"), + "verifier_status": "verified" if all(checks.values()) else "partial", + "checks": checks, + "issuer": { + "format_valid": checks["issuer_format_valid"], + "signature_result": "not_signed_in_compact_mode", + }, + "chain": { + "inclusion_status": ( + "material_present" if checks["chain_material_present"] else "missing_chain_material" + ), + "chain_sequence": chain_sequence, + "receipt_hash": receipt_hash, + "previous_receipt_hash": receipt.get("previous_receipt_hash"), + }, + "hashes": { + "input_hash_status": "present" if request_hash else "missing", + "output_hash_status": "present" if response_hash else "missing", + "request_hash": request_hash, + "response_hash": response_hash, + }, + "redaction": { + "status": "compact_hashes_only", + "raw_payload_returned": False, + }, + "receipt_status": status, + "explanation_available": explanation_available, + "compact_receipt": _compact_receipt_from_row(receipt), + "what_is_proven": [ + "receipt row exists and carries chain material", + "stored input/output hashes are present when corresponding checks pass", + "route facts and route-plan token hash are present when corresponding checks pass", + ], + "what_is_not_proven": [ + "provider-side content truth beyond returned payload hashing", + "global chain continuity; use GET /v2/receipts/chain/verify for range checks", + "raw payload recovery from compact receipt metadata", + ], + } + + def _validated_receipt_id(receipt_id: str) -> str: normalized = str(receipt_id or "").strip() if normalized: @@ -94,7 +206,9 @@ def _parsed_int_filter(value: str | int | None, *, field_name: str) -> int: return parsed -def _validated_int_range(value: str | int | None, *, field_name: str, minimum: int, maximum: int) -> int: +def _validated_int_range( + value: str | int | None, *, field_name: str, minimum: int, maximum: int +) -> int: parsed = _parsed_int_filter(value, field_name=field_name) if minimum <= parsed <= maximum: return parsed @@ -226,13 +340,43 @@ async def get_receipt_explanation(receipt_id: str) -> dict[str, Any]: return {"data": explanation.to_dict(), "error": None} +@router.post("/receipts/{receipt_id}/verify") +async def verify_receipt(receipt_id: str) -> dict[str, Any]: + """Verify one receipt and return compact/full-verifier status fields.""" + + receipt_id = _validated_receipt_id(receipt_id) + service = get_receipt_service() + receipt = await service.get_receipt(receipt_id) + if receipt is None: + raise RhumbError( + "RECEIPT_NOT_FOUND", + message=f"No receipt found with id '{receipt_id}'", + detail="Check the receipt_id from an execution response, or query receipts at GET /v2/receipts", + ) + + explanation_available = False + exp_id_from_receipt = receipt.get("route_explanation_id") or receipt.get("explanation_id") + if exp_id_from_receipt: + explanation_available = get_explanation(str(exp_id_from_receipt)) is not None + if not explanation_available: + explanation_available = ( + await get_persisted_explanation(str(exp_id_from_receipt)) is not None + ) + if not explanation_available: + explanation_available = await get_persisted_explanation_by_receipt(receipt_id) is not None + + return {"data": _verify_single_receipt(receipt, explanation_available), "error": None} + + @router.get("/receipts") async def query_receipts( agent_id: Optional[str] = Query(default=None, description="Filter by agent ID"), org_id: Optional[str] = Query(default=None, description="Filter by organization ID"), capability_id: Optional[str] = Query(default=None, description="Filter by capability ID"), provider_id: Optional[str] = Query(default=None, description="Filter by provider ID"), - status: Optional[str] = Query(default=None, description="Filter by status (success, failure, timeout, rejected)"), + status: Optional[str] = Query( + default=None, description="Filter by status (success, failure, timeout, rejected)" + ), limit: str = Query(default="50", description="Max results"), offset: str = Query(default="0", description="Pagination offset"), ) -> dict[str, Any]: @@ -242,8 +386,8 @@ async def query_receipts( capability_id = _validated_optional_filter(capability_id, field_name="capability_id") provider_id = _validated_optional_filter(provider_id, field_name="provider_id") status = _validated_receipt_status(status) - limit = _validated_int_range(limit, field_name="limit", minimum=1, maximum=200) - offset = _validated_non_negative_int(offset, field_name="offset") + limit_value = _validated_int_range(limit, field_name="limit", minimum=1, maximum=200) + offset_value = _validated_non_negative_int(offset, field_name="offset") service = get_receipt_service() receipts = await service.query_receipts( agent_id=agent_id, @@ -251,15 +395,15 @@ async def query_receipts( capability_id=capability_id, provider_id=provider_id, status=status, - limit=limit, - offset=offset, + limit=limit_value, + offset=offset_value, ) return { "data": { "receipts": receipts, "count": len(receipts), - "limit": limit, - "offset": offset, + "limit": limit_value, + "offset": offset_value, }, "error": None, } @@ -267,7 +411,9 @@ async def query_receipts( @router.get("/receipts/chain/verify") async def verify_chain( - start_sequence: Optional[str] = Query(default=None, description="Start chain sequence (inclusive)"), + start_sequence: Optional[str] = Query( + default=None, description="Start chain sequence (inclusive)" + ), end_sequence: Optional[str] = Query(default=None, description="End chain sequence (inclusive)"), limit: str = Query(default="100", description="Max receipts to check"), ) -> dict[str, Any]: @@ -277,14 +423,16 @@ async def verify_chain( preceding receipt's receipt_hash. Returns verification results including any broken chain links detected. """ - start_sequence = _validated_optional_positive_int(start_sequence, field_name="start_sequence") - end_sequence = _validated_optional_positive_int(end_sequence, field_name="end_sequence") - _validate_chain_range(start_sequence, end_sequence) - limit = _validated_int_range(limit, field_name="limit", minimum=1, maximum=1000) + start_sequence_value = _validated_optional_positive_int( + start_sequence, field_name="start_sequence" + ) + end_sequence_value = _validated_optional_positive_int(end_sequence, field_name="end_sequence") + _validate_chain_range(start_sequence_value, end_sequence_value) + limit_value = _validated_int_range(limit, field_name="limit", minimum=1, maximum=1000) service = get_receipt_service() result = await service.verify_chain( - start_sequence=start_sequence, - end_sequence=end_sequence, - limit=limit, + start_sequence=start_sequence_value, + end_sequence=end_sequence_value, + limit=limit_value, ) return {"data": result, "error": None} diff --git a/packages/api/routes/resolve_v2.py b/packages/api/routes/resolve_v2.py index ee672286..5db02dbf 100644 --- a/packages/api/routes/resolve_v2.py +++ b/packages/api/routes/resolve_v2.py @@ -21,6 +21,7 @@ from __future__ import annotations +import json import logging import re import time @@ -28,8 +29,6 @@ from typing import Any import httpx - -logger = logging.getLogger(__name__) from fastapi import APIRouter, Body, Header, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -37,6 +36,7 @@ from routes import capabilities as v1_capabilities from routes import capability_execute as v1_execute from services.budget_enforcer import BudgetEnforcer, BudgetStatus +from services.chain_integrity import get_signing_key from services.error_envelope import RhumbError from services.policy_engine import PolicyProviderDecision, get_policy_engine from services.receipt_service import ( @@ -48,13 +48,34 @@ ) from services.provider_attribution import build_attribution from services.resolve_policy_store import StoredResolvePolicy, get_resolve_policy_store +from services.route_plan_enforcement import ( + canonical_json_sha256, + issue_route_plan, + validate_route_plan, +) +from services.route_plan_state import ( + RoutePlanStateUnavailable, + init_route_plan_state_store, +) +from services.kill_switches import init_kill_switch_registry from services.route_explanation import build_explanation, persist_explanation, store_explanation from services.service_slugs import ( CANONICAL_TO_PROXY, canonicalize_service_slug, public_service_slug_candidates, ) +from services.index_manifest_store import ( + get_durable_index_manifest_store, + with_recommendation_policy, +) +from schemas.resolve_boundary_contract import boundary_contract_payload +from schemas.resolve_route_candidate import ( + annotate_resolve_body_with_route_candidates, + route_candidates_from_resolve_data, +) +from schemas.route_taxonomy import PROVENANCE_ORIGINS, SOURCE_RISKS, SUBSTRATES +logger = logging.getLogger(__name__) router = APIRouter() _COMPAT_VERSION = "2026-03-30" @@ -89,6 +110,15 @@ "X-Rhumb-Wallet", "X-Rhumb-Rate-Remaining", ] +_ROUTE_PLAN_TTL_SECONDS = 300 +_ROUTE_PLAN_ISSUABLE_SAFETY_STATES = frozenset({"executable", "dry_run_only"}) +_ROUTE_PLAN_CLAIM_BOUNDARY = ( + "PP-16 bridge: v2 Resolve/estimate issue opaque signed route plans for " + "executable/dry-run candidates; v2 execute binds supplied or same-planner " + "plans to planner facts and fail-closes on signature, expiry, principal, " + "route/provider, credential, input, manifest, policy, budget, scope, durable " + "replay/revocation, kill-switch, or runtime echo mismatch." +) _V2_NAVIGATION_URL_KEYS = { "search_url", "resolve_url", @@ -206,6 +236,14 @@ class V2CapabilityExecuteRequest(BaseModel): default=None, description="Optional per-call idempotency key.", ) + route_plan_id: str | None = Field( + default=None, + description=( + "Optional opaque signed Resolve route plan id. When supplied, " + "v2 execute fail-closes unless it matches the current internal " + "planner route for this request." + ), + ) interface: str = Field( default="rest", description="Calling surface label for analytics and compat reporting.", @@ -397,6 +435,581 @@ def _extract_error_fields(body: Any) -> tuple[str | None, str | None]: return None, None +def _route_plan_principal_facts(raw_request: Request, agent: Any | None) -> dict[str, str]: + if agent is not None: + agent_id = str(agent.agent_id) + return { + "org_id": str(agent.organization_id), + "agent_id": agent_id, + "principal_id": agent_id, + "auth_rail": "governed_api_key", + } + + principal_id = _normalized_agent_id_header(raw_request) or "x402_anonymous" + return { + "org_id": "anonymous", + "agent_id": principal_id, + "principal_id": principal_id, + "auth_rail": ( + "x402_per_call" if _has_inline_x402_payment(raw_request) else "anonymous_compat" + ), + } + + +def _route_plan_digest_or_sentinel(value: Any, *, sentinel: str) -> str: + if value is None or value == {} or value == []: + return canonical_json_sha256({"sentinel": sentinel}) + return canonical_json_sha256(value) + + +def _planned_parameters_from_query(value: str | None) -> dict[str, Any]: + """Parse optional route-plan parameter binding from read endpoints. + + Resolve/estimate are GET endpoints, so the execute body is not otherwise + present when a route plan is issued. This opt-in query parameter lets a + caller bind a signed plan to the exact later execute parameters without + weakening the fail-closed empty-parameters default. + """ + + if value is None or value == "": + return {} + try: + loaded = json.loads(value) + except json.JSONDecodeError as exc: + raise RhumbError( + "INVALID_PARAMETERS", + message="Invalid planned_parameters query value.", + detail="Provide planned_parameters as a JSON object so the route plan can bind the later execute body.", + ) from exc + if not isinstance(loaded, dict): + raise RhumbError( + "INVALID_PARAMETERS", + message="Invalid planned_parameters query value.", + detail="planned_parameters must be a JSON object.", + ) + return loaded + + +def _selected_route_candidate( + *, + capability_id: str, + estimate_data: dict[str, Any], + selected_provider_public: str | None, +) -> dict[str, Any]: + candidate_source = dict(estimate_data) + candidate_source.setdefault("capability_id", capability_id) + if selected_provider_public: + candidate_source["provider"] = selected_provider_public + candidates = route_candidates_from_resolve_data(candidate_source) + for candidate in candidates: + candidate_provider = _public_provider_slug(candidate.get("provider_id")) + if selected_provider_public and candidate_provider == selected_provider_public: + return candidate + return candidates[0] if candidates else {} + + +def _route_plan_boundary_facts( + *, + capability_id: str, + estimate_data: dict[str, Any], + selected_provider_public: str | None, + credential_mode: str, + parameters: dict[str, Any], + raw_request: Request, + agent: Any | None, + policy_summary: dict[str, Any] | None, + policy_source: dict[str, Any], + budget_summary: dict[str, Any] | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + route_candidate = _selected_route_candidate( + capability_id=capability_id, + estimate_data=estimate_data, + selected_provider_public=selected_provider_public, + ) + return _route_plan_boundary_facts_from_candidate( + capability_id=capability_id, + route_candidate=route_candidate, + selected_provider_public=selected_provider_public, + credential_mode=credential_mode, + parameters=parameters, + raw_request=raw_request, + agent=agent, + policy_summary=policy_summary, + policy_source=policy_source, + budget_summary=budget_summary, + ) + + +def _route_plan_boundary_facts_from_candidate( + *, + capability_id: str, + route_candidate: dict[str, Any], + selected_provider_public: str | None = None, + credential_mode: str | None = None, + parameters: dict[str, Any] | None = None, + raw_request: Request, + agent: Any | None, + policy_summary: dict[str, Any] | None, + policy_source: dict[str, Any], + budget_summary: dict[str, Any] | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Build signed-route-plan payload and expected Runtime checks for one candidate.""" + + provider_id = ( + _public_provider_slug(selected_provider_public) + or _public_provider_slug(route_candidate.get("provider_id")) + or selected_provider_public + or str(route_candidate.get("provider_id") or "unknown") + ) + service_id = _public_provider_slug(route_candidate.get("service_id")) or str( + route_candidate.get("service_id") or provider_id + ) + required_scopes = route_candidate.get("required_scopes") + if not isinstance(required_scopes, list): + required_scopes = [] + required_scopes = [str(scope) for scope in required_scopes] + manifest_digest = route_candidate.get("manifest_digest") or canonical_json_sha256( + { + "source": "v1_catalog_compat_until_index_manifest", + "capability_id": capability_id, + "provider_id": provider_id, + } + ) + policy_snapshot = { + "source": policy_source, + "summary": policy_summary or {"active": False}, + } + budget_snapshot = budget_summary or {"active": False} + base = { + **_route_plan_principal_facts(raw_request, agent), + "capability_id": capability_id, + "service_id": service_id, + "provider_id": provider_id, + "route_id": str(route_candidate.get("route_id") or "route_unknown"), + "credential_mode": _canonicalize_credential_mode( + route_candidate.get("credential_mode") or credential_mode + ), + "credential_handle_id": None, + "required_scopes": required_scopes, + "input_hash": canonical_json_sha256(parameters or {}), + "manifest_digest": str(manifest_digest), + "evidence_packet_digest": route_candidate.get("evidence_packet_digest"), + "policy_snapshot_digest": _route_plan_digest_or_sentinel( + policy_snapshot, + sentinel="no_policy_snapshot", + ), + "budget_snapshot_digest": _route_plan_digest_or_sentinel( + budget_snapshot, + sentinel="no_budget_snapshot", + ), + "sandbox_profile_id": route_candidate.get("sandbox_profile_id"), + "artifact_hashes": [], + } + expected = { + **base, + "granted_scopes": required_scopes, + "kill_switch_allows_execution": True, + } + return base, expected + + +def _issue_internal_route_plan(payload: dict[str, Any]) -> tuple[str, int]: + issued_at = int(time.time()) + route_plan_id = issue_route_plan( + payload, + get_signing_key(), + now=issued_at, + ttl_seconds=_ROUTE_PLAN_TTL_SECONDS, + ) + return route_plan_id, issued_at + _ROUTE_PLAN_TTL_SECONDS + + +async def _route_plan_issue_context( + raw_request: Request, +) -> tuple[Any | None, dict[str, Any] | None, dict[str, Any], dict[str, Any] | None]: + """Resolve read-only context needed to bind route-plan tokens in Resolve responses.""" + + agent = None + account_policy = None + if _normalized_governed_key(raw_request): + agent = await _resolve_policy_agent(raw_request) + account_policy = await get_resolve_policy_store().get_policy(agent.organization_id) + + effective_policy, policy_source = _merge_effective_policy(account_policy, None) + return ( + agent, + _public_policy_summary(_policy_summary(effective_policy)), + policy_source, + None, + ) + + +def _issue_route_plans_for_response_candidates( + body: dict[str, Any], + *, + capability_id: str, + credential_mode: str | None, + parameters: dict[str, Any] | None, + raw_request: Request, + agent: Any | None, + policy_summary: dict[str, Any] | None, + policy_source: dict[str, Any], + budget_summary: dict[str, Any] | None, +) -> dict[str, Any]: + data = body.get("data") + if not isinstance(data, dict): + return body + + candidates = data.get("route_candidates") + if not isinstance(candidates, list): + return body + + annotated = dict(body) + annotated_data = dict(data) + issued_candidates: list[Any] = [] + for candidate in candidates: + if not isinstance(candidate, dict): + issued_candidates.append(candidate) + continue + + candidate_with_plan = dict(candidate) + if candidate_with_plan.get("safety_state") not in _ROUTE_PLAN_ISSUABLE_SAFETY_STATES: + candidate_with_plan["route_plan_id"] = None + candidate_with_plan["route_plan_expires_at"] = None + candidate_with_plan["route_plan_input_hash"] = None + issued_candidates.append(candidate_with_plan) + continue + + route_plan_payload, _ = _route_plan_boundary_facts_from_candidate( + capability_id=capability_id, + route_candidate=candidate_with_plan, + credential_mode=credential_mode, + parameters=parameters, + raw_request=raw_request, + agent=agent, + policy_summary=policy_summary, + policy_source=policy_source, + budget_summary=budget_summary, + ) + route_plan_id, expires_at = _issue_internal_route_plan(route_plan_payload) + candidate_with_plan["route_plan_id"] = route_plan_id + candidate_with_plan["route_plan_expires_at"] = expires_at + candidate_with_plan["route_plan_input_hash"] = route_plan_payload.get("input_hash") + issued_candidates.append(candidate_with_plan) + + annotated_data["route_candidates"] = issued_candidates + annotated["data"] = annotated_data + return annotated + + +def _route_plan_id_hash(route_plan_id: str | None) -> str | None: + if not route_plan_id: + return None + return canonical_json_sha256({"route_plan_id": route_plan_id}) + + +def _route_plan_failed_checks(checks: dict[str, Any] | None) -> list[str]: + return sorted(name for name, passed in (checks or {}).items() if passed is not True) + + +def _raise_route_plan_rejected( + validation: dict[str, Any], + *, + route_plan_id: str | None, + message: str, + detail: str, + extra: dict[str, Any] | None = None, +) -> None: + checks = validation.get("checks") if isinstance(validation, dict) else {} + stop_condition = validation.get("stop_condition") if isinstance(validation, dict) else None + enforcement = { + "source": "PP-16", + "status": "fail_closed", + "stop_condition": stop_condition or "route_plan_mismatch", + "route_plan_id_hash": _route_plan_id_hash(route_plan_id), + "checks": checks, + "failed_checks": _route_plan_failed_checks(checks), + "claim_boundary": _ROUTE_PLAN_CLAIM_BOUNDARY, + } + if extra: + enforcement.update(extra) + raise RhumbError( + "ROUTE_PLAN_REJECTED", + message=message, + detail=detail, + extra={ + "stop_condition": enforcement["stop_condition"], + "route_plan_enforcement": enforcement, + }, + ) + + +async def _apply_route_plan_kill_switch_state(expected: dict[str, Any]) -> dict[str, Any]: + """Fold durable kill-switch state into the expected route-plan facts.""" + + checked = dict(expected) + try: + registry = await init_kill_switch_registry() + blocked, reason = registry.is_blocked( + agent_id=str(checked.get("agent_id") or ""), + provider_slug=str(checked.get("provider_id") or ""), + operation_class="non_financial", + require_authoritative=False, + ) + except Exception as exc: + logger.warning("route_plan_kill_switch_check_failed", exc_info=True) + blocked = True + reason = f"Kill-switch state unavailable: {exc}" + + if blocked: + checked["kill_switch_allows_execution"] = False + checked["execution_disabled"] = True + checked["kill_switch_reason"] = reason + else: + checked["kill_switch_allows_execution"] = True + return checked + + +async def _claim_route_plan_state_or_reject( + *, + validation: dict[str, Any], + route_plan_id: str | None, + source: str, +) -> None: + try: + store = await init_route_plan_state_store() + claim = await store.check_and_claim( + nonce=validation.get("nonce"), + route_plan_id_hash=_route_plan_id_hash(route_plan_id), + expires_at=validation.get("expires_at"), + allow_fallback=True, + ) + except RoutePlanStateUnavailable as exc: + checks = dict(validation.get("checks", {})) + checks["durable_replay_state_available"] = False + _raise_route_plan_rejected( + {"allowed": False, "stop_condition": "route_plan_mismatch", "checks": checks}, + route_plan_id=route_plan_id, + message="Resolve route plan durable state was unavailable.", + detail="Execution was blocked because route-plan replay/revocation state could not be checked.", + extra={"mode": source, "state_backend": "unavailable", "state_error": str(exc)}, + ) + except Exception as exc: + checks = dict(validation.get("checks", {})) + checks["durable_replay_state_available"] = False + _raise_route_plan_rejected( + {"allowed": False, "stop_condition": "route_plan_mismatch", "checks": checks}, + route_plan_id=route_plan_id, + message="Resolve route plan durable state was unavailable.", + detail="Execution was blocked because route-plan replay/revocation state could not be checked.", + extra={"mode": source, "state_backend": "unavailable", "state_error": str(exc)}, + ) + + checks = dict(validation.get("checks", {})) + checks["durable_replay_state_available"] = claim.state_backend in { + "database", + "memory_fallback", + } + checks["durable_replay_not_seen"] = claim.stop_condition != "route_plan_replay" + checks["durable_not_revoked"] = claim.stop_condition != "route_plan_revoked" + validation["checks"] = checks + validation["state_backend"] = claim.state_backend + validation["nonce_hash"] = claim.nonce_hash + if claim.allowed: + return + + _raise_route_plan_rejected( + {"allowed": False, "stop_condition": claim.stop_condition, "checks": checks}, + route_plan_id=route_plan_id, + message="Resolve route plan was rejected before execution.", + detail=claim.detail or "Route-plan nonce state rejected execution.", + extra={"mode": source, "state_backend": claim.state_backend}, + ) + + +async def _validate_route_plan_or_reject( + route_plan_id: str | None, + expected: dict[str, Any], + *, + source: str, +) -> dict[str, Any]: + expected = await _apply_route_plan_kill_switch_state(expected) + validation = validate_route_plan(route_plan_id, expected, get_signing_key()) + if validation.get("allowed") is True: + await _claim_route_plan_state_or_reject( + validation=validation, + route_plan_id=route_plan_id, + source=source, + ) + return validation + _raise_route_plan_rejected( + validation, + route_plan_id=route_plan_id, + message="Resolve route plan was rejected before execution.", + detail=( + "Retry through Resolve/estimate for a fresh route plan; execution will not " + "silently switch route, provider, credential mode, principal, policy, budget, or input." + ), + extra={"mode": source}, + ) + raise AssertionError("unreachable: route-plan rejection should raise") + + +def _route_plan_enforcement_meta( + *, + validation: dict[str, Any], + mode: str, + route_plan_id: str, + expected: dict[str, Any], +) -> dict[str, Any]: + return { + "source": "PP-16", + "mode": mode, + "status": "enforced", + "stop_condition": None, + "route_plan_id_hash": _route_plan_id_hash(route_plan_id), + "checks": validation.get("checks", {}), + "failed_checks": [], + "state_backend": validation.get("state_backend"), + "nonce_hash": validation.get("nonce_hash"), + "binding": { + "capability_id": expected.get("capability_id"), + "service_id": expected.get("service_id"), + "provider_id": expected.get("provider_id"), + "route_id": expected.get("route_id"), + "credential_mode": expected.get("credential_mode"), + "input_hash": expected.get("input_hash"), + "manifest_digest": expected.get("manifest_digest"), + "evidence_packet_digest": expected.get("evidence_packet_digest"), + }, + "claim_boundary": _ROUTE_PLAN_CLAIM_BOUNDARY, + } + + +def _compact_next_action(*, status: str, stop_condition: str | None, retryable: bool) -> str: + if status == "success": + return "fetch_or_verify_receipt" + if retryable: + return "retry_after_following_error_guidance" + if stop_condition: + return f"resolve_stop_condition:{stop_condition}" + return "inspect_error_and_resolve_again" + + +def _compact_execution_receipt_meta( + *, + status: str, + receipt_id: str | None, + route_plan_validation: dict[str, Any], + route_plan_mode: str, + route_plan_id: str, + route_plan_expected: dict[str, Any], + route_candidate: dict[str, Any], + route_policy_summary: dict[str, Any] | None, + route_budget_summary: dict[str, Any] | None, + explanation_id: str | None, + estimated_cost: Any, + error_code: str | None = None, + error_message: str | None = None, +) -> dict[str, Any]: + stop_condition = route_plan_validation.get("stop_condition") or route_candidate.get( + "stop_condition" + ) + retryable = status != "success" and stop_condition in { + "payment_required", + "budget_exceeded", + "missing_credentials", + "route_plan_expired", + "kill_switch_state_unavailable", + } + return { + "mode": "compact", + "status": status, + "typed_error": error_code, + "stop_condition": stop_condition, + "retryable": bool(retryable), + "receipt_id": receipt_id, + "route_explanation_id": explanation_id, + "route": { + "capability_id": route_plan_expected.get("capability_id"), + "service_id": route_plan_expected.get("service_id"), + "provider_id": route_plan_expected.get("provider_id"), + "route_id": route_plan_expected.get("route_id"), + "substrate": route_candidate.get("substrate"), + "provenance_origin": route_candidate.get("provenance_origin"), + "source_risk": route_candidate.get("source_risk"), + "manifest_digest": route_plan_expected.get("manifest_digest"), + "evidence_packet_digest": route_plan_expected.get("evidence_packet_digest"), + }, + "route_plan": _route_plan_enforcement_meta( + validation=route_plan_validation, + mode=route_plan_mode, + route_plan_id=route_plan_id, + expected=route_plan_expected, + ), + "budget": { + "estimated_cost_usd": estimated_cost, + "summary": route_budget_summary, + }, + "policy": route_policy_summary, + "next_recommended_action": _compact_next_action( + status=status, + stop_condition=stop_condition, + retryable=bool(retryable), + ), + "what_is_proven": [ + "receipt row carries request/response hashes and append-only chain hash", + "route metadata is copied from Resolve/Index facts selected before runtime", + "route_plan_id_hash binds the receipt to the enforced signed route plan without exposing the raw token", + ], + "what_is_not_proven": [ + "provider-side content truth beyond returned payload hashing", + "external API availability after receipt creation", + "redacted raw payload recovery from compact receipt metadata", + ], + **({"error_message": error_message} if error_message else {}), + } + + +def _enforce_runtime_route_plan_echo( + *, + execution_data: dict[str, Any], + effective_credential_mode: str, + expected: dict[str, Any], + validation: dict[str, Any], + route_plan_id: str, +) -> None: + expected_provider = _public_provider_slug(expected.get("provider_id")) or expected.get( + "provider_id" + ) + actual_provider = _public_provider_slug( + execution_data.get("provider_used") + ) or execution_data.get("provider_used") + checks = dict(validation.get("checks", {})) + checks["runtime_provider_matches_plan"] = bool( + expected_provider and actual_provider and actual_provider == expected_provider + ) + checks["runtime_credential_mode_matches_plan"] = _canonicalize_credential_mode( + effective_credential_mode + ) == _canonicalize_credential_mode(expected.get("credential_mode")) + if checks["runtime_provider_matches_plan"] and checks["runtime_credential_mode_matches_plan"]: + validation["checks"] = checks + return + _raise_route_plan_rejected( + {"allowed": False, "stop_condition": "route_plan_mismatch", "checks": checks}, + route_plan_id=route_plan_id, + message="Runtime result diverged from the Resolve route plan.", + detail="Execution result was withheld because provider or credential mode did not echo the planned route.", + extra={ + "mode": "runtime_echo", + "expected_provider": expected_provider, + "actual_provider": actual_provider, + "expected_credential_mode": expected.get("credential_mode"), + "actual_credential_mode": effective_credential_mode, + }, + ) + + def _forward_request_headers(raw_request: Request) -> dict[str, str]: headers: dict[str, str] = {} for name in _FORWARD_HEADER_NAMES: @@ -489,7 +1102,7 @@ def _normalized_idempotency_key(value: str | None) -> str | None: return normalized or None -async def _resolve_policy_agent(raw_request: Request): +async def _resolve_policy_agent(raw_request: Request) -> Any: x_rhumb_key = _normalized_governed_key(raw_request) if not x_rhumb_key: raise RhumbError( @@ -610,8 +1223,7 @@ def _policy_summary(policy: V2CapabilityPolicy | None) -> dict[str, Any] | None: def _has_inline_x402_payment(raw_request: Request) -> bool: return bool( - raw_request.headers.get("X-Payment") - or raw_request.headers.get("PAYMENT-SIGNATURE") + raw_request.headers.get("X-Payment") or raw_request.headers.get("PAYMENT-SIGNATURE") ) @@ -785,8 +1397,7 @@ def _canonicalize_execute_body_provider_payload( ) elif key in _PUBLIC_PROVIDER_LIST_KEYS and isinstance(item, list): canonicalized[key] = [ - _canonicalize_execute_body_provider_value(entry) - for entry in item + _canonicalize_execute_body_provider_value(entry) for entry in item ] else: canonicalized[key] = _canonicalize_execute_body_provider_payload( @@ -862,10 +1473,11 @@ async def _evaluate_provider_policy( ) # Eligible = the candidates that survived policy filtering eligible_slugs = set(decision.candidate_providers) if decision else set() - eligible_mappings = [ - m for m in all_mappings - if m.get("service_slug") in eligible_slugs - ] if eligible_slugs else all_mappings + eligible_mappings = ( + [m for m in all_mappings if m.get("service_slug") in eligible_slugs] + if eligible_slugs + else all_mappings + ) return PolicyEvaluationResult( decision=decision, @@ -884,6 +1496,138 @@ async def v2_health() -> dict[str, Any]: } +@router.get("/boundary-contract") +async def get_boundary_contract_v2() -> dict[str, Any]: + """Return the machine-readable Index / Resolve / Runtime boundary contract. + + This is the PP-0 interface seam: downstream implementation can import the + same contract module used by this route, while agents and docs can inspect + the public ownership boundary without relying on prose-only PRD text. + """ + return { + "error": None, + "data": { + **boundary_contract_payload(), + "_rhumb_v2": _compat_meta(), + }, + } + + +def _validated_index_taxonomy_filter( + value: str | None, + *, + field_name: str, + allowed: frozenset[str], +) -> str | None: + if value is None: + return None + + normalized = value.strip() + if normalized in allowed: + return normalized + + raise RhumbError( + "INVALID_PARAMETERS", + message=f"Invalid '{field_name}' filter.", + detail=f"Use one of: {', '.join(sorted(allowed))}.", + ) + + +def _validated_index_text_filter(value: str | None, *, field_name: str) -> str | None: + if value is None: + return None + + normalized = value.strip() + if normalized: + return normalized + + raise RhumbError( + "INVALID_PARAMETERS", + message=f"Invalid '{field_name}' filter.", + detail=f"Provide a non-empty {field_name} value or omit the filter.", + ) + + +@router.get("/index/manifests") +async def list_index_manifests_v2( + capability_id: str | None = Query(None, description="Filter by capability ID."), + substrate: str | None = Query(None, description="Filter by route substrate."), + provenance_origin: str | None = Query(None, description="Filter by provenance/origin."), + source_risk: str | None = Query(None, description="Filter by source-risk class."), +) -> dict[str, Any]: + """Serve current PP-2 command manifests from hosted Index storage. + + This Index serving seam exposes deterministic manifest facts plus the PP-1 + default recommendation policy. Hosted rows are preferred, with deterministic + fixture fallback for local development and empty/unavailable stores. + """ + + parsed_capability_id = _validated_index_text_filter(capability_id, field_name="capability_id") + parsed_substrate = _validated_index_taxonomy_filter( + substrate, field_name="substrate", allowed=SUBSTRATES + ) + parsed_provenance_origin = _validated_index_taxonomy_filter( + provenance_origin, + field_name="provenance_origin", + allowed=PROVENANCE_ORIGINS, + ) + parsed_source_risk = _validated_index_taxonomy_filter( + source_risk, field_name="source_risk", allowed=SOURCE_RISKS + ) + + store = get_durable_index_manifest_store() + filtered = await store.list_manifests( + capability_id=parsed_capability_id, + substrate=parsed_substrate, + provenance_origin=parsed_provenance_origin, + source_risk=parsed_source_risk, + ) + + return { + "error": None, + "data": { + "contract_id": "index_command_manifest_v1", + "source": "PP-2", + "status": store.status, + "total": len(filtered), + "manifests": [with_recommendation_policy(manifest) for manifest in filtered], + "taxonomy": { + "substrates": sorted(SUBSTRATES), + "provenance_origins": sorted(PROVENANCE_ORIGINS), + "source_risks": sorted(SOURCE_RISKS), + }, + "_rhumb_v2": _compat_meta(), + }, + } + + +@router.get("/index/manifests/{route_id}") +async def get_index_manifest_v2(route_id: str) -> dict[str, Any]: + """Return one command-level route manifest by stable route ID.""" + + parsed_route_id = _validated_index_text_filter(route_id, field_name="route_id") + assert parsed_route_id is not None + store = get_durable_index_manifest_store() + manifest = await store.get_manifest(parsed_route_id) + if manifest is None: + raise RhumbError( + "ROUTE_MANIFEST_NOT_FOUND", + message=f"Route manifest '{parsed_route_id}' not found.", + detail="Use GET /v2/index/manifests to list currently indexed route manifests.", + ) + + return { + "error": None, + "data": { + "contract_id": "index_command_manifest_v1", + "source": "PP-2", + "status": store.status, + "manifest": with_recommendation_policy(manifest), + "_rhumb_v2": _compat_meta(), + }, + } + + @router.get("/capabilities") async def list_capabilities_v2( domain: str | None = Query(default=None, description="Filter by domain"), @@ -910,9 +1654,14 @@ async def resolve_capability_v2( default=None, description="Filter by credential mode (byok, rhumb_managed, agent_vault; legacy byo accepted).", ), + planned_parameters: str | None = Query( + default=None, + description="Optional JSON object whose hash is bound into issued route_plan_id values.", + ), ) -> JSONResponse: capability_id = _validated_capability_path_id(capability_id) canonical_credential_mode = _validated_credential_mode_filter(credential_mode) + route_plan_parameters = _planned_parameters_from_query(planned_parameters) resolve_response = await _forward_internal( raw_request, method="GET", @@ -927,6 +1676,21 @@ async def resolve_capability_v2( body = _rewrite_navigation_urls(resolve_response.json()) body = _canonicalize_execute_body_provider_fields(body, provider_slug=None) if resolve_response.status_code == 200 and isinstance(body.get("data"), dict): + body = annotate_resolve_body_with_route_candidates(body) + agent, policy_summary, policy_source, budget_summary = await _route_plan_issue_context( + raw_request + ) + body = _issue_route_plans_for_response_candidates( + body, + capability_id=capability_id, + credential_mode=canonical_credential_mode, + parameters=route_plan_parameters, + raw_request=raw_request, + agent=agent, + policy_summary=policy_summary, + policy_source=policy_source, + budget_summary=budget_summary, + ) body = _annotate_v2_body(body) return JSONResponse( @@ -1033,10 +1797,15 @@ async def estimate_capability_v2( description="Credential mode (auto, byok, rhumb_managed, agent_vault; legacy byo accepted).", ), provider: str | None = Query(default=None), + planned_parameters: str | None = Query( + default=None, + description="Optional JSON object whose hash is bound into issued route_plan_id values.", + ), ) -> JSONResponse: capability_id = _validated_capability_path_id(capability_id) canonical_credential_mode = _validated_estimate_credential_mode(credential_mode) canonical_provider = _validated_estimate_provider_filter(provider) + route_plan_parameters = _planned_parameters_from_query(planned_parameters) estimate_response = await _forward_internal( raw_request, method="GET", @@ -1057,6 +1826,21 @@ async def estimate_capability_v2( body["data"]["credential_mode"] = _canonicalize_credential_mode( body["data"].get("credential_mode") ) + body = annotate_resolve_body_with_route_candidates(body) + agent, policy_summary, policy_source, budget_summary = await _route_plan_issue_context( + raw_request + ) + body = _issue_route_plans_for_response_candidates( + body, + capability_id=capability_id, + credential_mode=canonical_credential_mode, + parameters=route_plan_parameters, + raw_request=raw_request, + agent=agent, + policy_summary=policy_summary, + policy_source=policy_source, + budget_summary=budget_summary, + ) body = _annotate_v2_body(body) return JSONResponse( @@ -1093,7 +1877,9 @@ async def execute_capability_v2( if "policy" in extra: extra["policy"] = _public_policy_summary(extra.get("policy")) if "selected_provider" in extra: - extra["selected_provider"] = _public_provider_slug(extra.get("selected_provider")) or extra.get("selected_provider") + extra["selected_provider"] = _public_provider_slug( + extra.get("selected_provider") + ) or extra.get("selected_provider") raise RhumbError( exc.code, message=exc.message, @@ -1141,6 +1927,9 @@ async def execute_capability_v2( endpoint_pattern = estimate_data.get("endpoint_pattern") estimated_cost = estimate_data.get("cost_estimate_usd") method, path = _parse_endpoint_pattern(endpoint_pattern) + selected_credential_mode = _canonicalize_credential_mode( + estimate_data.get("credential_mode") or canonical_credential_mode + ) if ( provider_decision @@ -1157,7 +1946,11 @@ async def execute_capability_v2( }, ) - if effective_policy and effective_policy.max_cost_usd is not None and estimated_cost is not None: + if ( + effective_policy + and effective_policy.max_cost_usd is not None + and estimated_cost is not None + ): if float(estimated_cost) > effective_policy.max_cost_usd: raise RhumbError( "BUDGET_EXCEEDED", @@ -1175,9 +1968,43 @@ async def execute_capability_v2( estimated_cost=float(estimated_cost) if estimated_cost is not None else None, ) + route_policy_summary = _public_policy_summary( + provider_decision.policy_summary + if provider_decision is not None + else _policy_summary(effective_policy) + ) + route_budget_summary = _budget_summary(budget_status) + route_plan_payload, route_plan_expected = _route_plan_boundary_facts( + capability_id=capability_id, + estimate_data=estimate_data, + selected_provider_public=selected_provider_public, + credential_mode=selected_credential_mode, + parameters=payload.parameters, + raw_request=raw_request, + agent=agent, + policy_summary=route_policy_summary, + policy_source=policy_source, + budget_summary=route_budget_summary, + ) + route_candidate_for_receipt = _selected_route_candidate( + capability_id=capability_id, + estimate_data=estimate_data, + selected_provider_public=selected_provider_public, + ) + route_plan_mode = "supplied_signed_plan" + route_plan_id = payload.route_plan_id.strip() if payload.route_plan_id else None + if route_plan_id is None: + route_plan_id, _ = _issue_internal_route_plan(route_plan_payload) + route_plan_mode = "internal_same_planner" + route_plan_validation = await _validate_route_plan_or_reject( + route_plan_id, + route_plan_expected, + source=route_plan_mode, + ) + v1_payload: dict[str, Any] = { "provider": selected_provider_public or selected_provider, - "credential_mode": canonical_credential_mode, + "credential_mode": selected_credential_mode, "idempotency_key": effective_idempotency_key, "interface": f"{payload.interface}-v2", "body": payload.parameters, @@ -1202,16 +2029,15 @@ async def execute_capability_v2( # ── Receipt creation ───────────────────────────────────────────── execution_data = body.get("data") if isinstance(body.get("data"), dict) else {} - effective_credential_mode = canonical_credential_mode + effective_credential_mode = selected_credential_mode if execution_data.get("credential_mode") is not None: effective_credential_mode = _canonicalize_credential_mode( execution_data.get("credential_mode") ) execution_data["credential_mode"] = effective_credential_mode - provider_used_public = ( - _public_provider_slug(execution_data.get("provider_used")) - or execution_data.get("provider_used") - ) + provider_used_public = _public_provider_slug( + execution_data.get("provider_used") + ) or execution_data.get("provider_used") if provider_used_public: body = _canonicalize_execute_body_provider_fields( body, @@ -1223,6 +2049,15 @@ async def execute_capability_v2( execution_id = execution_data.get("execution_id", "") is_success = execute_response.status_code == 200 + if is_success and execution_data: + _enforce_runtime_route_plan_echo( + execution_data=execution_data, + effective_credential_mode=effective_credential_mode, + expected=route_plan_expected, + validation=route_plan_validation, + route_plan_id=route_plan_id, + ) + receipt_id: str | None = None provider_public_slug = provider_used_public or selected_provider_public or "unknown" try: @@ -1244,8 +2079,20 @@ async def execute_capability_v2( if provider_decision and provider_decision.candidate_providers else None ), - winner_reason=( - provider_decision.selected_reason if provider_decision else None + winner_reason=(provider_decision.selected_reason if provider_decision else None), + route_id=route_plan_expected.get("route_id"), + service_id=route_plan_expected.get("service_id"), + substrate=route_candidate_for_receipt.get("substrate"), + provenance_origin=route_candidate_for_receipt.get("provenance_origin"), + source_risk=route_candidate_for_receipt.get("source_risk"), + manifest_digest=route_plan_expected.get("manifest_digest"), + evidence_packet_digest=route_plan_expected.get("evidence_packet_digest"), + route_plan_id_hash=_route_plan_id_hash(route_plan_id), + stop_condition=route_plan_validation.get("stop_condition") + or route_candidate_for_receipt.get("stop_condition"), + retryable=False if is_success else None, + next_recommended_action=( + "fetch_or_verify_receipt" if is_success else "inspect_error_and_resolve_again" ), total_latency_ms=execution_data.get("latency_ms"), rhumb_overhead_ms=execution_data.get("overhead_ms"), @@ -1263,7 +2110,10 @@ async def execute_capability_v2( receipt_id = receipt.receipt_id logger.info( "v2_receipt_created receipt_id=%s execution_id=%s provider=%s status=%s", - receipt_id, execution_id, provider_public_slug, receipt_input.status, + receipt_id, + execution_id, + provider_public_slug, + receipt_input.status, ) except Exception: # Receipt creation must never block execution delivery. @@ -1275,18 +2125,24 @@ async def execute_capability_v2( try: # Gather scores for explanation via read-only score cache (WU-41.4) from services.score_cache import get_score_cache as _get_sc - _slugs = [m.get("service_slug", "") for m in policy_eval.all_mappings if m.get("service_slug")] + + _slugs = [ + m.get("service_slug", "") for m in policy_eval.all_mappings if m.get("service_slug") + ] _scores_by_slug: dict[str, float] = _get_sc().scores_by_slug(_slugs) if _slugs else {} _circuit_states: dict[str, str] = {} from routes.proxy import get_breaker_registry as _get_br + _br = _get_br() for m in policy_eval.all_mappings: slug = m.get("service_slug", "") if slug: agent_for_br = agent.agent_id if agent else "anonymous" breaker = _br.get(slug, agent_for_br) - _circuit_states[slug] = breaker.state.value if hasattr(breaker.state, 'value') else str(breaker.state) + _circuit_states[slug] = ( + breaker.state.value if hasattr(breaker.state, "value") else str(breaker.state) + ) explanation = build_explanation( capability_id=capability_id, @@ -1295,8 +2151,16 @@ async def execute_capability_v2( circuit_states=_circuit_states, selected_provider=selected_provider_public or selected_provider, policy_pin=effective_policy.pin if effective_policy else None, - policy_deny=list(effective_policy.provider_deny) if effective_policy and effective_policy.provider_deny else None, - policy_allow_only=list(effective_policy.allow_only) if effective_policy and effective_policy.allow_only else None, + policy_deny=( + list(effective_policy.provider_deny) + if effective_policy and effective_policy.provider_deny + else None + ), + policy_allow_only=( + list(effective_policy.allow_only) + if effective_policy and effective_policy.allow_only + else None + ), max_cost_usd=effective_policy.max_cost_usd if effective_policy else None, layer=2, ) @@ -1306,6 +2170,22 @@ async def execute_capability_v2( except Exception: logger.exception("v2_route_explanation_failed execution_id=%s", execution_id) + compact_receipt = _compact_execution_receipt_meta( + status="success" if is_success else "failure", + receipt_id=receipt_id, + route_plan_validation=route_plan_validation, + route_plan_mode=route_plan_mode, + route_plan_id=route_plan_id, + route_plan_expected=route_plan_expected, + route_candidate=route_candidate_for_receipt, + route_policy_summary=route_policy_summary, + route_budget_summary=route_budget_summary, + explanation_id=explanation_id, + estimated_cost=estimated_cost, + error_code=_extract_error_fields(body)[0] if not is_success else None, + error_message=_extract_error_fields(body)[1] if not is_success else None, + ) + # ── Provider attribution (WU-41.2) ───────────────────────────── attribution_headers: dict[str, str] = {} try: @@ -1323,31 +2203,39 @@ async def execute_capability_v2( # ── Annotate response ───────────────────────────────────────────── if is_success and execution_data: - policy_summary = _public_policy_summary( - provider_decision.policy_summary - if provider_decision is not None - else _policy_summary(effective_policy) - ) v2_meta: dict[str, Any] = { **_compat_meta(), "receipt_id": receipt_id or _compat_receipt_id(execution_id), "explanation_id": explanation_id, "selected_provider": selected_provider_public, "policy_applied": _effective_policy_active(effective_policy), - "policy_selected_reason": provider_decision.selected_reason if provider_decision else None, + "policy_selected_reason": ( + provider_decision.selected_reason if provider_decision else None + ), "policy_candidates": policy_candidates_public if provider_decision else None, - "policy_summary": policy_summary, + "policy_summary": route_policy_summary, "policy_source": policy_source, "budget_applied": bool(budget_status and budget_status.budget_usd is not None), - "budget_summary": _budget_summary(budget_status), + "budget_summary": route_budget_summary, "estimated_cost_usd": estimated_cost, + "route_plan_enforcement": _route_plan_enforcement_meta( + validation=route_plan_validation, + mode=route_plan_mode, + route_plan_id=route_plan_id, + expected=route_plan_expected, + ), + "compact_receipt": compact_receipt, "translated_from": { "parameters": True, - "policy_provider_preference": bool(payload.policy and payload.policy.provider_preference), + "policy_provider_preference": bool( + payload.policy and payload.policy.provider_preference + ), "policy_pin": bool(payload.policy and payload.policy.pin), "policy_provider_deny": bool(payload.policy and payload.policy.provider_deny), "policy_allow_only": bool(payload.policy and payload.policy.allow_only), - "idempotency_header_used": bool(header_idempotency_key and not payload_idempotency_key), + "idempotency_header_used": bool( + header_idempotency_key and not payload_idempotency_key + ), }, } if receipt_id: @@ -1361,6 +2249,7 @@ async def execute_capability_v2( # ── Billing event emission (WU-41.5) ────────────────────────────── try: from services.billing_events import BillingEventType, get_billing_event_stream + _billing_org = agent.organization_id if agent else None if _billing_org and is_success: get_billing_event_stream().emit( diff --git a/packages/api/schemas/capability_manifest.py b/packages/api/schemas/capability_manifest.py new file mode 100644 index 00000000..5425c0a4 --- /dev/null +++ b/packages/api/schemas/capability_manifest.py @@ -0,0 +1,389 @@ +"""Command-level capability manifest schema for Index + Resolve vNext. + +PP-2 defines one manifest shape that can describe official APIs, official CLIs, +official MCPs, official SDK/code-mode paths, and generated adapters without +substrate-specific schema branches. The linter is deliberately fail-closed: +missing ownership, scope, allowlist, evidence, expiry, cost/rate, or public claim +fields make the manifest non-executable. +""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime +from typing import Any + +from schemas.index_evidence import canonical_sha256 +from schemas.route_taxonomy import ( + NON_NATIVE_SUBSTRATES, + PROVENANCE_ORIGINS, + SOURCE_RISKS, + SUBSTRATES, + lint_public_claim_boundary, + route_recommendation_policy, +) + +SIDE_EFFECT_CLASSES = frozenset({"read", "write", "admin", "payment", "destructive"}) +AUTH_MODES = frozenset({"none", "byok", "rhumb_managed", "agent_vault", "oauth", "user_session"}) +CONFIRMATION_POLICIES = frozenset({"none", "required", "dry_run_then_confirm", "blocked"}) +CACHE_BEHAVIORS = frozenset( + {"none", "read_through", "write_through", "ephemeral", "provider_controlled"} +) + +REQUIRED_MANIFEST_FIELDS = ( + "manifest_id", + "manifest_version", + "route_id", + "route_name", + "service_id", + "provider_id", + "vendor", + "capability_id", + "substrate", + "provenance_origin", + "source_risk", + "auth_mode", + "required_scopes", + "data_classes", + "side_effect_class", + "required_credentials", + "network_allowlist", + "filesystem_allowlist", + "process_allowlist", + "cost_model", + "rate_limit_model", + "dry_run_supported", + "confirmation_policy", + "cache_behavior", + "tests", + "evidence_refs", + "owner", + "reviewer", + "expires_at", + "public_claim_boundary", +) + +_LIST_FIELDS = frozenset( + { + "required_scopes", + "data_classes", + "required_credentials", + "network_allowlist", + "filesystem_allowlist", + "process_allowlist", + "tests", + "evidence_refs", + } +) + + +def capability_manifest_digest(manifest: dict[str, Any]) -> str: + return canonical_sha256(manifest) + + +def _parse_time(value: Any) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _non_empty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _valid_string_list(value: Any, *, allow_empty: bool = True) -> bool: + return ( + isinstance(value, list) + and (allow_empty or bool(value)) + and all(_non_empty_string(item) for item in value) + ) + + +def lint_capability_manifest(manifest: dict[str, Any]) -> list[str]: + errors: list[str] = [] + for field in REQUIRED_MANIFEST_FIELDS: + value = manifest.get(field) + if field in _LIST_FIELDS: + if not _valid_string_list( + value, + allow_empty=field + in { + "required_scopes", + "required_credentials", + "filesystem_allowlist", + "process_allowlist", + }, + ): + errors.append(f"invalid_{field}") + elif value in (None, ""): + errors.append(f"missing_{field}") + + if manifest.get("substrate") not in SUBSTRATES: + errors.append("invalid_substrate") + if manifest.get("provenance_origin") not in PROVENANCE_ORIGINS: + errors.append("invalid_provenance_origin") + if manifest.get("source_risk") not in SOURCE_RISKS: + errors.append("invalid_source_risk") + if manifest.get("auth_mode") not in AUTH_MODES: + errors.append("invalid_auth_mode") + if manifest.get("side_effect_class") not in SIDE_EFFECT_CLASSES: + errors.append("invalid_side_effect_class") + if manifest.get("confirmation_policy") not in CONFIRMATION_POLICIES: + errors.append("invalid_confirmation_policy") + if manifest.get("cache_behavior") not in CACHE_BEHAVIORS: + errors.append("invalid_cache_behavior") + if not isinstance(manifest.get("dry_run_supported"), bool): + errors.append("invalid_dry_run_supported") + if not isinstance(manifest.get("cost_model"), dict) or not manifest.get("cost_model"): + errors.append("invalid_cost_model") + if not isinstance(manifest.get("rate_limit_model"), dict) or not manifest.get( + "rate_limit_model" + ): + errors.append("invalid_rate_limit_model") + if _parse_time(manifest.get("expires_at")) is None: + errors.append("invalid_expires_at") + + if manifest.get("substrate") in NON_NATIVE_SUBSTRATES: + if not manifest.get("sandbox_profile_class"): + errors.append("missing_sandbox_profile_class") + if not _valid_string_list(manifest.get("artifact_allowlist"), allow_empty=False): + errors.append("invalid_artifact_allowlist") + + if manifest.get("side_effect_class") in {"write", "admin", "payment", "destructive"}: + if manifest.get("confirmation_policy") not in { + "required", + "dry_run_then_confirm", + "blocked", + }: + errors.append("high_risk_requires_confirmation_policy") + + if ( + manifest.get("source_risk") == "anti_bot_or_tos_sensitive" + and manifest.get("confirmation_policy") != "blocked" + ): + errors.append("anti_bot_routes_must_be_blocked") + + policy = route_recommendation_policy(manifest) + if policy["default_recommendable"] and manifest.get("promotion_state") in { + "fixture_only", + "experimental_non_default", + "blocked", + "deprecated", + }: + errors.append("promotion_state_not_default_executable") + errors.extend(lint_public_claim_boundary(manifest)) + + expected_digest = capability_manifest_digest(manifest) + if manifest.get("manifest_digest") != expected_digest: + errors.append("manifest_digest_mismatch") + return sorted(set(errors)) + + +def _with_digest(manifest: dict[str, Any]) -> dict[str, Any]: + result = deepcopy(manifest) + result["manifest_digest"] = capability_manifest_digest(result) + return result + + +def _base_manifest( + *, + manifest_id: str, + route_id: str, + route_name: str, + service_id: str, + provider_id: str, + vendor: str, + capability_id: str, + substrate: str, + provenance_origin: str, + source_risk: str, + auth_mode: str, + data_classes: list[str], + side_effect_class: str, + public_claim_boundary: str, + network_allowlist: list[str] | None = None, + filesystem_allowlist: list[str] | None = None, + process_allowlist: list[str] | None = None, + artifact_allowlist: list[str] | None = None, + sandbox_profile_class: str | None = None, + confirmation_policy: str = "none", + dry_run_supported: bool = False, + cache_behavior: str = "none", + required_scopes: list[str] | None = None, + required_credentials: list[str] | None = None, + promotion_state: str = "beta_executable", +) -> dict[str, Any]: + manifest = { + "manifest_id": manifest_id, + "manifest_version": "2026-05-19.1", + "route_id": route_id, + "route_name": route_name, + "service_id": service_id, + "provider_id": provider_id, + "vendor": vendor, + "capability_id": capability_id, + "substrate": substrate, + "provenance_origin": provenance_origin, + "source_risk": source_risk, + "auth_mode": auth_mode, + "required_scopes": required_scopes or [], + "data_classes": data_classes, + "side_effect_class": side_effect_class, + "required_credentials": required_credentials or [], + "network_allowlist": network_allowlist or [], + "filesystem_allowlist": filesystem_allowlist or [], + "process_allowlist": process_allowlist or [], + "artifact_allowlist": artifact_allowlist or [], + "cost_model": {"unit": "call", "estimated_cost_usd": 0.003}, + "rate_limit_model": {"unit": "minute", "limit": 60, "enforced_by": "rhumb"}, + "dry_run_supported": dry_run_supported, + "confirmation_policy": confirmation_policy, + "cache_behavior": cache_behavior, + "tests": [f"test_manifest_{route_id}"], + "evidence_refs": [f"evidence:{route_id}"], + "owner": "Pedro", + "reviewer": "Pedro", + "expires_at": "2026-08-17T00:00:00Z", + "public_claim_boundary": public_claim_boundary, + "promotion_state": promotion_state, + } + if sandbox_profile_class is not None: + manifest["sandbox_profile_class"] = sandbox_profile_class + return _with_digest(manifest) + + +def command_manifest_fixtures() -> list[dict[str, Any]]: + """Representative PP-2 fixtures across managed and non-native substrates.""" + + managed = [ + ("search.query", "brave-search", "brave-search-api", "web_search_query"), + ("email.verify", "emailable", "emailable-api", "email_address"), + ("crm.contact.lookup", "salesforce", "salesforce-api", "crm_contact"), + ("support.ticket.create", "zendesk", "zendesk-api", "support_ticket"), + ("warehouse.query", "snowflake", "snowflake-api", "warehouse_query"), + ] + fixtures: list[dict[str, Any]] = [] + for capability_id, service_id, provider_id, data_class in managed: + safe_cap = capability_id.replace(".", "_") + fixtures.append( + _base_manifest( + manifest_id=f"manifest_{safe_cap}_{provider_id.replace('-', '_')}_official_api_v1", + route_id=f"route_{safe_cap}_{provider_id.replace('-', '_')}_official_api_v1", + route_name=f"{capability_id} via {provider_id} official API", + service_id=service_id, + provider_id=provider_id, + vendor=service_id, + capability_id=capability_id, + substrate="official_api", + provenance_origin="rhumb_managed", + source_risk="verified_low", + auth_mode="rhumb_managed", + data_classes=[data_class], + side_effect_class="read" if capability_id != "support.ticket.create" else "write", + required_credentials=[f"{provider_id}:api_key"], + network_allowlist=[f"api.{service_id}.example.com"], + confirmation_policy=( + "dry_run_then_confirm" if capability_id == "support.ticket.create" else "none" + ), + dry_run_supported=capability_id == "support.ticket.create", + public_claim_boundary=f"Rhumb can represent the {capability_id} managed official API route when governed auth, budget, and policy allow it.", + ) + ) + + fixtures.extend( + [ + _base_manifest( + manifest_id="manifest_github_workflow_list_official_cli_v1", + route_id="route_workflow_run_list_github_cli_v1", + route_name="workflow_run.list via official GitHub CLI", + service_id="github", + provider_id="github-cli", + vendor="GitHub", + capability_id="workflow_run.list", + substrate="official_cli", + provenance_origin="vendor_official", + source_risk="community_unverified", + auth_mode="agent_vault", + data_classes=["workflow_metadata"], + side_effect_class="read", + network_allowlist=["api.github.com"], + process_allowlist=["gh"], + artifact_allowlist=["gh@pinned-digest-placeholder"], + sandbox_profile_class="cli_readonly_network_github", + public_claim_boundary="Fixture-only route describing how a pinned official GitHub CLI could list workflow runs after non-native sandbox gates pass.", + promotion_state="fixture_only", + ), + _base_manifest( + manifest_id="manifest_filesystem_search_official_mcp_v1", + route_id="route_file_search_official_mcp_v1", + route_name="file.search via official MCP server fixture", + service_id="filesystem", + provider_id="filesystem-mcp", + vendor="Model Context Protocol", + capability_id="file.search", + substrate="official_mcp", + provenance_origin="vendor_official", + source_risk="community_unverified", + auth_mode="none", + data_classes=["local_file_metadata"], + side_effect_class="read", + network_allowlist=["none"], + filesystem_allowlist=["/tmp/rhumb-fixtures/read-only"], + process_allowlist=["node"], + artifact_allowlist=["mcp-filesystem@pinned-digest-placeholder"], + sandbox_profile_class="mcp_readonly_filesystem_fixture", + public_claim_boundary="Fixture-only route describing a pinned read-only MCP filesystem search path after sandbox and consent gates pass.", + promotion_state="fixture_only", + ), + _base_manifest( + manifest_id="manifest_stripe_customer_retrieve_sdk_code_v1", + route_id="route_customer_retrieve_stripe_sdk_code_mode_v1", + route_name="customer.retrieve via official SDK code-mode fixture", + service_id="stripe", + provider_id="stripe-sdk", + vendor="Stripe", + capability_id="customer.retrieve", + substrate="sdk_code_mode", + provenance_origin="vendor_official", + source_risk="community_unverified", + auth_mode="agent_vault", + data_classes=["customer_profile"], + side_effect_class="read", + network_allowlist=["api.stripe.com"], + process_allowlist=["python"], + artifact_allowlist=["stripe-python@pinned-digest-placeholder"], + sandbox_profile_class="sdk_code_readonly_network_stripe", + public_claim_boundary="Fixture-only route describing a pinned official Stripe SDK read path after code-mode sandbox gates pass.", + promotion_state="fixture_only", + ), + _base_manifest( + manifest_id="manifest_generated_calendar_freebusy_fixture_v1", + route_id="route_calendar_freebusy_generated_adapter_v1", + route_name="calendar.freebusy via generated adapter fixture", + service_id="google-calendar", + provider_id="generated-calendar-adapter", + vendor="Google Calendar", + capability_id="calendar.freebusy", + substrate="generated_adapter", + provenance_origin="rhumb_generated", + source_risk="community_unverified", + auth_mode="agent_vault", + data_classes=["calendar_availability"], + side_effect_class="read", + network_allowlist=["www.googleapis.com"], + process_allowlist=["python"], + artifact_allowlist=["generated-calendar-adapter@fixture-digest-placeholder"], + sandbox_profile_class="generated_adapter_readonly_fixture", + public_claim_boundary="Fixture-only generated adapter candidate; Rhumb must not describe it as approved by the vendor or production ready before evidence, sandbox, and review gates pass.", + promotion_state="fixture_only", + ), + ] + ) + return fixtures + + +def fixture_manifests_by_route_id() -> dict[str, dict[str, Any]]: + return {manifest["route_id"]: manifest for manifest in command_manifest_fixtures()} diff --git a/packages/api/schemas/index_evidence.py b/packages/api/schemas/index_evidence.py new file mode 100644 index 00000000..1ddcd962 --- /dev/null +++ b/packages/api/schemas/index_evidence.py @@ -0,0 +1,355 @@ +"""Rhumb Index evidence packet and manifest model. + +PP-15 creates the minimal Index substrate that backs Resolve route decisions: +versioned manifests, evidence packets, deterministic digests, validation, and a +core `search.query` / `brave-search-api` official API fixture. +""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime +import hashlib +import json +from typing import Any + +from schemas.route_taxonomy import PROVENANCE_ORIGINS, SOURCE_RISKS, SUBSTRATES + +REVIEW_STATES = frozenset( + {"draft", "current", "stale", "expired", "quarantined", "superseded", "missing"} +) +PROMOTION_STATES = frozenset( + { + "indexed", + "candidate", + "fixture_only", + "experimental_non_default", + "beta_executable", + "production_executable", + "blocked", + "deprecated", + } +) + +DIGEST_FIELDS = frozenset({"manifest_digest", "evidence_packet_digest"}) + +INDEX_ENTITY_VOCABULARY = { + "service": "Stable vendor/service identity, e.g. Brave Search.", + "provider": "Concrete execution/catalog provider/config under a service, e.g. brave-search-api.", + "capability": "User-intent/action class, e.g. search.query.", + "route": "Stable way to perform a capability through a provider/substrate.", + "adapter_artifact": "Executable or descriptive artifact used by a route.", + "manifest": "Versioned machine-readable contract for a route/action.", + "evidence_packet": "Versioned bundle supporting a manifest/route.", + "route_review": "Freshness, security, claim-safety, and promotion review state.", + "public_claim_boundary": "Text Rhumb is allowed to say publicly about the route.", + "an_score_input": "General trust evidence, separate from per-user Resolve route choice.", +} + + +def _without_digest_fields(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): _without_digest_fields(item) + for key, item in value.items() + if str(key) not in DIGEST_FIELDS + } + if isinstance(value, list): + return [_without_digest_fields(item) for item in value] + return value + + +def canonical_json(value: Any) -> str: + """Serialize deterministic canonical JSON for Index digests.""" + + return json.dumps( + _without_digest_fields(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + + +def canonical_sha256(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def manifest_digest(manifest: dict[str, Any]) -> str: + return canonical_sha256(manifest) + + +def evidence_packet_digest(evidence_packet: dict[str, Any]) -> str: + return canonical_sha256(evidence_packet) + + +def _parse_time(value: Any) -> datetime | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def lint_manifest(manifest: dict[str, Any]) -> list[str]: + errors: list[str] = [] + required = [ + "manifest_id", + "manifest_version", + "route_id", + "service_id", + "provider_id", + "capability_id", + "substrate", + "provenance_origin", + "source_risk", + "credential_modes", + "side_effect_class", + "owner", + "expires_at", + "public_claim_boundary", + ] + for field in required: + if manifest.get(field) in (None, "", []): + errors.append(f"missing_{field}") + + if manifest.get("substrate") not in SUBSTRATES: + errors.append("invalid_substrate") + if manifest.get("provenance_origin") not in PROVENANCE_ORIGINS: + errors.append("invalid_provenance_origin") + if manifest.get("source_risk") not in SOURCE_RISKS: + errors.append("invalid_source_risk") + if manifest.get("side_effect_class") not in { + "read", + "write", + "admin", + "payment", + "destructive", + }: + errors.append("invalid_side_effect_class") + if not isinstance(manifest.get("credential_modes"), list) or not all( + isinstance(item, str) and item for item in manifest.get("credential_modes", []) + ): + errors.append("invalid_credential_modes") + if _parse_time(manifest.get("expires_at")) is None: + errors.append("invalid_expires_at") + + expected_digest = manifest_digest(manifest) + if manifest.get("manifest_digest") != expected_digest: + errors.append("manifest_digest_mismatch") + return sorted(set(errors)) + + +def lint_evidence_packet( + evidence_packet: dict[str, Any], manifest: dict[str, Any] | None = None +) -> list[str]: + errors: list[str] = [] + required = [ + "evidence_packet_id", + "route_id", + "service_id", + "provider_id", + "capability_id", + "manifest_id", + "manifest_version", + "manifest_digest", + "sources", + "reviews", + "owner", + "reviewer", + "freshness_checked_at", + "evidence_expires_at", + "review_status", + "promotion_state", + "public_claim_boundary", + ] + for field in required: + if evidence_packet.get(field) in (None, "", []): + errors.append(f"missing_{field}") + + if evidence_packet.get("review_status") not in REVIEW_STATES: + errors.append("invalid_review_status") + if evidence_packet.get("promotion_state") not in PROMOTION_STATES: + errors.append("invalid_promotion_state") + if evidence_packet.get("substrate") not in SUBSTRATES: + errors.append("invalid_substrate") + if evidence_packet.get("provenance_origin") not in PROVENANCE_ORIGINS: + errors.append("invalid_provenance_origin") + if evidence_packet.get("source_risk") not in SOURCE_RISKS: + errors.append("invalid_source_risk") + if _parse_time(evidence_packet.get("freshness_checked_at")) is None: + errors.append("invalid_freshness_checked_at") + if _parse_time(evidence_packet.get("evidence_expires_at")) is None: + errors.append("invalid_evidence_expires_at") + + sources = evidence_packet.get("sources") + if not isinstance(sources, list) or not sources: + errors.append("missing_sources") + elif not all( + isinstance(source, dict) and source.get("url") and source.get("kind") for source in sources + ): + errors.append("invalid_sources") + + reviews = evidence_packet.get("reviews") + if not isinstance(reviews, list) or not reviews: + errors.append("missing_reviews") + elif not all( + isinstance(review, dict) and review.get("reviewer") and review.get("verdict") + for review in reviews + ): + errors.append("invalid_reviews") + + if manifest is not None: + if evidence_packet.get("manifest_digest") != manifest_digest(manifest): + errors.append("linked_manifest_digest_mismatch") + for field in ( + "route_id", + "service_id", + "provider_id", + "capability_id", + "manifest_id", + "manifest_version", + ): + if evidence_packet.get(field) != manifest.get(field): + errors.append(f"linked_{field}_mismatch") + + expected_digest = evidence_packet_digest(evidence_packet) + if evidence_packet.get("evidence_packet_digest") != expected_digest: + errors.append("evidence_packet_digest_mismatch") + return sorted(set(errors)) + + +def lint_index_route_fixture(route_fixture: dict[str, Any]) -> list[str]: + manifest: dict[str, Any] = ( + route_fixture.get("manifest") if isinstance(route_fixture.get("manifest"), dict) else {} + ) + evidence_packet: dict[str, Any] = ( + route_fixture.get("evidence_packet") + if isinstance(route_fixture.get("evidence_packet"), dict) + else {} + ) + errors = [f"manifest:{error}" for error in lint_manifest(manifest)] + errors.extend( + f"evidence_packet:{error}" for error in lint_evidence_packet(evidence_packet, manifest) + ) + if route_fixture.get("entity_vocabulary") != INDEX_ENTITY_VOCABULARY: + errors.append("entity_vocabulary_mismatch") + return sorted(set(errors)) + + +def _base_search_query_manifest() -> dict[str, Any]: + return { + "manifest_id": "manifest_search_query_brave_search_api_official_api_v1", + "manifest_version": "2026-05-19.1", + "route_id": "route_search_query_brave_search_api_official_api_v1", + "service_id": "brave-search", + "provider_id": "brave-search-api", + "capability_id": "search.query", + "substrate": "official_api", + "provenance_origin": "rhumb_managed", + "source_risk": "verified_low", + "adapter_artifact_id": "adapter_config_brave_search_api_v1", + "credential_modes": ["rhumb_managed"], + "required_scopes": [], + "data_classes": ["web_search_query", "public_web_results"], + "side_effect_class": "read", + "endpoint_pattern": "GET /res/v1/web/search", + "cost_model": {"unit": "call", "estimated_cost_usd": 0.003}, + "rate_limit_model": { + "provider_limit_source": "vendor_dashboard", + "enforced_by": "Rhumb budget and provider account limits", + }, + "confirmation_policy": "none", + "sandbox_profile_class": "network_official_api_readonly", + "owner": "Pedro", + "expires_at": "2026-08-17T00:00:00Z", + "public_claim_boundary": "Rhumb can route read-only web search queries through a Rhumb-managed Brave Search API route when the caller has a valid governed Rhumb key and available credit.", + "an_score_input_refs": ["scores:service:brave-search"], + } + + +def search_query_brave_manifest() -> dict[str, Any]: + manifest = _base_search_query_manifest() + manifest["manifest_digest"] = manifest_digest(manifest) + return manifest + + +def _base_search_query_evidence_packet(manifest: dict[str, Any]) -> dict[str, Any]: + return { + "evidence_packet_id": "evidence_search_query_brave_search_api_official_api_2026_05_19", + "route_id": manifest["route_id"], + "service_id": manifest["service_id"], + "provider_id": manifest["provider_id"], + "capability_id": manifest["capability_id"], + "manifest_id": manifest["manifest_id"], + "manifest_version": manifest["manifest_version"], + "manifest_digest": manifest["manifest_digest"], + "substrate": manifest["substrate"], + "provenance_origin": manifest["provenance_origin"], + "source_risk": manifest["source_risk"], + "side_effect_class": manifest["side_effect_class"], + "credential_modes": manifest["credential_modes"], + "sources": [ + { + "kind": "vendor_docs", + "url": "https://api-dashboard.search.brave.com/app/documentation/web-search/get-started", + "observed_at": "2026-05-19T17:05:18Z", + "observed_title": "Documentation - Brave Search API", + }, + { + "kind": "rhumb_contract", + "url": "docs/INDEX-RESOLVE-VNEXT-PRD-ROADMAP-2026-05-19.md#core-searchquery-index-fixture", + "observed_at": "2026-05-19T17:05:18Z", + }, + ], + "runtime_witnesses": [ + { + "kind": "planned_first_call_fixture", + "status": "pending_PP-9_live_receipt", + "capability_id": "search.query", + "provider_id": "brave-search-api", + } + ], + "reviews": [ + { + "reviewer": "Pedro", + "verdict": "current_for_core_fixture", + "reviewed_at": "2026-05-19T17:05:18Z", + "scope": "schema_and_source_fixture; live first-call receipt lands in PP-9", + } + ], + "owner": "Pedro", + "reviewer": "Pedro", + "freshness_checked_at": "2026-05-19T17:05:18Z", + "evidence_expires_at": "2026-08-17T00:00:00Z", + "review_status": "current", + "promotion_state": "beta_executable", + "public_claim_boundary": manifest["public_claim_boundary"], + "an_score_input_refs": manifest["an_score_input_refs"], + "resolve_separation_note": "This packet supports route trust. Resolve still decides per-user auth, budget, policy, and risk at request time.", + } + + +def search_query_brave_evidence_packet() -> dict[str, Any]: + manifest = search_query_brave_manifest() + evidence_packet = _base_search_query_evidence_packet(manifest) + evidence_packet["evidence_packet_digest"] = evidence_packet_digest(evidence_packet) + return evidence_packet + + +def search_query_brave_route_fixture() -> dict[str, Any]: + manifest = search_query_brave_manifest() + evidence_packet = _base_search_query_evidence_packet(manifest) + evidence_packet["evidence_packet_digest"] = evidence_packet_digest(evidence_packet) + return { + "fixture_id": "index_fixture_search_query_brave_search_api_2026_05_19", + "entity_vocabulary": deepcopy(INDEX_ENTITY_VOCABULARY), + "manifest": manifest, + "evidence_packet": evidence_packet, + } + + +def route_fixture_for(capability_id: str, provider_id: str) -> dict[str, Any] | None: + if capability_id == "search.query" and provider_id == "brave-search-api": + return search_query_brave_route_fixture() + return None diff --git a/packages/api/schemas/resolve_boundary_contract.py b/packages/api/schemas/resolve_boundary_contract.py new file mode 100644 index 00000000..644822fa --- /dev/null +++ b/packages/api/schemas/resolve_boundary_contract.py @@ -0,0 +1,285 @@ +"""Index / Resolve / Runtime boundary contract for Rhumb vNext. + +PP-0 intentionally ships this as an executable interface before deeper route-plan +and candidate implementation work. The contract is deliberately boring and +explicit: every field group has a single owner, downstream layers may reference +upstream facts, and no downstream layer may invent or mutate upstream truth. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +BoundaryOwner = Literal["index", "resolve", "runtime"] + + +@dataclass(frozen=True) +class BoundarySection: + """A product/runtime boundary section with a single accountable owner.""" + + owner: BoundaryOwner + owns: tuple[str, ...] + must_not: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "owner": self.owner, + "owns": list(self.owns), + "must_not": list(self.must_not), + } + + +@dataclass(frozen=True) +class FieldOwnershipGroup: + """Field ownership row from the vNext field ownership matrix.""" + + group: str + index_stored: tuple[str, ...] + resolve_computed: tuple[str, ...] + runtime_issued: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "group": self.group, + "index_stored": list(self.index_stored), + "resolve_computed": list(self.resolve_computed), + "runtime_issued": list(self.runtime_issued), + } + + +BOUNDARY_CONTRACT: tuple[BoundarySection, ...] = ( + BoundarySection( + owner="index", + owns=( + "route facts", + "evidence packets", + "manifests", + "stable route IDs", + "provenance/origin", + "source risk", + "review state", + "freshness", + "public claim language", + "AN Score inputs", + ), + must_not=( + "choose caller-specific routes", + "issue route plans", + "execute upstream calls", + "meter executions", + ), + ), + BoundarySection( + owner="resolve", + owns=( + "task/user/org-specific route choice", + "safety state", + "stop condition", + "estimate", + "route explanation", + "opaque signed route plan creation", + ), + must_not=( + "fabricate Index facts", + "mutate score facts", + "execute upstream calls", + "rerank after route-plan issuance inside Runtime", + ), + ), + BoundarySection( + owner="runtime", + owns=( + "route-plan enforcement", + "sandboxing", + "credential broker use", + "upstream call", + "metering", + "redaction", + "audit events", + "receipts", + ), + must_not=( + "score services", + "discover routes", + "rank or rerank route candidates", + "fabricate Resolve decisions", + "run expired/revoked/mismatched/principal-mismatched route plans", + ), + ), +) + +FIELD_OWNERSHIP_MATRIX: tuple[FieldOwnershipGroup, ...] = ( + FieldOwnershipGroup( + group="stable_identity", + index_stored=( + "service_id", + "provider_id", + "capability_id", + "route_id", + "adapter_artifact_id", + ), + resolve_computed=("route_candidate_id", "selected_route_id"), + runtime_issued=("execution_id", "receipt_id"), + ), + FieldOwnershipGroup( + group="evidence", + index_stored=( + "manifest_id", + "manifest_version", + "manifest_digest", + "evidence_packet_id", + "evidence_packet_digest", + "review_status", + "promotion_state", + ), + resolve_computed=("selected_evidence_references", "evidence_usable_for_request"), + runtime_issued=("route_plan_evidence_hashes", "receipt_evidence_hashes"), + ), + FieldOwnershipGroup( + group="classification", + index_stored=( + "substrate", + "provenance_origin", + "source_risk", + "side_effect_class", + "data_classes", + ), + resolve_computed=("safety_state", "stop_condition", "why_selected", "why_rejected"), + runtime_issued=("observed_runtime_status", "observed_error", "observed_stop_condition"), + ), + FieldOwnershipGroup( + group="auth_cost_policy", + index_stored=( + "supported_credential_modes", + "required_scopes", + "route_cost_model", + "rate_limit_model", + ), + resolve_computed=( + "caller_auth_fit", + "credential_handle_requirement", + "cost_estimate", + "budget_impact", + "policy_decision", + ), + runtime_issued=( + "credential_handle_used", + "actual_cost", + "metering_event", + "policy_snapshot_hash", + ), + ), + FieldOwnershipGroup( + group="execution_controls", + index_stored=( + "required_sandbox_profile_class", + "allowlist_references", + "confirmation_policy_requirement", + ), + resolve_computed=( + "route_plan_expires_at", + "confirmation_requirement", + "selected_sandbox_profile_id", + ), + runtime_issued=( + "route_plan_signature", + "artifact_runtime_hashes", + "redaction_status", + "receipt_signature", + "receipt_status", + ), + ), +) + +INDEX_STORED_FIELDS = frozenset( + field for row in FIELD_OWNERSHIP_MATRIX for field in row.index_stored +) +RESOLVE_COMPUTED_FIELDS = frozenset( + field for row in FIELD_OWNERSHIP_MATRIX for field in row.resolve_computed +) +RUNTIME_ISSUED_FIELDS = frozenset( + field for row in FIELD_OWNERSHIP_MATRIX for field in row.runtime_issued +) + +RUNTIME_FORBIDDEN_DECISION_FIELDS = frozenset( + { + "candidate_rank", + "candidate_score", + "composite_score", + "an_score", + "an_score_override", + "route_candidates", + "selected_route_id", + "why_selected", + "why_rejected", + } +) + +ROUTE_PLAN_ENFORCEMENT_CHECKS = ( + "signature_valid", + "not_expired", + "not_revoked", + "principal_matches", + "org_matches", + "agent_matches", + "route_id_matches", + "credential_handle_matches", + "input_hash_matches", + "policy_snapshot_current_or_allowed", + "budget_still_allowed", + "manifest_digest_matches", + "evidence_packet_digest_matches", + "kill_switch_allows_execution", + "replay_not_seen", +) + + +def boundary_contract_payload() -> dict[str, Any]: + """Return the machine-readable PP-0 boundary contract.""" + + return { + "contract_id": "index_resolve_runtime_boundary_v1", + "source": "PP-0", + "status": "active", + "layers": [section.to_dict() for section in BOUNDARY_CONTRACT], + "field_ownership_matrix": [row.to_dict() for row in FIELD_OWNERSHIP_MATRIX], + "runtime_forbidden_decision_fields": sorted(RUNTIME_FORBIDDEN_DECISION_FIELDS), + "route_plan_enforcement_checks": list(ROUTE_PLAN_ENFORCEMENT_CHECKS), + "invariants": [ + "Index stores route truth; Resolve may reference it but not fabricate or mutate it.", + "Resolve chooses and explains caller-specific routes; Runtime may enforce but not rerank them.", + "Runtime executes only a valid Resolve-issued route plan and emits receipts for what it observed.", + "AN Score inputs remain Index/scoring facts and cannot be inflated because Rhumb owns or generated an adapter.", + ], + } + + +def unexpected_resolve_owned_index_fields(candidate: dict[str, Any]) -> set[str]: + """Return Index-owned fields a Resolve-only candidate tried to provide as computed fields. + + Resolve responses may echo Index facts under the canonical candidate fields. This guard is for + implementation seams that label fields as Resolve-computed; those seams must not smuggle + Index truth into Resolve-owned blobs. + """ + + resolve_blob = candidate.get("resolve_computed") + if not isinstance(resolve_blob, dict): + return set() + return INDEX_STORED_FIELDS.intersection(resolve_blob.keys()) + + +def runtime_decision_mutations(runtime_payload: dict[str, Any]) -> set[str]: + """Return decision/ranking fields Runtime attempted to issue or mutate.""" + + mutated = set(runtime_payload.keys()).intersection(RUNTIME_FORBIDDEN_DECISION_FIELDS) + runtime_issued = runtime_payload.get("runtime_issued") + if isinstance(runtime_issued, dict): + mutated.update(set(runtime_issued.keys()).intersection(RUNTIME_FORBIDDEN_DECISION_FIELDS)) + return mutated + + +def missing_route_plan_enforcement_checks(checks: dict[str, bool]) -> set[str]: + """Return required route-plan checks that are missing or false.""" + + return set(name for name in ROUTE_PLAN_ENFORCEMENT_CHECKS if checks.get(name) is not True) diff --git a/packages/api/schemas/resolve_route_candidate.py b/packages/api/schemas/resolve_route_candidate.py new file mode 100644 index 00000000..f679bc0b --- /dev/null +++ b/packages/api/schemas/resolve_route_candidate.py @@ -0,0 +1,522 @@ +"""Resolve route-candidate schema and state machine. + +This is the PP-14 bridge from the existing v1-translate Resolve payload to the +canonical vNext route-decision contract. It is intentionally additive: existing +Resolve fields remain stable while `route_candidates` gives agents typed states +and stop conditions they can branch on without parsing prose. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import hashlib +import re +from typing import Any, Literal + +from schemas.index_evidence import route_fixture_for +from schemas.route_taxonomy import PROVENANCE_ORIGINS, SOURCE_RISKS, SUBSTRATES + +Substrate = Literal[ + "official_api", + "documented_public_endpoint", + "official_cli", + "official_mcp", + "official_sdk", + "sdk_code_mode", + "generated_adapter", + "browser_discovered_private_endpoint", + "user_authorized_private_endpoint", +] + +ProvenanceOrigin = Literal[ + "vendor_official", + "vendor_authorized", + "rhumb_managed", + "community_submitted", + "rhumb_generated", + "user_authorized", + "browser_observed", + "unknown", +] + +SourceRisk = Literal[ + "verified_low", + "community_unverified", + "experimental_private", + "deprecated_fragile", + "anti_bot_or_tos_sensitive", + "payment_or_real_world_write", + "unsupported", +] + +PromotionState = Literal[ + "indexed", + "candidate", + "fixture_only", + "experimental_non_default", + "beta_executable", + "production_executable", + "blocked", + "deprecated", +] + +ReviewStatus = Literal[ + "draft", + "current", + "stale", + "expired", + "quarantined", + "superseded", + "missing", +] + +SafetyState = Literal[ + "executable", + "dry_run_only", + "requires_credentials", + "requires_confirmation", + "experimental_non_default", + "blocked_policy", + "blocked_security", + "unsupported", +] + +ReceiptSupport = Literal["none", "compact", "full", "verifiable"] + +PROMOTION_STATES = frozenset(PromotionState.__args__) # type: ignore[attr-defined] +REVIEW_STATUSES = frozenset(ReviewStatus.__args__) # type: ignore[attr-defined] +SAFETY_STATES = frozenset(SafetyState.__args__) # type: ignore[attr-defined] +RECEIPT_SUPPORT_LEVELS = frozenset(ReceiptSupport.__args__) # type: ignore[attr-defined] + +STOP_CONDITIONS = frozenset( + { + "missing_manifest", + "missing_provenance", + "missing_evidence_packet", + "evidence_expired", + "review_state_invalid", + "unverified_artifact", + "sandbox_required", + "sandbox_profile_missing", + "unsupported_auth_mode", + "missing_credentials", + "invalid_rhumb_key", + "missing_required_scope", + "credential_scope_mismatch", + "policy_denied", + "budget_exceeded", + "payment_required", + "tos_risk", + "anti_bot_or_access_control_risk", + "high_risk_requires_confirmation", + "kill_switch_state_unavailable", + "unsupported", + } +) + +_SAFE_ID_RE = re.compile(r"[^a-zA-Z0-9_]+") + + +def _safe_id(value: Any) -> str: + cleaned = _SAFE_ID_RE.sub("_", str(value or "unknown").strip()).strip("_").lower() + return cleaned or "unknown" + + +def _first_string(values: Any) -> str | None: + if isinstance(values, list): + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(values, str) and values.strip(): + return values.strip() + return None + + +def _enum_value(value: Any, allowed: frozenset[str], default: str) -> str: + cleaned = str(value or "").strip() + return cleaned if cleaned in allowed else default + + +def stable_route_id(capability_id: str, provider_id: str, substrate: str = "official_api") -> str: + return f"route_{_safe_id(capability_id)}_{_safe_id(provider_id)}_{_safe_id(substrate)}_v1" + + +def stable_route_candidate_id(capability_id: str, provider_id: str, rank: int) -> str: + seed = f"{capability_id}:{provider_id}:{rank}".encode("utf-8") + digest = hashlib.sha256(seed).hexdigest()[:10] + return f"route_candidate_{rank:02d}_{_safe_id(provider_id)}_{digest}" + + +def infer_safety_state_and_stop(provider: dict[str, Any]) -> tuple[str, str | None]: + """Infer the PP-14 safety state and typed stop for one provider candidate.""" + + explicit_state = provider.get("safety_state") + explicit_stop = provider.get("stop_condition") + if explicit_state in SAFETY_STATES: + default_stops = { + "requires_credentials": "missing_credentials", + "requires_confirmation": "high_risk_requires_confirmation", + "blocked_policy": "policy_denied", + "blocked_security": "unverified_artifact", + "unsupported": "unsupported", + } + stop = ( + str(explicit_stop) + if explicit_stop in STOP_CONDITIONS + else default_stops.get(str(explicit_state)) + ) + return str(explicit_state), None if explicit_state == "executable" else stop + + if provider.get("blocked_policy") or provider.get("policy_denied"): + return "blocked_policy", "policy_denied" + + circuit_state = str(provider.get("circuit_state") or "").strip().lower() + kill_switch_state = str(provider.get("kill_switch_state") or "").strip().lower() + if circuit_state == "unavailable" or kill_switch_state == "unavailable": + return "blocked_security", "kill_switch_state_unavailable" + if provider.get("blocked_security") or circuit_state in {"open", "blocked"}: + return "blocked_security", ( + str(explicit_stop) if explicit_stop in STOP_CONDITIONS else "unverified_artifact" + ) + + if ( + provider.get("experimental_non_default") + or provider.get("promotion_state") == "experimental_non_default" + ): + return "experimental_non_default", ( + str(explicit_stop) if explicit_stop in STOP_CONDITIONS else None + ) + + if provider.get("requires_confirmation"): + return "requires_confirmation", "high_risk_requires_confirmation" + + endpoint_pattern = str(provider.get("endpoint_pattern") or "").strip() + if not endpoint_pattern: + return "unsupported", "missing_manifest" + + if provider.get("available_for_execute") is False: + return "blocked_security", "kill_switch_state_unavailable" + + if provider.get("configured") is False: + return "requires_credentials", "missing_credentials" + + return "executable", None + + +def route_candidate_from_provider( + *, + capability_id: str, + provider: dict[str, Any], + rank: int, + selected_provider_id: str | None, + alternatives_considered: list[str], +) -> dict[str, Any]: + """Build one canonical route-candidate object from current Resolve provider data.""" + + provider_id = str( + provider.get("provider_id") + or provider.get("service_slug") + or provider.get("provider") + or "unknown" + ).strip() + fixture = route_fixture_for(capability_id, provider_id) + fixture_manifest = fixture.get("manifest") if isinstance(fixture, dict) else None + fixture_evidence = fixture.get("evidence_packet") if isinstance(fixture, dict) else None + + manifest_facts = fixture_manifest or {} + evidence_facts = fixture_evidence or {} + + service_id = str( + manifest_facts.get("service_id") + or provider.get("service_id") + or provider.get("service_slug") + or provider_id + ).strip() + substrate = _enum_value( + manifest_facts.get("substrate") or provider.get("substrate"), + SUBSTRATES, + "official_api", + ) + provenance_origin = _enum_value( + manifest_facts.get("provenance_origin") or provider.get("provenance_origin"), + PROVENANCE_ORIGINS, + "unknown", + ) + source_risk = _enum_value( + manifest_facts.get("source_risk") or provider.get("source_risk"), + SOURCE_RISKS, + "community_unverified", + ) + promotion_state = _enum_value( + evidence_facts.get("promotion_state") or provider.get("promotion_state"), + PROMOTION_STATES, + "candidate", + ) + review_status = _enum_value( + evidence_facts.get("review_status") or provider.get("review_status"), + REVIEW_STATUSES, + "missing", + ) + safety_state, stop_condition = infer_safety_state_and_stop(provider) + + credential_mode = ( + provider.get("credential_mode") + or provider.get("preferred_credential_mode") + or _first_string(provider.get("credential_modes")) + ) + cost_per_call = provider.get("cost_per_call") + cost_estimate = { + "amount_usd": cost_per_call, + "currency": provider.get("cost_currency") or "USD", + "free_tier_calls": provider.get("free_tier_calls"), + } + if cost_estimate["amount_usd"] is not None: + try: + cost_estimate["amount_usd"] = float(cost_estimate["amount_usd"]) + except (TypeError, ValueError): + cost_estimate["amount_usd"] = None + + is_selected = selected_provider_id is not None and provider_id == selected_provider_id + why_rejected: str | None = None + if not is_selected: + if stop_condition: + why_rejected = f"typed_stop:{stop_condition}" + elif selected_provider_id: + why_rejected = "lower_ranked_candidate" + + return { + "route_candidate_id": str( + provider.get("route_candidate_id") + or stable_route_candidate_id(capability_id, provider_id, rank) + ), + "route_id": str( + manifest_facts.get("route_id") + or provider.get("route_id") + or stable_route_id(capability_id, provider_id, substrate) + ), + "route_plan_id": ( + provider.get("route_plan_id") + if safety_state in {"executable", "dry_run_only"} + else None + ), + "route_plan_expires_at": ( + provider.get("route_plan_expires_at") + if safety_state in {"executable", "dry_run_only"} + else None + ), + "capability_id": capability_id, + "service_id": service_id, + "provider_id": provider_id, + "substrate": substrate, + "provenance_origin": provenance_origin, + "source_risk": source_risk, + "promotion_state": promotion_state, + "manifest_id": manifest_facts.get("manifest_id") or provider.get("manifest_id"), + "manifest_digest": manifest_facts.get("manifest_digest") or provider.get("manifest_digest"), + "manifest_version": manifest_facts.get("manifest_version") + or provider.get("manifest_version"), + "evidence_packet_id": evidence_facts.get("evidence_packet_id") + or provider.get("evidence_packet_id"), + "evidence_packet_digest": evidence_facts.get("evidence_packet_digest") + or provider.get("evidence_packet_digest"), + "evidence_expires_at": evidence_facts.get("evidence_expires_at") + or provider.get("evidence_expires_at"), + "review_status": review_status, + "safety_state": safety_state, + "stop_condition": stop_condition, + "auth_mode": provider.get("auth_method"), + "credential_mode": credential_mode, + "required_scopes": ( + manifest_facts["required_scopes"] + if "required_scopes" in manifest_facts + else provider.get("required_scopes") or [] + ), + "data_classes": ( + manifest_facts["data_classes"] + if "data_classes" in manifest_facts + else provider.get("data_classes") or [] + ), + "side_effect_class": ( + manifest_facts["side_effect_class"] + if "side_effect_class" in manifest_facts + else provider.get("side_effect_class") or "read" + ), + "cost_estimate": cost_estimate, + "rate_limit_estimate": provider.get("rate_limit_estimate"), + "budget_impact": provider.get("budget_impact") or {"status": "not_estimated"}, + "confirmation_policy": provider.get("confirmation_policy") + or ("required" if safety_state == "requires_confirmation" else "none"), + "sandbox_profile_id": provider.get("sandbox_profile_id"), + "receipt_support": _enum_value( + provider.get("receipt_support"), + RECEIPT_SUPPORT_LEVELS, + "compact" if provider.get("endpoint_pattern") else "none", + ), + "verification_path": provider.get("verification_path"), + "route_explanation_id": provider.get("route_explanation_id"), + "alternatives_considered": alternatives_considered, + "why_selected": ( + "highest_ranked_executable_candidate" + if is_selected and safety_state == "executable" + else ("selected_candidate" if is_selected else None) + ), + "why_rejected": why_rejected, + } + + +def unsupported_route_candidate(capability_id: str, reason: str | None = None) -> dict[str, Any]: + now = datetime.now(UTC).isoformat().replace("+00:00", "Z") + provider_id = "unsupported" + return { + "route_candidate_id": stable_route_candidate_id(capability_id, provider_id, 1), + "route_id": stable_route_id(capability_id, provider_id, "documented_public_endpoint"), + "route_plan_id": None, + "route_plan_expires_at": None, + "capability_id": capability_id, + "service_id": provider_id, + "provider_id": provider_id, + "substrate": "documented_public_endpoint", + "provenance_origin": "unknown", + "source_risk": "unsupported", + "promotion_state": "blocked", + "manifest_id": None, + "manifest_digest": None, + "manifest_version": None, + "evidence_packet_id": None, + "evidence_packet_digest": None, + "evidence_expires_at": now, + "review_status": "missing", + "safety_state": "unsupported", + "stop_condition": ( + "unsupported" if reason == "no_providers_registered" else "missing_manifest" + ), + "auth_mode": None, + "credential_mode": None, + "required_scopes": [], + "data_classes": [], + "side_effect_class": "read", + "cost_estimate": {"amount_usd": None, "currency": "USD", "free_tier_calls": None}, + "rate_limit_estimate": None, + "budget_impact": {"status": "not_estimated"}, + "confirmation_policy": "none", + "sandbox_profile_id": None, + "receipt_support": "none", + "verification_path": None, + "route_explanation_id": None, + "alternatives_considered": [], + "why_selected": None, + "why_rejected": reason or "no_verified_route", + } + + +def route_candidates_from_resolve_data(data: dict[str, Any]) -> list[dict[str, Any]]: + capability_id = str(data.get("capability") or data.get("capability_id") or "unknown") + providers = data.get("providers") + if (not isinstance(providers, list) or not providers) and data.get("provider"): + readiness: dict[str, Any] = ( + data.get("execute_readiness") if isinstance(data.get("execute_readiness"), dict) else {} + ) + provider: dict[str, Any] = { + "provider_id": data.get("provider"), + "service_slug": data.get("provider"), + "endpoint_pattern": data.get("endpoint_pattern"), + "credential_mode": data.get("credential_mode"), + "auth_method": data.get("auth_method"), + "cost_per_call": data.get("cost_estimate_usd"), + "cost_currency": data.get("cost_currency") or "USD", + "route_explanation_id": data.get("route_explanation_id"), + } + readiness_status = str(readiness.get("status") or "").strip().lower() + if readiness_status in {"auth_required", "credentials_required", "missing_credentials"}: + provider["safety_state"] = "requires_credentials" + provider["stop_condition"] = "missing_credentials" + elif readiness_status in {"policy_denied", "blocked_policy"}: + provider["safety_state"] = "blocked_policy" + provider["stop_condition"] = "policy_denied" + elif readiness_status in {"confirmation_required", "requires_confirmation"}: + provider["safety_state"] = "requires_confirmation" + provider["stop_condition"] = "high_risk_requires_confirmation" + elif readiness_status in {"blocked_security", "kill_switch_state_unavailable"}: + provider["safety_state"] = "blocked_security" + provider["stop_condition"] = "kill_switch_state_unavailable" + elif readiness_status in {"ready", "executable"}: + provider["configured"] = True + return [ + route_candidate_from_provider( + capability_id=capability_id, + provider=provider, + rank=1, + selected_provider_id=str(data.get("provider")), + alternatives_considered=[str(data.get("provider"))], + ) + ] + if not isinstance(providers, list) or not providers: + recovery_hint: dict[str, Any] = ( + data.get("recovery_hint") if isinstance(data.get("recovery_hint"), dict) else {} + ) + return [ + unsupported_route_candidate( + capability_id, str(recovery_hint.get("reason") or "no_candidate") + ) + ] + + execute_hint = data.get("execute_hint") if isinstance(data.get("execute_hint"), dict) else {} + selected_provider_id = ( + execute_hint.get("preferred_provider") if isinstance(execute_hint, dict) else None + ) + if selected_provider_id is not None: + selected_provider_id = str(selected_provider_id) + alternatives = [ + str( + provider.get("provider_id") + or provider.get("service_slug") + or provider.get("provider") + or "unknown" + ) + for provider in providers + if isinstance(provider, dict) + ] + + candidates: list[dict[str, Any]] = [] + for index, provider in enumerate(providers, start=1): + if not isinstance(provider, dict): + continue + candidates.append( + route_candidate_from_provider( + capability_id=capability_id, + provider=provider, + rank=index, + selected_provider_id=selected_provider_id, + alternatives_considered=alternatives, + ) + ) + return candidates + + +def annotate_resolve_body_with_route_candidates(body: dict[str, Any]) -> dict[str, Any]: + """Add PP-14 route candidates to a Resolve/estimate response body.""" + + data = body.get("data") + if not isinstance(data, dict): + return body + + annotated = dict(body) + annotated_data = dict(data) + route_candidates = route_candidates_from_resolve_data(annotated_data) + annotated_data["route_candidates"] = route_candidates + has_index_fixture = any(candidate.get("evidence_packet_id") for candidate in route_candidates) + annotated_data["route_contract"] = { + "contract_id": "resolve_route_candidate_v1", + "source": "PP-14", + "status": "compat_bridge", + "index_fact_source": ( + "index_evidence_fixture_and_v1_catalog_compat" + if has_index_fixture + else "v1_catalog_compat_until_PP-15" + ), + "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "safety_states": sorted(SAFETY_STATES), + "stop_conditions": sorted(STOP_CONDITIONS), + } + annotated["data"] = annotated_data + return annotated diff --git a/packages/api/schemas/route_taxonomy.py b/packages/api/schemas/route_taxonomy.py new file mode 100644 index 00000000..ffb077ff --- /dev/null +++ b/packages/api/schemas/route_taxonomy.py @@ -0,0 +1,162 @@ +"""Route substrate/provenance/source-risk taxonomy for Index + Resolve vNext. + +PP-1 keeps route facts split into separate, machine-readable dimensions. +The helpers in this module are intentionally small and fail-closed so Resolve, +MCP, UI/logging, receipts, and manifest linting can share one vocabulary. +""" + +from __future__ import annotations + +from typing import Any + +SUBSTRATES = frozenset( + { + "official_api", + "documented_public_endpoint", + "official_cli", + "official_mcp", + "official_sdk", + "sdk_code_mode", + "generated_adapter", + "browser_discovered_private_endpoint", + "user_authorized_private_endpoint", + } +) + +PROVENANCE_ORIGINS = frozenset( + { + "vendor_official", + "vendor_authorized", + "rhumb_managed", + "community_submitted", + "rhumb_generated", + "user_authorized", + "browser_observed", + "unknown", + } +) + +SOURCE_RISKS = frozenset( + { + "verified_low", + "community_unverified", + "experimental_private", + "deprecated_fragile", + "anti_bot_or_tos_sensitive", + "payment_or_real_world_write", + "unsupported", + } +) + +PRIVATE_OR_SNIFFED_SUBSTRATES = frozenset( + { + "browser_discovered_private_endpoint", + "user_authorized_private_endpoint", + } +) + +NON_NATIVE_SUBSTRATES = frozenset( + { + "official_cli", + "official_mcp", + "official_sdk", + "sdk_code_mode", + "generated_adapter", + "browser_discovered_private_endpoint", + "user_authorized_private_endpoint", + } +) + +DEFAULT_EXCLUDED_SOURCE_RISKS = frozenset( + { + "community_unverified", + "experimental_private", + "deprecated_fragile", + "anti_bot_or_tos_sensitive", + "payment_or_real_world_write", + "unsupported", + } +) + +PUBLIC_CLAIM_UNSAFE_PHRASES = ( + "vendor approved", + "vendor-approved", + "officially approved", + "production-grade", + "production grade", +) + + +def normalize_taxonomy_value(value: Any, allowed: frozenset[str], default: str) -> str: + cleaned = str(value or "").strip() + return cleaned if cleaned in allowed else default + + +def route_recommendation_policy( + route: dict[str, Any], + *, + explicit_request: bool = False, + policy_allowed: bool = False, +) -> dict[str, Any]: + """Return the default recommendation decision for one route fact. + + PP-1's safety rail: private/sniffed, anti-bot, unsupported, high-risk, + deprecated, or unverified routes cannot become default recommendations just + because they are present in Index. Explicit request + policy allowance is + required for routes that are not fundamentally unsupported or anti-bot risky. + """ + + substrate = normalize_taxonomy_value(route.get("substrate"), SUBSTRATES, "generated_adapter") + provenance_origin = normalize_taxonomy_value( + route.get("provenance_origin"), PROVENANCE_ORIGINS, "unknown" + ) + source_risk = normalize_taxonomy_value(route.get("source_risk"), SOURCE_RISKS, "unsupported") + side_effect_class = str(route.get("side_effect_class") or "").strip() + + reasons: list[str] = [] + if substrate in PRIVATE_OR_SNIFFED_SUBSTRATES: + reasons.append("private_or_sniffed_route_requires_explicit_policy") + if substrate == "generated_adapter" and provenance_origin != "vendor_official": + reasons.append("generated_route_not_default") + if provenance_origin in {"community_submitted", "browser_observed", "unknown"}: + reasons.append("untrusted_provenance_requires_explicit_policy") + if source_risk in DEFAULT_EXCLUDED_SOURCE_RISKS: + reasons.append(f"source_risk_{source_risk}_not_default") + if side_effect_class in {"write", "admin", "payment", "destructive"}: + reasons.append("high_risk_side_effect_not_default") + + hard_blocked = source_risk in {"unsupported", "anti_bot_or_tos_sensitive"} + allowed_by_override = explicit_request and policy_allowed and not hard_blocked + default_recommendable = not reasons + recommendable = default_recommendable or allowed_by_override + + return { + "substrate": substrate, + "provenance_origin": provenance_origin, + "source_risk": source_risk, + "default_recommendable": default_recommendable, + "recommendable": recommendable, + "requires_explicit_request": bool(reasons) and not hard_blocked, + "policy_allowed": policy_allowed, + "blocked": hard_blocked and bool(reasons), + "reasons": reasons, + } + + +def lint_public_claim_boundary(route: dict[str, Any]) -> list[str]: + """Fail closed when public copy overclaims weak route evidence.""" + + claim = str(route.get("public_claim_boundary") or "").strip().lower() + if not claim: + return ["missing_public_claim_boundary"] + + policy = route_recommendation_policy(route) + if policy["default_recommendable"]: + return [] + + errors: list[str] = [] + for phrase in PUBLIC_CLAIM_UNSAFE_PHRASES: + if phrase in claim: + errors.append("public_claim_overstates_route_evidence") + break + return errors diff --git a/packages/api/services/error_envelope.py b/packages/api/services/error_envelope.py index 7dec66c6..60adbebd 100644 --- a/packages/api/services/error_envelope.py +++ b/packages/api/services/error_envelope.py @@ -11,12 +11,12 @@ import logging import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import Any, Optional +from typing import Any -from fastapi import HTTPException, Request +from fastapi import Request from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) @@ -113,6 +113,13 @@ class ErrorCodeDef: retryable=False, description="Score provider ID does not exist in the score cache", ), + "ROUTE_MANIFEST_NOT_FOUND": ErrorCodeDef( + code="ROUTE_MANIFEST_NOT_FOUND", + category=ErrorCategory.CLIENT, + http_status=404, + retryable=False, + description="Route manifest ID does not exist in the Index manifest registry", + ), "CREDENTIAL_INVALID": ErrorCodeDef( code="CREDENTIAL_INVALID", category=ErrorCategory.AUTH, @@ -188,6 +195,13 @@ class ErrorCodeDef: description="All providers unavailable", default_retry_after_ms=10000, ), + "ROUTE_PLAN_REJECTED": ErrorCodeDef( + code="ROUTE_PLAN_REJECTED", + category=ErrorCategory.ROUTING, + http_status=409, + retryable=False, + description="Signed Resolve route plan failed Runtime enforcement", + ), "PROVIDER_TIMEOUT": ErrorCodeDef( code="PROVIDER_TIMEOUT", category=ErrorCategory.PROVIDER, diff --git a/packages/api/services/index_manifest_seed.py b/packages/api/services/index_manifest_seed.py new file mode 100644 index 00000000..a7804908 --- /dev/null +++ b/packages/api/services/index_manifest_seed.py @@ -0,0 +1,171 @@ +"""Deterministic hosted Index manifest seed payloads. + +This module renders the PP-2 command manifest fixtures into the durable +``index_command_manifests`` table shape. The generated SQL is intentionally a +fixture seed, not evidence of production execution: manifest/evidence digests +and public claim boundaries are preserved exactly, and evidence columns are only +filled when an Index evidence packet fixture already exists. +""" + +from __future__ import annotations + +from copy import deepcopy +import json +from typing import Any + +from schemas.capability_manifest import command_manifest_fixtures +from schemas.index_evidence import route_fixture_for + +INDEX_COMMAND_MANIFEST_SEED_MIGRATION = "0166_index_command_manifest_seed.sql" + +_SEED_COLUMNS = ( + "route_id", + "manifest_id", + "manifest_version", + "manifest_digest", + "service_id", + "provider_id", + "capability_id", + "substrate", + "provenance_origin", + "source_risk", + "side_effect_class", + "promotion_state", + "review_status", + "evidence_packet_id", + "evidence_packet_digest", + "evidence_expires_at", + "public_claim_boundary", + "manifest_json", + "evidence_packet_json", + "owner", + "reviewer", + "expires_at", +) + +_JSONB_COLUMNS = frozenset({"manifest_json", "evidence_packet_json"}) +_TIMESTAMPTZ_COLUMNS = frozenset({"evidence_expires_at", "expires_at"}) + + +def _evidence_packet_for_manifest(manifest: dict[str, Any]) -> dict[str, Any] | None: + fixture = route_fixture_for( + str(manifest.get("capability_id") or ""), str(manifest.get("provider_id") or "") + ) + if not isinstance(fixture, dict): + return None + + evidence_packet = fixture.get("evidence_packet") + if not isinstance(evidence_packet, dict): + return None + + if evidence_packet.get("route_id") != manifest.get("route_id"): + return None + + return deepcopy(evidence_packet) + + +def index_command_manifest_seed_rows() -> list[dict[str, Any]]: + """Return deterministic rows for seeding hosted Index manifest storage.""" + + rows: list[dict[str, Any]] = [] + for manifest in command_manifest_fixtures(): + manifest_json = deepcopy(manifest) + evidence_packet = _evidence_packet_for_manifest(manifest_json) + rows.append( + { + "route_id": manifest_json["route_id"], + "manifest_id": manifest_json["manifest_id"], + "manifest_version": manifest_json["manifest_version"], + "manifest_digest": manifest_json["manifest_digest"], + "service_id": manifest_json["service_id"], + "provider_id": manifest_json["provider_id"], + "capability_id": manifest_json["capability_id"], + "substrate": manifest_json["substrate"], + "provenance_origin": manifest_json["provenance_origin"], + "source_risk": manifest_json["source_risk"], + "side_effect_class": manifest_json["side_effect_class"], + "promotion_state": manifest_json.get("promotion_state"), + "review_status": evidence_packet.get("review_status") if evidence_packet else None, + "evidence_packet_id": ( + evidence_packet.get("evidence_packet_id") if evidence_packet else None + ), + "evidence_packet_digest": ( + evidence_packet.get("evidence_packet_digest") if evidence_packet else None + ), + "evidence_expires_at": ( + evidence_packet.get("evidence_expires_at") if evidence_packet else None + ), + "public_claim_boundary": manifest_json["public_claim_boundary"], + "manifest_json": manifest_json, + "evidence_packet_json": evidence_packet, + "owner": manifest_json.get("owner"), + "reviewer": manifest_json.get("reviewer"), + "expires_at": manifest_json.get("expires_at"), + } + ) + + rows.sort(key=lambda row: (str(row["capability_id"]), str(row["route_id"]))) + return rows + + +def _sql_string_literal(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _jsonb_literal(value: Any) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return f"{_sql_string_literal(encoded)}::jsonb" + + +def _sql_value(column: str, value: Any) -> str: + if value is None: + return "NULL" + if column in _JSONB_COLUMNS: + return _jsonb_literal(value) + if column in _TIMESTAMPTZ_COLUMNS: + return f"{_sql_string_literal(str(value))}::timestamptz" + return _sql_string_literal(str(value)) + + +def render_index_command_manifest_seed_sql() -> str: + """Render the deterministic SQL migration for current PP-2 fixtures.""" + + column_block = ",\n ".join(_SEED_COLUMNS) + values = [] + for row in index_command_manifest_seed_rows(): + row_values = ",\n ".join(_sql_value(column, row[column]) for column in _SEED_COLUMNS) + values.append(f" (\n {row_values}\n )") + + values_block = ",\n".join(values) + update_block = ",\n ".join( + f"{column} = EXCLUDED.{column}" for column in _SEED_COLUMNS if column != "route_id" + ) + + return ( + "-- Migration 0166: Seed hosted Index command manifest storage\n" + "--\n" + "-- Deterministic PP-2 fixture seed for index_command_manifests. These rows\n" + "-- preserve manifest/evidence digests and public claim boundaries exactly;\n" + "-- they do not claim live production execution. Evidence columns are filled\n" + "-- only where an Index evidence packet fixture already exists.\n" + "\n" + "BEGIN;\n" + "\n" + "INSERT INTO index_command_manifests (\n" + f" {column_block}\n" + ")\n" + "VALUES\n" + f"{values_block}\n" + "ON CONFLICT (route_id) DO UPDATE SET\n" + f" {update_block},\n" + " updated_at = NOW();\n" + "\n" + "COMMIT;\n" + ) + + +__all__ = [ + "INDEX_COMMAND_MANIFEST_SEED_MIGRATION", + "index_command_manifest_seed_rows", + "render_index_command_manifest_seed_sql", +] diff --git a/packages/api/services/index_manifest_store.py b/packages/api/services/index_manifest_store.py new file mode 100644 index 00000000..3a12fbf7 --- /dev/null +++ b/packages/api/services/index_manifest_store.py @@ -0,0 +1,295 @@ +"""Index manifest store seam for route truth facts. + +PP-1/PP-2 currently use an in-repo fixture registry, but callers should depend +on this service rather than importing fixture constructors directly. That gives +Resolve, explanations, receipts/log surfaces, and future durable storage one +stable lookup interface for command manifests and route facts. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from routes._supabase import supabase_fetch +from schemas.capability_manifest import command_manifest_fixtures +from schemas.index_evidence import route_fixture_for +from schemas.route_taxonomy import route_recommendation_policy + +_SELECT_COLUMNS = ( + "route_id,manifest_id,manifest_version,manifest_digest,service_id,provider_id," + "capability_id,substrate,provenance_origin,source_risk,side_effect_class," + "promotion_state,review_status,evidence_packet_id,evidence_packet_digest," + "evidence_expires_at,public_claim_boundary,manifest_json,evidence_packet_json," + "owner,reviewer,expires_at" +) + + +class IndexManifestStore: + """Read-only PP-2 manifest registry abstraction.""" + + source = "PP-2" + status = "fixture_registry_until_index_store" + contract_id = "index_command_manifest_v1" + + def __init__(self, manifests: list[dict[str, Any]] | None = None) -> None: + self._manifests = [ + deepcopy(manifest) for manifest in (manifests or command_manifest_fixtures()) + ] + self._by_route_id = { + str(manifest.get("route_id")): manifest for manifest in self._manifests + } + + def list_manifests( + self, + *, + capability_id: str | None = None, + substrate: str | None = None, + provenance_origin: str | None = None, + source_risk: str | None = None, + ) -> list[dict[str, Any]]: + manifests = [ + manifest + for manifest in self._manifests + if (capability_id is None or manifest.get("capability_id") == capability_id) + and (substrate is None or manifest.get("substrate") == substrate) + and ( + provenance_origin is None or manifest.get("provenance_origin") == provenance_origin + ) + and (source_risk is None or manifest.get("source_risk") == source_risk) + ] + manifests.sort( + key=lambda item: (str(item.get("capability_id") or ""), str(item.get("route_id") or "")) + ) + return [deepcopy(manifest) for manifest in manifests] + + def get_manifest(self, route_id: str) -> dict[str, Any] | None: + manifest = self._by_route_id.get(route_id) + return deepcopy(manifest) if manifest is not None else None + + def route_facts_for_provider(self, capability_id: str, provider_id: str) -> dict[str, Any]: + """Return manifest/evidence route facts for a capability/provider pair.""" + + fixture = route_fixture_for(capability_id, provider_id) + manifest = fixture.get("manifest") if isinstance(fixture, dict) else None + evidence_packet = fixture.get("evidence_packet") if isinstance(fixture, dict) else None + + if not isinstance(manifest, dict): + candidates = self.list_manifests(capability_id=capability_id) + for candidate in candidates: + if candidate.get("provider_id") == provider_id: + manifest = candidate + break + + if not isinstance(manifest, dict): + return {} + + route: dict[str, Any] = { + "route_id": manifest.get("route_id"), + "service_id": manifest.get("service_id"), + "provider_id": manifest.get("provider_id"), + "substrate": manifest.get("substrate"), + "provenance_origin": manifest.get("provenance_origin"), + "source_risk": manifest.get("source_risk"), + "manifest_id": manifest.get("manifest_id"), + "manifest_digest": manifest.get("manifest_digest"), + "manifest_version": manifest.get("manifest_version"), + "side_effect_class": manifest.get("side_effect_class"), + "public_claim_boundary": manifest.get("public_claim_boundary"), + } + if isinstance(evidence_packet, dict): + route.update( + { + "evidence_packet_id": evidence_packet.get("evidence_packet_id"), + "evidence_packet_digest": evidence_packet.get("evidence_packet_digest"), + "review_status": evidence_packet.get("review_status"), + "promotion_state": evidence_packet.get("promotion_state"), + "evidence_expires_at": evidence_packet.get("evidence_expires_at"), + } + ) + + policy = route_recommendation_policy(route) + route["recommendation_policy"] = { + "default_recommendable": policy["default_recommendable"], + "recommendable": policy["recommendable"], + "requires_explicit_request": policy["requires_explicit_request"], + "blocked": policy["blocked"], + "reasons": policy["reasons"], + } + return {key: value for key, value in route.items() if value is not None} + + +def with_recommendation_policy(manifest: dict[str, Any]) -> dict[str, Any]: + policy = route_recommendation_policy(manifest) + return { + **manifest, + "recommendation_policy": { + "default_recommendable": policy["default_recommendable"], + "recommendable": policy["recommendable"], + "requires_explicit_request": policy["requires_explicit_request"], + "blocked": policy["blocked"], + "reasons": policy["reasons"], + }, + } + + +def get_index_manifest_store() -> IndexManifestStore: + return IndexManifestStore() + + +class DurableIndexManifestStore: + """Hosted Index-backed manifest reader with fixture fallback. + + Supabase/PostgREST is the durable source when rows are available. Empty or + unavailable reads fall back to the fixture store so local development and + fail-closed tests continue to expose the current public contract honestly. + """ + + source = "PP-2" + status = "hosted_index_with_fixture_fallback" + contract_id = "index_command_manifest_v1" + + def __init__(self, fallback: IndexManifestStore | None = None) -> None: + self._fallback = fallback or IndexManifestStore() + + async def list_manifests( + self, + *, + capability_id: str | None = None, + substrate: str | None = None, + provenance_origin: str | None = None, + source_risk: str | None = None, + ) -> list[dict[str, Any]]: + rows = await supabase_fetch( + self._manifest_query( + capability_id=capability_id, + substrate=substrate, + provenance_origin=provenance_origin, + source_risk=source_risk, + ) + ) + manifests = [_manifest_from_row(row) for row in rows or [] if isinstance(row, dict)] + manifests = [manifest for manifest in manifests if manifest] + if manifests: + return manifests + return self._fallback.list_manifests( + capability_id=capability_id, + substrate=substrate, + provenance_origin=provenance_origin, + source_risk=source_risk, + ) + + async def get_manifest(self, route_id: str) -> dict[str, Any] | None: + rows = await supabase_fetch( + "index_command_manifests" + f"?route_id=eq.{route_id}" + f"&select={_SELECT_COLUMNS}" + "&limit=1" + ) + if isinstance(rows, list) and rows: + manifest = _manifest_from_row(rows[0]) if isinstance(rows[0], dict) else None + if manifest: + return manifest + return self._fallback.get_manifest(route_id) + + async def route_facts_for_provider( + self, capability_id: str, provider_id: str + ) -> dict[str, Any]: + rows = await supabase_fetch( + "index_command_manifests" + f"?capability_id=eq.{capability_id}" + f"&provider_id=eq.{provider_id}" + f"&select={_SELECT_COLUMNS}" + "&limit=1" + ) + if isinstance(rows, list) and rows: + route = _route_facts_from_row(rows[0]) if isinstance(rows[0], dict) else None + if route: + return route + return self._fallback.route_facts_for_provider(capability_id, provider_id) + + def _manifest_query( + self, + *, + capability_id: str | None, + substrate: str | None, + provenance_origin: str | None, + source_risk: str | None, + ) -> str: + filters = [] + if capability_id is not None: + filters.append(f"capability_id=eq.{capability_id}") + if substrate is not None: + filters.append(f"substrate=eq.{substrate}") + if provenance_origin is not None: + filters.append(f"provenance_origin=eq.{provenance_origin}") + if source_risk is not None: + filters.append(f"source_risk=eq.{source_risk}") + filter_prefix = "&".join(filters) + if filter_prefix: + filter_prefix += "&" + return ( + "index_command_manifests?" + f"{filter_prefix}select={_SELECT_COLUMNS}" + "&order=capability_id.asc,route_id.asc" + ) + + +def _manifest_from_row(row: dict[str, Any]) -> dict[str, Any]: + manifest_json = row.get("manifest_json") + manifest = deepcopy(manifest_json) if isinstance(manifest_json, dict) else {} + for field_name in ( + "route_id", + "manifest_id", + "manifest_version", + "manifest_digest", + "service_id", + "provider_id", + "capability_id", + "substrate", + "provenance_origin", + "source_risk", + "side_effect_class", + "promotion_state", + "public_claim_boundary", + "owner", + "reviewer", + "expires_at", + ): + if row.get(field_name) is not None: + manifest[field_name] = row.get(field_name) + return manifest + + +def _route_facts_from_row(row: dict[str, Any]) -> dict[str, Any]: + route = { + "route_id": row.get("route_id"), + "service_id": row.get("service_id"), + "provider_id": row.get("provider_id"), + "substrate": row.get("substrate"), + "provenance_origin": row.get("provenance_origin"), + "source_risk": row.get("source_risk"), + "manifest_id": row.get("manifest_id"), + "manifest_digest": row.get("manifest_digest"), + "manifest_version": row.get("manifest_version"), + "side_effect_class": row.get("side_effect_class"), + "public_claim_boundary": row.get("public_claim_boundary"), + "evidence_packet_id": row.get("evidence_packet_id"), + "evidence_packet_digest": row.get("evidence_packet_digest"), + "review_status": row.get("review_status"), + "promotion_state": row.get("promotion_state"), + "evidence_expires_at": row.get("evidence_expires_at"), + } + policy = route_recommendation_policy(route) + route["recommendation_policy"] = { + "default_recommendable": policy["default_recommendable"], + "recommendable": policy["recommendable"], + "requires_explicit_request": policy["requires_explicit_request"], + "blocked": policy["blocked"], + "reasons": policy["reasons"], + } + return {key: value for key, value in route.items() if value is not None} + + +def get_durable_index_manifest_store() -> DurableIndexManifestStore: + return DurableIndexManifestStore() diff --git a/packages/api/services/proxy_auth.py b/packages/api/services/proxy_auth.py index 212aeebc..8b759fd6 100644 --- a/packages/api/services/proxy_auth.py +++ b/packages/api/services/proxy_auth.py @@ -175,6 +175,60 @@ class AuthInjector: "name": "Authorization", "format": "Token {credential}", }, + "openai": { + "methods": ["api_key"], + "target": "header", + "name": "Authorization", + "format": "Bearer {credential}", + }, + "groq": { + "methods": ["api_key"], + "target": "header", + "name": "Authorization", + "format": "Bearer {credential}", + }, + "cohere": { + "methods": ["api_key"], + "target": "header", + "name": "Authorization", + "format": "Bearer {credential}", + }, + "elevenlabs": { + "methods": ["api_key"], + "target": "header", + "name": "xi-api-key", + "format": "{credential}", + }, + "resend": { + "methods": ["api_key"], + "target": "header", + "name": "Authorization", + "format": "Bearer {credential}", + }, + "postmark": { + "methods": ["api_key"], + "target": "header", + "name": "X-Postmark-Server-Token", + "format": "{credential}", + }, + "google-maps": { + "methods": ["api_key"], + "target": "query", + "name": "key", + "format": "{credential}", + }, + "google-places": { + "methods": ["api_key"], + "target": "query", + "name": "key", + "format": "{credential}", + }, + "perplexity": { + "methods": ["api_key"], + "target": "header", + "name": "Authorization", + "format": "Bearer {credential}", + }, } def __init__( @@ -232,8 +286,7 @@ def inject_request_parts(self, request: AuthInjectionRequest) -> AuthInjectionRe if service not in self.AUTH_PATTERNS: error = ValueError( - f"Service '{service}' not supported. " - f"Available: {', '.join(self.AUTH_PATTERNS)}" + f"Service '{service}' not supported. " f"Available: {', '.join(self.AUTH_PATTERNS)}" ) self._emit_credential_lifecycle( request, @@ -247,7 +300,7 @@ def inject_request_parts(self, request: AuthInjectionRequest) -> AuthInjectionRe allowed_methods: list[str] = pattern["methods"] # type: ignore[assignment] injection_name = pattern["name"] # type: ignore[assignment] if auth_method.value not in allowed_methods: - error = ValueError( + auth_error = ValueError( f"Auth method '{auth_method.value}' not supported for '{service}'. " f"Supported: {allowed_methods}" ) @@ -256,9 +309,9 @@ def inject_request_parts(self, request: AuthInjectionRequest) -> AuthInjectionRe event_type="credential_lookup_failed", outcome="error", header_name=injection_name, - error=error, + error=auth_error, ) - raise error + raise auth_error # Retrieve credential try: @@ -273,17 +326,15 @@ def inject_request_parts(self, request: AuthInjectionRequest) -> AuthInjectionRe ) raise if credential is None: - error = RuntimeError( - f"Credential not found for {service}/{auth_method.value}" - ) + missing_error = RuntimeError(f"Credential not found for {service}/{auth_method.value}") self._emit_credential_lifecycle( request, event_type="credential_missing", outcome="missing", header_name=injection_name, - error=error, + error=missing_error, ) - raise error + raise missing_error # For Twilio basic-auth the credential value is "account_sid:auth_token". # We must base64-encode it before injection. @@ -308,15 +359,17 @@ def inject_request_parts(self, request: AuthInjectionRequest) -> AuthInjectionRe body = {} body[injection_name] = formatted else: - error = ValueError(f"Unsupported auth injection target '{target}' for '{service}'") + target_error = ValueError( + f"Unsupported auth injection target '{target}' for '{service}'" + ) self._emit_credential_lifecycle( request, event_type="credential_lookup_failed", outcome="error", header_name=injection_name, - error=error, + error=target_error, ) - raise error + raise target_error # Audit self.credentials.audit_log(service, request.agent_id, "auth_injected") diff --git a/packages/api/services/proxy_credentials.py b/packages/api/services/proxy_credentials.py index ce67e5be..5406e872 100644 --- a/packages/api/services/proxy_credentials.py +++ b/packages/api/services/proxy_credentials.py @@ -10,9 +10,8 @@ import os import subprocess -import time from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Any, Dict, List, Optional @@ -84,6 +83,7 @@ def is_stale(self) -> bool: "brave_search_api_key": ("brave-search", "api_key"), "Replicate API Token": ("replicate", "api_token"), "replicate_api_token": ("replicate", "api_token"), + "Tester - Algolia": ("algolia", "api_key"), "algolia_api_key": ("algolia", "api_key"), "E2B API Key": ("e2b", "api_key"), "e2b_api_key": ("e2b", "api_key"), @@ -94,6 +94,15 @@ def is_stale(self) -> bool: "IPinfo API Token": ("ipinfo", "bearer_token"), "ScraperAPI API Key": ("scraperapi", "api_key"), "Deepgram API Key": ("deepgram", "api_key"), + "OpenAI API Key": ("openai", "api_key"), + "Groq API Key": ("groq", "api_key"), + "Cohere API Key": ("cohere", "api_key"), + "Tester - Cohere": ("cohere", "api_key"), + "ElevenLabs API Key": ("elevenlabs", "api_key"), + "Tester - Resend": ("resend", "api_key"), + "Postmark API Key": ("postmark", "api_key"), + "Google Places API Key": ("google-places", "api_key"), + "Perplexity API Key": ("perplexity", "api_key"), } # Environment variable fallback: RHUMB_CREDENTIAL__= @@ -117,9 +126,26 @@ def is_stale(self) -> bool: "e2b": ("RHUMB_CREDENTIAL_E2B_API_KEY", "api_key"), "unstructured": ("RHUMB_CREDENTIAL_UNSTRUCTURED_API_KEY", "api_key"), "google-ai": ("RHUMB_CREDENTIAL_GOOGLE_AI_API_KEY", "api_key"), - "ipinfo": ("RHUMB_CREDENTIAL_IPINFO_API_TOKEN", "bearer_token"), + "ipinfo": ("RHUMB_CREDENTIAL_IPINFO_TOKEN", "bearer_token"), "scraperapi": ("RHUMB_CREDENTIAL_SCRAPERAPI_API_KEY", "api_key"), "deepgram": ("RHUMB_CREDENTIAL_DEEPGRAM_API_KEY", "api_key"), + "openai": ("RHUMB_CREDENTIAL_OPENAI_API_KEY", "api_key"), + "groq": ("RHUMB_CREDENTIAL_GROQ_API_KEY", "api_key"), + "cohere": ("RHUMB_CREDENTIAL_COHERE_API_KEY", "api_key"), + "elevenlabs": ("RHUMB_CREDENTIAL_ELEVENLABS_API_KEY", "api_key"), + "resend": ("RHUMB_CREDENTIAL_RESEND_API_KEY", "api_key"), + "postmark": ("RHUMB_CREDENTIAL_POSTMARK_API_KEY", "api_key"), + "google-maps": ("RHUMB_CREDENTIAL_GOOGLE_PLACES_API_KEY", "api_key"), + "google-places": ("RHUMB_CREDENTIAL_GOOGLE_PLACES_API_KEY", "api_key"), + "perplexity": ("RHUMB_CREDENTIAL_PERPLEXITY_API_KEY", "api_key"), +} + +# Backward/forward-compatible aliases for env names that shipped in migrations +# or Railway before the proxy credential loader settled on one convention. +_ENV_FALLBACK_ALIASES: Dict[str, list[tuple[str, str]]] = { + # Keep the older loader convention working after migration 0083 standardized + # the hosted key name as RHUMB_CREDENTIAL_IPINFO_TOKEN. + "ipinfo": [("RHUMB_CREDENTIAL_IPINFO_API_TOKEN", "bearer_token")], } @@ -127,12 +153,36 @@ class CredentialStore: """Manage provider credentials with 1Password integration and in-memory cache.""" SUPPORTED_SERVICES: List[str] = [ - "stripe", "slack", "github", "twilio", "sendgrid", - "firecrawl", "apify", "apollo", "pdl", + "stripe", + "slack", + "github", + "twilio", + "sendgrid", + "firecrawl", + "apify", + "apollo", + "pdl", # Stateless utility APIs (Rhumb-managed, free-tier) - "tavily", "exa", "brave-search", "replicate", "algolia", "e2b", "unstructured", + "tavily", + "exa", + "brave-search", + "replicate", + "algolia", + "e2b", + "unstructured", "google-ai", - "ipinfo", "scraperapi", "deepgram", + "ipinfo", + "scraperapi", + "deepgram", + "openai", + "groq", + "cohere", + "elevenlabs", + "resend", + "postmark", + "google-maps", + "google-places", + "perplexity", ] def __init__(self, *, auto_load: bool = True) -> None: @@ -206,12 +256,21 @@ def _load_service(self, service: str) -> None: def _load_from_env(self, service: str) -> None: """Fallback: load credentials from environment variables.""" - fallback = _ENV_FALLBACK.get(service) - if fallback is None: + fallbacks = [] + primary = _ENV_FALLBACK.get(service) + if primary is not None: + fallbacks.append(primary) + fallbacks.extend(_ENV_FALLBACK_ALIASES.get(service, [])) + if not fallbacks: return - env_var, cred_key = fallback - value = os.environ.get(env_var, "").strip() + value = "" + cred_key = "" + for env_var, candidate_key in fallbacks: + value = os.environ.get(env_var, "").strip() + if value: + cred_key = candidate_key + break if not value: return @@ -229,6 +288,8 @@ def _load_from_env(self, service: str) -> None: @staticmethod def _item_names_for(service: str) -> list[str]: """Return candidate 1Password item names for a service, in priority order.""" + if service == "google-maps": + service = "google-places" return [item for item, (svc, _) in _1PASSWORD_MAP.items() if svc == service] # ------------------------------------------------------------------ @@ -264,6 +325,19 @@ def get_credential(self, service: str, key: str = "default") -> Optional[str]: self.set_credential(service, key, value) return value + fallback_candidates = [] + primary_fallback = _ENV_FALLBACK.get(service) + if primary_fallback is not None: + fallback_candidates.append(primary_fallback) + fallback_candidates.extend(_ENV_FALLBACK_ALIASES.get(service, [])) + for fallback_env_var, fallback_key in fallback_candidates: + if fallback_key != key: + continue + value = os.environ.get(fallback_env_var, "").strip() + if value: + self.set_credential(service, key, value) + return value + return None def callable_services(self) -> list[str]: @@ -278,9 +352,7 @@ def callable_services(self) -> list[str]: result = [] for service in self.SUPPORTED_SERVICES: provider = self._cache.get(service) - if provider and any( - not entry.is_expired() for entry in provider.credentials.values() - ): + if provider and any(not entry.is_expired() for entry in provider.credentials.values()): result.append(service) return result diff --git a/packages/api/services/receipt_service.py b/packages/api/services/receipt_service.py index bdb523b8..d42984e1 100644 --- a/packages/api/services/receipt_service.py +++ b/packages/api/services/receipt_service.py @@ -23,12 +23,15 @@ import json import logging import re -import time from dataclasses import dataclass, field -from typing import Any, Optional +from typing import Any from routes._supabase import supabase_fetch, supabase_insert, supabase_patch -from services.service_slugs import CANONICAL_TO_PROXY, public_service_slug, public_service_slug_candidates +from services.service_slugs import ( + CANONICAL_TO_PROXY, + public_service_slug, + public_service_slug_candidates, +) logger = logging.getLogger(__name__) @@ -97,7 +100,6 @@ def _canonicalize_provider_text_from_contexts( return canonicalized - def _canonicalize_provider_message_text_from_contexts( text: Any, provider_slugs: list[Any], @@ -137,6 +139,7 @@ def _public_receipt_row(row: dict[str, Any] | None) -> dict[str, Any] | None: def _generate_receipt_id() -> str: """Generate a receipt ID with the rcpt_ prefix.""" import uuid + return f"rcpt_{uuid.uuid4().hex[:24]}" @@ -184,6 +187,18 @@ def _compute_receipt_hash(receipt_data: dict[str, Any]) -> str: "router_version", "candidates_evaluated", "winner_reason", + "route_id", + "service_id", + "substrate", + "provenance_origin", + "source_risk", + "manifest_digest", + "evidence_packet_digest", + "route_plan_id_hash", + "route_explanation_id", + "stop_condition", + "retryable", + "next_recommended_action", "total_latency_ms", "rhumb_overhead_ms", "provider_latency_ms", @@ -241,6 +256,18 @@ class ReceiptInput: router_version: str | None = None candidates_evaluated: int | None = None winner_reason: str | None = None + route_id: str | None = None + service_id: str | None = None + substrate: str | None = None + provenance_origin: str | None = None + source_risk: str | None = None + manifest_digest: str | None = None + evidence_packet_digest: str | None = None + route_plan_id_hash: str | None = None + route_explanation_id: str | None = None + stop_condition: str | None = None + retryable: bool | None = None + next_recommended_action: str | None = None # Timing total_latency_ms: float | None = None @@ -340,6 +367,18 @@ async def create_receipt(self, input: ReceiptInput) -> Receipt: input.winner_reason, provider_text_contexts, ), + "route_id": input.route_id, + "service_id": input.service_id, + "substrate": input.substrate, + "provenance_origin": input.provenance_origin, + "source_risk": input.source_risk, + "manifest_digest": input.manifest_digest, + "evidence_packet_digest": input.evidence_packet_digest, + "route_plan_id_hash": input.route_plan_id_hash, + "route_explanation_id": input.route_explanation_id, + "stop_condition": input.stop_condition, + "retryable": input.retryable, + "next_recommended_action": input.next_recommended_action, "total_latency_ms": input.total_latency_ms, "rhumb_overhead_ms": input.rhumb_overhead_ms, "provider_latency_ms": input.provider_latency_ms, @@ -370,9 +409,9 @@ async def create_receipt(self, input: ReceiptInput) -> Receipt: receipt_data["receipt_hash"] = receipt_hash # Persist (append-only: INSERT only, never UPDATE) - await supabase_insert("execution_receipts", { - k: v for k, v in receipt_data.items() if v is not None - }) + await supabase_insert( + "execution_receipts", {k: v for k, v in receipt_data.items() if v is not None} + ) # Update chain state with the new receipt hash await self._update_chain_hash(chain_sequence, receipt_hash) @@ -425,9 +464,7 @@ async def create_receipt(self, input: ReceiptInput) -> Receipt: async def get_receipt(self, receipt_id: str) -> dict[str, Any] | None: """Fetch a receipt by ID.""" - rows = await supabase_fetch( - f"execution_receipts?receipt_id=eq.{receipt_id}&limit=1" - ) + rows = await supabase_fetch(f"execution_receipts?receipt_id=eq.{receipt_id}&limit=1") if not rows: return None return _public_receipt_row(rows[0]) @@ -477,7 +514,7 @@ async def query_receipts( f"&limit={limit}&offset={offset}" ) rows = await supabase_fetch(query) or [] - return [_public_receipt_row(row) for row in rows] + return [_public_receipt_row(row) for row in rows if isinstance(row, dict)] async def verify_chain( self, @@ -515,12 +552,14 @@ async def verify_chain( expected_prev_hash = prev_row.get("receipt_hash") actual_prev_hash = row.get("previous_receipt_hash") if expected_prev_hash != actual_prev_hash: - broken_links.append({ - "receipt_id": row["receipt_id"], - "chain_sequence": row["chain_sequence"], - "expected_previous_hash": expected_prev_hash, - "actual_previous_hash": actual_prev_hash, - }) + broken_links.append( + { + "receipt_id": row["receipt_id"], + "chain_sequence": row["chain_sequence"], + "expected_previous_hash": expected_prev_hash, + "actual_previous_hash": actual_prev_hash, + } + ) else: verified += 1 @@ -547,11 +586,14 @@ async def _advance_chain_state(self) -> dict[str, Any]: ) if not rows: # Seed the chain state - await supabase_insert("receipt_chain_state", { - "id": 1, - "last_sequence": 0, - "last_receipt_hash": None, - }) + await supabase_insert( + "receipt_chain_state", + { + "id": 1, + "last_sequence": 0, + "last_receipt_hash": None, + }, + ) current_sequence = 0 current_hash = None else: @@ -599,6 +641,7 @@ async def _update_chain_hash(self, sequence: int, receipt_hash: str) -> None: @staticmethod def _now_iso() -> str: from datetime import datetime, timezone + return datetime.now(timezone.utc).isoformat() diff --git a/packages/api/services/route_explanation.py b/packages/api/services/route_explanation.py index fbe277d1..6425c774 100644 --- a/packages/api/services/route_explanation.py +++ b/packages/api/services/route_explanation.py @@ -19,7 +19,7 @@ import time from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any from urllib.parse import quote from routes._supabase import supabase_fetch, supabase_insert @@ -29,6 +29,8 @@ normalize_proxy_slug, public_service_slug_candidates, ) +from services.index_manifest_store import get_index_manifest_store +from schemas.route_taxonomy import route_recommendation_policy logger = logging.getLogger(__name__) @@ -46,6 +48,7 @@ # Data structures # --------------------------------------------------------------------------- + @dataclass class CandidateFactor: """A single scoring factor for a candidate provider.""" @@ -77,6 +80,7 @@ class CandidateExplanation: composite_score: float factors: dict[str, CandidateFactor] = field(default_factory=dict) policy_checks: dict[str, bool] = field(default_factory=dict) + route_facts: dict[str, Any] = field(default_factory=dict) ineligible_reason: str | None = None def to_dict(self) -> dict[str, Any]: @@ -87,6 +91,8 @@ def to_dict(self) -> dict[str, Any]: "factors": {k: v.to_dict() for k, v in self.factors.items()}, "policy_checks": self.policy_checks, } + if self.route_facts: + d["route_facts"] = self.route_facts if self.ineligible_reason: d["ineligible_reason"] = self.ineligible_reason return d @@ -150,6 +156,7 @@ def to_compact(self) -> dict[str, Any]: # ID generation # --------------------------------------------------------------------------- + def _generate_explanation_id(capability_id: str, timestamp_ms: int) -> str: """Generate a deterministic explanation ID.""" raw = f"{capability_id}:{timestamp_ms}" @@ -161,6 +168,7 @@ def _generate_explanation_id(capability_id: str, timestamp_ms: int) -> str: # Explanation builder # --------------------------------------------------------------------------- + def _normalize_slug(slug: str | None) -> str | None: if slug is None: return None @@ -191,6 +199,53 @@ def _public_provider_id(slug: str | None) -> str | None: return canonicalize_service_slug(cleaned) +def _route_facts_for_mapping(capability_id: str, mapping: dict[str, Any]) -> dict[str, Any]: + provider_id = str( + mapping.get("provider_id") + or mapping.get("service_slug") + or mapping.get("provider") + or "unknown" + ).strip() + stored_route = get_index_manifest_store().route_facts_for_provider(capability_id, provider_id) + if stored_route: + return stored_route + + route: dict[str, Any] = {} + for field_name in ( + "route_id", + "service_id", + "provider_id", + "substrate", + "provenance_origin", + "source_risk", + "manifest_id", + "manifest_digest", + "manifest_version", + "side_effect_class", + "public_claim_boundary", + "evidence_packet_id", + "evidence_packet_digest", + "review_status", + "promotion_state", + "evidence_expires_at", + ): + if mapping.get(field_name) is not None: + route[field_name] = mapping.get(field_name) + + if not route: + return {} + + policy = route_recommendation_policy(route) + route["recommendation_policy"] = { + "default_recommendable": policy["default_recommendable"], + "recommendable": policy["recommendable"], + "requires_explicit_request": policy["requires_explicit_request"], + "blocked": policy["blocked"], + "reasons": policy["reasons"], + } + return {key: value for key, value in route.items() if value is not None} + + def _canonicalize_provider_text(text: str, provider_ids: list[str] | None) -> str: canonicalized = text or "" replacements: dict[str, str] = {} @@ -253,9 +308,7 @@ def build_explanation( # Find max cost for normalization costs = [ - float(m.get("cost_per_call") or 0) - for m in mappings - if m.get("cost_per_call") is not None + float(m.get("cost_per_call") or 0) for m in mappings if m.get("cost_per_call") is not None ] max_cost = max(costs) if costs else 1.0 if max_cost == 0: @@ -274,14 +327,10 @@ def build_explanation( checks: dict[str, bool] = { "pinned": normalized_policy_pin == slug if normalized_policy_pin else False, "denied": slug in deny_set, - "cost_ceiling_ok": ( - cost <= max_cost_usd if max_cost_usd is not None else True - ), + "cost_ceiling_ok": (cost <= max_cost_usd if max_cost_usd is not None else True), "quality_floor_ok": an_score >= quality_floor, "circuit_healthy": circuit != "open", - "allow_list_ok": ( - slug in allow_set if allow_set else True - ), + "allow_list_ok": (slug in allow_set if allow_set else True), } # Determine eligibility @@ -354,6 +403,7 @@ def build_explanation( composite_score=composite, factors=factors, policy_checks=checks, + route_facts=_route_facts_for_mapping(capability_id, m), ineligible_reason=ineligible_reason, ) candidates.append(candidate) @@ -390,9 +440,7 @@ def build_explanation( explanation_id=explanation_id, capability_id=capability_id, winner_provider_id=normalized_selected_provider, - winner_composite_score=( - winner_candidate.composite_score if winner_candidate else None - ), + winner_composite_score=(winner_candidate.composite_score if winner_candidate else None), selection_reason=selection_reason, candidates=candidates, human_summary=human_summary, @@ -433,6 +481,7 @@ def build_layer1_explanation( # Human summary # --------------------------------------------------------------------------- + def _build_human_summary( *, selected_provider: str | None, @@ -474,10 +523,7 @@ def _build_human_summary( winner.factors.values(), key=lambda f: -f.weighted_contribution, )[:2] - factor_strs = [ - f"{f.name} ({f.weighted_contribution:.3f})" - for f in top_factors - ] + factor_strs = [f"{f.name} ({f.weighted_contribution:.3f})" for f in top_factors] parts.append(f"Strongest factors: {', '.join(factor_strs)}.") # Exclusions @@ -516,7 +562,7 @@ def store_explanation(explanation: RouteExplanation) -> None: _explanation_store.keys(), key=lambda k: _explanation_store[k].created_at_ms, ) - for k in sorted_keys[:_MAX_STORED // 2]: + for k in sorted_keys[: _MAX_STORED // 2]: del _explanation_store[k] _explanation_store[explanation.explanation_id] = explanation @@ -561,13 +607,24 @@ def _row_to_explanation(row: dict[str, Any]) -> RouteExplanation: normalized_score=float(factor.get("normalized_score") or 0.0), weight=float(factor.get("weight") or 0.0), ) + policy_checks: dict[str, bool] = ( + candidate.get("policy_checks") + if isinstance(candidate.get("policy_checks"), dict) + else {} + ) + route_facts: dict[str, Any] = ( + candidate.get("route_facts") + if isinstance(candidate.get("route_facts"), dict) + else {} + ) candidates.append( CandidateExplanation( provider_id=_public_provider_id(raw_provider_id) or "unknown", eligible=bool(candidate.get("eligible", False)), composite_score=float(candidate.get("composite_score") or 0.0), factors=factor_objs, - policy_checks=(candidate.get("policy_checks") if isinstance(candidate.get("policy_checks"), dict) else {}), + policy_checks=policy_checks, + route_facts=route_facts, ineligible_reason=candidate.get("ineligible_reason"), ) ) @@ -577,7 +634,11 @@ def _row_to_explanation(row: dict[str, Any]) -> RouteExplanation: explanation_id=str(row.get("explanation_id") or ""), capability_id=str(row.get("capability_id") or "unknown"), winner_provider_id=_public_provider_id(raw_winner_provider_id), - winner_composite_score=(float(row["winner_composite_score"]) if row.get("winner_composite_score") is not None else None), + winner_composite_score=( + float(row["winner_composite_score"]) + if row.get("winner_composite_score") is not None + else None + ), selection_reason=str(row.get("winner_reason") or "persisted_route_explanation"), candidates=candidates, human_summary=_canonicalize_provider_text( diff --git a/packages/api/services/route_plan_enforcement.py b/packages/api/services/route_plan_enforcement.py new file mode 100644 index 00000000..9eeedc3f --- /dev/null +++ b/packages/api/services/route_plan_enforcement.py @@ -0,0 +1,357 @@ +"""Pure-Python HMAC helper for Resolve-issued Runtime route plans.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import secrets +import time +from datetime import datetime +from typing import Any + +from schemas.resolve_boundary_contract import ROUTE_PLAN_ENFORCEMENT_CHECKS + +TOKEN_PREFIX = "rhrp1" +ROUTE_PLAN_VERSION = 1 + +REQUIRED_PAYLOAD_FIELDS = ( + "version", + "route_plan_id", + "nonce", + "org_id", + "agent_id", + "principal_id", + "auth_rail", + "capability_id", + "service_id", + "provider_id", + "route_id", + "credential_mode", + "required_scopes", + "input_hash", + "manifest_digest", + "policy_snapshot_digest", + "budget_snapshot_digest", + "artifact_hashes", + "issued_at", + "expires_at", +) + +OPTIONAL_MATCH_FIELDS = ( + "credential_handle_id", + "evidence_packet_digest", + "sandbox_profile_id", +) + +SUPPLEMENTAL_CHECKS = ( + "version_matches", + "route_plan_id_present", + "nonce_present", + "auth_rail_matches", + "capability_id_matches", + "service_id_matches", + "provider_id_matches", + "credential_mode_matches", + "required_scopes_covered", + "sandbox_profile_matches", + "artifact_hashes_match", +) + +EXPECTED_CHECK_FIELDS = ( + ("org_id", "org_matches"), + ("agent_id", "agent_matches"), + ("auth_rail", "auth_rail_matches"), + ("capability_id", "capability_id_matches"), + ("service_id", "service_id_matches"), + ("provider_id", "provider_id_matches"), + ("route_id", "route_id_matches"), + ("credential_mode", "credential_mode_matches"), + ("credential_handle_id", "credential_handle_matches"), + ("input_hash", "input_hash_matches"), + ("manifest_digest", "manifest_digest_matches"), + ("evidence_packet_digest", "evidence_packet_digest_matches"), + ("policy_snapshot_digest", "policy_snapshot_current_or_allowed"), + ("budget_snapshot_digest", "budget_still_allowed"), + ("sandbox_profile_id", "sandbox_profile_matches"), + ("artifact_hashes", "artifact_hashes_match"), +) + + +def canonical_json_sha256(value: Any) -> str: + """Return a stable sha256 digest for JSON-compatible input values.""" + + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def issue_route_plan( + payload: dict[str, Any], + secret: str | bytes, + now: int | float | datetime | None = None, + ttl_seconds: int = 300, +) -> str: + """Issue an opaque signed route-plan token.""" + + if ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive") + + issued_at = _unix_seconds(now) + route_plan = dict(payload) + route_plan.setdefault("version", ROUTE_PLAN_VERSION) + route_plan.setdefault("route_plan_id", "rp_" + secrets.token_urlsafe(18)) + route_plan.setdefault("nonce", route_plan["route_plan_id"]) + route_plan.setdefault("issued_at", issued_at) + route_plan.setdefault("expires_at", int(route_plan["issued_at"]) + ttl_seconds) + + missing = _missing_payload_fields(route_plan) + if missing: + raise ValueError(f"route plan payload missing required fields: {', '.join(missing)}") + if not isinstance(route_plan.get("required_scopes"), list): + raise ValueError("required_scopes must be a list") + if not isinstance(route_plan.get("artifact_hashes"), list): + raise ValueError("artifact_hashes must be a list") + + payload_segment = _b64url_encode(_canonical_json_bytes(route_plan)) + signature_segment = _signature_segment(payload_segment, secret) + return f"{TOKEN_PREFIX}.{payload_segment}.{signature_segment}" + + +def validate_route_plan( + token: str | None, + expected: dict[str, Any], + secret: str | bytes, + now: int | float | datetime | None = None, + revoked_nonces: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None, + seen_nonces: set[str] | frozenset[str] | list[str] | tuple[str, ...] | None = None, +) -> dict[str, Any]: + """Validate a signed route plan against expected Runtime boundary facts.""" + + checks = _blank_checks() + if not token: + return _result(False, "route_plan_missing", checks) + + parts = token.split(".") + if len(parts) != 3 or parts[0] != TOKEN_PREFIX or not parts[1] or not parts[2]: + return _result(False, "route_plan_missing", checks) + + payload_segment, signature_segment = parts[1], parts[2] + try: + expected_signature_segment = _signature_segment(payload_segment, secret) + except (TypeError, ValueError): + return _result(False, "route_plan_signature_invalid", checks) + + if not hmac.compare_digest(signature_segment, expected_signature_segment): + return _result(False, "route_plan_signature_invalid", checks) + checks["signature_valid"] = True + + try: + payload = json.loads(_b64url_decode(payload_segment).decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return _result(False, "route_plan_signature_invalid", checks) + if not isinstance(payload, dict): + return _result(False, "route_plan_missing", checks) + + if _missing_payload_fields(payload): + return _result(False, "route_plan_missing", checks) + if not isinstance(payload.get("required_scopes"), list): + return _result(False, "route_plan_missing", checks) + if not isinstance(payload.get("artifact_hashes"), list): + return _result(False, "route_plan_missing", checks) + + checks["version_matches"] = payload.get("version") == ROUTE_PLAN_VERSION + checks["route_plan_id_present"] = bool(payload.get("route_plan_id")) + checks["nonce_present"] = bool(payload.get("nonce")) + if not ( + checks["version_matches"] and checks["route_plan_id_present"] and checks["nonce_present"] + ): + return _result(False, "route_plan_mismatch", checks) + + try: + expires_at = int(payload["expires_at"]) + except (TypeError, ValueError): + return _result(False, "route_plan_missing", checks) + checks["not_expired"] = _unix_seconds(now) < expires_at + if not checks["not_expired"]: + return _result(False, "route_plan_expired", checks) + + nonce = str(payload["nonce"]) + revoked = set(revoked_nonces or ()) + checks["not_revoked"] = nonce not in revoked + if not checks["not_revoked"]: + return _result(False, "route_plan_revoked", checks) + + seen = set(seen_nonces or ()) + checks["replay_not_seen"] = nonce not in seen + if not checks["replay_not_seen"]: + return _result(False, "route_plan_replay", checks) + + expected = expected if isinstance(expected, dict) else {} + checks["principal_matches"] = payload.get("principal_id") == expected.get("principal_id") + if not checks["principal_matches"]: + return _result(False, "route_plan_principal_mismatch", checks) + + for field, check_name in EXPECTED_CHECK_FIELDS: + checks[check_name] = _field_matches(payload, expected, field) + + checks["required_scopes_covered"] = _required_scopes_covered(payload, expected) + if not checks["required_scopes_covered"]: + return _result(False, "credential_scope_mismatch", checks) + + checks["kill_switch_allows_execution"] = _kill_switch_allows_execution(expected) + + mismatch_checks = [ + check_name for _, check_name in EXPECTED_CHECK_FIELDS if checks.get(check_name) is not True + ] + if mismatch_checks or checks["kill_switch_allows_execution"] is not True: + return _result(False, "route_plan_mismatch", checks) + + _remember_nonce(seen_nonces, nonce) + result = _result(True, None, checks) + result.update( + { + "nonce": nonce, + "route_plan_id": str(payload["route_plan_id"]), + "expires_at": expires_at, + } + ) + return result + + +def _blank_checks() -> dict[str, bool]: + checks = {name: False for name in ROUTE_PLAN_ENFORCEMENT_CHECKS} + checks.update({name: False for name in SUPPLEMENTAL_CHECKS}) + return checks + + +def _result(allowed: bool, stop_condition: str | None, checks: dict[str, bool]) -> dict[str, Any]: + return { + "allowed": allowed, + "stop_condition": stop_condition, + "checks": checks, + } + + +def _canonical_json_bytes(value: Any) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + + +def _unix_seconds(value: int | float | datetime | None) -> int: + if value is None: + return int(time.time()) + if isinstance(value, datetime): + return int(value.timestamp()) + return int(value) + + +def _missing_payload_fields(payload: dict[str, Any]) -> list[str]: + return [field for field in REQUIRED_PAYLOAD_FIELDS if payload.get(field) is None] + + +def _field_matches(payload: dict[str, Any], expected: dict[str, Any], field: str) -> bool: + if ( + field in OPTIONAL_MATCH_FIELDS + and payload.get(field) is None + and expected.get(field) is None + ): + return True + if field == "policy_snapshot_digest": + return _digest_matches(payload, expected, field, "allowed_policy_snapshot_digests") + if field == "budget_snapshot_digest": + return _digest_matches(payload, expected, field, "allowed_budget_snapshot_digests") + if field == "evidence_packet_digest": + return _digest_matches(payload, expected, field, "allowed_evidence_packet_digests") + return field in expected and payload.get(field) == expected.get(field) + + +def _digest_matches( + payload: dict[str, Any], expected: dict[str, Any], field: str, allowed_field: str +) -> bool: + if ( + field in OPTIONAL_MATCH_FIELDS + and payload.get(field) is None + and expected.get(field) is None + ): + return True + if field in expected: + return payload.get(field) == expected.get(field) + allowed = expected.get(allowed_field) + if isinstance(allowed, (list, tuple, set, frozenset)): + return payload.get(field) in set(allowed) + return False + + +def _required_scopes_covered(payload: dict[str, Any], expected: dict[str, Any]) -> bool: + required_scopes = payload.get("required_scopes") + if not isinstance(required_scopes, list) or not all( + isinstance(scope, str) for scope in required_scopes + ): + return False + required = set(required_scopes) + if not required: + return True + + for field in ("granted_scopes", "credential_scopes", "available_scopes", "allowed_scopes"): + scopes = expected.get(field) + if isinstance(scopes, (list, tuple, set, frozenset)): + return required.issubset(set(scopes)) + + expected_required = expected.get("required_scopes") + if isinstance(expected_required, (list, tuple, set, frozenset)): + return required == set(expected_required) + return False + + +def _kill_switch_allows_execution(expected: dict[str, Any]) -> bool: + if expected.get("kill_switch_active") is True: + return False + if expected.get("execution_disabled") is True: + return False + return expected.get("kill_switch_allows_execution", True) is True + + +def _remember_nonce(seen_nonces: Any, nonce: str) -> None: + add = getattr(seen_nonces, "add", None) + if callable(add): + add(nonce) + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(data: str) -> bytes: + padded = data + "=" * (-len(data) % 4) + return base64.urlsafe_b64decode(padded.encode("ascii")) + + +def _signature_segment(payload_segment: str, secret: str | bytes) -> str: + digest = hmac.new( + _secret_bytes(secret), payload_segment.encode("ascii"), hashlib.sha256 + ).digest() + return _b64url_encode(digest) + + +def _secret_bytes(secret: str | bytes) -> bytes: + if isinstance(secret, str): + secret_bytes = secret.encode("utf-8") + elif isinstance(secret, bytes): + secret_bytes = secret + else: + raise TypeError("secret must be str or bytes") + if not secret_bytes: + raise ValueError("secret must be non-empty") + return secret_bytes diff --git a/packages/api/services/route_plan_state.py b/packages/api/services/route_plan_state.py new file mode 100644 index 00000000..8d1fb317 --- /dev/null +++ b/packages/api/services/route_plan_state.py @@ -0,0 +1,262 @@ +"""PP-16 durable state for Resolve route-plan enforcement. + +This module keeps route-plan nonce state out of the signed token helper so the +cryptographic verifier remains pure while Runtime can still make one-time-use, +revocation, and kill-switch decisions against shared state. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +from services.route_plan_enforcement import canonical_json_sha256 + +logger = logging.getLogger(__name__) + + +class RoutePlanStateUnavailable(RuntimeError): + """Raised when route-plan durable state is required but unavailable.""" + + +@dataclass(frozen=True, slots=True) +class RoutePlanClaim: + """Outcome of checking and claiming a route-plan nonce.""" + + allowed: bool + stop_condition: str | None + state_backend: str + nonce_hash: str | None = None + detail: str = "" + + +class RoutePlanStateStore: + """Database-backed route-plan nonce/revocation state. + + Nonces are persisted as deterministic hashes so raw opaque-token internals + do not need to be stored or surfaced. INSERT uniqueness provides the replay + guard; explicit ``revoked`` rows provide operator invalidation. + """ + + def __init__( + self, + supabase_client: Any, + *, + cleanup_interval_seconds: int = 3600, + ) -> None: + self._db = supabase_client + self._cleanup_interval = cleanup_interval_seconds + self._last_cleanup = 0.0 + self._fallback: dict[str, float] = {} + self._fallback_revoked: set[str] = set() + + async def check_and_claim( + self, + *, + nonce: str | None, + route_plan_id_hash: str | None, + expires_at: int | float | datetime | None, + allow_fallback: bool = True, + ) -> RoutePlanClaim: + """Atomically claim a nonce or return a fail-closed replay/revocation stop.""" + + nonce_hash = _nonce_hash(nonce) + if nonce_hash is None: + return RoutePlanClaim( + allowed=False, + stop_condition="route_plan_missing", + state_backend="none", + detail="Route plan validation did not expose a nonce.", + ) + + try: + return await self._db_check_and_claim( + nonce_hash=nonce_hash, + route_plan_id_hash=route_plan_id_hash, + expires_at=_as_datetime(expires_at), + ) + except Exception as exc: + if not allow_fallback: + logger.error( + "route_plan_state_unavailable nonce=%s fail_closed=true", + nonce_hash[:24], + exc_info=True, + ) + raise RoutePlanStateUnavailable("Durable route-plan state unavailable") from exc + logger.warning( + "route_plan_state_db_failed nonce=%s; using memory fallback", + nonce_hash[:24], + exc_info=True, + ) + return self._fallback_check_and_claim(nonce_hash, expires_at=_as_datetime(expires_at)) + + async def revoke_nonce( + self, + nonce: str, + *, + reason: str = "revoked", + route_plan_id_hash: str | None = None, + ) -> RoutePlanClaim: + """Persistently mark a route-plan nonce as revoked.""" + + nonce_hash = _nonce_hash(nonce) + if nonce_hash is None: + return RoutePlanClaim(False, "route_plan_missing", "none") + now = datetime.now(timezone.utc) + row = { + "nonce_hash": nonce_hash, + "route_plan_id_hash": route_plan_id_hash, + "state": "revoked", + "claimed_at": now.isoformat(), + "expires_at": (now + timedelta(days=7)).isoformat(), + "revoked_at": now.isoformat(), + "revocation_reason": reason, + } + try: + await self._db.table("route_plan_state").upsert(row).execute() + return RoutePlanClaim(True, None, "database", nonce_hash=nonce_hash) + except Exception: + self._fallback_revoked.add(nonce_hash) + logger.warning("route_plan_revoke_db_failed nonce=%s", nonce_hash[:24], exc_info=True) + return RoutePlanClaim(True, None, "memory_fallback", nonce_hash=nonce_hash) + + async def _db_check_and_claim( + self, + *, + nonce_hash: str, + route_plan_id_hash: str | None, + expires_at: datetime, + ) -> RoutePlanClaim: + mono_now = time.monotonic() + if mono_now - self._last_cleanup > self._cleanup_interval: + await self._cleanup_expired() + self._last_cleanup = mono_now + + existing = ( + await self._db.table("route_plan_state") + .select("nonce_hash,state,revoked_at,revocation_reason") + .eq("nonce_hash", nonce_hash) + .maybe_single() + .execute() + ) + if existing.data: + state = str(existing.data.get("state") or "claimed") + if state == "revoked" or existing.data.get("revoked_at") is not None: + return RoutePlanClaim( + allowed=False, + stop_condition="route_plan_revoked", + state_backend="database", + nonce_hash=nonce_hash, + detail=str(existing.data.get("revocation_reason") or "Route plan revoked."), + ) + return RoutePlanClaim( + allowed=False, + stop_condition="route_plan_replay", + state_backend="database", + nonce_hash=nonce_hash, + detail="Route plan nonce was already claimed.", + ) + + row = { + "nonce_hash": nonce_hash, + "route_plan_id_hash": route_plan_id_hash, + "state": "claimed", + "claimed_at": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at.isoformat(), + } + try: + await self._db.table("route_plan_state").insert(row).execute() + except Exception as exc: + err = str(exc).lower() + if "duplicate" in err or "unique" in err or "23505" in err: + return RoutePlanClaim( + allowed=False, + stop_condition="route_plan_replay", + state_backend="database", + nonce_hash=nonce_hash, + detail="Route plan nonce was concurrently claimed.", + ) + raise + + return RoutePlanClaim(True, None, "database", nonce_hash=nonce_hash) + + def _fallback_check_and_claim(self, nonce_hash: str, *, expires_at: datetime) -> RoutePlanClaim: + now = time.time() + stale = [key for key, expiry in self._fallback.items() if expiry <= now] + for key in stale: + self._fallback.pop(key, None) + + if nonce_hash in self._fallback_revoked: + return RoutePlanClaim( + False, "route_plan_revoked", "memory_fallback", nonce_hash=nonce_hash + ) + if nonce_hash in self._fallback: + return RoutePlanClaim( + False, "route_plan_replay", "memory_fallback", nonce_hash=nonce_hash + ) + + self._fallback[nonce_hash] = expires_at.timestamp() + return RoutePlanClaim(True, None, "memory_fallback", nonce_hash=nonce_hash) + + async def _cleanup_expired(self) -> None: + try: + cutoff = datetime.now(timezone.utc).isoformat() + await self._db.table("route_plan_state").delete().lt("expires_at", cutoff).execute() + except Exception: + logger.warning("route_plan_state_cleanup_failed", exc_info=True) + + +_route_plan_state_store: RoutePlanStateStore | None = None + + +async def init_route_plan_state_store( + supabase_client: Any | None = None, +) -> RoutePlanStateStore: + """Return the module-level route-plan state store.""" + + global _route_plan_state_store + if _route_plan_state_store is not None: + return _route_plan_state_store + + if supabase_client is None: + try: + from db.client import get_supabase_client + + supabase_client = await get_supabase_client() + except Exception: + logger.warning( + "route_plan_state_init_failed; using in-memory fallback", + exc_info=True, + ) + supabase_client = _UnavailableSupabase() + _route_plan_state_store = RoutePlanStateStore(supabase_client) + return _route_plan_state_store + + +def reset_route_plan_state_store_for_tests() -> None: + """Clear the module singleton for focused tests.""" + + global _route_plan_state_store + _route_plan_state_store = None + + +def _nonce_hash(nonce: str | None) -> str | None: + if not nonce: + return None + return canonical_json_sha256({"route_plan_nonce": str(nonce)}) + + +def _as_datetime(value: int | float | datetime | None) -> datetime: + if value is None: + return datetime.now(timezone.utc) + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return datetime.fromtimestamp(float(value), tz=timezone.utc) + + +class _UnavailableSupabase: + def table(self, name: str) -> Any: + raise RoutePlanStateUnavailable("Supabase route-plan state store unavailable") diff --git a/packages/api/tests/test_api_docs_first_call.py b/packages/api/tests/test_api_docs_first_call.py new file mode 100644 index 00000000..11e058a2 --- /dev/null +++ b/packages/api/tests/test_api_docs_first_call.py @@ -0,0 +1,27 @@ +"""Contract checks for PP-9 first-call quickstart docs.""" + +from __future__ import annotations + +from pathlib import Path + + +DOCS_API = Path(__file__).resolve().parents[3] / "docs" / "API.md" + + +def test_api_docs_include_pp9_first_call_verify_flow(): + text = DOCS_API.read_text(encoding="utf-8") + + assert "## Resolve v2 first-call proof: `search.query`" in text + assert "search -> resolve -> estimate -> execute -> receipt -> verify" in text + assert "-X GET" in text + assert "\"$RHUMB_API_BASE/v2/capabilities?search=web%20research&limit=5\"" in text + assert "\"$RHUMB_API_BASE/v2/capabilities/search.query/resolve?credential_mode=rhumb_managed\"" in text + assert "\"$RHUMB_API_BASE/v2/capabilities/search.query/execute/estimate?provider=brave-search-api&credential_mode=rhumb_managed\"" in text + assert "-X POST" in text + assert "\"$RHUMB_API_BASE/v2/capabilities/search.query/execute\"" in text + assert "\"$RHUMB_API_BASE/v2/receipts/$RECEIPT_ID/verify\"" in text + assert "verifier_status" in text + assert "missing_credentials" in text + assert "invalid_rhumb_key" in text + assert "payment_required" in text + assert "scripts/resolve_v2_dogfood.py" in text diff --git a/packages/api/tests/test_capability_manifest.py b/packages/api/tests/test_capability_manifest.py new file mode 100644 index 00000000..33daa039 --- /dev/null +++ b/packages/api/tests/test_capability_manifest.py @@ -0,0 +1,98 @@ +"""PP-2 command-level capability manifest schema tests.""" + +from __future__ import annotations + +from copy import deepcopy + +from schemas.capability_manifest import ( + capability_manifest_digest, + command_manifest_fixtures, + fixture_manifests_by_route_id, + lint_capability_manifest, +) + + +def test_command_manifest_fixtures_cover_managed_and_non_native_substrates() -> None: + manifests = command_manifest_fixtures() + assert len(manifests) == 9 + assert all(lint_capability_manifest(manifest) == [] for manifest in manifests) + + by_capability = {manifest["capability_id"]: manifest for manifest in manifests} + for capability_id in { + "search.query", + "email.verify", + "crm.contact.lookup", + "support.ticket.create", + "warehouse.query", + }: + assert by_capability[capability_id]["substrate"] == "official_api" + assert by_capability[capability_id]["auth_mode"] == "rhumb_managed" + + substrates = {manifest["substrate"] for manifest in manifests} + assert {"official_cli", "official_mcp", "sdk_code_mode", "generated_adapter"}.issubset( + substrates + ) + + +def test_command_manifest_digest_is_deterministic_and_route_index_is_stable() -> None: + manifests = command_manifest_fixtures() + first = manifests[0] + shuffled = { + "manifest_digest": "sha256:old", + **{key: first[key] for key in reversed(first.keys()) if key != "manifest_digest"}, + } + + assert capability_manifest_digest(first) == capability_manifest_digest(shuffled) + assert first["manifest_digest"] == capability_manifest_digest(first) + + by_route = fixture_manifests_by_route_id() + assert by_route[first["route_id"]]["manifest_id"] == first["manifest_id"] + + +def test_manifest_linter_fails_closed_on_missing_allowlists_evidence_expiry_and_digest() -> None: + manifest = command_manifest_fixtures()[0] + broken = deepcopy(manifest) + broken.pop("owner") + broken["network_allowlist"] = [] + broken["tests"] = [] + broken["evidence_refs"] = [] + broken["expires_at"] = "not-a-time" + broken["manifest_digest"] = "sha256:not-the-digest" + + assert lint_capability_manifest(broken) == [ + "invalid_evidence_refs", + "invalid_expires_at", + "invalid_network_allowlist", + "invalid_tests", + "manifest_digest_mismatch", + "missing_owner", + ] + + +def test_non_native_manifest_requires_sandbox_and_artifact_allowlist() -> None: + cli_manifest = next( + manifest + for manifest in command_manifest_fixtures() + if manifest["substrate"] == "official_cli" + ) + broken = deepcopy(cli_manifest) + broken.pop("sandbox_profile_class") + broken["artifact_allowlist"] = [] + + assert lint_capability_manifest(broken) == [ + "invalid_artifact_allowlist", + "manifest_digest_mismatch", + "missing_sandbox_profile_class", + ] + + +def test_high_risk_manifest_requires_confirmation_or_blocking_policy() -> None: + manifest = command_manifest_fixtures()[0] + broken = deepcopy(manifest) + broken["side_effect_class"] = "payment" + broken["confirmation_policy"] = "none" + + assert lint_capability_manifest(broken) == [ + "high_risk_requires_confirmation_policy", + "manifest_digest_mismatch", + ] diff --git a/packages/api/tests/test_index_evidence.py b/packages/api/tests/test_index_evidence.py new file mode 100644 index 00000000..fc306c69 --- /dev/null +++ b/packages/api/tests/test_index_evidence.py @@ -0,0 +1,146 @@ +"""PP-15 Index evidence packet + manifest model tests.""" + +from __future__ import annotations + +from copy import deepcopy + +from schemas.index_evidence import ( + INDEX_ENTITY_VOCABULARY, + canonical_json, + evidence_packet_digest, + lint_evidence_packet, + lint_index_route_fixture, + lint_manifest, + manifest_digest, + route_fixture_for, + search_query_brave_route_fixture, +) +from schemas.resolve_route_candidate import route_candidate_from_provider + + +def test_canonical_json_digest_is_deterministic_and_ignores_digest_fields() -> None: + left = {"b": 2, "a": {"z": 1, "manifest_digest": "sha256:old"}} + right = {"a": {"manifest_digest": "sha256:new", "z": 1}, "b": 2} + + assert canonical_json(left) == canonical_json(right) + assert manifest_digest(left) == manifest_digest(right) + + +def test_search_query_brave_fixture_lints_cleanly_and_distinguishes_index_entities() -> None: + fixture = search_query_brave_route_fixture() + + assert lint_index_route_fixture(fixture) == [] + assert fixture["entity_vocabulary"] == INDEX_ENTITY_VOCABULARY + + manifest = fixture["manifest"] + evidence = fixture["evidence_packet"] + assert manifest["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert manifest["service_id"] == "brave-search" + assert manifest["provider_id"] == "brave-search-api" + assert manifest["capability_id"] == "search.query" + assert manifest["substrate"] == "official_api" + assert manifest["provenance_origin"] == "rhumb_managed" + assert manifest["source_risk"] == "verified_low" + assert manifest["side_effect_class"] == "read" + assert manifest["credential_modes"] == ["rhumb_managed"] + assert manifest["manifest_digest"] == manifest_digest(manifest) + + assert evidence["manifest_digest"] == manifest["manifest_digest"] + assert evidence["review_status"] == "current" + assert evidence["promotion_state"] == "beta_executable" + assert evidence["evidence_packet_digest"] == evidence_packet_digest(evidence) + assert evidence["sources"] + assert evidence["reviews"] + assert evidence["public_claim_boundary"] == manifest["public_claim_boundary"] + + +def test_manifest_linter_fails_on_missing_owner_expiry_claim_and_bad_digest() -> None: + manifest = search_query_brave_route_fixture()["manifest"] + broken = deepcopy(manifest) + broken.pop("owner") + broken.pop("expires_at") + broken["public_claim_boundary"] = "" + broken["manifest_digest"] = "sha256:not-the-digest" + + assert lint_manifest(broken) == [ + "invalid_expires_at", + "manifest_digest_mismatch", + "missing_expires_at", + "missing_owner", + "missing_public_claim_boundary", + ] + + +def test_evidence_linter_fails_on_missing_sources_review_owner_expiry_claim_and_digest() -> None: + fixture = search_query_brave_route_fixture() + evidence = deepcopy(fixture["evidence_packet"]) + evidence["sources"] = [] + evidence["reviews"] = [] + evidence.pop("owner") + evidence.pop("reviewer") + evidence.pop("evidence_expires_at") + evidence["public_claim_boundary"] = "" + evidence["evidence_packet_digest"] = "sha256:not-the-digest" + + assert lint_evidence_packet(evidence, fixture["manifest"]) == [ + "evidence_packet_digest_mismatch", + "invalid_evidence_expires_at", + "missing_evidence_expires_at", + "missing_owner", + "missing_public_claim_boundary", + "missing_reviewer", + "missing_reviews", + "missing_sources", + ] + + +def test_route_candidate_uses_index_fixture_for_search_query_brave_search_api() -> None: + candidate = route_candidate_from_provider( + capability_id="search.query", + provider={ + "service_slug": "brave-search-api", + "service_id": "fake-service", + "route_id": "route_fake_override", + "substrate": "browser_discovered_private_endpoint", + "provenance_origin": "browser_observed", + "source_risk": "anti_bot_or_tos_sensitive", + "promotion_state": "blocked", + "review_status": "quarantined", + "manifest_id": "manifest_fake_override", + "manifest_digest": "sha256:fake-manifest", + "manifest_version": "fake-version", + "evidence_packet_id": "evidence_fake_override", + "evidence_packet_digest": "sha256:fake-evidence", + "evidence_expires_at": "2000-01-01T00:00:00Z", + "endpoint_pattern": "GET /res/v1/web/search", + "credential_mode": "rhumb_managed", + "configured": True, + "required_scopes": ["fake.scope"], + "data_classes": ["fake_private_data"], + "side_effect_class": "destructive", + }, + rank=1, + selected_provider_id="brave-search-api", + alternatives_considered=["brave-search-api"], + ) + + fixture = route_fixture_for("search.query", "brave-search-api") + assert fixture is not None + assert candidate["route_id"] == fixture["manifest"]["route_id"] + assert candidate["service_id"] == "brave-search" + assert candidate["substrate"] == fixture["manifest"]["substrate"] + assert candidate["provenance_origin"] == fixture["manifest"]["provenance_origin"] + assert candidate["source_risk"] == fixture["manifest"]["source_risk"] + assert candidate["promotion_state"] == fixture["evidence_packet"]["promotion_state"] + assert candidate["manifest_id"] == fixture["manifest"]["manifest_id"] + assert candidate["manifest_digest"] == fixture["manifest"]["manifest_digest"] + assert candidate["manifest_version"] == fixture["manifest"]["manifest_version"] + assert candidate["evidence_packet_id"] == fixture["evidence_packet"]["evidence_packet_id"] + assert ( + candidate["evidence_packet_digest"] == fixture["evidence_packet"]["evidence_packet_digest"] + ) + assert candidate["evidence_expires_at"] == fixture["evidence_packet"]["evidence_expires_at"] + assert candidate["review_status"] == "current" + assert candidate["required_scopes"] == fixture["manifest"]["required_scopes"] + assert candidate["data_classes"] == fixture["manifest"]["data_classes"] + assert candidate["side_effect_class"] == "read" diff --git a/packages/api/tests/test_index_manifest_routes.py b/packages/api/tests/test_index_manifest_routes.py new file mode 100644 index 00000000..d8ad9294 --- /dev/null +++ b/packages/api/tests/test_index_manifest_routes.py @@ -0,0 +1,214 @@ +"""PP-1/PP-2 Index manifest serving route tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from app import create_app +from schemas.capability_manifest import fixture_manifests_by_route_id +from services.index_manifest_store import DurableIndexManifestStore, IndexManifestStore + + +@pytest.fixture(autouse=True) +def _disable_durable_index_reads(): + """Keep route tests on deterministic fixture fallback unless a test opts in.""" + with patch( + "services.index_manifest_store.supabase_fetch", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = [] + yield mock_fetch + + +def _durable_manifest_row() -> dict: + return { + "route_id": "route_search_query_brave_search_api_official_api_v1", + "manifest_id": "manifest_search_query_brave_search_api_official_api_v1", + "manifest_version": "2026-05-19.1", + "manifest_digest": "sha256:durable-manifest", + "service_id": "brave-search", + "provider_id": "brave-search-api", + "capability_id": "search.query", + "substrate": "official_api", + "provenance_origin": "rhumb_managed", + "source_risk": "verified_low", + "side_effect_class": "read", + "promotion_state": "production_executable", + "review_status": "current", + "evidence_packet_id": "evidence_search_query_brave_search_api_official_api_2026_05_19", + "evidence_packet_digest": "sha256:durable-evidence", + "evidence_expires_at": "2026-08-17T00:00:00Z", + "public_claim_boundary": "Rhumb can route read-only web search queries through a governed Brave Search API route.", + "manifest_json": { + "route_name": "Durable Brave Search API route", + "network_allowlist": ["api.search.brave.com"], + }, + "evidence_packet_json": { + "sources": [{"kind": "vendor_docs", "url": "https://api.search.brave.com/"}] + }, + "owner": "Pedro", + "reviewer": "Pedro", + "expires_at": "2026-08-17T00:00:00Z", + } + + +@pytest.mark.asyncio +async def test_index_manifest_list_serves_taxonomy_policy_and_manifest_facts() -> None: + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.get("/v2/index/manifests?capability_id=search.query") + + assert response.status_code == 200 + payload = response.json() + assert payload["error"] is None + data = payload["data"] + assert data["contract_id"] == "index_command_manifest_v1" + assert data["source"] == "PP-2" + assert data["status"] == "hosted_index_with_fixture_fallback" + assert data["total"] == 1 + assert "official_api" in data["taxonomy"]["substrates"] + assert "verified_low" in data["taxonomy"]["source_risks"] + + manifest = data["manifests"][0] + assert manifest["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert manifest["capability_id"] == "search.query" + assert manifest["substrate"] == "official_api" + assert manifest["source_risk"] == "verified_low" + assert manifest["recommendation_policy"]["default_recommendable"] is True + assert manifest["recommendation_policy"]["reasons"] == [] + assert data["_rhumb_v2"]["compat_mode"] == "v1-translate" + + +@pytest.mark.asyncio +async def test_index_manifest_filters_expose_non_default_fixture_policy() -> None: + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.get("/v2/index/manifests?substrate=generated_adapter") + + assert response.status_code == 200 + data = response.json()["data"] + assert data["total"] == 1 + manifest = data["manifests"][0] + assert manifest["substrate"] == "generated_adapter" + assert manifest["recommendation_policy"]["default_recommendable"] is False + assert manifest["recommendation_policy"]["requires_explicit_request"] is True + assert "generated_route_not_default" in manifest["recommendation_policy"]["reasons"] + + +@pytest.mark.asyncio +async def test_index_manifest_detail_returns_one_stable_route_manifest() -> None: + route_id = "route_workflow_run_list_github_cli_v1" + expected = fixture_manifests_by_route_id()[route_id] + + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.get(f"/v2/index/manifests/{route_id}") + + assert response.status_code == 200 + data = response.json()["data"] + assert data["contract_id"] == "index_command_manifest_v1" + manifest = data["manifest"] + assert manifest["manifest_digest"] == expected["manifest_digest"] + assert manifest["substrate"] == "official_cli" + assert manifest["recommendation_policy"]["default_recommendable"] is False + + +@pytest.mark.asyncio +async def test_index_manifest_invalid_taxonomy_filter_rejects_before_manifest_serving() -> None: + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.get("/v2/index/manifests?source_risk=totally-made-up") + + assert response.status_code == 400 + body = response.json() + assert body["error"]["code"] == "INVALID_PARAMETERS" + assert body["error"]["message"] == "Invalid 'source_risk' filter." + assert "verified_low" in body["error"]["detail"] + + +@pytest.mark.asyncio +async def test_index_manifest_unknown_route_id_returns_typed_not_found() -> None: + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.get("/v2/index/manifests/route_missing") + + assert response.status_code == 404 + body = response.json() + assert body["error"]["code"] == "ROUTE_MANIFEST_NOT_FOUND" + assert "GET /v2/index/manifests" in body["error"]["detail"] + + +def test_index_manifest_store_returns_deep_copies_and_route_facts() -> None: + store = IndexManifestStore() + + manifests = store.list_manifests(capability_id="search.query") + manifests[0]["substrate"] = "mutated_by_test" + + fresh = store.list_manifests(capability_id="search.query")[0] + assert fresh["substrate"] == "official_api" + + route_facts = store.route_facts_for_provider("search.query", "brave-search-api") + assert route_facts["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert route_facts["manifest_digest"].startswith("sha256:") + assert ( + route_facts["evidence_packet_id"] + == "evidence_search_query_brave_search_api_official_api_2026_05_19" + ) + assert route_facts["recommendation_policy"]["default_recommendable"] is True + + +@pytest.mark.asyncio +async def test_durable_index_manifest_store_prefers_hosted_rows( + _disable_durable_index_reads, +) -> None: + _disable_durable_index_reads.return_value = [_durable_manifest_row()] + store = DurableIndexManifestStore() + + manifests = await store.list_manifests(capability_id="search.query") + + assert manifests[0]["route_name"] == "Durable Brave Search API route" + assert manifests[0]["manifest_digest"] == "sha256:durable-manifest" + assert _disable_durable_index_reads.await_args.args[0].startswith( + "index_command_manifests?capability_id=eq.search.query" + ) + + +@pytest.mark.asyncio +async def test_durable_index_manifest_store_route_facts_include_evidence_and_policy( + _disable_durable_index_reads, +) -> None: + _disable_durable_index_reads.return_value = [_durable_manifest_row()] + store = DurableIndexManifestStore() + + facts = await store.route_facts_for_provider("search.query", "brave-search-api") + + assert facts["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert facts["manifest_digest"] == "sha256:durable-manifest" + assert facts["evidence_packet_digest"] == "sha256:durable-evidence" + assert facts["review_status"] == "current" + assert facts["recommendation_policy"]["default_recommendable"] is True + + +@pytest.mark.asyncio +async def test_durable_index_manifest_store_falls_back_when_hosted_rows_empty( + _disable_durable_index_reads, +) -> None: + _disable_durable_index_reads.return_value = [] + store = DurableIndexManifestStore() + + manifest = await store.get_manifest("route_workflow_run_list_github_cli_v1") + + assert manifest is not None + assert manifest["substrate"] == "official_cli" diff --git a/packages/api/tests/test_index_manifest_seed.py b/packages/api/tests/test_index_manifest_seed.py new file mode 100644 index 00000000..1c5d627a --- /dev/null +++ b/packages/api/tests/test_index_manifest_seed.py @@ -0,0 +1,111 @@ +"""PP-1/PP-2 hosted Index manifest seed tests.""" + +from __future__ import annotations + +from pathlib import Path + +from schemas.capability_manifest import command_manifest_fixtures +from schemas.index_evidence import route_fixture_for +from services.index_manifest_seed import ( + index_command_manifest_seed_rows, + render_index_command_manifest_seed_sql, +) +from services.index_manifest_store import ( + _manifest_from_row, + _route_facts_from_row, + with_recommendation_policy, +) + + +def _rows_by_route_id() -> dict[str, dict]: + return {row["route_id"]: row for row in index_command_manifest_seed_rows()} + + +def test_seed_rows_cover_pp2_fixtures_and_preserve_manifest_digests() -> None: + fixtures = {manifest["route_id"]: manifest for manifest in command_manifest_fixtures()} + rows = _rows_by_route_id() + + assert set(rows) == set(fixtures) + assert len(rows) == 9 + + for route_id, manifest in fixtures.items(): + row = rows[route_id] + assert row["manifest_json"] == manifest + assert row["manifest_digest"] == manifest["manifest_digest"] + assert row["public_claim_boundary"] == manifest["public_claim_boundary"] + assert row["promotion_state"] == manifest["promotion_state"] + assert row["owner"] == manifest["owner"] + assert row["reviewer"] == manifest["reviewer"] + assert row["expires_at"] == manifest["expires_at"] + + +def test_seed_rows_preserve_evidence_metadata_only_where_available() -> None: + rows = _rows_by_route_id() + search_row = rows["route_search_query_brave_search_api_official_api_v1"] + fixture = route_fixture_for("search.query", "brave-search-api") + assert fixture is not None + evidence = fixture["evidence_packet"] + + assert search_row["evidence_packet_json"] == evidence + assert search_row["evidence_packet_id"] == evidence["evidence_packet_id"] + assert search_row["evidence_packet_digest"] == evidence["evidence_packet_digest"] + assert search_row["evidence_expires_at"] == evidence["evidence_expires_at"] + assert search_row["review_status"] == "current" + + rows_without_evidence = [ + row for row in rows.values() if row["route_id"] != search_row["route_id"] + ] + assert rows_without_evidence + assert all(row["evidence_packet_json"] is None for row in rows_without_evidence) + assert all(row["evidence_packet_digest"] is None for row in rows_without_evidence) + assert all(row["review_status"] is None for row in rows_without_evidence) + + +def test_seed_sql_matches_both_migration_files() -> None: + expected_sql = render_index_command_manifest_seed_sql() + for path in ( + Path("packages/api/migrations/0166_index_command_manifest_seed.sql"), + Path("supabase/migrations/0166_index_command_manifest_seed.sql"), + ): + assert path.read_text(encoding="utf-8") == expected_sql + + +def test_seeded_rows_hydrate_route_facts_and_fail_closed_recommendation_policy() -> None: + rows = _rows_by_route_id() + + search_facts = _route_facts_from_row( + rows["route_search_query_brave_search_api_official_api_v1"] + ) + assert search_facts["manifest_digest"].startswith("sha256:") + assert ( + search_facts["evidence_packet_id"] + == "evidence_search_query_brave_search_api_official_api_2026_05_19" + ) + assert search_facts["recommendation_policy"]["default_recommendable"] is True + + generated = with_recommendation_policy( + _manifest_from_row(rows["route_calendar_freebusy_generated_adapter_v1"]) + ) + assert generated["recommendation_policy"]["default_recommendable"] is False + assert generated["recommendation_policy"]["requires_explicit_request"] is True + assert "generated_route_not_default" in generated["recommendation_policy"]["reasons"] + assert ( + "source_risk_community_unverified_not_default" + in generated["recommendation_policy"]["reasons"] + ) + + write_route = with_recommendation_policy( + _manifest_from_row(rows["route_support_ticket_create_zendesk_api_official_api_v1"]) + ) + assert write_route["recommendation_policy"]["default_recommendable"] is False + assert write_route["recommendation_policy"]["requires_explicit_request"] is True + assert "high_risk_side_effect_not_default" in write_route["recommendation_policy"]["reasons"] + + cli_route = with_recommendation_policy( + _manifest_from_row(rows["route_workflow_run_list_github_cli_v1"]) + ) + assert cli_route["recommendation_policy"]["default_recommendable"] is False + assert ( + "source_risk_community_unverified_not_default" + in cli_route["recommendation_policy"]["reasons"] + ) diff --git a/packages/api/tests/test_proxy_credentials_env.py b/packages/api/tests/test_proxy_credentials_env.py new file mode 100644 index 00000000..5b42913f --- /dev/null +++ b/packages/api/tests/test_proxy_credentials_env.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from services.proxy_credentials import CredentialStore + + +def test_ipinfo_loader_accepts_live_migration_env_name(monkeypatch) -> None: + """Hosted Resolve uses RHUMB_CREDENTIAL_IPINFO_TOKEN from the DB-backed config.""" + monkeypatch.delenv("RHUMB_CREDENTIAL_IPINFO_API_TOKEN", raising=False) + monkeypatch.setenv("RHUMB_CREDENTIAL_IPINFO_TOKEN", "ipinfo_live_token") + + store = CredentialStore(auto_load=False) + store._load_from_env("ipinfo") + + assert store.get_credential("ipinfo", "bearer_token") == "ipinfo_live_token" + assert "ipinfo" in store.callable_services() + + +def test_dynamic_lookup_accepts_ipinfo_migration_env_alias(monkeypatch) -> None: + monkeypatch.delenv("RHUMB_CREDENTIAL_IPINFO_BEARER_TOKEN", raising=False) + monkeypatch.delenv("RHUMB_CREDENTIAL_IPINFO_API_TOKEN", raising=False) + monkeypatch.setenv("RHUMB_CREDENTIAL_IPINFO_TOKEN", "ipinfo_alias_token") + + store = CredentialStore(auto_load=False) + + assert store.get_credential("ipinfo", "bearer_token") == "ipinfo_alias_token" + + +def test_resend_and_google_places_envs_are_first_class(monkeypatch) -> None: + monkeypatch.setenv("RHUMB_CREDENTIAL_RESEND_API_KEY", "resend_live_key") + monkeypatch.setenv("RHUMB_CREDENTIAL_GOOGLE_PLACES_API_KEY", "google_places_live_key") + + store = CredentialStore(auto_load=False) + store._load_from_env("resend") + store._load_from_env("google-maps") + store._load_from_env("google-places") + + assert store.get_credential("resend", "api_key") == "resend_live_key" + assert store.get_credential("google-maps", "api_key") == "google_places_live_key" + assert store.get_credential("google-places", "api_key") == "google_places_live_key" diff --git a/packages/api/tests/test_proxy_slice_c.py b/packages/api/tests/test_proxy_slice_c.py index f20267d4..51db48e3 100644 --- a/packages/api/tests/test_proxy_slice_c.py +++ b/packages/api/tests/test_proxy_slice_c.py @@ -12,18 +12,14 @@ from __future__ import annotations -import asyncio import base64 import sys -import time from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any, Dict, List, Optional -from unittest.mock import MagicMock, patch +from typing import Any +from unittest.mock import patch import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -32,8 +28,8 @@ _LegacyAgentIdentitySchema as AgentIdentitySchema, ) from services.proxy_auth import AuthInjectionRequest, AuthInjector, AuthMethod -from services.proxy_credentials import CredentialEntry, CredentialStore, ProviderCredentials -from services.proxy_rate_limit import RateLimiter, RateLimitStatus +from services.proxy_credentials import CredentialStore +from services.proxy_rate_limit import RateLimiter def _utcnow() -> datetime: @@ -65,6 +61,15 @@ def credential_store() -> CredentialStore: store.set_credential("ipinfo", "bearer_token", "ipinfo_test_token_123") store.set_credential("scraperapi", "api_key", "scraperapi_test_key_123") store.set_credential("deepgram", "api_key", "deepgram_test_key_123") + store.set_credential("openai", "api_key", "openai_test_key_123") + store.set_credential("groq", "api_key", "groq_test_key_123") + store.set_credential("cohere", "api_key", "cohere_test_key_123") + store.set_credential("elevenlabs", "api_key", "elevenlabs_test_key_123") + store.set_credential("resend", "api_key", "resend_test_key_123") + store.set_credential("postmark", "api_key", "postmark_test_key_123") + store.set_credential("google-maps", "api_key", "google_places_test_key_123") + store.set_credential("google-places", "api_key", "google_places_test_key_123") + store.set_credential("perplexity", "api_key", "perplexity_test_key_123") return store @@ -146,15 +151,11 @@ def test_load_all_seeded_providers(self, credential_store: CredentialStore) -> N assert credential_store.get_credential("scraperapi", "api_key") is not None assert credential_store.get_credential("deepgram", "api_key") is not None - def test_credential_not_found_unknown_service( - self, credential_store: CredentialStore - ) -> None: + def test_credential_not_found_unknown_service(self, credential_store: CredentialStore) -> None: """Unknown service returns None.""" assert credential_store.get_credential("unknown_svc", "key") is None - def test_credential_not_found_unknown_key( - self, credential_store: CredentialStore - ) -> None: + def test_credential_not_found_unknown_key(self, credential_store: CredentialStore) -> None: """Known service but unknown key returns None.""" assert credential_store.get_credential("stripe", "nonexistent_key") is None @@ -171,9 +172,7 @@ def test_credential_not_expired(self, credential_store: CredentialStore) -> None assert credential_store.get_credential("stripe", "api_key") == "fresh_key" @pytest.mark.asyncio - async def test_credential_refresh_when_stale( - self, credential_store: CredentialStore - ) -> None: + async def test_credential_refresh_when_stale(self, credential_store: CredentialStore) -> None: """Stale provider triggers a refresh (force last_refreshed into the past).""" credential_store.set_credential("stripe", "api_key", "original", ttl_minutes=1) # Force staleness by backdating last_refreshed @@ -211,9 +210,7 @@ class TestAgentIdentityVerifier: """Bearer token verification and service access control.""" @pytest.mark.asyncio - async def test_verify_bearer_token_valid( - self, agent_verifier: AgentIdentityVerifier - ) -> None: + async def test_verify_bearer_token_valid(self, agent_verifier: AgentIdentityVerifier) -> None: """Valid token returns the correct identity.""" identity = await agent_verifier.verify_bearer_token("rhumb_lead_token_xyz") assert identity is not None @@ -221,9 +218,7 @@ async def test_verify_bearer_token_valid( assert identity.rate_limit_qpm == 500 @pytest.mark.asyncio - async def test_verify_bearer_token_invalid( - self, agent_verifier: AgentIdentityVerifier - ) -> None: + async def test_verify_bearer_token_invalid(self, agent_verifier: AgentIdentityVerifier) -> None: """Invalid token returns None.""" identity = await agent_verifier.verify_bearer_token("totally_bogus_token") assert identity is None @@ -254,9 +249,7 @@ async def test_verify_service_access_denied( assert await agent_verifier.verify_service_access("snowy", "twilio") is False @pytest.mark.asyncio - async def test_cache_hit( - self, agent_verifier: AgentIdentityVerifier - ) -> None: + async def test_cache_hit(self, agent_verifier: AgentIdentityVerifier) -> None: """Subsequent lookups use the cache (no Supabase call).""" id1 = await agent_verifier.verify_bearer_token("rhumb_lead_token_xyz") id2 = await agent_verifier.verify_bearer_token("rhumb_lead_token_xyz") @@ -510,6 +503,68 @@ def test_inject_deepgram_token_header(self, auth_injector: AuthInjector) -> None headers = auth_injector.inject(req) assert headers["Authorization"] == "Token deepgram_test_key_123" + def test_inject_openai_compatible_bearer_headers(self, auth_injector: AuthInjector) -> None: + """OpenAI-compatible managed providers use Authorization Bearer.""" + for service, expected in ( + ("openai", "Bearer openai_test_key_123"), + ("groq", "Bearer groq_test_key_123"), + ("cohere", "Bearer cohere_test_key_123"), + ("perplexity", "Bearer perplexity_test_key_123"), + ): + req = AuthInjectionRequest( + service=service, + agent_id="rhumb-lead", + auth_method=AuthMethod.API_KEY, + ) + headers = auth_injector.inject(req) + assert headers["Authorization"] == expected + + def test_inject_email_provider_headers(self, auth_injector: AuthInjector) -> None: + """Resend/Postmark/ElevenLabs each require provider-specific headers.""" + resend = auth_injector.inject( + AuthInjectionRequest( + service="resend", + agent_id="rhumb-lead", + auth_method=AuthMethod.API_KEY, + ) + ) + assert resend["Authorization"] == "Bearer resend_test_key_123" + + postmark = auth_injector.inject( + AuthInjectionRequest( + service="postmark", + agent_id="rhumb-lead", + auth_method=AuthMethod.API_KEY, + ) + ) + assert postmark["X-Postmark-Server-Token"] == "postmark_test_key_123" + assert "Authorization" not in postmark + + elevenlabs = auth_injector.inject( + AuthInjectionRequest( + service="elevenlabs", + agent_id="rhumb-lead", + auth_method=AuthMethod.API_KEY, + ) + ) + assert elevenlabs["xi-api-key"] == "elevenlabs_test_key_123" + + def test_inject_google_maps_key_query_param(self, auth_injector: AuthInjector) -> None: + """Google Maps/Places use key= query auth, not Bearer headers.""" + for service in ("google-maps", "google-places"): + req = AuthInjectionRequest( + service=service, + agent_id="rhumb-lead", + auth_method=AuthMethod.API_KEY, + existing_params={"address": "Los Angeles"}, + ) + injected = auth_injector.inject_request_parts(req) + assert injected.params == { + "address": "Los Angeles", + "key": "google_places_test_key_123", + } + assert injected.headers == {} + def test_inject_writes_audit_entry( self, credential_store: CredentialStore, auth_injector: AuthInjector ) -> None: @@ -543,6 +598,15 @@ def test_default_method_for_service(self) -> None: assert AuthInjector.default_method_for("ipinfo") == AuthMethod.BEARER_TOKEN assert AuthInjector.default_method_for("scraperapi") == AuthMethod.API_KEY assert AuthInjector.default_method_for("deepgram") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("openai") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("groq") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("cohere") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("elevenlabs") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("resend") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("postmark") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("google-maps") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("google-places") == AuthMethod.API_KEY + assert AuthInjector.default_method_for("perplexity") == AuthMethod.API_KEY assert AuthInjector.default_method_for("nonexistent") is None @@ -724,9 +788,7 @@ async def test_e2e_agent_hits_rate_limit( assert retry_after > -1 # reset is roughly now or in the near future @pytest.mark.asyncio - async def test_e2e_credential_refresh_on_stale( - self, credential_store: CredentialStore - ) -> None: + async def test_e2e_credential_refresh_on_stale(self, credential_store: CredentialStore) -> None: """Stale credentials trigger a refresh before the next request.""" credential_store.set_credential("stripe", "api_key", "old_key", ttl_minutes=1) # Force staleness diff --git a/packages/api/tests/test_receipt_service.py b/packages/api/tests/test_receipt_service.py index ccaa3fd5..25191e1d 100644 --- a/packages/api/tests/test_receipt_service.py +++ b/packages/api/tests/test_receipt_service.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib from unittest.mock import AsyncMock, patch import pytest @@ -15,7 +14,6 @@ _hash_content, hash_caller_ip, hash_request_payload, - hash_response_payload, ) @@ -100,6 +98,35 @@ def test_compute_receipt_hash_changes_on_different_data(): assert _compute_receipt_hash(data1) != _compute_receipt_hash(data2) +def test_compute_receipt_hash_covers_route_facts_and_route_plan_hash(): + data = { + "receipt_id": "rcpt_test123", + "status": "success", + "execution_id": "exec_abc", + "layer": 2, + "capability_id": "search.query", + "agent_id": "agent_123", + "provider_id": "brave-search-api", + "credential_mode": "rhumb_managed", + "route_id": "route_search_query_brave_search_api_official_api_v1", + "manifest_digest": "sha256:manifest", + "route_plan_id_hash": "sha256:plan", + } + + assert _compute_receipt_hash(data) != _compute_receipt_hash( + { + **data, + "route_id": "route_search_query_other_official_api_v1", + } + ) + assert _compute_receipt_hash(data) != _compute_receipt_hash( + { + **data, + "route_plan_id_hash": "sha256:other-plan", + } + ) + + @pytest.mark.anyio async def test_create_receipt_writes_to_supabase(): """Receipt creation should write to Supabase and return a valid Receipt.""" @@ -108,22 +135,40 @@ async def test_create_receipt_writes_to_supabase(): mock_chain_state = [{"last_sequence": 0, "last_receipt_hash": None}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), - patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)) as mock_insert, + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), + patch( + "services.receipt_service.supabase_insert", new=AsyncMock(return_value=True) + ) as mock_insert, patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - receipt = await service.create_receipt(ReceiptInput( - execution_id="exec_test_001", - capability_id="search.query", - status="success", - agent_id="agent_test_123", - provider_id="brave-search", - credential_mode="rhumb_managed", - layer=2, - total_latency_ms=342.5, - provider_latency_ms=290.0, - rhumb_overhead_ms=52.5, - )) + receipt = await service.create_receipt( + ReceiptInput( + execution_id="exec_test_001", + capability_id="search.query", + status="success", + agent_id="agent_test_123", + provider_id="brave-search", + credential_mode="rhumb_managed", + layer=2, + route_id="route_search_query_brave_search_api_official_api_v1", + service_id="brave-search-api", + substrate="official_api", + provenance_origin="rhumb_managed", + source_risk="verified_low", + manifest_digest="sha256:manifest", + evidence_packet_digest="sha256:evidence", + route_plan_id_hash="sha256:plan", + route_explanation_id="rex_123", + stop_condition=None, + retryable=False, + next_recommended_action="fetch_or_verify_receipt", + total_latency_ms=342.5, + provider_latency_ms=290.0, + rhumb_overhead_ms=52.5, + ) + ) assert receipt.receipt_id.startswith("rcpt_") assert receipt.execution_id == "exec_test_001" @@ -140,6 +185,10 @@ async def test_create_receipt_writes_to_supabase(): assert inserted_data["receipt_id"] == receipt.receipt_id assert inserted_data["execution_id"] == "exec_test_001" assert inserted_data["chain_sequence"] == 1 + assert inserted_data["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert inserted_data["manifest_digest"] == "sha256:manifest" + assert inserted_data["route_plan_id_hash"] == "sha256:plan" + assert inserted_data["next_recommended_action"] == "fetch_or_verify_receipt" @pytest.mark.anyio @@ -151,18 +200,22 @@ async def test_create_receipt_chains_correctly(): mock_chain_state = [{"last_sequence": 5, "last_receipt_hash": first_hash}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)), patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - receipt = await service.create_receipt(ReceiptInput( - execution_id="exec_test_002", - capability_id="person.enrich", - status="success", - agent_id="agent_test_456", - provider_id="apollo", - credential_mode="rhumb_managed", - )) + receipt = await service.create_receipt( + ReceiptInput( + execution_id="exec_test_002", + capability_id="person.enrich", + status="success", + agent_id="agent_test_456", + provider_id="apollo", + credential_mode="rhumb_managed", + ) + ) assert receipt.chain_sequence == 6 assert receipt.previous_receipt_hash == first_hash @@ -184,19 +237,25 @@ async def test_create_receipt_canonicalizes_alias_backed_provider_id_before_hash mock_chain_state = [{"last_sequence": 0, "last_receipt_hash": None}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), - patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)) as mock_insert, + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), + patch( + "services.receipt_service.supabase_insert", new=AsyncMock(return_value=True) + ) as mock_insert, patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - receipt = await service.create_receipt(ReceiptInput( - execution_id="exec_test_alias_001", - capability_id="search.query", - status="success", - agent_id="agent_test_alias", - provider_id="brave-search", - credential_mode="rhumb_managed", - layer=2, - )) + receipt = await service.create_receipt( + ReceiptInput( + execution_id="exec_test_alias_001", + capability_id="search.query", + status="success", + agent_id="agent_test_alias", + provider_id="brave-search", + credential_mode="rhumb_managed", + layer=2, + ) + ) inserted_data = mock_insert.call_args[0][1] hashed_payload = {k: v for k, v in inserted_data.items() if k != "receipt_hash"} @@ -204,10 +263,12 @@ async def test_create_receipt_canonicalizes_alias_backed_provider_id_before_hash assert inserted_data["provider_id"] == "brave-search-api" assert receipt.provider_id == "brave-search-api" assert inserted_data["receipt_hash"] == _compute_receipt_hash(hashed_payload) - assert inserted_data["receipt_hash"] != _compute_receipt_hash({ - **hashed_payload, - "provider_id": "brave-search", - }) + assert inserted_data["receipt_hash"] != _compute_receipt_hash( + { + **hashed_payload, + "provider_id": "brave-search", + } + ) @pytest.mark.anyio @@ -216,23 +277,29 @@ async def test_create_receipt_canonicalizes_alias_backed_provider_text_before_ha mock_chain_state = [{"last_sequence": 0, "last_receipt_hash": None}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), - patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)) as mock_insert, + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), + patch( + "services.receipt_service.supabase_insert", new=AsyncMock(return_value=True) + ) as mock_insert, patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - receipt = await service.create_receipt(ReceiptInput( - execution_id="exec_test_alias_text_001", - capability_id="search.query", - status="failure", - agent_id="agent_test_alias_text", - provider_id="brave-search", - credential_mode="rhumb_managed", - layer=2, - provider_name="Brave Search (brave-search)", - winner_reason="brave-search won on freshness", - error_message="brave-search upstream exploded", - error_code="upstream_error", - )) + receipt = await service.create_receipt( + ReceiptInput( + execution_id="exec_test_alias_text_001", + capability_id="search.query", + status="failure", + agent_id="agent_test_alias_text", + provider_id="brave-search", + credential_mode="rhumb_managed", + layer=2, + provider_name="Brave Search (brave-search)", + winner_reason="brave-search won on freshness", + error_message="brave-search upstream exploded", + error_code="upstream_error", + ) + ) inserted_data = mock_insert.call_args[0][1] hashed_payload = {k: v for k, v in inserted_data.items() if k != "receipt_hash"} @@ -243,12 +310,14 @@ async def test_create_receipt_canonicalizes_alias_backed_provider_text_before_ha assert inserted_data["winner_reason"] == "brave-search-api won on freshness" assert inserted_data["error_message"] == "brave-search-api upstream exploded" assert inserted_data["receipt_hash"] == _compute_receipt_hash(hashed_payload) - assert inserted_data["receipt_hash"] != _compute_receipt_hash({ - **hashed_payload, - "provider_name": "Brave Search (brave-search)", - "winner_reason": "brave-search won on freshness", - "error_message": "brave-search upstream exploded", - }) + assert inserted_data["receipt_hash"] != _compute_receipt_hash( + { + **hashed_payload, + "provider_name": "Brave Search (brave-search)", + "winner_reason": "brave-search won on freshness", + "error_message": "brave-search upstream exploded", + } + ) @pytest.mark.anyio @@ -257,36 +326,50 @@ async def test_create_receipt_canonicalizes_alternate_provider_alias_text_before mock_chain_state = [{"last_sequence": 0, "last_receipt_hash": None}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), - patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)) as mock_insert, + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), + patch( + "services.receipt_service.supabase_insert", new=AsyncMock(return_value=True) + ) as mock_insert, patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - await service.create_receipt(ReceiptInput( - execution_id="exec_test_alias_text_002", - capability_id="search.query", - status="failure", - agent_id="agent_test_alias_text", - provider_id="brave-search", - credential_mode="rhumb_managed", - layer=2, - winner_reason="brave-search fell back to pdl on contact coverage", - error_message="brave-search failed after pdl credential lookup", - error_code="upstream_error", - error_provider_raw="pdl", - )) + await service.create_receipt( + ReceiptInput( + execution_id="exec_test_alias_text_002", + capability_id="search.query", + status="failure", + agent_id="agent_test_alias_text", + provider_id="brave-search", + credential_mode="rhumb_managed", + layer=2, + winner_reason="brave-search fell back to pdl on contact coverage", + error_message="brave-search failed after pdl credential lookup", + error_code="upstream_error", + error_provider_raw="pdl", + ) + ) inserted_data = mock_insert.call_args[0][1] hashed_payload = {k: v for k, v in inserted_data.items() if k != "receipt_hash"} assert inserted_data["provider_id"] == "brave-search-api" - assert inserted_data["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" - assert inserted_data["error_message"] == "brave-search-api failed after people-data-labs credential lookup" + assert ( + inserted_data["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) + assert ( + inserted_data["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) assert inserted_data["receipt_hash"] == _compute_receipt_hash(hashed_payload) - assert inserted_data["receipt_hash"] != _compute_receipt_hash({ - **hashed_payload, - "winner_reason": "brave-search fell back to pdl on contact coverage", - "error_message": "brave-search failed after pdl credential lookup", - }) + assert inserted_data["receipt_hash"] != _compute_receipt_hash( + { + **hashed_payload, + "winner_reason": "brave-search fell back to pdl on contact coverage", + "error_message": "brave-search failed after pdl credential lookup", + } + ) @pytest.mark.anyio @@ -295,35 +378,49 @@ async def test_create_receipt_canonicalizes_known_alternate_provider_alias_text_ mock_chain_state = [{"last_sequence": 0, "last_receipt_hash": None}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), - patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)) as mock_insert, + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), + patch( + "services.receipt_service.supabase_insert", new=AsyncMock(return_value=True) + ) as mock_insert, patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - await service.create_receipt(ReceiptInput( - execution_id="exec_test_alias_text_003", - capability_id="search.query", - status="failure", - agent_id="agent_test_alias_text", - provider_id="brave-search-api", - credential_mode="rhumb_managed", - layer=2, - winner_reason="brave-search-api fell back to pdl on contact coverage", - error_message="brave-search-api failed after pdl credential lookup", - error_code="upstream_error", - )) + await service.create_receipt( + ReceiptInput( + execution_id="exec_test_alias_text_003", + capability_id="search.query", + status="failure", + agent_id="agent_test_alias_text", + provider_id="brave-search-api", + credential_mode="rhumb_managed", + layer=2, + winner_reason="brave-search-api fell back to pdl on contact coverage", + error_message="brave-search-api failed after pdl credential lookup", + error_code="upstream_error", + ) + ) inserted_data = mock_insert.call_args[0][1] hashed_payload = {k: v for k, v in inserted_data.items() if k != "receipt_hash"} assert inserted_data["provider_id"] == "brave-search-api" - assert inserted_data["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" - assert inserted_data["error_message"] == "brave-search-api failed after people-data-labs credential lookup" + assert ( + inserted_data["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) + assert ( + inserted_data["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) assert inserted_data["receipt_hash"] == _compute_receipt_hash(hashed_payload) - assert inserted_data["receipt_hash"] != _compute_receipt_hash({ - **hashed_payload, - "winner_reason": "brave-search-api fell back to pdl on contact coverage", - "error_message": "brave-search-api failed after pdl credential lookup", - }) + assert inserted_data["receipt_hash"] != _compute_receipt_hash( + { + **hashed_payload, + "winner_reason": "brave-search-api fell back to pdl on contact coverage", + "error_message": "brave-search-api failed after pdl credential lookup", + } + ) @pytest.mark.anyio @@ -332,23 +429,29 @@ async def test_create_receipt_canonicalizes_same_provider_alias_text_when_struct mock_chain_state = [{"last_sequence": 0, "last_receipt_hash": None}] with ( - patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state)), - patch("services.receipt_service.supabase_insert", new=AsyncMock(return_value=True)) as mock_insert, + patch( + "services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_chain_state) + ), + patch( + "services.receipt_service.supabase_insert", new=AsyncMock(return_value=True) + ) as mock_insert, patch("services.receipt_service.supabase_patch", new=AsyncMock(return_value=True)), ): - await service.create_receipt(ReceiptInput( - execution_id="exec_test_alias_text_004", - capability_id="search.query", - status="failure", - agent_id="agent_test_alias_text", - provider_id="brave-search-api", - credential_mode="rhumb_managed", - layer=2, - provider_name="Brave Search (brave-search)", - winner_reason="brave-search won on freshness", - error_message="brave-search upstream exploded", - error_code="upstream_error", - )) + await service.create_receipt( + ReceiptInput( + execution_id="exec_test_alias_text_004", + capability_id="search.query", + status="failure", + agent_id="agent_test_alias_text", + provider_id="brave-search-api", + credential_mode="rhumb_managed", + layer=2, + provider_name="Brave Search (brave-search)", + winner_reason="brave-search won on freshness", + error_message="brave-search upstream exploded", + error_code="upstream_error", + ) + ) inserted_data = mock_insert.call_args[0][1] hashed_payload = {k: v for k, v in inserted_data.items() if k != "receipt_hash"} @@ -361,12 +464,14 @@ async def test_create_receipt_canonicalizes_same_provider_alias_text_when_struct assert "brave-search-api-api" not in inserted_data["winner_reason"] assert "brave-search-api-api" not in inserted_data["error_message"] assert inserted_data["receipt_hash"] == _compute_receipt_hash(hashed_payload) - assert inserted_data["receipt_hash"] != _compute_receipt_hash({ - **hashed_payload, - "provider_name": "Brave Search (brave-search)", - "winner_reason": "brave-search won on freshness", - "error_message": "brave-search upstream exploded", - }) + assert inserted_data["receipt_hash"] != _compute_receipt_hash( + { + **hashed_payload, + "provider_name": "Brave Search (brave-search)", + "winner_reason": "brave-search won on freshness", + "error_message": "brave-search upstream exploded", + } + ) @pytest.mark.anyio @@ -434,8 +539,14 @@ async def test_get_receipt_canonicalizes_alternate_provider_alias_text_on_public assert result is not None assert result["provider_id"] == "brave-search-api" - assert result["error_message"] == "brave-search-api failed after people-data-labs credential lookup" - assert result["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" + assert ( + result["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) + assert ( + result["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) assert "error_provider_raw" not in result @@ -460,8 +571,14 @@ async def test_get_receipt_canonicalizes_known_alternate_provider_alias_text_wit assert result is not None assert result["provider_id"] == "brave-search-api" - assert result["error_message"] == "brave-search-api failed after people-data-labs credential lookup" - assert result["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" + assert ( + result["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) + assert ( + result["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) @pytest.mark.anyio @@ -506,6 +623,29 @@ async def test_query_receipts_canonicalizes_legacy_provider_text_on_public_reads ] +@pytest.mark.anyio +async def test_query_receipts_drops_malformed_supabase_rows_without_changing_public_receipt_count(): + service = ReceiptService() + mock_fetch = AsyncMock( + return_value=[ + {"receipt_id": "rcpt_valid_1", "provider_id": "brave-search"}, + None, + ["not", "a", "receipt", "row"], + "malformed receipt row", + {"receipt_id": "rcpt_valid_2", "provider_id": "people-data-labs"}, + ] + ) + + with patch("services.receipt_service.supabase_fetch", new=mock_fetch): + result = await service.query_receipts(limit=10) + + assert result == [ + {"receipt_id": "rcpt_valid_1", "provider_id": "brave-search-api"}, + {"receipt_id": "rcpt_valid_2", "provider_id": "people-data-labs"}, + ] + assert len(result) == 2 + + @pytest.mark.anyio async def test_public_receipt_row_preserves_human_provider_names_when_row_is_already_canonical(): service = ReceiptService() @@ -534,9 +674,24 @@ async def test_verify_chain_intact(): service = ReceiptService() mock_rows = [ - {"receipt_id": "r1", "receipt_hash": "h1", "previous_receipt_hash": None, "chain_sequence": 1}, - {"receipt_id": "r2", "receipt_hash": "h2", "previous_receipt_hash": "h1", "chain_sequence": 2}, - {"receipt_id": "r3", "receipt_hash": "h3", "previous_receipt_hash": "h2", "chain_sequence": 3}, + { + "receipt_id": "r1", + "receipt_hash": "h1", + "previous_receipt_hash": None, + "chain_sequence": 1, + }, + { + "receipt_id": "r2", + "receipt_hash": "h2", + "previous_receipt_hash": "h1", + "chain_sequence": 2, + }, + { + "receipt_id": "r3", + "receipt_hash": "h3", + "previous_receipt_hash": "h2", + "chain_sequence": 3, + }, ] with patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_rows)): @@ -552,8 +707,18 @@ async def test_verify_chain_detects_broken_link(): service = ReceiptService() mock_rows = [ - {"receipt_id": "r1", "receipt_hash": "h1", "previous_receipt_hash": None, "chain_sequence": 1}, - {"receipt_id": "r2", "receipt_hash": "h2", "previous_receipt_hash": "WRONG", "chain_sequence": 2}, + { + "receipt_id": "r1", + "receipt_hash": "h1", + "previous_receipt_hash": None, + "chain_sequence": 1, + }, + { + "receipt_id": "r2", + "receipt_hash": "h2", + "previous_receipt_hash": "WRONG", + "chain_sequence": 2, + }, ] with patch("services.receipt_service.supabase_fetch", new=AsyncMock(return_value=mock_rows)): diff --git a/packages/api/tests/test_receipts_v2.py b/packages/api/tests/test_receipts_v2.py index 9b51ae18..101ad231 100644 --- a/packages/api/tests/test_receipts_v2.py +++ b/packages/api/tests/test_receipts_v2.py @@ -14,6 +14,7 @@ def client(): """Create a test client for the FastAPI app.""" from app import create_app + app = create_app() return TestClient(app) @@ -102,12 +103,20 @@ def test_get_receipt_canonicalizes_alternate_provider_alias_text(client): assert resp.status_code == 200 body = resp.json() assert body["data"]["provider_id"] == "brave-search-api" - assert body["data"]["error_message"] == "brave-search-api failed after people-data-labs credential lookup" - assert body["data"]["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" + assert ( + body["data"]["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) + assert ( + body["data"]["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) assert "error_provider_raw" not in body["data"] -def test_get_receipt_canonicalizes_known_alternate_provider_alias_text_without_raw_provider_hint(client): +def test_get_receipt_canonicalizes_known_alternate_provider_alias_text_without_raw_provider_hint( + client, +): receipt_data = { "receipt_id": "rcpt_test125", "execution_id": "exec_003", @@ -124,11 +133,19 @@ def test_get_receipt_canonicalizes_known_alternate_provider_alias_text_without_r assert resp.status_code == 200 body = resp.json() assert body["data"]["provider_id"] == "brave-search-api" - assert body["data"]["error_message"] == "brave-search-api failed after people-data-labs credential lookup" - assert body["data"]["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" + assert ( + body["data"]["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) + assert ( + body["data"]["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) -def test_get_receipt_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical(client): +def test_get_receipt_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical( + client, +): receipt_data = { "receipt_id": "rcpt_test126", "execution_id": "exec_004", @@ -154,6 +171,50 @@ def test_get_receipt_canonicalizes_same_provider_alias_text_when_structured_fiel assert "brave-search-api-api" not in body["data"]["winner_reason"] +def test_verify_receipt_returns_compact_verifier_status(client): + receipt_data = { + "receipt_id": "rcpt_test_verify", + "execution_id": "exec_verify", + "status": "success", + "capability_id": "search.query", + "provider_id": "brave-search-api", + "route_id": "route_search_query_brave_search_api_official_api_v1", + "service_id": "brave-search-api", + "substrate": "official_api", + "provenance_origin": "rhumb_managed", + "source_risk": "verified_low", + "manifest_digest": "sha256:manifest", + "evidence_packet_digest": "sha256:evidence", + "route_plan_id_hash": "sha256:plan", + "request_hash": "sha256:req", + "response_hash": "sha256:resp", + "chain_sequence": 7, + "receipt_hash": "sha256:receipt", + "previous_receipt_hash": "sha256:prev", + "next_recommended_action": "fetch_or_verify_receipt", + } + with ( + patch("services.receipt_service.supabase_fetch", new_callable=AsyncMock) as mock_fetch, + patch( + "routes.receipts_v2.get_persisted_explanation_by_receipt", new_callable=AsyncMock + ) as mock_exp, + ): + mock_fetch.return_value = [receipt_data] + mock_exp.return_value = object() + resp = client.post("/v2/receipts/rcpt_test_verify/verify") + + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["verifier_status"] == "verified" + assert data["checks"]["route_plan_hash_present"] is True + assert data["checks"]["route_facts_present"] is True + assert data["hashes"]["input_hash_status"] == "present" + assert data["redaction"]["raw_payload_returned"] is False + assert data["compact_receipt"]["route"]["route_id"] == receipt_data["route_id"] + assert data["compact_receipt"]["route_plan"]["route_plan_id_hash"] == "sha256:plan" + assert "global chain continuity" in " ".join(data["what_is_not_proven"]) + + def test_query_receipts_empty(client): """GET /v2/receipts returns empty list when no receipts match.""" with patch("services.receipt_service.supabase_fetch", new_callable=AsyncMock) as mock_fetch: @@ -253,7 +314,9 @@ def test_query_receipts_rejects_blank_filters_before_reads(client, field, query) def test_query_receipts_trims_filters_before_reads(client): with patch("services.receipt_service.supabase_fetch", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = [] - resp = client.get("/v2/receipts?agent_id=%20agent_test%20&provider_id=%20brave-search-api%20") + resp = client.get( + "/v2/receipts?agent_id=%20agent_test%20&provider_id=%20brave-search-api%20" + ) assert resp.status_code == 200 query = mock_fetch.await_args.args[0] @@ -291,11 +354,19 @@ def test_query_receipts_canonicalizes_alias_backed_provider_text(client): body = resp.json() assert body["data"]["receipts"][0]["provider_id"] == "people-data-labs" assert body["data"]["receipts"][0]["provider_name"] == "people-data-labs" - assert body["data"]["receipts"][0]["error_message"] == "people-data-labs credential unavailable" - assert body["data"]["receipts"][0]["winner_reason"] == "people-data-labs won on contact coverage" + assert ( + body["data"]["receipts"][0]["error_message"] + == "people-data-labs credential unavailable" + ) + assert ( + body["data"]["receipts"][0]["winner_reason"] + == "people-data-labs won on contact coverage" + ) -def test_query_receipts_canonicalizes_known_alternate_provider_alias_text_without_raw_provider_hint(client): +def test_query_receipts_canonicalizes_known_alternate_provider_alias_text_without_raw_provider_hint( + client, +): receipts = [ { "receipt_id": "rcpt_2", @@ -310,11 +381,19 @@ def test_query_receipts_canonicalizes_known_alternate_provider_alias_text_withou assert resp.status_code == 200 body = resp.json() assert body["data"]["receipts"][0]["provider_id"] == "brave-search-api" - assert body["data"]["receipts"][0]["error_message"] == "brave-search-api failed after people-data-labs credential lookup" - assert body["data"]["receipts"][0]["winner_reason"] == "brave-search-api fell back to people-data-labs on contact coverage" + assert ( + body["data"]["receipts"][0]["error_message"] + == "brave-search-api failed after people-data-labs credential lookup" + ) + assert ( + body["data"]["receipts"][0]["winner_reason"] + == "brave-search-api fell back to people-data-labs on contact coverage" + ) -def test_query_receipts_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical(client): +def test_query_receipts_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical( + client, +): receipts = [ { "receipt_id": "rcpt_3", @@ -370,7 +449,9 @@ def test_get_receipt_explanation_uses_persisted_receipt_link(client): ) with ( patch("services.receipt_service.supabase_fetch", new_callable=AsyncMock) as mock_fetch, - patch("routes.receipts_v2.get_persisted_explanation_by_receipt", new_callable=AsyncMock) as mock_get, + patch( + "routes.receipts_v2.get_persisted_explanation_by_receipt", new_callable=AsyncMock + ) as mock_get, ): mock_fetch.return_value = [receipt_data] mock_get.return_value = persisted @@ -420,7 +501,9 @@ def test_verify_chain_invalid_range_uses_explicit_invalid_parameters(client): assert mock_fetch.await_count == 0 -@pytest.mark.parametrize("query", ["limit=0", "limit=1001", "limit=ten", "limit=false", "limit=%20%20"]) +@pytest.mark.parametrize( + "query", ["limit=0", "limit=1001", "limit=ten", "limit=false", "limit=%20%20"] +) def test_verify_chain_rejects_invalid_limit_before_reads(client, query): with patch("services.receipt_service.supabase_fetch", new_callable=AsyncMock) as mock_fetch: resp = client.get(f"/v2/receipts/chain/verify?{query}") @@ -429,7 +512,10 @@ def test_verify_chain_rejects_invalid_limit_before_reads(client, query): body = resp.json() assert body["error"]["code"] == "INVALID_PARAMETERS" assert body["error"]["message"] == "Invalid 'limit' filter." - assert "between 1 and 1000" in body["error"]["detail"] or "integer value" in body["error"]["detail"] + assert ( + "between 1 and 1000" in body["error"]["detail"] + or "integer value" in body["error"]["detail"] + ) assert mock_fetch.await_count == 0 diff --git a/packages/api/tests/test_resolve_boundary_contract.py b/packages/api/tests/test_resolve_boundary_contract.py new file mode 100644 index 00000000..bfbd430e --- /dev/null +++ b/packages/api/tests/test_resolve_boundary_contract.py @@ -0,0 +1,116 @@ +"""PP-0 boundary contract coverage for Index / Resolve / Runtime vNext.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app import create_app +from schemas.resolve_boundary_contract import ( + FIELD_OWNERSHIP_MATRIX, + INDEX_STORED_FIELDS, + ROUTE_PLAN_ENFORCEMENT_CHECKS, + RUNTIME_FORBIDDEN_DECISION_FIELDS, + boundary_contract_payload, + missing_route_plan_enforcement_checks, + runtime_decision_mutations, + unexpected_resolve_owned_index_fields, +) + + +@pytest.mark.asyncio +async def test_boundary_contract_endpoint_exposes_pp0_ownership_interface() -> None: + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get("/v2/boundary-contract") + + assert response.status_code == 200 + payload = response.json() + assert payload["error"] is None + data = payload["data"] + + assert data["contract_id"] == "index_resolve_runtime_boundary_v1" + assert data["source"] == "PP-0" + assert data["status"] == "active" + assert {layer["owner"] for layer in data["layers"]} == {"index", "resolve", "runtime"} + + matrix = {row["group"]: row for row in data["field_ownership_matrix"]} + assert "stable_identity" in matrix + assert "route_id" in matrix["stable_identity"]["index_stored"] + assert "selected_route_id" in matrix["stable_identity"]["resolve_computed"] + assert "execution_id" in matrix["stable_identity"]["runtime_issued"] + + runtime_layer = next(layer for layer in data["layers"] if layer["owner"] == "runtime") + assert "rank or rerank route candidates" in runtime_layer["must_not"] + assert "an_score" in data["runtime_forbidden_decision_fields"] + assert "principal_matches" in data["route_plan_enforcement_checks"] + assert data["_rhumb_v2"]["layer"] == 2 + + +def test_contract_has_single_owner_for_core_field_groups() -> None: + payload = boundary_contract_payload() + matrix = payload["field_ownership_matrix"] + + for row in matrix: + assert row["index_stored"] or row["resolve_computed"] or row["runtime_issued"] + assert not set(row["index_stored"]).intersection(row["resolve_computed"]) + assert not set(row["index_stored"]).intersection(row["runtime_issued"]) + assert not set(row["resolve_computed"]).intersection(row["runtime_issued"]) + + assert {"service_id", "provider_id", "capability_id", "route_id"}.issubset(INDEX_STORED_FIELDS) + + +def test_contract_fields_have_single_global_owner() -> None: + seen: dict[str, str] = {} + for row in FIELD_OWNERSHIP_MATRIX: + for bucket in ("index_stored", "resolve_computed", "runtime_issued"): + for field in getattr(row, bucket): + owner = f"{row.group}.{bucket}" + assert field not in seen, f"{field} is owned by both {seen[field]} and {owner}" + seen[field] = owner + + +def test_resolve_computed_blob_cannot_smuggle_index_truth() -> None: + candidate = { + "route_id": "route_search_query_brave_api_v1", + "provider_id": "brave-search-api", + "resolve_computed": { + "selected_route_id": "route_search_query_brave_api_v1", + "safety_state": "executable", + "manifest_digest": "sha256:resolve-must-not-invent-this", + }, + } + + assert unexpected_resolve_owned_index_fields(candidate) == {"manifest_digest"} + + +def test_runtime_payload_cannot_rerank_or_mutate_resolve_decision() -> None: + runtime_payload = { + "execution_id": "exec_123", + "receipt_id": "rcpt_123", + "runtime_issued": { + "receipt_status": "issued", + "an_score": 9.9, + }, + "selected_route_id": "runtime-picked-route", + } + + assert runtime_decision_mutations(runtime_payload) == {"an_score", "selected_route_id"} + assert "an_score" in RUNTIME_FORBIDDEN_DECISION_FIELDS + + +def test_route_plan_checks_fail_closed_until_every_required_check_passes() -> None: + checks = {name: True for name in ROUTE_PLAN_ENFORCEMENT_CHECKS} + checks["principal_matches"] = False + del checks["not_revoked"] + + assert missing_route_plan_enforcement_checks(checks) == {"principal_matches", "not_revoked"} + assert ( + missing_route_plan_enforcement_checks( + {name: True for name in ROUTE_PLAN_ENFORCEMENT_CHECKS} + ) + == set() + ) diff --git a/packages/api/tests/test_resolve_launch_catalog_contract.py b/packages/api/tests/test_resolve_launch_catalog_contract.py new file mode 100644 index 00000000..21aa2a47 --- /dev/null +++ b/packages/api/tests/test_resolve_launch_catalog_contract.py @@ -0,0 +1,67 @@ +"""Launch-catalog contract tests for the Rhumb-managed public P0 surface.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +from routes.proxy import SERVICE_REGISTRY +from services.proxy_auth import AuthInjector +from services.proxy_credentials import _ENV_FALLBACK +from services.service_slugs import normalize_proxy_slug + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / "scripts" / "resolve_launch_catalog_smoke.py" + + +def _load_catalog_module(): + spec = importlib.util.spec_from_file_location("resolve_launch_catalog_smoke", SCRIPT_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_p0_launch_catalog_has_expected_capability_set() -> None: + module = _load_catalog_module() + + assert set(module.P0_CATALOG) == { + "search.query", + "scrape.extract", + "ai.generate_text", + "ai.generate_image", + "data.enrich_person", + "data.enrich_company", + "geo.lookup", + "maps.places_search", + } + + +def test_p0_launch_catalog_providers_have_proxy_auth_and_env_fallbacks() -> None: + module = _load_catalog_module() + + expected_providers = {spec["provider"] for spec in module.P0_CATALOG.values()} + for provider in expected_providers: + proxy_provider = normalize_proxy_slug(provider) + assert proxy_provider in SERVICE_REGISTRY + assert proxy_provider in _ENV_FALLBACK + assert AuthInjector.default_method_for(proxy_provider) is not None + + +def test_p0_launch_catalog_keeps_person_enrichment_on_proof_substitute() -> None: + module = _load_catalog_module() + + assert module.P0_CATALOG["data.enrich_person"]["execution_policy"] == "proof_substitute" + assert "consented" in module.P0_CATALOG["data.enrich_person"]["proof_substitute"] + + +def test_external_write_and_missing_provider_surfaces_are_deferred() -> None: + module = _load_catalog_module() + + assert "email.send" in module.DEFERRED_CAPABILITIES + assert "email.verify" in module.DEFERRED_CAPABILITIES + assert "sendgrid" in module.DEFERRED_CAPABILITIES + assert "External-write" in module.DEFERRED_CAPABILITIES["email.send"] + assert "RHUMB_CREDENTIAL_SENDGRID_API_KEY" in module.DEFERRED_CAPABILITIES["sendgrid"] diff --git a/packages/api/tests/test_resolve_route_candidate.py b/packages/api/tests/test_resolve_route_candidate.py new file mode 100644 index 00000000..0b80bac0 --- /dev/null +++ b/packages/api/tests/test_resolve_route_candidate.py @@ -0,0 +1,223 @@ +"""PP-14 route candidate schema/state-machine coverage.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app import create_app +from schemas.resolve_route_candidate import ( + SAFETY_STATES, + STOP_CONDITIONS, + infer_safety_state_and_stop, + route_candidate_from_provider, + route_candidates_from_resolve_data, +) + + +@pytest.mark.parametrize( + ("provider", "expected_state", "expected_stop"), + [ + ({"endpoint_pattern": "GET /res/v1/web/search", "configured": True}, "executable", None), + ( + {"endpoint_pattern": "GET /res/v1/web/search", "configured": False}, + "requires_credentials", + "missing_credentials", + ), + ( + {"endpoint_pattern": "POST /danger", "requires_confirmation": True}, + "requires_confirmation", + "high_risk_requires_confirmation", + ), + ( + {"endpoint_pattern": "GET /x", "blocked_security": True}, + "blocked_security", + "unverified_artifact", + ), + ({"endpoint_pattern": "GET /x", "blocked_policy": True}, "blocked_policy", "policy_denied"), + ({"endpoint_pattern": None}, "unsupported", "missing_manifest"), + ( + {"endpoint_pattern": "GET /x", "promotion_state": "experimental_non_default"}, + "experimental_non_default", + None, + ), + ( + {"endpoint_pattern": "GET /x", "kill_switch_state": "unavailable"}, + "blocked_security", + "kill_switch_state_unavailable", + ), + ({"safety_state": "requires_credentials"}, "requires_credentials", "missing_credentials"), + ( + {"safety_state": "requires_confirmation"}, + "requires_confirmation", + "high_risk_requires_confirmation", + ), + ({"safety_state": "blocked_policy"}, "blocked_policy", "policy_denied"), + ({"safety_state": "blocked_security"}, "blocked_security", "unverified_artifact"), + ({"safety_state": "unsupported"}, "unsupported", "unsupported"), + ], +) +def test_route_candidate_state_machine_covers_required_pp14_states( + provider, expected_state, expected_stop +) -> None: + assert infer_safety_state_and_stop(provider) == (expected_state, expected_stop) + assert expected_state in SAFETY_STATES + if expected_stop is not None: + assert expected_stop in STOP_CONDITIONS + + +def test_route_candidate_contains_canonical_core_fields() -> None: + candidate = route_candidate_from_provider( + capability_id="search.query", + provider={ + "service_slug": "brave-search-api", + "provider_id": "brave-search-api", + "endpoint_pattern": "GET /res/v1/web/search", + "credential_modes": ["byok"], + "configured": True, + "substrate": "official_api", + "provenance_origin": "vendor_official", + "source_risk": "verified_low", + "promotion_state": "production_executable", + "review_status": "current", + "manifest_id": "manifest_brave_search_v1", + "manifest_digest": "sha256:manifest", + "evidence_packet_id": "evidence_brave_search_v1", + "evidence_packet_digest": "sha256:evidence", + "receipt_support": "verifiable", + }, + rank=1, + selected_provider_id="brave-search-api", + alternatives_considered=["brave-search-api"], + ) + + assert candidate["route_candidate_id"].startswith("route_candidate_01_brave_search_api_") + assert candidate["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert candidate["capability_id"] == "search.query" + assert candidate["service_id"] == "brave-search" + assert candidate["provider_id"] == "brave-search-api" + assert candidate["substrate"] == "official_api" + assert candidate["provenance_origin"] == "rhumb_managed" + assert candidate["source_risk"] == "verified_low" + assert candidate["promotion_state"] == "beta_executable" + assert candidate["review_status"] == "current" + assert candidate["safety_state"] == "executable" + assert candidate["stop_condition"] is None + assert candidate["credential_mode"] == "byok" + assert candidate["receipt_support"] == "verifiable" + assert candidate["why_selected"] == "highest_ranked_executable_candidate" + + +def test_estimate_payload_builds_single_route_candidate_with_auth_stop() -> None: + candidates = route_candidates_from_resolve_data( + { + "capability_id": "workflow_run.list", + "provider": "github", + "credential_mode": "byok", + "endpoint_pattern": "POST /v2/capabilities/workflow_run.list/execute", + "execute_readiness": {"status": "auth_required"}, + } + ) + + assert len(candidates) == 1 + assert candidates[0]["provider_id"] == "github" + assert candidates[0]["safety_state"] == "requires_credentials" + assert candidates[0]["stop_condition"] == "missing_credentials" + + +@pytest.mark.anyio +async def test_v2_resolve_exposes_route_candidates_from_current_resolve_payload( + monkeypatch, +) -> None: + async def mock_forward(*_args, **_kwargs): + class Response: + status_code = 200 + headers = {} + + @staticmethod + def json(): + return { + "data": { + "capability": "search.query", + "providers": [ + { + "service_slug": "brave-search-api", + "endpoint_pattern": "GET /res/v1/web/search", + "credential_modes": ["byok"], + "configured": True, + "available_for_execute": True, + }, + { + "service_slug": "browser-private-search", + "endpoint_pattern": "GET /private/search", + "credential_modes": ["byok"], + "blocked_security": True, + "stop_condition": "anti_bot_or_access_control_risk", + "substrate": "browser_discovered_private_endpoint", + "provenance_origin": "browser_observed", + "source_risk": "anti_bot_or_tos_sensitive", + }, + ], + "fallback_chain": [], + "related_bundles": [], + "execute_hint": {"preferred_provider": "brave-search-api"}, + }, + "error": None, + } + + return Response() + + monkeypatch.setattr("routes.resolve_v2._forward_internal", mock_forward) + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/v2/capabilities/search.query/resolve") + + assert response.status_code == 200 + data = response.json()["data"] + assert data["route_contract"]["contract_id"] == "resolve_route_candidate_v1" + assert data["route_contract"]["source"] == "PP-14" + assert [candidate["provider_id"] for candidate in data["route_candidates"]] == [ + "brave-search-api", + "browser-private-search", + ] + assert data["route_candidates"][0]["safety_state"] == "executable" + assert data["route_candidates"][1]["safety_state"] == "blocked_security" + assert data["route_candidates"][1]["stop_condition"] == "anti_bot_or_access_control_risk" + + +@pytest.mark.anyio +async def test_v2_estimate_exposes_route_candidate_from_estimate_payload(monkeypatch) -> None: + async def mock_forward(*_args, **_kwargs): + class Response: + status_code = 200 + headers = {} + + @staticmethod + def json(): + return { + "data": { + "capability_id": "workflow_run.list", + "provider": "github", + "credential_mode": "byo", + "endpoint_pattern": "POST /v1/capabilities/workflow_run.list/execute", + "cost_estimate_usd": 0.01, + "execute_readiness": {"status": "auth_required"}, + }, + "error": None, + } + + return Response() + + monkeypatch.setattr("routes.resolve_v2._forward_internal", mock_forward) + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/v2/capabilities/workflow_run.list/execute/estimate") + + assert response.status_code == 200 + data = response.json()["data"] + assert data["credential_mode"] == "byok" + assert data["route_candidates"][0]["provider_id"] == "github" + assert data["route_candidates"][0]["safety_state"] == "requires_credentials" + assert data["route_candidates"][0]["stop_condition"] == "missing_credentials" diff --git a/packages/api/tests/test_resolve_v2.py b/packages/api/tests/test_resolve_v2.py index 233d44c7..8d94d947 100644 --- a/packages/api/tests/test_resolve_v2.py +++ b/packages/api/tests/test_resolve_v2.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -24,8 +25,7 @@ def _request_with_headers(headers: dict[str, str]) -> Request: raw_headers = [ - (key.lower().encode("latin-1"), value.encode("latin-1")) - for key, value in headers.items() + (key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers.items() ] return Request({"type": "http", "method": "POST", "path": "/", "headers": raw_headers}) @@ -63,20 +63,78 @@ def _mock_policy_store(): @pytest.fixture(autouse=True) def _mock_v2_budget_enforcer(): mock_enforcer = MagicMock() - mock_enforcer.get_budget = AsyncMock(return_value=BudgetStatus( - allowed=True, - remaining_usd=None, - budget_usd=None, - spent_usd=None, - period=None, - hard_limit=None, - alert_threshold_pct=None, - alert_fired=None, - )) + mock_enforcer.get_budget = AsyncMock( + return_value=BudgetStatus( + allowed=True, + remaining_usd=None, + budget_usd=None, + spent_usd=None, + period=None, + hard_limit=None, + alert_threshold_pct=None, + alert_fired=None, + ) + ) with patch("routes.resolve_v2._v2_budget_enforcer", mock_enforcer): yield mock_enforcer +@pytest.fixture(autouse=True) +def _mock_v2_route_plan_runtime_seams(): + class MemoryRoutePlanStore: + def __init__(self): + self.seen: set[str] = set() + self.revoked: set[str] = set() + + async def check_and_claim( + self, *, nonce, route_plan_id_hash, expires_at, allow_fallback=True + ): + nonce_hash = ( + resolve_v2_route.canonical_json_sha256({"route_plan_nonce": nonce}) + if nonce + else None + ) + if nonce_hash in self.revoked: + return SimpleNamespace( + allowed=False, + stop_condition="route_plan_revoked", + state_backend="memory_fallback", + nonce_hash=nonce_hash, + detail="revoked in test", + ) + if nonce_hash in self.seen: + return SimpleNamespace( + allowed=False, + stop_condition="route_plan_replay", + state_backend="memory_fallback", + nonce_hash=nonce_hash, + detail="replayed in test", + ) + self.seen.add(nonce_hash) + return SimpleNamespace( + allowed=True, + stop_condition=None, + state_backend="memory_fallback", + nonce_hash=nonce_hash, + detail="", + ) + + route_plan_store = MemoryRoutePlanStore() + kill_switch_registry = MagicMock() + kill_switch_registry.is_blocked.return_value = (False, "") + with ( + patch( + "routes.resolve_v2.init_route_plan_state_store", + new=AsyncMock(return_value=route_plan_store), + ), + patch( + "routes.resolve_v2.init_kill_switch_registry", + new=AsyncMock(return_value=kill_switch_registry), + ), + ): + yield {"route_plan_store": route_plan_store, "kill_switch_registry": kill_switch_registry} + + @pytest.fixture(autouse=True) def _mock_v1_execute_runtime_seams(): mock_rate_limiter = MagicMock() @@ -86,11 +144,24 @@ def _mock_v1_execute_runtime_seams(): mock_kill_switch_registry.is_blocked.return_value = (False, None) with ( - patch("routes.capability_execute._get_rate_limiter", new=AsyncMock(return_value=mock_rate_limiter)), - patch("routes.capability_execute.init_kill_switch_registry", new=AsyncMock(return_value=mock_kill_switch_registry)), - patch("routes.capability_execute.check_billing_health", new=AsyncMock(return_value=(True, None))), - patch("routes.capability_execute.supabase_insert_required", new=AsyncMock(return_value=True)), - patch("routes.capability_execute.supabase_patch_required", new=AsyncMock(return_value=True)), + patch( + "routes.capability_execute._get_rate_limiter", + new=AsyncMock(return_value=mock_rate_limiter), + ), + patch( + "routes.capability_execute.init_kill_switch_registry", + new=AsyncMock(return_value=mock_kill_switch_registry), + ), + patch( + "routes.capability_execute.check_billing_health", + new=AsyncMock(return_value=(True, None)), + ), + patch( + "routes.capability_execute.supabase_insert_required", new=AsyncMock(return_value=True) + ), + patch( + "routes.capability_execute.supabase_patch_required", new=AsyncMock(return_value=True) + ), patch( "routes.capability_execute._budget_enforcer.check_and_decrement", new=AsyncMock( @@ -106,7 +177,9 @@ def _mock_v1_execute_runtime_seams(): ) ), ), - patch("routes.capability_execute._budget_enforcer.release", new=AsyncMock(return_value=None)), + patch( + "routes.capability_execute._budget_enforcer.release", new=AsyncMock(return_value=None) + ), patch( "routes.capability_execute._credit_deduction.deduct", new=AsyncMock( @@ -239,25 +312,29 @@ def _mock_db_direct_supabase_with_stale_mapping(path: str): def _mock_search_alias_supabase(path: str): if path.startswith("capabilities?"): if "id=eq.search.query" in path: - return [{ - "id": "search.query", - "domain": "search", - "action": "query", - "description": "Search the web through Brave Search", - }] + return [ + { + "id": "search.query", + "domain": "search", + "action": "query", + "description": "Search the web through Brave Search", + } + ] return [] if path.startswith("capability_services?"): if "capability_id=eq.search.query" in path: - return [{ - "capability_id": "search.query", - "service_slug": "brave-search", - "credential_modes": ["byo"], - "auth_method": "api_key", - "endpoint_pattern": "GET /res/v1/web/search", - "cost_per_call": 0.003, - "cost_currency": "USD", - "free_tier_calls": 2000, - }] + return [ + { + "capability_id": "search.query", + "service_slug": "brave-search", + "credential_modes": ["byo"], + "auth_method": "api_key", + "endpoint_pattern": "GET /res/v1/web/search", + "cost_per_call": 0.003, + "cost_currency": "USD", + "free_tier_calls": 2000, + } + ] return [] if path.startswith("scores?"): return [] @@ -276,6 +353,41 @@ def _make_mock_response(status_code: int = 202, json_body: dict | None = None): return resp +def _route_plan_policy_eval(provider: str = "brave-search-api") -> SimpleNamespace: + return SimpleNamespace( + decision=SimpleNamespace( + selected_provider=provider, + selected_reason="policy_preference_match", + candidate_providers=[provider], + policy_summary={"provider_preference": [provider]}, + ), + all_mappings=[], + eligible_mappings=[], + ) + + +def _route_plan_no_policy_eval() -> SimpleNamespace: + return SimpleNamespace(decision=None, all_mappings=[], eligible_mappings=[]) + + +def _route_plan_estimate_response(provider: str = "brave-search-api"): + resp = _make_mock_response( + status_code=200, + json_body={ + "data": { + "capability_id": "search.query", + "provider": provider, + "credential_mode": "byok", + "cost_estimate_usd": 0.003, + "endpoint_pattern": "GET /res/v1/web/search", + }, + "error": None, + }, + ) + resp.headers = {} + return resp + + def _build_patches(): mock_response = _make_mock_response() @@ -347,7 +459,7 @@ async def mock_cached_fetch(table: str, path: str, ttl: float = 30.0): del table, ttl if path.startswith("capabilities?"): return [DB_DIRECT_CAPABILITY] - if path.startswith("capability_services?") and "capability_id=in.(\"db.query.read\")" in path: + if path.startswith("capability_services?") and 'capability_id=in.("db.query.read")' in path: return [ { "capability_id": "db.query.read", @@ -397,7 +509,9 @@ async def mock_cached_fetch(table: str, path: str, ttl: float = 30.0): ] with ( - patch("routes.capabilities._cached_fetch", new=AsyncMock(side_effect=mock_cached_fetch)) as mock_fetch, + patch( + "routes.capabilities._cached_fetch", new=AsyncMock(side_effect=mock_cached_fetch) + ) as mock_fetch, patch("routes.capabilities._synthetic_capability_records", return_value=[]), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -460,7 +574,11 @@ async def test_v2_capabilities_rejects_invalid_offset_filter(app): ({"limit": "ten"}, "Invalid 'limit' filter.", "Provide an integer between 1 and 100."), ({"limit": "true"}, "Invalid 'limit' filter.", "Provide an integer between 1 and 100."), ({"limit": " "}, "Invalid 'limit' filter.", "Provide an integer between 1 and 100."), - ({"offset": "false"}, "Invalid 'offset' filter.", "Provide an integer greater than or equal to 0."), + ( + {"offset": "false"}, + "Invalid 'offset' filter.", + "Provide an integer greater than or equal to 0.", + ), ], ) async def test_v2_capabilities_rejects_malformed_pagination_before_reads( @@ -509,7 +627,9 @@ async def mock_cached_fetch(table: str, path: str, ttl: float = 30.0): return [] with ( - patch("routes.capabilities._cached_fetch", new=AsyncMock(side_effect=mock_cached_fetch)) as mock_fetch, + patch( + "routes.capabilities._cached_fetch", new=AsyncMock(side_effect=mock_cached_fetch) + ) as mock_fetch, patch("routes.capabilities._synthetic_capability_records", return_value=[]), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -558,7 +678,9 @@ async def mock_fetch(path: str): return [{"slug": "resend", "name": "Resend"}] return [] - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=mock_fetch): + with patch( + "routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=mock_fetch + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v2/capabilities", params={"search": "resend"}) @@ -578,7 +700,9 @@ async def mock_fetch(path: str): @pytest.mark.anyio async def test_v2_resolve_wraps_metadata_and_rewrites_nested_urls(app): - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase): + with patch( + "routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/email.send/resolve", @@ -596,10 +720,93 @@ async def test_v2_resolve_wraps_metadata_and_rewrites_nested_urls(app): "layer": 2, } assert data["execute_hint"]["preferred_credential_mode"] == "byok" - assert data["execute_hint"]["credential_modes_url"] == "/v2/capabilities/email.send/credential-modes" + assert ( + data["execute_hint"]["credential_modes_url"] + == "/v2/capabilities/email.send/credential-modes" + ) assert all(provider["credential_modes"] == ["byok"] for provider in data["providers"]) +@pytest.mark.anyio +async def test_v2_resolve_issues_route_plan_ids_only_for_issuable_candidates(app): + issued_at = 1_800_000_000 + resolve_resp = _make_mock_response( + status_code=200, + json_body={ + "data": { + "capability_id": "search.query", + "providers": [ + { + "service_slug": "brave-search-api", + "credential_modes": ["byok"], + "auth_method": "api_key", + "endpoint_pattern": "GET /res/v1/web/search", + "cost_per_call": 0.003, + }, + { + "service_slug": "dry-run-provider", + "credential_modes": ["byok"], + "auth_method": "api_key", + "endpoint_pattern": "POST /dry-run", + "safety_state": "dry_run_only", + }, + { + "service_slug": "blocked-provider", + "credential_modes": ["byok"], + "auth_method": "api_key", + "endpoint_pattern": "POST /blocked", + "safety_state": "blocked_policy", + "stop_condition": "policy_denied", + }, + ], + "execute_hint": {"preferred_provider": "brave-search-api"}, + }, + "error": None, + }, + ) + resolve_resp.headers = {} + + with ( + patch("routes.resolve_v2._forward_internal", new=AsyncMock(return_value=resolve_resp)), + patch("routes.resolve_v2.time.time", return_value=issued_at), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/v2/capabilities/search.query/resolve") + + assert resp.status_code == 200 + candidates = resp.json()["data"]["route_candidates"] + executable, dry_run, blocked = candidates + assert executable["safety_state"] == "executable" + assert executable["route_plan_id"].startswith("rhrp1.") + assert executable["route_plan_expires_at"] == issued_at + 300 + assert dry_run["safety_state"] == "dry_run_only" + assert dry_run["route_plan_id"].startswith("rhrp1.") + assert dry_run["route_plan_expires_at"] == issued_at + 300 + assert blocked["safety_state"] == "blocked_policy" + assert blocked["route_plan_id"] is None + assert blocked["route_plan_expires_at"] is None + + _, policy_source = resolve_v2_route._merge_effective_policy(None, None) + _, expected = resolve_v2_route._route_plan_boundary_facts_from_candidate( + capability_id="search.query", + route_candidate=executable, + credential_mode=None, + parameters={}, + raw_request=_request_with_headers({}), + agent=None, + policy_summary=None, + policy_source=policy_source, + budget_summary=None, + ) + validation = resolve_v2_route.validate_route_plan( + executable["route_plan_id"], + expected, + resolve_v2_route.get_signing_key(), + now=issued_at + 1, + ) + assert validation["allowed"] is True + + @pytest.mark.anyio async def test_v2_resolve_rejects_invalid_credential_mode_filter_before_forward(app): with patch("routes.resolve_v2._forward_internal", new=AsyncMock()) as mock_forward: @@ -718,9 +925,369 @@ async def test_v2_execute_rejects_missing_payload_before_policy_reads(app): mock_forward.assert_not_awaited() +@pytest.mark.anyio +async def test_v2_execute_enforces_internal_same_planner_route_plan(app): + execute_resp = _make_mock_response( + status_code=200, + json_body={ + "data": { + "execution_id": "exec_v2_route_plan_success", + "provider_used": "brave-search-api", + "credential_mode": "byok", + "result": {"ok": True}, + }, + "error": None, + }, + ) + execute_resp.headers = {} + + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[_route_plan_estimate_response(), execute_resp]), + ) as mock_forward, + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post( + "/v2/capabilities/search.query/execute", + json={"parameters": {"q": "rhumb"}, "credential_mode": "byok"}, + ) + + assert resp.status_code == 200 + data = resp.json()["data"] + enforcement = data["_rhumb_v2"]["route_plan_enforcement"] + assert enforcement["mode"] == "internal_same_planner" + assert enforcement["status"] == "enforced" + assert enforcement["route_plan_id_hash"].startswith("sha256:") + assert "route_plan_id" not in enforcement + assert enforcement["binding"]["provider_id"] == "brave-search-api" + assert enforcement["binding"]["credential_mode"] == "byok" + compact = data["_rhumb_v2"]["compact_receipt"] + assert compact["mode"] == "compact" + assert compact["status"] == "success" + assert compact["route"]["provider_id"] == "brave-search-api" + assert compact["route"]["route_id"] == enforcement["binding"]["route_id"] + assert compact["route_plan"]["route_plan_id_hash"] == enforcement["route_plan_id_hash"] + assert compact["next_recommended_action"] == "fetch_or_verify_receipt" + assert "provider-side content truth" in " ".join(compact["what_is_not_proven"]) + assert mock_forward.await_count == 2 + + +@pytest.mark.anyio +async def test_v2_estimate_route_plan_can_bind_planned_parameters_for_execute(app): + execute_resp = _make_mock_response( + status_code=200, + json_body={ + "data": { + "execution_id": "exec_v2_supplied_route_plan_success", + "provider_used": "brave-search-api", + "credential_mode": "byok", + "result": {"ok": True}, + }, + "error": None, + }, + ) + execute_resp.headers = {} + + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_no_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock( + side_effect=[ + _route_plan_estimate_response(), + _route_plan_estimate_response(), + execute_resp, + ] + ), + ), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + estimate = await client.get( + "/v2/capabilities/search.query/execute/estimate", + params={ + "credential_mode": "byok", + "provider": "brave-search-api", + "planned_parameters": json.dumps({"q": "rhumb"}), + }, + ) + route_plan_id = estimate.json()["data"]["route_candidates"][0]["route_plan_id"] + route_plan_input_hash = estimate.json()["data"]["route_candidates"][0][ + "route_plan_input_hash" + ] + + resp = await client.post( + "/v2/capabilities/search.query/execute", + json={ + "parameters": {"q": "rhumb"}, + "credential_mode": "byok", + "route_plan_id": route_plan_id, + }, + ) + + assert resp.status_code == 200 + enforcement = resp.json()["data"]["_rhumb_v2"]["route_plan_enforcement"] + assert enforcement["mode"] == "supplied_signed_plan" + assert enforcement["binding"]["input_hash"] == route_plan_input_hash + assert enforcement["state_backend"] == "memory_fallback" + assert enforcement["nonce_hash"].startswith("sha256:") + + +@pytest.mark.anyio +async def test_v2_execute_rejects_supplied_route_plan_replay_before_runtime(app): + execute_resp = _make_mock_response( + status_code=200, + json_body={ + "data": { + "execution_id": "exec_v2_supplied_route_plan_replay", + "provider_used": "brave-search-api", + "credential_mode": "byok", + "result": {"ok": True}, + }, + "error": None, + }, + ) + execute_resp.headers = {} + + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_no_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock( + side_effect=[ + _route_plan_estimate_response(), + _route_plan_estimate_response(), + execute_resp, + _route_plan_estimate_response(), + ] + ), + ) as mock_forward, + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + estimate = await client.get( + "/v2/capabilities/search.query/execute/estimate", + params={ + "credential_mode": "byok", + "provider": "brave-search-api", + "planned_parameters": json.dumps({"q": "rhumb"}), + }, + ) + route_plan_id = estimate.json()["data"]["route_candidates"][0]["route_plan_id"] + + first = await client.post( + "/v2/capabilities/search.query/execute", + json={ + "parameters": {"q": "rhumb"}, + "credential_mode": "byok", + "route_plan_id": route_plan_id, + }, + ) + second = await client.post( + "/v2/capabilities/search.query/execute", + json={ + "parameters": {"q": "rhumb"}, + "credential_mode": "byok", + "route_plan_id": route_plan_id, + }, + ) + + assert first.status_code == 200 + assert second.status_code == 409 + error = second.json()["error"] + assert error["code"] == "ROUTE_PLAN_REJECTED" + assert error["stop_condition"] == "route_plan_replay" + assert "durable_replay_not_seen" in error["route_plan_enforcement"]["failed_checks"] + assert mock_forward.await_count == 4 + + +@pytest.mark.anyio +async def test_v2_execute_rejects_route_plan_when_kill_switch_blocks( + app, _mock_v2_route_plan_runtime_seams +): + _mock_v2_route_plan_runtime_seams["kill_switch_registry"].is_blocked.return_value = ( + True, + "Provider kill switch active: incident", + ) + + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(return_value=_route_plan_estimate_response()), + ) as mock_forward, + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post( + "/v2/capabilities/search.query/execute", + json={"parameters": {"q": "rhumb"}, "credential_mode": "byok"}, + ) + + assert resp.status_code == 409 + error = resp.json()["error"] + assert error["code"] == "ROUTE_PLAN_REJECTED" + assert error["stop_condition"] == "route_plan_mismatch" + assert "kill_switch_allows_execution" in error["route_plan_enforcement"]["failed_checks"] + assert mock_forward.await_count == 1 + + +@pytest.mark.anyio +async def test_v2_execute_rejects_bad_signed_route_plan_before_runtime(app): + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(return_value=_route_plan_estimate_response()), + ) as mock_forward, + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post( + "/v2/capabilities/search.query/execute", + json={ + "parameters": {"q": "rhumb"}, + "credential_mode": "byok", + "route_plan_id": "rhrp1.bad.bad", + }, + ) + + assert resp.status_code == 409 + error = resp.json()["error"] + assert error["code"] == "ROUTE_PLAN_REJECTED" + assert error["stop_condition"] == "route_plan_signature_invalid" + assert error["route_plan_enforcement"]["mode"] == "supplied_signed_plan" + assert "signature_valid" in error["route_plan_enforcement"]["failed_checks"] + assert "route_plan_id" not in error["route_plan_enforcement"] + assert mock_forward.await_count == 1 + assert mock_forward.await_args.kwargs["method"] == "GET" + + +@pytest.mark.anyio +async def test_v2_execute_rejects_route_plan_that_names_different_provider(app): + mismatched_plan = resolve_v2_route.issue_route_plan( + { + "org_id": "anonymous", + "agent_id": "x402_anonymous", + "principal_id": "x402_anonymous", + "auth_rail": "anonymous_compat", + "capability_id": "search.query", + "service_id": "people-data-labs", + "provider_id": "people-data-labs", + "route_id": "route_search_query_people_data_labs_official_api_v1", + "credential_mode": "byok", + "credential_handle_id": None, + "required_scopes": [], + "input_hash": resolve_v2_route.canonical_json_sha256({"q": "rhumb"}), + "manifest_digest": resolve_v2_route.canonical_json_sha256( + {"provider_id": "people-data-labs"} + ), + "evidence_packet_digest": None, + "policy_snapshot_digest": resolve_v2_route.canonical_json_sha256( + {"source": "mismatch"} + ), + "budget_snapshot_digest": resolve_v2_route.canonical_json_sha256({"active": False}), + "sandbox_profile_id": None, + "artifact_hashes": [], + }, + resolve_v2_route.get_signing_key(), + now=1_800_000_000, + ttl_seconds=300, + ) + + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(return_value=_route_plan_estimate_response()), + ) as mock_forward, + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post( + "/v2/capabilities/search.query/execute", + json={ + "parameters": {"q": "rhumb"}, + "credential_mode": "byok", + "route_plan_id": mismatched_plan, + }, + ) + + assert resp.status_code == 409 + error = resp.json()["error"] + assert error["code"] == "ROUTE_PLAN_REJECTED" + assert error["stop_condition"] == "route_plan_mismatch" + failed = error["route_plan_enforcement"]["failed_checks"] + assert "provider_id_matches" in failed + assert "route_id_matches" in failed + assert mock_forward.await_count == 1 + + +@pytest.mark.anyio +async def test_v2_execute_rejects_runtime_provider_divergence_from_route_plan(app): + execute_resp = _make_mock_response( + status_code=200, + json_body={ + "data": { + "execution_id": "exec_v2_route_divergence", + "provider_used": "people-data-labs", + "credential_mode": "byok", + "result": {"ok": True}, + }, + "error": None, + }, + ) + execute_resp.headers = {} + + with ( + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock(return_value=_route_plan_policy_eval()), + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[_route_plan_estimate_response(), execute_resp]), + ) as mock_forward, + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post( + "/v2/capabilities/search.query/execute", + json={"parameters": {"q": "rhumb"}, "credential_mode": "byok"}, + ) + + assert resp.status_code == 409 + error = resp.json()["error"] + assert error["code"] == "ROUTE_PLAN_REJECTED" + assert error["stop_condition"] == "route_plan_mismatch" + assert "runtime_provider_matches_plan" in error["route_plan_enforcement"]["failed_checks"] + assert mock_forward.await_count == 2 + + @pytest.mark.anyio async def test_v2_resolve_rewrites_nested_recovery_urls_for_alternate_handoff(app): - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase): + with ( + patch( + "routes.capabilities.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch("routes.capabilities._has_proxy_credential_configured", return_value=False), + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/email.send/resolve", @@ -800,7 +1367,14 @@ async def mock_fetch(path: str): return [] return [] - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=mock_fetch): + with ( + patch( + "routes.capabilities.supabase_fetch", + new_callable=AsyncMock, + side_effect=mock_fetch, + ), + patch("routes.capabilities._has_proxy_credential_configured", return_value=False), + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/email.send/resolve", @@ -875,7 +1449,9 @@ async def mock_fetch(path: str): return [] return [] - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=mock_fetch): + with patch( + "routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=mock_fetch + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/email.send/resolve", @@ -921,7 +1497,11 @@ async def mock_fetch(path: str): @pytest.mark.anyio async def test_v2_resolve_rewrites_direct_execute_endpoint_pattern(app): - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_db_direct_supabase): + with patch( + "routes.capabilities.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_db_direct_supabase, + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get("/v2/capabilities/db.query.read/resolve") @@ -951,7 +1531,11 @@ async def test_v2_resolve_rewrites_direct_execute_endpoint_pattern(app): @pytest.mark.anyio async def test_v2_resolve_rewrites_direct_recovery_alternate_execute_endpoint_pattern(app): - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_db_direct_supabase): + with patch( + "routes.capabilities.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_db_direct_supabase, + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/db.query.read/resolve", @@ -1011,7 +1595,9 @@ async def test_v2_resolve_direct_capability_ignores_stale_catalog_mapping_rows(a @pytest.mark.anyio async def test_v2_credential_modes_wraps_metadata(app): - with patch("routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase): + with patch( + "routes.capabilities.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase + ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/email.send/credential-modes", @@ -1102,10 +1688,16 @@ async def test_v2_resolve_canonicalizes_alias_backed_provider_fields_in_success_ assert resp.status_code == 200 data = resp.json()["data"] assert data["providers"][0]["service_slug"] == "brave-search-api" - assert data["providers"][0]["setup_hint"] == "Configure brave-search-api before falling back to people-data-labs." + assert ( + data["providers"][0]["setup_hint"] + == "Configure brave-search-api before falling back to people-data-labs." + ) assert data["execute_hint"]["preferred_provider"] == "brave-search-api" assert data["execute_hint"]["fallback_providers"] == ["people-data-labs"] - assert data["execute_hint"]["setup_hint"] == "Use brave-search-api before switching to people-data-labs." + assert ( + data["execute_hint"]["setup_hint"] + == "Use brave-search-api before switching to people-data-labs." + ) @pytest.mark.anyio @@ -1138,7 +1730,9 @@ async def test_v2_resolve_canonicalizes_alias_backed_provider_fields_in_failure_ @pytest.mark.anyio -async def test_v2_credential_modes_canonicalizes_alias_backed_provider_fields_in_success_payload(app): +async def test_v2_credential_modes_canonicalizes_alias_backed_provider_fields_in_success_payload( + app, +): modes_resp = _make_mock_response( status_code=200, json_body={ @@ -1172,11 +1766,16 @@ async def test_v2_credential_modes_canonicalizes_alias_backed_provider_fields_in assert resp.status_code == 200 data = resp.json()["data"] assert data["providers"][0]["service_slug"] == "brave-search-api" - assert data["providers"][0]["modes"][0]["setup_hint"] == "Set brave-search-api before falling back to people-data-labs." + assert ( + data["providers"][0]["modes"][0]["setup_hint"] + == "Set brave-search-api before falling back to people-data-labs." + ) @pytest.mark.anyio -async def test_v2_credential_modes_canonicalizes_alias_backed_provider_fields_in_failure_payload(app): +async def test_v2_credential_modes_canonicalizes_alias_backed_provider_fields_in_failure_payload( + app, +): modes_resp = MagicMock(spec=httpx.Response) modes_resp.status_code = 503 modes_resp.json.return_value = { @@ -1227,7 +1826,9 @@ async def test_v2_estimate_rewrites_recovery_urls_and_canonicalizes_mode(app): } estimate_resp.headers = {} - with patch("routes.resolve_v2._forward_internal", new=AsyncMock(return_value=estimate_resp)) as mock_forward: + with patch( + "routes.resolve_v2._forward_internal", new=AsyncMock(return_value=estimate_resp) + ) as mock_forward: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.get( "/v2/capabilities/email.send/execute/estimate", @@ -1237,13 +1838,63 @@ async def test_v2_estimate_rewrites_recovery_urls_and_canonicalizes_mode(app): assert resp.status_code == 402 body = resp.json() assert body["resolve_url"] == "/v2/capabilities/email.send/resolve?credential_mode=byok" - assert body["estimate_url"] == "/v2/capabilities/email.send/execute/estimate?provider=sendgrid&credential_mode=byok" + assert ( + body["estimate_url"] + == "/v2/capabilities/email.send/execute/estimate?provider=sendgrid&credential_mode=byok" + ) assert mock_forward.await_args.kwargs["params"] == { "credential_mode": "byok", "provider": "sendgrid", } +@pytest.mark.anyio +async def test_v2_estimate_issues_route_plan_id_for_executable_candidate(app): + issued_at = 1_800_000_000 + estimate_resp = _route_plan_estimate_response() + + with ( + patch( + "routes.resolve_v2._forward_internal", new=AsyncMock(return_value=estimate_resp) + ) as mock_forward, + patch("routes.resolve_v2.time.time", return_value=issued_at), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get( + "/v2/capabilities/search.query/execute/estimate", + params={"credential_mode": "byok", "provider": "brave-search-api"}, + ) + + assert resp.status_code == 200 + data = resp.json()["data"] + candidate = data["route_candidates"][0] + assert candidate["safety_state"] == "executable" + assert candidate["route_plan_id"].startswith("rhrp1.") + assert candidate["route_plan_expires_at"] == issued_at + 300 + + _, policy_source = resolve_v2_route._merge_effective_policy(None, None) + _, expected = resolve_v2_route._route_plan_boundary_facts( + capability_id="search.query", + estimate_data=data, + selected_provider_public="brave-search-api", + credential_mode="byok", + parameters={}, + raw_request=_request_with_headers({}), + agent=None, + policy_summary=None, + policy_source=policy_source, + budget_summary=None, + ) + validation = resolve_v2_route.validate_route_plan( + candidate["route_plan_id"], + expected, + resolve_v2_route.get_signing_key(), + now=issued_at + 1, + ) + assert validation["allowed"] is True + assert mock_forward.await_count == 1 + + @pytest.mark.anyio async def test_v2_estimate_rejects_invalid_credential_mode_before_forward(app): with patch("routes.resolve_v2._forward_internal", new=AsyncMock()) as mock_forward: @@ -1335,8 +1986,14 @@ async def test_v2_estimate_rewrites_direct_execute_readiness_handoff(app): data = resp.json()["data"] assert data["endpoint_pattern"] == "POST /v2/capabilities/workflow_run.list/execute" assert data["execute_readiness"]["resolve_url"] == "/v2/capabilities/workflow_run.list/resolve" - assert data["execute_readiness"]["credential_modes_url"] == "/v2/capabilities/workflow_run.list/credential-modes" - assert data["execute_readiness"]["auth_handoff"]["retry_url"] == "/v2/capabilities/workflow_run.list/execute" + assert ( + data["execute_readiness"]["credential_modes_url"] + == "/v2/capabilities/workflow_run.list/credential-modes" + ) + assert ( + data["execute_readiness"]["auth_handoff"]["retry_url"] + == "/v2/capabilities/workflow_run.list/execute" + ) @pytest.mark.anyio @@ -1363,7 +2020,10 @@ async def test_v2_estimate_direct_capability_ignores_stale_catalog_mapping_rows( assert data["provider"] == "postgresql" assert data["endpoint_pattern"] == "POST /v2/capabilities/db.query.read/execute" assert data["execute_readiness"]["resolve_url"] == "/v2/capabilities/db.query.read/resolve" - assert data["execute_readiness"]["credential_modes_url"] == "/v2/capabilities/db.query.read/credential-modes" + assert ( + data["execute_readiness"]["credential_modes_url"] + == "/v2/capabilities/db.query.read/credential-modes" + ) @pytest.mark.anyio @@ -1383,14 +2043,19 @@ async def test_v2_estimate_direct_capability_accepts_canonical_provider_query(ap patch("routes.capability_execute.get_breaker_registry", return_value=breaker_registry), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - resp = await client.get("/v2/capabilities/db.query.read/execute/estimate?provider=postgresql") + resp = await client.get( + "/v2/capabilities/db.query.read/execute/estimate?provider=postgresql" + ) assert resp.status_code == 200 data = resp.json()["data"] assert data["provider"] == "postgresql" assert data["endpoint_pattern"] == "POST /v2/capabilities/db.query.read/execute" assert data["execute_readiness"]["resolve_url"] == "/v2/capabilities/db.query.read/resolve" - assert data["execute_readiness"]["credential_modes_url"] == "/v2/capabilities/db.query.read/credential-modes" + assert ( + data["execute_readiness"]["credential_modes_url"] + == "/v2/capabilities/db.query.read/credential-modes" + ) @pytest.mark.anyio @@ -1410,7 +2075,9 @@ async def test_v2_estimate_direct_capability_rejects_stale_provider_query(app): patch("routes.capability_execute.get_breaker_registry", return_value=breaker_registry), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - resp = await client.get("/v2/capabilities/db.query.read/execute/estimate?provider=stale-db-proxy") + resp = await client.get( + "/v2/capabilities/db.query.read/execute/estimate?provider=stale-db-proxy" + ) assert resp.status_code == 503 assert "stale-db-proxy" in resp.text @@ -1550,7 +2217,9 @@ async def test_v2_execute_canonicalizes_alias_backed_estimate_failure_payload(ap ) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), patch("routes.resolve_v2._forward_internal", new=AsyncMock(return_value=estimate_resp)), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -1623,12 +2292,18 @@ async def test_v2_execute_direct_capability_ignores_stale_catalog_mapping_rows(a new_callable=AsyncMock, side_effect=_mock_db_direct_supabase_with_stale_mapping, ), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])) as mock_forward, + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ) as mock_forward, patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_db_v2_test")) as mock_build_explanation, + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_db_v2_test"), + ) as mock_build_explanation, patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), ): @@ -1669,7 +2344,9 @@ async def test_v2_execute_direct_capability_ignores_stale_catalog_mapping_rows(a "method": "POST", "path": "/v2/capabilities/db.query.read/execute", } - assert [m["service_slug"] for m in mock_build_explanation.call_args.kwargs["mappings"]] == ["postgresql"] + assert [m["service_slug"] for m in mock_build_explanation.call_args.kwargs["mappings"]] == [ + "postgresql" + ] @pytest.mark.anyio @@ -1761,13 +2438,21 @@ async def test_v2_execute_normalizes_idempotency_keys_before_forwarding_and_rece headers["X-Rhumb-Idempotency-Key"] = header_key with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])) as mock_forward, + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ) as mock_forward, patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_idem")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_idem"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -1785,7 +2470,10 @@ async def test_v2_execute_normalizes_idempotency_keys_before_forwarding_and_rece assert execute_call.kwargs["json_body"]["idempotency_key"] == expected_key receipt_input = mock_receipt_svc.return_value.create_receipt.await_args.args[0] assert receipt_input.idempotency_key == expected_key - assert resp.json()["data"]["_rhumb_v2"]["translated_from"]["idempotency_header_used"] is header_used + assert ( + resp.json()["data"]["_rhumb_v2"]["translated_from"]["idempotency_header_used"] + is header_used + ) @pytest.mark.anyio @@ -1940,11 +2628,24 @@ async def test_v2_execute_translates_provider_preference_and_wraps_metadata(app) _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -1973,7 +2674,10 @@ async def test_v2_execute_translates_provider_preference_and_wraps_metadata(app) assert data["_rhumb_v2"]["policy_applied"] is True assert data["_rhumb_v2"]["policy_selected_reason"] == "policy_preference_match" assert data["_rhumb_v2"]["policy_candidates"] == ["sendgrid", "resend"] - assert data["_rhumb_v2"]["receipt_id"] == data.get("receipt_id") or f"rcpt_compat_{data['execution_id']}" + assert ( + data["_rhumb_v2"]["receipt_id"] == data.get("receipt_id") + or f"rcpt_compat_{data['execution_id']}" + ) request_call = mock_pool.acquire.return_value.request.await_args assert request_call.kwargs["json"] == {"to": "test@example.com"} @@ -1981,15 +2685,30 @@ async def test_v2_execute_translates_provider_preference_and_wraps_metadata(app) @pytest.mark.anyio -async def test_v2_execute_honors_alias_provider_preference_and_reports_canonical_public_selection(app): +async def test_v2_execute_honors_alias_provider_preference_and_reports_canonical_public_selection( + app, +): _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2024,11 +2743,24 @@ async def test_v2_execute_honors_pinned_alias_provider_and_reports_canonical_pub _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2058,15 +2790,30 @@ async def test_v2_execute_honors_pinned_alias_provider_and_reports_canonical_pub @pytest.mark.anyio -async def test_v2_execute_honors_allow_only_alias_provider_and_reports_canonical_public_selection(app): +async def test_v2_execute_honors_allow_only_alias_provider_and_reports_canonical_public_selection( + app, +): _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2099,7 +2846,11 @@ async def test_v2_execute_honors_allow_only_alias_provider_and_reports_canonical @pytest.mark.anyio async def test_v2_execute_rejects_denied_alias_provider_and_surfaces_normalized_policy(app): with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), patch("routes.resolve_v2._forward_internal", new=AsyncMock()) as mock_forward, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -2166,11 +2917,24 @@ async def test_v2_execute_uses_single_canonical_receipt_id_and_skips_v1_receipt( mock_attribution.to_rhumb_block.return_value = {"receipt_id": "rcpt_v2_canonical"} with ( - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])) as mock_forward, - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=SimpleNamespace(decision=None, all_mappings=[], eligible_mappings=[]))), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ) as mock_forward, + patch( + "routes.resolve_v2._evaluate_provider_policy", + new=AsyncMock( + return_value=SimpleNamespace(decision=None, all_mappings=[], eligible_mappings=[]) + ), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, - patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)) as mock_build_attribution, - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_test")), + patch( + "routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution) + ) as mock_build_attribution, + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_test"), + ), patch("routes.resolve_v2.store_explanation"), ): mock_receipt_svc.return_value.create_receipt = AsyncMock(return_value=mock_receipt) @@ -2205,11 +2969,24 @@ async def test_v2_execute_respects_provider_deny_and_uses_next_allowed_preferenc _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2241,11 +3018,24 @@ async def test_v2_execute_rejects_when_policy_filters_remove_all_providers(app): _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2272,11 +3062,24 @@ async def test_v2_execute_enforces_max_cost_ceiling_before_execution(app): _, mock_pool, budget_state = _build_patches() with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2300,7 +3103,9 @@ async def test_v2_execute_enforces_max_cost_ceiling_before_execution(app): @pytest.mark.anyio -async def test_v2_execute_rejects_when_durable_agent_budget_is_exhausted(app, _mock_v2_budget_enforcer): +async def test_v2_execute_rejects_when_durable_agent_budget_is_exhausted( + app, _mock_v2_budget_enforcer +): _, mock_pool, budget_state = _build_patches() budget_state.remaining_usd = 0.001 budget_state.budget_usd = 0.001 @@ -2320,11 +3125,24 @@ async def test_v2_execute_rejects_when_durable_agent_budget_is_exhausted(app, _m ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2361,8 +3179,14 @@ async def test_v2_policy_auth_errors_use_governed_api_key_language(app, _mock_id assert missing_resp.status_code == 401 missing_body = missing_resp.json() assert missing_body["error"]["code"] == "CREDENTIAL_INVALID" - assert missing_body["error"]["message"] == "Resolve v2 policy endpoints require a valid governed API key." - assert missing_body["error"]["detail"] == "Provide a valid X-Rhumb-Key header tied to an organization-backed agent." + assert ( + missing_body["error"]["message"] + == "Resolve v2 policy endpoints require a valid governed API key." + ) + assert ( + missing_body["error"]["detail"] + == "Provide a valid X-Rhumb-Key header tied to an organization-backed agent." + ) _mock_identity_store.verify_api_key_with_agent.return_value = None @@ -2373,24 +3197,34 @@ async def test_v2_policy_auth_errors_use_governed_api_key_language(app, _mock_id invalid_body = invalid_resp.json() assert invalid_body["error"]["code"] == "CREDENTIAL_INVALID" assert invalid_body["error"]["message"] == "Invalid or expired governed API key." - assert invalid_body["error"]["detail"] == "Provide a valid X-Rhumb-Key header or use an x402 payment flow." + assert ( + invalid_body["error"]["detail"] + == "Provide a valid X-Rhumb-Key header or use an x402 payment flow." + ) @pytest.mark.anyio -async def test_v2_policy_governed_key_headers_are_normalized_before_identity_reads(app, _mock_identity_store): +async def test_v2_policy_governed_key_headers_are_normalized_before_identity_reads( + app, _mock_identity_store +): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: blank_resp = await client.get("/v2/policy", headers={"X-Rhumb-Key": " "}) assert blank_resp.status_code == 401 blank_body = blank_resp.json() assert blank_body["error"]["code"] == "CREDENTIAL_INVALID" - assert blank_body["error"]["message"] == "Resolve v2 policy endpoints require a valid governed API key." + assert ( + blank_body["error"]["message"] + == "Resolve v2 policy endpoints require a valid governed API key." + ) _mock_identity_store.verify_api_key_with_agent.assert_not_awaited() _mock_identity_store.verify_api_key_with_agent.reset_mock() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - padded_resp = await client.get("/v2/policy", headers={"X-Rhumb-Key": " rhumb_test_key_v2 "}) + padded_resp = await client.get( + "/v2/policy", headers={"X-Rhumb-Key": " rhumb_test_key_v2 "} + ) assert padded_resp.status_code == 200 _mock_identity_store.verify_api_key_with_agent.assert_awaited_once_with("rhumb_test_key_v2") @@ -2594,8 +3428,13 @@ async def _mock_insert_returning(_table: str, payload: dict[str, object]): with ( patch("routes.resolve_v2.get_resolve_policy_store", return_value=store), - patch("services.resolve_policy_store.supabase_fetch", new=AsyncMock(side_effect=_mock_fetch)), - patch("services.resolve_policy_store.supabase_insert_returning", new=AsyncMock(side_effect=_mock_insert_returning)), + patch( + "services.resolve_policy_store.supabase_fetch", new=AsyncMock(side_effect=_mock_fetch) + ), + patch( + "services.resolve_policy_store.supabase_insert_returning", + new=AsyncMock(side_effect=_mock_insert_returning), + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: put_resp = await client.put( @@ -2650,11 +3489,24 @@ async def test_v2_execute_merges_account_policy_with_inline_override(app, _mock_ ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2683,7 +3535,9 @@ async def test_v2_execute_merges_account_policy_with_inline_override(app, _mock_ @pytest.mark.anyio -async def test_v2_execute_applies_stored_alias_pin_and_reports_policy_source(app, _mock_policy_store): +async def test_v2_execute_applies_stored_alias_pin_and_reports_policy_source( + app, _mock_policy_store +): _, mock_pool, budget_state = _build_patches() _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", @@ -2696,11 +3550,24 @@ async def test_v2_execute_applies_stored_alias_pin_and_reports_policy_source(app ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -2729,7 +3596,9 @@ async def test_v2_execute_applies_stored_alias_pin_and_reports_policy_source(app @pytest.mark.anyio -async def test_v2_execute_keeps_canonical_provider_identity_in_attribution_for_alias_backed_execution(app, _mock_policy_store): +async def test_v2_execute_keeps_canonical_provider_identity_in_attribution_for_alias_backed_execution( + app, _mock_policy_store +): _, mock_pool, budget_state = _build_patches() _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", @@ -2743,27 +3612,49 @@ async def test_v2_execute_keeps_canonical_provider_identity_in_attribution_for_a async def _mock_provider_detail_fetch(path: str): if path.startswith("services?slug=eq.brave-search-api"): - return [{ - "slug": "brave-search-api", - "name": "Brave Search", - "category": "search", - "official_docs": "https://api.search.brave.com/docs", - "aggregate_recommendation_score": 7.8, - "tier_label": "L3", - }] + return [ + { + "slug": "brave-search-api", + "name": "Brave Search", + "category": "search", + "official_docs": "https://api.search.brave.com/docs", + "aggregate_recommendation_score": 7.8, + "tier_label": "L3", + } + ] return [] clear_provider_cache() try: with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), - patch("services.provider_attribution.supabase_fetch", new=AsyncMock(side_effect=_mock_provider_detail_fetch)), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), + patch( + "services.provider_attribution.supabase_fetch", + new=AsyncMock(side_effect=_mock_provider_detail_fetch), + ), ): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: resp = await client.post( "/v2/capabilities/search.query/execute", json={ @@ -2786,7 +3677,9 @@ async def _mock_provider_detail_fetch(path: str): @pytest.mark.anyio -async def test_v2_execute_keeps_canonical_provider_identity_in_receipts_and_billing_for_alias_backed_execution(app, _mock_policy_store): +async def test_v2_execute_keeps_canonical_provider_identity_in_receipts_and_billing_for_alias_backed_execution( + app, _mock_policy_store +): _, mock_pool, budget_state = _build_patches() _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", @@ -2803,33 +3696,60 @@ async def test_v2_execute_keeps_canonical_provider_identity_in_receipts_and_bill async def _mock_provider_detail_fetch(path: str): if path.startswith("services?slug=eq.brave-search-api"): - return [{ - "slug": "brave-search-api", - "name": "Brave Search", - "category": "search", - "official_docs": "https://api.search.brave.com/docs", - "aggregate_recommendation_score": 7.8, - "tier_label": "L3", - }] + return [ + { + "slug": "brave-search-api", + "name": "Brave Search", + "category": "search", + "official_docs": "https://api.search.brave.com/docs", + "aggregate_recommendation_score": 7.8, + "tier_label": "L3", + } + ] return [] clear_provider_cache() try: with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_alias_truth")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_alias_truth"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), - patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), - patch("services.provider_attribution.supabase_fetch", new=AsyncMock(side_effect=_mock_provider_detail_fetch)), + patch( + "services.billing_events.get_billing_event_stream", return_value=mock_billing_stream + ), + patch( + "services.provider_attribution.supabase_fetch", + new=AsyncMock(side_effect=_mock_provider_detail_fetch), + ), ): mock_receipt_svc.return_value.create_receipt = AsyncMock(return_value=mock_receipt) - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: resp = await client.post( "/v2/capabilities/search.query/execute", json={ @@ -2852,7 +3772,9 @@ async def _mock_provider_detail_fetch(path: str): @pytest.mark.anyio -async def test_v2_execute_hardens_alias_backed_internal_provider_ids_before_public_v2_shaping(app, _mock_policy_store): +async def test_v2_execute_hardens_alias_backed_internal_provider_ids_before_public_v2_shaping( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -2933,13 +3855,23 @@ async def test_v2_execute_hardens_alias_backed_internal_provider_ids_before_publ mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])) as mock_forward, + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ) as mock_forward, patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, - patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)) as mock_build_attribution, + patch( + "routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution) + ) as mock_build_attribution, patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_alias_harden")) as mock_build_explanation, + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_alias_harden"), + ) as mock_build_explanation, patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -2974,7 +3906,9 @@ async def test_v2_execute_hardens_alias_backed_internal_provider_ids_before_publ @pytest.mark.anyio -async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_success_payload(app, _mock_policy_store): +async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_success_payload( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -3072,13 +4006,21 @@ async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_success_ mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_alias_fields_success")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_alias_fields_success"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -3123,7 +4065,9 @@ async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_success_ @pytest.mark.anyio -async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_failure_payload(app, _mock_policy_store): +async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_failure_payload( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -3198,13 +4142,21 @@ async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_failure_ mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_alias_fields_failure")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_alias_fields_failure"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -3235,7 +4187,9 @@ async def test_v2_execute_canonicalizes_alias_backed_provider_fields_in_failure_ @pytest.mark.anyio -async def test_v2_execute_canonicalizes_alternate_provider_alias_text_when_structured_fields_are_already_canonical(app, _mock_policy_store): +async def test_v2_execute_canonicalizes_alternate_provider_alias_text_when_structured_fields_are_already_canonical( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -3333,13 +4287,21 @@ async def test_v2_execute_canonicalizes_alternate_provider_alias_text_when_struc mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_alt_alias_text_success")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_alt_alias_text_success"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -3380,7 +4342,9 @@ async def test_v2_execute_canonicalizes_alternate_provider_alias_text_when_struc @pytest.mark.anyio -async def test_v2_execute_failure_canonicalizes_alternate_provider_alias_text_when_structured_fields_are_already_canonical(app, _mock_policy_store): +async def test_v2_execute_failure_canonicalizes_alternate_provider_alias_text_when_structured_fields_are_already_canonical( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -3455,13 +4419,21 @@ async def test_v2_execute_failure_canonicalizes_alternate_provider_alias_text_wh mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_alt_alias_text_failure")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_alt_alias_text_failure"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -3480,7 +4452,9 @@ async def test_v2_execute_failure_canonicalizes_alternate_provider_alias_text_wh assert resp.status_code == 502 error = resp.json()["error"] - assert error["message"] == "brave-search-api upstream exploded after people-data-labs comparison" + assert ( + error["message"] == "brave-search-api upstream exploded after people-data-labs comparison" + ) assert error["detail"] == "Retry brave-search-api or choose people-data-labs" assert error["requested_provider"] == "brave-search-api" assert error["available_providers"] == ["brave-search-api"] @@ -3488,11 +4462,16 @@ async def test_v2_execute_failure_canonicalizes_alternate_provider_alias_text_wh receipt_input = mock_receipt_svc.return_value.create_receipt.await_args.args[0] assert receipt_input.provider_id == "brave-search-api" - assert receipt_input.error_message == "brave-search-api upstream exploded after people-data-labs comparison" + assert ( + receipt_input.error_message + == "brave-search-api upstream exploded after people-data-labs comparison" + ) @pytest.mark.anyio -async def test_v2_execute_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical(app, _mock_policy_store): +async def test_v2_execute_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -3590,13 +4569,21 @@ async def test_v2_execute_canonicalizes_same_provider_alias_text_when_structured mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_same_alias_text_success")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_same_alias_text_success"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -3638,7 +4625,9 @@ async def test_v2_execute_canonicalizes_same_provider_alias_text_when_structured @pytest.mark.anyio -async def test_v2_execute_failure_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical(app, _mock_policy_store): +async def test_v2_execute_failure_canonicalizes_same_provider_alias_text_when_structured_fields_are_already_canonical( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin="brave-search-api", @@ -3713,13 +4702,21 @@ async def test_v2_execute_failure_canonicalizes_same_provider_alias_text_when_st mock_score_cache = SimpleNamespace(scores_by_slug=lambda _slugs: {}) with ( - patch("routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval)), - patch("routes.resolve_v2._forward_internal", new=AsyncMock(side_effect=[estimate_resp, execute_resp])), + patch( + "routes.resolve_v2._evaluate_provider_policy", new=AsyncMock(return_value=policy_eval) + ), + patch( + "routes.resolve_v2._forward_internal", + new=AsyncMock(side_effect=[estimate_resp, execute_resp]), + ), patch("routes.resolve_v2.get_receipt_service") as mock_receipt_svc, patch("routes.resolve_v2.build_attribution", new=AsyncMock(return_value=mock_attribution)), patch("services.score_cache.get_score_cache", return_value=mock_score_cache), patch("routes.proxy.get_breaker_registry", return_value=breaker_registry), - patch("routes.resolve_v2.build_explanation", return_value=SimpleNamespace(explanation_id="rexp_v2_same_alias_text_failure")), + patch( + "routes.resolve_v2.build_explanation", + return_value=SimpleNamespace(explanation_id="rexp_v2_same_alias_text_failure"), + ), patch("routes.resolve_v2.store_explanation"), patch("routes.resolve_v2.persist_explanation", new=AsyncMock(return_value=None)), patch("services.billing_events.get_billing_event_stream", return_value=mock_billing_stream), @@ -3738,7 +4735,9 @@ async def test_v2_execute_failure_canonicalizes_same_provider_alias_text_when_st assert resp.status_code == 502 error = resp.json()["error"] - assert error["message"] == "brave-search-api upstream exploded after brave-search-api comparison" + assert ( + error["message"] == "brave-search-api upstream exploded after brave-search-api comparison" + ) assert error["detail"] == "Retry brave-search-api later" assert error["requested_provider"] == "brave-search-api" assert error["available_providers"] == ["brave-search-api"] @@ -3747,11 +4746,16 @@ async def test_v2_execute_failure_canonicalizes_same_provider_alias_text_when_st receipt_input = mock_receipt_svc.return_value.create_receipt.await_args.args[0] assert receipt_input.provider_id == "brave-search-api" - assert receipt_input.error_message == "brave-search-api upstream exploded after brave-search-api comparison" + assert ( + receipt_input.error_message + == "brave-search-api upstream exploded after brave-search-api comparison" + ) @pytest.mark.anyio -async def test_v2_execute_applies_stored_alias_provider_preference_and_reports_policy_source(app, _mock_policy_store): +async def test_v2_execute_applies_stored_alias_provider_preference_and_reports_policy_source( + app, _mock_policy_store +): _, mock_pool, budget_state = _build_patches() _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", @@ -3764,11 +4768,24 @@ async def test_v2_execute_applies_stored_alias_provider_preference_and_reports_p ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -3798,7 +4815,9 @@ async def test_v2_execute_applies_stored_alias_provider_preference_and_reports_p @pytest.mark.anyio -async def test_v2_execute_applies_stored_alias_allow_only_and_reports_policy_source(app, _mock_policy_store): +async def test_v2_execute_applies_stored_alias_allow_only_and_reports_policy_source( + app, _mock_policy_store +): _, mock_pool, budget_state = _build_patches() _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", @@ -3811,11 +4830,24 @@ async def test_v2_execute_applies_stored_alias_allow_only_and_reports_policy_sou ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( @@ -3845,7 +4877,9 @@ async def test_v2_execute_applies_stored_alias_allow_only_and_reports_policy_sou @pytest.mark.anyio -async def test_v2_execute_rejects_stored_denied_alias_provider_and_surfaces_normalized_policy(app, _mock_policy_store): +async def test_v2_execute_rejects_stored_denied_alias_provider_and_surfaces_normalized_policy( + app, _mock_policy_store +): _mock_policy_store.get_policy.return_value = SimpleNamespace( org_id="org_v2_test", pin=None, @@ -3857,7 +4891,11 @@ async def test_v2_execute_rejects_stored_denied_alias_provider_and_surfaces_norm ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_search_alias_supabase), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_search_alias_supabase, + ), patch("routes.resolve_v2._forward_internal", new=AsyncMock()) as mock_forward, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -3899,11 +4937,24 @@ async def test_v2_execute_inline_empty_list_clears_stored_preference(app, _mock_ ) with ( - patch("routes.capability_execute.supabase_fetch", new_callable=AsyncMock, side_effect=_mock_supabase), - patch("routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True), - patch("routes.capability_execute._inject_auth_request_parts", side_effect=_passthrough_inject_auth_request_parts), + patch( + "routes.capability_execute.supabase_fetch", + new_callable=AsyncMock, + side_effect=_mock_supabase, + ), + patch( + "routes.capability_execute.supabase_insert", new_callable=AsyncMock, return_value=True + ), + patch( + "routes.capability_execute._inject_auth_request_parts", + side_effect=_passthrough_inject_auth_request_parts, + ), patch("routes.capability_execute.get_pool_manager", return_value=mock_pool), - patch("routes.capability_execute._budget_enforcer.get_budget", new_callable=AsyncMock, return_value=budget_state), + patch( + "routes.capability_execute._budget_enforcer.get_budget", + new_callable=AsyncMock, + return_value=budget_state, + ), ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: resp = await client.post( diff --git a/packages/api/tests/test_resolve_v2_dogfood_script.py b/packages/api/tests/test_resolve_v2_dogfood_script.py index efb1e522..4cbd2b4b 100644 --- a/packages/api/tests/test_resolve_v2_dogfood_script.py +++ b/packages/api/tests/test_resolve_v2_dogfood_script.py @@ -97,6 +97,78 @@ def test_extract_receipt_id_prefers_top_level_receipt_id(): assert resolve_v2_dogfood.extract_receipt_id(data) == "rcpt_top" +def test_extract_compact_receipt_reads_v2_execute_metadata(): + compact = {"receipt_id": "rcpt_123", "route": {"substrate": "official_api"}} + + assert ( + resolve_v2_dogfood.extract_compact_receipt({"_rhumb_v2": {"compact_receipt": compact}}) + == compact + ) + assert resolve_v2_dogfood.extract_compact_receipt({"_rhumb_v2": {}}) is None + + +def test_require_verified_receipt_rejects_partial_verifier_status(): + state = {} + + with pytest.raises(resolve_v2_dogfood.FlowError) as exc_info: + resolve_v2_dogfood._require_verified_receipt( + "v2 layer2", + {"verifier_status": "partial", "checks": {"route_plan_hash_present": False}}, + state, + ) + + assert "receipt verifier did not return verified" in str(exc_info.value) + assert state["last_error_response"]["label"] == "v2 layer2 receipt verify" + assert "route_plan_hash_present" in state["last_error_response"]["detail"] + + +def test_build_first_call_proof_uses_compact_receipt_and_verifier(): + proof = resolve_v2_dogfood._build_first_call_proof( + { + "config": {"capability": "search.query", "credential_mode": "rhumb_managed"}, + "layer2": { + "execute": { + "data": { + "provider_used": "brave-search-api", + "receipt_id": "rcpt_l2", + "_rhumb_v2": { + "compact_receipt": { + "route": { + "route_id": "route_search_query_brave_search_api_official_api_v1", + "substrate": "official_api", + "provenance_origin": "rhumb_managed", + "source_risk": "verified_low", + "manifest_digest": "sha256:manifest", + "evidence_packet_digest": "sha256:evidence", + } + } + }, + } + }, + "receipt": {"receipt_id": "rcpt_l2"}, + "verify": {"verifier_status": "verified"}, + }, + } + ) + + assert proof == { + "ok": True, + "flow": ["search", "resolve", "estimate", "execute", "receipt", "verify"], + "capability": "search.query", + "credential_mode": "rhumb_managed", + "provider_selected": "brave-search-api", + "substrate": "official_api", + "provenance_origin": "rhumb_managed", + "source_risk": "verified_low", + "route_id": "route_search_query_brave_search_api_official_api_v1", + "manifest_digest": "sha256:manifest", + "evidence_packet_digest": "sha256:evidence", + "layer2_receipt_id": "rcpt_l2", + "layer2_verifier_status": "verified", + "compact_receipt_present": True, + } + + def test_extract_provider_used_reads_top_level_provider(): data = {"provider_used": "exa"} @@ -107,7 +179,9 @@ def test_extract_provider_used_reads_top_level_provider(): def test_get_api_key_uses_env_first_when_the_governed_probe_passes(): with ( - patch.dict(resolve_v2_dogfood.os.environ, {"RHUMB_DOGFOOD_API_KEY": "rhumb_env_key"}, clear=True), + patch.dict( + resolve_v2_dogfood.os.environ, {"RHUMB_DOGFOOD_API_KEY": "rhumb_env_key"}, clear=True + ), patch.object(resolve_v2_dogfood, "_api_key_probe_ok", return_value=True) as mock_probe, ): assert resolve_v2_dogfood._get_api_key("RHUMB_DOGFOOD_API_KEY") == "rhumb_env_key" @@ -122,16 +196,24 @@ def test_get_api_key_uses_env_first_when_the_governed_probe_passes(): def test_get_api_key_falls_back_to_sop_when_env_missing(): with ( patch.dict(resolve_v2_dogfood.os.environ, {}, clear=True), - patch.object(resolve_v2_dogfood, "_load_first_working_api_key_from_sop", return_value="rhumb_sop_key"), + patch.object( + resolve_v2_dogfood, "_load_first_working_api_key_from_sop", return_value="rhumb_sop_key" + ), ): assert resolve_v2_dogfood._get_api_key("RHUMB_DOGFOOD_API_KEY") == "rhumb_sop_key" def test_get_api_key_falls_back_to_sop_when_env_key_fails_governed_probe(): with ( - patch.dict(resolve_v2_dogfood.os.environ, {"RHUMB_DOGFOOD_API_KEY": "rhumb_stale_env_key"}, clear=True), + patch.dict( + resolve_v2_dogfood.os.environ, + {"RHUMB_DOGFOOD_API_KEY": "rhumb_stale_env_key"}, + clear=True, + ), patch.object(resolve_v2_dogfood, "_api_key_probe_ok", return_value=False) as mock_probe, - patch.object(resolve_v2_dogfood, "_load_first_working_api_key_from_sop", return_value="rhumb_sop_key"), + patch.object( + resolve_v2_dogfood, "_load_first_working_api_key_from_sop", return_value="rhumb_sop_key" + ), ): assert resolve_v2_dogfood._get_api_key("RHUMB_DOGFOOD_API_KEY") == "rhumb_sop_key" @@ -192,17 +274,22 @@ def test_get_api_key_raises_when_no_candidate_is_live(): raise AssertionError("Expected RuntimeError when no live API key is available") - def test_get_api_key_raises_when_stale_env_key_has_no_live_fallback(): with ( - patch.dict(resolve_v2_dogfood.os.environ, {"RHUMB_DOGFOOD_API_KEY": "rhumb_stale_env_key"}, clear=True), + patch.dict( + resolve_v2_dogfood.os.environ, + {"RHUMB_DOGFOOD_API_KEY": "rhumb_stale_env_key"}, + clear=True, + ), patch.object(resolve_v2_dogfood, "_api_key_probe_ok", return_value=False), patch.object(resolve_v2_dogfood, "_load_first_working_api_key_from_sop", return_value=None), ): try: resolve_v2_dogfood._get_api_key("RHUMB_DOGFOOD_API_KEY") except RuntimeError as exc: - assert "Configured RHUMB_DOGFOOD_API_KEY did not pass the governed API probe" in str(exc) + assert "Configured RHUMB_DOGFOOD_API_KEY did not pass the governed API probe" in str( + exc + ) assert "Rhumb API Key - pedro-dogfood" in str(exc) assert "Rhumb API Key - atlas@supertrained.ai" in str(exc) else: @@ -210,7 +297,9 @@ def test_get_api_key_raises_when_stale_env_key_has_no_live_fallback(): def test_get_admin_key_uses_primary_env_first(): - with patch.dict(resolve_v2_dogfood.os.environ, {"RHUMB_ADMIN_SECRET": "admin_primary"}, clear=True): + with patch.dict( + resolve_v2_dogfood.os.environ, {"RHUMB_ADMIN_SECRET": "admin_primary"}, clear=True + ): assert resolve_v2_dogfood._get_admin_key("RHUMB_ADMIN_SECRET") == "admin_primary" @@ -228,73 +317,79 @@ def test_get_admin_key_falls_back_to_sop_when_env_missing(): def test_build_summary_mentions_l1_and_l2_receipts(): - summary = resolve_v2_dogfood._build_summary({ - "config": { - "capability": "search.query", - "provider": "brave-search-api", - }, - "layer2": { - "execute": { - "data": { - "execution_id": "exec_l2", - "provider_used": "brave-search-api", - "receipt_id": "rcpt_l2", - } + summary = resolve_v2_dogfood._build_summary( + { + "config": { + "capability": "search.query", + "provider": "brave-search-api", }, - "receipt": {"receipt_id": "rcpt_l2"}, - }, - "layer1": { - "execute": { - "data": { - "execution_id": "exec_l1", - "receipt_id": "rcpt_l1", - } + "layer2": { + "execute": { + "data": { + "execution_id": "exec_l2", + "provider_used": "brave-search-api", + "receipt_id": "rcpt_l2", + } + }, + "receipt": {"receipt_id": "rcpt_l2"}, }, - "receipt": {"receipt_id": "rcpt_l1"}, - }, - "billing": { - "summary": {"data": {"events_count": 4}}, - }, - "audit": { - "status": {"data": {"total_events": 7}}, - }, - }) + "layer1": { + "execute": { + "data": { + "execution_id": "exec_l1", + "receipt_id": "rcpt_l1", + } + }, + "receipt": {"receipt_id": "rcpt_l1"}, + }, + "billing": { + "summary": {"data": {"events_count": 4}}, + }, + "audit": { + "status": {"data": {"total_events": 7}}, + }, + "first_call_proof": {"ok": True, "layer2_verifier_status": "verified"}, + } + ) assert "Resolve v2 dogfood complete" in summary assert "L2 search.query via brave-search-api exec=exec_l2 receipt=rcpt_l2" in summary assert "L1 provider=brave-search-api exec=exec_l1 receipt=rcpt_l1" in summary assert "billing_events=4" in summary assert "audit_events=7" in summary + assert "first_call_verified=true verifier=verified" in summary def test_build_summary_uses_canonical_layer1_provider_when_requested_provider_is_runtime_alias(): - summary = resolve_v2_dogfood._build_summary({ - "config": { - "capability": "search.query", - "provider": "brave-search", - }, - "layer2": { - "execute": { - "data": { - "execution_id": "exec_l2", - "provider_used": "brave-search-api", - "receipt_id": "rcpt_l2", - } + summary = resolve_v2_dogfood._build_summary( + { + "config": { + "capability": "search.query", + "provider": "brave-search", }, - "receipt": {"receipt_id": "rcpt_l2"}, - }, - "layer1": { - "provider": {"id": "brave-search-api"}, - "execute": { - "data": { - "execution_id": "exec_l1", - "provider_used": "brave-search-api", - "receipt_id": "rcpt_l1", - } + "layer2": { + "execute": { + "data": { + "execution_id": "exec_l2", + "provider_used": "brave-search-api", + "receipt_id": "rcpt_l2", + } + }, + "receipt": {"receipt_id": "rcpt_l2"}, }, - "receipt": {"receipt_id": "rcpt_l1"}, - }, - }) + "layer1": { + "provider": {"id": "brave-search-api"}, + "execute": { + "data": { + "execution_id": "exec_l1", + "provider_used": "brave-search-api", + "receipt_id": "rcpt_l1", + } + }, + "receipt": {"receipt_id": "rcpt_l1"}, + }, + } + ) assert "L1 provider=brave-search-api exec=exec_l1 receipt=rcpt_l1" in summary assert "L1 provider=brave-search exec=exec_l1 receipt=rcpt_l1" not in summary @@ -324,7 +419,10 @@ def test_apply_profile_defaults_sets_interface_and_parameters_from_profile(): assert profiled.interface == "dogfood-beacon" assert profiled.provider == "brave-search" assert profiled.capability == "search.query" - assert profiled.parameters_json == '{"query": "best MCP server distribution channels for developers", "numResults": 3}' + assert ( + profiled.parameters_json + == '{"query": "best MCP server distribution channels for developers", "numResults": 3}' + ) def test_parse_args_accepts_summary_only_flag(): @@ -436,7 +534,9 @@ def test_build_batch_summary_mentions_profile_statuses_and_actual_provider_used( ) assert "Resolve v2 dogfood batch complete; ok_profiles=1/2" in summary - assert "pedro=ok provider=exa requested_provider=brave-search interface=dogfood-pedro" in summary + assert ( + "pedro=ok provider=exa requested_provider=brave-search interface=dogfood-pedro" in summary + ) assert "beacon=failed provider=brave-search interface=dogfood-beacon" in summary @@ -467,7 +567,10 @@ def fake_run_flow(run_args): assert payload["ok"] is True assert payload["profile_count"] == 2 - assert {call_args.args[0].profile for call_args in mock_run_flow.call_args_list} == {"pedro", "keel"} + assert {call_args.args[0].profile for call_args in mock_run_flow.call_args_list} == { + "pedro", + "keel", + } pedro_artifact = json.loads( (artifact_root / "resolve-v2-dogfood-pedro-admin-latest.json").read_text(encoding="utf-8") @@ -500,7 +603,9 @@ def test_build_fleet_status_entry_marks_profile_ok(tmp_path): } } }, - "layer1": {"execute": {"data": {"execution_id": "exec_l1", "receipt_id": "rcpt_l1"}}}, + "layer1": { + "execute": {"data": {"execution_id": "exec_l1", "receipt_id": "rcpt_l1"}} + }, "billing": {"summary": {"data": {"events_count": 4}}}, "audit": {"status": {"data": {"total_events": 2}}}, "receipt_chain": {"chain_intact": True, "verified": 20, "total_checked": 20}, @@ -578,7 +683,10 @@ def test_build_fleet_status_summary_mentions_age_and_freshness_window(): 1080, ) - assert "Resolve v2 dogfood fleet status complete; ok_profiles=1/2; freshness_window_minutes=1080" in summary + assert ( + "Resolve v2 dogfood fleet status complete; ok_profiles=1/2; freshness_window_minutes=1080" + in summary + ) assert "keel=ok provider=brave-search age_min=5.0" in summary assert "helm=failed provider=brave-search age_min=1200.0" in summary @@ -626,13 +734,16 @@ def fake_run_flow(run_args): with ( patch.object(resolve_v2_dogfood, "_artifact_root", return_value=artifact_root), patch.object(resolve_v2_dogfood, "run_flow", side_effect=fake_run_flow) as mock_run_flow, - patch.object(resolve_v2_dogfood.time, "time", side_effect=[1_700_066_800, 1_700_066_800]), + patch.object(resolve_v2_dogfood.time, "time", side_effect=[1_700_066_800, 1_700_066_800]), ): payload = resolve_v2_dogfood.run_fleet_status(args, ["keel", "helm", "beacon"]) assert payload["ok"] is True assert payload["refreshed_profiles"] == ["keel", "beacon"] - assert {call_args.args[0].profile for call_args in mock_run_flow.call_args_list} == {"keel", "beacon"} + assert {call_args.args[0].profile for call_args in mock_run_flow.call_args_list} == { + "keel", + "beacon", + } assert payload["profiles"]["keel"]["ok"] is True assert payload["profiles"]["helm"]["ok"] is True assert payload["profiles"]["beacon"]["ok"] is True @@ -643,8 +754,12 @@ def fake_run_flow(run_args): assert fleet_status_payload["ok"] is True assert fleet_status_payload["refreshed_profiles"] == ["keel", "beacon"] - refreshed_keel = json.loads((artifact_root / "resolve-v2-dogfood-keel-admin-latest.json").read_text(encoding="utf-8")) - refreshed_beacon = json.loads((artifact_root / "resolve-v2-dogfood-beacon-admin-latest.json").read_text(encoding="utf-8")) + refreshed_keel = json.loads( + (artifact_root / "resolve-v2-dogfood-keel-admin-latest.json").read_text(encoding="utf-8") + ) + refreshed_beacon = json.loads( + (artifact_root / "resolve-v2-dogfood-beacon-admin-latest.json").read_text(encoding="utf-8") + ) assert refreshed_keel["summary"] == "refreshed keel" assert refreshed_beacon["summary"] == "refreshed beacon" @@ -675,7 +790,13 @@ def test_run_fleet_status_refresh_preserves_failure_when_rerun_still_fails(tmp_p "run_flow", side_effect=resolve_v2_dogfood.FlowError( "refresh failed", - {"config": {"profile": "keel", "provider": "brave-search", "interface": "dogfood-keel"}}, + { + "config": { + "profile": "keel", + "provider": "brave-search", + "interface": "dogfood-keel", + } + }, ), ), patch.object(resolve_v2_dogfood.time, "time", side_effect=[1_700_066_800, 1_700_066_800]), @@ -687,7 +808,9 @@ def test_run_fleet_status_refresh_preserves_failure_when_rerun_still_fails(tmp_p assert payload["profiles"]["keel"]["ok"] is False assert "artifact marked failed" in payload["profiles"]["keel"]["blocker"] - refreshed_keel = json.loads((artifact_root / "resolve-v2-dogfood-keel-admin-latest.json").read_text(encoding="utf-8")) + refreshed_keel = json.loads( + (artifact_root / "resolve-v2-dogfood-keel-admin-latest.json").read_text(encoding="utf-8") + ) assert refreshed_keel["summary"] == "refresh failed" assert refreshed_keel["ok"] is False @@ -702,17 +825,23 @@ def test_provision_api_key_via_admin_creates_agent_and_grants_access(): timeout=30.0, ) - responses = iter([ - {"status": 200, "json": []}, - {"status": 200, "json": {"agent_id": "agent_123", "api_key": "rhumb_new_key"}}, - {"status": 200, "json": {"access_id": "acc_123"}}, - ]) + responses = iter( + [ + {"status": 200, "json": []}, + {"status": 200, "json": {"agent_id": "agent_123", "api_key": "rhumb_new_key"}}, + {"status": 200, "json": {"access_id": "acc_123"}}, + ] + ) with ( patch.object(resolve_v2_dogfood, "_get_admin_key", return_value="admin_secret"), - patch.object(resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses)) as mock_http, + patch.object( + resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses) + ) as mock_http, ): - api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin(args, provider="brave-search") + api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin( + args, provider="brave-search" + ) assert api_key == "rhumb_new_key" assert metadata == { @@ -737,17 +866,21 @@ def test_provision_api_key_via_admin_rotates_existing_agent_and_tolerates_existi timeout=30.0, ) - responses = iter([ - {"status": 200, "json": [{"agent_id": "agent_existing", "name": "Verifier Agent"}]}, - {"status": 200, "json": {"new_api_key": "rhumb_rotated_key"}}, - {"status": 409, "json": {"detail": "already granted"}, "detail": "already granted"}, - ]) + responses = iter( + [ + {"status": 200, "json": [{"agent_id": "agent_existing", "name": "Verifier Agent"}]}, + {"status": 200, "json": {"new_api_key": "rhumb_rotated_key"}}, + {"status": 409, "json": {"detail": "already granted"}, "detail": "already granted"}, + ] + ) with ( patch.object(resolve_v2_dogfood, "_get_admin_key", return_value="admin_secret"), patch.object(resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses)), ): - api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin(args, provider="brave-search") + api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin( + args, provider="brave-search" + ) assert api_key == "rhumb_rotated_key" assert metadata == { @@ -771,19 +904,25 @@ def test_provision_api_key_via_admin_retries_transient_list_failure_once(): timeout=30.0, ) - responses = iter([ - {"status": 500, "json": {"detail": "An unexpected error occurred."}}, - {"status": 200, "json": [{"agent_id": "agent_existing", "name": "Verifier Agent"}]}, - {"status": 200, "json": {"new_api_key": "rhumb_rotated_key"}}, - {"status": 409, "json": {"detail": "already granted"}, "detail": "already granted"}, - ]) + responses = iter( + [ + {"status": 500, "json": {"detail": "An unexpected error occurred."}}, + {"status": 200, "json": [{"agent_id": "agent_existing", "name": "Verifier Agent"}]}, + {"status": 200, "json": {"new_api_key": "rhumb_rotated_key"}}, + {"status": 409, "json": {"detail": "already granted"}, "detail": "already granted"}, + ] + ) with ( patch.object(resolve_v2_dogfood, "_get_admin_key", return_value="admin_secret"), - patch.object(resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses)) as mock_http, + patch.object( + resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses) + ) as mock_http, patch.object(resolve_v2_dogfood.time, "sleep") as mock_sleep, ): - api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin(args, provider="brave-search") + api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin( + args, provider="brave-search" + ) assert api_key == "rhumb_rotated_key" assert metadata == { @@ -809,21 +948,27 @@ def test_provision_api_key_via_admin_retries_multiple_transient_list_failures_be timeout=30.0, ) - responses = iter([ - {"status": 500, "json": {"detail": "An unexpected error occurred."}}, - {"status": 500, "json": {"detail": "An unexpected error occurred."}}, - {"status": 500, "json": {"detail": "An unexpected error occurred."}}, - {"status": 200, "json": [{"agent_id": "agent_existing", "name": "Verifier Agent"}]}, - {"status": 200, "json": {"new_api_key": "rhumb_rotated_key"}}, - {"status": 409, "json": {"detail": "already granted"}, "detail": "already granted"}, - ]) + responses = iter( + [ + {"status": 500, "json": {"detail": "An unexpected error occurred."}}, + {"status": 500, "json": {"detail": "An unexpected error occurred."}}, + {"status": 500, "json": {"detail": "An unexpected error occurred."}}, + {"status": 200, "json": [{"agent_id": "agent_existing", "name": "Verifier Agent"}]}, + {"status": 200, "json": {"new_api_key": "rhumb_rotated_key"}}, + {"status": 409, "json": {"detail": "already granted"}, "detail": "already granted"}, + ] + ) with ( patch.object(resolve_v2_dogfood, "_get_admin_key", return_value="admin_secret"), - patch.object(resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses)) as mock_http, + patch.object( + resolve_v2_dogfood, "_http_json", side_effect=lambda *a, **k: next(responses) + ) as mock_http, patch.object(resolve_v2_dogfood.time, "sleep") as mock_sleep, ): - api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin(args, provider="brave-search") + api_key, metadata = resolve_v2_dogfood.provision_api_key_via_admin( + args, provider="brave-search" + ) assert api_key == "rhumb_rotated_key" assert metadata == { @@ -839,7 +984,6 @@ def test_provision_api_key_via_admin_retries_multiple_transient_list_failures_be assert mock_sleep.call_args_list == [call(0.5), call(1.0), call(2.0)] - def test_provision_api_key_via_admin_surfaces_http_status_and_detail_on_list_failure(): args = resolve_v2_dogfood.argparse.Namespace( base_url="https://api.rhumb.dev", diff --git a/packages/api/tests/test_route_explanation.py b/packages/api/tests/test_route_explanation.py index 264f236e..3631111c 100644 --- a/packages/api/tests/test_route_explanation.py +++ b/packages/api/tests/test_route_explanation.py @@ -26,6 +26,7 @@ # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def _clear_store(): """Clear the explanation store before each test.""" @@ -108,6 +109,7 @@ def _make_alias_circuits() -> dict[str, str]: # Explanation building # --------------------------------------------------------------------------- + class TestBuildExplanation: """Test the core explanation builder.""" @@ -203,6 +205,53 @@ def test_alias_backed_public_pin_is_normalized_for_explanation(self): assert pdl.policy_checks["allow_list_ok"] is False assert pdl.ineligible_reason == "not_in_allow_list" + def test_explanation_candidates_embed_index_route_facts_and_recommendation_policy(self): + exp = build_explanation( + capability_id="search.query", + mappings=[ + { + "service_slug": "brave-search-api", + "cost_per_call": 0.01, + "credential_modes": ["rhumb_managed"], + }, + { + "service_slug": "private-search", + "cost_per_call": 0.02, + "credential_modes": ["byok"], + "route_id": "route_private_search_browser_v1", + "substrate": "browser_discovered_private_endpoint", + "provenance_origin": "browser_observed", + "source_risk": "anti_bot_or_tos_sensitive", + "side_effect_class": "read", + "public_claim_boundary": "Private observed route, not approved for default execution.", + }, + ], + scores_by_slug={"brave-search-api": 8.7, "private-search": 7.0}, + circuit_states={"brave-search-api": "closed", "private-search": "closed"}, + selected_provider="brave-search-api", + ) + + by_provider = {candidate.provider_id: candidate.to_dict() for candidate in exp.candidates} + brave_facts = by_provider["brave-search-api"]["route_facts"] + assert brave_facts["route_id"] == "route_search_query_brave_search_api_official_api_v1" + assert brave_facts["manifest_digest"].startswith("sha256:") + assert ( + brave_facts["evidence_packet_id"] + == "evidence_search_query_brave_search_api_official_api_2026_05_19" + ) + assert brave_facts["source_risk"] == "verified_low" + assert brave_facts["recommendation_policy"]["default_recommendable"] is True + + private_facts = by_provider["private-search"]["route_facts"] + assert private_facts["substrate"] == "browser_discovered_private_endpoint" + assert private_facts["source_risk"] == "anti_bot_or_tos_sensitive" + assert private_facts["recommendation_policy"]["default_recommendable"] is False + assert private_facts["recommendation_policy"]["blocked"] is True + assert ( + "source_risk_anti_bot_or_tos_sensitive_not_default" + in private_facts["recommendation_policy"]["reasons"] + ) + def test_no_provider_available(self): exp = build_explanation( capability_id="ai.generate_text", @@ -220,6 +269,7 @@ def test_no_provider_available(self): # Policy filtering # --------------------------------------------------------------------------- + class TestPolicyFiltering: """Test that policy controls show up in candidate explanations.""" @@ -320,6 +370,7 @@ def test_cost_ceiling(self): # Human summary # --------------------------------------------------------------------------- + class TestHumanSummary: """Test human-readable summary generation.""" @@ -362,6 +413,7 @@ def test_no_provider_summary(self): # Serialization # --------------------------------------------------------------------------- + class TestSerialization: """Test dict and compact serialization.""" @@ -442,7 +494,9 @@ def test_alias_backed_to_dict_uses_public_provider_ids(self): assert data["winner"]["provider_id"] == "brave-search-api" assert data["candidates"][0]["provider_id"] == "brave-search-api" - assert any(candidate["provider_id"] == "people-data-labs" for candidate in data["candidates"]) + assert any( + candidate["provider_id"] == "people-data-labs" for candidate in data["candidates"] + ) assert "brave-search-api" in data["human_summary"] assert "brave-search selected" not in data["human_summary"] assert compact["winner"] == "brave-search-api" @@ -481,7 +535,9 @@ def test_to_dict_canonicalizes_alternate_alias_text_when_provider_ids_are_alread assert compact["human_summary"] == "brave-search-api selected over people-data-labs." assert "brave-search-api-api" not in data["human_summary"] - def test_to_dict_canonicalizes_same_provider_alias_text_when_provider_ids_are_already_public(self): + def test_to_dict_canonicalizes_same_provider_alias_text_when_provider_ids_are_already_public( + self, + ): exp = RouteExplanation( explanation_id="rexp_public_ids_same_alias", capability_id="search.query", @@ -511,8 +567,14 @@ def test_to_dict_canonicalizes_same_provider_alias_text_when_provider_ids_are_al data = exp.to_dict() compact = exp.to_compact() - assert data["human_summary"] == "Brave Search (brave-search-api) selected over people-data-labs." - assert compact["human_summary"] == "Brave Search (brave-search-api) selected over people-data-labs." + assert ( + data["human_summary"] + == "Brave Search (brave-search-api) selected over people-data-labs." + ) + assert ( + compact["human_summary"] + == "Brave Search (brave-search-api) selected over people-data-labs." + ) assert "brave-search-api-api" not in data["human_summary"] def test_candidate_factor_dict(self): @@ -537,6 +599,7 @@ def test_candidate_factor_dict(self): # Store / retrieve # --------------------------------------------------------------------------- + class TestStore: """Test the in-memory explanation store.""" @@ -628,7 +691,9 @@ def test_row_to_explanation_does_not_double_rewrite_canonical_provider_text(self assert exp.human_summary == "brave-search-api selected over people-data-labs." assert "brave-search-api-api" not in exp.human_summary - def test_row_to_explanation_canonicalizes_alternate_alias_text_when_persisted_ids_are_already_public(self): + def test_row_to_explanation_canonicalizes_alternate_alias_text_when_persisted_ids_are_already_public( + self, + ): exp = _row_to_explanation( { "explanation_id": "rexp_public_ids", @@ -679,6 +744,7 @@ def test_store_eviction(self): # Should not exceed max capacity (1000) from services.route_explanation import _explanation_store + assert len(_explanation_store) <= 1000 @@ -807,6 +873,7 @@ async def test_persist_explanation_canonicalizes_alternate_alias_text_when_provi # Layer 1 explanations # --------------------------------------------------------------------------- + class TestLayer1: """Test Layer 1 (agent-pinned) explanations.""" diff --git a/packages/api/tests/test_route_plan_enforcement.py b/packages/api/tests/test_route_plan_enforcement.py new file mode 100644 index 00000000..7a805848 --- /dev/null +++ b/packages/api/tests/test_route_plan_enforcement.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from schemas.resolve_boundary_contract import ROUTE_PLAN_ENFORCEMENT_CHECKS +from services.route_plan_enforcement import ( + canonical_json_sha256, + issue_route_plan, + validate_route_plan, +) + +SECRET = "test-route-plan-secret" +NOW = 1_800_000_000 + + +def _payload() -> dict[str, object]: + return { + "route_plan_id": "rp_test_001", + "nonce": "nonce-test-001", + "org_id": "org_123", + "agent_id": "agent_456", + "principal_id": "principal_789", + "auth_rail": "oauth", + "capability_id": "cap_search_query", + "service_id": "search", + "provider_id": "brave-search-api", + "route_id": "route_search_query_brave_api_v1", + "credential_mode": "delegated", + "credential_handle_id": "cred_abc", + "required_scopes": ["search.query", "search.read"], + "input_hash": canonical_json_sha256({"query": "weather", "filters": {"freshness": "day"}}), + "manifest_digest": "sha256:manifest", + "evidence_packet_digest": "sha256:evidence", + "policy_snapshot_digest": "sha256:policy", + "budget_snapshot_digest": "sha256:budget", + "sandbox_profile_id": "sandbox_readonly_net", + "artifact_hashes": ["sha256:artifact-a", "sha256:artifact-b"], + } + + +def _expected() -> dict[str, object]: + expected = _payload() + expected["granted_scopes"] = ["search.query", "search.read", "search.admin"] + return expected + + +def _token(payload: dict[str, object] | None = None, *, ttl_seconds: int = 300) -> str: + return issue_route_plan(payload or _payload(), SECRET, now=NOW, ttl_seconds=ttl_seconds) + + +def test_valid_route_plan_round_trip_allows_and_records_nonce() -> None: + seen_nonces: set[str] = set() + token = _token() + + result = validate_route_plan(token, _expected(), SECRET, now=NOW + 1, seen_nonces=seen_nonces) + + assert token.startswith("rhrp1.") + assert token.count(".") == 2 + assert result["allowed"] is True + assert result["stop_condition"] is None + assert set(ROUTE_PLAN_ENFORCEMENT_CHECKS).issubset(result["checks"]) + assert all(result["checks"].values()) + assert seen_nonces == {"nonce-test-001"} + + +def test_expired_route_plan_fails_closed() -> None: + result = validate_route_plan(_token(ttl_seconds=5), _expected(), SECRET, now=NOW + 5) + + assert result["allowed"] is False + assert result["stop_condition"] == "route_plan_expired" + assert result["checks"]["signature_valid"] is True + assert result["checks"]["not_expired"] is False + + +def test_bad_signature_fails_closed() -> None: + token = _token() + bad_token = token[:-1] + ("A" if token[-1] != "A" else "B") + + result = validate_route_plan(bad_token, _expected(), SECRET, now=NOW + 1) + + assert result["allowed"] is False + assert result["stop_condition"] == "route_plan_signature_invalid" + assert result["checks"]["signature_valid"] is False + + +def test_principal_mismatch_uses_principal_stop() -> None: + expected = _expected() + expected["principal_id"] = "principal_other" + + result = validate_route_plan(_token(), expected, SECRET, now=NOW + 1) + + assert result["allowed"] is False + assert result["stop_condition"] == "route_plan_principal_mismatch" + assert result["checks"]["principal_matches"] is False + + +def test_route_and_provider_mismatch_uses_route_plan_mismatch() -> None: + expected = _expected() + expected["route_id"] = "route_search_query_other_v1" + expected["provider_id"] = "other-provider" + + result = validate_route_plan(_token(), expected, SECRET, now=NOW + 1) + + assert result["allowed"] is False + assert result["stop_condition"] == "route_plan_mismatch" + assert result["checks"]["route_id_matches"] is False + assert result["checks"]["provider_id_matches"] is False + + +def test_missing_required_scope_uses_credential_scope_stop() -> None: + expected = _expected() + expected["granted_scopes"] = ["search.query"] + + result = validate_route_plan(_token(), expected, SECRET, now=NOW + 1) + + assert result["allowed"] is False + assert result["stop_condition"] == "credential_scope_mismatch" + assert result["checks"]["required_scopes_covered"] is False + + +def test_revoked_nonce_fails_closed() -> None: + result = validate_route_plan( + _token(), + _expected(), + SECRET, + now=NOW + 1, + revoked_nonces={"nonce-test-001"}, + ) + + assert result["allowed"] is False + assert result["stop_condition"] == "route_plan_revoked" + assert result["checks"]["not_revoked"] is False + + +def test_replay_nonce_fails_closed() -> None: + result = validate_route_plan( + _token(), + _expected(), + SECRET, + now=NOW + 1, + seen_nonces={"nonce-test-001"}, + ) + + assert result["allowed"] is False + assert result["stop_condition"] == "route_plan_replay" + assert result["checks"]["replay_not_seen"] is False + + +def test_canonical_input_hash_is_stable_for_key_order() -> None: + left = {"b": [2, 1], "a": {"z": True, "x": None}} + right = {"a": {"x": None, "z": True}, "b": [2, 1]} + + assert canonical_json_sha256(left) == canonical_json_sha256(right) + assert canonical_json_sha256(left).startswith("sha256:") + assert canonical_json_sha256(left) != canonical_json_sha256({"b": [1, 2], "a": right["a"]}) diff --git a/packages/api/tests/test_route_plan_state.py b/packages/api/tests/test_route_plan_state.py new file mode 100644 index 00000000..6b028634 --- /dev/null +++ b/packages/api/tests/test_route_plan_state.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import pytest + +from services.route_plan_state import RoutePlanStateStore, RoutePlanStateUnavailable + + +class MockQueryResult: + def __init__(self, data=None): + self.data = data + + +class MockQueryBuilder: + def __init__(self, data=None): + self._data = data + self.inserted = None + + def select(self, *args): + return self + + def eq(self, *args): + return self + + def lt(self, *args): + return self + + def maybe_single(self): + return self + + def delete(self): + return self + + def insert(self, data): + self.inserted = data + return self + + def upsert(self, data): + self.inserted = data + return self + + async def execute(self): + return MockQueryResult(self._data) + + +class MockSupabase: + def __init__(self): + self._tables = {} + + def table(self, name): + return self._tables.get(name, MockQueryBuilder()) + + def set_table(self, name, builder): + self._tables[name] = builder + + +@pytest.mark.asyncio +async def test_route_plan_state_first_claim_allowed_and_hashes_nonce() -> None: + builder = MockQueryBuilder(None) + db = MockSupabase() + db.set_table("route_plan_state", builder) + store = RoutePlanStateStore(db, cleanup_interval_seconds=99999) + + claim = await store.check_and_claim( + nonce="nonce-001", + route_plan_id_hash="sha256:plan", + expires_at=1_800_000_300, + ) + + assert claim.allowed is True + assert claim.state_backend == "database" + assert claim.nonce_hash.startswith("sha256:") + assert builder.inserted["nonce_hash"] == claim.nonce_hash + assert builder.inserted["route_plan_id_hash"] == "sha256:plan" + assert builder.inserted["state"] == "claimed" + + +@pytest.mark.asyncio +async def test_route_plan_state_existing_claim_is_replay() -> None: + db = MockSupabase() + db.set_table("route_plan_state", MockQueryBuilder({"state": "claimed"})) + store = RoutePlanStateStore(db, cleanup_interval_seconds=99999) + + claim = await store.check_and_claim( + nonce="nonce-001", + route_plan_id_hash="sha256:plan", + expires_at=1_800_000_300, + ) + + assert claim.allowed is False + assert claim.stop_condition == "route_plan_replay" + + +@pytest.mark.asyncio +async def test_route_plan_state_existing_revoked_row_is_revoked() -> None: + db = MockSupabase() + db.set_table( + "route_plan_state", + MockQueryBuilder( + { + "state": "revoked", + "revoked_at": "2026-06-04T00:00:00Z", + "revocation_reason": "operator", + } + ), + ) + store = RoutePlanStateStore(db, cleanup_interval_seconds=99999) + + claim = await store.check_and_claim( + nonce="nonce-001", + route_plan_id_hash="sha256:plan", + expires_at=1_800_000_300, + ) + + assert claim.allowed is False + assert claim.stop_condition == "route_plan_revoked" + assert "operator" in claim.detail + + +@pytest.mark.asyncio +async def test_route_plan_state_db_failure_can_fail_closed() -> None: + class FailingBuilder(MockQueryBuilder): + async def execute(self): + raise ConnectionError("db down") + + db = MockSupabase() + db.set_table("route_plan_state", FailingBuilder()) + store = RoutePlanStateStore(db, cleanup_interval_seconds=99999) + + with pytest.raises(RoutePlanStateUnavailable): + await store.check_and_claim( + nonce="nonce-001", + route_plan_id_hash="sha256:plan", + expires_at=1_800_000_300, + allow_fallback=False, + ) + + +@pytest.mark.asyncio +async def test_route_plan_state_memory_fallback_detects_replay() -> None: + class FailingBuilder(MockQueryBuilder): + async def execute(self): + raise ConnectionError("db down") + + db = MockSupabase() + db.set_table("route_plan_state", FailingBuilder()) + store = RoutePlanStateStore(db, cleanup_interval_seconds=99999) + + first = await store.check_and_claim( + nonce="nonce-001", + route_plan_id_hash="sha256:plan", + expires_at=1_800_000_300, + ) + second = await store.check_and_claim( + nonce="nonce-001", + route_plan_id_hash="sha256:plan", + expires_at=1_800_000_300, + ) + + assert first.allowed is True + assert first.state_backend == "memory_fallback" + assert second.allowed is False + assert second.stop_condition == "route_plan_replay" diff --git a/packages/api/tests/test_route_taxonomy.py b/packages/api/tests/test_route_taxonomy.py new file mode 100644 index 00000000..567d98f8 --- /dev/null +++ b/packages/api/tests/test_route_taxonomy.py @@ -0,0 +1,96 @@ +"""PP-1 route taxonomy policy tests.""" + +from __future__ import annotations + +from schemas.route_taxonomy import ( + PROVENANCE_ORIGINS, + SOURCE_RISKS, + SUBSTRATES, + lint_public_claim_boundary, + route_recommendation_policy, +) + + +def test_route_taxonomy_exposes_required_substrate_provenance_and_risk_dimensions() -> None: + assert { + "official_api", + "official_cli", + "official_mcp", + "sdk_code_mode", + "generated_adapter", + "browser_discovered_private_endpoint", + }.issubset(SUBSTRATES) + assert {"vendor_official", "rhumb_managed", "browser_observed", "unknown"}.issubset( + PROVENANCE_ORIGINS + ) + assert { + "verified_low", + "community_unverified", + "anti_bot_or_tos_sensitive", + "unsupported", + }.issubset(SOURCE_RISKS) + + +def test_verified_official_api_route_is_default_recommendable() -> None: + policy = route_recommendation_policy( + { + "substrate": "official_api", + "provenance_origin": "rhumb_managed", + "source_risk": "verified_low", + "side_effect_class": "read", + } + ) + + assert policy["default_recommendable"] is True + assert policy["recommendable"] is True + assert policy["reasons"] == [] + + +def test_private_or_unverified_routes_are_excluded_until_explicit_and_policy_allowed() -> None: + route = { + "substrate": "browser_discovered_private_endpoint", + "provenance_origin": "browser_observed", + "source_risk": "experimental_private", + "side_effect_class": "read", + } + + default_policy = route_recommendation_policy(route) + assert default_policy["default_recommendable"] is False + assert default_policy["recommendable"] is False + assert default_policy["requires_explicit_request"] is True + assert "private_or_sniffed_route_requires_explicit_policy" in default_policy["reasons"] + + override_policy = route_recommendation_policy(route, explicit_request=True, policy_allowed=True) + assert override_policy["default_recommendable"] is False + assert override_policy["recommendable"] is True + + +def test_anti_bot_routes_remain_blocked_even_with_explicit_override() -> None: + policy = route_recommendation_policy( + { + "substrate": "browser_discovered_private_endpoint", + "provenance_origin": "browser_observed", + "source_risk": "anti_bot_or_tos_sensitive", + "side_effect_class": "read", + }, + explicit_request=True, + policy_allowed=True, + ) + + assert policy["recommendable"] is False + assert policy["blocked"] is True + assert "source_risk_anti_bot_or_tos_sensitive_not_default" in policy["reasons"] + + +def test_public_claim_linter_rejects_overclaims_for_weak_routes() -> None: + errors = lint_public_claim_boundary( + { + "substrate": "generated_adapter", + "provenance_origin": "rhumb_generated", + "source_risk": "community_unverified", + "side_effect_class": "read", + "public_claim_boundary": "This is a vendor-approved production-grade route.", + } + ) + + assert errors == ["public_claim_overstates_route_evidence"] diff --git a/scripts/resolve_launch_catalog_smoke.py b/scripts/resolve_launch_catalog_smoke.py new file mode 100755 index 00000000..41f229e5 --- /dev/null +++ b/scripts/resolve_launch_catalog_smoke.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Resolve launch-catalog smoke for Rhumb-managed agent capabilities. + +This script proves the P0 public launch catalog through resolve + estimate, and +can execute only bounded, non-send/non-post/non-CRM fixtures. It writes compact +redacted artifacts only. Secrets are loaded from RHUMB_DOGFOOD_API_KEY or +1Password via `sop` and are never written to disk. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +DEFAULT_BASE_URL = "https://api.rhumb.dev" +DEFAULT_VAULT = "OpenClaw Agents" +DEFAULT_RHUMB_KEY_ITEMS = ( + "Rhumb API Key - pedro-dogfood", + "Rhumb API Key - atlas@supertrained.ai", +) + +P0_CATALOG: dict[str, dict[str, Any]] = { + "search.query": { + "provider": "exa", + "safety": "green", + "execution_policy": "safe_execute", + "body": {"query": "agent native API capability routing", "numResults": 3}, + }, + "scrape.extract": { + "provider": "firecrawl", + "safety": "green", + "execution_policy": "safe_execute", + "body": { + "url": "https://example.com", + "formats": ["markdown"], + "onlyMainContent": True, + }, + }, + "ai.generate_text": { + "provider": "openai", + "safety": "green", + "execution_policy": "safe_execute", + "body": { + "model": "gpt-4o-mini", + "messages": [ + {"role": "system", "content": "Answer with exactly four words."}, + {"role": "user", "content": "Name the purpose of Resolve."}, + ], + "max_tokens": 20, + "temperature": 0, + }, + }, + "ai.generate_image": { + "provider": "openai", + "safety": "amber", + "execution_policy": "safe_execute", + "body": { + "model": "gpt-image-1", + "prompt": ( + "A simple abstract blue compass icon on a plain white background, " + "no text, no people." + ), + "size": "1024x1024", + "quality": "low", + "n": 1, + }, + }, + "data.enrich_person": { + "provider": "people-data-labs", + "safety": "amber", + "execution_policy": "proof_substitute", + "proof_substitute": ( + "Resolve + estimate only until a consented internal person fixture is approved." + ), + }, + "data.enrich_company": { + "provider": "apollo", + "safety": "amber", + "execution_policy": "safe_execute", + "body": {"domain": "rhumb.dev"}, + }, + "geo.lookup": { + "provider": "ipinfo", + "safety": "green", + "execution_policy": "safe_execute", + "body": {"ip": "8.8.8.8"}, + }, + "maps.places_search": { + "provider": "google-places", + "safety": "green", + "execution_policy": "safe_execute", + "body": {"query": "Golden Gate Park San Francisco"}, + }, +} + +DEFERRED_CAPABILITIES: dict[str, str] = { + "email.send": "External-write capability; requires owned sender, one-message cap, idempotency, and explicit approval.", + "email.verify": "No managed provider is configured in the current hosted Resolve surface.", + "sendgrid": "Registered proxy provider, but non-callable until RHUMB_CREDENTIAL_SENDGRID_API_KEY is configured.", +} + + +def _json_hash16(value: Any) -> str: + rendered = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(rendered.encode("utf-8")).hexdigest()[:16] + + +def _load_key_from_sop(title: str, vault: str) -> str | None: + try: + cp = subprocess.run( + ["sop", "item", "get", title, "--vault", vault, "--fields", "credential", "--reveal"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=25, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if cp.returncode != 0: + return None + value = (cp.stdout or "").replace("\r", "").replace("\n", "").strip() + return value or None + + +class RhumbClient: + def __init__(self, base_url: str, api_key: str | None = None) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + + def request( + self, + method: str, + path: str, + *, + auth: bool = False, + body: Any | None = None, + timeout: float = 90.0, + ) -> tuple[int, Any]: + data = None + headers = { + "Accept": "application/json", + "User-Agent": "rhumb-launch-catalog-smoke/2026-06", + } + if auth and self.api_key: + headers["X-Rhumb-Key"] = self.api_key + if body is not None: + data = json.dumps(body, separators=(",", ":")).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request( + f"{self.base_url}{path}", + data=data, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + status = int(getattr(resp, "status", 0) or 0) + except urllib.error.HTTPError as exc: + raw = exc.read() + status = int(exc.code) + except Exception as exc: # pragma: no cover - live diagnostic path + return 0, {"transport_error": type(exc).__name__, "message": str(exc)} + + if not raw: + return status, None + try: + return status, json.loads(raw.decode("utf-8")) + except Exception: + return status, { + "non_json_bytes": len(raw), + "sha256_16": hashlib.sha256(raw).hexdigest()[:16], + } + + +def load_working_key(base_url: str, vault: str, item_names: tuple[str, ...]) -> tuple[str, str]: + env_value = os.environ.get("RHUMB_DOGFOOD_API_KEY", "").strip() + candidates: list[tuple[str, str]] = [] + if env_value: + candidates.append(("RHUMB_DOGFOOD_API_KEY", env_value)) + for item_name in item_names: + value = _load_key_from_sop(item_name, vault) + if value: + candidates.append((item_name, value)) + + for source, key in candidates: + status, _payload = RhumbClient(base_url, key).request( + "GET", + "/v1/billing/balance", + auth=True, + timeout=20, + ) + if 200 <= status < 300: + return source, key + raise SystemExit("No working Rhumb governed API key found.") + + +def _find_scalar_values(value: Any, keys: set[str]) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + for key, child in value.items(): + if key in keys and isinstance(child, (str, int, float, bool)): + found.append({key: child if not isinstance(child, str) else child[:160]}) + found.extend(_find_scalar_values(child, keys)) + elif isinstance(value, list): + for child in value: + found.extend(_find_scalar_values(child, keys)) + return found[:8] + + +def compact_payload(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + return {"payload_type": type(payload).__name__, "payload_hash16": _json_hash16(payload)} + + out: dict[str, Any] = {"top_keys": sorted(payload.keys())[:20]} + for key in ("error", "message", "detail", "resolution", "request_id"): + if isinstance(payload.get(key), str): + out[key] = payload[key] + + data = payload.get("data") if isinstance(payload.get("data"), dict) else payload + if isinstance(data, dict): + out["data_keys"] = sorted(data.keys())[:30] + for key in ( + "capability_id", + "provider", + "provider_used", + "credential_mode", + "upstream_status", + "execution_id", + "receipt_id", + "latency_ms", + "cost_estimate_usd", + "estimated_cost_usd", + ): + if key in data: + out[key] = data[key] + + upstream = data.get("upstream_response") + if upstream is not None: + out["upstream_type"] = type(upstream).__name__ + out["upstream_hash16"] = _json_hash16(upstream) + if isinstance(upstream, dict): + out["upstream_keys"] = sorted(upstream.keys())[:25] + out["evidence_values"] = _find_scalar_values( + upstream, + { + "ip", + "city", + "region", + "country", + "name", + "formatted_address", + "title", + "url", + "link", + }, + ) + if isinstance(upstream.get("data"), list): + out["upstream_data_count"] = len(upstream["data"]) + out["upstream_data_shapes"] = [ + { + "keys": sorted(item.keys()), + "b64_json_len": ( + len(item.get("b64_json", "")) + if isinstance(item, dict) and isinstance(item.get("b64_json"), str) + else None + ), + "url_present": ( + isinstance(item.get("url"), str) + if isinstance(item, dict) + else False + ), + "item_hash16": _json_hash16(item), + } + for item in upstream["data"][:2] + ] + nested = upstream.get("data") if isinstance(upstream.get("data"), dict) else {} + if isinstance(nested, dict): + if isinstance(nested.get("markdown"), str): + markdown = nested["markdown"] + out["markdown_len"] = len(markdown) + out["markdown_hash16"] = hashlib.sha256( + markdown.encode("utf-8") + ).hexdigest()[:16] + if isinstance(nested.get("metadata"), dict): + out["metadata_keys"] = sorted(nested["metadata"].keys())[:20] + + candidates: list[Any] = [] + for result_key in ("results", "web", "items"): + if isinstance(upstream.get(result_key), list): + candidates = upstream[result_key] + break + if isinstance(nested, dict): + for result_key in ("results", "items"): + if isinstance(nested.get(result_key), list): + candidates = nested[result_key] + break + if candidates: + out["result_count_observed"] = len(candidates) + out["top_result_shapes"] = [ + ( + {"keys": sorted(item.keys())[:12], "hash16": _json_hash16(item)} + if isinstance(item, dict) + else {"type": type(item).__name__, "hash16": _json_hash16(item)} + ) + for item in candidates[:3] + ] + return out + + +def prove_catalog(client: RhumbClient, *, execute_safe: bool) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for capability, spec in P0_CATALOG.items(): + provider = str(spec["provider"]) + encoded_cap = urllib.parse.quote(capability, safe="") + encoded_provider = urllib.parse.quote(provider, safe="") + record: dict[str, Any] = { + "capability": capability, + "provider": provider, + "safety": spec["safety"], + "execution_policy": spec["execution_policy"], + } + + resolve_status, resolve_payload = client.request( + "GET", + f"/v1/capabilities/{encoded_cap}/resolve?credential_mode=rhumb_managed", + auth=True, + timeout=45, + ) + record["resolve_status"] = resolve_status + record["resolve"] = compact_payload(resolve_payload) + + estimate_status, estimate_payload = client.request( + "GET", + ( + f"/v1/capabilities/{encoded_cap}/execute/estimate" + f"?credential_mode=rhumb_managed&provider={encoded_provider}" + ), + auth=True, + timeout=45, + ) + record["estimate_status"] = estimate_status + record["estimate"] = compact_payload(estimate_payload) + + should_execute = ( + execute_safe + and spec["execution_policy"] == "safe_execute" + and 200 <= estimate_status < 300 + ) + record["execute_ran"] = False + if should_execute: + status, payload = client.request( + "POST", + f"/v1/capabilities/{encoded_cap}/execute", + auth=True, + body={ + "provider": provider, + "credential_mode": "rhumb_managed", + "interface": "launch-catalog-smoke-202606", + "idempotency_key": f"launch-{capability}-{provider}-{int(time.time() * 1000)}", + "body": spec["body"], + }, + timeout=120, + ) + record["execute_ran"] = True + record["execute_status"] = status + record["execute"] = compact_payload(payload) + elif spec["execution_policy"] == "proof_substitute": + record["proof_substitute"] = spec.get("proof_substitute") + + records.append(record) + return records + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--base-url", default=DEFAULT_BASE_URL) + ap.add_argument("--out", required=True, help="Path for compact redacted JSON artifact") + ap.add_argument("--execute-safe", action="store_true", help="Run bounded non-write executions") + ap.add_argument("--vault", default=DEFAULT_VAULT) + args = ap.parse_args() + + key_source, api_key = load_working_key(args.base_url, args.vault, DEFAULT_RHUMB_KEY_ITEMS) + client = RhumbClient(args.base_url, api_key) + records = prove_catalog(client, execute_safe=args.execute_safe) + summary = { + "base_url": args.base_url, + "key_source": key_source, + "execute_safe": args.execute_safe, + "p0_count": len(P0_CATALOG), + "deferred_capabilities": DEFERRED_CAPABILITIES, + "records": records, + } + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(summary, indent=2, sort_keys=True)) + print( + json.dumps( + [ + { + "capability": r["capability"], + "provider": r["provider"], + "resolve_status": r.get("resolve_status"), + "estimate_status": r.get("estimate_status"), + "execute_ran": r.get("execute_ran"), + "execute_status": r.get("execute_status"), + "receipt_id": (r.get("execute") or {}).get("receipt_id"), + "proof_substitute": r.get("proof_substitute"), + } + for r in records + ], + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resolve_v2_dogfood.py b/scripts/resolve_v2_dogfood.py index aa2a9745..0cfb3eba 100644 --- a/scripts/resolve_v2_dogfood.py +++ b/scripts/resolve_v2_dogfood.py @@ -5,10 +5,10 @@ 1. Health + capability catalog read 2. Optional account-policy smoke test -3. Layer 2 capability estimate + execute -4. Layer 2 receipt + explanation fetch +3. Layer 2 capability resolve + estimate + execute +4. Layer 2 receipt + explanation fetch + single-receipt verification 5. Layer 1 provider detail + exact-provider execute -6. Layer 1 receipt fetch +6. Layer 1 receipt fetch + single-receipt verification 7. Billing / trust / audit reads 8. Receipt-chain verification @@ -320,9 +320,7 @@ def _list_agents_via_admin_with_retry( headers: dict[str, str], timeout: float, ) -> tuple[dict[str, Any], int]: - url = ( - f"{root}/v1/admin/agents?organization_id={quote(org_id, safe='')}&status=active" - ) + url = f"{root}/v1/admin/agents?organization_id={quote(org_id, safe='')}&status=active" delays = list(ADMIN_BOOTSTRAP_LIST_RETRY_DELAYS) attempts = len(delays) + 1 @@ -374,9 +372,7 @@ def provision_api_key_via_admin( agents = list_resp.get("json") if list_resp.get("status") != 200 or not isinstance(agents, list): detail = _extract_error_detail(list_resp) - raise RuntimeError( - f"Admin agent list failed ({list_resp.get('status')}): {detail}" - ) + raise RuntimeError(f"Admin agent list failed ({list_resp.get('status')}): {detail}") existing_agent = next( (item for item in agents if item.get("name") == agent_name), @@ -408,9 +404,7 @@ def provision_api_key_via_admin( api_key = create_payload.get("api_key") if create_resp.get("status") != 200 or not agent_id or not api_key: detail = _extract_error_detail(create_resp) - raise RuntimeError( - f"Admin agent create failed ({create_resp.get('status')}): {detail}" - ) + raise RuntimeError(f"Admin agent create failed ({create_resp.get('status')}): {detail}") metadata.update({"mode": "created", "agent_id": agent_id}) else: agent_id = existing_agent.get("agent_id") @@ -425,9 +419,7 @@ def provision_api_key_via_admin( api_key = rotate_payload.get("new_api_key") if rotate_resp.get("status") != 200 or not api_key: detail = _extract_error_detail(rotate_resp) - raise RuntimeError( - f"Admin agent rotate failed ({rotate_resp.get('status')}): {detail}" - ) + raise RuntimeError(f"Admin agent rotate failed ({rotate_resp.get('status')}): {detail}") metadata.update({"mode": "rotated", "agent_id": agent_id}) if service: @@ -443,7 +435,9 @@ def provision_api_key_via_admin( raise RuntimeError( f"Admin agent grant-access failed ({grant_resp.get('status')}): {detail}" ) - metadata["service_access"] = "already_granted" if grant_resp.get("status") == 409 else "granted" + metadata["service_access"] = ( + "already_granted" if grant_resp.get("status") == 409 else "granted" + ) return api_key, metadata @@ -645,6 +639,23 @@ def extract_receipt_id(data: dict[str, Any] | None) -> str | None: return None +def extract_compact_receipt(data: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(data, dict): + return None + rhumb_v2 = data.get("_rhumb_v2") + if not isinstance(rhumb_v2, dict): + return None + compact = rhumb_v2.get("compact_receipt") + return compact if isinstance(compact, dict) else None + + +def extract_verifier_status(data: dict[str, Any] | None) -> str | None: + if not isinstance(data, dict): + return None + value = data.get("verifier_status") + return value if isinstance(value, str) and value else None + + def extract_execution_id(data: dict[str, Any] | None) -> str | None: if not isinstance(data, dict): return None @@ -717,9 +728,69 @@ def _build_summary(state: dict[str, Any]) -> str: if isinstance(audit, dict): parts.append(f"audit_events={audit.get('total_events', 'n/a')}") + proof = state.get("first_call_proof") or {} + if isinstance(proof, dict) and proof: + parts.append( + "first_call_verified=" + f"{str(bool(proof.get('ok'))).lower()}" + f" verifier={proof.get('layer2_verifier_status') or 'n/a'}" + ) + return "; ".join(parts) +def _require_verified_receipt( + label: str, verify_data: Any, state: dict[str, Any] +) -> dict[str, Any]: + if not isinstance(verify_data, dict): + raise FlowError(f"{label} receipt verifier returned malformed payload", state) + if verify_data.get("verifier_status") != "verified": + checks = verify_data.get("checks") if isinstance(verify_data.get("checks"), dict) else {} + failed_checks = sorted(key for key, value in checks.items() if not value) + state["last_error_response"] = { + "label": f"{label} receipt verify", + "status": "partial", + "detail": f"verifier_status={verify_data.get('verifier_status')}; failed_checks={failed_checks}", + } + raise FlowError(f"{label} receipt verifier did not return verified", state) + return verify_data + + +def _build_first_call_proof(state: dict[str, Any]) -> dict[str, Any]: + config = state.get("config") or {} + layer2 = state.get("layer2") or {} + execute_data = (layer2.get("execute") or {}).get("data") or {} + receipt = layer2.get("receipt") or {} + verify = layer2.get("verify") or {} + compact = extract_compact_receipt(execute_data) + if compact is None and isinstance(verify, dict): + verifier_compact = verify.get("compact_receipt") + compact = verifier_compact if isinstance(verifier_compact, dict) else None + route = (compact or {}).get("route") if isinstance(compact, dict) else {} + if not isinstance(route, dict): + route = {} + + verifier_status = extract_verifier_status(verify if isinstance(verify, dict) else None) + receipt_id = extract_receipt_id(execute_data) or receipt.get("receipt_id") + provider_selected = extract_provider_used(execute_data) or route.get("provider_id") + return { + "ok": bool(receipt_id and verifier_status == "verified"), + "flow": ["search", "resolve", "estimate", "execute", "receipt", "verify"], + "capability": config.get("capability"), + "credential_mode": config.get("credential_mode"), + "provider_selected": provider_selected, + "substrate": route.get("substrate"), + "provenance_origin": route.get("provenance_origin"), + "source_risk": route.get("source_risk"), + "route_id": route.get("route_id"), + "manifest_digest": route.get("manifest_digest"), + "evidence_packet_digest": route.get("evidence_packet_digest"), + "layer2_receipt_id": receipt_id, + "layer2_verifier_status": verifier_status, + "compact_receipt_present": isinstance(compact, dict), + } + + def _build_batch_summary(results: dict[str, dict[str, Any]]) -> str: total = len(results) ok_count = sum(1 for payload in results.values() if payload.get("ok")) @@ -1102,6 +1173,26 @@ def run_flow(args: argparse.Namespace) -> dict[str, Any]: state["policy"] = policy_state + state["layer2"] = {} + + resolve_resp = _expect_success( + "v2 layer2 resolve", + _http_json( + "GET", + ( + f"{v2_root}/capabilities/{quote(args.capability, safe='')}/resolve" + f"?credential_mode={quote(args.credential_mode, safe='')}" + ), + headers=headers, + timeout=args.timeout, + ), + state, + ) + state["layer2"]["resolve"] = { + "data": _extract_data(resolve_resp.get("json")), + "headers": resolve_resp.get("headers"), + } + estimate_resp = _expect_success( "v2 layer2 estimate", _http_json( @@ -1119,11 +1210,9 @@ def run_flow(args: argparse.Namespace) -> dict[str, Any]: ), state, ) - state["layer2"] = { - "estimate": { - "data": _extract_data(estimate_resp.get("json")), - "headers": estimate_resp.get("headers"), - } + state["layer2"]["estimate"] = { + "data": _extract_data(estimate_resp.get("json")), + "headers": estimate_resp.get("headers"), } l2_payload = build_l2_execute_payload( @@ -1158,7 +1247,12 @@ def run_flow(args: argparse.Namespace) -> dict[str, Any]: l2_receipt_resp = _expect_success( "v2 layer2 receipt", - _http_json("GET", f"{v2_root}/receipts/{quote(l2_receipt_id, safe='')}", headers=headers, timeout=args.timeout), + _http_json( + "GET", + f"{v2_root}/receipts/{quote(l2_receipt_id, safe='')}", + headers=headers, + timeout=args.timeout, + ), state, ) state["layer2"]["receipt"] = _extract_data(l2_receipt_resp.get("json")) @@ -1175,6 +1269,22 @@ def run_flow(args: argparse.Namespace) -> dict[str, Any]: ) state["layer2"]["explanation"] = _extract_data(l2_explanation_resp.get("json")) + l2_verify_resp = _expect_success( + "v2 layer2 receipt verify", + _http_json( + "POST", + f"{v2_root}/receipts/{quote(l2_receipt_id, safe='')}/verify", + headers=headers, + timeout=args.timeout, + ), + state, + ) + state["layer2"]["verify"] = _require_verified_receipt( + "v2 layer2", + _extract_data(l2_verify_resp.get("json")), + state, + ) + if not args.skip_layer1: provider_resp = _expect_success( "v2 provider detail", @@ -1220,11 +1330,32 @@ def run_flow(args: argparse.Namespace) -> dict[str, Any]: l1_receipt_resp = _expect_success( "v2 layer1 receipt", - _http_json("GET", f"{v2_root}/receipts/{quote(l1_receipt_id, safe='')}", headers=headers, timeout=args.timeout), + _http_json( + "GET", + f"{v2_root}/receipts/{quote(l1_receipt_id, safe='')}", + headers=headers, + timeout=args.timeout, + ), state, ) state["layer1"]["receipt"] = _extract_data(l1_receipt_resp.get("json")) + l1_verify_resp = _expect_success( + "v2 layer1 receipt verify", + _http_json( + "POST", + f"{v2_root}/receipts/{quote(l1_receipt_id, safe='')}/verify", + headers=headers, + timeout=args.timeout, + ), + state, + ) + state["layer1"]["verify"] = _require_verified_receipt( + "v2 layer1", + _extract_data(l1_verify_resp.get("json")), + state, + ) + billing_summary_resp = _expect_success( "v2 billing summary", _http_json("GET", f"{v2_root}/billing/summary", headers=headers, timeout=args.timeout), @@ -1316,6 +1447,8 @@ def run_flow(args: argparse.Namespace) -> dict[str, Any]: ) state["receipt_chain"] = _extract_data(receipt_chain_resp.get("json")) + state["first_call_proof"] = _build_first_call_proof(state) + state["ok"] = True state["summary"] = _build_summary(state) return state @@ -1472,7 +1605,9 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "status is not ok, write refreshed artifacts, then recompute the fleet summary" ), ) - parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Rhumb API root URL (without /v2)") + parser.add_argument( + "--base-url", default=DEFAULT_BASE_URL, help="Rhumb API root URL (without /v2)" + ) parser.add_argument( "--api-key-env", default=DEFAULT_API_KEY_ENV, @@ -1506,8 +1641,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "--bootstrap-service", help="Optional service slug to grant during verifier bootstrap (defaults to --provider)", ) - parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="HTTP timeout in seconds") - parser.add_argument("--capability", default=DEFAULT_CAPABILITY, help="Capability ID for L1/L2 execute") + parser.add_argument( + "--timeout", type=float, default=DEFAULT_TIMEOUT, help="HTTP timeout in seconds" + ) + parser.add_argument( + "--capability", default=DEFAULT_CAPABILITY, help="Capability ID for L1/L2 execute" + ) parser.add_argument( "--provider", default=DEFAULT_PROVIDER, @@ -1526,7 +1665,9 @@ def parse_args(argv: list[str]) -> argparse.Namespace: default=DEFAULT_PARAMETERS_JSON, help="JSON object for execute parameters", ) - parser.add_argument("--interface", default=DEFAULT_INTERFACE, help="Interface label for analytics") + parser.add_argument( + "--interface", default=DEFAULT_INTERFACE, help="Interface label for analytics" + ) parser.add_argument( "--max-cost-usd", type=float, @@ -1547,11 +1688,19 @@ def parse_args(argv: list[str]) -> argparse.Namespace: type=float, help="Optional durable org policy smoke test: write max_cost_usd before executing", ) - parser.add_argument("--skip-layer1", action="store_true", help="Skip exact-provider Layer 1 execute") - parser.add_argument("--audit-export", action="store_true", help="Also hit POST /v2/audit/export?format=json") - parser.add_argument("--read-limit", type=int, default=DEFAULT_MAX_READ_LIMIT, help="Limit for read endpoints") + parser.add_argument( + "--skip-layer1", action="store_true", help="Skip exact-provider Layer 1 execute" + ) + parser.add_argument( + "--audit-export", action="store_true", help="Also hit POST /v2/audit/export?format=json" + ) + parser.add_argument( + "--read-limit", type=int, default=DEFAULT_MAX_READ_LIMIT, help="Limit for read endpoints" + ) parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") - parser.add_argument("--summary-only", action="store_true", help="Print only the top-line summary") + parser.add_argument( + "--summary-only", action="store_true", help="Print only the top-line summary" + ) parser.add_argument("--json-out", help="Write the result payload to a file") return parser.parse_args(argv) diff --git a/supabase/migrations/0165_index_command_manifests.sql b/supabase/migrations/0165_index_command_manifests.sql new file mode 100644 index 00000000..01409e05 --- /dev/null +++ b/supabase/migrations/0165_index_command_manifests.sql @@ -0,0 +1,60 @@ +-- Migration 0165: Durable Index command manifest storage +-- +-- PP-1/PP-2 durable hosted storage for command-level route manifests and +-- evidence packets. This table intentionally stores full manifest/evidence JSON +-- alongside indexed route taxonomy columns so Resolve can query route truth +-- without depending on private fixture registries. + +BEGIN; + +CREATE TABLE IF NOT EXISTS index_command_manifests ( + route_id TEXT PRIMARY KEY, + manifest_id TEXT NOT NULL, + manifest_version TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + service_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + capability_id TEXT NOT NULL, + substrate TEXT NOT NULL, + provenance_origin TEXT NOT NULL, + source_risk TEXT NOT NULL, + side_effect_class TEXT NOT NULL, + promotion_state TEXT, + review_status TEXT, + evidence_packet_id TEXT, + evidence_packet_digest TEXT, + evidence_expires_at TIMESTAMPTZ, + public_claim_boundary TEXT NOT NULL, + manifest_json JSONB NOT NULL, + evidence_packet_json JSONB, + owner TEXT, + reviewer TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_capability + ON index_command_manifests (capability_id, route_id); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_provider + ON index_command_manifests (capability_id, provider_id); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_taxonomy + ON index_command_manifests (substrate, provenance_origin, source_risk); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_manifest_digest + ON index_command_manifests (manifest_digest); + +CREATE INDEX IF NOT EXISTS idx_index_command_manifests_evidence_digest + ON index_command_manifests (evidence_packet_digest) + WHERE evidence_packet_digest IS NOT NULL; + +ALTER TABLE index_command_manifests ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS index_command_manifests_read ON index_command_manifests; +CREATE POLICY index_command_manifests_read ON index_command_manifests + FOR SELECT + USING (true); + +COMMIT; diff --git a/supabase/migrations/0166_index_command_manifest_seed.sql b/supabase/migrations/0166_index_command_manifest_seed.sql new file mode 100644 index 00000000..18e82866 --- /dev/null +++ b/supabase/migrations/0166_index_command_manifest_seed.sql @@ -0,0 +1,275 @@ +-- Migration 0166: Seed hosted Index command manifest storage +-- +-- Deterministic PP-2 fixture seed for index_command_manifests. These rows +-- preserve manifest/evidence digests and public claim boundaries exactly; +-- they do not claim live production execution. Evidence columns are filled +-- only where an Index evidence packet fixture already exists. + +BEGIN; + +INSERT INTO index_command_manifests ( + route_id, + manifest_id, + manifest_version, + manifest_digest, + service_id, + provider_id, + capability_id, + substrate, + provenance_origin, + source_risk, + side_effect_class, + promotion_state, + review_status, + evidence_packet_id, + evidence_packet_digest, + evidence_expires_at, + public_claim_boundary, + manifest_json, + evidence_packet_json, + owner, + reviewer, + expires_at +) +VALUES + ( + 'route_calendar_freebusy_generated_adapter_v1', + 'manifest_generated_calendar_freebusy_fixture_v1', + '2026-05-19.1', + 'sha256:bc8b9916a1122e58d66baf8ddd50c3b6ee0b4734a467211f77868f004657fbb5', + 'google-calendar', + 'generated-calendar-adapter', + 'calendar.freebusy', + 'generated_adapter', + 'rhumb_generated', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only generated adapter candidate; Rhumb must not describe it as approved by the vendor or production ready before evidence, sandbox, and review gates pass.', + '{"artifact_allowlist":["generated-calendar-adapter@fixture-digest-placeholder"],"auth_mode":"agent_vault","cache_behavior":"none","capability_id":"calendar.freebusy","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["calendar_availability"],"dry_run_supported":false,"evidence_refs":["evidence:route_calendar_freebusy_generated_adapter_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:bc8b9916a1122e58d66baf8ddd50c3b6ee0b4734a467211f77868f004657fbb5","manifest_id":"manifest_generated_calendar_freebusy_fixture_v1","manifest_version":"2026-05-19.1","network_allowlist":["www.googleapis.com"],"owner":"Pedro","process_allowlist":["python"],"promotion_state":"fixture_only","provenance_origin":"rhumb_generated","provider_id":"generated-calendar-adapter","public_claim_boundary":"Fixture-only generated adapter candidate; Rhumb must not describe it as approved by the vendor or production ready before evidence, sandbox, and review gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_calendar_freebusy_generated_adapter_v1","route_name":"calendar.freebusy via generated adapter fixture","sandbox_profile_class":"generated_adapter_readonly_fixture","service_id":"google-calendar","side_effect_class":"read","source_risk":"community_unverified","substrate":"generated_adapter","tests":["test_manifest_route_calendar_freebusy_generated_adapter_v1"],"vendor":"Google Calendar"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_crm_contact_lookup_salesforce_api_official_api_v1', + 'manifest_crm_contact_lookup_salesforce_api_official_api_v1', + '2026-05-19.1', + 'sha256:b36192fc5873116ede939d10cfd6ad0a5b9daf2512515371f5f791c555b4e031', + 'salesforce', + 'salesforce-api', + 'crm.contact.lookup', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the crm.contact.lookup managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"crm.contact.lookup","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["crm_contact"],"dry_run_supported":false,"evidence_refs":["evidence:route_crm_contact_lookup_salesforce_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:b36192fc5873116ede939d10cfd6ad0a5b9daf2512515371f5f791c555b4e031","manifest_id":"manifest_crm_contact_lookup_salesforce_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.salesforce.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"salesforce-api","public_claim_boundary":"Rhumb can represent the crm.contact.lookup managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["salesforce-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_crm_contact_lookup_salesforce_api_official_api_v1","route_name":"crm.contact.lookup via salesforce-api official API","service_id":"salesforce","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_crm_contact_lookup_salesforce_api_official_api_v1"],"vendor":"salesforce"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_customer_retrieve_stripe_sdk_code_mode_v1', + 'manifest_stripe_customer_retrieve_sdk_code_v1', + '2026-05-19.1', + 'sha256:af34bda7f48ec62fe7c070dd1045829ddd3d6320578593a7c9438f22b6f9f8b8', + 'stripe', + 'stripe-sdk', + 'customer.retrieve', + 'sdk_code_mode', + 'vendor_official', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only route describing a pinned official Stripe SDK read path after code-mode sandbox gates pass.', + '{"artifact_allowlist":["stripe-python@pinned-digest-placeholder"],"auth_mode":"agent_vault","cache_behavior":"none","capability_id":"customer.retrieve","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["customer_profile"],"dry_run_supported":false,"evidence_refs":["evidence:route_customer_retrieve_stripe_sdk_code_mode_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:af34bda7f48ec62fe7c070dd1045829ddd3d6320578593a7c9438f22b6f9f8b8","manifest_id":"manifest_stripe_customer_retrieve_sdk_code_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.stripe.com"],"owner":"Pedro","process_allowlist":["python"],"promotion_state":"fixture_only","provenance_origin":"vendor_official","provider_id":"stripe-sdk","public_claim_boundary":"Fixture-only route describing a pinned official Stripe SDK read path after code-mode sandbox gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_customer_retrieve_stripe_sdk_code_mode_v1","route_name":"customer.retrieve via official SDK code-mode fixture","sandbox_profile_class":"sdk_code_readonly_network_stripe","service_id":"stripe","side_effect_class":"read","source_risk":"community_unverified","substrate":"sdk_code_mode","tests":["test_manifest_route_customer_retrieve_stripe_sdk_code_mode_v1"],"vendor":"Stripe"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_email_verify_emailable_api_official_api_v1', + 'manifest_email_verify_emailable_api_official_api_v1', + '2026-05-19.1', + 'sha256:51b416b5febdf2ebfa50df20959e5f055f9ff058e546151445bcef45023d15eb', + 'emailable', + 'emailable-api', + 'email.verify', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the email.verify managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"email.verify","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["email_address"],"dry_run_supported":false,"evidence_refs":["evidence:route_email_verify_emailable_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:51b416b5febdf2ebfa50df20959e5f055f9ff058e546151445bcef45023d15eb","manifest_id":"manifest_email_verify_emailable_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.emailable.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"emailable-api","public_claim_boundary":"Rhumb can represent the email.verify managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["emailable-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_email_verify_emailable_api_official_api_v1","route_name":"email.verify via emailable-api official API","service_id":"emailable","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_email_verify_emailable_api_official_api_v1"],"vendor":"emailable"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_file_search_official_mcp_v1', + 'manifest_filesystem_search_official_mcp_v1', + '2026-05-19.1', + 'sha256:01f0bedd335144423455a1a0cd94c59937db9153e5d2956c6e5f2798b4dfa5fc', + 'filesystem', + 'filesystem-mcp', + 'file.search', + 'official_mcp', + 'vendor_official', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only route describing a pinned read-only MCP filesystem search path after sandbox and consent gates pass.', + '{"artifact_allowlist":["mcp-filesystem@pinned-digest-placeholder"],"auth_mode":"none","cache_behavior":"none","capability_id":"file.search","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["local_file_metadata"],"dry_run_supported":false,"evidence_refs":["evidence:route_file_search_official_mcp_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":["/tmp/rhumb-fixtures/read-only"],"manifest_digest":"sha256:01f0bedd335144423455a1a0cd94c59937db9153e5d2956c6e5f2798b4dfa5fc","manifest_id":"manifest_filesystem_search_official_mcp_v1","manifest_version":"2026-05-19.1","network_allowlist":["none"],"owner":"Pedro","process_allowlist":["node"],"promotion_state":"fixture_only","provenance_origin":"vendor_official","provider_id":"filesystem-mcp","public_claim_boundary":"Fixture-only route describing a pinned read-only MCP filesystem search path after sandbox and consent gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_file_search_official_mcp_v1","route_name":"file.search via official MCP server fixture","sandbox_profile_class":"mcp_readonly_filesystem_fixture","service_id":"filesystem","side_effect_class":"read","source_risk":"community_unverified","substrate":"official_mcp","tests":["test_manifest_route_file_search_official_mcp_v1"],"vendor":"Model Context Protocol"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_search_query_brave_search_api_official_api_v1', + 'manifest_search_query_brave_search_api_official_api_v1', + '2026-05-19.1', + 'sha256:500330f51d0cc678ab9eb1427858d116df15c4c047c1aadb39c465ac343dade2', + 'brave-search', + 'brave-search-api', + 'search.query', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + 'current', + 'evidence_search_query_brave_search_api_official_api_2026_05_19', + 'sha256:d8976c9766905eaa2c08987205f056ce24520665874bb2dab48461a3e7561c2a', + '2026-08-17T00:00:00Z'::timestamptz, + 'Rhumb can represent the search.query managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"search.query","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["web_search_query"],"dry_run_supported":false,"evidence_refs":["evidence:route_search_query_brave_search_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:500330f51d0cc678ab9eb1427858d116df15c4c047c1aadb39c465ac343dade2","manifest_id":"manifest_search_query_brave_search_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.brave-search.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"brave-search-api","public_claim_boundary":"Rhumb can represent the search.query managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["brave-search-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_search_query_brave_search_api_official_api_v1","route_name":"search.query via brave-search-api official API","service_id":"brave-search","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_search_query_brave_search_api_official_api_v1"],"vendor":"brave-search"}'::jsonb, + '{"an_score_input_refs":["scores:service:brave-search"],"capability_id":"search.query","credential_modes":["rhumb_managed"],"evidence_expires_at":"2026-08-17T00:00:00Z","evidence_packet_digest":"sha256:d8976c9766905eaa2c08987205f056ce24520665874bb2dab48461a3e7561c2a","evidence_packet_id":"evidence_search_query_brave_search_api_official_api_2026_05_19","freshness_checked_at":"2026-05-19T17:05:18Z","manifest_digest":"sha256:e84b79fa73722472c846e4b7f352b9d444adae73edb91150a51aee8329d3524a","manifest_id":"manifest_search_query_brave_search_api_official_api_v1","manifest_version":"2026-05-19.1","owner":"Pedro","promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"brave-search-api","public_claim_boundary":"Rhumb can route read-only web search queries through a Rhumb-managed Brave Search API route when the caller has a valid governed Rhumb key and available credit.","resolve_separation_note":"This packet supports route trust. Resolve still decides per-user auth, budget, policy, and risk at request time.","review_status":"current","reviewer":"Pedro","reviews":[{"reviewed_at":"2026-05-19T17:05:18Z","reviewer":"Pedro","scope":"schema_and_source_fixture; live first-call receipt lands in PP-9","verdict":"current_for_core_fixture"}],"route_id":"route_search_query_brave_search_api_official_api_v1","runtime_witnesses":[{"capability_id":"search.query","kind":"planned_first_call_fixture","provider_id":"brave-search-api","status":"pending_PP-9_live_receipt"}],"service_id":"brave-search","side_effect_class":"read","source_risk":"verified_low","sources":[{"kind":"vendor_docs","observed_at":"2026-05-19T17:05:18Z","observed_title":"Documentation - Brave Search API","url":"https://api-dashboard.search.brave.com/app/documentation/web-search/get-started"},{"kind":"rhumb_contract","observed_at":"2026-05-19T17:05:18Z","url":"docs/INDEX-RESOLVE-VNEXT-PRD-ROADMAP-2026-05-19.md#core-searchquery-index-fixture"}],"substrate":"official_api"}'::jsonb, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_support_ticket_create_zendesk_api_official_api_v1', + 'manifest_support_ticket_create_zendesk_api_official_api_v1', + '2026-05-19.1', + 'sha256:e274aa5195cf74e98cf66eed3e683b80a3670045a17fe5503d3a68bed2cae7ce', + 'zendesk', + 'zendesk-api', + 'support.ticket.create', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'write', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the support.ticket.create managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"support.ticket.create","confirmation_policy":"dry_run_then_confirm","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["support_ticket"],"dry_run_supported":true,"evidence_refs":["evidence:route_support_ticket_create_zendesk_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:e274aa5195cf74e98cf66eed3e683b80a3670045a17fe5503d3a68bed2cae7ce","manifest_id":"manifest_support_ticket_create_zendesk_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.zendesk.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"zendesk-api","public_claim_boundary":"Rhumb can represent the support.ticket.create managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["zendesk-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_support_ticket_create_zendesk_api_official_api_v1","route_name":"support.ticket.create via zendesk-api official API","service_id":"zendesk","side_effect_class":"write","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_support_ticket_create_zendesk_api_official_api_v1"],"vendor":"zendesk"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_warehouse_query_snowflake_api_official_api_v1', + 'manifest_warehouse_query_snowflake_api_official_api_v1', + '2026-05-19.1', + 'sha256:bf89210fbb4d4286de046a17715fa98e2ac1643e552947e99dedd265ae393220', + 'snowflake', + 'snowflake-api', + 'warehouse.query', + 'official_api', + 'rhumb_managed', + 'verified_low', + 'read', + 'beta_executable', + NULL, + NULL, + NULL, + NULL, + 'Rhumb can represent the warehouse.query managed official API route when governed auth, budget, and policy allow it.', + '{"artifact_allowlist":[],"auth_mode":"rhumb_managed","cache_behavior":"none","capability_id":"warehouse.query","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["warehouse_query"],"dry_run_supported":false,"evidence_refs":["evidence:route_warehouse_query_snowflake_api_official_api_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:bf89210fbb4d4286de046a17715fa98e2ac1643e552947e99dedd265ae393220","manifest_id":"manifest_warehouse_query_snowflake_api_official_api_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.snowflake.example.com"],"owner":"Pedro","process_allowlist":[],"promotion_state":"beta_executable","provenance_origin":"rhumb_managed","provider_id":"snowflake-api","public_claim_boundary":"Rhumb can represent the warehouse.query managed official API route when governed auth, budget, and policy allow it.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":["snowflake-api:api_key"],"required_scopes":[],"reviewer":"Pedro","route_id":"route_warehouse_query_snowflake_api_official_api_v1","route_name":"warehouse.query via snowflake-api official API","service_id":"snowflake","side_effect_class":"read","source_risk":"verified_low","substrate":"official_api","tests":["test_manifest_route_warehouse_query_snowflake_api_official_api_v1"],"vendor":"snowflake"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ), + ( + 'route_workflow_run_list_github_cli_v1', + 'manifest_github_workflow_list_official_cli_v1', + '2026-05-19.1', + 'sha256:4bd0fa611792130576998c6c7bb3de695d6554a212bb2f151798d60713b20b76', + 'github', + 'github-cli', + 'workflow_run.list', + 'official_cli', + 'vendor_official', + 'community_unverified', + 'read', + 'fixture_only', + NULL, + NULL, + NULL, + NULL, + 'Fixture-only route describing how a pinned official GitHub CLI could list workflow runs after non-native sandbox gates pass.', + '{"artifact_allowlist":["gh@pinned-digest-placeholder"],"auth_mode":"agent_vault","cache_behavior":"none","capability_id":"workflow_run.list","confirmation_policy":"none","cost_model":{"estimated_cost_usd":0.003,"unit":"call"},"data_classes":["workflow_metadata"],"dry_run_supported":false,"evidence_refs":["evidence:route_workflow_run_list_github_cli_v1"],"expires_at":"2026-08-17T00:00:00Z","filesystem_allowlist":[],"manifest_digest":"sha256:4bd0fa611792130576998c6c7bb3de695d6554a212bb2f151798d60713b20b76","manifest_id":"manifest_github_workflow_list_official_cli_v1","manifest_version":"2026-05-19.1","network_allowlist":["api.github.com"],"owner":"Pedro","process_allowlist":["gh"],"promotion_state":"fixture_only","provenance_origin":"vendor_official","provider_id":"github-cli","public_claim_boundary":"Fixture-only route describing how a pinned official GitHub CLI could list workflow runs after non-native sandbox gates pass.","rate_limit_model":{"enforced_by":"rhumb","limit":60,"unit":"minute"},"required_credentials":[],"required_scopes":[],"reviewer":"Pedro","route_id":"route_workflow_run_list_github_cli_v1","route_name":"workflow_run.list via official GitHub CLI","sandbox_profile_class":"cli_readonly_network_github","service_id":"github","side_effect_class":"read","source_risk":"community_unverified","substrate":"official_cli","tests":["test_manifest_route_workflow_run_list_github_cli_v1"],"vendor":"GitHub"}'::jsonb, + NULL, + 'Pedro', + 'Pedro', + '2026-08-17T00:00:00Z'::timestamptz + ) +ON CONFLICT (route_id) DO UPDATE SET + manifest_id = EXCLUDED.manifest_id, + manifest_version = EXCLUDED.manifest_version, + manifest_digest = EXCLUDED.manifest_digest, + service_id = EXCLUDED.service_id, + provider_id = EXCLUDED.provider_id, + capability_id = EXCLUDED.capability_id, + substrate = EXCLUDED.substrate, + provenance_origin = EXCLUDED.provenance_origin, + source_risk = EXCLUDED.source_risk, + side_effect_class = EXCLUDED.side_effect_class, + promotion_state = EXCLUDED.promotion_state, + review_status = EXCLUDED.review_status, + evidence_packet_id = EXCLUDED.evidence_packet_id, + evidence_packet_digest = EXCLUDED.evidence_packet_digest, + evidence_expires_at = EXCLUDED.evidence_expires_at, + public_claim_boundary = EXCLUDED.public_claim_boundary, + manifest_json = EXCLUDED.manifest_json, + evidence_packet_json = EXCLUDED.evidence_packet_json, + owner = EXCLUDED.owner, + reviewer = EXCLUDED.reviewer, + expires_at = EXCLUDED.expires_at, + updated_at = NOW(); + +COMMIT; diff --git a/supabase/migrations/0167_route_plan_state.sql b/supabase/migrations/0167_route_plan_state.sql new file mode 100644 index 00000000..7bbe45bd --- /dev/null +++ b/supabase/migrations/0167_route_plan_state.sql @@ -0,0 +1,21 @@ +-- PP-16: Durable route-plan nonce, replay, and revocation state + +CREATE TABLE IF NOT EXISTS route_plan_state ( + nonce_hash TEXT PRIMARY KEY, + route_plan_id_hash TEXT, + state TEXT NOT NULL DEFAULT 'claimed', + claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revocation_reason TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (state IN ('claimed', 'revoked')) +); + +CREATE INDEX IF NOT EXISTS idx_route_plan_state_expires + ON route_plan_state (expires_at); + +CREATE INDEX IF NOT EXISTS idx_route_plan_state_state + ON route_plan_state (state); + +ALTER TABLE route_plan_state ENABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/0168_execution_receipts_route_facts.sql b/supabase/migrations/0168_execution_receipts_route_facts.sql new file mode 100644 index 00000000..9107ac21 --- /dev/null +++ b/supabase/migrations/0168_execution_receipts_route_facts.sql @@ -0,0 +1,23 @@ +-- PP-6: route-fact and compact-verification fields on execution receipts + +ALTER TABLE execution_receipts + ADD COLUMN IF NOT EXISTS route_id TEXT, + ADD COLUMN IF NOT EXISTS service_id TEXT, + ADD COLUMN IF NOT EXISTS substrate TEXT, + ADD COLUMN IF NOT EXISTS provenance_origin TEXT, + ADD COLUMN IF NOT EXISTS source_risk TEXT, + ADD COLUMN IF NOT EXISTS manifest_digest TEXT, + ADD COLUMN IF NOT EXISTS evidence_packet_digest TEXT, + ADD COLUMN IF NOT EXISTS route_plan_id_hash TEXT, + ADD COLUMN IF NOT EXISTS route_explanation_id TEXT, + ADD COLUMN IF NOT EXISTS stop_condition TEXT, + ADD COLUMN IF NOT EXISTS retryable BOOLEAN, + ADD COLUMN IF NOT EXISTS next_recommended_action TEXT; + +CREATE INDEX IF NOT EXISTS idx_receipts_route_id_created + ON execution_receipts (route_id, created_at DESC) + WHERE route_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_receipts_manifest_digest + ON execution_receipts (manifest_digest) + WHERE manifest_digest IS NOT NULL;