diff --git a/schemas/cache/enums/error-code.json b/schemas/cache/enums/error-code.json index 91d1ba094..1e596252f 100644 --- a/schemas/cache/enums/error-code.json +++ b/schemas/cache/enums/error-code.json @@ -1,7 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/latest/enums/error-code.json", "title": "Error Code", - "description": "Standard error code vocabulary for AdCP. Codes are machine-readable so agents can apply autonomous recovery strategies based on the recovery classification. Sellers MAY return codes not listed here for platform-specific errors \u2014 the error.json code field accepts any string. Agents MUST handle unknown codes by falling back to the recovery classification.", + "description": "Standard error code vocabulary for AdCP. Codes are machine-readable so agents can apply autonomous recovery strategies based on the recovery classification. Sellers MAY return codes not listed here for platform-specific errors — the error.json code field accepts any string. Agents MUST handle unknown codes by falling back to the recovery classification.", "type": "string", "enum": [ "INVALID_REQUEST", @@ -48,11 +49,26 @@ "VERSION_UNSUPPORTED", "CAMPAIGN_SUSPENDED", "GOVERNANCE_UNAVAILABLE", - "PERMISSION_DENIED" + "PERMISSION_DENIED", + "SCOPE_INSUFFICIENT", + "READ_ONLY_SCOPE", + "FIELD_NOT_PERMITTED", + "PROVENANCE_REQUIRED", + "PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING", + "PROVENANCE_DISCLOSURE_MISSING", + "PROVENANCE_EMBEDDED_MISSING", + "PROVENANCE_VERIFIER_NOT_ACCEPTED", + "PROVENANCE_CLAIM_CONTRADICTED", + "BILLING_NOT_SUPPORTED", + "BILLING_NOT_PERMITTED_FOR_AGENT", + "PAYMENT_TERMS_NOT_SUPPORTED", + "BRAND_REQUIRED", + "AGENT_SUSPENDED", + "AGENT_BLOCKED" ], "enumDescriptions": { "INVALID_REQUEST": "Request is malformed, missing required fields, or violates schema constraints. Recovery: correctable (check request parameters and fix).", - "AUTH_REQUIRED": "Authentication is required, or presented credentials were rejected. Two operational sub-cases share this code: (a) credentials missing \u2014 agent provides credentials and retries; (b) credentials presented but rejected (expired / revoked / malformed signature) \u2014 agent SHOULD NOT auto-retry, since re-presenting a rejected credential against an SSO endpoint creates retry-storm patterns indistinguishable from brute-force probes. In sub-case (b) the agent SHOULD escalate to operator for credential rotation rather than loop. A future minor release splits this code into AUTH_MISSING (correctable) and AUTH_INVALID (terminal); agents handling 3.0.x sellers SHOULD apply the same operational distinction at the application layer. Recovery: correctable (provide credentials via auth header \u2014 but only when the credential was missing, not when it was presented and rejected).", + "AUTH_REQUIRED": "Authentication is required to access this resource. Recovery: correctable (provide credentials via auth header).", "RATE_LIMITED": "Request rate exceeded. Retry after the retry_after interval. Recovery: transient.", "SERVICE_UNAVAILABLE": "Seller service is temporarily unavailable. Retry with exponential backoff. Recovery: transient.", "POLICY_VIOLATION": "Request violates the seller's content or advertising policies. Recovery: correctable (review policy requirements in the error details).", @@ -68,23 +84,23 @@ "ACCOUNT_AMBIGUOUS": "Natural key resolves to multiple accounts. Recovery: correctable (pass explicit account_id or a more specific natural key).", "ACCOUNT_PAYMENT_REQUIRED": "Account has an outstanding balance requiring payment before new buys. Recovery: terminal (buyer must resolve billing).", "ACCOUNT_SUSPENDED": "Account has been suspended. Recovery: terminal (contact seller to resolve suspension).", - "COMPLIANCE_UNSATISFIED": "A required disclosure from the brief's compliance section cannot be satisfied by the target format \u2014 either the required position or the required persistence mode is not in the format's disclosure_capabilities. Recovery: correctable (choose a format that supports the required disclosure positions and persistence modes, or remove the disclosure requirement).", - "GOVERNANCE_DENIED": "A registered governance agent denied the transaction. The buyer may restructure the buy (e.g., reduce budget, split into smaller transactions), escalate to human spending authority, or contact the governance agent for details. Recovery: correctable.", + "COMPLIANCE_UNSATISFIED": "A required disclosure from the brief's compliance section cannot be satisfied by the target format — either the required position or the required persistence mode is not in the format's disclosure_capabilities. Recovery: correctable (choose a format that supports the required disclosure positions and persistence modes, or remove the disclosure requirement).", + "GOVERNANCE_DENIED": "A registered governance agent denied the transaction. Sellers MUST place the denial in the task's structured rejection arm when one exists (e.g., `acquire_rights` → `AcquireRightsRejected`, `creative_approval` → `CreativeRejected`); otherwise in `errors[]` + `adcp_error`. Buyers MUST dispatch on the response's discriminated `status` first and fall back to `errors[].code` / `adcp_error.code` only when no rejection arm exists for that task. The buyer may restructure the buy (e.g., reduce budget, split into smaller transactions), escalate to human spending authority, or contact the governance agent for details. Recovery: correctable.\n\nWire placement (full guidance). Governance denial is a structured business outcome, not a system error — the governance call SUCCEEDED and the agent returned a denial verdict. Two cases:\n\n1. Task response defines a structured rejection arm. The arm IS the canonical denial shape. The seller populates `reason` (human-readable, propagating governance findings) and `suggestions` (optional) and does NOT additionally emit `GOVERNANCE_DENIED` in `errors[]` or `adcp_error`. The rejection arms enforce this at the schema layer: e.g., `AcquireRightsRejected` and `CreativeRejected` both declare `not: { required: [errors] }`, so dual-emission is already a schema violation. The code does not appear on the wire when the rejection arm is used. Transport-level success markers MUST NOT be flipped (HTTP 200, MCP `isError: false`, A2A `succeeded`) — the task ran successfully and produced a structured response.\n\n2. Task response has no rejection arm (e.g., `create_media_buy` returns Success / Error / Submitted arms only). The seller populates `errors[].code: GOVERNANCE_DENIED` in the payload AND `adcp_error.code: GOVERNANCE_DENIED` on the envelope per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. Transport-level failure markers DO flip in this case (HTTP 4xx, MCP `isError: true`, A2A `failed`) — the task could not produce a success artifact.\n\nThe rule generalizes to any current or future task whose response defines a discriminated rejection arm. In either placement, sellers SHOULD propagate governance findings verbatim — buyers' recovery decisions depend on what specifically was rejected. `GOVERNANCE_DENIED` is reserved for verdicts received from a reachable governance agent; if the governance call itself failed (timeout, network, config error), use `GOVERNANCE_UNAVAILABLE` instead.", "BUDGET_EXHAUSTED": "Account or campaign budget has been fully spent. Distinct from BUDGET_TOO_LOW (rejected at submission). Recovery: terminal (buyer must add funds or increase budget cap).", "BUDGET_EXCEEDED": "Operation would exceed the allocated budget for the media buy or package. Distinct from BUDGET_EXHAUSTED (already spent) and BUDGET_TOO_LOW (below minimum). Recovery: correctable (reduce requested amount or increase budget allocation).", "CREATIVE_DEADLINE_EXCEEDED": "Creative change submitted after the package's creative_deadline. Distinct from CREATIVE_REJECTED (content policy failure). Recovery: correctable (check creative_deadline via get_media_buys before submitting changes, or negotiate a deadline extension with the seller).", "CONFLICT": "Concurrent modification detected. The resource was modified by another request between read and write. Recovery: transient (re-read the resource and retry with current state).", - "IDEMPOTENCY_CONFLICT": "An earlier request with the same idempotency_key was processed with a different canonical payload within the seller's replay window. Distinct from CONFLICT (concurrent write) \u2014 this indicates the client reused a key across semantically different requests. Recovery: correctable (use a fresh UUID v4 for the new request, or resend the exact original payload to get the cached response).", - "IDEMPOTENCY_EXPIRED": "The idempotency_key was seen previously but its cached response has been evicted because it is past the seller's declared replay_ttl_seconds. Distinct from IDEMPOTENCY_CONFLICT (different payload within window) \u2014 this indicates the retry arrived too late for at-most-once guarantees. Recovery: correctable (perform a natural-key check \u2014 e.g., get_media_buys by context.internal_campaign_id \u2014 to determine whether the original request succeeded, then either accept that result or generate a fresh idempotency_key for a new attempt). If the buyer has any evidence the prior call succeeded (partial response received before crash, entry in the buyer's own DB, a webhook fired), the buyer MUST do the natural-key check BEFORE minting a new key \u2014 minting a new key in that situation is exactly how double-creation happens.", + "IDEMPOTENCY_CONFLICT": "An earlier request with the same idempotency_key was processed with a different canonical payload within the seller's replay window. Distinct from CONFLICT (concurrent write) — this indicates the client reused a key across semantically different requests. Recovery: correctable (use a fresh UUID v4 for the new request, or resend the exact original payload to get the cached response).", + "IDEMPOTENCY_EXPIRED": "The idempotency_key was seen previously but its cached response has been evicted because it is past the seller's declared replay_ttl_seconds. Distinct from IDEMPOTENCY_CONFLICT (different payload within window) — this indicates the retry arrived too late for at-most-once guarantees. Recovery: correctable (perform a natural-key check — e.g., get_media_buys by context.internal_campaign_id — to determine whether the original request succeeded, then either accept that result or generate a fresh idempotency_key for a new attempt). If the buyer has any evidence the prior call succeeded (partial response received before crash, entry in the buyer's own DB, a webhook fired), the buyer MUST do the natural-key check BEFORE minting a new key — minting a new key in that situation is exactly how double-creation happens.", "INVALID_STATE": "Operation is not permitted for the resource's current status (e.g., updating a completed or canceled media buy, or modifying a canceled package). Recovery: correctable (check current status via get_media_buys and adjust request).", "MEDIA_BUY_NOT_FOUND": "Referenced media buy does not exist or is not accessible to the requesting agent. Recovery: correctable (verify media_buy_id or buyer_ref).", "NOT_CANCELLABLE": "The media buy or package cannot be canceled in its current state. The seller may have contractual or operational constraints that prevent cancellation. Recovery: correctable (check the seller's cancellation policy or contact the seller).", "PACKAGE_NOT_FOUND": "Referenced package does not exist within the specified media buy. Recovery: correctable (verify package_id or buyer_ref via get_media_buys).", - "CREATIVE_NOT_FOUND": "Referenced creative does not exist in the agent's creative library. Recovery: correctable (verify creative_id via list_creatives, or sync_creatives to register it). Sellers MUST return this code uniformly for any creative_id not owned by the calling account \u2014 never distinguish 'exists in another tenant' from 'does not exist', which would enable cross-tenant enumeration.", - "SIGNAL_NOT_FOUND": "Referenced signal does not exist in the agent's catalog. Recovery: correctable (verify signal_id via get_signals, or confirm the signal is available from this agent). Sellers MUST return this code uniformly for any signal_id not accessible to the calling account \u2014 never distinguish 'exists but unauthorized' from 'does not exist', which would enable cross-tenant enumeration.", - "REFERENCE_NOT_FOUND": "Generic fallback for a referenced identifier, grant, session, or other resource that does not exist or is not accessible by the caller. Use when no resource-specific not-found code applies (e.g., property lists, content standards, rights grants, SI offerings, proposals, catalogs, event sources, collection lists, brands, individual properties). Typed parameters that lack a dedicated standard code MUST also use REFERENCE_NOT_FOUND rather than minting a custom *_NOT_FOUND code. See 'Uniform response for inaccessible references' in error-handling.mdx for the full MUST list. Recovery: correctable. Summary of the uniform-response MUST: sellers MUST return the same response for 'exists but the caller lacks access' as for 'does not exist' across every observable channel \u2014 error.code/message/field/details (message MUST be generic; error.field MUST be identical across both cases on typed parameters); HTTP status, A2A task.status.state, and MCP isError; response headers (ETag, Cache-Control, per-type rate-limit buckets, CDN tags); side effects (webhook/audit writes, background-job enqueues, per-type quota counters, DB-shard routing); and observability (logs, APM spans, third-party error telemetry like Sentry/Datadog). Sellers MUST perform the same resolution-and-authorization work on both paths (resolve-then-authorize; on true-miss still run an authorization decision of equivalent shape against an empty principal set so authorizer latency is not a side channel). Cache population MUST NOT be gated on authorization. Polymorphism is evaluated against the tool-schema's declared parameter shape before any lookup, and a tool's declared shape MUST be identical across all callers.", + "CREATIVE_NOT_FOUND": "Referenced creative does not exist in the agent's creative library. Recovery: correctable (verify creative_id via list_creatives, or sync_creatives to register it). Sellers MUST return this code uniformly for any creative_id not owned by the calling account — never distinguish 'exists in another tenant' from 'does not exist', which would enable cross-tenant enumeration.", + "SIGNAL_NOT_FOUND": "Referenced signal does not exist in the agent's catalog. Recovery: correctable (verify signal_id via get_signals, or confirm the signal is available from this agent). Sellers MUST return this code uniformly for any signal_id not accessible to the calling account — never distinguish 'exists but unauthorized' from 'does not exist', which would enable cross-tenant enumeration.", + "REFERENCE_NOT_FOUND": "Generic fallback for a referenced identifier, grant, session, or other resource that does not exist or is not accessible by the caller. Use when no resource-specific not-found code applies (e.g., property lists, content standards, rights grants, SI offerings, proposals, catalogs, event sources, collection lists, brands, individual properties). Typed parameters that lack a dedicated standard code MUST also use REFERENCE_NOT_FOUND rather than minting a custom *_NOT_FOUND code. See 'Uniform response for inaccessible references' in error-handling.mdx for the full MUST list. Recovery: correctable. Summary of the uniform-response MUST: sellers MUST return the same response for 'exists but the caller lacks access' as for 'does not exist' across every observable channel — error.code/message/field/details (message MUST be generic; error.field MUST be identical across both cases on typed parameters); HTTP status, A2A task.status.state, and MCP isError; response headers (ETag, Cache-Control, per-type rate-limit buckets, CDN tags); side effects (webhook/audit writes, background-job enqueues, per-type quota counters, DB-shard routing); and observability (logs, APM spans, third-party error telemetry like Sentry/Datadog). Sellers MUST perform the same resolution-and-authorization work on both paths (resolve-then-authorize; on true-miss still run an authorization decision of equivalent shape against an empty principal set so authorizer latency is not a side channel). Cache population MUST NOT be gated on authorization. Polymorphism is evaluated against the tool-schema's declared parameter shape before any lookup, and a tool's declared shape MUST be identical across all callers.", "SESSION_NOT_FOUND": "SI session ID is invalid, expired, or does not exist. Recovery: correctable (initiate a new session via si_initiate_session).", - "PLAN_NOT_FOUND": "Referenced governance plan does not exist or is not accessible to the requesting agent. Recovery: correctable (verify plan_id via sync_plans, or register the plan first). Sellers MUST return this code uniformly for any plan_id not accessible to the calling account \u2014 never distinguish 'exists but unauthorized' from 'does not exist', which would enable cross-tenant enumeration of governance plans.", + "PLAN_NOT_FOUND": "Referenced governance plan does not exist or is not accessible to the requesting agent. Recovery: correctable (verify plan_id via sync_plans, or register the plan first). Sellers MUST return this code uniformly for any plan_id not accessible to the calling account — never distinguish 'exists but unauthorized' from 'does not exist', which would enable cross-tenant enumeration of governance plans.", "SESSION_TERMINATED": "SI session has already been terminated and cannot accept further messages. Recovery: correctable (initiate a new session via si_initiate_session).", "VALIDATION_ERROR": "Request contains invalid field values or violates business rules beyond schema validation. Recovery: correctable (review error details and fix field values).", "PRODUCT_EXPIRED": "One or more referenced products have passed their expires_at timestamp and are no longer available for purchase. Recovery: correctable (re-discover with get_products to find current inventory).", @@ -92,20 +108,35 @@ "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", "REQUOTE_REQUIRED": "An update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].budget', 'end_time') so the buyer's agent can autonomously re-discover. Recovery: correctable (re-negotiate via get_products in 'refine' mode against the existing proposal_id to obtain a fresh quote reflecting the new parameters, then resubmit the update against the new proposal_id).", - "VERSION_UNSUPPORTED": "The declared adcp_major_version is not supported by this seller. Recovery: correctable (call get_adcp_capabilities without adcp_major_version to discover supported major_versions, then retry with a supported version).", - "CAMPAIGN_SUSPENDED": "Campaign governance has been suspended pending human review; the governance agent MUST reject `check_governance` and `report_plan_outcome` calls on the affected plan until the escalation is resolved. Distinct from `ACCOUNT_SUSPENDED` (account-wide) \u2014 this is scoped to a single plan/campaign. Recovery: transient (wait for the escalation to resolve; contact the plan operator if the suspension persists).", - "GOVERNANCE_UNAVAILABLE": "A registered governance agent is unreachable (timeout, network error, or repeated failure) and the seller cannot obtain a governance decision for the spend-commit. Distinct from `GOVERNANCE_DENIED` (agent reachable and explicitly denied). Recovery: transient (retry with backoff; if the agent remains unreachable, the buyer MUST contact the plan's governance operator \u2014 the seller MUST NOT proceed with the media buy without a valid decision).", - "PERMISSION_DENIED": "The authenticated caller is not authorized for the requested action under the seller's own policies, or a required signed credential (e.g., a `governance_context` token on a spend-commit) is missing, fails verification, or was issued for a different plan, seller, or phase. Distinct from `AUTH_REQUIRED` (no credentials presented) and `GOVERNANCE_DENIED` (governance agent denied). Recovery: correctable (call `check_governance` to mint a valid token, or contact the seller to resolve the underlying permission)." + "VERSION_UNSUPPORTED": "The declared adcp_version (release-precision) or adcp_major_version (deprecated) is not supported by this seller. The error details SHOULD follow `error-details/version-unsupported.json` — `supported_versions` (release-precision strings) is authoritative for retry; `supported_majors` is deprecated. Recovery: correctable (re-pin to a release in supported_versions and retry; or call get_adcp_capabilities without a version pin to discover supported_versions).", + "CAMPAIGN_SUSPENDED": "Campaign governance has been suspended pending human review; the governance agent MUST reject `check_governance` and `report_plan_outcome` calls on the affected plan until the escalation is resolved. Distinct from `ACCOUNT_SUSPENDED` (account-wide) — this is scoped to a single plan/campaign. Recovery: transient (wait for the escalation to resolve; contact the plan operator if the suspension persists).", + "GOVERNANCE_UNAVAILABLE": "A registered governance agent is unreachable. Sellers MUST place this code in `errors[]` + `adcp_error` (never a structured rejection arm) and flip transport-level failure markers (HTTP 5xx, MCP `isError: true`, A2A `failed`). Distinct from `GOVERNANCE_DENIED` (agent reachable and explicitly denied — see that code's wire-placement guidance). Recovery: transient (retry with backoff; if the agent remains unreachable, the buyer MUST contact the plan's governance operator — the seller MUST NOT proceed with the media buy without a valid decision).\n\nWire placement (full guidance). Governance unavailability is a system error — the governance call FAILED (timeout, network, config error) and the seller could not get a verdict at all. Always populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. Do NOT use a structured rejection arm for unavailability even when the task offers one — the buyer's recovery semantics differ (retry-with-backoff for unavailability vs. restructure-or-escalate for denial), and conflating them masks the system-error signal.", + "PERMISSION_DENIED": "The authenticated caller is not authorized for the requested action under the seller's own policies, or a required signed credential (e.g., a `governance_context` token on a spend-commit) is missing, fails verification, or was issued for a different plan, seller, or phase. Distinct from `AUTH_REQUIRED` (no credentials presented), `GOVERNANCE_DENIED` (governance agent denied), `AGENT_SUSPENDED` (agent's relationship temporarily paused), and `AGENT_BLOCKED` (agent's relationship permanently denied). When the gate that fired is specifically a non-status per-agent provisioning constraint — e.g., the agent is provisioned for sandbox traffic only and the request was against a non-sandbox account — `error.details` SHOULD conform to `error-details/agent-permission-denied.json` (`scope: \"agent\"` plus `reason: \"sandbox_only\"`) so callers can dispatch without parsing prose. Sellers MUST emit `scope: \"agent\"` only when buyer-agent identity has been established via signed-request derivation or a credential-to-agent mapping in the seller's onboarding record; in all other cases (including bearer credentials not mapped to a specific agent record) sellers MUST return `PERMISSION_DENIED` and MUST omit `error.details.scope` — emitting the per-agent scope without established identity is a cross-tenant onboarding oracle, and the omit MUST be enforced across every observable channel (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity) per the channel-coverage rules in error-handling.mdx Per-Agent Authorization Gate, mirroring the `*_NOT_FOUND` uniform-response rule and `BILLING_NOT_PERMITTED_FOR_AGENT`. The `suspended` and `blocked` per-agent states are NOT carried on this code — sellers MUST emit `AGENT_SUSPENDED` / `AGENT_BLOCKED` instead, each of which is its own discriminator. Recovery: correctable (call `check_governance` to mint a valid token, or contact the seller to resolve the underlying permission); when `details.reason` is present the rejection is terminal-pending-onboarding — the agent MUST surface to a human at the buyer rather than auto-retrying, since the agent cannot unilaterally extend its sandbox-only provisioning.", + "SCOPE_INSUFFICIENT": "The authenticated caller is not authorized for the invoked task — the task is not in the caller's `allowed_tasks` for this account (discoverable via the `authorization` object on sync_accounts / list_accounts responses). Distinct from `PERMISSION_DENIED` (generic authz failure, often credential-shaped) by being narrowly about task-level scope. Sellers SHOULD populate `error.details.introspection_hint` pointing at where the caller can re-read its scope (strawman: `{ task: 'list_accounts', account: {...} }`). Recovery: correctable in the sense that the request can be re-sent after the scope is broadened, but the agent cannot broaden its own scope — this requires operator intervention, and agents SHOULD surface rather than auto-retry.", + "READ_ONLY_SCOPE": "The caller's scope is read-only; the invoked task would mutate state and was rejected. Distinct from `SCOPE_INSUFFICIENT` (task not in scope at all) — the task is in some scopes this seller supports, just not this caller's. Recovery: correctable but not agent-autonomous — use a non-mutating alternative, or surface to the operator to request a scope that permits mutation.", + "FIELD_NOT_PERMITTED": "A request field is not in the caller's `field_scopes` allowlist for this task. Sellers declaring `field_scopes` on the account's `authorization` object MUST reject any request that sets a non-allowlisted field with this code. Distinct from `VALIDATION_ERROR` (schema/business-rule violation) - the field is valid, just not writable by this caller. `error.field` MUST identify the exact offending field path (e.g., `packages[0].budget`); when multiple fields are disallowed, sellers SHOULD return one error per field, or MAY enumerate them in `error.details.fields`. Recovery: correctable and agent-autonomous - agent may drop the disallowed field(s) and retry.", + "PROVENANCE_REQUIRED": "Seller's `creative_policy.provenance_required` is true and the submitted creative has no `provenance` object on the manifest, on the creative-asset, or on any individual asset. Distinct from `CREATIVE_REJECTED` (generic content-policy failure) by being narrowly about provenance presence. Recovery: correctable (attach a provenance object - at minimum `digital_source_type` - and resubmit). `error.field` MUST point at the path where provenance was expected (e.g., `creatives[0].creative_manifest`).", + "PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING": "Seller's `creative_policy.provenance_requirements.require_digital_source_type` is true and the submitted creative's resolved provenance (after inheritance) has no `digital_source_type` value, or has it set to null. Distinct from `PROVENANCE_REQUIRED` (no provenance object at all) - provenance is present, just missing this specific field. Recovery: correctable (set `provenance.digital_source_type` to a value from the `digital-source-type` enum and resubmit). `error.field` MUST point at the resolved provenance path that was inspected (e.g., `creatives[0].creative_manifest.provenance.digital_source_type`).", + "PROVENANCE_DISCLOSURE_MISSING": "Seller's `creative_policy.provenance_requirements.require_disclosure_metadata` is true and the submitted creative's resolved provenance has no `disclosure.required` boolean, or `disclosure.required` is true with no `disclosure.jurisdictions` entries. Recovery: correctable (set `provenance.disclosure.required` and, when true, populate `disclosure.jurisdictions`). `error.field` MUST point at `provenance.disclosure` (e.g., `creatives[0].creative_manifest.provenance.disclosure`).", + "PROVENANCE_EMBEDDED_MISSING": "Seller's `creative_policy.provenance_requirements.require_embedded_provenance` is true and the submitted creative's resolved provenance has no `embedded_provenance` array, or has it as an empty array. Used in pipelines where sidecar `c2pa.manifest_url` is stripped by intermediaries and the seller requires content-stream-resilient provenance. Recovery: correctable (attach at least one `embedded_provenance` entry from a supported provider and resubmit, optionally with a `verify_agent` pointer matching one of the seller's `creative_policy.accepted_verifiers`). `error.field` MUST point at `provenance.embedded_provenance` on the resolved manifest.", + "PROVENANCE_VERIFIER_NOT_ACCEPTED": "Buyer attached a `verify_agent.agent_url` on `embedded_provenance[]` or `watermarks[]` that does not match (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments) any entry in the seller's `creative_policy.accepted_verifiers[].agent_url`. The seller does not call buyer-asserted endpoints outside its allowlist; this is the cross-check that closes the buyer-controlled-URL trust gap. `error.field` MUST point at the offending `verify_agent.agent_url` path; `error.details` SHOULD include a reference to the product whose `creative_policy.accepted_verifiers` the buyer should consult (the buyer already has this from `get_products`). Recovery: correctable (replace `verify_agent.agent_url` with one from the seller's published `accepted_verifiers`, drop the `verify_agent` entirely if the embedding is self-verifiable, or re-embed evidence using a verifier the seller accepts).", + "PROVENANCE_CLAIM_CONTRADICTED": "Seller invoked a governance agent from `creative_policy.accepted_verifiers` via `get_creative_features` and the verifier's result contradicts the buyer's provenance claim - e.g., buyer claims `digital_source_type: digital_capture` but the AI-detection feature returns `ai_generated: true` above the seller's confidence threshold. Distinct from the `PROVENANCE_*_MISSING` family (structural absence) by being an active refutation. `error.details` SHOULD be limited to the audit-safe allowlist `{ agent_url, feature_id, claimed_value, observed_value, confidence }`; sellers MUST NOT forward arbitrary verifier extension fields, `detail_url`, or any verifier response shape that may carry cross-tenant or PII data. When the seller calls a different on-list agent than the buyer nominated (the seller is the verifier-of-record), `error.details.agent_url` is the agent the seller actually called and `error.details.substituted_for` SHOULD carry the buyer's nominated `agent_url` so the buyer can reconcile. Recovery: correctable - buyer revises the provenance claim to match reality (or replaces the creative); auto-retry without correction will not pass.", + "BILLING_NOT_SUPPORTED": "The seller declines the requested `billing` value either at the seller-wide capability level (`supported_billing` does not include the value) or at the per-account-relationship level (e.g., the seller accepts `operator` billing in general but has no direct billing relationship with the operator on this specific account). The default reject code for billing-value mismatches; `error.details` SHOULD conform to `error-details/billing-not-supported.json` (`scope` ∈ `{\"capability\", \"account\"}` plus optional `supported_billing` echo for the `\"capability\"` scope) so callers can dispatch without parsing prose. Distinct from `BILLING_NOT_PERMITTED_FOR_AGENT`, which is narrowly scoped to the calling buyer agent's commercial relationship with the seller (passthrough-only vs agent-billable) rather than to the seller's capability or per-account state. Sellers MUST emit `BILLING_NOT_PERMITTED_FOR_AGENT` only when agent identity has been established via signed-request derivation or a credential-to-agent mapping in the seller's onboarding record; in all other cases (unauthenticated callers and bearer credentials not mapped to a specific agent record) sellers MUST return `BILLING_NOT_SUPPORTED` and MUST omit `error.details.scope` — emitting the per-agent code or the `\"account\"`-scope hint without established identity is a cross-tenant onboarding oracle (same uniform-response shape required by the `*_NOT_FOUND` family). Recovery: correctable (check `get_adcp_capabilities` for `supported_billing` and resubmit with a value the seller supports, or omit `billing` to accept the seller's default).", + "BILLING_NOT_PERMITTED_FOR_AGENT": "The seller's `supported_billing` capability accepts the requested model, but the calling buyer agent's commercial relationship with the seller does not — e.g., the agent is onboarded as passthrough-only (no payments relationship — only the operator can be invoiced) and `billing: 'agent'` or `billing: 'advertiser'` is rejected even though the seller supports both at the capability level. Distinct from `BILLING_NOT_SUPPORTED` (seller-wide capability) by being narrowly per-buyer-agent: the gate is the seller's onboarding record for this caller, not the seller's global wire capability. Sellers MUST emit this code only after agent identity has been established via signed-request derivation or a credential-to-agent mapping in the seller's onboarding record; callers without established identity MUST receive `BILLING_NOT_SUPPORTED` instead, to prevent the distinct code from acting as an onboarding oracle. The recovery shape is deliberately minimal — `error.details` MUST conform to `error-details/billing-not-permitted-for-agent.json` (`rejected_billing` plus an optional single `suggested_billing` retry value, typically `operator`) and MUST NOT carry the agent's full permitted-billing subset, rate cards, payment terms, credit limit, billing entity, or any other per-agent commercial state. Recovery: correctable (retry with `error.details.suggested_billing` when present; when absent, surface to a human at the buyer — the agent cannot unilaterally extend its commercial relationship and MUST NOT auto-retry, since payments-relationship onboarding with the seller is offline).", + "PAYMENT_TERMS_NOT_SUPPORTED": "The seller does not accept the requested `payment_terms` value for this account. Payment terms are never silently remapped — sellers either accept or reject. Distinct from `BILLING_NOT_SUPPORTED` (the `billing` enum) by being narrowly about the `payment_terms` enum on the same account. Recovery: correctable (omit `payment_terms` to accept the seller's default, retry with a different value the seller supports, or negotiate offline).", + "BRAND_REQUIRED": "A billable operation was attempted without a brand reference. Every billable operation requires either a seller-assigned `account_id` or a natural key including `brand`. Recovery: correctable (include `brand` — `domain` plus optional `brand_id` — on the request).", + "AGENT_SUSPENDED": "The calling buyer agent's commercial relationship with the seller is temporarily paused — the agent is onboarded but currently suspended. Sibling to `ACCOUNT_SUSPENDED` (account-wide) and `CAMPAIGN_SUSPENDED` (per-plan) but scoped to the agent-relationship axis (orthogonal to any specific account on that agent). The code itself is the discriminator — it does NOT carry an `error.details` payload (mirroring `BILLING_NOT_PERMITTED_FOR_AGENT`'s discriminator-by-code pattern), and MUST NOT carry per-agent commercial state (rate cards, payment terms, credit limit, billing entity, contact channels) since full disclosure of per-agent state in a single probe is a per-agent oracle. Cross-tenant onboarding oracle clamp + channel-coverage requirements (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity, retry-counter side channel) are normative in error-handling.mdx Per-Agent Authorization Gate; this description does not restate them to avoid drift. Recovery: terminal (re-onboarding may resolve the suspension; the agent MUST surface to a human at the buyer rather than auto-retrying — the agent cannot unilaterally lift a suspension, and re-attempts only reinforce the gate).", + "AGENT_BLOCKED": "The calling buyer agent's commercial relationship with the seller is permanently denied — the agent is blocked. Sibling to `AGENT_SUSPENDED` on the agent-relationship axis but with no recovery path (a suspension may lift via re-onboarding; a block does not). The code itself is the discriminator — same posture as `AGENT_SUSPENDED`: no `error.details` payload, no per-agent commercial state, cross-tenant onboarding oracle clamp + channel-coverage requirements normative in error-handling.mdx Per-Agent Authorization Gate. Recovery: terminal (no autonomous recovery — the agent MUST surface to a human at the buyer; relationships are reinstated only through offline operator action with the seller, not via any seller-callable AdCP task)." }, "enumMetadata": { - "$comment": "Structured recovery classification and remediation hints for each error code. SDKs MUST consume this block instead of parsing 'Recovery: X' from enumDescriptions prose. Each entry is { recovery, suggestion }. recovery is one of: correctable (caller can fix and retry), transient (retry with backoff), terminal (no autonomous recovery \u2014 operator intervention required). enumDescriptions is retained for human readability and will continue to carry the canonical narrative; the recovery classification embedded in that prose is normative and MUST match the value here.", + "$comment": "Structured recovery classification and remediation hints for each error code. SDKs MUST consume this block instead of parsing 'Recovery: X' from enumDescriptions prose. Each entry is { recovery, suggestion }. recovery is one of: correctable (caller can fix and retry), transient (retry with backoff), terminal (no autonomous recovery - operator intervention required). enumDescriptions is retained for human readability and will continue to carry the canonical narrative; the recovery classification embedded in that prose is normative and MUST match the value here.", "INVALID_REQUEST": { "recovery": "correctable", "suggestion": "check request parameters and fix" }, "AUTH_REQUIRED": { "recovery": "correctable", - "suggestion": "provide credentials via auth header on missing-credential case; do NOT auto-retry on presented-but-rejected credentials \u2014 escalate to operator for credential rotation (3.1+ splits this into AUTH_MISSING / AUTH_INVALID)" + "suggestion": "provide credentials via auth header" }, "RATE_LIMITED": { "recovery": "transient", @@ -277,7 +308,67 @@ }, "PERMISSION_DENIED": { "recovery": "correctable", - "suggestion": "call check_governance to mint a valid token, or contact the seller to resolve the underlying permission" + "suggestion": "call check_governance to mint a valid token, or contact the seller to resolve the underlying permission; when error.details.scope is 'agent' with reason 'sandbox_only' the rejection is terminal-pending-onboarding — surface to a human rather than auto-retrying. For suspended/blocked agent relationships, sellers emit AGENT_SUSPENDED / AGENT_BLOCKED instead (those codes carry recovery: terminal directly)." + }, + "SCOPE_INSUFFICIENT": { + "recovery": "correctable", + "suggestion": "the agent cannot broaden its own scope - surface to the operator rather than auto-retry" + }, + "READ_ONLY_SCOPE": { + "recovery": "correctable", + "suggestion": "use a non-mutating alternative, or surface to the operator to request a scope that permits mutation" + }, + "FIELD_NOT_PERMITTED": { + "recovery": "correctable", + "suggestion": "drop the disallowed field(s) and retry" + }, + "PROVENANCE_REQUIRED": { + "recovery": "correctable", + "suggestion": "attach a provenance object - at minimum digital_source_type - and resubmit" + }, + "PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING": { + "recovery": "correctable", + "suggestion": "set provenance.digital_source_type to a value from the digital-source-type enum and resubmit" + }, + "PROVENANCE_DISCLOSURE_MISSING": { + "recovery": "correctable", + "suggestion": "set provenance.disclosure.required and, when true, populate disclosure.jurisdictions" + }, + "PROVENANCE_EMBEDDED_MISSING": { + "recovery": "correctable", + "suggestion": "attach at least one embedded_provenance entry from a supported provider and resubmit" + }, + "PROVENANCE_VERIFIER_NOT_ACCEPTED": { + "recovery": "correctable", + "suggestion": "replace verify_agent.agent_url with one from the seller's published accepted_verifiers, drop verify_agent if the embedding is self-verifiable, or re-embed with a verifier the seller accepts" + }, + "PROVENANCE_CLAIM_CONTRADICTED": { + "recovery": "correctable", + "suggestion": "revise the provenance claim to match the verifier's observation or replace the creative; auto-retry without correction will not pass" + }, + "BILLING_NOT_SUPPORTED": { + "recovery": "correctable", + "suggestion": "check get_adcp_capabilities for supported_billing and resubmit with a supported value, or omit billing to accept the seller's default" + }, + "BILLING_NOT_PERMITTED_FOR_AGENT": { + "recovery": "correctable", + "suggestion": "retry with error.details.suggested_billing (typically 'operator') when present; when absent, surface to a human at the buyer — the agent cannot unilaterally extend its commercial relationship and MUST NOT auto-retry" + }, + "PAYMENT_TERMS_NOT_SUPPORTED": { + "recovery": "correctable", + "suggestion": "omit payment_terms to accept the seller's default, retry with a different supported value, or negotiate offline" + }, + "BRAND_REQUIRED": { + "recovery": "correctable", + "suggestion": "include brand (domain plus optional brand_id) on the request" + }, + "AGENT_SUSPENDED": { + "recovery": "terminal", + "suggestion": "surface to a human at the buyer — the agent cannot unilaterally lift a suspension; re-onboarding with the seller offline may resolve" + }, + "AGENT_BLOCKED": { + "recovery": "terminal", + "suggestion": "surface to a human at the buyer — the relationship is permanently denied and is reinstated only through offline operator action with the seller, not via any seller-callable AdCP task" } } } \ No newline at end of file diff --git a/src/adcp/types/generated_poc/enums/error_code.py b/src/adcp/types/generated_poc/enums/error_code.py index d01551e95..6172a7636 100644 --- a/src/adcp/types/generated_poc/enums/error_code.py +++ b/src/adcp/types/generated_poc/enums/error_code.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: enums/error_code.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-03T02:59:35+00:00 from __future__ import annotations @@ -53,3 +53,18 @@ class ErrorCode(Enum): CAMPAIGN_SUSPENDED = 'CAMPAIGN_SUSPENDED' GOVERNANCE_UNAVAILABLE = 'GOVERNANCE_UNAVAILABLE' PERMISSION_DENIED = 'PERMISSION_DENIED' + SCOPE_INSUFFICIENT = 'SCOPE_INSUFFICIENT' + READ_ONLY_SCOPE = 'READ_ONLY_SCOPE' + FIELD_NOT_PERMITTED = 'FIELD_NOT_PERMITTED' + PROVENANCE_REQUIRED = 'PROVENANCE_REQUIRED' + PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING = 'PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING' + PROVENANCE_DISCLOSURE_MISSING = 'PROVENANCE_DISCLOSURE_MISSING' + PROVENANCE_EMBEDDED_MISSING = 'PROVENANCE_EMBEDDED_MISSING' + PROVENANCE_VERIFIER_NOT_ACCEPTED = 'PROVENANCE_VERIFIER_NOT_ACCEPTED' + PROVENANCE_CLAIM_CONTRADICTED = 'PROVENANCE_CLAIM_CONTRADICTED' + BILLING_NOT_SUPPORTED = 'BILLING_NOT_SUPPORTED' + BILLING_NOT_PERMITTED_FOR_AGENT = 'BILLING_NOT_PERMITTED_FOR_AGENT' + PAYMENT_TERMS_NOT_SUPPORTED = 'PAYMENT_TERMS_NOT_SUPPORTED' + BRAND_REQUIRED = 'BRAND_REQUIRED' + AGENT_SUSPENDED = 'AGENT_SUSPENDED' + AGENT_BLOCKED = 'AGENT_BLOCKED' diff --git a/tests/test_error_code_conformance.py b/tests/test_error_code_conformance.py new file mode 100644 index 000000000..28e12c6ad --- /dev/null +++ b/tests/test_error_code_conformance.py @@ -0,0 +1,228 @@ +"""AdCP error-code spec conformance — static AST walker. + +Scans all ``.py`` files under ``src/adcp/`` for ``AdcpError(...)`` raise +sites and asserts every string-literal first-positional code is either: + +* in the canonical AdCP error-code enum (bundled at + :file:`src/adcp/types/generated_poc/enums/error_code.py`, generated + from :file:`schemas/cache/enums/error-code.json`); +* prefixed with ``X_`` per the AdCP vendor-extension convention; or +* explicitly listed in :data:`KNOWN_NON_SPEC_CODES` below — a small, + documented allowlist for codes the SDK uses intentionally that are + not (yet) in the enum. + +Background — issue #375 / PR #393: four codes shipped for months as +non-spec (``AGENT_SUSPENDED`` / ``AGENT_BLOCKED`` / +``REQUEST_AUTH_UNRECOGNIZED_AGENT`` / ``INVALID_BILLING_MODEL``) before +being migrated to spec-conformant ``PERMISSION_DENIED`` and +``BILLING_NOT_PERMITTED_FOR_AGENT``. This test is the load-bearing CI +signal preventing that drift from recurring. + +Why AST and not regex: regex over multi-line raise expressions +(``AdcpError(\n "FOO",\n message=...,``) is fragile. ``ast`` walks +the parsed module and picks out exactly the first positional arg of +``Call`` nodes whose ``func`` is named ``AdcpError``. + +Limitations (deliberate, documented): + +* Only string-literal codes are inspected. Variable / attribute / + computed codes are skipped — those are rare, intentional in the + framework's catch-and-re-raise paths, and need separate manual review. + A count of skipped raise sites is reported alongside failures. +* Only the symbol name ``AdcpError`` is walked (the structured + server-side error from :mod:`adcp.decisioning.types`). The + unrelated client-side ``ADCPError`` (all-caps, from + :mod:`adcp.exceptions`) takes ``(message, ...)`` not ``(code, ...)`` + and is excluded by name. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from adcp.types.generated_poc.enums.error_code import ErrorCode + +# --------------------------------------------------------------------------- +# Allowlist — codes used intentionally by the SDK that are not in the +# canonical enum. Keep this list short and documented; every entry is a +# spec-drift point that should ideally migrate upstream. +# --------------------------------------------------------------------------- +KNOWN_NON_SPEC_CODES: dict[str, str] = { + # TODO: track upstream addition to error-code.json enum. + # Universal "framework caught an unhandled exception" wrap. Used by + # the dispatch layer to project arbitrary Python exceptions to a + # safe wire shape (without leaking stack traces). The spec + # description on error-code.json explicitly notes that sellers MAY + # return codes outside the enum for platform-specific errors; + # INTERNAL_ERROR is the SDK's canonical fallback. + "INTERNAL_ERROR": ( + "Universal exception wrap used by adcp.decisioning.dispatch. " + "Spec allows codes outside the enum; this is the SDK's fallback." + ), + # TODO: track upstream 3.1 split of AUTH_REQUIRED → AUTH_MISSING + AUTH_INVALID. + # 3.1 will split AUTH_REQUIRED into AUTH_MISSING + AUTH_INVALID per + # the canonical enumDescription on AUTH_REQUIRED. The SDK uses + # AUTH_INVALID at the FromAuthAccounts gate where the principal is + # missing/empty after auth verification — distinct from "no + # credentials presented" (AUTH_REQUIRED). + "AUTH_INVALID": ( + "Pre-canonical 3.1 split of AUTH_REQUIRED. Documented in the " + "AUTH_REQUIRED enumDescription as a future spec change." + ), +} + +CANONICAL_CODES: frozenset[str] = frozenset(member.value for member in ErrorCode) +SRC_ROOT = Path(__file__).resolve().parent.parent / "src" / "adcp" + + +@dataclass(frozen=True) +class RaiseSite: + """A single ``AdcpError(...)`` call site found by the walker.""" + + file: Path + lineno: int + code: str | None # None means "non-literal first arg" (skipped) + + +def _extract_literal_code(call: ast.Call) -> str | None: + """Return the first positional arg if it is a ``str`` literal, else None. + + Handles: + * ``AdcpError("CODE", message=...)`` — positional literal + * ``AdcpError(code="CODE", message=...)`` — keyword literal + """ + if call.args: + first = call.args[0] + if isinstance(first, ast.Constant) and isinstance(first.value, str): + return first.value + return None + for kw in call.keywords: + if kw.arg == "code": + if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): + return kw.value.value + return None + return None + + +def _is_adcp_error_call(call: ast.Call) -> bool: + """Match ``AdcpError(...)`` and ``module.AdcpError(...)`` calls. + + Excludes the unrelated all-caps ``ADCPError`` (client-side + connection-error class with a different signature). + """ + func = call.func + if isinstance(func, ast.Name): + return func.id == "AdcpError" + if isinstance(func, ast.Attribute): + return func.attr == "AdcpError" + return False + + +def _walk_file(path: Path) -> list[RaiseSite]: + """Parse ``path`` and return every ``AdcpError(...)`` call site.""" + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + sites: list[RaiseSite] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call) and _is_adcp_error_call(node): + sites.append( + RaiseSite( + file=path, + lineno=node.lineno, + code=_extract_literal_code(node), + ) + ) + return sites + + +def _collect_raise_sites() -> list[RaiseSite]: + """Walk every ``.py`` file under ``src/adcp/`` for AdcpError calls.""" + sites: list[RaiseSite] = [] + for path in sorted(SRC_ROOT.rglob("*.py")): + sites.extend(_walk_file(path)) + return sites + + +def _is_acceptable_code(code: str) -> bool: + """Code is in the canonical enum, has X_ vendor prefix, or is allowlisted.""" + if code in CANONICAL_CODES: + return True + if code.startswith("X_"): + return True + if code in KNOWN_NON_SPEC_CODES: + return True + return False + + +def test_canonical_enum_is_loaded() -> None: + """Sanity-check: the bundled enum has the expected shape. + + Pins the assumption that the generated ``ErrorCode`` enum mirrors + the schema. If this drifts (e.g. the schema gains a code), this + test surfaces the drift before the conformance walker silently + starts accepting it as canonical. + """ + assert "PERMISSION_DENIED" in CANONICAL_CODES + assert "ACCOUNT_SUSPENDED" in CANONICAL_CODES + # Spot-check a non-spec code that historically got misnamed and is + # still not in the canonical enum: + assert "INVALID_BILLING_MODEL" not in CANONICAL_CODES + assert "REQUEST_AUTH_UNRECOGNIZED_AGENT" not in CANONICAL_CODES + # If this assertion fails, the bundled error-code.json was resynced; + # update both the count AND audit allowlist entries that may now be + # in the canonical enum. + assert len(CANONICAL_CODES) == 60, f"Expected 60 spec error codes, got {len(CANONICAL_CODES)}" + + +def test_adcp_error_codes_are_spec_conformant() -> None: + """Every literal AdcpError(code, ...) is in the spec enum, X_-prefixed, or allowlisted.""" + sites = _collect_raise_sites() + assert sites, ( + f"AdcpError raise-site walker found zero call sites under {SRC_ROOT}; " + "this suggests the walker is broken (the codebase is known to raise " + "AdcpError in adcp.decisioning.*)." + ) + + violations: list[RaiseSite] = [] + for site in sites: + if site.code is None: + continue # Non-literal — skipped by design (see module docstring). + if not _is_acceptable_code(site.code): + violations.append(site) + + if violations: + lines = [ + f" {site.file.relative_to(SRC_ROOT.parent.parent)}:{site.lineno} → {site.code!r}" + for site in violations + ] + msg = ( + f"Found {len(violations)} non-spec AdcpError code(s):\n" + + "\n".join(lines) + + "\n\nEvery AdcpError(code, ...) must use a code from the canonical " + "AdCP enum (schemas/cache/enums/error-code.json), the X_ " + "vendor-extension prefix, or be added to KNOWN_NON_SPEC_CODES " + "in tests/test_error_code_conformance.py with a documented reason." + ) + pytest.fail(msg) + + +def test_allowlist_entries_are_actually_used() -> None: + """Every KNOWN_NON_SPEC_CODES entry must appear in at least one raise site. + + Prevents the allowlist from accumulating dead entries — once a + non-spec code is migrated to a spec code (or removed), its allowlist + entry should also be removed. Without this check the allowlist + becomes a graveyard of historical codes that silently mask future + drift. + """ + sites = _collect_raise_sites() + used_codes = {site.code for site in sites if site.code is not None} + stale = [code for code in KNOWN_NON_SPEC_CODES if code not in used_codes] + assert not stale, ( + f"KNOWN_NON_SPEC_CODES entries no longer used in src/adcp/: {stale}. " + "Remove them — dead entries mask future drift." + )