diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b984d767e..9cc8bf0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,7 +91,7 @@ jobs: - name: Run adopter type-check suite if: matrix.python-version == '3.12' - run: mypy --strict tests/type_checks/ + run: mypy --strict tests/type_checks/ examples/reporting_webhook_activity.py - name: Enforce adopter type-check fixture contract if: matrix.python-version == '3.12' @@ -106,9 +106,15 @@ jobs: run: pytest tests/ -v --cov=src/adcp --cov-report=term-missing pg-conformance: - name: Postgres conformance tests (Postgres 16) + name: Postgres conformance tests (Postgres 16, ${{ matrix.lane }}) runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + lane: [core, process] services: postgres: # CI-local ephemeral database. POSTGRES_HOST_AUTH_METHOD=trust @@ -130,6 +136,10 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Fetch reviewed reporting baseline for rolling binary tests + timeout-minutes: 1 + run: git fetch --no-tags --depth=1 origin 17ee407ae3978c8a2bb54437287afbf9dafb8130 + - name: Set up Python 3.12 uses: actions/setup-python@v6 with: @@ -145,15 +155,48 @@ jobs: - name: Run Postgres conformance tests env: ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_test + PG_LANE: ${{ matrix.lane }} run: | - pytest tests/conformance/signing/test_pg_replay_store.py \ - tests/conformance/signing/test_pg_replay_store_e2e.py \ - tests/conformance/decisioning/test_pg_buyer_agent_registry.py \ - tests/conformance/decisioning/test_pg_idempotency_backend.py \ - tests/conformance/decisioning/test_pg_task_webhook_outbox.py \ - tests/conformance/decisioning/test_pg_reference_workflow_queue.py \ - tests/conformance/reporting/ \ - -v + # Keep every case and its deadline. Separate process-crash controls + # so setup and teardown also fit inside each unchanged job budget. + case "$PG_LANE" in + core) + pytest tests/conformance/signing/test_pg_replay_store.py \ + tests/conformance/signing/test_pg_replay_store_e2e.py \ + tests/conformance/decisioning/test_pg_buyer_agent_registry.py \ + tests/conformance/decisioning/test_pg_idempotency_backend.py \ + tests/conformance/decisioning/test_pg_task_webhook_outbox.py \ + tests/conformance/decisioning/test_pg_reference_workflow_queue.py \ + tests/conformance/reporting/ \ + --ignore=tests/conformance/reporting/test_reporting_notification_process_matrix.py \ + -v + ;; + process) + pytest tests/conformance/reporting/test_reporting_notification_process_matrix.py -v + ;; + *) + echo "Unknown Postgres conformance lane" + exit 1 + ;; + esac + + pg-conformance-required-gate: + name: Postgres conformance tests (Postgres 16) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + needs: pg-conformance + if: ${{ always() }} + steps: + - name: Require every Postgres conformance lane + env: + PG_RESULT: ${{ needs.pg-conformance.result }} + run: | + if [ "$PG_RESULT" != "success" ]; then + echo "Postgres conformance matrix result: $PG_RESULT" + exit 1 + fi + echo "All Postgres conformance lanes passed" conventional-commits: name: Validate conventional commit format diff --git a/docs/reporting-ledger-migration.md b/docs/reporting-ledger-migration.md index ab7e8eb19..60ea2e473 100644 --- a/docs/reporting-ledger-migration.md +++ b/docs/reporting-ledger-migration.md @@ -52,7 +52,9 @@ obligation IDs, the named obligations must exist in the requested account. `reporting_ledger_account_generations.sql`, `reporting_ledger_obligation_currency.sql`, `reporting_ledger_reconciliation.sql`, and - `reporting_notification_outbox.sql` migrations in one transaction. + `reporting_notification_outbox.sql` and `reporting_webhook_activity.sql` + migrations in one transaction. For the later A-to-B additive upgrade and + activity activation barrier, see [durable reporting activity](reporting-webhook-activity.md). 3. Restart reporting work with the upgraded SDK on every instance. For deployments managed by a migration tool, the standalone migration is diff --git a/docs/reporting-notification-outbox.md b/docs/reporting-notification-outbox.md index a361bb804..2fa201546 100644 --- a/docs/reporting-notification-outbox.md +++ b/docs/reporting-notification-outbox.md @@ -12,10 +12,11 @@ It retains a durable status-dirty handoff for the later complete status projecto | Managed destination/reconciliation change | Consumer-scoped status-dirty evidence | | Verified materialization with its frozen Managed binding | `reporting.delivery_ready`, scoped to the reconciliation consumer | -There is no `reporting.status_changed` emitter. Clock sweeps, complete status -fingerprint deduplication, and webhook activity projection belong to #1168B. -The capability helper omits `status_notification` and sets -`supports_webhook_activity=false`. +There is no `reporting.status_changed` emitter. Clock sweeps and complete status +fingerprint deduplication belong to #1168C. The optional +[#1168B activity layer](reporting-webhook-activity.md) adds durable HTTP reservations +and a list-accounts projection. Without that mounted layer the capability helper +omits `status_notification` and sets `supports_webhook_activity=false`. ## Optional wiring @@ -63,8 +64,9 @@ Core subscriber request cannot create a readiness event or capability. The helper verifies that the opted-in ledger and outbox use the same store or pool, that current registrations have usable authentication, and that the -entire installed PostgreSQL chain matches its column, constraint, index, -trigger, and guard-function contract. It does not infer operational readiness +required objects in the PostgreSQL chain match their column, constraint, index, +trigger, and guard-function contracts. Unrelated adopter objects are allowed. +It does not infer operational readiness from the presence of objects. Continue scheduling the worker while advertising these fields. Custom stores can implement the additive outbox protocol; automatic capability verification conservatively covers the SDK reference stores. @@ -185,8 +187,9 @@ deduplicate using a trusted publisher identity that survives key rotation and the idempotency key. A lost ACK can produce another authenticated request with identical body/key and a fresh signature. Polling remains the recovery path. -This slice retains events, expansion checkpoints, prepared bindings and dirty -evidence indefinitely. It has no purge API or finite advertised activity horizon. +The base outbox retains events, expansion checkpoints, prepared bindings and dirty +evidence indefinitely. The optional activity layer retains pending reservations +and counters indefinitely; its scoped purge enforces a 30-day terminal-history floor. Do not delete parent events, keys, or prepared bindings while any delivery is nonterminal or within an adopter's promised retry/activity retention horizon. @@ -195,12 +198,12 @@ nonterminal or within an adopter's promised retry/activity retention horizon. `reporting_notification_outbox.sql` follows the reviewed four-file foundation chain. It adds notification events, expansion/delivery leases, ordered dirty records, projector checkpoints, and typed issue scope storage. It rewrites and -backfills no ledger evidence. `create_schema()` installs all five steps atomically; +backfills no ledger evidence. `reporting_webhook_activity.sql` follows as an +additive sixth step. `create_schema()` installs all six steps atomically; opted-in stores and `PgReportingOutbox.create_schema()` also validate the complete -installed contract before committing. Default-off Core startup preserves its -compatibility with adopter indexes. The conservative notification readiness check -requires the SDK table definitions, including their indexes and guards, to match -the bundled contract. Concurrent and repeated installations serialize on the schema +required outbox contract before committing. Activity startup additionally validates +the sixth step. Readiness validates required objects independently and ignores +unrelated adopter additions. Concurrent and repeated installations serialize on the schema advisory lock. The standalone outbox SQL is atomic even on an autocommit connection with the foundation already installed. See [reporting ledger migrations](reporting-ledger-migration.md). @@ -216,6 +219,6 @@ cover commit/fanout and real TLS HTTP acceptance/ACK. All child, pipe, receiver and barrier waits have hard watchdogs with sanitized role/PID/checkpoint diagnostics; no timing sleeps control an interleaving. The receiver fixture self-check verifies both rotation keys before the full process lane is run. -The distribution tests build an sdist, build its wheel, import a real base -installation with PostgreSQL absent, then install `[pg]` and exercise migration, -commit and restart from that wheel. +The distribution tests build an sdist and its wheel, install each with PostgreSQL +absent, then install each with `[pg]` and exercise migration, commit, retry, +activity projection and restart on PostgreSQL 16. diff --git a/docs/reporting-webhook-activity.md b/docs/reporting-webhook-activity.md new file mode 100644 index 000000000..5af0465aa --- /dev/null +++ b/docs/reporting-webhook-activity.md @@ -0,0 +1,197 @@ +# Durable reporting webhook activity (#1168B) + +This optional layer extends the [reporting outbox](reporting-notification-outbox.md). +It records reporting HTTP attempts and projects them into visible +`list_accounts.accounts[].webhook_activity`. It adds no methods to the legacy +`AccountStore` or `ReportingLedgerStore` protocols. The generic webhook supervisor +does not provide this reporting activity contract. Status notification projection +and its clock sweeper remain #1168C. + +## Mounting and lifecycle + +Install `adcp[pg]` and use PostgreSQL 16. The +[fully typed example](../examples/reporting_webhook_activity.py) shows trusted +identity resolution, registration persistence, migration, capability validation, +both worker phases, HTTP serving, bounded draining, and pool shutdown. + +Construct the opted-in `PgReportingLedgerStore` (or reconciliation store), +`PgReportingOutbox`, and `ReportingNotificationWorker` on the same pool. Pass that +exact outbox instance as the worker's `activity=` argument. Construct +`ReportingActivityProjector(outbox)` and +`ReportingActivitySupport(worker, ledger, projector)`. Mount the support object as +`reporting_activity=` and the projector as `account_activity=` on +`create_adcp_server_from_platform` or `serve`. + +Run `ledger.create_schema()` before serving. In an async startup hook use +`validate_at_init=False`, then await +`validate_capabilities_response_shape_async(handler)` on the pool's event loop. +Schedule expansion and delivery for the service's authorized accounts and keep +the worker healthy while advertising activity. Stop accepting requests and new +claims before draining in-flight work. If a drain deadline requires cancellation, +retain the pending row and parent lease; a later worker reclaims the delivery. +Close the pool after the workers exit. Encryption keys and old key versions must +remain available while their immutable prepared deliveries can retry. + +The capability helper checks required database objects and the concrete mounted +chain. `support.capability_flags(account_activity=projector)` returns independent +`reporting` and `account_notifications` booleans to use in an adopter's existing +capability declarations. It does not create account lifecycle emission support. +The handler checks truthy declarations after static and request-specific +projection, without mutating a shared capability model. Unsupported truthy +overrides fail startup/discovery with a constant diagnostic. + +| Mounted components | Reporting activity | Account activity capability | +| --- | --- | --- | +| None, outbox only, or memory only | false | false | +| Durable writer without activity projector | false | false | +| Durable outbox, attempt writer, worker, projector | true | false | +| Above plus list-accounts projector and account listing | true | true | + +Relationship notification support is independent and supplies no evidence for +either flag. The memory implementation supports shared conformance vectors; it +cannot justify a durable claim. + +## Canonical consumer and account visibility + +Call `resolve_reporting_consumer(auth_info=..., agent=...)` when registering a +trusted subscription and persist its result as `principal_id` with the authorized +configuration. The same resolver runs for requested activity reads. It checks +every present identity: `ResolveContext.agent.agent_url`, `AuthInfo.principal`, +`AuthInfo.agent_url`, and `HttpSigCredential.agent_url`. A sole valid registry +identity (including typed API/OAuth resolution) or a sole valid auth identity is +sufficient. All present values must agree exactly. Blank, nonprintable, or +reserved anonymous values fail, including case variants of `anonymous`, `anon`, +`unauthenticated`, `none`, and `null`. Normalize legitimate aliases in trusted +authentication adapters before this boundary. + +Neither an account assertion, request body, subscriber ID, nor +`consumer_namespace` chooses this principal. The async worker restores it from +the trusted persisted subscription and revalidates its principal and entire +configuration fingerprint before preparation. Keep subscription URL/credentials +in access-controlled configuration storage; the outbox protects its routing +snapshot with authenticated encryption. + +The list-accounts handler passes the original optional `account`, `status`, +`sandbox`, and pagination filters to the legacy store. Activity-only request +fields are not store arguments. Enrichment runs after existing response +projection, exclusively for account IDs the store returned as visible. Every +activity SQL read/write includes both account and principal predicates. Limits +are per account, integer 1–200 (default 50), applied after those predicates. + +Unrequested or unsupported activity is omitted, including adopter-supplied +activity fields. Supported, requested activity replaces them with canonical rows +or `[]`. Principal resolution is required only for supported, requested activity; +anonymous legacy list calls retain their existing behavior. List, dict, and +Pydantic store results retain their envelope, errors, context, pagination, and +final credential scrub. + +## Reservation boundary and outcomes + +`WebhookSender.before_attempt` is single-use. DNS/SSRF validation and signing +finish first, followed by the final lease fence and activity reservation, +immediately before HTTP. A preflight DNS/SSRF failure creates **no activity row**. +DNS or signing can outlive a lease; an expired final fence prevents reservation +and HTTP. A reservation database failure prevents HTTP as well. + +The reservation transaction locks the exact parent by account, canonical +principal, consumer namespace, delivery ID, lease token, and database expiry. +It then locks/increments the retained head with an atomic upsert/returning path. +The lock order is always parent then head. The head is keyed by account, +principal, subscriber, and idempotency key; its 1-based counter never uses queue +claim counts and cannot be deleted or reset. Notification/parent references, +identity, request diagnostics, tokens, and attempt number are immutable. + +| Observed result after reservation | Activity status | Parent policy | +| --- | --- | --- | +| HTTP 2xx | `success` | complete | +| Other HTTP response | `failed` | retry 408/425/429/5xx; terminal otherwise | +| HTTP timeout exception | `timeout` | retry | +| Connect, TLS, socket failure | `connection_error` | retry | +| Crash, cancellation, unknown completion | `pending` | expire/reclaim | + +Activity terminalization precedes the parent ACK. It uses the primary identity, +opaque reservation token, and pending-state predicate, without requiring the +parent lease to remain current. A late known response can complete only its own +attempt. Failed or uncertain terminalization prevents the parent ACK. Queue +quarantine, circuit state, provider response bodies, and exception prose are not +activity outcomes. + +The reservation-to-peer-I/O and post-HTTP/pre-activity-ACK windows cannot be made +atomic. A crash in either window leaves `pending`: it cannot establish whether +the peer observed a request. Reclaim creates a new attempt with the exact same +notification, body bytes, and idempotency key. Worker deadline cancellation is +also uncertain; it is not an HTTP timeout result. Receivers still deduplicate by +authenticated sender and idempotency key. + +## Projection, redaction, and retention + +Rows sort by `fired_at DESC`, then C-collated notification ID, idempotency key, +subscriber ID, attempt, and delivery ID descending. Equal timestamps have stable +per-account ordering. PostgreSQL supplies fired/completed timestamps; elapsed +HTTP response time uses a monotonic clock. Optional sequence/notification IDs +are omitted when absent. The completion, HTTP code, latency, and error fields +retain explicit nulls required by the state contract. Each projected row is +validated against bundled `core/webhook-activity-record.json` (3.2.0-rc.3). + +Displayed URLs remove userinfo, query, and fragment. Path segments are decoded +for detection; UUID, JWT, long hex/base64, encoded credentials, and other unknown +segments become the constant `redacted`. This deliberately conservative policy +also hides unfamiliar non-secret route words; a small public-route allowlist +preserves useful structure. The sanitized URL is validated as `AnyUrl`. Raw +subscription URLs are bounded at 8,192 characters and never interpolated into +sanitizer exceptions. Activity plaintext columns contain only the sanitized URL +and closed diagnostics; transport logs cannot disclose URLs, headers, secrets, +or provider errors. + +Use `purge_activity(account_id=..., consumer_id=..., now=...)` for each authorized +account/principal. PostgreSQL ignores the caller's clock for retention and uses +database time. Only terminal rows strictly older than 30 days by `completed_at` +are removed; exactly-on-boundary and pending rows remain. `retention_days` may +increase that floor, never reduce it. Heads are retained even when every terminal +row is purged. Pending orphans remain readable after parent loss. No status clock +sweeper or broad unscoped tenant purge is installed by this slice. + +## Migration and rolling upgrades + +The ordered packaged chain is `reporting_ledger.sql`, +`reporting_ledger_account_generations.sql`, `reporting_ledger_obligation_currency.sql`, +`reporting_ledger_reconciliation.sql`, `reporting_notification_outbox.sql` (#1168A), +then `reporting_webhook_activity.sql` (#1168B). `create_schema()` executes the chain +in one transaction. The B step is also atomic on its own, serializes concurrent +installations, backfills nothing, and adds only B tables/indexes/constraints/ +functions/triggers. It does not change any A table or attach a new object to one. + +The actual-binary rolling controls pin integrated A commit `17ee407a`, including +its checkpoint-schema and database-locale readiness corrections. That A binary +can continue existing work on a B-upgraded database without rejecting B's +additive objects or claiming activity. These controls do not qualify the earlier +`21bf443e` snapshot, whose schema fingerprints depend on database collation. +B accepts +the A required-object subset for existing outbox work, but its activity readiness +fails closed until B migration commits. Attempts against an A-only schema fail +without HTTP or parent ACK; they become retryable after migration. + +**Activation ordering:** migrate the additive schema while A still serves its +existing configurations, then drain/replace A workers before enabling B activity +and new canonical URL-principal subscriptions. A's registration validator rejects +URL principals, and A workers do not record activity. Mixed A/B workers therefore +cannot justify advertising complete activity coverage. This is an activation +barrier, not an A-table rewrite or data backfill. + +B validates a required-object subset rather than hashing each whole table. +Required columns, named constraints, ready/valid indexes, enabled triggers and +their definitions, and guard function definitions/security/volatility must match. +This includes A's restatement checkpoint table, columns, constraints and indexes. +Unrelated adopter columns/indexes/constraints/triggers are ignored. Diagnostics +are `notification_schema_unready:{missing|disabled|changed}:` or +`catalog_unavailable`; they contain bundled names, never catalog/provider prose. +Install a missing migration, re-enable a disabled required object, or restore a +changed required definition from the matching release before advertising. Do not +regenerate the bundled manifest from a damaged adopter database to bypass checks. + +The manifest regression compares all 453 required objects across two fresh random +schemas, repeats each unchanged migration with zero manifest diff, and compares +installed wheel/sdist migrations to the same manifest. Schema names and OIDs do +not identify required objects. Function source, security mode, volatility, +strictness, parallel safety, leakproof flag, and function-local settings (including +`search_path`) remain in the fingerprint; changing them fails readiness. diff --git a/examples/reporting_webhook_activity.py b/examples/reporting_webhook_activity.py new file mode 100644 index 000000000..efdaecaa2 --- /dev/null +++ b/examples/reporting_webhook_activity.py @@ -0,0 +1,172 @@ +"""Typed #1168B mounting/lifecycle example; install ``adcp[pg]``. + +Call ``register_approved_subscription`` after account authorization and endpoint +proof verification. ``run_reporting_service`` accepts the adopter's existing +platform, trusted configuration/key stores, and async HTTP service entrypoint. +It starts both worker phases and stops the service if the worker exits. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Sequence +from typing import Protocol + +from psycopg_pool import AsyncConnectionPool + +from adcp.decisioning import ( + DecisioningPlatform, + create_adcp_server_from_platform, + validate_capabilities_response_shape_async, +) +from adcp.decisioning.accounts import ResolveContext +from adcp.decisioning.handler import PlatformHandler +from adcp.reporting.ledger import PgReportingLedgerStore +from adcp.reporting.outbox import ( + PgReportingOutbox, + ReportingActivityProjector, + ReportingActivitySupport, + ReportingEnvelopeCipher, + ReportingNotificationSubscription, + ReportingNotificationWorker, + ReportingSigningResolver, + ReportingSubscriptionResolver, + resolve_reporting_consumer, +) + + +class TrustedRegistrations(ReportingSubscriptionResolver, Protocol): + """Adopter-owned durable, access-controlled subscription configuration.""" + + async def put(self, subscription: ReportingNotificationSubscription) -> None: ... + + +async def register_approved_subscription( + *, + context: ResolveContext, + registrations: TrustedRegistrations, + account_id: str, + subscriber_id: str, + url: str, + configuration_revision: str, + authorization_ref: str, + proof_of_control_ref: str, + signing_scope_id: str, +) -> None: + # All present registry, flat auth, and signed credential identities must + # agree. The request body and account reference cannot supply this value. + principal = resolve_reporting_consumer(auth_info=context.auth_info, agent=context.agent) + await registrations.put( + ReportingNotificationSubscription( + account_id=account_id, + subscriber_id=subscriber_id, + principal_id=principal, + url=url, + event_types=("reporting.ledger_changed",), + configuration_revision=configuration_revision, + authorization_ref=authorization_ref, + proof_of_control_ref=proof_of_control_ref, + signing_scope_id=signing_scope_id, + active=True, + authorized=True, + proof_valid=True, + ) + ) + + +async def _work( + worker: ReportingNotificationWorker, accounts: Sequence[str], stop: asyncio.Event +) -> None: + while not stop.is_set(): + worked = False + for account_id in accounts: + if stop.is_set(): + break + worked = await worker.expand_one(account_id=account_id) or worked + if not stop.is_set(): + worked = await worker.deliver_one(account_id=account_id) or worked + if not worked: + try: + await asyncio.wait_for(stop.wait(), timeout=1) + except asyncio.TimeoutError: + # The poll interval elapsed; check for work or shutdown on the next turn. + pass + + +async def run_reporting_service( + *, + platform: DecisioningPlatform, + serve: Callable[[PlatformHandler, PgReportingLedgerStore], Awaitable[None]], + conninfo: str, + account_ids: Sequence[str], + subscriptions: ReportingSubscriptionResolver, + signing: ReportingSigningResolver, + cipher: ReportingEnvelopeCipher, +) -> None: + """Own the pool and worker until the HTTP service stops or the worker fails. + + The existing platform declares its normal reporting capabilities. It may + set reporting ``supports_webhook_activity=True`` with this mounting. An + existing account notification declaration may additionally set its own + flag; this example does not create an account status emitter. The service + callback receives the ledger to mount on its reporting producer. + + The platform declares its RFC 9421 webhook signing and retry horizon with + ``webhook_signing_managed_externally=True``; this service owns delivery. + """ + async with AsyncConnectionPool(conninfo, open=False) as pool: + await pool.wait(timeout=10) + ledger = PgReportingLedgerStore(pool=pool, notifications=True) + # All six packaged steps are atomic/repeatable. Migrate before serving. + await ledger.create_schema() + outbox = PgReportingOutbox(pool=pool) + worker = ReportingNotificationWorker( + outbox=outbox, + activity=outbox, + subscriptions=subscriptions, + signing=signing, + cipher=cipher, + ) + projector = ReportingActivityProjector(outbox) + support = ReportingActivitySupport(worker, ledger, projector) + if not await support.durable(): + raise RuntimeError("reporting_activity_unready") + handler, executor, _ = create_adcp_server_from_platform( + platform, + account_activity=projector, + reporting_activity=support, + auto_emit_task_webhooks=False, + validate_at_init=False, + ) + stop = asyncio.Event() + tasks: list[asyncio.Task[None]] = [] + try: + # Resolves readiness on this event loop; no shared capability model + # is mutated. Contradictory static/request overrides fail closed. + await validate_capabilities_response_shape_async(handler) + + async def serve_http() -> None: + await serve(handler, ledger) + + worker_task = asyncio.create_task(_work(worker, account_ids, stop)) + http_task = asyncio.create_task(serve_http()) + tasks.extend((worker_task, http_task)) + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in done: + task.result() + finally: + stop.set() # Stop new claims; let an in-flight attempt finish first. + try: + if tasks: + tasks[-1].cancel() # Stop serving before withdrawing the worker. + try: + await asyncio.wait_for(asyncio.shield(tasks[0]), timeout=10) + except asyncio.TimeoutError: + tasks[0].cancel() # Uncertain activity remains pending. + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + finally: + executor.shutdown(wait=True) + # The pool closes only after worker HTTP/transactions have drained. diff --git a/pyproject.toml b/pyproject.toml index 80ffcaee7..cd57eb7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,6 +194,7 @@ adcp = [ "signing/pg/*.sql", "decisioning/pg/*.sql", "reporting/ledger/*.sql", + "reporting/outbox/*.json", # PREVIEW: vendored sync_reporting_status schemas. They are the runtime # validator for the wire conditionals codegen cannot express, so the wheel # must carry them. Removed with the rest of _preview/ at rc.2. diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 61070f0e5..8d08a555f 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -258,6 +258,8 @@ from adcp.decisioning.state import StateReader from adcp.decisioning.task_registry import TaskRegistry from adcp.decisioning.types import Account + from adcp.reporting.outbox.activity import ReportingActivityProjector + from adcp.reporting.outbox.support import ReportingActivitySupport from adcp.webhook_sender import WebhookSender from adcp.webhook_supervisor import WebhookDeliverySupervisor @@ -1034,6 +1036,13 @@ def _build_list_accounts_filter(params: Any) -> dict[str, Any]: checks. Mirrors the JS-side ``buildListAccountsFilter`` shape. """ filter_dict: dict[str, Any] = {} + account = getattr(params, "account", None) + if account is not None: + filter_dict["account"] = ( + account.model_dump(mode="json", exclude_none=True) + if hasattr(account, "model_dump") + else account + ) status = getattr(params, "status", None) if status is not None: filter_dict["status"] = status.value if hasattr(status, "value") else status @@ -1414,6 +1423,8 @@ def __init__( advertise_all: bool = False, timed_sync_get_products_limit: int | None = None, adcp_version: str | None = None, + account_activity: ReportingActivityProjector | None = None, + reporting_activity: ReportingActivitySupport | None = None, ) -> None: super().__init__() # ``None`` resolves to the protocol version bundled with this SDK, so @@ -1421,6 +1432,8 @@ def __init__( # unnegotiated dispatch. self._adcp_version = resolve_adcp_version(adcp_version) self._platform = platform + self._account_activity = account_activity + self._reporting_activity = reporting_activity self._executor = executor self._registry = registry self._state_reader = state_reader @@ -2044,6 +2057,15 @@ async def get_adcp_capabilities( adcp_version=(context.resolved_adcp_version if context is not None else None), ) + from adcp.reporting.outbox.support import validate_activity_claims + + await validate_activity_claims( + response, + support=self._reporting_activity, + account_activity=self._account_activity, + account_listing=callable(getattr(self._platform.accounts, "list", None)), + ) + if has_scoped_caps: from adcp.decisioning.validate_capabilities import _validate_response_dict @@ -2894,7 +2916,22 @@ async def list_accounts( # type: ignore[override] result = _call_with_optional_ctx(listing, filter_dict, ctx=resolve_ctx) if inspect.isawaitable(result): result = await result - return cast("ListAccountsResponse", _project_list_accounts(result)) + projected = _project_list_accounts(result) + if self._account_activity is not None: + limit = getattr(params, "webhook_activity_limit", None) + projected = await self._account_activity.enrich( + projected, + context=resolve_ctx, + include=getattr(params, "include_webhook_activity", False) is True, + limit=50 if limit is None else limit, + ) + else: + from adcp.reporting.outbox.activity import omit_account_activity + + projected = omit_account_activity(projected) + return cast( + "ListAccountsResponse", strip_credentials_from_wire_result("list_accounts", projected) + ) # ----- Optional-method gate ----- diff --git a/src/adcp/decisioning/serve.py b/src/adcp/decisioning/serve.py index 770920b17..256ca2b45 100644 --- a/src/adcp/decisioning/serve.py +++ b/src/adcp/decisioning/serve.py @@ -51,6 +51,8 @@ from adcp.decisioning.resolve import ResourceResolver from adcp.decisioning.state import StateReader from adcp.decisioning.task_registry import TaskRegistry + from adcp.reporting.outbox.activity import ReportingActivityProjector + from adcp.reporting.outbox.support import ReportingActivitySupport from adcp.signing.brand_authz import BrandAuthorizationResolver from adcp.webhook_sender import WebhookSender from adcp.webhook_supervisor import WebhookDeliverySupervisor @@ -100,6 +102,8 @@ def create_adcp_server_from_platform( advertise_all: bool = False, validate_at_init: bool = True, adcp_version: str | None = None, + account_activity: ReportingActivityProjector | None = None, + reporting_activity: ReportingActivitySupport | None = None, ) -> tuple[PlatformHandler, ThreadPoolExecutor, TaskRegistry]: """Build the :class:`PlatformHandler` + supporting wiring from a :class:`DecisioningPlatform`. @@ -234,6 +238,11 @@ def create_adcp_server_from_platform( :func:`validate_idempotency_wiring`) are synchronous-pure and always run; this flag only gates the capabilities-response check. See #700. + :param account_activity: Optional reporting activity projector applied after + legacy account visibility/filtering. Only requested activity is read. + :param reporting_activity: Optional concrete durable reporting worker/store + chain. Truthy activity capability claims require this validated mount; + account activity additionally requires ``account_activity``. To wire a :class:`ProposalManager` (v1 two-platform composition), pass it on a :class:`PlatformRouter` via @@ -395,6 +404,8 @@ def create_adcp_server_from_platform( advertise_all=advertise_all, timed_sync_get_products_limit=resolved_timed_sync_limit, adcp_version=resolved_adcp_version, + account_activity=account_activity, + reporting_activity=reporting_activity, ) # Boot-time fail-fast: property_list_filtering declared but no fetcher wired. @@ -491,6 +502,8 @@ def serve( pre_validation_hooks: dict[str, Any] | None = None, validate_at_init: bool = True, adcp_version: str | None = None, + account_activity: ReportingActivityProjector | None = None, + reporting_activity: ReportingActivitySupport | None = None, **serve_kwargs: Any, ) -> None: """One-call wrapper — build the handler and serve over MCP. @@ -614,6 +627,8 @@ def serve( advertise_all=advertise_all, validate_at_init=validate_at_init, adcp_version=adcp_version, + account_activity=account_activity, + reporting_activity=reporting_activity, ) # Phase 1 sandbox-authority — wire the comply controller's account diff --git a/src/adcp/reporting/ledger/pg.py b/src/adcp/reporting/ledger/pg.py index 02f49d4f5..82fa64e25 100644 --- a/src/adcp/reporting/ledger/pg.py +++ b/src/adcp/reporting/ledger/pg.py @@ -147,6 +147,7 @@ _CURRENCY_DDL_PATH = Path(__file__).parent / "reporting_ledger_obligation_currency.sql" _RECONCILIATION_DDL_PATH = Path(__file__).parent / "reporting_ledger_reconciliation.sql" _NOTIFICATIONS_DDL_PATH = Path(__file__).parent / "reporting_notification_outbox.sql" +_ACTIVITY_DDL_PATH = Path(__file__).parent / "reporting_webhook_activity.sql" __all__ = ["PG_AVAILABLE", "PgReportingLedgerStore"] @@ -201,6 +202,7 @@ async def create_schema(self) -> None: await connection.execute(_CURRENCY_DDL_PATH.read_text()) await connection.execute(_RECONCILIATION_DDL_PATH.read_text()) await connection.execute(_NOTIFICATIONS_DDL_PATH.read_text()) + await connection.execute(_ACTIVITY_DDL_PATH.read_text()) if self._notifications_enabled: from adcp.reporting.outbox._schema import validate_schema diff --git a/src/adcp/reporting/ledger/reporting_webhook_activity.sql b/src/adcp/reporting/ledger/reporting_webhook_activity.sql new file mode 100644 index 000000000..8cb6b2c58 --- /dev/null +++ b/src/adcp/reporting/ledger/reporting_webhook_activity.sql @@ -0,0 +1,140 @@ +-- #1168B: additive objects only. An unmodified #1168A binary can keep working. +-- No foreign key/index/trigger is added to an A table, and no evidence is backfilled. +DO $activity$ +BEGIN + PERFORM pg_advisory_xact_lock(hashtext('adcp.reporting.schema'), hashtext(current_schema())); + PERFORM account_id, principal_id, body_sha256 FROM reporting_notification_deliveries LIMIT 0; + + CREATE TABLE IF NOT EXISTS reporting_webhook_attempt_heads ( + account_id TEXT COLLATE "C" NOT NULL, + principal_id TEXT COLLATE "C" NOT NULL CHECK (length(principal_id) > 0), + subscriber_id TEXT COLLATE "C" NOT NULL, + idempotency_key TEXT COLLATE "C" NOT NULL, + last_attempt BIGINT NOT NULL CHECK (last_attempt > 0), + PRIMARY KEY (account_id, principal_id, subscriber_id, idempotency_key) + ); + CREATE TABLE IF NOT EXISTS reporting_webhook_attempts ( + account_id TEXT COLLATE "C" NOT NULL, + principal_id TEXT COLLATE "C" NOT NULL, + subscriber_id TEXT COLLATE "C" NOT NULL, + notification_id TEXT COLLATE "C" NOT NULL, + idempotency_key TEXT COLLATE "C" NOT NULL, + attempt BIGINT NOT NULL CHECK (attempt > 0), + delivery_id TEXT COLLATE "C" NOT NULL, + consumer_namespace TEXT COLLATE "C" NOT NULL, + lease_token TEXT NOT NULL, + reservation_token TEXT NOT NULL, + binding JSONB NOT NULL, + fired_at TIMESTAMPTZ NOT NULL, + url TEXT NOT NULL CONSTRAINT reporting_webhook_url_safe + CHECK (length(url) <= 8192 AND url !~ '[?#@]' AND url ~ '^https?://'), + payload_size_bytes BIGINT NOT NULL CHECK (payload_size_bytes >= 0), + status TEXT NOT NULL DEFAULT 'pending' CHECK + (status IN ('pending', 'success', 'failed', 'timeout', 'connection_error')), + completed_at TIMESTAMPTZ, + http_status_code INTEGER, + response_time_ms BIGINT CHECK (response_time_ms >= 0), + PRIMARY KEY (account_id, principal_id, subscriber_id, idempotency_key, attempt), + UNIQUE (account_id, principal_id, delivery_id, lease_token), + FOREIGN KEY (account_id, principal_id, subscriber_id, idempotency_key) + REFERENCES reporting_webhook_attempt_heads + (account_id, principal_id, subscriber_id, idempotency_key), + CONSTRAINT reporting_webhook_identity CHECK ( + (binding->>'account_id') IS NOT DISTINCT FROM account_id AND + (binding->>'principal_id') IS NOT DISTINCT FROM principal_id AND + (binding->>'subscriber_id') IS NOT DISTINCT FROM subscriber_id AND + (binding->>'notification_id') IS NOT DISTINCT FROM notification_id AND + (binding->>'idempotency_key') IS NOT DISTINCT FROM idempotency_key AND + (binding->>'delivery_id') IS NOT DISTINCT FROM delivery_id AND + (binding->>'consumer_namespace') IS NOT DISTINCT FROM consumer_namespace), + CONSTRAINT reporting_webhook_completion CHECK ((status = 'pending') = (completed_at IS NULL)), + CONSTRAINT reporting_webhook_timestamps CHECK (completed_at >= fired_at), + CONSTRAINT reporting_webhook_outcome CHECK ( + (status IN ('pending', 'timeout', 'connection_error') + AND http_status_code IS NULL AND response_time_ms IS NULL) + OR (status IN ('success', 'failed') AND http_status_code BETWEEN 100 AND 599 + AND http_status_code IS NOT NULL AND response_time_ms IS NOT NULL + AND ((status = 'success') = (http_status_code BETWEEN 200 AND 299)))) + ); + CREATE INDEX IF NOT EXISTS reporting_webhook_activity_newest ON reporting_webhook_attempts + (account_id, principal_id, fired_at DESC, notification_id DESC, + idempotency_key DESC, subscriber_id DESC, attempt DESC, delivery_id DESC); + CREATE INDEX IF NOT EXISTS reporting_webhook_activity_retention ON reporting_webhook_attempts + (account_id, principal_id, completed_at) WHERE completed_at IS NOT NULL; + + -- Retention never resets a logical delivery's sequence, even after all + -- terminal attempts are purged. Writers always lock parent, then head. + CREATE OR REPLACE FUNCTION reporting_webhook_head_guard() + RETURNS TRIGGER LANGUAGE plpgsql AS $head_guard$ + BEGIN + IF TG_OP = 'DELETE' OR + (TG_OP = 'INSERT' AND NEW.last_attempt <> 1) OR + (TG_OP = 'UPDATE' AND (NEW.last_attempt <> OLD.last_attempt + 1 OR + (to_jsonb(NEW) - 'last_attempt') <> (to_jsonb(OLD) - 'last_attempt'))) THEN + RAISE EXCEPTION 'reporting activity counter must advance and be retained' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $head_guard$; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = 'reporting_webhook_attempt_heads'::regclass + AND tgname = 'reporting_webhook_head_guard' AND NOT tgisinternal) THEN + CREATE TRIGGER reporting_webhook_head_guard BEFORE INSERT OR UPDATE OR DELETE + ON reporting_webhook_attempt_heads FOR EACH ROW EXECUTE FUNCTION reporting_webhook_head_guard(); + END IF; + + CREATE OR REPLACE FUNCTION reporting_webhook_attempt_guard() + RETURNS TRIGGER LANGUAGE plpgsql AS $guard$ + DECLARE + delivery reporting_notification_deliveries; + BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD.completed_at IS NULL THEN + RAISE EXCEPTION 'pending reporting activity must be retained' USING ERRCODE = '23514'; + END IF; + RETURN OLD; + END IF; + IF TG_OP = 'UPDATE' AND ( + OLD.status <> 'pending' OR NEW.status = 'pending' OR + (to_jsonb(NEW) - ARRAY['status','completed_at','http_status_code','response_time_ms']) <> + (to_jsonb(OLD) - ARRAY['status','completed_at','http_status_code','response_time_ms'])) THEN + RAISE EXCEPTION 'immutable reporting activity reservation' USING ERRCODE = '23514'; + END IF; + -- The opaque reservation token fences terminalization in the UPDATE + -- predicate. A known late response may finish its own reservation after + -- lease expiry/reclaim; it must not require or modify the parent lease. + IF TG_OP = 'UPDATE' THEN + RETURN NEW; + END IF; + IF TG_OP = 'INSERT' AND NEW.status <> 'pending' THEN + RAISE EXCEPTION 'reporting activity requires reservation' USING ERRCODE = '23514'; + END IF; + SELECT * INTO delivery FROM reporting_notification_deliveries + WHERE account_id = NEW.account_id AND principal_id = NEW.principal_id + AND consumer_namespace = NEW.consumer_namespace AND delivery_id = NEW.delivery_id + AND subscriber_id = NEW.subscriber_id AND notification_id = NEW.notification_id + AND idempotency_key = NEW.idempotency_key + AND state = 'leased' AND lease_token = NEW.lease_token + AND lease_expires_at > coalesce(NEW.completed_at, NEW.fired_at) FOR UPDATE; + IF NOT FOUND OR + NEW.binding->>'account_id' IS DISTINCT FROM NEW.account_id OR + NEW.binding->>'principal_id' IS DISTINCT FROM NEW.principal_id OR + NEW.binding->>'subscriber_id' IS DISTINCT FROM NEW.subscriber_id OR + NEW.binding->>'notification_id' IS DISTINCT FROM NEW.notification_id OR + NEW.binding->>'idempotency_key' IS DISTINCT FROM NEW.idempotency_key OR + NEW.binding->>'delivery_id' IS DISTINCT FROM NEW.delivery_id OR + NEW.binding->>'consumer_namespace' IS DISTINCT FROM NEW.consumer_namespace OR + NEW.binding->>'notification_type' IS DISTINCT FROM delivery.notification_type OR + NEW.binding->>'body_sha256' IS DISTINCT FROM delivery.body_sha256 THEN + RAISE EXCEPTION 'reporting activity lease or identity mismatch' USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $guard$; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = 'reporting_webhook_attempts'::regclass + AND tgname = 'reporting_webhook_attempt_guard' AND NOT tgisinternal) THEN + CREATE TRIGGER reporting_webhook_attempt_guard BEFORE INSERT OR UPDATE OR DELETE + ON reporting_webhook_attempts FOR EACH ROW EXECUTE FUNCTION reporting_webhook_attempt_guard(); + END IF; +END; +$activity$; diff --git a/src/adcp/reporting/outbox/__init__.py b/src/adcp/reporting/outbox/__init__.py index 290dccef6..41d2037de 100644 --- a/src/adcp/reporting/outbox/__init__.py +++ b/src/adcp/reporting/outbox/__init__.py @@ -13,6 +13,15 @@ RevisionPublished, validate_notification_payload, ) +from adcp.reporting.outbox.activity import ( + ActivityOutcome, + ActivityRequest, + ReportingActivityProjector, + ReportingActivityStore, + WebhookAttempt, + sanitize_activity_url, +) +from adcp.reporting.outbox.identity import resolve_reporting_consumer from adcp.reporting.outbox.memory import InMemoryReportingOutbox from adcp.reporting.outbox.models import ( DeliveryBinding, @@ -30,12 +39,21 @@ ReportingSigningResolver, ReportingSubscriptionResolver, ) +from adcp.reporting.outbox.support import ReportingActivitySupport from adcp.reporting.outbox.worker import ReportingNotificationWorker if TYPE_CHECKING: from adcp.reporting.outbox.pg import PgReportingOutbox __all__ = [ + "ActivityOutcome", + "ActivityRequest", + "ReportingActivityProjector", + "ReportingActivityStore", + "ReportingActivitySupport", + "WebhookAttempt", + "resolve_reporting_consumer", + "sanitize_activity_url", "AdjustmentPublished", "DeliveryBinding", "DeliveryLease", diff --git a/src/adcp/reporting/outbox/_activity_pg.py b/src/adcp/reporting/outbox/_activity_pg.py new file mode 100644 index 000000000..827ae0f8f --- /dev/null +++ b/src/adcp/reporting/outbox/_activity_pg.py @@ -0,0 +1,238 @@ +"""SQL activity participant for PgReportingOutbox, with no independent queue.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import asdict +from datetime import datetime +from secrets import token_hex +from typing import TYPE_CHECKING, Any + +from adcp.reporting.ledger.notification_models import ReportingNotificationError +from adcp.reporting.outbox.activity import ( + ActivityOutcome, + ActivityRequest, + WebhookAttempt, + activity_limit, + retention_cutoff, +) +from adcp.reporting.outbox.identity import canonical_consumer +from adcp.reporting.outbox.models import DeliveryBinding, DeliveryLease + +if TYPE_CHECKING: + from psycopg_pool import AsyncConnectionPool + +_ACTIVITY_COLUMNS = ( + "binding, attempt, lease_token, reservation_token, fired_at, url, payload_size_bytes," + " status, completed_at, http_status_code, response_time_ms" +) + + +def _attempt(row: Any) -> WebhookAttempt: + try: + return WebhookAttempt( + DeliveryBinding(**row[0]), + row[1], + row[2], + row[3], + row[4], + ActivityRequest(row[5], row[6]), + ActivityOutcome(row[7], row[9], row[10]) if row[7] != "pending" else None, + row[8], + ) + except (TypeError, ValueError, KeyError): + raise ReportingNotificationError("invalid_activity_record") from None + + +class _PgReportingActivity: + _pool: AsyncConnectionPool + _clock: Callable[[], datetime] | None + + @asynccontextmanager + async def _activity_transaction(self) -> AsyncIterator[Any]: + try: + async with self._pool.connection() as conn, conn.transaction(): + yield conn + except ReportingNotificationError: + raise + except Exception: + # psycopg diagnostics may include statements, parameters, provider + # strings, and connection URLs. None belong in activity failures. + raise ReportingNotificationError("activity_store_unavailable") from None + + async def _activity_fence(self, conn: Any, lease: DeliveryLease) -> datetime | None: + from adcp.reporting.outbox.pg import _DELIVERY_COLUMNS, _delivery, database_now + + b = lease.delivery.binding + # The projection is an SDK constant; every selector is a bound value. + row = await ( + await conn.execute( + f"SELECT {_DELIVERY_COLUMNS}, lease_expires_at" # nosec B608 + " FROM reporting_notification_deliveries" + " WHERE account_id = %s AND principal_id = %s AND consumer_namespace = %s" + " AND delivery_id = %s AND subscriber_id = %s AND notification_id = %s" + " AND idempotency_key = %s AND state = 'leased' AND lease_token = %s FOR UPDATE", + ( + b.account_id, + b.principal_id, + b.consumer_namespace, + b.delivery_id, + b.subscriber_id, + b.notification_id, + b.idempotency_key, + lease.token, + ), + ) + ).fetchone() + # Take database time AFTER acquiring the row lock, never before waiting. + at = await database_now(conn, self._clock) + if ( + row is None + or _delivery(row) != lease.delivery + or row[-1] != lease.expires_at + or row[-1] <= at + ): + return None + return at + + async def reserve_attempt( + self, lease: DeliveryLease, *, request: ActivityRequest, now: datetime + ) -> WebhookAttempt | None: + from adcp.reporting.outbox.pg import database_now + + b = lease.delivery.binding + consumer = canonical_consumer(b.principal_id) + request = ActivityRequest(request.url, request.payload_size_bytes) + key = (b.account_id, consumer, b.subscriber_id, b.idempotency_key) + async with self._activity_transaction() as conn: + if await self._activity_fence(conn, lease) is None: + return None + duplicate = await ( + await conn.execute( + "SELECT 1 FROM reporting_webhook_attempts WHERE account_id = %s" + " AND principal_id = %s AND delivery_id = %s AND lease_token = %s", + (b.account_id, consumer, b.delivery_id, lease.token), + ) + ).fetchone() + if duplicate is not None: + return None + row = await ( + await conn.execute( + "INSERT INTO reporting_webhook_attempt_heads (account_id, principal_id," + " subscriber_id, idempotency_key, last_attempt)" + " VALUES (%s,%s,%s,%s,1) ON CONFLICT" + " (account_id, principal_id, subscriber_id, idempotency_key)" + " DO UPDATE SET last_attempt = reporting_webhook_attempt_heads.last_attempt + 1" + " WHERE reporting_webhook_attempt_heads.account_id = %s" + " AND reporting_webhook_attempt_heads.principal_id = %s RETURNING last_attempt", + (*key, b.account_id, consumer), + ) + ).fetchone() + assert row is not None + at = await database_now(conn, self._clock) + # The row stays locked from the fence through this commit. A later + # reclaim can never mutate this reservation, including after purge. + if at >= lease.expires_at: + # Roll back the increment as well; no reservation means no HTTP. + raise ReportingNotificationError("activity_lease_expired") + reservation = token_hex(32) + await conn.execute( + "INSERT INTO reporting_webhook_attempts (account_id, principal_id, subscriber_id," + " idempotency_key, notification_id, attempt, delivery_id, consumer_namespace," + " lease_token, reservation_token, binding, fired_at, url, payload_size_bytes)" + " VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s)", + ( + *key, + b.notification_id, + row[0], + b.delivery_id, + b.consumer_namespace, + lease.token, + reservation, + json.dumps(asdict(b)), + at, + request.url, + request.payload_size_bytes, + ), + ) + return WebhookAttempt(b, row[0], lease.token, reservation, at, request) + + async def complete_attempt( + self, attempt: WebhookAttempt, *, outcome: ActivityOutcome, now: datetime + ) -> bool: + from adcp.reporting.outbox.pg import database_now + + ActivityOutcome.__post_init__(outcome) + b = attempt.binding + consumer = canonical_consumer(b.principal_id) + if attempt.outcome is not None or attempt.completed_at is not None: + return False + async with self._activity_transaction() as conn: + at = await database_now(conn, self._clock) + if at < attempt.fired_at: + return False + cursor = await conn.execute( + "UPDATE reporting_webhook_attempts SET status = %s, completed_at = %s," + " http_status_code = %s, response_time_ms = %s" + " WHERE account_id = %s AND principal_id = %s AND subscriber_id = %s" + " AND notification_id = %s AND idempotency_key = %s AND attempt = %s" + " AND delivery_id = %s AND consumer_namespace = %s AND reservation_token = %s" + " AND status = 'pending' AND fired_at = %s AND url = %s" + " AND payload_size_bytes = %s AND lease_token = %s AND binding = %s::jsonb", + ( + outcome.status, + at, + outcome.http_status_code, + outcome.response_time_ms, + b.account_id, + consumer, + b.subscriber_id, + b.notification_id, + b.idempotency_key, + attempt.attempt, + b.delivery_id, + b.consumer_namespace, + attempt.reservation_token, + attempt.fired_at, + attempt.request.url, + attempt.request.payload_size_bytes, + attempt.lease_token, + json.dumps(asdict(b)), + ), + ) + return bool(cursor.rowcount) + + async def list_activity( + self, *, account_id: str, consumer_id: str, limit: int = 50 + ) -> tuple[WebhookAttempt, ...]: + consumer_id, limit = canonical_consumer(consumer_id), activity_limit(limit) + async with self._activity_transaction() as conn: + # Only the SDK-owned column list is interpolated, never tenant input. + rows = await ( + await conn.execute( + f"SELECT {_ACTIVITY_COLUMNS} FROM reporting_webhook_attempts" # nosec B608 + " WHERE account_id = %s AND principal_id = %s ORDER BY fired_at DESC," + " notification_id DESC, idempotency_key DESC, subscriber_id DESC, attempt DESC," + " delivery_id DESC LIMIT %s", + (account_id, consumer_id, limit), + ) + ).fetchall() + return tuple(_attempt(row) for row in rows) + + async def purge_activity( + self, *, account_id: str, consumer_id: str, now: datetime, retention_days: int = 30 + ) -> int: + from adcp.reporting.outbox.pg import database_now + + consumer_id = canonical_consumer(consumer_id) + retention_cutoff(now, retention_days) + async with self._activity_transaction() as conn: + cutoff = retention_cutoff(await database_now(conn, self._clock), retention_days) + cursor = await conn.execute( + "DELETE FROM reporting_webhook_attempts WHERE account_id = %s AND principal_id = %s" + " AND completed_at IS NOT NULL AND completed_at < %s", + (account_id, consumer_id, cutoff), + ) + return int(cursor.rowcount) diff --git a/src/adcp/reporting/outbox/_capabilities.py b/src/adcp/reporting/outbox/_capabilities.py index f5d972df1..9779d53af 100644 --- a/src/adcp/reporting/outbox/_capabilities.py +++ b/src/adcp/reporting/outbox/_capabilities.py @@ -12,6 +12,7 @@ from adcp.reporting.outbox.routing import ReportingEnvelopeCipher, ReportingNotificationSubscription if TYPE_CHECKING: + from adcp.reporting.outbox.activity import ReportingActivityProjector from adcp.reporting.outbox.worker import ReportingNotificationWorker @@ -21,6 +22,7 @@ async def advertised_notifications( *, account_id: str, ready_scope: ReportingDeliveryScope | None, + activity_projector: ReportingActivityProjector | None = None, ) -> dict[str, str | bool]: from adcp.reporting.outbox.worker import ReportingNotificationWorker @@ -83,6 +85,12 @@ async def advertised_notifications( "ledger_notification": "reporting.ledger_changed", "supports_webhook_activity": False, } + if activity_projector is not None: + from adcp.reporting.outbox.support import ReportingActivitySupport + + result["supports_webhook_activity"] = await ReportingActivitySupport( + worker, ledger, activity_projector + ).durable() if ready_scope is not None: if ready_scope.principal.account_id != account_id: raise ReportingNotificationError("notification_chain_unready") diff --git a/src/adcp/reporting/outbox/_schema.py b/src/adcp/reporting/outbox/_schema.py index a6dec0077..a130b89db 100644 --- a/src/adcp/reporting/outbox/_schema.py +++ b/src/adcp/reporting/outbox/_schema.py @@ -1,412 +1,134 @@ -"""Read-only verification of the installed transactional reporting chain. +"""Read-only required-object validation; unrelated adopter DDL is permitted. -The contract covers column types/nullability/defaults, validated constraints, -usable indexes, enabled triggers, and the actual guard function definitions. -Presence of tables, a version marker, or a caller-supplied capability is not a -readiness check. Catalog reads are schema-scoped; they read no tenant records. +The manifest is generated from a clean, packaged migration chain. Each required +column, constraint, index, trigger, and guard function is checked independently. +Diagnostics contain only a static classification and a bundled object name. +Catalog queries are schema-scoped and never inspect tenant rows. """ from __future__ import annotations import hashlib import json +from importlib.resources import files from typing import Any from adcp.reporting.ledger.notification_models import ReportingNotificationError -# Generated from the bundled five-step chain, including restatement checkpoints. -# Deliberate schema changes must update this contract and exercise both fresh -# and populated upgrade paths. +REQUIRED_OBJECTS: dict[str, dict[str, Any]] = json.loads( + files("adcp.reporting.outbox").joinpath("required_schema.json").read_text() +) SCHEMA_CONTRACT: dict[str, str] = { - "function:reporting_adjustment_evidence_immutable()": ( - "6435361e1fcbbf1926ee1e11f13897fb" "9a660ccc5e5f4bbe93114077c1bea636" - ), - "function:reporting_canonical_evidence_immutable()": ( - "7c62d73989b1ddcff1340e4f2108bcb8" "60bd09c54659094260aff1ee3c8af335" - ), - "function:reporting_canonical_json(document jsonb)": ( - "d50759bae48ef38808360d4a59f55978" "0eb1d1b934c2ded883cb7e918159c3e9" - ), - "function:reporting_identity_sha256(value text)": ( - "93224ff42ac26d1550e63a3726227f7e" "2ba55fea2a3b5af8f6f6879f649432f5" - ), - "function:reporting_iso_utc(moment timestamp with time zone)": ( - "af73b04da507f3d5d8fc0994aa693a7e" "621fb7e339f380e309beb5af10c60058" - ), - "function:reporting_notification_immutable()": ( - "8bfe540ace33195ad5588d04e869a870" "3fa162501aef6e955b897f1cc9413d14" - ), - "function:reporting_obligation_currency_immutable()": ( - "03e654a4967f2e8d174c1530d49de312" "540891baf061a341669bfc15112496e9" - ), - "function:reporting_payload_keys(document jsonb)": ( - "48b1d45bddd9a43affd0b63ad0eb8874" "73849985d82ded01a6a5cae6d108b476" - ), - "function:reporting_payload_sha256(document jsonb)": ( - "b08900f152a3c21c0ba84e5f89826b9f" "29967837d5277ade6ccc8de0e84eaa0d" - ), - "function:reporting_receipt_advance()": ( - "9869d9d96df91934b82f83a7c6f1f1ca" "3e6139358424646700e1f42d2c1ab6d7" - ), - "function:reporting_receipt_head_exact()": ( - "7f8887f5ea1a5a938fb8dd789398adc8" "8461927cda4b80b3b7196d5d665d88cd" - ), - "function:reporting_receipt_terminal()": ( - "bff86010f9ac57af1eee22a78a7ab461" "6ed34185a06431f3bf0b138ea2bc4386" - ), - "function:reporting_reconciliation_change_guard()": ( - "5844dc57d056f89d74ecd938f41674cd" "1994cbf90132ba567f5953d3a9aaacef" - ), - "function:reporting_reconciliation_evidence(r reporting_reconciliation_records)": ( - "60e89af5cb7a88aae39da7875aa0bb7e" "daa361cc3fbd5d2ebb9c0e0009aa48ea" - ), - "function:reporting_reconciliation_guard()": ( - "a7624ca621e3c95932cd23f5b6683af1" "16463984439e567ed3fbbe64969b7da3" - ), - "function:reporting_reconciliation_head_guard()": ( - "a92fd0d4b284d1a75aab0d2afb9e3ea7" "0829118d3d970427482ac5f5a75ce7c1" - ), - "function:reporting_reconciliation_immutable()": ( - "cb1b0e47e2848ae79292f539f4c3fe85" "8b651db28434bdd468596b36cd08d9cb" - ), - "function:reporting_reconciliation_reference_immutable()": ( - "6461682d3ebaceb7ccd795f1d87c524a" "02f8d1f1487dd182e23779b44dae4ddf" - ), - "function:reporting_reconciliation_validate(r reporting_reconciliation_records)": ( - "309164066f02cc4d0f3ff66df47cf5c4" "ddddcc69525436634f94845e0d2864ed" - ), - "function:reporting_sorted_strings(document jsonb)": ( - "9c5ec19205737071bd5f9fe79563397b" "63ee5105cb190a4d2db01dc414691736" - ), - "function:reporting_sorted_totals(document jsonb)": ( - "d29826abcc96b3de6a27851d088376e8" "f9925d59d785c05fecec0bf6dbdc721c" - ), - "function:reporting_wire_digest(document jsonb)": ( - "eba0b3444ee72fbf3d5c467c6ca2c68c" "2dd057b82e4dfd24ae10688a1e0e53af" - ), - "reporting_adjustments:columns": ( - "21f185134755dc64bf18f6650712ae1d" "06405e92e26a75913ed26ee239cfa37a" - ), - "reporting_adjustments:constraints": ( - "603d958337b456b6130c75cee2c6b9e5" "114a40fa53f1f59be86c421215a0a5af" - ), - "reporting_adjustments:indexes": ( - "bb16320d04f5cc3c1d36ee1e1a1d63ab" "e4a3901f9e9ad0505104f4b7dd8983b4" - ), - "reporting_adjustments:triggers": ( - "307588c8df362a216195f22724c5b29e" "9751c687cc36c9990bba82979a1889e7" - ), - "reporting_configurations:columns": ( - "e8f6dd2f4d6b8cc90e578dbaef272c46" "57c231db0a6a1989db3a5f2ab89a7326" - ), - "reporting_configurations:constraints": ( - "8fe2605ba4fdf546e2ae09dad3c8f506" "96be067933dfa8ef1601633693edd6a9" - ), - "reporting_configurations:indexes": ( - "0691ad10621df08ea0876d1a705c2d43" "7dc06a21350f64b481056f1e70d3d13f" - ), - "reporting_configurations:triggers": ( - "a089938618c994c2c0a6d12993bb8078" "07d3f8eda1874d540e2f8f95fcb04a5d" - ), - "reporting_consumer_statuses:columns": ( - "ec88098deb17a31cb3d84c7b4d50c07a" "c81f32f3780f20532ca9bef2845ce2e0" - ), - "reporting_consumer_statuses:constraints": ( - "45afdf7bcf4cce7974ab6b0714c036fb" "c9d1a01d0bcb33052fcf74d8d098b5eb" - ), - "reporting_consumer_statuses:indexes": ( - "d9b2b4ef81d2809cafa3da5aec6a92dc" "8e0c6fb11ada4ab59fefe9a363dbdb0b" - ), - "reporting_consumer_statuses:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_issue_lifecycle:columns": ( - "1291ebaa7ee1fd72bfabe1a72d6a10fc" "3bc0a7429ce2efe48e3974901c550d1b" - ), - "reporting_issue_lifecycle:constraints": ( - "60f4b8b3c39bc0f413f32541c2eb3ebc" "2bd0978d97ad2dab22fa57582b537c74" - ), - "reporting_issue_lifecycle:indexes": ( - "f8869b28a542094006b97cc10ac4927c" "888e5744ba5035fa9c124f94b0cad9eb" - ), - "reporting_issue_lifecycle:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_issue_status_scopes:columns": ( - "8af4b6a4cf02f0beb52988a124cd0d44" "dd00170bfb27ddf8840aa181f5e35379" - ), - "reporting_issue_status_scopes:constraints": ( - "7be693d1c5e228f0b61bd2aca8470328" "7478623259546a0dda0ccc2df2071590" - ), - "reporting_issue_status_scopes:indexes": ( - "239b1b0172b2335f81b32d5b1cf72fc3" "f04cf4c30a0ec40b66ae87e9f35343ed" - ), - "reporting_issue_status_scopes:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_ledger_changes:columns": ( - "d6f6a70deebd30f0a0f3cfac3d6e9581" "1b2d60a4204d2949b2abe898e432cd1f" - ), - "reporting_ledger_changes:constraints": ( - "0f56059184f30d8e32e6323015b0ca5b" "19cb7f4865c73ca3bb2bd4459005de91" - ), - "reporting_ledger_changes:indexes": ( - "04708d175375319a7fdad3385b5fd944" "30b64cd55ef9d79b7d01dec8401faa88" - ), - "reporting_ledger_changes:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_notification_deliveries:columns": ( - "d96c42f82f3092c8cdee3bfd5712cc49" "34efffcf0d8d4e0c5a32ef8782cc3fa8" - ), - "reporting_notification_deliveries:constraints": ( - "9934d10675a54cc3cb4bf9dfde0b083c" "9bdc2df2c71f6d94d75a8629a8ef1240" - ), - "reporting_notification_deliveries:indexes": ( - "b393111322b0cb8ab8ea5a75078411e5" "4ce87a8770ecb5509ddcc92c36289fe9" - ), - "reporting_notification_deliveries:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_notification_events:columns": ( - "7ca4437ca676ca4abeb9ee97e17277f1" "0d7ec0f5cdab2d4d64bd7ea75042f2b7" - ), - "reporting_notification_events:constraints": ( - "043076e5170a06867999db8a1c8d1589" "56be50116ec96647be88ea1a613d03b8" - ), - "reporting_notification_events:indexes": ( - "0f014658961d9cc532c4293b63ca62f7" "2fea2d377d0614568f5eab358bfea8fa" - ), - "reporting_notification_events:triggers": ( - "2e8fabd912e2ab93f4c2f888b7a75000" "1ef85e3c5779f0cb21ce4b2a0fc583fd" - ), - "reporting_notification_expansions:columns": ( - "716e73267e27ea1122e48821d996df76" "739f47e7560bb23648dab5efb2483577" - ), - "reporting_notification_expansions:constraints": ( - "d3256a3b17badf25b73f9ff62cf7c883" "fbdaa8915f11cf5e2d6e3a875bcda7c0" - ), - "reporting_notification_expansions:indexes": ( - "d8312996f9b5dc4c060ce308bb076a8a" "8aee45dc95d55f38f7475dc677868060" - ), - "reporting_notification_expansions:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_obligations:columns": ( - "ed51fd558b29f46b11fa84abb0125af1" "be0d8ec3020df0bee4955b85f354fc75" - ), - "reporting_obligations:constraints": ( - "38594ba5b18657441707ac036d1b3a00" "93a41d4759acf98269e9bab44bda2940" - ), - "reporting_obligations:indexes": ( - "1c4f8f60d5a93eae9a7017640d958737" "220c7d57a3e9ef7ce1ce8fcd68b22869" - ), - "reporting_obligations:triggers": ( - "497b6f5ef2514960254fc0975bfe5cb6" "36e529634eb9baae027d14ec4cb0353e" - ), - "reporting_receipt_heads:columns": ( - "df430b84134c9d75645eacf3c78d5794" "ffabfae82a291970e35f16c5ce029d53" - ), - "reporting_receipt_heads:constraints": ( - "c6913cfe5697563e9477ce428ce672fc" "ef50c9255ac950b311c6cb4220349876" - ), - "reporting_receipt_heads:indexes": ( - "7e2484d7965549de6455c430af370cca" "1d9d645f3ab725c9da140aeab8d43d1b" - ), - "reporting_receipt_heads:triggers": ( - "f15506c66bc42c0df42a30e82a3d0bdc" "d739d50e7322c60574cb6bf8bf46ab25" - ), - "reporting_reconciliation_changes:columns": ( - "1b74719a83f904f551236a99149e4cc0" "3801b13a21bc0d20eee277c9a159b642" - ), - "reporting_reconciliation_changes:constraints": ( - "796834b9cebe998250662fcfb69a8d1c" "a66e759231ba4589fe35a813c7978044" - ), - "reporting_reconciliation_changes:indexes": ( - "c16f715b3cafbd731fd2564380c2bce7" "a8800fc72fdf8792bd8f569b36d93125" - ), - "reporting_reconciliation_changes:triggers": ( - "9cfc23afa441c2cd92fb7f653d86fe42" "e6b421a125cc654fbf910ef44e860622" - ), - "reporting_reconciliation_heads:columns": ( - "2fee5528f8f85bd6d40cc4b458a78c46" "de462d460a115c7ee9856f03846e7d2f" - ), - "reporting_reconciliation_heads:constraints": ( - "e0b0d4037ec594e3ca0c533d79504ac6" "71a29a6b5e334b8df5511beaea0a8ada" - ), - "reporting_reconciliation_heads:indexes": ( - "231d8a63c10130aaf95cbd801c68335e" "060dbd38d6c4e0c465cdb7170c56f635" - ), - "reporting_reconciliation_heads:triggers": ( - "571348e9aa132f91ba2452b4d1cf8205" "952f3075ce8e6320e976445a471385e8" - ), - "reporting_reconciliation_records:columns": ( - "a7ec44cc41ad8cf17c9d1ffe8e9552f9" "dcda5fb434c95362d224a0e335db60aa" - ), - "reporting_reconciliation_records:constraints": ( - "184cda4ff4e6fd0d340c3238ad549068" "d7e07fe5628f030be0f9113a7bf694c7" - ), - "reporting_reconciliation_records:indexes": ( - "6beb345279c860a3d67bf55d00d9f723" "ab7bdd685b04c5d1f8e463f6ad29df98" - ), - "reporting_reconciliation_records:triggers": ( - "7fc917df860adedda86f1cbd68cccbf9" "f51b5d34d0eacb35c8bd645c8e230007" - ), - "reporting_restatement_checkpoints:columns": ( - "7b5173aa00d054200f90bcbf11c78433" "62a762501190f0e6e85bc406a8cd448f" - ), - "reporting_restatement_checkpoints:constraints": ( - "57182b0f5e1efde00e31cb86dc707522" "13ec9583863e82c015d7355677be4962" - ), - "reporting_restatement_checkpoints:indexes": ( - "a068d9f7e766c82e817be79025cac9b7" "657489a4592c82c79a317d6ceac97411" - ), - "reporting_restatement_checkpoints:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_revision_rows:columns": ( - "133e7beac71a52ea7c25a71b4f296297" "ecde85780eb6a28f4ff6d9847785861b" - ), - "reporting_revision_rows:constraints": ( - "829c1080b2db3344e8a67d30934a56b7" "6f934908148d662ea299c36cac1b40e2" - ), - "reporting_revision_rows:indexes": ( - "6aebb2613c50d6153b0edfe91db2a5b0" "7d5a3f8ddd90e3398c15b952c87de56b" - ), - "reporting_revision_rows:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_revisions:columns": ( - "d861397cc8fd6986a387740a8f4a6084" "69504715e967ec438ff7d6af1aaa9540" - ), - "reporting_revisions:constraints": ( - "b6ed0e9e662bdfa3c2369fe9537b64a2" "9126e62a0f34c2c332824c51447078cb" - ), - "reporting_revisions:indexes": ( - "26df1e37323d185f21ef652224c15445" "39d03eeef282137dc8fdf86406fc4af0" - ), - "reporting_revisions:triggers": ( - "ab2267a69931c6ea860822156376d227" "28fbc7304b4ac7f9193afb3141448e16" - ), - "reporting_status_checkpoints:columns": ( - "665a64ed1e0f1546c4e15dc60cc71ec3" "16ecb701ca87ea809263c3ad1499388b" - ), - "reporting_status_checkpoints:constraints": ( - "936a423446f30aa77a1a30e2629f5da8" "4b798934f24ef855d0ec40bc50089dff" - ), - "reporting_status_checkpoints:indexes": ( - "9827d3c92209487636d64391d156414b" "9a36cef0d9971a47613b3df1bf572d01" - ), - "reporting_status_checkpoints:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), - "reporting_status_dirty:columns": ( - "d93019d63daca890b024760abaf313d5" "3cce8be654f6951ac7c1d5c98be47f34" - ), - "reporting_status_dirty:constraints": ( - "a9623edb2feb4e3be913b9ee8eac1ae0" "5d301d0fedf997fc77cfb12adc76fe76" - ), - "reporting_status_dirty:indexes": ( - "b4542cf6d4be2a90969056d33ca6a61f" "9fdf2cd02be2f59a44913ec3e835bae2" - ), - "reporting_status_dirty:triggers": ( - "f8812273d8b9c664f5154737298c5837" "1e57e910b8a72a5b3b1ea73427414db9" - ), - "reporting_status_dirty_heads:columns": ( - "29981f0f8145ce9a2f07370fe19550ce" "076fcd8faf32d627453566f9998aa11b" - ), - "reporting_status_dirty_heads:constraints": ( - "a21b12facd96ab7c2031cf0b7e1a57ec" "112f60f369fa393680e87b6b6de9fa79" - ), - "reporting_status_dirty_heads:indexes": ( - "90afb31ce0eccf93a47bdcef2334dafb" "fe09a73fba9c55db977d67871a7221ae" - ), - "reporting_status_dirty_heads:triggers": ( - "4f53cda18c2baa0c0354bb5f9a3ecbe5" "ed12ab4d8e11ba873c2f11161202b945" - ), + key: value["fingerprint"] for key, value in REQUIRED_OBJECTS.items() } -async def schema_contract(connection: Any) -> dict[str, str]: +def _digest(value: object) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +async def schema_objects(connection: Any) -> dict[str, dict[str, Any]]: tables = await ( await connection.execute( - "SELECT c.oid, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace" - " WHERE n.nspname = current_schema() AND c.relkind = 'r'" - " AND starts_with(c.relname, 'reporting_') ORDER BY c.relname" + "SELECT c.oid, c.relname, c.relkind, c.relpersistence FROM pg_class c" + " JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema()" + " AND c.relkind IN ('r','p') AND starts_with(c.relname, 'reporting_')" + " ORDER BY c.relname" ) ).fetchall() - result: dict[str, str] = {} - for oid, name in tables: + result: dict[str, dict[str, Any]] = {} + + def remember(key: str, value: object, *, enabled: bool = True) -> None: + result[key] = {"fingerprint": _digest(value), "enabled": enabled} + + for oid, name, kind, persistence in tables: + remember(f"table:{name}", (kind, persistence)) columns = await ( await connection.execute( "SELECT a.attname, format_type(a.atttypid, a.atttypmod), a.attnotnull, co.collname," - " pg_get_expr(d.adbin, d.adrelid) FROM pg_attribute a" + " replace(pg_get_expr(d.adbin, d.adrelid)," + " quote_ident(current_schema()) || '.', '')," + " a.attidentity, a.attgenerated FROM pg_attribute a" " LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum" - " LEFT JOIN pg_collation co ON co.oid = a.attcollation" - " WHERE a.attrelid = %s AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attname", + " LEFT JOIN pg_collation co ON co.oid = a.attcollation WHERE a.attrelid = %s" + " AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attname", (oid,), ) ).fetchall() + for row in columns: + remember(f"column:{name}.{row[0]}", row[1:]) constraints = await ( await connection.execute( - "SELECT contype, pg_get_constraintdef(oid), convalidated FROM pg_constraint" - ' WHERE conrelid = %s ORDER BY contype, pg_get_constraintdef(oid) COLLATE "C"', + "SELECT conname, contype, replace(pg_get_constraintdef(oid)," + " quote_ident(current_schema()) || '.', '')," + " convalidated, condeferrable, condeferred" + " FROM pg_constraint WHERE conrelid = %s ORDER BY conname", (oid,), ) ).fetchall() + for row in constraints: + remember(f"constraint:{name}.{row[0]}", row[1:], enabled=bool(row[3])) indexes = await ( await connection.execute( - "SELECT i.indisunique, i.indisvalid, i.indisready," - " ARRAY(SELECT pg_get_indexdef(i.indexrelid, k, true)" - ' FROM generate_series(1, i.indnatts) k) COLLATE "C",' - ' pg_get_expr(i.indpred, i.indrelid) COLLATE "C"' - " FROM pg_index i WHERE i.indrelid = %s ORDER BY 1, 4, 5", + "SELECT c.relname, i.indisunique, i.indisvalid, i.indisready, i.indislive," + " replace(pg_get_indexdef(i.indexrelid), quote_ident(current_schema()) || '.', '')" + " FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid" + " WHERE i.indrelid = %s ORDER BY c.relname", (oid,), ) ).fetchall() + for row in indexes: + remember(f"index:{name}.{row[0]}", row[1:], enabled=all(row[2:5])) triggers = await ( await connection.execute( "SELECT tgname, tgenabled, replace(pg_get_triggerdef(oid)," - " quote_ident(current_schema()) || '.', '') FROM pg_trigger" - " WHERE tgrelid = %s AND NOT tgisinternal ORDER BY tgname", + " quote_ident(current_schema()) || '.', '') FROM pg_trigger WHERE tgrelid = %s" + " AND NOT tgisinternal ORDER BY tgname", (oid,), ) ).fetchall() - # Pin text and text-array sort keys to byte order before hashing rows; - # an adopter's database collation must not change the frozen contract. - # Keep a separate hash for each layer so diagnosis is local and static, - # without exposing DDL, tenant data, or arbitrary database diagnostics. - for kind, value in ( - ("columns", columns), - ("constraints", constraints), - ("indexes", indexes), - ("triggers", triggers), - ): - result[f"{name}:{kind}"] = _digest(value) + for row in triggers: + remember(f"trigger:{name}.{row[0]}", row[1:], enabled=row[1] != "D") functions = await ( await connection.execute( "SELECT p.proname, pg_get_function_identity_arguments(p.oid), p.prosrc," " l.lanname, p.provolatile, p.proisstrict, p.prosecdef, p.proconfig," - " pg_get_function_result(p.oid) FROM pg_proc p" + " pg_get_function_result(p.oid), p.proparallel, p.proleakproof FROM pg_proc p" " JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang" " WHERE n.nspname = current_schema() AND starts_with(p.proname, 'reporting_')" " ORDER BY p.proname, pg_get_function_identity_arguments(p.oid)" ) ).fetchall() for row in functions: - result[f"function:{row[0]}({row[1]})"] = _digest(row[2:]) + remember(f"function:{row[0]}({row[1]})", row[2:]) return result -def _digest(value: object) -> str: - return hashlib.sha256( - json.dumps(value, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() +async def schema_contract(connection: Any) -> dict[str, str]: + return {key: value["fingerprint"] for key, value in (await schema_objects(connection)).items()} -async def validate_schema(connection: Any) -> None: - installed = await schema_contract(connection) - if not SCHEMA_CONTRACT or any( - installed.get(key) != value for key, value in SCHEMA_CONTRACT.items() - ): - raise ReportingNotificationError("notification_schema_unready") +async def validate_schema(connection: Any, *, activity: bool = False) -> None: + try: + installed = await schema_objects(connection) + except Exception: + raise ReportingNotificationError( + "notification_schema_unready:catalog_unavailable" + ) from None + if not REQUIRED_OBJECTS: + raise ReportingNotificationError("notification_schema_unready:manifest_missing") + for key, expected in REQUIRED_OBJECTS.items(): + if not activity and "reporting_webhook_" in key: + continue + actual = installed.get(key) + if actual is None: + classification = "missing" + elif not actual["enabled"]: + classification = "disabled" + elif actual["fingerprint"] != expected["fingerprint"]: + classification = "changed" + else: + continue + raise ReportingNotificationError(f"notification_schema_unready:{classification}:{key}") diff --git a/src/adcp/reporting/outbox/_transport_logging.py b/src/adcp/reporting/outbox/_transport_logging.py index 7b86be784..ea5482ca6 100644 --- a/src/adcp/reporting/outbox/_transport_logging.py +++ b/src/adcp/reporting/outbox/_transport_logging.py @@ -1,7 +1,10 @@ """Keep httpx/httpcore's URL/header diagnostics out of reporting delivery logs. -Filters are context-local: concurrent application HTTP traffic keeps its normal -logging configuration. No handlers, levels, or global record factory are changed. +The shared filter is installed while any protected context is active. A locked +reference count keeps overlapping tasks/threads from removing it prematurely; +its decision is context-local so concurrent application HTTP traffic keeps its +normal logging behavior. The last exit restores the original filters. No +handlers, levels, or global record factory are changed. The SDK-owned sender uses HTTP/1 and the connection logger; HTTP/2 is included defensively and the conformance test audits the installed httpcore logger set. """ @@ -12,6 +15,7 @@ from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar +from threading import Lock _PROTECTED = ContextVar("adcp_reporting_protected_transport", default=False) _LOGGER_NAMES = ( @@ -30,14 +34,25 @@ def filter(self, record: logging.LogRecord) -> bool: _FILTER = _ReportingTransportFilter() +_FILTER_LOCK = Lock() +_ACTIVE_CONTEXTS = 0 @contextmanager def protected_transport_logs() -> Iterator[None]: - for name in _LOGGER_NAMES: - logging.getLogger(name).addFilter(_FILTER) + global _ACTIVE_CONTEXTS + with _FILTER_LOCK: + if _ACTIVE_CONTEXTS == 0: + for name in _LOGGER_NAMES: + logging.getLogger(name).addFilter(_FILTER) + _ACTIVE_CONTEXTS += 1 token = _PROTECTED.set(True) try: yield finally: _PROTECTED.reset(token) + with _FILTER_LOCK: + _ACTIVE_CONTEXTS -= 1 + if _ACTIVE_CONTEXTS == 0: + for name in _LOGGER_NAMES: + logging.getLogger(name).removeFilter(_FILTER) diff --git a/src/adcp/reporting/outbox/activity.py b/src/adcp/reporting/outbox/activity.py new file mode 100644 index 000000000..34f82048f --- /dev/null +++ b/src/adcp/reporting/outbox/activity.py @@ -0,0 +1,259 @@ +"""Bounded, principal-scoped projection of reporting HTTP reservations. + +No URL, body, header, response text, exception prose, or queue state is used as +an activity diagnostic. Pending means that peer I/O cannot yet be determined. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias +from urllib.parse import unquote, urlsplit, urlunsplit + +from pydantic import AnyUrl + +from adcp.reporting.evidence import aware_utc +from adcp.reporting.ledger.notification_models import ReportingNotificationError +from adcp.reporting.outbox.identity import resolve_reporting_consumer +from adcp.reporting.outbox.models import DeliveryBinding, DeliveryLease + +if TYPE_CHECKING: + from adcp.decisioning.accounts import ResolveContext + +ActivityStatus: TypeAlias = Literal["pending", "success", "failed", "timeout", "connection_error"] +TerminalActivityStatus: TypeAlias = Literal["success", "failed", "timeout", "connection_error"] + +# An allowlist intentionally also hides unfamiliar, non-secret route names. +# Entropy-only heuristics miss human-chosen passwords and encoded credentials. +_PUBLIC_PATH_SEGMENTS = frozenset( + { + "", + "api", + "adcp", + "webhook", + "webhooks", + "reporting", + "reports", + "report", + "callback", + "callbacks", + "notify", + "notifications", + "events", + "delivery", + "redacted", + "hook", + "hooks", + "ingest", + "endpoint", + } +) + + +def sanitize_activity_url(value: str) -> str: + """Keep the origin and public route words; redact every other path segment. + + This deterministic policy covers UUIDs, JWTs, long hex/base64, mixed tokens, + percent encoding, and short human-chosen secrets without exposing hashes. + Userinfo, query, and fragment are always removed. Failures contain no input. + """ + try: + if type(value) is not str or len(value) > 8192: + raise ValueError + parsed = urlsplit(value) + if parsed.scheme not in {"https", "http"} or not parsed.hostname: + raise ValueError + host = parsed.hostname + if ":" in host: + host = f"[{host}]" + if parsed.port is not None: + host += f":{parsed.port}" + segments = [] + for part in parsed.path.split("/"): + decoded = unquote(part) + segments.append( + decoded + if decoded in _PUBLIC_PATH_SEGMENTS or re.fullmatch(r"v[0-9]{1,3}", decoded) + else "redacted" + ) + return str(AnyUrl(urlunsplit((parsed.scheme, host, "/".join(segments), "", "")))) + except (ValueError, TypeError): + pass + # Raise outside the parser's except block so even __context__ cannot retain + # a raw port/URL from urllib or a validation exception. + raise ReportingNotificationError("invalid_activity_url") + + +def activity_limit(value: int) -> int: + if type(value) is not int or not 1 <= value <= 200: + raise ReportingNotificationError("activity_limit_must_be_1_to_200") + return value + + +def retention_cutoff(now: datetime, retention_days: int) -> datetime: + if type(retention_days) is not int or retention_days < 30: + raise ReportingNotificationError("activity_retention_requires_30_days") + return aware_utc(now) - timedelta(days=retention_days) + + +@dataclass(frozen=True) +class ActivityRequest: + """Only safe, immutable HTTP request diagnostics cross the SQL boundary.""" + + url: str + payload_size_bytes: int + + def __post_init__(self) -> None: + object.__setattr__(self, "url", sanitize_activity_url(self.url)) + if type(self.payload_size_bytes) is not int or self.payload_size_bytes < 0: + raise ReportingNotificationError("invalid_activity_request") + + +@dataclass(frozen=True) +class ActivityOutcome: + status: TerminalActivityStatus + http_status_code: int | None = None + response_time_ms: int | None = None + + def __post_init__(self) -> None: + valid = self.status in {"success", "failed", "timeout", "connection_error"} + if self.status in {"success", "failed"}: + valid = valid and ( + type(self.http_status_code) is int + and 100 <= self.http_status_code <= 599 + and (self.status == "success") == (200 <= self.http_status_code < 300) + and type(self.response_time_ms) is int + and self.response_time_ms >= 0 + ) + else: + valid = valid and self.http_status_code is None and self.response_time_ms is None + if not valid: + raise ReportingNotificationError("invalid_activity_outcome") + + +@dataclass(frozen=True) +class WebhookAttempt: + binding: DeliveryBinding + attempt: int + lease_token: str = field(repr=False) + reservation_token: str = field(repr=False) + fired_at: datetime + request: ActivityRequest + outcome: ActivityOutcome | None = None + completed_at: datetime | None = None + + def to_wire(self) -> dict[str, Any]: + from adcp.validation.schema_loader import get_named_validator + + outcome = self.outcome + status = outcome.status if outcome else "pending" + row: dict[str, Any] = { + "idempotency_key": self.binding.idempotency_key, + "subscriber_id": self.binding.subscriber_id, + "notification_type": self.binding.notification_type, + "attempt": self.attempt, + "fired_at": self.fired_at.isoformat(), + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "status": status, + "url": sanitize_activity_url(self.request.url), + "http_status_code": outcome.http_status_code if outcome else None, + "response_time_ms": outcome.response_time_ms if outcome else None, + "payload_size_bytes": self.request.payload_size_bytes, + "error_message": { + "pending": None, + "success": None, + "failed": "HTTP non-success response", + "timeout": "HTTP attempt timed out", + "connection_error": "Connection failed", + }[status], + } + # Reporting notifications normally have an ID; optional wire fields + # must be absent, rather than null, when the source has none. + if self.binding.notification_id: + row["notification_id"] = self.binding.notification_id + validator = get_named_validator("core/webhook-activity-record.json", version="3.2.0-rc.3") + if validator is None or not validator.is_valid(row): + raise ReportingNotificationError("invalid_activity_record") + return row + + +class ReportingActivityStore(Protocol): + async def reserve_attempt( + self, lease: DeliveryLease, *, request: ActivityRequest, now: datetime + ) -> WebhookAttempt | None: ... + + async def complete_attempt( + self, attempt: WebhookAttempt, *, outcome: ActivityOutcome, now: datetime + ) -> bool: ... + + async def list_activity( + self, *, account_id: str, consumer_id: str, limit: int = 50 + ) -> tuple[WebhookAttempt, ...]: ... + + async def purge_activity( + self, *, account_id: str, consumer_id: str, now: datetime, retention_days: int = 30 + ) -> int: ... + + +def omit_account_activity(envelope: Any) -> Any: + """Unrequested/unsupported activity can never leak from adopter envelopes.""" + if not isinstance(envelope, dict) or not isinstance(envelope.get("accounts"), list): + return envelope + return { + **envelope, + "accounts": [ + ( + {key: value for key, value in account.items() if key != "webhook_activity"} + if isinstance(account, dict) + else account + ) + for account in envelope["accounts"] + ], + } + + +class ReportingActivityProjector: + """Optional decorator mounted *after* AccountStore's visibility/filtering. + + Pass this instance as ``account_activity=`` to the platform server factory. + It never resolves additional accounts or accepts a consumer from the body. + Memory stores support conformance but cannot justify durable capabilities. + """ + + def __init__(self, store: ReportingActivityStore) -> None: + self.store = store + + async def for_account( + self, *, account_id: str, context: ResolveContext, limit: int = 50 + ) -> list[dict[str, Any]]: + consumer = resolve_reporting_consumer(auth_info=context.auth_info, agent=context.agent) + attempts = await self.store.list_activity( + account_id=account_id, consumer_id=consumer, limit=activity_limit(limit) + ) + return [attempt.to_wire() for attempt in attempts] + + async def enrich( + self, envelope: Any, *, context: ResolveContext, include: bool, limit: int = 50 + ) -> Any: + # Preserve legacy envelopes, pagination, and account ordering. Only + # already-returned accounts can ever reach the activity read boundary. + if not isinstance(envelope, dict) or not isinstance(envelope.get("accounts"), list): + return envelope + if include: + activity_limit(limit) + resolve_reporting_consumer(auth_info=context.auth_info, agent=context.agent) + accounts = [] + for original in envelope["accounts"]: + if not isinstance(original, dict): + accounts.append(original) + continue + account = dict(original) + account.pop("webhook_activity", None) + if include and isinstance(account.get("account_id"), str): + account["webhook_activity"] = await self.for_account( + account_id=account["account_id"], context=context, limit=limit + ) + accounts.append(account) + return {**envelope, "accounts": accounts} diff --git a/src/adcp/reporting/outbox/identity.py b/src/adcp/reporting/outbox/identity.py new file mode 100644 index 000000000..204fa71a0 --- /dev/null +++ b/src/adcp/reporting/outbox/identity.py @@ -0,0 +1,66 @@ +"""One identity rule for authenticated activity reads and trusted registrations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from adcp.reporting.ledger.notification_models import ReportingNotificationError + +if TYPE_CHECKING: + from adcp.decisioning.context import AuthInfo + from adcp.decisioning.registry import BuyerAgent + + +def canonical_consumer(principal: str | None) -> str: + """Validate a principal restored from *trusted* subscription configuration. + + Registration must first call :func:`resolve_reporting_consumer`. This + function restores that persisted result for asynchronous work; payloads, + account references, and subscriber identifiers are never identity sources. + """ + try: + if not isinstance(principal, str) or principal.strip().lower() in { + "", + "anonymous", + "anon", + "unauthenticated", + "none", + "null", + }: + raise ValueError + if principal != principal.strip() or not principal.isprintable() or len(principal) > 2048: + raise ValueError + # Auth identities are opaque and include canonical BuyerAgent URLs. + # The reporting evidence helper intentionally rejects URLs and is not + # appropriate for this authentication boundary. Never normalize here. + except (ValueError, TypeError): + raise ReportingNotificationError("activity_identity_required") from None + return principal + + +def resolve_reporting_consumer( + *, auth_info: AuthInfo | None, agent: BuyerAgent | None = None +) -> str: + """Resolve the authenticated consumer without choosing between disagreements. + + Inputs must come from verified middleware/the trusted registry. Every + present flat/registry/signed identity must agree. API/OAuth credentials may + resolve solely through the registry without a flat principal. + Normalize aliases in the trusted authentication adapter before this boundary. + Persist this result as ``ReportingNotificationSubscription.principal_id``. + """ + from adcp.decisioning.registry import HttpSigCredential + + values = [agent.agent_url] if agent is not None else [] + if auth_info is not None: + values.extend( + value for value in (auth_info.principal, auth_info.agent_url) if value is not None + ) + if isinstance(auth_info.credential, HttpSigCredential): + values.append(auth_info.credential.agent_url) + principals = {canonical_consumer(value) for value in values} + if len(principals) > 1: + raise ReportingNotificationError("activity_identity_conflict") + if not principals: + raise ReportingNotificationError("activity_identity_required") + return principals.pop() diff --git a/src/adcp/reporting/outbox/memory.py b/src/adcp/reporting/outbox/memory.py index 8a667e666..2292670fc 100644 --- a/src/adcp/reporting/outbox/memory.py +++ b/src/adcp/reporting/outbox/memory.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import deepcopy -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from datetime import datetime, timedelta from secrets import token_hex from typing import TYPE_CHECKING @@ -19,6 +19,14 @@ ReportingStatusScope, event_storage, ) +from adcp.reporting.outbox.activity import ( + ActivityOutcome, + ActivityRequest, + WebhookAttempt, + activity_limit, + retention_cutoff, +) +from adcp.reporting.outbox.identity import canonical_consumer from adcp.reporting.outbox.models import ( ERROR_CODES, DeliveryLease, @@ -76,6 +84,8 @@ class NotificationState: dirty: list[ReportingStatusDirty] = field(default_factory=list) checkpoints: dict[tuple[str, str], int] = field(default_factory=dict) issue_scopes: dict[tuple[str, str], ReportingStatusScope] = field(default_factory=dict) + activity_heads: dict[tuple[str, str, str, str], int] = field(default_factory=dict) + activity: dict[tuple[str, str, str, str, int], WebhookAttempt] = field(default_factory=dict) def enqueue(self, event: ReportingDomainEvent) -> None: """Internal synchronous participant; caller owns the ledger transaction.""" @@ -263,7 +273,7 @@ async def delivery_lease_current(self, lease: DeliveryLease, *, now: datetime) - async with self._store._lock: binding = lease.delivery.binding item = self._state.deliveries.get((binding.account_id, binding.delivery_id)) - return item is not None and item[1].held(lease.token, now) + return item is not None and item[0] == lease.delivery and item[1].held(lease.token, now) async def finish_delivery( self, @@ -278,11 +288,113 @@ async def finish_delivery( async with self._store._lock: binding = lease.delivery.binding item = self._state.deliveries.get((binding.account_id, binding.delivery_id)) - if item is None or not item[1].held(lease.token, now): + if item is None or item[0] != lease.delivery or not item[1].held(lease.token, now): return False _finish(item[1], now=now, state=state, error_code=error_code, retry_at=retry_at) return True + async def reserve_attempt( + self, lease: DeliveryLease, *, request: ActivityRequest, now: datetime + ) -> WebhookAttempt | None: + binding = lease.delivery.binding + consumer = canonical_consumer(binding.principal_id) + request = ActivityRequest(request.url, request.payload_size_bytes) + key = ( + binding.account_id, + consumer, + binding.subscriber_id, + binding.idempotency_key, + ) + async with self._store._lock: + item = self._state.deliveries.get((binding.account_id, binding.delivery_id)) + if ( + item is None + or item[0] != lease.delivery + or item[1].expires_at != lease.expires_at + or not item[1].held(lease.token, now) + ): + return None + if any( + row.lease_token == lease.token and row.binding == binding + for row in self._state.activity.values() + ): + return None + number = self._state.activity_heads.get(key, 0) + 1 + attempt = WebhookAttempt( + binding, number, lease.token, token_hex(32), aware_utc(now), request + ) + self._state.activity_heads[key] = number + self._state.activity[(*key, number)] = attempt + return attempt + + async def complete_attempt( + self, attempt: WebhookAttempt, *, outcome: ActivityOutcome, now: datetime + ) -> bool: + ActivityOutcome.__post_init__(outcome) + binding = attempt.binding + consumer = canonical_consumer(binding.principal_id) + key = ( + binding.account_id, + consumer, + binding.subscriber_id, + binding.idempotency_key, + attempt.attempt, + ) + async with self._store._lock: + original = self._state.activity.get(key) + if ( + original != attempt + or attempt.outcome is not None + or aware_utc(now) < attempt.fired_at + ): + return False + self._state.activity[key] = replace( + attempt, outcome=outcome, completed_at=aware_utc(now) + ) + return True + + async def list_activity( + self, *, account_id: str, consumer_id: str, limit: int = 50 + ) -> tuple[WebhookAttempt, ...]: + consumer_id, limit = canonical_consumer(consumer_id), activity_limit(limit) + async with self._store._lock: + return tuple( + sorted( + ( + row + for key, row in self._state.activity.items() + if key[0] == account_id and key[1] == consumer_id + ), + key=lambda row: ( + row.fired_at, + row.binding.notification_id, + row.binding.idempotency_key, + row.binding.subscriber_id, + row.attempt, + row.binding.delivery_id, + ), + reverse=True, + )[:limit] + ) + + async def purge_activity( + self, *, account_id: str, consumer_id: str, now: datetime, retention_days: int = 30 + ) -> int: + consumer_id = canonical_consumer(consumer_id) + cutoff = retention_cutoff(now, retention_days) + async with self._store._lock: + keys = [ + key + for key, row in self._state.activity.items() + if key[0] == account_id + and key[1] == consumer_id + and row.completed_at is not None + and row.completed_at < cutoff + ] + for key in keys: + del self._state.activity[key] + return len(keys) + async def list_deliveries(self, *, account_id: str) -> tuple[DeliveryStatus, ...]: async with self._store._lock: return tuple( diff --git a/src/adcp/reporting/outbox/pg.py b/src/adcp/reporting/outbox/pg.py index dfd7b69df..b0efbdf34 100644 --- a/src/adcp/reporting/outbox/pg.py +++ b/src/adcp/reporting/outbox/pg.py @@ -24,6 +24,7 @@ dirty_storage, event_storage, ) +from adcp.reporting.outbox._activity_pg import _PgReportingActivity from adcp.reporting.outbox.memory import validate_finish from adcp.reporting.outbox.models import ( DeliveryBinding, @@ -165,14 +166,15 @@ class _LostLeaseError(Exception): pass -class PgReportingOutbox: +class PgReportingOutbox(_PgReportingActivity): """Caller-owned pool, database time, expiring random tokens, fenced writes. ``now`` arguments implement the shared memory protocol. In PostgreSQL they never override the database clock; only the explicit constructor ``clock`` seam does, for deterministic tests. Retry *durations* are applied to DB time. Claims are counted for diagnostics, never as an HTTP retry limit. Evidence - and prepared bindings are retained indefinitely; this slice has no purge. + and prepared bindings are retained indefinitely. The additive activity + participant purges only completed HTTP history beyond its retention floor. """ def __init__( @@ -418,12 +420,13 @@ async def delivery_lease_current(self, lease: DeliveryLease, *, now: datetime) - row = await ( await conn.execute( "SELECT 1 FROM reporting_notification_deliveries WHERE account_id = %s" - " AND consumer_namespace = %s" + " AND consumer_namespace = %s AND principal_id = %s" " AND delivery_id = %s AND state = 'leased' AND lease_token = %s" " AND lease_expires_at > %s", ( binding.account_id, binding.consumer_namespace, + binding.principal_id, binding.delivery_id, lease.token, at, @@ -450,7 +453,7 @@ async def finish_delivery( "UPDATE reporting_notification_deliveries SET state = %s, error_code = %s," " due_at = %s, lease_token = NULL, lease_expires_at = NULL" " WHERE account_id = %s AND delivery_id = %s AND state = 'leased'" - " AND consumer_namespace = %s" + " AND consumer_namespace = %s AND principal_id = %s" " AND lease_token = %s AND lease_expires_at > %s", ( state, @@ -459,6 +462,7 @@ async def finish_delivery( binding.account_id, binding.delivery_id, binding.consumer_namespace, + binding.principal_id, lease.token, at, ), diff --git a/src/adcp/reporting/outbox/required_schema.json b/src/adcp/reporting/outbox/required_schema.json new file mode 100644 index 000000000..8494d80a3 --- /dev/null +++ b/src/adcp/reporting/outbox/required_schema.json @@ -0,0 +1,1858 @@ +{ + "column:reporting_adjustments.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_adjustments.accounting_period_end": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_adjustments.accounting_period_start": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_adjustments.adjusts_reporting_revision_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_adjustments.control_total_deltas": { + "enabled": true, + "fingerprint": "3cddda27b283bb6e5a335e88170dc75ed50ded8194e1c9e52a73c024e4f90cd3" + }, + "column:reporting_adjustments.correction_observed_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_adjustments.created_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_adjustments.managed_control_total_deltas": { + "enabled": true, + "fingerprint": "1f539b84d6adb1a8577add320aabb81fdd5c614ad01b46c7b77eb55b995a6556" + }, + "column:reporting_adjustments.reason_code": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_adjustments.reason_detail": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_adjustments.reporting_adjustment_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_configurations.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_configurations.account_timezone": { + "enabled": true, + "fingerprint": "93ccae93fde6bd67bf741180f69e891aa45fcf7a97f22084caf577642a783fdf" + }, + "column:reporting_configurations.activated_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_configurations.authoritative_party": { + "enabled": true, + "fingerprint": "8e265afa649b7a2ec481f8e06e97300820a1586082cc1bf47ed056ca1e24461d" + }, + "column:reporting_configurations.automated_recovery_seconds": { + "enabled": true, + "fingerprint": "d0753fa97340c7a39048ed2bf58da8dd7d907d2be2cbc72d3a672c53c096527c" + }, + "column:reporting_configurations.content_sha256": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_configurations.deactivated_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_configurations.definition": { + "enabled": true, + "fingerprint": "1f539b84d6adb1a8577add320aabb81fdd5c614ad01b46c7b77eb55b995a6556" + }, + "column:reporting_configurations.delivery_config_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_configurations.delivery_config_version": { + "enabled": true, + "fingerprint": "64be57437fdc0a07a97985c2aa058031f8082db7251bdb4d5afa1a9b088de97a" + }, + "column:reporting_configurations.feed_purpose": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_configurations.lease_expires_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_configurations.lease_worker_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_configurations.media_buy_ids": { + "enabled": true, + "fingerprint": "3cddda27b283bb6e5a335e88170dc75ed50ded8194e1c9e52a73c024e4f90cd3" + }, + "column:reporting_configurations.report_definition_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_configurations.reporting_profile": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_configurations.required_finality": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_configurations.schedule": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_configurations.status_retention_days": { + "enabled": true, + "fingerprint": "22545e55ce7bbb2ec50879ac60414b6101f3d3bd726fac0645c06c7ae1254c6d" + }, + "column:reporting_consumer_statuses.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_consumer_statuses.consumer_commit_ref": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_consumer_statuses.consumer_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_consumer_statuses.consumer_status": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_consumer_statuses.content_sha256": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_consumer_statuses.delivery_config_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_consumer_statuses.delivery_config_version": { + "enabled": true, + "fingerprint": "64be57437fdc0a07a97985c2aa058031f8082db7251bdb4d5afa1a9b088de97a" + }, + "column:reporting_consumer_statuses.failure_code": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_consumer_statuses.mismatch_code": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_consumer_statuses.observed_revision_content_sha256": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_consumer_statuses.period_end": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_consumer_statuses.period_source_timezone": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_consumer_statuses.period_start": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_consumer_statuses.recorded_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_consumer_statuses.report_definition_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_consumer_statuses.reporting_obligation_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_consumer_statuses.reporting_revision_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_consumer_statuses.reporting_status_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_consumer_statuses.seller_ledger_as_of": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_consumer_statuses.seller_ledger_snapshot_id": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_consumer_statuses.status_as_of": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_consumer_statuses.superseded": { + "enabled": true, + "fingerprint": "981e469ff869d932309f9e6aab9b07ff394c7439fe2431281320e25b41c4198d" + }, + "column:reporting_consumer_statuses.supersedes_reporting_status_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_issue_lifecycle.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_issue_lifecycle.consumer_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_issue_lifecycle.external_ref": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_issue_lifecycle.generation": { + "enabled": true, + "fingerprint": "64be57437fdc0a07a97985c2aa058031f8082db7251bdb4d5afa1a9b088de97a" + }, + "column:reporting_issue_lifecycle.issue_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_issue_lifecycle.issue_key": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_issue_lifecycle.issue_state": { + "enabled": true, + "fingerprint": "69ff456c4ece2240eee5eddda514b673f39e572f0191c402234ea6371a703b95" + }, + "column:reporting_issue_lifecycle.opened_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_issue_lifecycle.retired_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_issue_status_scopes.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_issue_status_scopes.issue_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_issue_status_scopes.scope": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_ledger_changes.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_ledger_changes.committed_at": { + "enabled": true, + "fingerprint": "cf8e79be3fcf845fdb87b25524bcaf9c6abcc891dc777f46b3a99f6d3b82bdb6" + }, + "column:reporting_ledger_changes.record_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_ledger_changes.record_kind": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_ledger_changes.seq": { + "enabled": true, + "fingerprint": "9bc9a08a72bc1fb9aa39e764ad2cece0247b07f1ea9ca60d69aedf60c34e330f" + }, + "column:reporting_notification_deliveries.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.auth_mode": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_notification_deliveries.body_sha256": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_notification_deliveries.cause_generation": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_notification_deliveries.cause_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.cause_kind": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.claim_count": { + "enabled": true, + "fingerprint": "42202005517b72e082eb22c9eceb2ac0252815e5df700c83eb50c54cfeb46297" + }, + "column:reporting_notification_deliveries.consumer_namespace": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.delivery_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.destination_sha256": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_notification_deliveries.due_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_notification_deliveries.emission_generation": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_notification_deliveries.envelope": { + "enabled": true, + "fingerprint": "554c34e416bd4546469b42d5773bc7c59b2104366ffa5259a2838c64cd826e57" + }, + "column:reporting_notification_deliveries.envelope_version": { + "enabled": true, + "fingerprint": "64be57437fdc0a07a97985c2aa058031f8082db7251bdb4d5afa1a9b088de97a" + }, + "column:reporting_notification_deliveries.error_code": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_notification_deliveries.idempotency_key": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.key_version": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.lease_expires_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_notification_deliveries.lease_token": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_notification_deliveries.notification_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.notification_type": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.principal_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.signing_scope_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_notification_deliveries.state": { + "enabled": true, + "fingerprint": "1a1ab7c892bfef3ca42f00cf764453bc5e0578b3a82ecf81da90b56816df493f" + }, + "column:reporting_notification_deliveries.subscriber_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_deliveries.subscription_fingerprint": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_notification_events.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_events.cause_generation": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_notification_events.cause_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_events.cause_kind": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_events.consumer_namespace": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_events.fired_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_notification_events.notification_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_events.notification_type": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_events.snapshot": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_notification_expansions.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_expansions.claim_count": { + "enabled": true, + "fingerprint": "42202005517b72e082eb22c9eceb2ac0252815e5df700c83eb50c54cfeb46297" + }, + "column:reporting_notification_expansions.consumer_namespace": { + "enabled": true, + "fingerprint": "128b66e02f14946100273f112c144af7605d626124d20fe16c1ba30c6b19ae3a" + }, + "column:reporting_notification_expansions.due_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_notification_expansions.emission_generation": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_notification_expansions.error_code": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_notification_expansions.lease_expires_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_notification_expansions.lease_token": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_notification_expansions.notification_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_notification_expansions.state": { + "enabled": true, + "fingerprint": "1a1ab7c892bfef3ca42f00cf764453bc5e0578b3a82ecf81da90b56816df493f" + }, + "column:reporting_obligations.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_obligations.automated_recovery_deadline_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_obligations.coverage_status": { + "enabled": true, + "fingerprint": "ef5a1a6ab4e7e5e6416c04138653c688cda83f8698de309c769190e946038953" + }, + "column:reporting_obligations.created_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_obligations.currency": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_obligations.definition": { + "enabled": true, + "fingerprint": "1f539b84d6adb1a8577add320aabb81fdd5c614ad01b46c7b77eb55b995a6556" + }, + "column:reporting_obligations.delivery_config_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_obligations.delivery_config_version": { + "enabled": true, + "fingerprint": "64be57437fdc0a07a97985c2aa058031f8082db7251bdb4d5afa1a9b088de97a" + }, + "column:reporting_obligations.expected_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_obligations.feed_purpose": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_obligations.media_buy_ids": { + "enabled": true, + "fingerprint": "3cddda27b283bb6e5a335e88170dc75ed50ded8194e1c9e52a73c024e4f90cd3" + }, + "column:reporting_obligations.package_ids": { + "enabled": true, + "fingerprint": "3cddda27b283bb6e5a335e88170dc75ed50ded8194e1c9e52a73c024e4f90cd3" + }, + "column:reporting_obligations.period_end": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_obligations.period_key": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_obligations.period_start": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_obligations.report_definition_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_obligations.reporting_obligation_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_obligations.reporting_profile": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_obligations.required_finality": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_obligations.schedule": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_obligations.scope_resolved_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_obligations.source_timezone": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_receipt_heads.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_receipt_heads.chain_key": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_receipt_heads.consumer_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_receipt_heads.namespace": { + "enabled": true, + "fingerprint": "a4101999ec94f9d8063e80155bac4ec7557b468926a2f2490287792852e32c11" + }, + "column:reporting_receipt_heads.receipt_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_receipt_heads.receipt_status": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_receipt_heads.supersedes_receipt_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_reconciliation_changes.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_changes.change_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_changes.committed_at": { + "enabled": true, + "fingerprint": "336df3243b293695d8321965e92f26d3f132e11514ed9678fb5e64e0015fa580" + }, + "column:reporting_reconciliation_changes.consumer_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_changes.content_sha256": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_changes.namespace": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_changes.record_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_changes.record_kind": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_reconciliation_changes.seq": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_reconciliation_heads.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_heads.consumer_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_heads.max_sequence": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_reconciliation_records.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.attempt_number": { + "enabled": true, + "fingerprint": "4340876cb26818ac55a8d51cfbc7047e90fc4d7e44f570ee454b4552beb351a1" + }, + "column:reporting_reconciliation_records.change_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.consumer_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.content_sha256": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.delivery_config_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.delivery_config_version": { + "enabled": true, + "fingerprint": "64be57437fdc0a07a97985c2aa058031f8082db7251bdb4d5afa1a9b088de97a" + }, + "column:reporting_reconciliation_records.namespace": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.payload": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_reconciliation_records.receipt_chain_key": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_reconciliation_records.receipt_status": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_reconciliation_records.record_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_reconciliation_records.record_kind": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_reconciliation_records.reporting_adjustment_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_reconciliation_records.reporting_materialization_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_reconciliation_records.reporting_obligation_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_reconciliation_records.reporting_revision_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_reconciliation_records.supersedes_receipt_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_restatement_checkpoints.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_restatement_checkpoints.checked_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_restatement_checkpoints.next_observation": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_restatement_checkpoints.provisional_until": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_restatement_checkpoints.reporting_obligation_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revision_rows.ordinal": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_revision_rows.reporting_revision_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revision_rows.row_payload": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_revisions.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revisions.canonical_content_digest": { + "enabled": true, + "fingerprint": "1f539b84d6adb1a8577add320aabb81fdd5c614ad01b46c7b77eb55b995a6556" + }, + "column:reporting_revisions.content_sha256": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revisions.control_totals": { + "enabled": true, + "fingerprint": "3cddda27b283bb6e5a335e88170dc75ed50ded8194e1c9e52a73c024e4f90cd3" + }, + "column:reporting_revisions.created_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_revisions.data_through": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_revisions.finality": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_revisions.finality_basis": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_revisions.finality_policy_id": { + "enabled": true, + "fingerprint": "5b92595d0b54d473a3a3818455845f1fe0dc20b48cfe0faf63061a82d1a3cb02" + }, + "column:reporting_revisions.finalized_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_revisions.managed_control_totals": { + "enabled": true, + "fingerprint": "1f539b84d6adb1a8577add320aabb81fdd5c614ad01b46c7b77eb55b995a6556" + }, + "column:reporting_revisions.observed_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_revisions.readable": { + "enabled": true, + "fingerprint": "b53adb77cbd95524b16abf02100e53643b297b4687168fab253a9fb99c6393e5" + }, + "column:reporting_revisions.readable_at_commit": { + "enabled": true, + "fingerprint": "b53adb77cbd95524b16abf02100e53643b297b4687168fab253a9fb99c6393e5" + }, + "column:reporting_revisions.reporting_obligation_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revisions.reporting_revision_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revisions.revision_content_sha256": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_revisions.row_count": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_revisions.source_manifest_sha256": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_revisions.source_publication_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_revisions.supersedes_reporting_revision_id": { + "enabled": true, + "fingerprint": "85b88fb407e112399f25b5dc8830dfdcb2a8271ecf00e70b662f6917b4a002cd" + }, + "column:reporting_status_checkpoints.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_status_checkpoints.projector_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_status_checkpoints.sequence": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_status_dirty.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_status_dirty.cause_generation": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_status_dirty.cause_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_status_dirty.consumer_namespace": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_status_dirty.reason": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_status_dirty.scope_sha256": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_status_dirty.sequence": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_status_dirty.snapshot": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_status_dirty_heads.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_status_dirty_heads.max_sequence": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_webhook_attempt_heads.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempt_heads.idempotency_key": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempt_heads.last_attempt": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_webhook_attempt_heads.principal_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempt_heads.subscriber_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.account_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.attempt": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_webhook_attempts.binding": { + "enabled": true, + "fingerprint": "ac355fc16c02b70cb0a24afee8214cdce5f5cbfdc7fd1630786d5101932ecfa4" + }, + "column:reporting_webhook_attempts.completed_at": { + "enabled": true, + "fingerprint": "6f1466ce5d0aaac8471e39834b9c4b1f85d6f7169d9238a245ac035ff518e0fc" + }, + "column:reporting_webhook_attempts.consumer_namespace": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.delivery_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.fired_at": { + "enabled": true, + "fingerprint": "1cac4e73af11a8ecafd646ef6a0ff6ecb087d46f150408dcfebc630fe1bf5e1e" + }, + "column:reporting_webhook_attempts.http_status_code": { + "enabled": true, + "fingerprint": "4340876cb26818ac55a8d51cfbc7047e90fc4d7e44f570ee454b4552beb351a1" + }, + "column:reporting_webhook_attempts.idempotency_key": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.lease_token": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_webhook_attempts.notification_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.payload_size_bytes": { + "enabled": true, + "fingerprint": "54a5f9962598caa8723435f8b55bd5e15823053af5dd63d531663997537dc6ba" + }, + "column:reporting_webhook_attempts.principal_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.reservation_token": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "column:reporting_webhook_attempts.response_time_ms": { + "enabled": true, + "fingerprint": "992336704a95e12ec6e959825c59fa2e51cddc5f1568af6bebaf12f03ac5655f" + }, + "column:reporting_webhook_attempts.status": { + "enabled": true, + "fingerprint": "1a1ab7c892bfef3ca42f00cf764453bc5e0578b3a82ecf81da90b56816df493f" + }, + "column:reporting_webhook_attempts.subscriber_id": { + "enabled": true, + "fingerprint": "fdc4b549138bf90a5af5ed00b87a86ff9a0cbea553e7f017b4d95510708f6f88" + }, + "column:reporting_webhook_attempts.url": { + "enabled": true, + "fingerprint": "10ccaa0dc3b93d48a1f32c7ef2352a11676632e1e871d77ef9ef4393eea15d27" + }, + "constraint:reporting_adjustments.reporting_adjustment_exact_revision": { + "enabled": true, + "fingerprint": "0e96c0637836b18d9abce3a88daeae106e2e736829cec20d451c84de209737e5" + }, + "constraint:reporting_adjustments.reporting_adjustments_adjusts_reporting_revision_id_fkey": { + "enabled": true, + "fingerprint": "05a20551eddef9900b08d4a48cf60bb5fa1946dfa16346e168be0c5362121f89" + }, + "constraint:reporting_adjustments.reporting_adjustments_pkey": { + "enabled": true, + "fingerprint": "9cf309cbfbca6b2d8395ccb46194e071919c7c838c98bf72df1ea6f0ac368b64" + }, + "constraint:reporting_configurations.reporting_configurations_pkey": { + "enabled": true, + "fingerprint": "d9ba22090ae520014ae689fbeb3206c705853c9227e68c533acd51317287e944" + }, + "constraint:reporting_consumer_statuses.reporting_consumer_statuses_pkey": { + "enabled": true, + "fingerprint": "4665373f9ddb8c6f3f8e7c599383b917c788402444d20650b51bd8117345bf46" + }, + "constraint:reporting_issue_lifecycle.reporting_issue_lifecycle_pkey": { + "enabled": true, + "fingerprint": "358e0f239c456cd9b50f8948e3834dd7219d3cb1449d3afd45939763bcfa4bd4" + }, + "constraint:reporting_issue_status_scopes.reporting_issue_status_scopes_pkey": { + "enabled": true, + "fingerprint": "6ef4d3cd31261bc5913681b701efebe99fb35dc5431dddc4447f0962506db024" + }, + "constraint:reporting_ledger_changes.reporting_ledger_changes_pkey": { + "enabled": true, + "fingerprint": "636ca20c082bd2fc1c02c79ebbe3f805b2ee45f86c665b5d6bcae7bf936c5d98" + }, + "constraint:reporting_notification_deliveries.reporting_notification_delive_account_id_consumer_namespac_fkey": { + "enabled": true, + "fingerprint": "02eabbed841e8c9a85dd76e50fe4fb006b09792fc36c5bad814f878edf4cc99f" + }, + "constraint:reporting_notification_deliveries.reporting_notification_delive_account_id_consumer_namespac_key1": { + "enabled": true, + "fingerprint": "bece3b7de1c22f3a600c89c175f942f83ecff078142418072b1bc1b42a670c18" + }, + "constraint:reporting_notification_deliveries.reporting_notification_delive_account_id_consumer_namespace_key": { + "enabled": true, + "fingerprint": "91982910079d78e1b6162e69e1e40d0f98ab055b811726cd8a9357c4aa9cfd22" + }, + "constraint:reporting_notification_deliveries.reporting_notification_deliveries_cause_generation_check": { + "enabled": true, + "fingerprint": "d93961696665129cf254c93dae4377741c5cef5fe9d373c6a5cb36226e5a69e2" + }, + "constraint:reporting_notification_deliveries.reporting_notification_deliveries_emission_generation_check": { + "enabled": true, + "fingerprint": "5b7b2793bd710c7eef8c02003fd8adb1df91d7c10659364fe37d7f6351d4bf5e" + }, + "constraint:reporting_notification_deliveries.reporting_notification_deliveries_error_code_check": { + "enabled": true, + "fingerprint": "502085808cda7e7ec1e45e3d88257a660bdc65b59a71727b473303a362b9cf9d" + }, + "constraint:reporting_notification_deliveries.reporting_notification_deliveries_pkey": { + "enabled": true, + "fingerprint": "126da0131155e9b09e48d24466c5f61b7b6dc1540a68c465c52b59769637820d" + }, + "constraint:reporting_notification_deliveries.reporting_notification_deliveries_state_check": { + "enabled": true, + "fingerprint": "12b7a569e49f4bf374d5dade9ec0a0d812e4dea251366467c95bcd58bee90b9d" + }, + "constraint:reporting_notification_events.reporting_notification_events_account_id_consumer_namespace_key": { + "enabled": true, + "fingerprint": "7156cd6623f45f181906d2886b53dfba82bfdfc6cdcbdd0017c1f1cd7ceae407" + }, + "constraint:reporting_notification_events.reporting_notification_events_cause_generation_check": { + "enabled": true, + "fingerprint": "d93961696665129cf254c93dae4377741c5cef5fe9d373c6a5cb36226e5a69e2" + }, + "constraint:reporting_notification_events.reporting_notification_events_cause_id_check": { + "enabled": true, + "fingerprint": "e86db06c50f414ddc137693ad14cc7faaf8c3805591871c6d001cc5438d31a71" + }, + "constraint:reporting_notification_events.reporting_notification_events_cause_kind_check": { + "enabled": true, + "fingerprint": "025235387f154ff5e1c2de4bdb4b8ad3b6ace5b1b2b8b1182f4f5bfa3426706b" + }, + "constraint:reporting_notification_events.reporting_notification_events_check": { + "enabled": true, + "fingerprint": "d38125dfe891cd18a15d2536cb289c440703ea336bb21b93492ad155ec96cd6e" + }, + "constraint:reporting_notification_events.reporting_notification_events_check1": { + "enabled": true, + "fingerprint": "71beff0577dbdf0f7ae6f44e48acc0416d234e6cc16610b3a649da46915ee6f8" + }, + "constraint:reporting_notification_events.reporting_notification_events_check2": { + "enabled": true, + "fingerprint": "1545cc63bf7a27fb4a8a71cb1f5e02b70b8cca50f82a2123700a4ff943975324" + }, + "constraint:reporting_notification_events.reporting_notification_events_notification_type_check": { + "enabled": true, + "fingerprint": "a9fa9c18e7042602012f016cfdb22cee9ee06c1be12e4896c6bf4df54e2e4eb0" + }, + "constraint:reporting_notification_events.reporting_notification_events_pkey": { + "enabled": true, + "fingerprint": "e8d63f44b25700915bcb3cb69e2e24b98cf21fd304f36a2e9c2992236309309d" + }, + "constraint:reporting_notification_expansions.reporting_notification_expans_account_id_consumer_namespac_fkey": { + "enabled": true, + "fingerprint": "f711c9b094f00f5965f98a4f2c42c2b4a7c18fe31d9ef55f60ad3c39edcb49eb" + }, + "constraint:reporting_notification_expansions.reporting_notification_expansions_emission_generation_check": { + "enabled": true, + "fingerprint": "5b7b2793bd710c7eef8c02003fd8adb1df91d7c10659364fe37d7f6351d4bf5e" + }, + "constraint:reporting_notification_expansions.reporting_notification_expansions_error_code_check": { + "enabled": true, + "fingerprint": "502085808cda7e7ec1e45e3d88257a660bdc65b59a71727b473303a362b9cf9d" + }, + "constraint:reporting_notification_expansions.reporting_notification_expansions_pkey": { + "enabled": true, + "fingerprint": "8ac920e8e1ed5a59417d5d1ffb9bea608bbeb64dbcd1a431240370fe555fe6f0" + }, + "constraint:reporting_notification_expansions.reporting_notification_expansions_state_check": { + "enabled": true, + "fingerprint": "12b7a569e49f4bf374d5dade9ec0a0d812e4dea251366467c95bcd58bee90b9d" + }, + "constraint:reporting_obligations.reporting_obligations_currency_code": { + "enabled": true, + "fingerprint": "dba53309a6d9d17fc9830be86ce72a26938650b7997ec692990c5b7ae1847ae2" + }, + "constraint:reporting_obligations.reporting_obligations_pkey": { + "enabled": true, + "fingerprint": "bb4f6ac8f57972cf428329ac480aeceef314f15cb01d4905ba8e330acdb1cb86" + }, + "constraint:reporting_receipt_heads.reporting_head_exact_receipt": { + "enabled": true, + "fingerprint": "de96253671d577e695e6d3a30ab15cb65e25d6177eff30625f83b856a69fcce9" + }, + "constraint:reporting_receipt_heads.reporting_receipt_heads_account_id_consumer_id_namespace_r_fkey": { + "enabled": true, + "fingerprint": "8be5b9f087d56d24d3f3d88bbc7c4c70f405a1a3c6b8154557c995349f55d483" + }, + "constraint:reporting_receipt_heads.reporting_receipt_heads_namespace_check": { + "enabled": true, + "fingerprint": "2c9a5200ec554a76a42ee5145e5cd2a019c953a11804ca641612f6abbe401bdd" + }, + "constraint:reporting_receipt_heads.reporting_receipt_heads_pkey": { + "enabled": true, + "fingerprint": "70be2760b686f1f048745d8fce4f33363ef36b1b719ecdd12a614d70160202e9" + }, + "constraint:reporting_receipt_heads.reporting_receipt_heads_receipt_status_check": { + "enabled": true, + "fingerprint": "a7940c52ae4d16eadd8965171e01c52bb1faaca7e6ee09af1d95b9b816161cb2" + }, + "constraint:reporting_reconciliation_changes.reporting_reconciliation_chan_account_id_consumer_id_names_fkey": { + "enabled": true, + "fingerprint": "93345bef829071346cc173d9037d10154a503a39639a1c3d5888c1dbd0e44088" + }, + "constraint:reporting_reconciliation_changes.reporting_reconciliation_chan_account_id_consumer_id_names_key1": { + "enabled": true, + "fingerprint": "61b1562acd2073d68f327d395be38360b9326d85921933894c5af400f5fe9389" + }, + "constraint:reporting_reconciliation_changes.reporting_reconciliation_chan_account_id_consumer_id_namesp_key": { + "enabled": true, + "fingerprint": "50340c80ddd2149980725bc3734ea5268e66170fe949b2a4f8a3ba3de9b78843" + }, + "constraint:reporting_reconciliation_changes.reporting_reconciliation_changes_pkey": { + "enabled": true, + "fingerprint": "f790c4112839c47bd34693939298e2ad72b99b0d5dab05aa3f60fabda72635b9" + }, + "constraint:reporting_reconciliation_changes.reporting_reconciliation_changes_seq_check": { + "enabled": true, + "fingerprint": "f3b7f9418faeb18b45b744951da831dd5e63faa3cdf6bd2531dcf8aafa4fbf84" + }, + "constraint:reporting_reconciliation_heads.reporting_reconciliation_head_account_id_consumer_id_max_s_fkey": { + "enabled": true, + "fingerprint": "15051991329aa6777a7e0a91dadb644d00229f28bfdd06adf2507ed3b4bf679e" + }, + "constraint:reporting_reconciliation_heads.reporting_reconciliation_heads_max_sequence_check": { + "enabled": true, + "fingerprint": "ee47acec8d450eed7f99b637596847d21bc449a12317a605fd6b6cb0fad6aa79" + }, + "constraint:reporting_reconciliation_heads.reporting_reconciliation_heads_pkey": { + "enabled": true, + "fingerprint": "4d853add149814edf9eadd3f752bf43f1f7164cba2bd079f0349d4d540a7f20c" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_consumer_id_names_fkey": { + "enabled": true, + "fingerprint": "cd64aa115b1b5e4c228fd2692533f86a726b6b22c0e712748cf07d8d7dddd328" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_delivery_config_i_fkey": { + "enabled": true, + "fingerprint": "4761471309c35947c8d3928dce4595b4d455e7de6166aeb78a5de220465cb64a" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_record_kind_change_key": { + "enabled": true, + "fingerprint": "76cb249d3556f2449aa03c6f83be74c411a1922a73eb54a2be320a26ede5bcc4" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_reporting_adjustm_fkey": { + "enabled": true, + "fingerprint": "7d9d5fa1d80e5163335d9fb45c0a16da0be0b5fc09d1a7b6e5c58477cf85bc51" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_reporting_obligat_fkey": { + "enabled": true, + "fingerprint": "a16c879766a899d572875db28fb19e13779063dfda05a1662c1919f38ed42ab5" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_reporting_revisio_fkey": { + "enabled": true, + "fingerprint": "6336638ff31659c0ea0a0fa6c67a7ed65e1b1bf792d17b5fbaec138fa6869c17" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_attempt_number_check": { + "enabled": true, + "fingerprint": "f9dafc82e1fb7cbb9f2557001d24a8f78ecd56b79335ed1d32b655ff1b2ded90" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_check": { + "enabled": true, + "fingerprint": "1e8dbf37ffb77046b6a432af698ae8458f71ada353cf58af574e0845c8d13bc0" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_check1": { + "enabled": true, + "fingerprint": "534c3793815fbda8c64aaa9a137bbca4ffe854db0f79e85a47ed80dc97f6695c" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_check2": { + "enabled": true, + "fingerprint": "8af513d97a5ea978095e4f51bb53083c3a871f9cee075cef86d14fc1721bbe9c" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_check3": { + "enabled": true, + "fingerprint": "78c62ecc08e4fa291b06e52b1d1c78f089f61044fe25672431771f08c9d8a82e" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_check4": { + "enabled": true, + "fingerprint": "766e29c1594f6af20de0c90bb26e6695e226939b4f021cba062fb4e2e8bd3062" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_content_sha256_check": { + "enabled": true, + "fingerprint": "9640003b982e9fc0198392d3f62b9faca4c5f1d433b26bf9d986c82d26582133" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_payload_check": { + "enabled": true, + "fingerprint": "51b31fa955a6416878156a4893f8fdcfd7024371f554ea868461e2278e938f83" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_pkey": { + "enabled": true, + "fingerprint": "87c17db7959eab2073cc98b26ad22aa8b11b2fdce629b52e1916ea1986190f33" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_receipt_status_check": { + "enabled": true, + "fingerprint": "a7940c52ae4d16eadd8965171e01c52bb1faaca7e6ee09af1d95b9b816161cb2" + }, + "constraint:reporting_reconciliation_records.reporting_reconciliation_records_record_kind_check": { + "enabled": true, + "fingerprint": "0bd2d2e34687677c807a70e4f29faaebd80549e7aa596cb115eb3822757dcc15" + }, + "constraint:reporting_reconciliation_records.reporting_record_exact_adjustment": { + "enabled": true, + "fingerprint": "542c61855eff08390ca3e1b41d895bdb31e8238d5583ebb28a299548773472c3" + }, + "constraint:reporting_reconciliation_records.reporting_record_exact_obligation": { + "enabled": true, + "fingerprint": "f2fd7665f778fae1bec63184c8468e0212256b25009a44659abd7284bc54b8a7" + }, + "constraint:reporting_reconciliation_records.reporting_record_exact_revision": { + "enabled": true, + "fingerprint": "a0dca2cc4a27c9d4380eadb1513d30fe6383849657fc5e0d0f93c4b4c4ed4c08" + }, + "constraint:reporting_reconciliation_records.reporting_record_identity_shape": { + "enabled": true, + "fingerprint": "4d4e726709b4c97a66ddf41cbd2730468d298bf8c12de682ac43d29d3f461a3a" + }, + "constraint:reporting_reconciliation_records.reporting_record_transactional_feed": { + "enabled": true, + "fingerprint": "7fb2d5772f19e9e102db16010fd4202fb7407d334a60cfbdf0d7bac4344f9788" + }, + "constraint:reporting_restatement_checkpoints.reporting_restatement_checkpoints_next_observation_check": { + "enabled": true, + "fingerprint": "73653a8ce9b807e164c709b5f435570f33b2c7cb0c572f474fb02e5aa2b244e0" + }, + "constraint:reporting_restatement_checkpoints.reporting_restatement_checkpoints_pkey": { + "enabled": true, + "fingerprint": "bb4f6ac8f57972cf428329ac480aeceef314f15cb01d4905ba8e330acdb1cb86" + }, + "constraint:reporting_restatement_checkpoints.reporting_restatement_checkpoints_reporting_obligation_id_fkey": { + "enabled": true, + "fingerprint": "52080b2461e9d04a0fc27011d8aa726299ff01b18975d49f00c9644137b78b06" + }, + "constraint:reporting_revision_rows.reporting_revision_rows_pkey": { + "enabled": true, + "fingerprint": "692fea7e282c2d0fbebecc2b97751399d8d736412e5cf9e371ebcf31702dd403" + }, + "constraint:reporting_revision_rows.reporting_revision_rows_reporting_revision_id_fkey": { + "enabled": true, + "fingerprint": "bcc8b0b2e48041ae0f82bddc58265c7d7b9e59b87c971b8b1b166a950633cd49" + }, + "constraint:reporting_revisions.reporting_revision_exact_obligation": { + "enabled": true, + "fingerprint": "a16c879766a899d572875db28fb19e13779063dfda05a1662c1919f38ed42ab5" + }, + "constraint:reporting_revisions.reporting_revision_exact_predecessor": { + "enabled": true, + "fingerprint": "3cb1592e1e5bb1fd77955efe4598fc6cce7d6639ef0fb5bbdd48dc9ad8ad17d5" + }, + "constraint:reporting_revisions.reporting_revisions_pkey": { + "enabled": true, + "fingerprint": "662ad170109383adfe0ff8802d20f28909467d25f0700014b1e0ef14b1ccc963" + }, + "constraint:reporting_revisions.reporting_revisions_reporting_obligation_id_fkey": { + "enabled": true, + "fingerprint": "52080b2461e9d04a0fc27011d8aa726299ff01b18975d49f00c9644137b78b06" + }, + "constraint:reporting_status_checkpoints.reporting_status_checkpoints_pkey": { + "enabled": true, + "fingerprint": "ba41d37e9a6c4740f48a90a0b843ffe96fd029970848fd56267c7a3f8108e50b" + }, + "constraint:reporting_status_checkpoints.reporting_status_checkpoints_sequence_check": { + "enabled": true, + "fingerprint": "6e6eb3e303ee49760656d73ef75421b724865ca5d916ed6efb8eb3fb113d72fe" + }, + "constraint:reporting_status_dirty.reporting_status_dirty_account_id_consumer_namespace_scope__key": { + "enabled": true, + "fingerprint": "7e5073537c9310882d6d7b17d1c5148c73d296e6af775a4ac260450816cddd9c" + }, + "constraint:reporting_status_dirty.reporting_status_dirty_cause_generation_check": { + "enabled": true, + "fingerprint": "d93961696665129cf254c93dae4377741c5cef5fe9d373c6a5cb36226e5a69e2" + }, + "constraint:reporting_status_dirty.reporting_status_dirty_pkey": { + "enabled": true, + "fingerprint": "146d890eadd6744b5c6a7bfdbe840580d36e46d49ece8c02eff7c076f4edaec3" + }, + "constraint:reporting_status_dirty_heads.reporting_status_dirty_heads_max_sequence_check": { + "enabled": true, + "fingerprint": "0fc3b5ce464cce8adb79b8a8e8379c84713d2c37902b8b969cbaa0ac5322b0ae" + }, + "constraint:reporting_status_dirty_heads.reporting_status_dirty_heads_pkey": { + "enabled": true, + "fingerprint": "e65d70e61c89a93d66c4c4f4c59ef755d0ff526b0fcdd1d9ae6d830d2abea913" + }, + "constraint:reporting_webhook_attempt_heads.reporting_webhook_attempt_heads_last_attempt_check": { + "enabled": true, + "fingerprint": "5bf9c354d680faec2bf3155f8c9b27323d7893564b30688a039ba19fc8b8d5d3" + }, + "constraint:reporting_webhook_attempt_heads.reporting_webhook_attempt_heads_pkey": { + "enabled": true, + "fingerprint": "74131da4e7afd9d285b8e28c8daa9681c17e1fde5a81077882cf251bb0095a88" + }, + "constraint:reporting_webhook_attempt_heads.reporting_webhook_attempt_heads_principal_id_check": { + "enabled": true, + "fingerprint": "bfc70b4b01cc74b9c93c630928d7cde9b0134c3d6614e2cbb326604c865c2cac" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_account_id_principal_id_delivery_key": { + "enabled": true, + "fingerprint": "0c785b232273d08598a24ab5764dce4925a954dd670e6f2cffa6e0dfd24c0133" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_account_id_principal_id_subscri_fkey": { + "enabled": true, + "fingerprint": "1d5ff32514b04141029a2411b5ebbcf8a7f5cb9a60d7b1b84486882ef0af69ab" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_attempt_check": { + "enabled": true, + "fingerprint": "e05925f8a9fe41263902b6b2a72bca02958627e079787e99987820a52cb47d71" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_payload_size_bytes_check": { + "enabled": true, + "fingerprint": "95548ff5519cec8b0ff110ad66f7b77d77e6015ef8c54a52e09cd4d7d80a7118" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_pkey": { + "enabled": true, + "fingerprint": "7dc665e485922ab5f59babc63efb2216f3a0192a69b91d8d4728784e63181020" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_response_time_ms_check": { + "enabled": true, + "fingerprint": "6698890e9bba25670f73e3a5a7a7a7f9b664ab2571637eba582f22863c92db4d" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_attempts_status_check": { + "enabled": true, + "fingerprint": "1c821d6bf41e5137a6263eae308d0a8648b7f0c95154724e5430e8c630aec30e" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_completion": { + "enabled": true, + "fingerprint": "adc02762a63dcbf72330ca318884e6236fb2418cb7e1111f861ee74f98b53316" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_identity": { + "enabled": true, + "fingerprint": "1e3f9725baa47d118c5b2efeeafbd5a2429400c1f667188fd14c4f2795114bf8" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_outcome": { + "enabled": true, + "fingerprint": "cc0cc61f029bb3b28629e12a42c6f6ddbfd943156e1b90e5eeb2346aed678e00" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_timestamps": { + "enabled": true, + "fingerprint": "0175d921479ed64020d88874f3b6ac822a9979f3ec8c3780d89bb9e0e3e3556c" + }, + "constraint:reporting_webhook_attempts.reporting_webhook_url_safe": { + "enabled": true, + "fingerprint": "3dd08bbc446317552434fb91bebd96e2a534395454b436910450f909ce2ab88e" + }, + "function:reporting_adjustment_evidence_immutable()": { + "enabled": true, + "fingerprint": "f07c8764de8ffd0eec4829ee0389e4f9cb3daba6d760d0435d7f09b0c5606802" + }, + "function:reporting_canonical_evidence_immutable()": { + "enabled": true, + "fingerprint": "60d5d471cf76d0bfb814811da795eb1a478a4ab58c2aa38a5bf6b26a06165340" + }, + "function:reporting_canonical_json(document jsonb)": { + "enabled": true, + "fingerprint": "9aadec512a1ad13b8dfc93cbd412fc2f72164976055c5730c226350d8d3aa38b" + }, + "function:reporting_identity_sha256(value text)": { + "enabled": true, + "fingerprint": "a5b716ad551fd1b0ff258fae54f4574c69658caae7a745e3bcce11273335463e" + }, + "function:reporting_iso_utc(moment timestamp with time zone)": { + "enabled": true, + "fingerprint": "53ec8972775bce31d1dfb5abdba8f0467d8776d6c0341f7b88fcae1404b57f75" + }, + "function:reporting_notification_immutable()": { + "enabled": true, + "fingerprint": "3da21cac071d8b97da85302b32198a0e933c259691d0db2fb2aac2123d39a448" + }, + "function:reporting_obligation_currency_immutable()": { + "enabled": true, + "fingerprint": "2ddaefa54db934ce7d606607bc92399fc92f2a114fee5224f8379da842042a3f" + }, + "function:reporting_payload_keys(document jsonb)": { + "enabled": true, + "fingerprint": "1049c4db7c4e7fef290ec4a12dd9b0f6eeb925f1c2b29c00354b66528d45823d" + }, + "function:reporting_payload_sha256(document jsonb)": { + "enabled": true, + "fingerprint": "c9e9f7a68ae6f3a7fb3105cf09cc3e3cd7d22e550c0cdb48e4c4dbff125e7a6f" + }, + "function:reporting_receipt_advance()": { + "enabled": true, + "fingerprint": "d802097ef1f888f864fbcb08e9e7c1915b7b3b0d1d393c81cc5f3c8db4bb4259" + }, + "function:reporting_receipt_head_exact()": { + "enabled": true, + "fingerprint": "3ff14fa969904df44702939e705bf2a243c8be7d1b037373a0248ab1d8b95b14" + }, + "function:reporting_receipt_terminal()": { + "enabled": true, + "fingerprint": "2bf731af0c689cc40ddd71150c9ae724e99510326d864eeb1ffa57951a1149b6" + }, + "function:reporting_reconciliation_change_guard()": { + "enabled": true, + "fingerprint": "51d14683d92777a8864452dd2a5f3237f499041b156ed362176a98601b3d2e18" + }, + "function:reporting_reconciliation_evidence(r reporting_reconciliation_records)": { + "enabled": true, + "fingerprint": "cfa4e3f5b4c6505d2fe72d9828d95d3760555b1957b6d0754e3bac86c925bfdf" + }, + "function:reporting_reconciliation_guard()": { + "enabled": true, + "fingerprint": "f0582535757c794b4be6754cd109712d6c6f5a3d74abaf2c0aafda7adce8bde8" + }, + "function:reporting_reconciliation_head_guard()": { + "enabled": true, + "fingerprint": "56de93c3e85639aa31d89fd9a80055a3f79ffb64769e142c7e6b0085683f289a" + }, + "function:reporting_reconciliation_immutable()": { + "enabled": true, + "fingerprint": "4305b129a5a1a68086ca7a2cb718a139a33c7e7faddcfbb5b2093e65380b932d" + }, + "function:reporting_reconciliation_reference_immutable()": { + "enabled": true, + "fingerprint": "24b73bfc41f5333b929ad416589aeb3e25183f089ca236793e3adc0421db8b8c" + }, + "function:reporting_reconciliation_validate(r reporting_reconciliation_records)": { + "enabled": true, + "fingerprint": "74e1314b7f8b3e67cae5e8f4ead36c15521cb2c7c5edc6bc60065bb8318a06d9" + }, + "function:reporting_sorted_strings(document jsonb)": { + "enabled": true, + "fingerprint": "bb166cad808b9decee7f266dcc42e2dd86852e21b1436822ef8426d5d1f18783" + }, + "function:reporting_sorted_totals(document jsonb)": { + "enabled": true, + "fingerprint": "e83cdffca675949e03854e75637422a788983ef63c99a0f131aa911a6836df9b" + }, + "function:reporting_webhook_attempt_guard()": { + "enabled": true, + "fingerprint": "b3fc0bd8e034e565804d3f9fa5ba4e40ae2ac606da2f6c558073d223fb5a8fd4" + }, + "function:reporting_webhook_head_guard()": { + "enabled": true, + "fingerprint": "4e448e0df3d56a5b04bc01ca41a4c5afbb8e601a4699d2e6dd0e3922ed2f0bf8" + }, + "function:reporting_wire_digest(document jsonb)": { + "enabled": true, + "fingerprint": "ad6f96bdd4fe08095e66cb5911cb1112ca20cacfa91ce5a63fd77d56ea0c1133" + }, + "index:reporting_adjustments.reporting_adjustments_account_identity": { + "enabled": true, + "fingerprint": "f23ac96736f68fa32693ee93025d57427ddd28646761d03dce5717d8a7842bcb" + }, + "index:reporting_adjustments.reporting_adjustments_pkey": { + "enabled": true, + "fingerprint": "67204c6e9bd15018f6f61595fa8fad67a2aec5b6898a3fb030afca1266267fc9" + }, + "index:reporting_adjustments.reporting_adjustments_revision_identity": { + "enabled": true, + "fingerprint": "d7d7808d6204a2dae9dd7de69c7233e94480bdd562145cb723da2df9363c8078" + }, + "index:reporting_adjustments.reporting_adjustments_revision_idx": { + "enabled": true, + "fingerprint": "46ce60c8c6dab29fd922f21c36551d4eb8df9c6b2de295a3d8ecbafb37827759" + }, + "index:reporting_configurations.reporting_configurations_account_idx": { + "enabled": true, + "fingerprint": "b6cb1a09fd7fbb70505c014b8cd00e783cd0959898791c87f2ef3d9a0e3736c6" + }, + "index:reporting_configurations.reporting_configurations_lease_idx": { + "enabled": true, + "fingerprint": "fc8367514a3505f9680174e432507e45b7928314d6403d9854d90a4d3e61f10b" + }, + "index:reporting_configurations.reporting_configurations_pkey": { + "enabled": true, + "fingerprint": "a7d15c48660d485c211ef08bbe1fdb9a2b205aa1bede46044e923b297b268e53" + }, + "index:reporting_consumer_statuses.reporting_consumer_statuses_chain_idx": { + "enabled": true, + "fingerprint": "e1f0e4a0c75fb1c54ca3fa0f92a3c65e415e3d3a1ed456c8eb036ce3ede503d5" + }, + "index:reporting_consumer_statuses.reporting_consumer_statuses_one_leaf": { + "enabled": true, + "fingerprint": "553f221b882d374a339d2b08381f4436aed247cc1efca6e1cc8a97d014acbeb4" + }, + "index:reporting_consumer_statuses.reporting_consumer_statuses_one_successor": { + "enabled": true, + "fingerprint": "4fc2c0abdd40544e7151b80b2923f7e3c780d9e45ef2ce6f3da87de4597efb1c" + }, + "index:reporting_consumer_statuses.reporting_consumer_statuses_pkey": { + "enabled": true, + "fingerprint": "e110666dcd6d1848d0379732437a93ff86102bbbdb2c8ef79b4b200459405b86" + }, + "index:reporting_issue_lifecycle.reporting_issue_lifecycle_issue_id": { + "enabled": true, + "fingerprint": "80a0a03c6dc79a50c64a7e465080244c25d84a0c49fdbb78bf0bc456396a80d4" + }, + "index:reporting_issue_lifecycle.reporting_issue_lifecycle_one_live": { + "enabled": true, + "fingerprint": "9c0c8ca76531ed843b76ad957c484f2a71d397f2e40c5de33f2b6f8ce83d71e8" + }, + "index:reporting_issue_lifecycle.reporting_issue_lifecycle_pkey": { + "enabled": true, + "fingerprint": "72f8a03edcd9549648f5f4a9289f8ba576491418ea26cfa47f1372634f41d2dc" + }, + "index:reporting_issue_status_scopes.reporting_issue_status_scopes_pkey": { + "enabled": true, + "fingerprint": "3222f1e0633cbc50fe1d66cb6cbb202541868317a6221c9979241e8bd31eebf7" + }, + "index:reporting_ledger_changes.reporting_ledger_changes_feed_idx": { + "enabled": true, + "fingerprint": "59414be8bfaeb5747aa4e245e0923b466f8e3f9eb56fae69931dc6458a04523f" + }, + "index:reporting_ledger_changes.reporting_ledger_changes_pkey": { + "enabled": true, + "fingerprint": "032e23b9547abed0a81323185d2b2d1016c378e2e411be09f15df61d3d45319b" + }, + "index:reporting_ledger_changes.reporting_ledger_changes_record": { + "enabled": true, + "fingerprint": "42fa0a6f20465ebb6b4a73fb582865dac99264c909cc43b29bfd0cea4397216a" + }, + "index:reporting_notification_deliveries.reporting_notification_delive_account_id_consumer_namespac_key1": { + "enabled": true, + "fingerprint": "05f197f21b4fb579033d3601f49018296eef8e1bf4dd6c9c3fc6cf6a1fb7e68d" + }, + "index:reporting_notification_deliveries.reporting_notification_delive_account_id_consumer_namespace_key": { + "enabled": true, + "fingerprint": "0e032d675db52def1db5a7163971950928c2aa156c6c0f32a48d50b143d2d326" + }, + "index:reporting_notification_deliveries.reporting_notification_deliveries_due": { + "enabled": true, + "fingerprint": "f902955569fde774991d0f7e79ca41a1246ed6628e537762cf9725e40db2ced8" + }, + "index:reporting_notification_deliveries.reporting_notification_deliveries_pkey": { + "enabled": true, + "fingerprint": "d5b250c5ad9c2655ab6d0d1db199df263f170f75e7a240efb8c9ca4f1701b90f" + }, + "index:reporting_notification_events.reporting_notification_events_account_id_consumer_namespace_key": { + "enabled": true, + "fingerprint": "0cf8a709f1483c4084ba4a7b98fb96b6b07e028cf63f56f8bd79be7a527edb0c" + }, + "index:reporting_notification_events.reporting_notification_events_pkey": { + "enabled": true, + "fingerprint": "24c35a994cdc117a62bbad3705c969bbcff16a6b8ecf0779e44fd65e61870fd6" + }, + "index:reporting_notification_expansions.reporting_notification_expansions_due": { + "enabled": true, + "fingerprint": "cc3edd82cccbdc303c3a152cafd88c26fe01e33148044e1481be0bb88ac5565f" + }, + "index:reporting_notification_expansions.reporting_notification_expansions_pkey": { + "enabled": true, + "fingerprint": "102f2355d14679cb33e54026a0ce6fbfffd2ab5837e14359c3d7e8dda86bb556" + }, + "index:reporting_obligations.reporting_obligations_account_identity": { + "enabled": true, + "fingerprint": "7f324b29eb6a83105cb332ad27863f586c0b254b21d34e01f16e46b888483a52" + }, + "index:reporting_obligations.reporting_obligations_account_idx": { + "enabled": true, + "fingerprint": "aa9c21a45902028ce098285386a46e392cbf3d89ef38fba4ab66149ab85a244b" + }, + "index:reporting_obligations.reporting_obligations_generation_identity": { + "enabled": true, + "fingerprint": "c545d8cf59d79612553aee84fa63c79b3b917e7105c800757eea46bd3dc0e6ca" + }, + "index:reporting_obligations.reporting_obligations_period_key": { + "enabled": true, + "fingerprint": "a75c17a83eedbec888a1af8ac15367ca4cfa32b1d3454c33ad39a67d9bbb81b1" + }, + "index:reporting_obligations.reporting_obligations_pkey": { + "enabled": true, + "fingerprint": "caf2ae913d11b2bfb138ed9a917b51601f9724727330a36a2f9de6d5e2c12e6f" + }, + "index:reporting_receipt_heads.reporting_receipt_heads_pkey": { + "enabled": true, + "fingerprint": "0ee78bb15b687174b6d5050d9b8714635ca826f24f5f74eca2d8742d13059834" + }, + "index:reporting_reconciliation_changes.reporting_reconciliation_chan_account_id_consumer_id_names_key1": { + "enabled": true, + "fingerprint": "b3927dbf207bb8bbf2d03f1bcf697d14524616c49a702aad1a7dbdd556ec2def" + }, + "index:reporting_reconciliation_changes.reporting_reconciliation_chan_account_id_consumer_id_namesp_key": { + "enabled": true, + "fingerprint": "b5235d22aa7a8c882d3a197f868793a732807342a2635c75799df7341809cd6d" + }, + "index:reporting_reconciliation_changes.reporting_reconciliation_changes_pkey": { + "enabled": true, + "fingerprint": "2531ddb1f0f183d6b399ed6e68f74901a98d531e65b1ded332657733d755886a" + }, + "index:reporting_reconciliation_heads.reporting_reconciliation_heads_pkey": { + "enabled": true, + "fingerprint": "a4f229652de56ee3abe6bac3050b373626de8151317644bbe174e466f8a9d0c2" + }, + "index:reporting_reconciliation_records.reporting_materialization_attempt_identity": { + "enabled": true, + "fingerprint": "acc521b9e1b35324b01373b2efeefc803cc3a40f126e8033deab1a38ae9a2147" + }, + "index:reporting_reconciliation_records.reporting_receipt_one_root": { + "enabled": true, + "fingerprint": "b26a88aa303f7cc8d50b2eb3252d5efa86081c2ecaed5351d92008d228c4b38d" + }, + "index:reporting_reconciliation_records.reporting_receipt_one_successor": { + "enabled": true, + "fingerprint": "a3ac5ab34f170cee75c40f3b6d3180bff36206b0d16f4a2e7d1e4983f7f31d16" + }, + "index:reporting_reconciliation_records.reporting_receipts_exact_identity": { + "enabled": true, + "fingerprint": "80f5ba2acea6892e4bb53c4db73674959116d8cdfa1b699dc8e31bf0b71532ad" + }, + "index:reporting_reconciliation_records.reporting_reconciliation_feed_identity": { + "enabled": true, + "fingerprint": "399ac4db6c12239978d5602cf3387b5a3a7f4e626907b22da8d25979e849e2ec" + }, + "index:reporting_reconciliation_records.reporting_reconciliation_graph": { + "enabled": true, + "fingerprint": "77dee7a5ac2292163edfc46805a61ea0a94fc94fc0690d253d3b4a8351543f00" + }, + "index:reporting_reconciliation_records.reporting_reconciliation_reco_account_id_record_kind_change_key": { + "enabled": true, + "fingerprint": "a4271ed11defa8930f3e9289b7e287979bde3cc9a616ba5381a9a7cbe3968783" + }, + "index:reporting_reconciliation_records.reporting_reconciliation_records_pkey": { + "enabled": true, + "fingerprint": "497709a0cc3e043ba4bf3840516a9ba136dba4ef9429c7e7d3c79bfa7f188730" + }, + "index:reporting_restatement_checkpoints.reporting_restatement_checkpoints_account_idx": { + "enabled": true, + "fingerprint": "83598ed430f624be6b8714f2e9835b938d52da0c1a0a45a815038fb3fbdc8150" + }, + "index:reporting_restatement_checkpoints.reporting_restatement_checkpoints_pkey": { + "enabled": true, + "fingerprint": "92c832de2a092bf62d130b5063fb48cf66df260a6971689439241d86dfb44c97" + }, + "index:reporting_revision_rows.reporting_revision_rows_pkey": { + "enabled": true, + "fingerprint": "7b76e15e30c1656c95f5253558e1ba2edb375f984c53f25e6f22754d74e8182e" + }, + "index:reporting_revisions.reporting_revisions_account_identity": { + "enabled": true, + "fingerprint": "091b1cd25482529511dad151ba66bed38834f14faf056a3ded85593db75cde82" + }, + "index:reporting_revisions.reporting_revisions_obligation_identity": { + "enabled": true, + "fingerprint": "4f967c51df751697293e5b285e655e61dc8afa97bf3bb01befa790c8ca4a4907" + }, + "index:reporting_revisions.reporting_revisions_obligation_idx": { + "enabled": true, + "fingerprint": "0ce3c777182890ba2137b911b2e6abbb39ba75ca6b758634df4952e7cae51e2b" + }, + "index:reporting_revisions.reporting_revisions_one_official": { + "enabled": true, + "fingerprint": "eba2840468e52eeb5b4e578ca6829f128d4b768dcb55cb60a3435d80fda3d5ca" + }, + "index:reporting_revisions.reporting_revisions_one_successor": { + "enabled": true, + "fingerprint": "dda635d11046a33559b6ba92485cbd83c75d5ab409c385357277eef4e82a49e0" + }, + "index:reporting_revisions.reporting_revisions_pkey": { + "enabled": true, + "fingerprint": "bc7593406f25a5a75bf4e680b5ab6183028a5bf5321523971fe0da19b28da43e" + }, + "index:reporting_status_checkpoints.reporting_status_checkpoints_pkey": { + "enabled": true, + "fingerprint": "373a898bc32bc3bffc4e0a34ae8b314e2ec7a447d48e236721b43f6aa9a8ba91" + }, + "index:reporting_status_dirty.reporting_status_dirty_account_id_consumer_namespace_scope__key": { + "enabled": true, + "fingerprint": "96efb9f1fdd5e535d23b581dd9d85b5ae2bf5ba83533c515dc350de85ab414b9" + }, + "index:reporting_status_dirty.reporting_status_dirty_pkey": { + "enabled": true, + "fingerprint": "b22dedd20e6033fb986dbf649310667ad446fe062445da6c280cbed084486b1d" + }, + "index:reporting_status_dirty_heads.reporting_status_dirty_heads_pkey": { + "enabled": true, + "fingerprint": "ca7b71b50f875856061764c9101f29d91302e9d9ed9798e090757a0011e8bacb" + }, + "index:reporting_webhook_attempt_heads.reporting_webhook_attempt_heads_pkey": { + "enabled": true, + "fingerprint": "47c79b3feadbf39b23b5d68a27464e81a8284c454d72a2b3ad0044dbca02ea70" + }, + "index:reporting_webhook_attempts.reporting_webhook_activity_newest": { + "enabled": true, + "fingerprint": "8729e9c3c503c6ee6998433e361c6402836e116af84d092324589856a1670854" + }, + "index:reporting_webhook_attempts.reporting_webhook_activity_retention": { + "enabled": true, + "fingerprint": "aa7bf060ba858702b71b0cac060d15a5f354ee4b28952583b17718343002bd44" + }, + "index:reporting_webhook_attempts.reporting_webhook_attempts_account_id_principal_id_delivery_key": { + "enabled": true, + "fingerprint": "c7ad23db8b2c6ddf1b12cef99ced2b185edab7c3a42437daebb746c019564b27" + }, + "index:reporting_webhook_attempts.reporting_webhook_attempts_pkey": { + "enabled": true, + "fingerprint": "fc023ae7479069ef035620c70d4c1421d06ec6131a9e35bb185a154363809cf0" + }, + "table:reporting_adjustments": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_configurations": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_consumer_statuses": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_issue_lifecycle": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_issue_status_scopes": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_ledger_changes": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_notification_deliveries": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_notification_events": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_notification_expansions": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_obligations": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_receipt_heads": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_reconciliation_changes": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_reconciliation_heads": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_reconciliation_records": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_restatement_checkpoints": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_revision_rows": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_revisions": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_status_checkpoints": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_status_dirty": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_status_dirty_heads": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_webhook_attempt_heads": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "table:reporting_webhook_attempts": { + "enabled": true, + "fingerprint": "1f824779ff80f110344420b019786663d8c9beaad230da90e0439795e734ccda" + }, + "trigger:reporting_adjustments.reporting_adjustment_evidence_immutable": { + "enabled": true, + "fingerprint": "70bd402e03e71714681d5caf0acf8ce5a1fae64cf5d14caa485c6761080e0e25" + }, + "trigger:reporting_adjustments.reporting_reconciliation_reference_immutable": { + "enabled": true, + "fingerprint": "11b33052e2c9e40c162bdbfdcc10188e016a8ea1bc9bd66120cc0b69298d2a81" + }, + "trigger:reporting_configurations.reporting_reconciliation_reference_immutable": { + "enabled": true, + "fingerprint": "0e1eeb5094b5e24c152145e8c5f02c1a6f3493af7c541a38e7fa5ca7063d6e13" + }, + "trigger:reporting_notification_events.reporting_notification_immutable": { + "enabled": true, + "fingerprint": "752b3e61b2425c7408bbc097e6fbdb3735f2495aff3268f7daa72d86628cea5b" + }, + "trigger:reporting_obligations.reporting_obligation_currency_immutable": { + "enabled": true, + "fingerprint": "d02a397affaf6bfc4ec468a920029898b5f0819ae9d581e18b014f29976cadc0" + }, + "trigger:reporting_obligations.reporting_reconciliation_reference_immutable": { + "enabled": true, + "fingerprint": "a87b3cad66634291a94a80735db659ce9aabfe7523e6fbd5daa9408db400447f" + }, + "trigger:reporting_receipt_heads.reporting_receipt_head_exact": { + "enabled": true, + "fingerprint": "5600cacd5c703a960f4f6d866c89c5505585a1103f98df174500ad82138f170a" + }, + "trigger:reporting_receipt_heads.reporting_receipt_terminal": { + "enabled": true, + "fingerprint": "ab8e2a876bfa82c433fddc35b02ab9c6aa0aca69144e7a284f48c05a9e7f0dd9" + }, + "trigger:reporting_reconciliation_changes.reporting_reconciliation_change_guard": { + "enabled": true, + "fingerprint": "3410a149d0eeb7a4bbd463aec25892a8a435083fec7d0109d2f854f3abe05c33" + }, + "trigger:reporting_reconciliation_changes.reporting_reconciliation_change_immutable": { + "enabled": true, + "fingerprint": "eddc87e0f371e10c487f0ae3d755b8090da77f1f6ea78922dfa787152a37515e" + }, + "trigger:reporting_reconciliation_heads.reporting_reconciliation_head_guard": { + "enabled": true, + "fingerprint": "e9133d6ff27212ad9063deda358da1b6924dbad7fe282b6c5e1c48dab5d819eb" + }, + "trigger:reporting_reconciliation_records.reporting_receipt_advance": { + "enabled": true, + "fingerprint": "5b7031343fbc033de18e0f86820c10bcebf7678e1b2a94ff699b4dfee69b7e44" + }, + "trigger:reporting_reconciliation_records.reporting_reconciliation_guard": { + "enabled": true, + "fingerprint": "0fb9ec641c3d73f6a392463124cab90d141b4845f710f3919f6ae25e25580812" + }, + "trigger:reporting_reconciliation_records.reporting_reconciliation_immutable": { + "enabled": true, + "fingerprint": "ddc7d20fc1283201bdb122d19850b7dbfb3c2aabc272c4edd77fe8aefb9efed6" + }, + "trigger:reporting_revisions.reporting_canonical_evidence_immutable": { + "enabled": true, + "fingerprint": "adbe244b3ba87012da3111a0cc82527b4dfbe6da20163597a61cbaa84e68c3e8" + }, + "trigger:reporting_revisions.reporting_reconciliation_reference_immutable": { + "enabled": true, + "fingerprint": "8660c01502583098822e5af226a33a1379a8c11c72386ea62284404107fc2579" + }, + "trigger:reporting_status_dirty.reporting_status_dirty_immutable": { + "enabled": true, + "fingerprint": "f323c5742c7a73eebf15b1044d1f20c1665e90282888c87dd13c5d112f0cd7d7" + }, + "trigger:reporting_webhook_attempt_heads.reporting_webhook_head_guard": { + "enabled": true, + "fingerprint": "11b09ab20e2ee25137306ef54336638fb4c91daa44d3a54bbd2be1d394015a17" + }, + "trigger:reporting_webhook_attempts.reporting_webhook_attempt_guard": { + "enabled": true, + "fingerprint": "cf64ef22262d90957ce590e9e7edae4b838dba71ff8401f979a7490d4bb44acb" + } +} diff --git a/src/adcp/reporting/outbox/routing.py b/src/adcp/reporting/outbox/routing.py index 0a8cf29c0..642129e78 100644 --- a/src/adcp/reporting/outbox/routing.py +++ b/src/adcp/reporting/outbox/routing.py @@ -25,6 +25,7 @@ event_storage, validate_notification_payload, ) +from adcp.reporting.outbox.identity import canonical_consumer from adcp.reporting.outbox.models import DeliveryBinding, StoredDelivery from adcp.signing.crypto import ALG_ED25519, ALG_ES256, ALLOWED_ALGS, PrivateKey from adcp.webhook_sender import PreparedWebhook @@ -73,7 +74,7 @@ class ReportingNotificationSubscription: def __post_init__(self) -> None: try: principal_reference(self.account_id) - principal_reference(self.principal_id) + canonical_consumer(self.principal_id) reporting_identifier(self.subscriber_id, maximum=64) for value in ( self.configuration_revision, @@ -101,20 +102,29 @@ def __post_init__(self) -> None: and type(self.authentication) is not ReportingLegacyAuthentication ): raise ValueError - if type(self.url) is not str or not self.url.isprintable(): + if type(self.url) is not str or not self.url.isprintable() or len(self.url) > 8192: raise ValueError parsed = httpx.URL(self.url) + # Percent-encoding expands the canonical form, so the bound has to + # hold on the value that is actually stored, sanitized for activity + # and length-checked in SQL. Rejecting it here keeps a deterministic + # failure at registration instead of quarantining every expansion. + canonical = str(parsed) if ( parsed.scheme != "https" or not parsed.host or parsed.userinfo or parsed.fragment or parsed.port not in (None, 443) + or len(canonical) > 8192 ): raise ValueError - object.__setattr__(self, "url", str(parsed)) + object.__setattr__(self, "url", canonical) except (ValueError, TypeError, httpx.InvalidURL): - raise ReportingNotificationError("invalid_configuration") from None + pass + else: + return + raise ReportingNotificationError("invalid_configuration") @property def auth_mode(self) -> str: diff --git a/src/adcp/reporting/outbox/support.py b/src/adcp/reporting/outbox/support.py new file mode 100644 index 000000000..822eba9d0 --- /dev/null +++ b/src/adcp/reporting/outbox/support.py @@ -0,0 +1,105 @@ +"""Independent reporting and list-accounts activity capability gates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from adcp.reporting.ledger.notification_models import ReportingNotificationError +from adcp.reporting.outbox.activity import ReportingActivityProjector + +if TYPE_CHECKING: + from adcp.reporting.ledger.store import ReportingLedgerStore + from adcp.reporting.outbox.worker import ReportingNotificationWorker + + +@dataclass(frozen=True) +class ReportingActivitySupport: + """The concrete writer/store/projector chain scheduled by the adopter. + + Mount this as ``reporting_activity=`` on the platform server factory. Mount + ``projector`` separately as ``account_activity=`` to enable list-accounts + enrichment. Neither relationship notification claims nor memory components + supply evidence of durable reporting activity. + """ + + worker: ReportingNotificationWorker + ledger: ReportingLedgerStore + projector: ReportingActivityProjector | None = None + + async def durable(self) -> bool: + from adcp.reporting.ledger.delivery import InMemoryReportingReconciliationStore + from adcp.reporting.ledger.store import InMemoryReportingLedgerStore + from adcp.reporting.outbox.memory import InMemoryReportingOutbox + from adcp.reporting.outbox.routing import ReportingEnvelopeCipher + from adcp.reporting.outbox.worker import ReportingNotificationWorker + + if self.projector is None or self.worker.activity is None: + return False + if ( + type(self.worker) is not ReportingNotificationWorker + or type(self.worker.cipher) is not ReportingEnvelopeCipher + or type(self.projector) is not ReportingActivityProjector + or id(self.worker.activity) != id(self.worker.outbox) + or id(self.projector.store) != id(self.worker.outbox) + ): + raise ReportingNotificationError("activity_chain_unready") + if type(self.worker.outbox) is InMemoryReportingOutbox: + if ( + type(self.ledger) + not in {InMemoryReportingLedgerStore, InMemoryReportingReconciliationStore} + or self.worker.outbox._store is not self.ledger + ): + raise ReportingNotificationError("activity_chain_unready") + return False + # Lazy imports retain base-install operation without the [pg] extra. + from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore + from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp.reporting.outbox._schema import validate_schema + from adcp.reporting.outbox.pg import PgReportingOutbox + + if ( + type(self.worker.outbox) is not PgReportingOutbox + or not isinstance(self.worker.outbox, PgReportingOutbox) + or type(self.ledger) not in {PgReportingLedgerStore, PgReportingReconciliationStore} + or not isinstance(self.ledger, PgReportingLedgerStore) + or self.ledger._pool is not self.worker.outbox._pool + or not self.ledger._notifications_enabled + ): + raise ReportingNotificationError("activity_chain_unready") + async with self.ledger._pool.connection() as connection: + await validate_schema(connection, activity=True) + return True + + async def capability_flags( + self, *, account_activity: ReportingActivityProjector | None = None + ) -> dict[str, bool]: + durable = await self.durable() + return { + "reporting": durable, + "account_notifications": durable and account_activity is self.projector, + } + + +async def validate_activity_claims( + response: dict[str, Any], + *, + support: ReportingActivitySupport | None, + account_activity: ReportingActivityProjector | None, + account_listing: bool, +) -> None: + reporting = response.get("media_buy", {}).get("reporting_delivery", {}) + account = response.get("account", {}).get("notifications", {}) + reporting_claim = reporting.get("supports_webhook_activity") is True + account_claim = account.get("supports_webhook_activity") is True + if not reporting_claim and not account_claim: + return + flags = ( + await support.capability_flags(account_activity=account_activity) + if support is not None + else {"reporting": False, "account_notifications": False} + ) + if reporting_claim and not flags["reporting"]: + raise ReportingNotificationError("activity_capability_requires_durable_reporting") + if account_claim and not (flags["account_notifications"] and account_listing): + raise ReportingNotificationError("activity_capability_requires_account_projection") diff --git a/src/adcp/reporting/outbox/worker.py b/src/adcp/reporting/outbox/worker.py index 2cacbbca4..0370a5cf3 100644 --- a/src/adcp/reporting/outbox/worker.py +++ b/src/adcp/reporting/outbox/worker.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -15,6 +16,13 @@ decode_event, ) from adcp.reporting.outbox._transport_logging import protected_transport_logs +from adcp.reporting.outbox.activity import ( + ActivityOutcome, + ActivityRequest, + ReportingActivityProjector, + ReportingActivityStore, + WebhookAttempt, +) from adcp.reporting.outbox.models import ( DeliveryLease, ErrorCode, @@ -48,6 +56,12 @@ class _Outcome: error: ErrorCode | None = None +@dataclass +class _HttpObservation: + reservation: WebhookAttempt | None = None + started_ns: int = 0 + + class ReportingNotificationWorker: """At-least-once delivery with immutable bytes and per-attempt signing. @@ -70,6 +84,7 @@ def __init__( clock: Callable[[], datetime] | None = None, lease_seconds: float = 60, retry_seconds: float = 5, + activity: ReportingActivityStore | None = None, ) -> None: if lease_seconds < 1 or retry_seconds <= 0: raise ValueError("positive retry and at least one second of lease are required") @@ -81,6 +96,9 @@ def __init__( ) self._clock = clock or (lambda: datetime.now(timezone.utc)) self.lease_seconds, self.retry_seconds = lease_seconds, retry_seconds + if activity is not None and id(activity) != id(outbox): + raise ReportingNotificationError("activity_requires_reporting_outbox") + self.activity = activity async def advertised_notifications( self, @@ -88,17 +106,23 @@ async def advertised_notifications( *, account_id: str, ready_scope: ReportingDeliveryScope | None = None, + activity_projector: ReportingActivityProjector | None = None, ) -> dict[str, str | bool]: """Check the installed chain at startup before publishing these fields. Merge the result into the producer's capability block while this worker is scheduled. Ready support additionally requires a retained Managed - scope. No status or activity projection is claimed by this slice. + scope. Activity additionally requires the mounted durable projector; + status notification projection belongs to the subsequent slice. """ from adcp.reporting.outbox._capabilities import advertised_notifications return await advertised_notifications( - self, ledger, account_id=account_id, ready_scope=ready_scope + self, + ledger, + account_id=account_id, + ready_scope=ready_scope, + activity_projector=activity_projector, ) async def expand_one(self, *, account_id: str) -> bool: @@ -184,11 +208,16 @@ async def deliver_one(self, *, account_id: str) -> bool: except (ReportingNotificationError, ValueError, TypeError): outcome = _Outcome("quarantined", "integrity_failure") else: + observation = _HttpObservation() try: outcome = await asyncio.wait_for( - self._attempt(lease, opened), timeout=self.lease_seconds * 0.8 + self._attempt(lease, opened, observation), timeout=self.lease_seconds * 0.8 ) except (TimeoutError, asyncio.TimeoutError): + # Worker cancellation is not an observed HTTP timeout. A + # reservation remains pending until a known result is ACKed. + if observation.reservation is not None: + return True outcome = _Outcome("pending", "network") now = self._clock() # A DB failure after HTTP acceptance is intentionally not converted to @@ -204,7 +233,19 @@ async def deliver_one(self, *, account_id: str) -> bool: ) return True - async def _attempt(self, lease: DeliveryLease, opened: OpenedReportingDelivery) -> _Outcome: + async def _record_outcome( + self, observation: _HttpObservation, outcome: ActivityOutcome + ) -> None: + if self.activity is not None and observation.reservation is not None: + completed = await self.activity.complete_attempt( + observation.reservation, outcome=outcome, now=self._clock() + ) + if not completed: + raise ReportingNotificationError("activity_completion_unconfirmed") + + async def _attempt( + self, lease: DeliveryLease, opened: OpenedReportingDelivery, observation: _HttpObservation + ) -> _Outcome: binding = lease.delivery.binding try: current = await self.subscriptions.get_active( @@ -223,6 +264,7 @@ async def _attempt(self, lease: DeliveryLease, opened: OpenedReportingDelivery) type(current) is not ReportingNotificationSubscription or not current.matches(opened.event) or current.subscriber_id != binding.subscriber_id + or current.principal_id != binding.principal_id or current.fingerprint != binding.subscription_fingerprint ): return _Outcome("suppressed", "subscription_changed") @@ -239,8 +281,34 @@ async def _attempt(self, lease: DeliveryLease, opened: OpenedReportingDelivery) # Invoke the concrete SDK seam. Adopter-provided sender objects, # subclasses, overridden send methods, clients and hooks never # cross this boundary. URL/DNS validation is inside this seam. + request = ActivityRequest(opened.subscription.url, len(opened.prepared.body)) + callback_used = False + async def current_fence() -> bool: - return await self.outbox.delivery_lease_current(lease, now=self._clock()) + nonlocal callback_used + if callback_used: + raise PreparedWebhookAttemptExpiredError("prepared_attempt_expired") + callback_used = True + if not await self.outbox.delivery_lease_current(lease, now=self._clock()): + return False + if self.activity is not None: + # Signing, URL/DNS preparation, and the final fence + # precede this transaction. No awaitable preparation + # remains between reservation and starting peer I/O. + try: + observation.reservation = await self.activity.reserve_attempt( + lease, request=request, now=self._clock() + ) + except ReportingNotificationError as error: + if error.code == "activity_lease_expired": + raise PreparedWebhookAttemptExpiredError( + "prepared_attempt_expired" + ) from None + raise + if observation.reservation is None: + return False + observation.started_ns = time.monotonic_ns() + return True with protected_transport_logs(): result = await WebhookSender.send_prepared( @@ -248,20 +316,39 @@ async def current_fence() -> bool: ) except PreparedWebhookAttemptExpiredError: return _Outcome("pending", "lease_expired") + except ReportingNotificationError: + # Failed/unknown reservation commit means no HTTP and no ACK. + raise except SSRFValidationError as error: return ( _Outcome("pending", "network") if error.transient else (_Outcome("quarantined", "invalid_configuration")) ) - except (httpx.TransportError, OSError, TimeoutError): + except (httpx.TimeoutException, TimeoutError): + await self._record_outcome(observation, ActivityOutcome("timeout")) + return _Outcome("pending", "network") + except (httpx.TransportError, OSError): + await self._record_outcome(observation, ActivityOutcome("connection_error")) return _Outcome("pending", "network") except (ValueError, TypeError): + if observation.reservation is not None: + raise ReportingNotificationError("activity_result_unknown") from None return _Outcome("quarantined", "invalid_payload") except Exception: # Signing backends can fail transiently; retain no exception # prose, traceback, headers, URL, or response/provider body. + if observation.reservation is not None: + raise ReportingNotificationError("activity_result_unknown") from None return _Outcome("pending", "signing_unavailable") + await self._record_outcome( + observation, + ActivityOutcome( + "success" if result.ok else "failed", + result.status_code, + max(0, (time.monotonic_ns() - observation.started_ns) // 1_000_000), + ), + ) if result.ok: return _Outcome("complete") if result.status_code in {408, 425, 429} or 500 <= result.status_code < 600: diff --git a/src/adcp/server/principal.py b/src/adcp/server/principal.py index ffc29a9f4..541cf125d 100644 --- a/src/adcp/server/principal.py +++ b/src/adcp/server/principal.py @@ -194,6 +194,7 @@ def __init__(self, code: str, message: str) -> None: "signal.priced", "signal.removed", "wholesale_feed.bulk_change", + "reporting.ledger_changed", "reporting.delivery_ready", "reporting.status_changed", } diff --git a/tests/conformance/reporting/test_reporting_activity_migration.py b/tests/conformance/reporting/test_reporting_activity_migration.py new file mode 100644 index 000000000..a6d2eab50 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_activity_migration.py @@ -0,0 +1,685 @@ +"""PG16 multi-worker fencing, subset readiness, and actual A/B rolling binaries.""" + +from __future__ import annotations + +import asyncio +import json +import os +import subprocess +import sys +from dataclasses import asdict, replace +from datetime import timedelta +from importlib.resources import files +from pathlib import Path + +import pytest + +from adcp.reporting.ledger import PgReportingLedgerStore +from adcp.reporting.outbox import ( + ActivityOutcome, + ActivityRequest, + PgReportingOutbox, + ReportingActivityProjector, + ReportingNotificationError, +) +from adcp.reporting.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema + +from ._generation_support import ( + NOW, + configuration, + isolated_reporting_pool, + obligation_for, + revision_for, +) +from ._reliable_support import ( + Barrier, + NotificationHarness, + notification_subscription, + reliable_factory, +) +from .test_reporting_notification_migration import CHAIN, retained_physical_rows +from .test_reporting_notification_packaging import run_step +from .test_reporting_webhook_activity import prepare, reserve, rows + +SQL = files("adcp.reporting.ledger").joinpath("reporting_webhook_activity.sql").read_text() +# The integrated A predecessor includes checkpoint and locale-portable readiness. +BASE = "17ee407ae3978c8a2bb54437287afbf9dafb8130" +ROOT = Path(__file__).resolve().parents[3] + + +async def install_a(pool): + async with pool.connection() as conn, conn.transaction(): + for name in CHAIN: + await conn.execute(files("adcp.reporting.ledger").joinpath(name).read_text()) + + +async def test_required_manifest_is_identical_across_random_schemas_and_repeated_migration(): + snapshots = [] + identities = [] + manifest = files("adcp.reporting.outbox").joinpath("required_schema.json").read_text() + async with ( + isolated_reporting_pool(autocommit=True) as first, + isolated_reporting_pool(autocommit=True) as second, + ): + for pool in (first, second): + store = PgReportingLedgerStore(pool=pool) + for _ in range(2): + await store.create_schema() + async with pool.connection() as conn: + objects = await schema_objects(conn) + assert objects == REQUIRED_OBJECTS + # Regenerating unchanged SQL must have byte-for-byte zero + # diff, including all function security/search_path flags. + assert json.dumps(objects, indent=2, sort_keys=True) + "\n" == manifest + snapshots.append(objects) + async with pool.connection() as conn: + identities.append( + await ( + await conn.execute( + "SELECT current_schema()," + " 'reporting_webhook_attempt_guard()'::regprocedure::oid" + ) + ).fetchone() + ) + assert identities[0][0] != identities[1][0] and identities[0][1] != identities[1][1] + assert snapshots == [snapshots[0]] * 4 + + +@pytest.mark.parametrize("autocommit", [False, True]) +async def test_populated_a_to_b_migration_is_additive_repeated_and_atomic(autocommit): + async with isolated_reporting_pool(autocommit=autocommit) as pool: + await install_a(pool) + ledger = PgReportingLedgerStore(pool=pool, clock=lambda: NOW, notifications=True) + config = configuration() + obligation = obligation_for(config) + revision, data = revision_for(obligation) + await ledger.put_configuration(config) + await ledger.commit_obligation(obligation) + await ledger.commit_revision(revision, data) + before = await retained_physical_rows(pool) + for _ in range(2): + async with pool.connection() as conn, conn.transaction(): + await conn.execute(SQL) + await validate_schema(conn, activity=True) + assert await retained_physical_rows(pool) == before + outbox = PgReportingOutbox(pool=pool) + assert await outbox.list_activity(account_id="acct_a", consumer_id="buyer") == () + assert len(await outbox.list_events(account_id="acct_a")) == 1 + + +async def test_concurrent_and_interrupted_b_migration_visibility(): + async with isolated_reporting_pool(autocommit=True) as pool: + from psycopg_pool import AsyncConnectionPool + + await install_a(pool) + gate = Barrier() + async with AsyncConnectionPool(pool.conninfo, kwargs=pool.kwargs, open=False) as observer: + + async def interrupted(): + async with pool.connection() as conn, conn.transaction(): + await conn.execute(SQL) + await gate.pause() + + task = asyncio.create_task(interrupted()) + await gate.wait() + async with observer.connection() as conn: + assert ( + await ( + await conn.execute("SELECT to_regclass('reporting_webhook_attempts')") + ).fetchone() + )[0] is None + with pytest.raises(ReportingNotificationError, match="missing"): + await validate_schema(conn, activity=True) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + async def install(selected): + async with selected.connection() as conn, conn.transaction(): + await conn.execute(SQL) + + await asyncio.wait_for( + asyncio.gather(*(install(selected) for selected in (pool, observer) * 3)), + timeout=20, + ) + async with observer.connection() as conn: + await validate_schema(conn, activity=True) + + +async def test_required_subset_accepts_unrelated_adopter_objects(): + async with isolated_reporting_pool(autocommit=True) as pool: + await PgReportingLedgerStore(pool=pool).create_schema() + async with pool.connection() as conn: + await conn.execute( + "CREATE INDEX adopter_lookup ON reporting_notification_deliveries (principal_id)" + ) + await conn.execute( + "ALTER TABLE reporting_webhook_attempts ADD COLUMN adopter_note integer DEFAULT 0" + ) + await conn.execute( + "ALTER TABLE reporting_webhook_attempts ADD CONSTRAINT adopter_payload" + " CHECK (payload_size_bytes >= 0)" + ) + await conn.execute( + "CREATE FUNCTION adopter_noop() RETURNS TRIGGER LANGUAGE plpgsql" + " AS $$ BEGIN RETURN NEW; END $$" + ) + await conn.execute( + "CREATE TRIGGER adopter_observe BEFORE INSERT ON reporting_webhook_attempts" + " FOR EACH ROW EXECUTE FUNCTION adopter_noop()" + ) + await validate_schema(conn) + await validate_schema(conn, activity=True) + + +@pytest.mark.parametrize( + "damage,classification", + [ + ("DROP INDEX reporting_webhook_activity_newest", "missing"), + ( + "ALTER TABLE reporting_webhook_attempts ALTER COLUMN principal_id DROP NOT NULL", + "changed", + ), + ( + "ALTER TABLE reporting_webhook_attempts DISABLE TRIGGER" + " reporting_webhook_attempt_guard", + "disabled", + ), + ( + "ALTER TABLE reporting_webhook_attempts DROP CONSTRAINT reporting_webhook_outcome", + "missing", + ), + ("ALTER FUNCTION reporting_webhook_attempt_guard() SECURITY DEFINER", "changed"), + ("ALTER FUNCTION reporting_webhook_attempt_guard() STABLE", "changed"), + ("ALTER FUNCTION reporting_webhook_attempt_guard() LEAKPROOF", "changed"), + ( + "ALTER FUNCTION reporting_webhook_attempt_guard() SET search_path TO pg_catalog", + "changed", + ), + ( + "CREATE OR REPLACE FUNCTION reporting_webhook_attempt_guard()" + " RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$", + "changed", + ), + ], +) +async def test_damaged_required_b_objects_fail_closed_with_safe_actionable_classification( + damage, classification +): + async with isolated_reporting_pool(autocommit=True) as pool: + await PgReportingLedgerStore(pool=pool).create_schema() + async with pool.connection() as conn: + # PK columns cannot drop NOT NULL; use an immutable non-PK column. + await conn.execute( + damage.replace("principal_id DROP NOT NULL", "reservation_token DROP NOT NULL") + ) + await validate_schema(conn) # Layer A stays independently ready. + with pytest.raises( + ReportingNotificationError, match=f"notification_schema_unready:{classification}:" + ) as caught: + await validate_schema(conn, activity=True) + assert "RETURN NEW" not in str(caught.value) and "SECURITY DEFINER" not in str( + caught.value + ) + + +async def test_required_named_constraint_must_be_validated(): + async with isolated_reporting_pool(autocommit=True) as pool: + await PgReportingLedgerStore(pool=pool).create_schema() + async with pool.connection() as conn: + await conn.execute( + "ALTER TABLE reporting_webhook_attempts DROP CONSTRAINT" + " reporting_webhook_timestamps" + ) + await conn.execute( + "ALTER TABLE reporting_webhook_attempts ADD CONSTRAINT reporting_webhook_timestamps" + " CHECK (completed_at >= fired_at) NOT VALID" + ) + with pytest.raises(ReportingNotificationError, match="disabled:constraint"): + await validate_schema(conn, activity=True) + + +@pytest.mark.parametrize("flag", ["indisvalid", "indisready"]) +async def test_required_unusable_index_blocks_capability_boot(flag): + from adcp.reporting.outbox import ReportingActivitySupport + + async with reliable_factory("postgres", notifications=True) as reliable: + from psycopg import sql + + h = NotificationHarness(reliable) + outbox, worker = await prepare(h) + support = ReportingActivitySupport( + worker, reliable.store, ReportingActivityProjector(outbox) + ) + assert await support.durable() + # The task-owned PG16 admin fixture can model the catalog state left + # by a failed concurrent index build without timing a real crash. + async with reliable.blobs.pool.connection() as conn: + await conn.execute( + sql.SQL( + "UPDATE pg_index SET {}=false" + " WHERE indexrelid='reporting_webhook_activity_newest'::regclass" + ).format(sql.Identifier(flag)) + ) + with pytest.raises( + ReportingNotificationError, match="notification_schema_unready:disabled:index:" + ): + await support.durable() + + +async def test_independent_pg_workers_reserve_once_and_lock_parent_before_head(monkeypatch): + async with reliable_factory("postgres", notifications=True) as reliable: + from psycopg import AsyncConnection + from psycopg_pool import AsyncConnectionPool + + h = NotificationHarness(reliable) + outbox, _ = await prepare(h) + lease = await outbox.claim_delivery( + account_id="acct_a", now=reliable.clock(), lease_seconds=60 + ) + assert lease is not None + commands = [] + execute = AsyncConnection.execute + + async def record(conn, query, params=None, **kwargs): + if isinstance(query, str) and "reporting_" in query: + commands.append(query) + if any( + name in query + for name in ("reporting_webhook_attempts", "reporting_webhook_attempt_heads") + ): + assert "account_id" in query and "principal_id" in query + if query.startswith(("SELECT", "UPDATE", "DELETE")): + assert "WHERE account_id = %s" in query and "principal_id = %s" in query + return await execute(conn, query, params, **kwargs) + + monkeypatch.setattr(AsyncConnection, "execute", record) + pool = reliable.blobs.pool + async with AsyncConnectionPool(pool.conninfo, kwargs=pool.kwargs, open=False) as other_pool: + other = PgReportingOutbox(pool=other_pool, clock=reliable.clock) + request = ActivityRequest("https://receiver.example.test/reporting?TOKEN_SECRET", 1) + reserved = await asyncio.wait_for( + asyncio.gather( + *( + box.reserve_attempt(lease, request=request, now=reliable.clock()) + for box in (outbox, other) * 3 + ) + ), + timeout=10, + ) + assert sum(item is not None for item in reserved) == 1 + attempt = next(item for item in reserved if item is not None) + assert attempt.attempt == 1 + parent_index = next(i for i, query in enumerate(commands) if "FOR UPDATE" in query) + head_index = next( + i + for i, query in enumerate(commands) + if "INSERT INTO reporting_webhook_attempt_heads" in query + ) + assert parent_index < head_index + assert await other.list_activity(account_id="acct_a", consumer_id="buyer", limit=1) == ( + attempt, + ) + forged = replace(attempt, binding=replace(attempt.binding, principal_id="other")) + completed_attempt_5 = await other.complete_attempt( + forged, outcome=ActivityOutcome("timeout"), now=reliable.clock() + ) + assert not completed_attempt_5 + completed_attempt_6 = await outbox.complete_attempt( + attempt, outcome=ActivityOutcome("timeout"), now=reliable.clock() + ) + assert completed_attempt_6 + purged_count_4 = await other.purge_activity( + account_id="acct_a", consumer_id="buyer", now=reliable.clock() + ) + assert purged_count_4 == 0 + + +@pytest.mark.parametrize("locked", ["parent", "head"]) +async def test_lock_wait_rechecks_expiry_before_reservation_and_rolls_back_counter( + locked, monkeypatch +): + async with reliable_factory("postgres", notifications=True) as reliable: + from psycopg import AsyncConnection + from psycopg_pool import AsyncConnectionPool + + h = NotificationHarness(reliable) + outbox, _ = await prepare(h) + previous_lease, previous = await reserve(h, outbox) + completed_attempt_1 = await outbox.complete_attempt( + previous, outcome=ActivityOutcome("failed", 500, 1), now=reliable.clock() + ) + assert completed_attempt_1 + finished_delivery_1 = await outbox.finish_delivery( + previous_lease, now=reliable.clock(), state="pending", retry_at=reliable.clock() + ) + assert finished_delivery_1 + lease = await outbox.claim_delivery( + account_id="acct_a", now=reliable.clock(), lease_seconds=1 + ) + pool = reliable.blobs.pool + async with AsyncConnectionPool(pool.conninfo, kwargs=pool.kwargs, open=False) as second: + other = PgReportingOutbox(pool=second, clock=reliable.clock) + entered = asyncio.get_running_loop().create_future() + execute = AsyncConnection.execute + + async def observe(conn, query, params=None, **kwargs): + target = ( + "FROM reporting_notification_deliveries" + if locked == "parent" + else "INSERT INTO reporting_webhook_attempt_heads" + ) + if isinstance(query, str) and target in query and not entered.done(): + entered.set_result(conn.info.backend_pid) + return await execute(conn, query, params, **kwargs) + + async with pool.connection() as conn, conn.transaction(): + b = lease.delivery.binding + if locked == "parent": + await conn.execute( + "SELECT 1 FROM reporting_notification_deliveries WHERE account_id=%s" + " AND principal_id=%s AND delivery_id=%s FOR UPDATE", + (b.account_id, b.principal_id, b.delivery_id), + ) + else: + await conn.execute( + "SELECT 1 FROM reporting_webhook_attempt_heads WHERE account_id=%s" + " AND principal_id=%s AND subscriber_id=%s" + " AND idempotency_key=%s FOR UPDATE", + (b.account_id, b.principal_id, b.subscriber_id, b.idempotency_key), + ) + monkeypatch.setattr(AsyncConnection, "execute", observe) + task = asyncio.create_task( + other.reserve_attempt( + lease, + request=ActivityRequest("https://example.test/hooks", 1), + now=reliable.clock(), + ) + ) + pid = await asyncio.wait_for(entered, timeout=5) + + async def blocked(): + while True: + row = await ( + await conn.execute("SELECT pg_blocking_pids(%s)", (pid,)) + ).fetchone() + if row[0]: + return + await asyncio.sleep(0) + + await asyncio.wait_for(blocked(), timeout=5) + reliable.clock.advance(timedelta(seconds=2)) + if locked == "parent": + task_result_1 = await asyncio.wait_for(task, timeout=5) + assert task_result_1 is None + else: + with pytest.raises(ReportingNotificationError, match="activity_lease_expired"): + await asyncio.wait_for(task, timeout=5) + assert len(await rows(other)) == 1 + _, next_attempt = await reserve(h, other) + assert next_attempt.attempt == 2 + + +@pytest.mark.parametrize("mutation", ["DELETE", "RESET", "RETARGET"]) +async def test_retained_head_cannot_be_deleted_reset_or_retargeted_after_purge(mutation): + async with reliable_factory("postgres", notifications=True) as reliable: + import psycopg + + h = NotificationHarness(reliable) + outbox, _ = await prepare(h) + _, attempt = await reserve(h, outbox) + completed_attempt_2 = await outbox.complete_attempt( + attempt, outcome=ActivityOutcome("timeout"), now=reliable.clock() + ) + assert completed_attempt_2 + reliable.clock.advance(timedelta(days=31)) + purged_count_1 = await outbox.purge_activity( + account_id="acct_a", consumer_id="buyer", now=reliable.clock() + ) + assert purged_count_1 == 1 + commands = { + "DELETE": "DELETE FROM reporting_webhook_attempt_heads", + "RESET": "UPDATE reporting_webhook_attempt_heads SET last_attempt=1", + "RETARGET": "UPDATE reporting_webhook_attempt_heads SET principal_id='foreign'," + " last_attempt=last_attempt+1", + } + async with reliable.blobs.pool.connection() as conn: + with pytest.raises(psycopg.Error, match="counter must advance and be retained"): + await conn.execute( + commands[mutation] + " WHERE account_id=%s AND principal_id=%s", + ("acct_a", "buyer"), + ) + await conn.rollback() + _, next_attempt = await reserve(h, outbox) + assert next_attempt.attempt == 2 + + +@pytest.mark.parametrize( + "field,value", + [ + ("url", "'https://example.test/RAW_SECRET'"), + ("payload_size_bytes", "99"), + ("idempotency_key", "'other'"), + ("notification_id", "'other'"), + ("principal_id", "'other'"), + ("reservation_token", "'other'"), + ("attempt", "99"), + ], +) +async def test_sql_rejects_mutable_identity_request_and_token(field, value): + async with reliable_factory("postgres", notifications=True) as reliable: + import psycopg + from psycopg import sql + + h = NotificationHarness(reliable) + outbox, _ = await prepare(h) + _, attempt = await reserve(h, outbox) + async with reliable.blobs.pool.connection() as conn: + with pytest.raises(psycopg.Error) as caught: + await conn.execute( + sql.SQL( + "UPDATE reporting_webhook_attempts SET {} = {}" + " WHERE account_id=%s AND principal_id=%s" + ).format(sql.Identifier(field), sql.SQL(value)), + ("acct_a", "buyer"), + ) + await conn.rollback() + assert "RAW_SECRET" not in str(caught.value) + assert await outbox.list_activity(account_id="acct_a", consumer_id="buyer") == (attempt,) + + +async def test_pending_orphans_are_retained_without_parent_and_db_clock_cannot_be_overridden(): + async with reliable_factory("postgres", notifications=True) as reliable: + h = NotificationHarness(reliable) + _, _ = await prepare(h) + outbox = PgReportingOutbox(pool=reliable.blobs.pool) # Real database clock. + lease = await outbox.claim_delivery(account_id="acct_a", now=NOW, lease_seconds=60) + attempt = await outbox.reserve_attempt( + lease, request=ActivityRequest("https://example.test/webhooks", 1), now=NOW + ) + assert attempt is not None and attempt.fired_at > NOW + async with reliable.blobs.pool.connection() as conn: + await conn.execute( + "DELETE FROM reporting_notification_deliveries" + " WHERE account_id=%s AND principal_id=%s", + ("acct_a", "buyer"), + ) + purged_count_2 = await outbox.purge_activity( + account_id="acct_a", consumer_id="buyer", now=NOW + timedelta(days=900) + ) + assert purged_count_2 == 0 + completed_attempt_3 = await outbox.complete_attempt( + attempt, outcome=ActivityOutcome("connection_error"), now=NOW + ) + assert completed_attempt_3 + completed = (await outbox.list_activity(account_id="acct_a", consumer_id="buyer"))[0] + assert completed.completed_at >= attempt.fired_at + purged_count_3 = await outbox.purge_activity( + account_id="acct_a", consumer_id="buyer", now=NOW + timedelta(days=900) + ) + assert purged_count_3 == 0 + + +@pytest.fixture(scope="module") +def actual_a_source(tmp_path_factory): + if not os.environ.get("ADCP_PG_TEST_URL"): + pytest.skip("actual A/B compatibility requires real PostgreSQL") + target = tmp_path_factory.mktemp("reporting-1168a-source") / "worktree" + checkout = subprocess.run( + ["git", "worktree", "add", "--detach", str(target), BASE], + cwd=ROOT, + capture_output=True, + timeout=60, + check=False, + ) + assert ( + checkout.returncode == 0 + ), "rolling gate requires reviewed A commit; checkout with fetch-depth: 0" + try: + yield target + finally: + removed = subprocess.run( + ["git", "worktree", "remove", "--force", str(target)], + cwd=ROOT, + capture_output=True, + timeout=60, + check=False, + ) + assert removed.returncode == 0, "task-owned A worktree cleanup failed" + + +A_SCRIPT = r""" +import asyncio, hashlib, json, sys +from datetime import datetime, timedelta +from pathlib import Path +values = json.load(sys.stdin) +sys.path.insert(0, str(Path.cwd() / 'src')) +from psycopg_pool import AsyncConnectionPool +from adcp.reporting.ledger import PgReportingLedgerStore +from adcp.reporting.outbox import ( + PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationSubscription, +) +from adcp.reporting.outbox._schema import validate_schema +import adcp.reporting.outbox.worker as worker_module +assert Path(worker_module.__file__).is_relative_to(Path.cwd()) +assert 'activity' not in __import__('inspect').signature( + worker_module.ReportingNotificationWorker +).parameters +async def main(): + clock = lambda: datetime.fromisoformat(values['now']) + async with AsyncConnectionPool(values['conninfo'], kwargs=values['kwargs'], open=False) as pool: + ledger = PgReportingLedgerStore(pool=pool, notifications=True, clock=clock) + await ledger.create_schema() + async with pool.connection() as conn: + await validate_schema(conn) + if values['action'] == 'bootstrap': + return + from adcp.reporting.ledger.notification_models import decode_event + from adcp.reporting.outbox import ReportingSigningMaterial + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + subscription = ReportingNotificationSubscription(**values['subscription']) + class Subscriptions: + async def list_active(self, **kwargs): return (subscription,) + async def get_active(self, **kwargs): return subscription + class Signing: + async def resolve(self, **kwargs): + return ReportingSigningMaterial(Ed25519PrivateKey.from_private_bytes(b'\x01'*32), + 'https://seller.example.test/keys#key-1', 'ed25519', frozenset({'ed25519'})) + outbox = PgReportingOutbox(pool=pool, clock=clock) + cipher = ReportingEnvelopeCipher(b'e'*32) + worker = worker_module.ReportingNotificationWorker( + outbox=outbox, subscriptions=Subscriptions(), cipher=cipher, + signing=Signing(), clock=clock, + ) + advertised = await worker.advertised_notifications(ledger, account_id='acct_a') + assert advertised['supports_webhook_activity'] is False + expanded_1 = await worker.expand_one(account_id='acct_a') + assert expanded_1 + lease = await outbox.claim_delivery(account_id='acct_a', now=clock(), lease_seconds=60) + opened = cipher.open(lease.delivery) + finished_delivery_1 = await outbox.finish_delivery(lease, now=clock(), state='pending', retry_at=clock()) + assert finished_delivery_1 + print(json.dumps({'body_sha256': hashlib.sha256(opened.prepared.body).hexdigest(), + 'idempotency_key': opened.prepared.idempotency_key})) +asyncio.run(asyncio.wait_for(main(), 30)) +""" + + +async def run_a(source, pool, *, action): + return await asyncio.to_thread( + run_step, + [sys.executable, "-c", A_SCRIPT], + label=f"actual-a-{action}", + cwd=source, + timeout=45, + value={ + "conninfo": pool.conninfo, + "kwargs": pool.kwargs, + "now": NOW.isoformat(), + "action": action, + "subscription": asdict(notification_subscription()), + }, + ) + + +async def test_actual_a_binary_on_b_database_keeps_readiness_and_delivery_identity(actual_a_source): + async with reliable_factory("postgres", notifications=True) as reliable: + h = NotificationHarness(reliable) + from .test_reporting_notification_outbox import seed + + await seed(h) + result = json.loads(await run_a(actual_a_source, reliable.blobs.pool, action="roundtrip")) + outbox = h.outbox + lease, attempt = await reserve(h, outbox) + assert lease.attempt_count == 2 and attempt.attempt == 1 + assert attempt.binding.body_sha256 == result["body_sha256"] + assert attempt.binding.idempotency_key == result["idempotency_key"] + completed_attempt_4 = await outbox.complete_attempt( + attempt, outcome=ActivityOutcome("success", 200, 1), now=reliable.clock() + ) + assert completed_attempt_4 + finished_delivery_2 = await outbox.finish_delivery( + lease, state="complete", now=reliable.clock() + ) + assert finished_delivery_2 + async with reliable.blobs.pool.connection() as conn: + await validate_schema(conn, activity=True) + + +async def test_b_binary_on_actual_a_schema_refuses_activity_without_corrupting_work( + actual_a_source, +): + async with isolated_reporting_pool(autocommit=True) as pool: + await run_a(actual_a_source, pool, action="bootstrap") + async with pool.connection() as conn: + await validate_schema(conn) + with pytest.raises(ReportingNotificationError, match="missing"): + await validate_schema(conn, activity=True) + assert ( + await ( + await conn.execute("SELECT to_regclass('reporting_webhook_attempts')") + ).fetchone() + )[0] is None + ledger = PgReportingLedgerStore(pool=pool, clock=lambda: NOW, notifications=True) + config = configuration() + obligation = obligation_for(config) + revision, data = revision_for(obligation) + await ledger.put_configuration(config) + await ledger.commit_obligation(obligation) + await ledger.commit_revision(revision, data) + await run_a(actual_a_source, pool, action="roundtrip") + outbox = PgReportingOutbox(pool=pool, clock=lambda: NOW) + lease = await outbox.claim_delivery(account_id="acct_a", now=NOW, lease_seconds=60) + with pytest.raises(ReportingNotificationError, match="activity_store_unavailable"): + await outbox.reserve_attempt( + lease, request=ActivityRequest("https://example.test/reporting", 1), now=NOW + ) + assert await outbox.delivery_lease_current(lease, now=NOW) + assert len(await outbox.list_events(account_id="acct_a")) == 1 + before = (await outbox.list_deliveries(account_id="acct_a"))[0] + await ledger.create_schema() + assert (await outbox.list_deliveries(account_id="acct_a"))[0] == before + reserved_attempt_1 = await outbox.reserve_attempt( + lease, request=ActivityRequest("https://example.test/reporting", 1), now=NOW + ) + assert reserved_attempt_1 is not None diff --git a/tests/conformance/reporting/test_reporting_activity_projection.py b/tests/conformance/reporting/test_reporting_activity_projection.py new file mode 100644 index 000000000..e1243e2fc --- /dev/null +++ b/tests/conformance/reporting/test_reporting_activity_projection.py @@ -0,0 +1,572 @@ +"""Identity, optional account decoration, truthful capability, and URL boundaries.""" + +from __future__ import annotations + +import re +from base64 import urlsafe_b64encode +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from dataclasses import replace + +import httpx +import pytest +from pydantic import AnyUrl, BaseModel + +from adcp.decisioning import DecisioningPlatform +from adcp.decisioning.accounts import ResolveContext +from adcp.decisioning.context import AuthInfo +from adcp.decisioning.handler import PlatformHandler, _build_list_accounts_filter +from adcp.decisioning.registry import ( + ApiKeyCredential, + BuyerAgent, + HttpSigCredential, + OAuthCredential, +) +from adcp.decisioning.task_registry import InMemoryTaskRegistry +from adcp.reporting.outbox import ( + ReportingActivityProjector, + ReportingActivitySupport, + ReportingNotificationError, + ReportingNotificationSubscription, + resolve_reporting_consumer, + sanitize_activity_url, +) +from adcp.reporting.outbox.support import validate_activity_claims +from adcp.server import ToolContext +from adcp.types import ListAccountsRequest + +from ._reliable_support import notification_subscription +from .test_reporting_webhook_activity import prepare + +PRINCIPAL = "https://buyer.example/agent" +CANONICAL_BOUND_HOST = "https://receiver.example.test/" +ACTIVITY_URL_BOUND = 8192 + + +def agent(value=PRINCIPAL): + return BuyerAgent(agent_url=value, display_name="Buyer", status="active") + + +@pytest.mark.parametrize("source", ["auth", "registry", "api_key", "oauth", "signed", "equal_all"]) +def test_identity_accepts_either_trusted_source_and_checks_all_signed_coordinates(source): + info = None + buyer = None + if source == "auth": + info = AuthInfo(kind="bearer", principal=PRINCIPAL) + elif source == "registry": + buyer = agent() + elif source in {"api_key", "oauth"}: + credential = ( + ApiKeyCredential(kind="api_key", key_id="key-1") + if source == "api_key" + else OAuthCredential(kind="oauth", client_id="client-1") + ) + info = AuthInfo(kind="bearer", principal=None, credential=credential) + buyer = agent() + else: + info = AuthInfo( + kind="http_sig", + credential=HttpSigCredential( + kind="http_sig", + keyid="key-1", + agent_url=PRINCIPAL, + verified_at=1, + ), + ) + if source == "equal_all": + info.principal = info.agent_url = PRINCIPAL + buyer = agent() + assert resolve_reporting_consumer(auth_info=info, agent=buyer) == PRINCIPAL + + +@pytest.mark.parametrize("source", ["principal", "agent_url", "credential", "registry"]) +def test_every_contradictory_trusted_identity_is_rejected_without_echo(source): + info = AuthInfo(kind="bearer", principal=PRINCIPAL) + info.agent_url = PRINCIPAL + info.credential = HttpSigCredential( + kind="http_sig", keyid="key-1", agent_url=PRINCIPAL, verified_at=1 + ) + buyer = agent() + if source == "registry": + buyer = agent("FOREIGN_SECRET") + elif source == "credential": + info.credential = replace(info.credential, agent_url="FOREIGN_SECRET") + else: + setattr(info, source, "FOREIGN_SECRET") + with pytest.raises(ReportingNotificationError, match="activity_identity_conflict") as caught: + resolve_reporting_consumer(auth_info=info, agent=buyer) + assert "FOREIGN_SECRET" not in str(caught.value) + + +@pytest.mark.parametrize( + "value", [None, "", " ", "anonymous", "ANONYMOUS", "Anon", "NULL", "none", "unauthenticated"] +) +def test_absent_and_anonymous_identity_fails_closed(value): + with pytest.raises(ReportingNotificationError, match="activity_identity_required"): + resolve_reporting_consumer(auth_info=AuthInfo(kind="bearer", principal=value)) + + +@pytest.mark.parametrize("source", ["principal", "agent_url", "credential", "registry"]) +def test_present_anonymous_sentinel_is_not_hidden_by_another_valid_identity(source): + info = AuthInfo(kind="bearer", principal=PRINCIPAL) + buyer = agent() + if source == "registry": + buyer = agent("ANONYMOUS") + elif source == "credential": + info.credential = HttpSigCredential( + kind="http_sig", keyid="k", agent_url="ANONYMOUS", verified_at=1 + ) + else: + setattr(info, source, "ANONYMOUS") + with pytest.raises(ReportingNotificationError, match="activity_identity_required"): + resolve_reporting_consumer(auth_info=info, agent=buyer) + + +@pytest.mark.parametrize( + "segment", + [ + "c5b1b9b3-3e99-4da8-b930-6a9e67245553", + "a" * 48, + pytest.param(None, id="synthetic-jwt"), + "aGVsbG9TZWNyZXRUYXJnZXRUb2tlbkFiQ0QxMjM0NTY=", + "api_key_Live123MixedSecret", + "%55%52%4c%5f%53%45%43%52%45%54", + "%252fSECRET%252f", + "shortpassword", + "secret;param=PRIVATE", + "a%2Fb%3Fc", + "秘密令牌", + "12345678901234567890", + ], +) +def test_sanitizer_redacts_tokens_and_encoded_segments_with_constant(segment): + if segment is None: + # Build an invalid JWT-shaped segment without embedding a credential. + segment = ".".join( + urlsafe_b64encode(part).decode("ascii").rstrip("=") + for part in (b'{"alg":"ES256"}', b'{"sub":"sanitizer-fixture"}', bytes(8)) + ) + assert re.fullmatch(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", segment) + raw = f"https://user:USERINFO_SECRET@example.test:443/api/v1/webhooks/{segment}/reporting?QUERY_SECRET=x#FRAGMENT_SECRET" + value = sanitize_activity_url(raw) + assert value == "https://example.test/api/v1/webhooks/redacted/reporting" + assert str(AnyUrl(value)) == value + assert sanitize_activity_url(value) == value + assert all( + secret not in value + for secret in (segment, "USERINFO_SECRET", "QUERY_SECRET", "FRAGMENT_SECRET") + ) + + +@pytest.mark.parametrize( + "url", + ["INVALID_URL_SECRET", "https://host:INVALID_PORT_SECRET/x", "https://host/" + "x" * 8192], +) +def test_invalid_url_and_subscription_errors_are_bounded_and_secret_free(url): + with pytest.raises(ReportingNotificationError) as caught: + sanitize_activity_url(url) + assert str(caught.value) == "invalid_activity_url" + assert caught.value.__context__ is None + with pytest.raises(ReportingNotificationError) as caught: + notification_subscription(url=url) + assert str(caught.value) == "invalid_configuration" + assert caught.value.__context__ is None + + +def test_registration_rejects_a_url_only_the_canonical_form_exceeds(): + # Percent-encoding expands the stored form. A configuration accepted here + # would otherwise quarantine every expansion instead of failing closed at + # registration, so the bound must hold on the canonical value. + url = CANONICAL_BOUND_HOST + "PATH_SECRET" + " " * 8000 + assert len(url) <= ACTIVITY_URL_BOUND < len(str(httpx.URL(url))) + with pytest.raises(ReportingNotificationError) as caught: + notification_subscription(url=url) + assert str(caught.value) == "invalid_configuration" + assert caught.value.__context__ is None + assert "PATH_SECRET" not in repr(caught.value) + + +def test_registration_keeps_a_canonical_url_exactly_on_the_bound(): + url = CANONICAL_BOUND_HOST + "a" * (ACTIVITY_URL_BOUND - len(CANONICAL_BOUND_HOST)) + subscription = notification_subscription(url=url) + assert subscription.url == url and len(subscription.url) == ACTIVITY_URL_BOUND + # Expansion and envelope reopening re-run this validator on the stored + # value, so an accepted registration stays accepted and sanitizes. + ReportingNotificationSubscription.__post_init__(subscription) + assert subscription.url == url + assert sanitize_activity_url(subscription.url) == CANONICAL_BOUND_HOST + "redacted" + + +class ReadSpy: + def __init__(self): + self.calls = [] + + async def list_activity(self, **kwargs): + self.calls.append(kwargs) + return () + + +class Envelope(BaseModel): + accounts: list[dict] + pagination: dict + context: dict + + +@pytest.mark.parametrize("shape", ["list", "dict", "pydantic"]) +@pytest.mark.parametrize( + "mounted,requested", [(False, False), (False, True), (True, False), (True, True)] +) +async def test_legacy_filters_shapes_nulls_and_three_state_projection(shape, mounted, requested): + source = [ + { + "account_id": "acct_a", + "name": "Visible", + "status": "active", + "webhook_activity": [{"url": "ADOPTER_SECRET"}], + "billing_entity": {"legal_name": "Buyer", "bank": {"iban": "BANK_SECRET"}}, + } + ] + envelope = { + "accounts": source, + "pagination": {"has_more": True, "cursor": "next"}, + "context": {"roundtrip": "same"}, + } + seen = [] + + class LegacyStore: + # Deliberately legacy: no ctx parameter and no new required methods. + def list(self, filter=None): + seen.append(deepcopy(filter)) + if shape == "list": + return source + return envelope if shape == "dict" else Envelope(**envelope) + + class Platform(DecisioningPlatform): + accounts = LegacyStore() + + spy = ReadSpy() + projector = ReportingActivityProjector(spy) if mounted else None + params = ListAccountsRequest( + account={"account_id": "acct_a"}, + status="active", + sandbox=False, + pagination={"max_results": 7}, + include_webhook_activity=requested, + webhook_activity_limit=2, + ) + # Unsupported/unrequested legacy calls must not require an identity. + context = ( + ToolContext(metadata={"adcp.auth_info": AuthInfo(kind="bearer", principal=PRINCIPAL)}) + if mounted and requested + else ToolContext() + ) + with ThreadPoolExecutor(max_workers=1) as executor: + handler = PlatformHandler( + Platform(), + executor=executor, + registry=InMemoryTaskRegistry(), + account_activity=projector, + ) + result = await handler.list_accounts(params, context) + assert seen == [ + { + "account": {"account_id": "acct_a"}, + "status": "active", + "sandbox": False, + "pagination": {"max_results": 7}, + } + ] + assert "ADOPTER_SECRET" not in str(result) and "BANK_SECRET" not in str(result) + if mounted and requested: + assert result["accounts"][0]["webhook_activity"] == [] + assert spy.calls == [{"account_id": "acct_a", "consumer_id": PRINCIPAL, "limit": 2}] + else: + assert "webhook_activity" not in result["accounts"][0] and spy.calls == [] + if shape != "list": + assert ( + result["pagination"] == envelope["pagination"] + and result["context"] == envelope["context"] + ) + assert source[0]["webhook_activity"] == [{"url": "ADOPTER_SECRET"}] + + +async def test_exact_account_filter_controls_visible_accounts_before_activity_reads(): + class Store: + def list(self, filter=None, ctx=None): + visible = [{"account_id": "acct_a"}, {"account_id": "acct_b"}] + return [row for row in visible if row["account_id"] == filter["account"]["account_id"]] + + class Platform(DecisioningPlatform): + accounts = Store() + + spy = ReadSpy() + with ThreadPoolExecutor(max_workers=1) as executor: + handler = PlatformHandler( + Platform(), + executor=executor, + registry=InMemoryTaskRegistry(), + account_activity=ReportingActivityProjector(spy), + ) + for selected, expected in [("acct_b", ["acct_b"]), ("invisible", [])]: + result = await handler.list_accounts( + ListAccountsRequest( + account={"account_id": selected}, include_webhook_activity=True + ), + ToolContext( + metadata={"adcp.auth_info": AuthInfo(kind="bearer", principal=PRINCIPAL)} + ), + ) + assert [row["account_id"] for row in result["accounts"]] == expected + assert [call["account_id"] for call in spy.calls] == ["acct_b"] + + +def test_complete_natural_key_filter_is_preserved(): + account = { + "brand": {"domain": "brand.example"}, + "operator": "operator.example", + "operator_unit": {"id": "seat-1"}, + "currency": "USD", + "timezone": "UTC", + "sandbox": True, + } + request = ListAccountsRequest( + account=account, include_webhook_activity=True, webhook_activity_limit=4 + ) + assert _build_list_accounts_filter(request) == {"account": account} + + +@pytest.mark.parametrize( + "credential", + [ + ApiKeyCredential(kind="api_key", key_id="key-1"), + OAuthCredential(kind="oauth", client_id="client-1"), + ], +) +@pytest.mark.parametrize("disagree", [False, True]) +async def test_handler_registry_migration_and_identity_disagreement(credential, disagree): + class Registry: + async def resolve_by_credential(self, value): + return agent() + + async def resolve_by_agent_url(self, value): + return agent() + + class Store: + def list(self, filter=None, ctx=None): + assert ctx.agent.agent_url == PRINCIPAL + return [{"account_id": "acct_a"}] + + class Platform(DecisioningPlatform): + accounts = Store() + + info = AuthInfo(kind="bearer", credential=credential) + if disagree: + info.principal = "different-principal" + spy = ReadSpy() + with ThreadPoolExecutor(max_workers=1) as executor: + handler = PlatformHandler( + Platform(), + executor=executor, + registry=InMemoryTaskRegistry(), + buyer_agent_registry=Registry(), + account_activity=ReportingActivityProjector(spy), + ) + request = ListAccountsRequest(include_webhook_activity=True) + context = ToolContext(metadata={"adcp.auth_info": info}) + if disagree: + with pytest.raises(ReportingNotificationError, match="activity_identity_conflict"): + await handler.list_accounts(request, context) + assert spy.calls == [] + else: + assert (await handler.list_accounts(request, context))["accounts"][0][ + "webhook_activity" + ] == [] + assert spy.calls[0]["consumer_id"] == PRINCIPAL + + +@pytest.mark.parametrize( + "result", [{"errors": [{"code": "DENIED"}], "context": {"a": 1}}, {"accounts": []}, None] +) +async def test_optional_decorator_preserves_error_and_empty_envelopes(result): + spy = ReadSpy() + projector = ReportingActivityProjector(spy) + actual = await projector.enrich( + result, + context=ResolveContext(auth_info=AuthInfo(kind="bearer", principal=PRINCIPAL)), + include=True, + ) + assert actual == result and spy.calls == [] + + +@pytest.mark.parametrize( + "mode", ["none", "outbox_only", "missing_projection", "missing_account", "full"] +) +async def test_independent_capability_matrix_and_contradictory_overrides( + notification_harness, mode +): + h = notification_harness + outbox, worker = await prepare(h) + projector = ReportingActivityProjector(outbox) + if mode == "outbox_only": + worker.activity = None + support = ( + None + if mode == "none" + else ReportingActivitySupport( + worker, h.reliable.store, None if mode == "missing_projection" else projector + ) + ) + account_mount = projector if mode == "full" else None + durable = h.reliable.blobs.pool is not None and mode in {"missing_account", "full"} + expected = {"reporting": durable, "account_notifications": durable and mode == "full"} + if support is not None: + assert await support.capability_flags(account_activity=account_mount) == expected + for reporting, account in [(False, False), (True, False), (False, True), (True, True)]: + response = { + "media_buy": { + "reporting_delivery": {"supports_webhook_activity": reporting}, + "relationship_notifications": { + "supported": True, + "supports_webhook_activity": True, + }, + }, + "account": {"notifications": {"supports_webhook_activity": account}}, + } + before = deepcopy(response) + if ( + reporting + and not expected["reporting"] + or account + and not expected["account_notifications"] + ): + with pytest.raises(ReportingNotificationError, match="activity_capability_requires"): + await validate_activity_claims( + response, support=support, account_activity=account_mount, account_listing=True + ) + else: + await validate_activity_claims( + response, support=support, account_activity=account_mount, account_listing=True + ) + assert response == before + + +@pytest.mark.parametrize("source", ["static", "request"]) +@pytest.mark.parametrize("claim", ["reporting", "account"]) +@pytest.mark.parametrize("mounted", [False, True]) +async def test_handler_boot_and_request_capability_gates_follow_projection_without_mutation( + notification_harness, source, claim, mounted +): + from adcp.decisioning import validate_capabilities_response_shape_async + from adcp.decisioning.capabilities import Account, MediaBuy, WebhookSigning + from adcp.types import ReportingDeliveryCapabilities + from tests.test_decisioning_capabilities_projection import _SalesPlatform + from tests.test_reporting_ledger import _OFFERING + + h = notification_harness + outbox, worker = await prepare(h) + projector = ReportingActivityProjector(outbox) + support = ReportingActivitySupport(worker, h.reliable.store, projector) + reporting = ReportingDeliveryCapabilities.model_validate( + { + "supported": True, + "offerings": [_OFFERING], + "automated_recovery_window_seconds": 3600, + "status_retention_days": 30, + "supports_webhook_activity": claim == "reporting", + } + ).model_copy(update={"readiness_notification": None, "status_notification": None}) + capabilities = replace( + _SalesPlatform.capabilities, + experimental_features=["media_buy.reporting_delivery"], + media_buy=MediaBuy(supported_pricing_models=["cpm"], reporting_delivery=reporting), + account=Account.model_validate( + { + "supported_billing": ["operator"], + "notifications": { + "supported": True, + "registration_task": "sync_accounts", + "read_task": "list_accounts", + "event_types": ["account.status_changed"], + "supports_webhook_activity": claim == "account", + }, + } + ), + webhook_signing=WebhookSigning( + supported=True, + profile="adcp/webhook-signing/v1", + algorithms=["ed25519"], + delivery_retry_horizon_seconds=86400, + ), + webhook_signing_managed_externally=True, + ) + + class Listing: + def list(self, filter=None): + return [] + + class Platform(_SalesPlatform): + accounts = Listing() + + def get_adcp_capabilities_for_request(self, params=None, context=None): + return capabilities if source == "request" else None + + platform = Platform() + platform.capabilities = capabilities if source == "static" else _SalesPlatform.capabilities + before = deepcopy(platform.capabilities) + with ThreadPoolExecutor(max_workers=1) as executor: + handler = PlatformHandler( + platform, + executor=executor, + registry=InMemoryTaskRegistry(), + reporting_activity=support if mounted else None, + account_activity=projector if mounted else None, + auto_emit_task_webhooks=False, + ) + if mounted and h.reliable.blobs.pool is not None: + await validate_capabilities_response_shape_async(handler) + response = await handler.get_adcp_capabilities() + block = ( + response["media_buy"]["reporting_delivery"] + if claim == "reporting" + else response["account"]["notifications"] + ) + assert block["supports_webhook_activity"] is True + else: + with pytest.raises(ReportingNotificationError, match="activity_capability_requires"): + await validate_capabilities_response_shape_async(handler) + assert platform.capabilities == before + + +async def test_account_activity_claim_requires_account_listing_even_with_full_stack( + notification_harness, +): + h = notification_harness + outbox, worker = await prepare(h) + projector = ReportingActivityProjector(outbox) + with pytest.raises( + ReportingNotificationError, match="activity_capability_requires_account_projection" + ): + await validate_activity_claims( + {"account": {"notifications": {"supports_webhook_activity": True}}}, + support=ReportingActivitySupport(worker, h.reliable.store, projector), + account_activity=projector, + account_listing=False, + ) + + +@pytest.mark.parametrize("all_accounts", [None, False, True]) +def test_ledger_changed_requires_all_authorized_accounts(all_accounts): + from adcp.server.principal import _normalize_notification_config + from adcp.types import AgentNotificationConfig + + config = AgentNotificationConfig( + subscriber_id="buyer", + url="https://8.8.8.8/webhooks", + event_types=["reporting.ledger_changed"], + all_authorized_accounts=all_accounts, + ) + if all_accounts is True: + assert _normalize_notification_config(config, 0).all_authorized_accounts is True + else: + with pytest.raises(ValueError, match="all_authorized_accounts"): + _normalize_notification_config(config, 0) diff --git a/tests/conformance/reporting/test_reporting_notification_migration.py b/tests/conformance/reporting/test_reporting_notification_migration.py index 7ebde6680..414c16fee 100644 --- a/tests/conformance/reporting/test_reporting_notification_migration.py +++ b/tests/conformance/reporting/test_reporting_notification_migration.py @@ -28,7 +28,7 @@ ) -async def test_schema_fingerprint_uses_byte_order_for_index_keys_and_predicates(): +async def test_schema_fingerprints_preserve_index_keys_and_predicates_individually(): async with isolated_reporting_pool(autocommit=True) as pool: async with pool.connection() as conn: await conn.execute( @@ -40,18 +40,31 @@ async def test_schema_fingerprint_uses_byte_order_for_index_keys_and_predicates( "CREATE INDEX locale_probe_lower_predicate ON reporting_catalog_locale_probe (a)" " WHERE a > 0;" ) - # ASCII byte order: quoted Z before a, then predicates before NULL. - expected = [ - [False, True, True, ['"Z"'], None], - [False, True, True, ["a"], '("Z" > 0)'], - [False, True, True, ["a"], "(a > 0)"], - [False, True, True, ["a"], None], - ] - expected_digest = hashlib.sha256( - json.dumps(expected, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() + # Each named index has its own digest; cross-row collation cannot + # alter it. Keep the independent oracle for quoted keys, predicates, + # and validity/readiness/liveness flags across database locales. + definitions = { + "locale_probe_upper": '("Z")', + "locale_probe_lower": "(a)", + "locale_probe_upper_predicate": '(a) WHERE ("Z" > 0)', + "locale_probe_lower_predicate": "(a) WHERE (a > 0)", + } + expected = {} + for name, definition in definitions.items(): + value = [ + False, + True, + True, + True, + f"CREATE INDEX {name} ON reporting_catalog_locale_probe" + f" USING btree {definition}", + ] + expected[f"index:reporting_catalog_locale_probe.{name}"] = hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() contract = await schema_contract(conn) - assert contract["reporting_catalog_locale_probe:indexes"] == expected_digest + actual = {key: value for key, value in contract.items() if key.startswith("index:")} + assert actual == expected async def foundation(pool): @@ -315,8 +328,9 @@ async def test_default_off_upgrade_preserves_adopter_index_and_opt_in_checks_rea ).fetchone() )[0] assert retained == original - # Enabling the outbox invokes the full conservative SDK chain check. + # B validates the required subset: an unrelated index remains compatible. from adcp.reporting.outbox import PgReportingOutbox - with pytest.raises(ReportingNotificationError, match="notification_schema_unready"): - await PgReportingOutbox(pool=pool).create_schema() + await PgReportingOutbox(pool=pool).create_schema() + async with pool.connection() as conn: + await validate_schema(conn, activity=True) diff --git a/tests/conformance/reporting/test_reporting_notification_packaging.py b/tests/conformance/reporting/test_reporting_notification_packaging.py index a74235262..ace988bb1 100644 --- a/tests/conformance/reporting/test_reporting_notification_packaging.py +++ b/tests/conformance/reporting/test_reporting_notification_packaging.py @@ -10,6 +10,7 @@ import subprocess import sys import tarfile +import time import zipfile from pathlib import Path @@ -29,6 +30,7 @@ def run_step(command, *, label, cwd, value=None, timeout=120): + started = time.monotonic() process = subprocess.Popen( command, cwd=cwd, @@ -46,23 +48,36 @@ def run_step(command, *, label, cwd, value=None, timeout=120): timeout=timeout, ) except subprocess.TimeoutExpired: - # The new session contains only this test's build/install descendants. - # Kill the owned group too, so a package-manager child cannot survive a - # timed-out build or keep an inherited pipe open indefinitely. + # Give only this task-owned process group a bounded graceful shutdown, + # then escalate. A timeout is a failing gate, never an implicit retry. + cleanup = "terminated" try: - os.killpg(process.pid, signal.SIGKILL) + os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: # The owned group already exited; still drain its pipes and reap the child below. pass try: - process.communicate(timeout=10) + stdout, stderr = process.communicate(timeout=5) except subprocess.TimeoutExpired: - raise AssertionError( - f"notification distribution {label}: cleanup_deadline pid={process.pid}" - ) from None + cleanup = "killed" + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + # The owned group already exited; drain and reap it in communicate below. + pass + try: + stdout, stderr = process.communicate(timeout=5) + except subprocess.TimeoutExpired: + raise AssertionError( + f"notification distribution {label}: cleanup_deadline pid={process.pid}" + ) from None + # Capture actionable process diagnostics without ever including child + # prose, URLs, fixture values, provider output, or credentials. raise AssertionError( f"notification distribution {label}: deadline" - f" pid={process.pid} exit={process.returncode}" + f" pid={process.pid} exit={process.returncode} cleanup={cleanup}" + f" elapsed_ms={int((time.monotonic() - started) * 1000)}" + f" stdout_chars={len(stdout)} stderr_chars={len(stderr)}" ) from None # Do not turn package-manager/provider output into test diagnostics. assert process.returncode == 0, f"notification distribution {label}: exit {process.returncode}" @@ -71,7 +86,7 @@ def run_step(command, *, label, cwd, value=None, timeout=120): def test_distribution_subprocess_deadline_is_bounded_and_sanitized(tmp_path): - with pytest.raises(AssertionError, match=r"deadline_probe: deadline pid=\d+ exit=-9"): + with pytest.raises(AssertionError, match=r"deadline_probe: deadline pid=\d+ exit=-(15|9)"): run_step( [sys.executable, "-c", "import threading; threading.Event().wait()"], label="deadline_probe", @@ -81,7 +96,7 @@ def test_distribution_subprocess_deadline_is_bounded_and_sanitized(tmp_path): @pytest.fixture(scope="module") -def installed_distribution(tmp_path_factory): +def built_distribution(tmp_path_factory): path = tmp_path_factory.mktemp("reporting-outbox-distribution") project = path / "project" project.mkdir() @@ -106,7 +121,14 @@ def installed_distribution(tmp_path_factory): cwd=path, ) wheel, source = next(dist.glob("*.whl")), next(dist.glob("*.tar.gz")) - environment = path / "installed" + return path, wheel, source + + +@pytest.fixture(scope="module", params=["wheel", "sdist"]) +def installed_distribution(built_distribution, request): + path, wheel, source = built_distribution + distribution = wheel if request.param == "wheel" else source + environment = path / f"installed-{request.param}" run_step( [sys.executable, "-m", "venv", str(environment)], label="isolated-environment", @@ -120,7 +142,7 @@ def installed_distribution(tmp_path_factory): if shutil.which("uv") else [str(python), "-m", "pip", "install"] ) - run_step([*installer, str(wheel)], label="base-install", cwd=path) + run_step([*installer, str(distribution)], label=f"{request.param}-base-install", cwd=path) base_check = r""" import importlib.util from importlib.resources import files @@ -131,6 +153,9 @@ def installed_distribution(tmp_path_factory): PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationWorker, + ReportingActivityProjector, + ReportingActivitySupport, + resolve_reporting_consumer, ) from adcp.reporting.ledger import InMemoryReportingLedgerStore, ReportingProducer from adcp.validation.schema_loader import get_named_validator @@ -151,6 +176,9 @@ def installed_distribution(tmp_path_factory): else: raise AssertionError("PgReportingOutbox must raise the [pg] install hint") assert files("adcp.reporting.ledger").joinpath("reporting_notification_outbox.sql").is_file() +assert files("adcp.reporting.ledger").joinpath("reporting_webhook_activity.sql").is_file() +assert files("adcp.reporting.outbox").joinpath("required_schema.json").is_file() +assert get_named_validator("core/webhook-activity-record.json", version="3.2.0-rc.3") is not None assert ( get_named_validator("core/reporting-ledger-changed-webhook.json", version="3.2.0-rc.3") is not None @@ -161,14 +189,14 @@ def installed_distribution(tmp_path_factory): run_step([str(python), "-c", base_check], label="base-import-without-pg", cwd=path).strip() == "base-import-without-pg-ok" ) - return path, python, installer, wheel, source + return path, python, installer, distribution -def test_wheel_and_sdist_contain_exact_complete_sql_chain(installed_distribution): - _, _, _, wheel, source = installed_distribution +def test_wheel_and_sdist_contain_exact_complete_sql_chain(built_distribution): + _, wheel, source = built_distribution with zipfile.ZipFile(wheel) as archive, tarfile.open(source) as tar: prefix = tar.getnames()[0].split("/")[0] - for name in CHAIN: + for name in (*CHAIN, "reporting_webhook_activity.sql"): expected = (ROOT / "src" / "adcp" / "reporting" / "ledger" / name).read_bytes() assert archive.read(f"adcp/reporting/ledger/{name}") == expected member = tar.extractfile(f"{prefix}/src/adcp/reporting/ledger/{name}") @@ -177,20 +205,24 @@ def test_wheel_and_sdist_contain_exact_complete_sql_chain(installed_distribution archive.read("adcp/reporting/outbox/_schema.py") == (ROOT / "src" / "adcp" / "reporting" / "outbox" / "_schema.py").read_bytes() ) + expected = (ROOT / "src/adcp/reporting/outbox/required_schema.json").read_bytes() + assert archive.read("adcp/reporting/outbox/required_schema.json") == expected + member = tar.extractfile(f"{prefix}/src/adcp/reporting/outbox/required_schema.json") + assert member is not None and member.read() == expected def test_installed_base_import_needs_no_pg_extra(installed_distribution): # The fixture runs the import with PG genuinely absent, before any extra # installation, so collection/test order cannot accidentally fake this gate. - _, python, _, _, _ = installed_distribution + _, python, _, _ = installed_distribution assert python.is_file() async def test_installed_pg_extra_migrates_commits_and_restarts(installed_distribution): - path, python, installer, wheel, _ = installed_distribution + path, python, installer, distribution = installed_distribution async with isolated_reporting_pool(autocommit=True) as pool: await asyncio.to_thread( - run_step, [*installer, str(wheel) + "[pg]"], label="pg-install", cwd=path + run_step, [*installer, str(distribution) + "[pg]"], label="pg-install", cwd=path ) config = configuration() obligation = obligation_for(config) @@ -200,6 +232,9 @@ async def test_installed_pg_extra_migrates_commits_and_restarts(installed_distri "kwargs": pool.kwargs, "now": NOW.isoformat(), "rows": rows, + "required_objects": json.loads( + (ROOT / "src/adcp/reporting/outbox/required_schema.json").read_text() + ), } for name, record in ( ("config", config), @@ -209,7 +244,8 @@ async def test_installed_pg_extra_migrates_commits_and_restarts(installed_distri values[name] = TypeAdapter(type(record)).dump_python(record, mode="json") script = r""" import asyncio, json, sys -from datetime import datetime +from datetime import datetime, timedelta +from importlib.resources import files from pydantic import TypeAdapter from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( @@ -218,19 +254,47 @@ async def test_installed_pg_extra_migrates_commits_and_restarts(installed_distri ReportingObligationRecord, ReportingRevisionRecord, ) -from adcp.reporting.outbox import PgReportingOutbox +from adcp.reporting.outbox import ( + ActivityOutcome, ActivityRequest, PgReportingOutbox, ReportingEnvelopeCipher, + ReportingLegacyAuthentication, ReportingNotificationSubscription, + ReportingNotificationWorker, ReportingActivityProjector, ReportingActivitySupport, +) +from adcp.reporting.outbox._schema import schema_objects values = json.load(sys.stdin) async def main(): clock = lambda: datetime.fromisoformat(values["now"]) + subscription = ReportingNotificationSubscription( + account_id="acct_a", subscriber_id="buyer", principal_id="https://buyer.example/agent", + url="https://example.test/api/PRIVATE_SECRET?token=QUERY_SECRET", + event_types=("reporting.ledger_changed",), configuration_revision="revision-1", + authorization_ref="grant-1", proof_of_control_ref="proof-1", + authentication=ReportingLegacyAuthentication("Bearer", "AUTH_SECRET"), + active=True, authorized=True, proof_valid=True, + ) + class Configurations: + async def list_active(self, *, account_id, notification_type): + return (subscription,) if account_id == subscription.account_id else () + async def get_active(self, *, account_id, subscriber_id, notification_type): + return subscription if account_id == subscription.account_id else None + cipher = ReportingEnvelopeCipher(b"e" * 32) async with AsyncConnectionPool( values["conninfo"], kwargs=values["kwargs"], open=False ) as pool: await pool.wait(timeout=10) + async with pool.connection() as connection: + version = (await (await connection.execute("SHOW server_version")).fetchone())[0] + assert version.startswith("16."), "package gate requires PostgreSQL 16.x" store = PgReportingReconciliationStore(pool=pool, clock=clock, notifications=True) await store.create_schema() + async with pool.connection() as connection: + objects = await schema_objects(connection) + assert objects == values["required_objects"] + assert json.dumps(objects, indent=2, sort_keys=True) + "\n" == files( + "adcp.reporting.outbox" + ).joinpath("required_schema.json").read_text() await store.put_configuration( TypeAdapter(ReportingConfiguration).validate_python(values["config"]) ) @@ -241,20 +305,62 @@ async def main(): TypeAdapter(ReportingRevisionRecord).validate_python(values["revision"]), values["rows"], ) - events = await PgReportingOutbox(pool=pool, clock=clock).list_events( - account_id="acct_a" + outbox = PgReportingOutbox(pool=pool, clock=clock) + worker = ReportingNotificationWorker( + outbox=outbox, activity=outbox, subscriptions=Configurations(), cipher=cipher, + clock=clock, ) + projector = ReportingActivityProjector(outbox) + assert await ReportingActivitySupport(worker, store, projector).durable() + events = await outbox.list_events(account_id="acct_a") + expanded_1 = await worker.expand_one(account_id="acct_a") + assert expanded_1 + lease = await outbox.claim_delivery(account_id="acct_a", now=clock(), lease_seconds=60) + original = cipher.open(lease.delivery).prepared + attempt = await outbox.reserve_attempt( + lease, request=ActivityRequest(subscription.url, len(original.body)), now=clock(), + ) + assert attempt.attempt == 1 and "SECRET" not in str(attempt.to_wire()) + completed_attempt_1 = await outbox.complete_attempt( + attempt, outcome=ActivityOutcome("failed", 503, 1), now=clock(), + ) + assert completed_attempt_1 + finished_delivery_1 = await outbox.finish_delivery(lease, now=clock(), state="pending", retry_at=clock()) + assert finished_delivery_1 async with AsyncConnectionPool( values["conninfo"], kwargs=values["kwargs"], open=False ) as fresh: await fresh.wait(timeout=10) await PgReportingReconciliationStore(pool=fresh).create_schema() + async with fresh.connection() as connection: + assert await schema_objects(connection) == values["required_objects"] outbox = PgReportingOutbox(pool=fresh, clock=clock) assert len(events) == 1 and await outbox.list_events(account_id="acct_a") == events - assert ( - await outbox.claim_expansion(account_id="acct_a", now=clock(), lease_seconds=60) - is not None + claimed_expansion_1 = await outbox.claim_expansion( + account_id="acct_a", now=clock(), lease_seconds=60, + ) + assert claimed_expansion_1 is None + lease = await outbox.claim_delivery(account_id="acct_a", now=clock(), lease_seconds=60) + retried = cipher.open(lease.delivery).prepared + assert retried.body == original.body and retried.idempotency_key == original.idempotency_key + attempt = await outbox.reserve_attempt( + lease, request=ActivityRequest(subscription.url, len(retried.body)), now=clock(), + ) + assert attempt.attempt == 2 + completed_attempt_2 = await outbox.complete_attempt( + attempt, outcome=ActivityOutcome("success", 200, 2), now=clock(), + ) + assert completed_attempt_2 + finished_delivery_2 = await outbox.finish_delivery(lease, now=clock(), state="complete") + assert finished_delivery_2 + rows = await outbox.list_activity( + account_id="acct_a", consumer_id=subscription.principal_id, ) + assert [row.to_wire()["status"] for row in rows] == ["success", "failed"] + assert await outbox.list_activity(account_id="acct_a", consumer_id="other") == () + assert await outbox.list_activity( + account_id="other", consumer_id=subscription.principal_id, + ) == () assert await outbox.list_events(account_id="other") == () print("installed-pg-restart-ok") diff --git a/tests/conformance/reporting/test_reporting_transport_logging.py b/tests/conformance/reporting/test_reporting_transport_logging.py new file mode 100644 index 000000000..bbbf95b70 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_transport_logging.py @@ -0,0 +1,189 @@ +"""Deterministic overlapping transport protection across tasks and threads.""" + +from __future__ import annotations + +import ast +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event + +import httpcore +import httpx +import pytest + +from adcp.reporting.outbox._transport_logging import ( + _LOGGER_NAMES, + protected_transport_logs, +) + + +def logger_filters(): + return {name: tuple(logging.getLogger(name).filters) for name in _LOGGER_NAMES} + + +def test_installed_transport_loggers_are_all_protected(): + discovered = set() + for module in (httpcore, httpx): + for path in Path(module.__file__).parent.rglob("*.py"): + for node in ast.walk(ast.parse(path.read_text())): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "logging" + and node.func.attr == "getLogger" + ): + continue + assert len(node.args) == 1 and isinstance(node.args[0], ast.Constant) + name = node.args[0].value + assert isinstance(name, str) and name.startswith(("httpx", "httpcore")) + discovered.add(name) + assert discovered and discovered <= set(_LOGGER_NAMES) + + +@pytest.mark.parametrize("logger_name", _LOGGER_NAMES) +async def test_overlapping_tasks_keep_secrets_protected_after_first_exit(caplog, logger_name): + caplog.set_level(logging.DEBUG) + logger = logging.getLogger(logger_name) + before = logger_filters() + first_entered, second_entered = asyncio.Event(), asyncio.Event() + first_exited, normal_logged = asyncio.Event(), asyncio.Event() + + async def first(): + with protected_transport_logs(): + first_entered.set() + await second_entered.wait() + first_exited.set() + logger.info("first-task-normal-record") + + async def second(): + await first_entered.wait() + with protected_transport_logs(): + second_entered.set() + await first_exited.wait() + logger.warning("OVERLAPPING_TASK_SECRET") + logging.getLogger("adcp.test.application").info("protected-task-application-record") + await normal_logged.wait() + + async def application(): + await first_exited.wait() + logger.info("concurrent-task-normal-record") + normal_logged.set() + + await asyncio.wait_for(asyncio.gather(first(), second(), application()), timeout=5) + logger.info("after-tasks-normal-record") + assert "OVERLAPPING_TASK_SECRET" not in caplog.text + for message in ( + "first-task-normal-record", + "concurrent-task-normal-record", + "protected-task-application-record", + "after-tasks-normal-record", + ): + assert message in caplog.text + assert logger_filters() == before + + +@pytest.mark.parametrize("logger_name", _LOGGER_NAMES) +def test_overlapping_threads_keep_secrets_protected_after_first_exit(caplog, logger_name): + caplog.set_level(logging.DEBUG) + logger = logging.getLogger(logger_name) + before = logger_filters() + first_entered, second_entered = Event(), Event() + first_exited, protected_logged, normal_logged = Event(), Event(), Event() + + def first(): + with protected_transport_logs(): + first_entered.set() + assert second_entered.wait(5), "second thread entry deadline" + first_exited.set() + logger.info("first-thread-normal-record") + + def second(): + assert first_entered.wait(5), "first thread entry deadline" + with protected_transport_logs(): + second_entered.set() + assert first_exited.wait(5), "first thread exit deadline" + logger.warning("OVERLAPPING_THREAD_SECRET") + logging.getLogger("adcp.test.application").info("protected-thread-application-record") + protected_logged.set() + assert normal_logged.wait(5), "application record deadline" + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(first), executor.submit(second)] + try: + assert protected_logged.wait(5), "protected record deadline" + logger.info("concurrent-thread-normal-record") + finally: + normal_logged.set() + for future in futures: + future.result(timeout=5) + logger.info("after-threads-normal-record") + assert "OVERLAPPING_THREAD_SECRET" not in caplog.text + for message in ( + "first-thread-normal-record", + "concurrent-thread-normal-record", + "protected-thread-application-record", + "after-threads-normal-record", + ): + assert message in caplog.text + assert logger_filters() == before + + +@pytest.mark.parametrize("exception_exit", [False, True]) +def test_nested_contexts_restore_filters_and_context_after_exit(caplog, exception_exit): + caplog.set_level(logging.DEBUG) + before = logger_filters() + + class ContextExitError(Exception): + pass + + try: + with protected_transport_logs(): + try: + with protected_transport_logs(): + for name in _LOGGER_NAMES: + logging.getLogger(name).warning("NESTED_CONTEXT_SECRET") + if exception_exit: + raise ContextExitError + except ContextExitError: + # The injected inner exit must leave the outer context protecting logs. + pass + for name in _LOGGER_NAMES: + logging.getLogger(name).warning("OUTER_CONTEXT_SECRET") + if exception_exit: + raise ContextExitError + except ContextExitError: + # The injected outer exit lets the checks below verify normal logging is restored. + pass + + for name in _LOGGER_NAMES: + logging.getLogger(name).info("restored-normal-record:%s", name) + assert "NESTED_CONTEXT_SECRET" not in caplog.text + assert "OUTER_CONTEXT_SECRET" not in caplog.text + assert all(f"restored-normal-record:{name}" in caplog.text for name in _LOGGER_NAMES) + assert logger_filters() == before + + +async def test_cancelled_task_restores_transport_filters(caplog): + caplog.set_level(logging.DEBUG) + before = logger_filters() + entered = asyncio.Event() + + async def protected(): + with protected_transport_logs(): + entered.set() + await asyncio.Event().wait() + + task = asyncio.create_task(protected()) + try: + await asyncio.wait_for(entered.wait(), timeout=5) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=5) + for name in _LOGGER_NAMES: + logging.getLogger(name).info("after-cancellation:%s", name) + assert all(f"after-cancellation:{name}" in caplog.text for name in _LOGGER_NAMES) + assert logger_filters() == before diff --git a/tests/conformance/reporting/test_reporting_webhook_activity.py b/tests/conformance/reporting/test_reporting_webhook_activity.py new file mode 100644 index 000000000..ffa60d5e6 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_webhook_activity.py @@ -0,0 +1,647 @@ +"""Shared memory/PostgreSQL vectors at the reporting worker's actual HTTP seam.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import socket +from dataclasses import replace +from datetime import timedelta + +import httpx +import pytest + +from adcp.decisioning.accounts import ResolveContext +from adcp.decisioning.context import AuthInfo +from adcp.reporting.outbox import ( + ActivityOutcome, + ActivityRequest, + ReportingActivityProjector, + ReportingNotificationError, + ReportingNotificationWorker, +) +from adcp.webhook_sender import PreparedWebhookAttemptExpiredError, WebhookSender + +from ._reliable_support import Barrier, SimulatedCrash, notification_subscription +from .test_reporting_notification_outbox import seed + + +def worker_for(h, outbox=None, **options): + outbox = outbox or h.outbox + return ReportingNotificationWorker( + outbox=outbox, + activity=outbox, + subscriptions=h.subscriptions, + signing=h.signing, + cipher=h.cipher, + clock=h.reliable.clock, + **options, + ) + + +async def prepare(h, *, account="acct_a"): + await seed(h, account=account) + outbox = h.outbox + worker = worker_for(h, outbox) + expanded_1 = await worker.expand_one(account_id=account) + assert expanded_1 + return outbox, worker + + +async def reserve(h, outbox, *, account="acct_a", lease_seconds=60): + lease = await outbox.claim_delivery( + account_id=account, now=h.reliable.clock(), lease_seconds=lease_seconds + ) + assert lease is not None + opened = h.cipher.open(lease.delivery) + attempt = await outbox.reserve_attempt( + lease, + request=ActivityRequest(opened.subscription.url, len(opened.prepared.body)), + now=h.reliable.clock(), + ) + assert attempt is not None + return lease, attempt + + +async def rows(outbox, *, account="acct_a", consumer="buyer", limit=50): + return [ + item.to_wire() + for item in await outbox.list_activity( + account_id=account, consumer_id=consumer, limit=limit + ) + ] + + +@pytest.mark.parametrize( + "code,parent", + [ + (200, "complete"), + (299, "complete"), + (302, "quarantined"), + (400, "quarantined"), + (401, "quarantined"), + (408, "pending"), + (425, "pending"), + (429, "pending"), + (500, "pending"), + (503, "pending"), + (599, "pending"), + ], +) +async def test_http_outcomes_are_independent_of_parent_retry_policy( + notification_harness, code, parent +): + h = notification_harness + outbox, worker = await prepare(h) + h.receiver.responses["buyer"].append(code) + delivered_1 = await worker.deliver_one(account_id="acct_a") + assert delivered_1 + (row,) = await rows(outbox) + assert row["status"] == ("success" if 200 <= code < 300 else "failed") + assert row["attempt"] == 1 and row["http_status_code"] == code + assert row["completed_at"] is not None and row["response_time_ms"] >= 0 + assert row["payload_size_bytes"] == len(h.receiver.received[0].body) + assert (await outbox.list_deliveries(account_id="acct_a"))[0].state == parent + assert row["error_message"] == (None if code < 300 else "HTTP non-success response") + assert "sequence_number" not in row + assert not {"lease_token", "reservation_token", "state", "principal_id"} & row.keys() + h.reliable.clock.advance(timedelta(seconds=10)) + if parent != "pending": + delivered_3 = await worker.deliver_one(account_id="acct_a") + assert not delivered_3 + assert len(await rows(outbox)) == 1 + + +@pytest.mark.parametrize( + "error,status", + [ + (httpx.ReadTimeout("URL_SECRET"), "timeout"), + (httpx.ConnectTimeout("URL_SECRET"), "timeout"), + (TimeoutError("URL_SECRET"), "timeout"), + (httpx.ConnectError("TLS_SECRET"), "connection_error"), + (ConnectionRefusedError("SOCKET_SECRET"), "connection_error"), + (socket.gaierror("DNS_SECRET"), "connection_error"), + ], +) +async def test_post_reservation_transport_classification( + notification_harness, error, status, caplog +): + h = notification_harness + outbox, worker = await prepare(h) + caplog.set_level(logging.DEBUG) + h.receiver.responses["buyer"].append(error) + await worker.deliver_one(account_id="acct_a") + (row,) = await rows(outbox) + assert row["status"] == status and row["completed_at"] is not None + assert row["http_status_code"] is None and row["response_time_ms"] is None + assert "SECRET" not in json.dumps(row) + caplog.text + + +async def test_response_latency_uses_monotonic_time(notification_harness, monkeypatch): + from adcp.reporting.outbox import worker as worker_module + + h = notification_harness + outbox, worker = await prepare(h) + ticks = iter((1_000_000_000, 1_042_000_000)) + monkeypatch.setattr(worker_module.time, "monotonic_ns", lambda: next(ticks)) + await worker.deliver_one(account_id="acct_a") + (row,) = await rows(outbox) + assert row["response_time_ms"] == 42 + + +@pytest.mark.parametrize("phase", ["dns_error", "ssrf", "dns_expired", "signing_expired"]) +async def test_preflight_never_reserves_and_dns_signing_can_outlive_lease( + notification_harness, monkeypatch, phase +): + h = notification_harness + outbox, worker = await prepare(h) + original = socket.getaddrinfo + + def dns(host, *args, **kwargs): + if host == "receiver.example.test": + if phase == "dns_error": + raise socket.gaierror("DNS_SECRET") + if phase == "dns_expired": + h.reliable.clock.advance(timedelta(seconds=61)) + return original(host, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", dns) + if phase == "ssrf": + h.receiver.dns_addresses["receiver.example.test"] = ["127.0.0.1"] + if phase == "signing_expired": + resolve = h.signing.resolve + + async def delayed(**kwargs): + material = await resolve(**kwargs) + h.reliable.clock.advance(timedelta(seconds=61)) + return material + + monkeypatch.setattr(h.signing, "resolve", delayed) + await worker.deliver_one(account_id="acct_a") + assert await rows(outbox) == [] + assert h.receiver.connections == [] + + +async def test_callback_is_single_use_and_claims_do_not_count_as_http( + notification_harness, monkeypatch +): + h = notification_harness + outbox, worker = await prepare(h) + for _ in range(3): + claimed_delivery_1 = await outbox.claim_delivery( + account_id="acct_a", now=h.reliable.clock(), lease_seconds=1 + ) + assert claimed_delivery_1 + h.reliable.clock.advance(timedelta(seconds=2)) + + async def twice(sender, prepared, *, before_attempt=None): + assert before_attempt is not None + attempt_prepared_1 = await before_attempt() + assert attempt_prepared_1 + with pytest.raises(PreparedWebhookAttemptExpiredError): + await before_attempt() + raise SimulatedCrash + + monkeypatch.setattr(WebhookSender, "send_prepared", twice) + with pytest.raises(SimulatedCrash): + await worker.deliver_one(account_id="acct_a") + (row,) = await rows(outbox) + assert row["attempt"] == 1 and row["status"] == "pending" + assert all( + row[key] is None + for key in ("completed_at", "http_status_code", "response_time_ms", "error_message") + ) + assert h.receiver.connections == [] + + +@pytest.mark.parametrize("window", ["reservation_to_io", "http_to_activity_ack"]) +async def test_irreducible_crash_windows_retain_pending_and_retry_exact_bytes( + notification_harness, monkeypatch, window +): + h = notification_harness + outbox, worker = await prepare(h) + method = "reserve_attempt" if window == "reservation_to_io" else "complete_attempt" + original = getattr(outbox, method) + + async def crash(*args, **kwargs): + if window == "reservation_to_io": + await original(*args, **kwargs) + raise SimulatedCrash + + monkeypatch.setattr(outbox, method, crash) + with pytest.raises(SimulatedCrash): + await worker.deliver_one(account_id="acct_a") + assert (await rows(outbox))[0]["status"] == "pending" + assert (await outbox.list_deliveries(account_id="acct_a"))[0].state == "leased" + assert len(h.receiver.received) == (0 if window == "reservation_to_io" else 1) + monkeypatch.setattr(outbox, method, original) + h.reliable.clock.advance(timedelta(seconds=61)) + await worker_for(h, outbox).deliver_one(account_id="acct_a") + result = await rows(outbox) + assert [(r["attempt"], r["status"]) for r in result] == [(2, "success"), (1, "pending")] + assert len({r["idempotency_key"] for r in result}) == 1 + assert len({r["notification_id"] for r in result}) == 1 + if window == "http_to_activity_ack": + assert h.receiver.received[0].body == h.receiver.received[1].body + assert ( + h.cipher.open((await outbox.list_deliveries(account_id="acct_a"))[0].delivery).prepared.body + == h.receiver.received[-1].body + ) + + +@pytest.mark.parametrize("phase", ["reserve", "complete", "unconfirmed"]) +async def test_activity_database_failure_never_sends_or_acks_past_unknown_commit( + notification_harness, monkeypatch, phase +): + h = notification_harness + outbox, worker = await prepare(h) + + async def fail(*args, **kwargs): + if phase == "unconfirmed": + return False + raise ReportingNotificationError("activity_store_unavailable") + + monkeypatch.setattr( + outbox, "reserve_attempt" if phase == "reserve" else "complete_attempt", fail + ) + with pytest.raises(ReportingNotificationError): + await worker.deliver_one(account_id="acct_a") + assert (await outbox.list_deliveries(account_id="acct_a"))[0].state == "leased" + assert len(h.receiver.received) == (0 if phase == "reserve" else 1) + if phase != "reserve": + assert (await rows(outbox))[0]["status"] == "pending" + + +@pytest.mark.parametrize("deadline", [False, True]) +async def test_cancellation_and_worker_deadline_leave_unknown_pending( + notification_harness, deadline +): + h = notification_harness + outbox, _ = await prepare(h) + worker = worker_for(h, outbox, lease_seconds=1 if deadline else 60) + barrier = Barrier() + h.reliable.failures.at("http.before", barrier) + task = asyncio.create_task(worker.deliver_one(account_id="acct_a")) + await barrier.wait() + if deadline: + task_result_1 = await asyncio.wait_for(task, timeout=3) + assert task_result_1 + else: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert (await rows(outbox))[0]["status"] == "pending" + assert (await outbox.list_deliveries(account_id="acct_a"))[0].state == "leased" + + +async def test_late_completion_only_updates_its_token_after_parent_reclaim(notification_harness): + h = notification_harness + outbox, _ = await prepare(h) + old_lease, old = await reserve(h, outbox) + h.reliable.clock.advance(timedelta(seconds=61)) + current_lease, current = await reserve(h, outbox) + assert current.attempt == 2 + completed_attempt_1 = await outbox.complete_attempt( + old, outcome=ActivityOutcome("success", 200, 61_000), now=h.reliable.clock() + ) + assert completed_attempt_1 + completed_attempt_2 = await outbox.complete_attempt( + old, outcome=ActivityOutcome("failed", 500, 1), now=h.reliable.clock() + ) + assert not completed_attempt_2 + completed_attempt_3 = await outbox.complete_attempt( + replace(current, reservation_token=old.reservation_token), + outcome=ActivityOutcome("timeout"), + now=h.reliable.clock(), + ) + assert not completed_attempt_3 + finished_delivery_1 = await outbox.finish_delivery( + old_lease, state="complete", now=h.reliable.clock() + ) + assert not finished_delivery_1 + assert await outbox.delivery_lease_current(current_lease, now=h.reliable.clock()) + assert [(r["attempt"], r["status"]) for r in await rows(outbox)] == [ + (2, "pending"), + (1, "success"), + ] + + +async def test_exact_completed_at_retention_floor_never_deletes_pending(notification_harness): + h = notification_harness + outbox, _ = await prepare(h) + _, pending = await reserve(h, outbox) + h.reliable.clock.advance(timedelta(days=5)) + _, completed = await reserve(h, outbox) + h.reliable.clock.advance(timedelta(seconds=1)) + completed_attempt_4 = await outbox.complete_attempt( + completed, outcome=ActivityOutcome("timeout"), now=h.reliable.clock() + ) + assert completed_attempt_4 + for days in (0, 29, True): + with pytest.raises(ReportingNotificationError, match="30_days"): + await outbox.purge_activity( + account_id="acct_a", + consumer_id="buyer", + now=h.reliable.clock(), + retention_days=days, + ) + h.reliable.clock.advance(timedelta(days=30)) + purged_count_1 = await outbox.purge_activity( + account_id="acct_a", consumer_id="buyer", now=h.reliable.clock() + ) + assert purged_count_1 == 0 + h.reliable.clock.advance(timedelta(microseconds=1)) + purged_count_2 = await outbox.purge_activity( + account_id="acct_a", consumer_id="other", now=h.reliable.clock() + ) + assert purged_count_2 == 0 + purged_count_3 = await outbox.purge_activity( + account_id="other", consumer_id="buyer", now=h.reliable.clock() + ) + assert purged_count_3 == 0 + purged_count_4 = await outbox.purge_activity( + account_id="acct_a", consumer_id="buyer", now=h.reliable.clock() + ) + assert purged_count_4 == 1 + assert (await outbox.list_activity(account_id="acct_a", consumer_id="buyer")) == (pending,) + + +async def test_purged_terminal_history_cannot_reset_a_retryable_delivery_counter( + notification_harness, +): + h = notification_harness + outbox, worker = await prepare(h) + h.receiver.responses["buyer"].append(500) + await worker.deliver_one(account_id="acct_a") + first = (await rows(outbox))[0] + h.reliable.clock.advance(timedelta(days=31)) + purged_count_5 = await outbox.purge_activity( + account_id="acct_a", consumer_id="buyer", now=h.reliable.clock() + ) + assert purged_count_5 == 1 + assert await rows(outbox) == [] + await worker_for(h, outbox).deliver_one(account_id="acct_a") + (second,) = await rows(outbox) + assert second["attempt"] == 2 and second["status"] == "success" + assert second["idempotency_key"] == first["idempotency_key"] + + +async def test_wire_serialization_omits_absent_optional_ids_and_preserves_pending_nulls( + notification_harness, +): + h = notification_harness + outbox, _ = await prepare(h) + _, attempt = await reserve(h, outbox) + projected = replace(attempt, binding=replace(attempt.binding, notification_id="")).to_wire() + assert "notification_id" not in projected and "sequence_number" not in projected + assert all( + projected[key] is None + for key in ( + "completed_at", + "http_status_code", + "response_time_ms", + "error_message", + ) + ) + + +async def test_canonical_url_bound_is_registration_closed_on_both_stores(notification_harness): + h = notification_harness + # A raw URL inside the bound whose percent-encoded canonical form is not + # must never become a durable configuration: it would otherwise quarantine + # every expansion and record no activity at all. + with pytest.raises(ReportingNotificationError, match="invalid_configuration"): + notification_subscription(url="https://receiver.example.test/PATH_SECRET" + " " * 8000) + host = "https://receiver.example.test/" + h.subscriptions.put(notification_subscription(url=host + "a" * (8192 - len(host)))) + outbox, worker = await prepare(h) + delivered_2 = await worker.deliver_one(account_id="acct_a") + assert delivered_2 + (row,) = await rows(outbox) + # The rejected registration left the store working, and the maximal + # in-bound canonical URL delivers and projects its sanitized form. + assert (row["status"], row["attempt"]) == ("success", 1) + assert row["url"] == host + "redacted" and len(row["url"]) <= 8192 + assert (await outbox.list_deliveries(account_id="acct_a"))[0].state == "complete" + assert "PATH_SECRET" not in json.dumps(row) + + +@pytest.mark.parametrize("limit", [0, 201, -1, True, 1.5, "2"]) +async def test_activity_limit_is_validated_before_store_access(notification_harness, limit): + with pytest.raises(ReportingNotificationError, match="1_to_200"): + await notification_harness.outbox.list_activity( + account_id="acct_a", consumer_id="buyer", limit=limit + ) + + +async def test_account_principal_write_read_isolation_and_deterministic_ties(notification_harness): + h = notification_harness + h.subscriptions.put(notification_subscription(subscriber="audit", principal="auditor")) + h.subscriptions.put(notification_subscription(account="acct_b", principal="other")) + outbox, worker = await prepare(h) + await seed(h, account="acct_b") + await worker.expand_one(account_id="acct_b") + for account in ("acct_a", "acct_b"): + while await worker.deliver_one(account_id=account): + pass + own = await outbox.list_activity(account_id="acct_a", consumer_id="buyer") + foreign = await outbox.list_activity(account_id="acct_a", consumer_id="auditor") + assert len(own) == len(foreign) == 1 + assert own[0].binding.notification_id == foreign[0].binding.notification_id + assert await rows(outbox, account="acct_b", consumer="buyer") == [] + assert await rows(outbox, account="acct_a", consumer="other") == [] + # Both predicates must hold for completion, even with another attempt's token. + forged = replace(own[0], binding=replace(own[0].binding, principal_id="auditor")) + completed_attempt_5 = await outbox.complete_attempt( + forged, outcome=ActivityOutcome("timeout"), now=h.reliable.clock() + ) + assert not completed_attempt_5 + # Re-emissions get new keys at the same timestamp; limiting is per principal. + notification = own[0].binding.notification_id + for _ in range(4): + await outbox.reemit( + account_id="acct_a", notification_id=notification, now=h.reliable.clock() + ) + await worker.expand_one(account_id="acct_a") + while await worker.deliver_one(account_id="acct_a"): + pass + all_rows = await rows(outbox, limit=200) + assert len(all_rows) == 5 + assert all_rows == sorted( + all_rows, + key=lambda r: ( + r["fired_at"], + r["notification_id"], + r["idempotency_key"], + r["subscriber_id"], + r["attempt"], + ), + reverse=True, + ) + assert await rows(outbox, limit=2) == all_rows[:2] + assert await rows(outbox, limit=2) == all_rows[:2] + projected = await ReportingActivityProjector(outbox).for_account( + account_id="acct_a", + context=ResolveContext(auth_info=AuthInfo(kind="bearer", principal="buyer")), + limit=2, + ) + assert projected == all_rows[:2] + + +@pytest.mark.parametrize( + "change", ["account_id", "principal_id", "consumer_namespace", "body_sha256", "idempotency_key"] +) +async def test_reservation_cannot_rebind_authenticated_parent(notification_harness, change): + h = notification_harness + outbox, _ = await prepare(h) + lease = await outbox.claim_delivery( + account_id="acct_a", now=h.reliable.clock(), lease_seconds=60 + ) + assert lease is not None + forged = replace( + lease, + delivery=replace( + lease.delivery, binding=replace(lease.delivery.binding, **{change: "foreign"}) + ), + ) + reserved_attempt_1 = await outbox.reserve_attempt( + forged, + request=ActivityRequest("https://example.test/reporting", 1), + now=h.reliable.clock(), + ) + assert reserved_attempt_1 is None + assert await rows(outbox) == [] + + +@pytest.mark.parametrize("field", ["body_sha256", "principal_id", "account_id", "lease_token"]) +async def test_completion_cannot_rebind_reservation_snapshot(notification_harness, field): + h = notification_harness + outbox, _ = await prepare(h) + _, attempt = await reserve(h, outbox) + forged = ( + replace(attempt, lease_token="foreign") + if field == "lease_token" + else replace(attempt, binding=replace(attempt.binding, **{field: "foreign"})) + ) + completed_attempt_6 = await outbox.complete_attempt( + forged, + outcome=ActivityOutcome("timeout"), + now=h.reliable.clock(), + ) + assert not completed_attempt_6 + assert await outbox.list_activity(account_id="acct_a", consumer_id="buyer") == (attempt,) + + +async def test_signed_failure_exact_retry_authorized_read_and_colliding_tenants( + notification_harness, monkeypatch, caplog +): + from concurrent.futures import ThreadPoolExecutor + from uuid import UUID + + from adcp.decisioning import DecisioningPlatform + from adcp.decisioning.handler import PlatformHandler + from adcp.decisioning.task_registry import InMemoryTaskRegistry + from adcp.reporting.ledger import notification_models + from adcp.reporting.outbox import resolve_reporting_consumer, routing + from adcp.server import ToolContext + from adcp.signing.jwks import StaticJwksResolver + from adcp.signing.webhook_verifier import WebhookVerifyOptions, verify_webhook_signature + from adcp.types import ListAccountsRequest + + from ._reliable_support import notification_verification_keys + + h = notification_harness + principal = "https://buyer.example/agent" + other_principal = "https://other.example/agent" + secret_url = "https://receiver.example.test/webhooks/c5b1b9b3-3e99-4da8-b930-6a9e67245553/URL_SECRET?QUERY_SECRET=1" + h.subscriptions.put(notification_subscription(principal=principal, url=secret_url)) + h.subscriptions.put( + notification_subscription(account="acct_b", principal=other_principal, url=secret_url) + ) + monkeypatch.setattr(notification_models, "uuid4", lambda: UUID(int=1)) + monkeypatch.setattr(routing, "uuid4", lambda: UUID(int=2)) + outbox, worker = await prepare(h) + await seed(h, account="acct_b") + await worker.expand_one(account_id="acct_b") + caplog.set_level(logging.DEBUG) + h.receiver.responses["buyer"].extend([503, 200, 201]) + await worker.deliver_one(account_id="acct_a") + h.reliable.clock.advance(timedelta(seconds=6)) + await worker.deliver_one(account_id="acct_a") + await worker.deliver_one(account_id="acct_b") + assert len(h.receiver.received) == 3 + first, second, foreign = h.receiver.received + assert first.body == second.body and first.idempotency_key == second.idempotency_key + assert foreign.idempotency_key == first.idempotency_key and foreign.body != first.body + for delivery in h.receiver.received: + assert ( + verify_webhook_signature( + method="POST", + url=secret_url, + headers=delivery.headers, + body=delivery.body, + options=WebhookVerifyOptions( + jwks_resolver=StaticJwksResolver({"keys": notification_verification_keys()}), + clock=lambda: h.reliable.clock().timestamp(), + ), + ).alg + == "ed25519" + ) + + class VisibleStore: + def list(self, filter=None, ctx=None): + consumer = resolve_reporting_consumer(auth_info=ctx.auth_info, agent=ctx.agent) + return ( + [{"account_id": "acct_a", "name": "Buyer", "status": "active"}] + if consumer == principal + else [] + ) + + class Platform(DecisioningPlatform): + accounts = VisibleStore() + + with ThreadPoolExecutor(max_workers=1) as executor: + handler = PlatformHandler( + Platform(), + executor=executor, + registry=InMemoryTaskRegistry(), + account_activity=ReportingActivityProjector(outbox), + ) + response = await handler.list_accounts( + ListAccountsRequest(include_webhook_activity=True, webhook_activity_limit=2), + ToolContext(metadata={"adcp.auth_info": AuthInfo(kind="bearer", principal=principal)}), + ) + activity = response["accounts"][0]["webhook_activity"] + assert [(row["attempt"], row["status"], row["http_status_code"]) for row in activity] == [ + (2, "success", 200), + (1, "failed", 503), + ] + assert all(row["notification_id"] == str(UUID(int=1)) for row in activity) + assert await rows(outbox, account="acct_b", consumer=principal) == [] + assert await rows(outbox, account="acct_a", consumer=other_principal) == [] + diagnostics = json.dumps(response) + caplog.text + if h.reliable.blobs.pool is not None: + async with h.reliable.blobs.pool.connection() as conn: + for table, expression in [ + ("reporting_webhook_attempts", "to_jsonb(t)"), + ("reporting_notification_deliveries", "to_jsonb(t) - 'envelope'"), + ]: + from psycopg import sql + + record = await ( + await conn.execute( + sql.SQL( + "SELECT {} FROM {} t WHERE account_id=%s AND principal_id=%s" + ).format(sql.SQL(expression), sql.Identifier(table)), + ("acct_a", principal), + ) + ).fetchall() + diagnostics += json.dumps(record, default=str) + for secret in ( + "URL_SECRET", + "QUERY_SECRET", + "c5b1b9b3-3e99-4da8-b930-6a9e67245553", + "DO_NOT_PERSIST", + ): + assert secret not in diagnostics diff --git a/tests/type_checks/reporting_webhook_activity.py b/tests/type_checks/reporting_webhook_activity.py new file mode 100644 index 000000000..d73b7a3d8 --- /dev/null +++ b/tests/type_checks/reporting_webhook_activity.py @@ -0,0 +1,44 @@ +"""Public activity types and optional handler mounting need no suppressions.""" + +from datetime import datetime +from typing import Any + +from adcp.decisioning.accounts import ResolveContext +from adcp.reporting.outbox import ( + ActivityOutcome, + ActivityRequest, + InMemoryReportingOutbox, + PgReportingOutbox, + ReportingActivityProjector, + ReportingActivityStore, + WebhookAttempt, + resolve_reporting_consumer, +) +from adcp.reporting.outbox.models import DeliveryLease + + +def reference_store( + store: InMemoryReportingOutbox | PgReportingOutbox, +) -> ReportingActivityStore: + return store + + +async def reserve_and_complete( + store: ReportingActivityStore, lease: DeliveryLease, at: datetime +) -> WebhookAttempt | None: + attempt = await store.reserve_attempt( + lease, request=ActivityRequest("https://example.test/reporting", 1), now=at + ) + if attempt is not None: + await store.complete_attempt(attempt, outcome=ActivityOutcome("success", 200, 1), now=at) + return attempt + + +async def authorized_read( + store: ReportingActivityStore, account_id: str, context: ResolveContext, at: datetime +) -> list[dict[str, Any]]: + consumer = resolve_reporting_consumer(auth_info=context.auth_info, agent=context.agent) + await store.purge_activity(account_id=account_id, consumer_id=consumer, now=at) + return await ReportingActivityProjector(store).for_account( + account_id=account_id, context=context, limit=50 + )