diff --git a/.changeset/instrument-3-2-surfaces.md b/.changeset/instrument-3-2-surfaces.md new file mode 100644 index 0000000000..869f33a3d0 --- /dev/null +++ b/.changeset/instrument-3-2-surfaces.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Instrument the AdCP 3.2 beta across the training agent, compliance surface, adopter documentation, and learning system. Targeting-aware product discovery now issues bounded configured-product offers, validates purchase-time narrowing against declared overlay support, preserves proposal acceptance and lifecycle readback, and enforces retry-safe idempotency. Webhook delivery uses durable, scoped request bindings and recoverable outbox snapshots with the SDK 14 beta emitter contract. A release manifest tracks all 16 feature families across documentation, training, compliance, and runtime, with explicit dispositions for deferred candidates. diff --git a/docs/building/by-layer/L4/choose-your-sdk.mdx b/docs/building/by-layer/L4/choose-your-sdk.mdx index 3bf90d34b5..2daf80965c 100644 --- a/docs/building/by-layer/L4/choose-your-sdk.mdx +++ b/docs/building/by-layer/L4/choose-your-sdk.mdx @@ -13,20 +13,20 @@ For the layered model behind this — what each layer contains and what an SDK a What "shipped" means at each layer is the [L0–L3 checklist](/docs/building/cross-cutting/sdk-stack#what-an-sdk-at-each-layer-should-provide). -*Last updated: 2026-08-20.* +*Last updated: 2026-08-23.* | SDK | Current package | 3.2 beta support | L0 | L1 | L2 | L3 | |---|---|---|:-:|:-:|:-:|:-:| -| **`@adcp/sdk`** (TypeScript) | `14.0.0-beta.5` | Exact `3.2.0-beta.4` support | ✅ | ✅ | ✅ | ✅ | -| **`adcp`** (Python) | `7.0.2` | Pending exact 3.2 support | ✅ | ⚠️ | ⚠️ | ⚠️ | +| **`@adcp/sdk`** (TypeScript) | `14.0.0-beta.7` | Exact `3.2.0-beta.5` support | ✅ | ✅ | ✅ | ✅ | +| **`adcp`** (Python) | `8.0.0b7` | Exact `3.2.0-beta.5` schemas and types; higher-level helpers remain partial | ✅ | ⚠️ | ⚠️ | ⚠️ | | **`adcp/v3`** (Go) | `v3.0.0` | Pending exact 3.2 support | ⚠️ | ⚠️ | ⚠️ | ⚠️ | Legend: ✅ shipped · ⚠️ partial / role-dependent · ❌ not yet covered. The L0–L3 columns describe the current package, not a 3.2 support claim. -**3.2 packages are prereleases.** The TypeScript beta supports the exact beta.4 -protocol checkpoint; it does not imply compatibility with a later beta. -Python and Go have not published exact 3.2 support statements yet. See the +**3.2 packages are prereleases.** The TypeScript and Python betas support the +exact beta.5 protocol checkpoint; that does not imply compatibility with a +later beta. Go has not published an exact 3.2 support statement yet. See the [3.2 beta program](/docs/reference/3-2-beta). @@ -49,7 +49,7 @@ and the [Slack community](/docs/community/joining-slack). [![npm version](https://img.shields.io/npm/v/@adcp/sdk)](https://www.npmjs.com/package/@adcp/sdk) ```bash -npm install @adcp/sdk@14.0.0-beta.5 +npm install @adcp/sdk@14.0.0-beta.7 ``` ```javascript @@ -63,7 +63,7 @@ const client = createSingleAgentClient({ }); const products = await client.listProducts({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: { account_id: 'account_123' }, criteria: { offer_filters: { channels: ['ctv'] }, @@ -100,7 +100,7 @@ integrations should use the main server and per-tool type exports. [![PyPI version](https://img.shields.io/pypi/v/adcp)](https://pypi.org/project/adcp/) ```bash -pip install adcp +pip install adcp==8.0.0b7 ``` ```python @@ -158,11 +158,11 @@ Both SDKs share the same positional shape: `adcp [tool] [payload]`. The ### JavaScript CLI ```bash -npx @adcp/sdk@14.0.0-beta.5 --help -npx @adcp/sdk@14.0.0-beta.5 --save-auth my-agent https://sales.example.com/mcp -npx @adcp/sdk@14.0.0-beta.5 my-agent list_products '{"adcp_version":"3.2-beta.4","account":{"account_id":"account_123"}}' +npx @adcp/sdk@14.0.0-beta.7 --help +npx @adcp/sdk@14.0.0-beta.7 --save-auth my-agent https://sales.example.com/mcp +npx @adcp/sdk@14.0.0-beta.7 my-agent list_products '{"adcp_version":"3.2-beta.5","account":{"account_id":"account_123"}}' # or against the built-in public test agent: -npx @adcp/sdk@14.0.0-beta.5 test-mcp list_products '{"adcp_version":"3.2-beta.4"}' +npx @adcp/sdk@14.0.0-beta.7 test-mcp list_products '{"adcp_version":"3.2-beta.5"}' ``` The CLI also drives storyboards (`adcp storyboard run`), conformance grading (`adcp grade`), and registry diagnostics. See `--help` for the full surface. diff --git a/docs/learning/foundations/a2b-testing-your-first-agent.mdx b/docs/learning/foundations/a2b-testing-your-first-agent.mdx index b145af31ee..7465b5a524 100644 --- a/docs/learning/foundations/a2b-testing-your-first-agent.mdx +++ b/docs/learning/foundations/a2b-testing-your-first-agent.mdx @@ -72,12 +72,12 @@ Use these as a quick reference while working through the module with Addie, or t Call `get_products` with `buying_mode: "brief"` and a plain-English description of your campaign goals. The agent returns curated `products[]` and ready-to-execute `proposals[]`. -This public training-agent lab continues to use the pre-3.2 brief flow. The 3.2 -structured discovery exercise will be enabled only after the beta SDKs and -training runtime implement targeting-aware discovery; follow -[issue #6199](https://github.com/adcontextprotocol/adcp/issues/6199). Until -then, do not use this sandbox to test `get_products.targeting_overlay` or -`required_overlay_support`, because the live runtime does not enforce them. +This foundation lab intentionally uses the `get_products` compatibility facade +so first-time learners can focus on the four-call lifecycle. The same public +seller also supports compact 3.2 targeting-aware discovery. After this module, +use the [buyer briefs supplement](/docs/learning/supplements/buyer-briefs-and-get-products) +to practice `list_products`, `request_proposals`, and effective targeting +readback with the pinned beta wire and SDK. ```bash diff --git a/docs/learning/instructional-design.mdx b/docs/learning/instructional-design.mdx index 5dfd549aaf..dd17c23700 100644 --- a/docs/learning/instructional-design.mdx +++ b/docs/learning/instructional-design.mdx @@ -157,7 +157,7 @@ AdCP is a living protocol. When the specification evolves — new tasks added, e Each required demonstration is tracked by a stable ID tied to specific protocol knowledge. When a protocol change affects what a certified person should know, the system can identify which credential holders learned under the previous criteria and flag them for recertification. -Recertification is targeted, not blanket. If a protocol update adds a new governance task but doesn't affect media buy workflows, only credentials that cover governance are flagged. Credential holders receive a notification through Addie with context on what changed and what they need to review. The decision process, delta-assessment rules, and AdCP 3.1 S2 canonical formats rationale are documented in the [recertification policy](/docs/learning/policies/recertification). +Recertification is targeted, not blanket. If a protocol update adds a new governance task but doesn't affect media buy workflows, only credentials that cover governance are flagged. Credential holders receive a notification through Addie with context on what changed and what they need to review. The decision process, delta-assessment rules, and recorded AdCP 3.1 and 3.2 decisions are documented in the [recertification policy](/docs/learning/policies/recertification). | Tier | Validity | Recertification | |------|----------|-----------------| diff --git a/docs/learning/policies/recertification.mdx b/docs/learning/policies/recertification.mdx index 7d747d1199..c95721f6e2 100644 --- a/docs/learning/policies/recertification.mdx +++ b/docs/learning/policies/recertification.mdx @@ -1,7 +1,7 @@ --- title: Recertification and protocol-change updates sidebarTitle: Recertification -description: "AdCP certification recertification policy: targeted protocol-change updates, delta assessments, learner notifications, and the AdCP 3.1 canonical formats S2 rationale." +description: "AdCP certification recertification policy: targeted protocol-change updates, delta assessments, learner notifications, and recorded AdCP 3.1 and 3.2 decisions." "og:title": "AdCP - Recertification and protocol-change updates" --- @@ -18,7 +18,8 @@ This policy is IACET-aligned and CPD-aligned. It is not an assertion of current AdCP credentials are tied to protocol competency. When the protocol changes, the certification program determines whether existing credential holders need no action, a targeted delta assessment, or full recertification. -This policy documents that decision process and records the AdCP 3.1 canonical formats rationale for pre-3.1 S2 Creative specialist holders. +This policy documents that decision process and records the AdCP 3.1 canonical +formats rationale and the AdCP 3.2 specialist-credential decision. ## Decision levels @@ -107,6 +108,59 @@ Learners are assigned full S2 recertification instead of the delta when: The delta-only window is 90 days after `delta_window_opens_at`. During that window, affected credential holders keep their S2 credential but are marked as requiring a protocol update. After the window closes, unresolved holders retain their historical S2 completion record, but their learner-facing S2 status is no longer current until they complete the current S2 module. Learner-facing messages must include the computed absolute deadline date. +## AdCP 3.2 specialist decision + +AdCP 3.2 adds bounded competencies to three specialist credentials without +invalidating their prior evidence. The recorded decision is therefore a +**targeted delta assessment** for pre-3.2 holders of S1 Media Buy, S2 Creative, +and S6 Security—not full recertification. No 3.2 learner targeting, notification, +or credential-status change may occur during beta. + +Migration `550_curriculum_3_2_criteria.sql` adds stable evidence handles to the +current module definitions so beta-path learners can demonstrate the new wire +behavior. That curriculum instrumentation is not, by itself, a release gate or +authorization to target prior credential holders. Evidence recorded during beta +is provisional until program leadership maps it to the final GA artifact. + +| Credential | New criterion IDs | Decision rationale | +|---|---|---| +| S1 Media Buy | `s1_ex1_sc_compact_media_buy_lifecycle`, `s1_ex1_sc_targeting_aware_discovery`, `s1_ex1_sc_availability_and_outcome_planning` | Compact lifecycle, targeting resolution, and planning inputs extend prior discovery, proposal, purchase, and optimization evidence. | +| S2 Creative | `s2_ex1_sc_pixel_density_and_renditions`, `s2_ex1_sc_vast_accessibility_provenance` | Pixel-density, rendition, validation, accessibility, and provenance rules narrow prior format-authoring and validation competencies. | +| S6 Security | `s6_ex2_sc_3_2_signed_body_integrity`, `s6_ex4_sc_webhook_delivery_identity` | Body binding and binary encoding extend request-signing evidence; separating echoed `operation_id` correlation from payload `idempotency_key` deduplication extends webhook-security evidence. | + +For each credential, `criteria_effective_at` is the later of AdCP 3.2.0 GA and +production deployment of `550_curriculum_3_2_criteria.sql` with the applicable +assessment path available. `delta_window_opens_at` equals that effective point; +`delta_window_closes_at` is 90 calendar days later at 23:59 UTC. The auditable +record requirement is a pre-effective-date completion record for the affected +module plus a criterion/checkpoint trail sufficient to verify its prior required +demonstrations. A holder whose record already includes every applicable 3.2 +criterion requires no action. + +Assign the current full module instead of the delta when the prior record is +missing or cannot be mapped, the learner misses the delta window, or later GA +changes materially invalidate the beta-era mapping above. Program leadership +must re-review this decision against the final 3.2.0 artifact before opening the +window; if GA changes a listed competency, update the criterion and rationale +before targeting learners. + +### GA activation gate + +The 3.2 delta is recorded but inactive during beta. Before anyone sets a 3.2 +criteria deployment timestamp or targets an existing holder, engineering and +program leadership must verify all of the following: + +- the final 3.2.0 artifact still maps to the criterion IDs above; +- each affected module has a registered delta definition and independently + configurable release gate—one module's window must not activate another; +- Addie can present only the missing criteria, save criterion-level evidence, + and route an unmappable or late record to the full current module; +- automated tests cover pre-GA suppression, the later-of-GA-and-deployment + boundary, the 90-day UTC deadline, already-complete evidence, missing records, + and the post-window full-recertification path; and +- learner notification copy contains the affected credential, exact required + demonstrations, absolute deadline, support contact, and appeal path. + ## CPD and accreditation disclosure Recommended disclosure for continuing professional development records: @@ -131,7 +185,7 @@ Email notification: > > Complete the delta by `` to keep your credential current. After that window, the current S2 module is required for renewal. If you believe you were targeted in error, email certification@agenticadvertising.org; we acknowledge assessment and targeting review requests within 2 business days and resolve them through the complaints process. -## Implementation gates +## AdCP 3.1 implementation gates Engineering must not enable learner targeting or send learner notifications until all of the following are true: diff --git a/docs/learning/specialist/creative.mdx b/docs/learning/specialist/creative.mdx index 1c9f332f70..ab16f93ac1 100644 --- a/docs/learning/specialist/creative.mdx +++ b/docs/learning/specialist/creative.mdx @@ -37,7 +37,8 @@ The following `specialisms` fall under the `creative` domain. Each has its own c - Understand creative agent pricing: the `pricing_options[]` array on discovery responses, how `pricing_option_id` flows through `build_creative` and `report_usage`, and why CPM ad servers show zero `vendor_cost` at build time while transformation agents do not - Reason about tracker-slot presence — a format supports third-party measurement only if its `assets` array declares a tracker slot (e.g., `impression_tracker`). Broadcast formats intentionally omit tracker slots; measurement comes from panel and STB data via `billing_measurement` - Attach `industry_identifiers[]` to broadcast manifests with the correct `creative-identifier-type` (`ad_id`, `isci`, `clearcast_clock`, or `idcrea`), and give each cut (`:15` vs `:30`) its own traffic identifier -- Reason about format edge cases, accessibility, and provenance +- Validate logical image dimensions independently from intrinsic pixels and distinguish accepted `pixel_ratios` from a required rendition set +- Apply the seller's advertised VAST validation level, supply format-required accessibility detail, and distinguish buyer-declared provenance from independently verified evidence ## Prerequisite reading @@ -197,15 +198,17 @@ Vendors often offer multiple pricing options per creative — volume/commitment 10. **Preview modes** — Run `preview_creative` as `request_type: "single"`, then `"batch"` (submit 5 creatives in one call and measure the speedup), then `"variant"` against a prior `get_creative_delivery` result. Compare `output_format: "url"` vs `"html"` for rendering latency. 11. **Tracker-slot audit** — For each sandbox format, inspect the `assets` array and determine whether it supports third-party measurement. Explain why assigning a DoubleVerify pixel to a broadcast spot won't work, and which `billing_measurement` vendor would instead. 12. **Broadcast identifiers** — Build a broadcast manifest with distinct `industry_identifiers[]` for `:15` and `:30` cuts of the same spot. Verify the `creative-identifier-type` values and explain why each cut needs its own traffic identifier. +13. **Pixel density and rendition coverage** — Validate the canonical image reference vectors at 1x and 2x. Show that a 600×500 asset can satisfy a logical 300×250 slot at 2x without creating a new logical placement; trigger `pixel_ratio_ambiguous` on an ambiguous undeclared density; then satisfy `required_pixel_ratios: [1, 2]` with exactly one valid rendition at each required density. Explain why accepted densities do not require every density unless `required_pixel_ratios` says so. +14. **VAST, accessibility, and provenance** — Read `creative_specs.vast_validation` before syncing one structurally valid but document-invalid VAST asset and predict whether the seller may return `VAST_PARSE_FAILED`. For a format with `accessibility.requires_accessible_assets: true`, submit the applicable alt text, captions, or transcript and interpret a structured accessibility violation. Finally submit synthetic creative metadata that declares `synthetic_depiction` and provenance evidence; distinguish a declaration from an independent verifier result and do not treat unverified metadata as an attestation. ## Assessment | Dimension | Weight | What Addie evaluates | |-----------|--------|---------------------| | Interaction models | 20% | Correctly identifies and works with all three creative agent types | -| Cross-platform adaptation | 15% | Adapts creatives across display, video, native, preview, measurement, and broadcast workflows | +| Cross-platform adaptation | 15% | Adapts creatives across display, video, native, preview, measurement, broadcast, and density-aware rendition workflows | | Canonical formats | 10% | Reads `format_options[]`, selects `format_kind` and `format_option_id`, explains single-option, multi-option, and `v1_format_ref[]` fan-out cardinality, maps `asset_source` values to buyer-provided, agent-synthesized, seller-rendered, seller-designed, and publisher-hosted workflows, and explains canonical-first/product-second validation order | -| Compliance | 25% | Configures disclosures, provenance, and regulatory requirements | +| Compliance | 25% | Applies advertised VAST validation, configures accessibility and disclosures, and distinguishes provenance declarations from verified evidence | | Pricing and accounts | 15% | Understands rate cards, reads pricing from `list_creatives` and `list_transformers`, interprets build costs, closes the `report_usage` loop | | Analytical skill | 15% | Interprets creative feature evaluation and delivery results | diff --git a/docs/learning/specialist/media-buy.mdx b/docs/learning/specialist/media-buy.mdx index 55ef3cbe34..41dcf76b3b 100644 --- a/docs/learning/specialist/media-buy.mdx +++ b/docs/learning/specialist/media-buy.mdx @@ -48,6 +48,8 @@ See the [Compliance Catalog](/docs/building/verification/compliance-catalog) for - Interpret seller indicators as current relationship-scoped state: explain why creative fatigue belongs to one package–creative assignment, why the same creative may be fatigued on one seller but not another, and why provider methodology belongs in `ext` - Handle pricing negotiation, budget allocation, and multi-agent orchestration - Use refinement and package requests for complex buying scenarios +- Use `offer_filters.availability_horizon` only when the seller advertises flexible-window availability, interpret the returned time windows and `incomplete[]` gaps, and distinguish planning availability from a hold +- Use `criteria.outcome_target` only when advertised, verify `total_budget_guidance` and matching forecast metrics, and explain why reverse-forecast guidance is not a delivery guarantee - Monitor and optimize campaign delivery using protocol tools - Explain the measurement-agent loop: make the orchestrator a buyer-controlled gateway for the fixed `get_media_buy_delivery` and `provide_performance_feedback` tasks, authenticate provider output, and fan normalized assertions out to sellers without granting providers seller access - Reason about failure modes, conflict resolution, and edge cases @@ -214,9 +216,9 @@ See the [Quickstart](/docs/quickstart) for a walkthrough of your first call. During the module, Addie will guide you through hands-on exercises: -1. **Product discovery and evaluation** — Query multiple sandbox agents, compare products, evaluate pricing +1. **Product discovery and evaluation** — Query multiple sandbox agents, compare products, evaluate pricing, and separate concrete `targeting_overlay` values from future-selection `required_overlay_support`. Verify unsupported requirements filter products, compare the result with a supported requirement, review any sparse `targeting_resolution.modifications`, and prove acceptance by reading the effective package targeting back after purchase. 2. **Pricing option selection** — From the same product's `pricing_options[]` array, select a CPM option and a CPP option. Explain what each option's `parameters` mean and why the minimum spend differs. -3. **Proposal and forecasting** — Request proposals, analyze delivery forecasts (spend curves and availability) +3. **Proposal and forecasting** — Request proposals and analyze delivery forecasts. On a seller advertising both planning capabilities, send a flexible `offer_filters.availability_horizon` and confirm the returned half-open windows cover the requested horizon or disclose gaps through `incomplete[]`. Then send a supported `criteria.outcome_target`, verify `total_budget_guidance` plus the matching forecast metric, and explain why neither forecast nor guidance creates a delivery guarantee before proposal finalization. 4. **Terms negotiation** — On a guaranteed product, propose `measurement_terms` with a different vendor than the seller's default, and `performance_standards` with a tighter viewability threshold. Observe seller acceptance, adjustment, or `TERMS_REJECTED`. Recover by aligning to the seller's supported vendors. 5. **Campaign commitment and optimization** — Commit a direct offer with `buy_products`, assign creatives through `sync_creatives`, monitor through `get_media_buys`, and apply an in-envelope change with `control_media_buy`. Submit compact performance feedback with a named baseline, interpreting `accepted`, `applied`, and `not_applied` without overstating causality. Explain how the 3.x `create_media_buy` and `update_media_buy` facades map to the canonical operations, and verify that the update facade requires `account`. 6. **Proposal negotiation and lifecycle walkthrough** — Following the [proposal negotiation implementation guide](/docs/media-buy/product-discovery/proposal-negotiation), act as Sam at Pinnacle Agency against StreamHaus. Use `request_proposals`, inspect `proposal_refinement.supported_dimensions`, and construct a refinement the seller can actually evaluate. Try a USD budget ceiling, a product include/omit decision, a targeting change, and multiple alternatives only when advertised; interpret `partial` and `unable` as structured counteroffers rather than generic failures. On an ask-only seller, observe the pre-mutation `UNSUPPORTED_FEATURE`, remove or translate the typed field, and retry. Finalize the chosen immutable proposal, call `accept_proposal`, and trace the resulting buy through `pending_creatives` → `sync_creatives` → `pending_start` → `active`. diff --git a/docs/learning/specialist/security.mdx b/docs/learning/specialist/security.mdx index 574bd1d05b..db02d8f3e2 100644 --- a/docs/learning/specialist/security.mdx +++ b/docs/learning/specialist/security.mdx @@ -33,8 +33,10 @@ The following `specialisms` fall under the security domain. Each has its own com - Explain the agentic advertising threat model: credential theft, replay attacks, cross-tenant data leakage, SSRF on outbound fetches, spoofed agent identity, unauthorized governance token use, and audit log tampering - Walk through AdCP's five-layer defense model — identity, isolation, idempotency, signed governance, auditability — and name the specific attack each layer closes - Mint an idempotency key and produce the three observable outcomes against a sandbox — successful first call, idempotent replay (`replayed: true`, unchanged resource), and conflict on payload change — and reason about the fourth: TTL expiry. The replay window is 24h, while mutated sandbox resources are retained for at least 25h after their last persisted mutation so they remain available throughout that window. Read-only calls do not extend resource retention. Expiry is therefore reasoned about rather than observed during the lab. Explain what a missing key removes from the seller's safety guarantees. +- For a 3.2 request-signing endpoint, require `content-digest` coverage on every signed request with a body, serialize request `Signature` and `Content-Digest` binary values as RFC 8941 padded standard Base64, and reject a legacy Base64URL request token without falling back to a second parser - Obtain and decode a signed governance token, then use the sandbox verifier to watch the checklist accept a valid token and reject a tampered one (signature), a misaddressed one (`aud` / confused-deputy), and one signed under a revoked key — explaining the attack each failing step closes and why revocation is checked before expiry - Specify the 6-point SSRF check on an outbound fetch (HTTPS-only enforcement, reserved-IP deny list including cloud metadata endpoints, IP-pin validation, redirect suppression, size and timeout caps, and suppressed error detail) and demonstrate the agent refusing a metadata-IP webhook target +- Distinguish buyer-supplied `operation_id` correlation from sender-generated webhook `idempotency_key` deduplication: retries of the same byte-identical logical fire reuse the dedup key, while a changed payload or later logical observation receives a fresh key - Design an operational runbook covering credential compromise, webhook secret rotation, governance key revocation, and cross-party incident communication - Given an incident description, identify which defense layer failed and what specific control to harden @@ -81,19 +83,20 @@ See the [Quickstart](/docs/quickstart) for a walkthrough of your first call. 1. **Threat model walkthrough** — Map each threat (credential theft, replay, cross-tenant leakage, SSRF, spoofed identity, unauthorized governance, audit tampering) to the specific AdCP control that closes it. Explain why no single layer is sufficient alone. 2. **Idempotency lifecycle** — Using one idempotency key on a mutating call (e.g. `create_property_list`): (a) first call — observe success; (b) identical replay — observe `replayed: true` with an unchanged resource id, confirming no new side effect; (c) same key, different payload — observe the `IDEMPOTENCY_CONFLICT` error. Then reason about the fourth outcome — expiry after the 24h replay window lapses. The sandbox retains mutated resources for at least 25h after their last persisted mutation, without extending that retention on read-only calls, so the original resource remains available throughout the replay window. Explain what a missing idempotency key means for the seller's at-most-once safety guarantee. 3. **Governance token verification** — Obtain a signed governance token from the sandbox governance agent (`sync_plans`, then an intent-phase `check_governance`) and decode its header (`alg`, `typ`, `kid`) and claims (`aud`, `sub`, `phase`, `jti`, `exp`). Then run the sandbox verifier (`comply_test_controller` scenario `verify_governance_token`) and watch the JWS checklist accept and reject tokens: a valid token passes every step; a tampered claim fails at the signature step (`governance_token_invalid`); a token bound to a different seller fails the `aud` byte-match (`governance_token_not_applicable` — confused deputy, via `mode: wrong_aud_demo`); and a token signed under a revoked key fails the revocation step (`governance_token_revoked`, via `mode: revoked_demo`). For each, explain the attack the failing step closes — and note that revocation is checked *before* expiry, so a revoked token is rejected even if it has also lapsed. (`jti` seen-before dedup — distinct from `aud` — is the step that stops replay of the same token.) -4. **SSRF defense and endpoint activation** — Submit `https://169.254.169.254/latest/meta-data/` in an account `notification_config` and observe the synchronous `VALIDATION_ERROR` on `notification_configs[].url`. Then register a public receiver you control with `active: false`, configure it to verify the RFC 9421 `webhook.challenge` and echo the single-use nonce, and reactivate the same subscriber with `active: true`. Inspect the signed body bindings (`account_id`, `subscriber_id`, `delivery_auth`, and `event_types`) plus the covered `@target-uri` and signature expiry. Finally pause the subscriber, re-point the exercise hostname to a reserved address, and attempt reactivation: the agent re-runs the full public-target check immediately before enabling delivery and refuses the re-bound target. Specify the 6-point SSRF check and explain what each point closes — the reserved-IP/metadata deny list, IP-pin-at-connect, redirect suppression, bounded responses/timeouts, and suppressed error detail. +4. **SSRF defense, endpoint activation, and delivery identity** — Submit `https://169.254.169.254/latest/meta-data/` in an account `notification_config` and observe the synchronous `VALIDATION_ERROR` on `notification_configs[].url`. Then register a public receiver you control with `active: false`, configure it to verify the RFC 9421 `webhook.challenge` and echo the single-use nonce, and reactivate the same subscriber with `active: true`. Inspect the signed body bindings (`account_id`, `subscriber_id`, `delivery_auth`, and `event_types`) plus the covered `@target-uri` and signature expiry. Finally pause the subscriber, re-point the exercise hostname to a reserved address, and attempt reactivation: the agent re-runs the full public-target check immediately before enabling delivery and refuses the re-bound target. Specify the 6-point SSRF check and explain what each point closes — the reserved-IP/metadata deny list, IP-pin-at-connect, redirect suppression, bounded responses/timeouts, and suppressed error detail. Capture two retry attempts of one logical fire and verify they echo the same `operation_id`, carry the same payload `idempotency_key`, and differ only in permitted transport metadata; then trigger a later changed observation and verify it receives a fresh payload key. 5. **Principal isolation** — Scope a read to a different account than the one that created a resource and interpret the result correctly: distinguish an account-scoped *not-found* from an authorization *denial*. Explain the separation model — account-scoped access — and what would break (cross-tenant read/write on a leaked token) if account-scoped tokens were not enforced. 6. **Incident runbook design** — Given a credential compromise scenario (API key leaked in a public repo), design the response: which keys to rotate and in what order, how to notify counterparties, what audit events to review, and how to verify the compromise window. 7. **Defense layer diagnosis** — Given three incident descriptions (replay attack succeeded, cross-tenant data returned, governance token accepted after key revocation), identify which layer failed in each case and what specific control to harden. +8. **3.2 signed-body integrity** — Read `request_signing.covers_content_digest` from a 3.2 capability response. Verify a signed body succeeds only when `content-digest` is covered and matches the exact transmitted bytes; show that omitting coverage yields `request_signature_components_incomplete` and changing the body yields `request_signature_digest_mismatch`. Inspect the `Signature` and `Content-Digest` structured fields to confirm padded standard Base64, then show that an unpadded Base64URL request token is rejected as malformed rather than retried through the legacy parser. Explain why the separately routed webhook profile keeps its legacy encoding during 3.x. ## Assessment | Dimension | Weight | What Addie evaluates | |-----------|--------|---------------------| | Threat model fluency | 20% | Can you name an attack and the specific layer that closes it? | -| Hands-on idempotency | 20% | Can you produce the observable idempotency outcomes (success, replay, conflict) on demand and reason about expiry and a missing key? | +| Replay and request integrity | 20% | Can you produce the idempotency outcomes and verify 3.2 signed-body coverage, digest binding, and strict request-profile binary parsing? | | Governance verification | 25% | Can you walk the 15-step checklist and explain what each step prevents? | -| SSRF discipline | 15% | Can you specify the 6-point check and demonstrate the agent refusing a metadata-IP webhook target? | +| SSRF and webhook discipline | 15% | Can you specify the 6-point check, demonstrate metadata-IP refusal, and keep correlation separate from delivery dedup identity? | | Operational design | 20% | Can you design a runbook for credential compromise, including rotation order and cross-party communication? | Passing threshold: 70%. diff --git a/docs/learning/supplements/buyer-briefs-and-get-products.mdx b/docs/learning/supplements/buyer-briefs-and-get-products.mdx index 354b7623ec..db9e66b671 100644 --- a/docs/learning/supplements/buyer-briefs-and-get-products.mdx +++ b/docs/learning/supplements/buyer-briefs-and-get-products.mdx @@ -1,21 +1,21 @@ --- -title: "Buyer briefs and get_products request shape" +title: "Buyer briefs and discovery request shape" sidebarTitle: "Buyer briefs" -description: "How buyer agents decompose campaign intent into get_products briefs, product filters, targeting overlays, future targeting support, and refinements." -"og:title": "AdCP - Buyer briefs and get_products request shape" +description: "How buyer agents decompose campaign intent into proposal briefs, offer filters, targeting overlays, future targeting support, and refinements." +"og:title": "AdCP - Buyer briefs and discovery request shape" --- -# Buyer briefs and get_products request shape +# Buyer briefs and discovery request shape -This supplement prepares buyer-side implementers to turn a human campaign request into a precise `get_products` call. +This supplement prepares buyer-side implementers to turn a human campaign request into precise `list_products` and `request_proposals` calls. Existing 3.x callers can apply the same decomposition through the `get_products` compatibility facade. The goal is not to make the brief verbose. The goal is to put intent in the brief and hard constraints in typed fields so the seller can curate inventory without guessing which parts are negotiable. -This module teaches the AdCP 3.2 targeting-aware discovery contract. Use it -against sellers and SDKs that declare 3.2 support. The public training-agent lab -will enable these calls after the 3.2 beta SDK release; implementation is -tracked in [issue #6199](https://github.com/adcontextprotocol/adcp/issues/6199). +This module teaches the AdCP 3.2 targeting-aware discovery contract. The public +training seller supports the exact `3.2-beta.5` wire bundle with +`@adcp/sdk@14.0.0-beta.7`. Verify those advertised capabilities before the lab; +unknown-field acceptance by an older seller is not evidence of support. ## Mental model @@ -23,12 +23,12 @@ tracked in [issue #6199](https://github.com/adcontextprotocol/adcp/issues/6199). | Input | Use it for | Avoid putting here | |---|---|---| | `brief` | Buyer intent, semantic audience language, context, tone, business goal, and requirements without a typed representation | Constraints that already have structured fields | -| `filters` | Hard offer filters—metadata, dates, budget, availability, commercial fit, and reporting capabilities—that exclude non-matching products | Delivery targeting or soft preferences | -| `targeting_overlay` | Exact delivery constraints known now; availability, pricing, and forecasts must account for them | Product characteristics or values the buyer has not chosen yet | -| `required_overlay_support` | Targeting dimensions whose values will be selected independently on packages later | Current targeting values or a request for one product per value | +| `criteria.offer_filters` | Hard offer filters—metadata, dates, budget, availability, commercial fit, and reporting capabilities—that exclude non-matching products | Delivery targeting or soft preferences | +| `criteria.targeting_overlay` | Exact delivery constraints known now; availability, pricing, and forecasts must account for them | Product characteristics or values the buyer has not chosen yet | +| `criteria.required_overlay_support` | Targeting dimensions whose values will be selected independently on packages later | Current targeting values or a request for one product per value | | `brand` | The buyer brand identity the seller uses for eligibility, safety, and fit | A second copy of the campaign brief | | `catalog` | Commerce or product-set context when the campaign is catalog-driven | General brand positioning | -| `refine` | Specific changes to a prior discovery response | New unrelated discovery goals | +| `refine_proposals` changes | Specific changes to a prior proposal snapshot | New unrelated discovery goals | ## What goes in the brief @@ -44,8 +44,9 @@ Example: ```json { + "$schema": "https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json", + "adcp_version": "3.2-beta.5", "idempotency_key": "550e8400-e29b-41d4-a716-446655442008", - "buying_mode": "brief", "brief": "Launch Nova Running's spring trail shoe line with outdoor enthusiasts. Favor trusted adventure and fitness contexts, avoid discount-led positioning, and prioritize packages that can support a brand-lift readout.", "brand": { "domain": "novarunning.example" @@ -53,12 +54,13 @@ Example: } ``` -## What goes in filters +## What goes in offer filters Use filters when a product that fails the condition should not come back. -That exclusion rule is identical in `brief`, `wholesale`, and `refine`; the -mode changes how the seller curates and versions the result, not whether a -valid filter applies. +That exclusion rule is identical across `list_products`, `request_proposals`, +and `refine_proposals`; the operation changes whether the seller lists, +curates, or revises offers, not whether a valid filter applies. +The brief, wholesale/catalog listing, and refine flows therefore use the same hard-filter semantics. Good filter candidates: @@ -73,21 +75,24 @@ Example: ```json { + "$schema": "https://adcontextprotocol.org/schemas/v3/media-buy/request-proposals-request.json", "idempotency_key": "550e8400-e29b-41d4-a716-446655442009", - "buying_mode": "brief", + "adcp_version": "3.2-beta.5", "brief": "Launch Nova Running's spring trail shoe line with outdoor enthusiasts. Favor trusted adventure and fitness contexts.", "brand": { "domain": "novarunning.example" }, - "filters": { - "channels": ["ctv", "display"], - "pricing_currencies": ["USD"], - "required_metrics": ["impressions", "clicks"] + "criteria": { + "offer_filters": { + "channels": ["ctv", "display"], + "pricing_currencies": ["USD"], + "required_metrics": ["impressions", "clicks"] + } } } ``` -If the buyer says "ideally CTV, but display is okay," keep that preference in the brief. If they say "CTV only," use `filters.channels`. +If the buyer says "ideally CTV, but display is okay," keep that preference in the brief. If they say "CTV only," use `criteria.offer_filters.channels`. ## What goes in targeting @@ -95,10 +100,12 @@ Use `targeting_overlay` for exact delivery constraints the buyer knows now: ```json { - "targeting_overlay": { - "geo_countries": ["US"], - "demographics": { - "age": { "min": 25, "max": 44, "include_unknown": false } + "criteria": { + "targeting_overlay": { + "geo_countries": ["US"], + "demographics": { + "age": { "min": 25, "max": 44, "include_unknown": false } + } } } } @@ -109,9 +116,11 @@ will be chosen later: ```json { - "required_overlay_support": { - "geo_metros": { "systems": ["nielsen_dma"] }, - "placement_selection": true + "criteria": { + "required_overlay_support": { + "geo_metros": { "systems": ["nielsen_dma"] }, + "placement_selection": true + } } } ``` @@ -129,21 +138,21 @@ accepts the disclosed differences. ## Brief vs. refine -Use `buying_mode: "refine"` when the buyer is reacting to a previous discovery response. A refine request should point at what changed: remove a product, adjust budget, request more premium placements, narrow geography, or ask for alternatives. +Use `refine_proposals` when the buyer is reacting to a previous proposal. A refinement should point at what changed: adjust budget, include or omit a product, request more premium placements, narrow geography, or ask for alternatives. Existing callers can express the equivalent flow through `get_products` with `buying_mode: "refine"`. -Do not send a totally new campaign in `refine`; start a new `brief` request instead. +Do not send a totally new campaign through `refine_proposals`; start a new `request_proposals` call instead. ## Implementation checklist -- Read `get_adcp_capabilities.adcp.supported_versions`, pin a compatible 3.2 release in `adcp_version`, and validate the echoed served release before using targeting-aware discovery. +- Read `get_adcp_capabilities.adcp.supported_versions` and `media_buy.lifecycle_tools`, pin `adcp_version: "3.2-beta.5"` for this lab, and validate the echoed served release before using targeting-aware discovery. - If release precision is absent, major-only, or 3.1-or-earlier, omit the 3.2 fields and fall back to retained legacy targeting filters or explicit brief prose; unknown fields may otherwise be silently ignored. - Normalize the human request into intent, hard constraints, and follow-up changes before calling the seller. - Preserve the buyer's business language in `brief`; do not collapse it into only keywords. -- Put product characteristics in `filters` and exact delivery constraints in `targeting_overlay`. -- Use `required_overlay_support` only for dimensions whose values will be supplied later. +- Put product characteristics in `criteria.offer_filters` and exact delivery constraints in `criteria.targeting_overlay` on compact tasks. Map those to the compatibility facade's top-level `filters` and `targeting_overlay` only for an existing `get_products` caller. +- Use `criteria.required_overlay_support` only for dimensions whose values will be supplied later. - Inspect every returned `targeting_resolution`; silence confirms an unchanged structured overlay, not prose interpretation. -- Keep `brief` out of `wholesale` mode. -- Persist the request tuple with the response so later `refine` calls and `wholesale_feed_version` comparisons are scoped correctly. +- Keep `brief` on `request_proposals`; `list_products` is structured discovery and has no brief. +- Persist the request tuple with the response so later refinements and feed-version comparisons are scoped correctly. ## Practice prompt @@ -154,7 +163,7 @@ A buyer says: Expected decomposition: - Brief: family dinner kit launch, parent audience, suitable contexts, six-week launch. -- Filters: video-capable channel/format constraints, USD pricing, completion-rate reporting. +- Offer filters: video-capable channel/format constraints, USD pricing, completion-rate reporting. - Targeting overlay: US and ages 25–44. - Required overlay support: Nielsen DMA selection later. - Brand: Acme Meals domain or BrandRef. diff --git a/docs/learning/tracks/buyer.mdx b/docs/learning/tracks/buyer.mdx index 67ecc45bdc..50f6c95dc2 100644 --- a/docs/learning/tracks/buyer.mdx +++ b/docs/learning/tracks/buyer.mdx @@ -74,7 +74,7 @@ How buyer agents orchestrate across multiple sales agents simultaneously: discov - **Order lifecycle** — `pending_creatives` → `pending_start` → `active`; check `valid_actions` from `get_media_buys` before acting. S1 covers the full state machine and recovery - **Version negotiation** — declare `adcp_major_version` on every request; handle `VERSION_UNSUPPORTED` by selecting a compatible seller or downgrading the payload - **Pricing selection** — products return `pricing_options[]`; buyer selects one via `pricing_option_id` in `create_media_buy` -- **Structured-first discovery** — use `filters` to decide which offers may be returned, `targeting_overlay` to constrain delivery, and `required_overlay_support` for dimensions selected on packages later +- **Structured-first discovery** — on the compact 3.2 tasks, use `criteria.offer_filters` to decide which offers may be returned, `criteria.targeting_overlay` to constrain delivery, and `criteria.required_overlay_support` for dimensions selected on packages later. The 3.x `get_products` compatibility facade keeps the equivalent top-level fields - **Two optional discovery surfaces** — the 3.2 wholesale feed (`list_products`) is the self-serve, lower-resolution surface; a brief (`request_proposals`, or `get_products` with a brief) is the higher-resolution one. Query both where the seller advertises them; a thin or absent feed is not a capability assessment - **Resolution acceptance** — no resolution means an exact structured overlay; review `modifications` and brief-derived targeting before selecting an opaque configured `product_id` - **Negotiated accountability** — for guaranteed buys, propose `measurement_terms` / `performance_standards`; seller accepts, adjusts, or returns `TERMS_REJECTED`. S1 covers the recovery patterns @@ -256,11 +256,11 @@ New to running tool calls and reading JSON in a terminal? Do [A2b: Testing your ### What you'll build -The targeting-aware discovery portion is a 3.2 assessment objective. The public -training seller will enable it after the 3.2 beta SDK release; follow -[issue #6199](https://github.com/adcontextprotocol/adcp/issues/6199). Until -then, validate this portion with schema fixtures rather than assuming the live -training runtime enforces unknown fields. +The public training seller implements targeting-aware discovery on the exact +AdCP `3.2-beta.5` wire bundle. Pin `@adcp/sdk@14.0.0-beta.7`, verify the +seller's advertised version and lifecycle tools, and retain the request, +response, and package readback as assessment evidence. Do not treat an +uncapable seller silently ignoring unknown fields as validation. - Account setup with `sync_accounts` @@ -281,7 +281,7 @@ To see the seller responses your agent must handle, you can inspect the test sel ```bash # Inspects the test SELLER's get_products response — the shape your buyer agent must parse. -npx @adcp/sdk@latest test-mcp get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442065","buying_mode":"brief","brief":"your campaign brief"}' +npx @adcp/sdk@14.0.0-beta.7 test-mcp get_products '{"adcp_version":"3.2-beta.5","idempotency_key":"550e8400-e29b-41d4-a716-446655442065","buying_mode":"brief","brief":"your campaign brief"}' ``` See [Build a caller](/docs/building/by-layer/L4/build-a-caller) for client setup and [Validate Your Agent](/docs/building/verification/validate-your-agent) for the storyboard workflow. diff --git a/docs/media-buy/index.mdx b/docs/media-buy/index.mdx index 9728f9b6c3..e2c3e3b84a 100644 --- a/docs/media-buy/index.mdx +++ b/docs/media-buy/index.mdx @@ -46,7 +46,7 @@ delivery constraints use structured criteria: const proposals = await Promise.all( sellers.map((seller) => seller.requestProposals({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-proposals-001', account: { account_id: 'account_123' }, brand: { domain: 'acme-outdoor.example' }, @@ -108,7 +108,7 @@ draft with typed constraints: ```javascript const refined = await streamHaus.refineProposals({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-refine-001', refinements: [ { @@ -154,7 +154,7 @@ creates a committed successor and an inventory hold: ```javascript const finalized = await streamHaus.refineProposals({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-finalize-001', refinements: [ { @@ -170,7 +170,7 @@ accepts that exact snapshot: ```javascript const buy = await streamHaus.acceptProposal({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-accept-001', account: { account_id: 'account_123' }, proposal_id: 'proposal_streamhaus_003', @@ -200,7 +200,7 @@ required format has coverage, then moves to `pending_start` or `active`. ```javascript await streamHaus.syncCreatives({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-creatives-001', account: { account_id: 'account_123' }, creatives: [ @@ -255,7 +255,7 @@ a package or adjust a budget inside the accepted commercial envelope, he uses ```javascript const controlled = await streamHaus.controlMediaBuy({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-control-001', account: { account_id: 'account_123' }, media_buy_id: buy.media_buy_id, diff --git a/docs/media-buy/product-discovery/index.mdx b/docs/media-buy/product-discovery/index.mdx index ac0d87df88..6954ff652b 100644 --- a/docs/media-buy/product-discovery/index.mdx +++ b/docs/media-buy/product-discovery/index.mdx @@ -103,7 +103,7 @@ without asking the seller to author a media plan: ```javascript const result = await seller.listProducts({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: { account_id: 'account_123' }, criteria: { offer_filters: { @@ -121,6 +121,23 @@ The response contains products only—never proposals—and carries an opaque to `buy_products` so the seller can reject stale offers instead of silently applying changed terms. +When discovery includes concrete `targeting_overlay` values, inspect each +returned product before purchase: + +- No product-level `targeting_resolution` means the product accepts the + requested structured overlay exactly. +- `targeting_resolution.modifications` is an ordered, sparse disclosure of the + alternative the seller can execute. Selecting that `product_id` accepts the + modified result. +- A request-specific configured product carries `is_custom: true` and + `expires_at`. Purchase that exact ID before expiry; do not substitute the + baseline product ID or send a conflicting overlay at commitment. + +After `buy_products`, verify that the package readback preserves the accepted +targeting. If the buyer requested only `required_overlay_support`, rediscover +with concrete values before purchase when it needs a value-specific price or +forecast; support alone does not promise inventory for every later value. + A catalog mirror uses the same read as its bootstrap and repair surface. Store `feed_version`, `pricing_version`, and `cache_scope`; subscribe to `product.*` and `wholesale_feed.bulk_change` through account notification configuration; @@ -133,12 +150,12 @@ for the complete mirror contract. ## Seller-planned proposals Use `request_proposals` when the seller should translate a campaign brief into -one or more priced plans. Sam Adeyemi at Pinnacle Agency can send the same +one or more priced plans. Sam at Pinnacle Agency can send the same structured criteria to StreamHaus while keeping strategy in prose: ```javascript const response = await seller.requestProposals({ - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'acme-q2-proposals-001', account: { account_id: 'account_123' }, brand: { domain: 'acme-outdoor.example' }, @@ -166,6 +183,11 @@ The seller returns immutable draft proposals. Each revision creates a new 4. Call `accept_proposal` with both the committed `proposal_id` and `terms_digest` before `expires_at`. +The criteria and any configured product targeting are part of the proposal's +commercial terms. Before finalizing, verify the selected product IDs, effective +targeting or disclosed modifications, price, forecast assumptions, and expiry; +after acceptance, confirm the same targeting on package readback. + Commercial changes after acceptance use the same proposal lineage to create an amendment or negotiated cancellation. Operational changes inside the accepted envelope use [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy). diff --git a/docs/media-buy/product-discovery/proposal-negotiation.mdx b/docs/media-buy/product-discovery/proposal-negotiation.mdx index 0d2fb8431e..6b61b846ee 100644 --- a/docs/media-buy/product-discovery/proposal-negotiation.mdx +++ b/docs/media-buy/product-discovery/proposal-negotiation.mdx @@ -488,7 +488,7 @@ Record the authenticated buyer, source IDs, successor IDs, idempotency fingerpri The canonical compliance scenario is `media_buy_seller/typed_proposal_negotiation`. It exercises capability gating, satisfied and unsatisfied constraints, alternatives, immutable lineage, finalize atomicity, exact replay, acceptance, amendment, cancellation, double-finalize rejection, and multi-source ordering. -The deterministic training profiles expose the exact `3.2-beta.4` proposal-negotiation wire contract. The ordinary `/sales/mcp` route remains the backward-compatible ask-only seller. Use the [public test token](/docs/quickstart#setup) with `https://test-agent.adcontextprotocol.org` and one of these capability-distinct routes: +The deterministic training profiles expose the exact `3.2-beta.5` proposal-negotiation wire contract. The ordinary `/sales/mcp` route remains the backward-compatible ask-only seller. Use the [public test token](/docs/quickstart#setup) with `https://test-agent.adcontextprotocol.org` and one of these capability-distinct routes: | Profile | Route | Deterministic behavior | | --- | --- | --- | @@ -496,7 +496,7 @@ The deterministic training profiles expose the exact `3.2-beta.4` proposal-negot | Constrained seller | `/sales/profiles/constrained-seller/mcp` | USD floors, product conflicts, and at most two available alternatives | | Finalization failure | `/sales/profiles/finalization-failure/mcp` | `hold_unavailable` plus `batch_aborted` siblings with no committed successors | -Pin the beta exactly on every call. A stable `3.2` selector intentionally negotiates to the seller's stable compatibility response instead of silently opting into beta behavior. The bundled `ADCPMultiAgentClient.simple()` proposal path cannot yet target a different exact beta ordinal. Until [adcp-client#2609](https://github.com/adcontextprotocol/adcp-client/issues/2609) ships, construct a lower-level client with `wireAdcpVersion: "3.2-beta.4"` or send raw calls as below. +Pin the beta exactly on every call. A stable `3.2` selector intentionally negotiates to the seller's stable compatibility response instead of silently opting into beta behavior. SDK 14 beta.7 accepts `wireAdcpVersion: "3.2-beta.5"` on its single- and multi-agent client configuration; raw calls remain useful for inspecting the exact envelope as below. ### Run the constrained profile @@ -540,14 +540,14 @@ async function callTool(id, name, args) { } const version = { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, }; const nonce = crypto.randomUUID(); const capabilities = await callTool(1, 'get_adcp_capabilities', version); if ( - capabilities.adcp_version !== '3.2-beta.4' + capabilities.adcp_version !== '3.2-beta.5' || capabilities.media_buy?.supports_proposals !== true || !capabilities.media_buy?.lifecycle_tools?.includes('refine_proposals') ) { diff --git a/docs/reference/3-2-beta.mdx b/docs/reference/3-2-beta.mdx index a38b4dfecc..42cb0e485c 100644 --- a/docs/reference/3-2-beta.mdx +++ b/docs/reference/3-2-beta.mdx @@ -15,7 +15,7 @@ before GA. The 3.2 beta cycle started with a protocol-first checkpoint. The current -TypeScript SDK beta now consumes the exact signed beta.4 bundle: +TypeScript and Python SDK betas now consume the exact signed beta.5 bundle: | Checkpoint | Purpose | Who should test | |---|---|---| @@ -23,60 +23,65 @@ TypeScript SDK beta now consumes the exact signed beta.4 bundle: | `3.2.0-beta.1` | First integration-correction checkpoint | Pinned adopters comparing beta.0 and beta.1 behavior | | `3.2.0-beta.2` | Second integration-correction checkpoint | Pinned adopters comparing beta.1 and beta.2 behavior | | `3.2.0-beta.3` | Third integration-correction checkpoint | Pinned adopters comparing beta.2 and beta.3 behavior | -| `3.2.0-beta.4` | Current protocol checkpoint | Buyer and seller teams running end-to-end integrations | -| SDK wave | `@adcp/sdk@14.0.0-beta.5` embeds `3.2.0-beta.4`; Python and Go remain pending | TypeScript adopters, Python and Go maintainers, and early integration teams | +| `3.2.0-beta.4` | Fourth integration checkpoint | Pinned adopters comparing beta.3 and beta.4 behavior | +| `3.2.0-beta.5` | Current protocol checkpoint | Buyer and seller teams running end-to-end integrations | +| SDK wave | `@adcp/sdk@14.0.0-beta.7` and Python `adcp==8.0.0b7` embed `3.2.0-beta.5`; Go remains pending | TypeScript and Python adopters, Go maintainers, and early integration teams | Earlier betas remain useful as pinned implementation checkpoints. For new -TypeScript testing, pair SDK beta.5 with protocol beta.4. Do not combine a newer -wire pin with an SDK generated from an older bundle unless the SDK maintainer -explicitly documents forward compatibility. +TypeScript or Python testing, pair the packages above with protocol beta.5. Do +not combine a newer wire pin with an SDK generated from an older bundle unless +the SDK maintainer explicitly documents forward compatibility. ## Know which version string to use The artifact version, wire pin, and documentation selector are related but not interchangeable: -| Surface | beta.0 | beta.1 | beta.2 | beta.3 | beta.4 | -|---|---|---|---|---|---| -| Protocol artifact | `3.2.0-beta.0` | `3.2.0-beta.1` | `3.2.0-beta.2` | `3.2.0-beta.3` | `3.2.0-beta.4` | -| Git tag | `v3.2.0-beta.0` | `v3.2.0-beta.1` | `v3.2.0-beta.2` | `v3.2.0-beta.3` | `v3.2.0-beta.4` | -| `adcp_version` wire pin | `"3.2-beta.0"` | `"3.2-beta.1"` | `"3.2-beta.2"` | `"3.2-beta.3"` | `"3.2-beta.4"` | -| Documentation selector | `3.2-beta` | `3.2-beta` | `3.2-beta` | `3.2-beta` | `3.2-beta` | +| Surface | beta.0 | beta.1 | beta.2 | beta.3 | beta.4 | beta.5 | +|---|---|---|---|---|---|---| +| Protocol artifact | `3.2.0-beta.0` | `3.2.0-beta.1` | `3.2.0-beta.2` | `3.2.0-beta.3` | `3.2.0-beta.4` | `3.2.0-beta.5` | +| Git tag | `v3.2.0-beta.0` | `v3.2.0-beta.1` | `v3.2.0-beta.2` | `v3.2.0-beta.3` | `v3.2.0-beta.4` | `v3.2.0-beta.5` | +| `adcp_version` wire pin | `"3.2-beta.0"` | `"3.2-beta.1"` | `"3.2-beta.2"` | `"3.2-beta.3"` | `"3.2-beta.4"` | `"3.2-beta.5"` | +| Documentation selector | `3.2-beta` | `3.2-beta` | `3.2-beta` | `3.2-beta` | `3.2-beta` | `3.2-beta` | Only send a prerelease wire pin to a peer that advertises that exact value in `get_adcp_capabilities.adcp.supported_versions`. Prerelease pins are exact: a -buyer pinned to `"3.2-beta.4"` must not silently negotiate to another beta, and +buyer pinned to `"3.2-beta.5"` must not silently negotiate to another beta, and a stable `"3.2"` pin must not silently negotiate to a beta. -## Use the TypeScript SDK beta +## Use matched SDK betas -The TypeScript SDK is the first package with exact beta.4 support. The npm +The TypeScript SDK has exact beta.5 support. The npm `latest` tag remains on SDK 13, so opt into the preview explicitly: ```bash -npm install @adcp/sdk@14.0.0-beta.5 +npm install @adcp/sdk@14.0.0-beta.7 ``` SDK 14 includes typed caller and server support for the compact media-buy -lifecycle, the beta.4 schemas and compliance assets, version-aware request +lifecycle, the beta.5 schemas and compliance assets, version-aware request signing, governance plan-adjustment reporting, and agent notification configuration. It retains 3.0 and 3.1 adaptation for mixed-version testing. See [Choose your SDK](/docs/building/by-layer/L4/choose-your-sdk) for the support matrix and install guidance. -## Use the beta.4 artifact directly +Python `adcp==8.0.0b7` carries the same schemas and generated types. Its +higher-level caller/server helpers remain role-dependent, so consult the SDK +matrix before treating schema parity as complete L1–L3 coverage. -Download the signed beta.4 protocol bundle rather +## Use the beta.5 artifact directly + +Download the signed beta.5 protocol bundle rather than copying schemas from `main` or from the moving `/schemas/latest/` path: ```bash -curl -fLO https://adcontextprotocol.org/protocol/3.2.0-beta.4.tgz -curl -fLO https://adcontextprotocol.org/protocol/3.2.0-beta.4.tgz.sha256 -shasum -a 256 -c 3.2.0-beta.4.tgz.sha256 -tar -xzf 3.2.0-beta.4.tgz +curl -fLO https://adcontextprotocol.org/protocol/3.2.0-beta.5.tgz +curl -fLO https://adcontextprotocol.org/protocol/3.2.0-beta.5.tgz.sha256 +shasum -a 256 -c 3.2.0-beta.5.tgz.sha256 +tar -xzf 3.2.0-beta.5.tgz ``` -The extracted `adcp-3.2.0-beta.4/` directory contains: +The extracted `adcp-3.2.0-beta.5/` directory contains: - `manifest.json` — release version, bundle contents, skills, and file count; - `schemas/` — source-shaped and bundled request/response schemas, including @@ -94,13 +99,13 @@ Useful direct-artifact tests are: 1. Generate or refresh types from the pinned bundled schemas. 2. Validate representative requests and responses for the roles you implement. 3. Compare your advertised tools with `schemas/manifest.json`. -4. Exercise raw MCP or A2A calls with `adcp_version: "3.2-beta.4"` against a +4. Exercise raw MCP or A2A calls with `adcp_version: "3.2-beta.5"` against a staging peer that advertises the same pin. 5. Run the compliance assets with a source-compatible runner if you maintain one. Do not weaken production authentication to accommodate a beta runner. -**No 3.2 verification badge is currently issued for beta.4.** Treat it as an +**No 3.2 verification badge is currently issued for beta.5.** Treat it as an implementation input, not a certification target. @@ -115,10 +120,10 @@ An SDK support claim must name all of the following: - install command and one tested validation command. The SDK compatibility matrix lives in [Choose your SDK](/docs/building/by-layer/L4/choose-your-sdk). -Only the TypeScript row currently names exact beta.4 support. Treat generated -3.2 types and validators in every other SDK as unavailable until its row names -an exact package and protocol checkpoint. Raw JSON calls may still be possible, -but they are not equivalent to SDK support. +The TypeScript and Python rows name exact beta.5 support, with different +higher-layer coverage. Treat generated 3.2 types and validators in every other +SDK as unavailable until its row names an exact package and protocol checkpoint. +Raw JSON calls may still be possible, but they are not equivalent to SDK support. SDK maintainers should report protocol ambiguities against the [AdCP repository](https://github.com/adcontextprotocol/adcp/issues). Report @@ -126,24 +131,23 @@ SDK implementation bugs in the SDK's own repository. Accepted protocol fixes receive a changeset and ship in a later beta; published betas are never rebuilt in place. -## Current beta.4 checkpoint +## Current beta.5 checkpoint -Protocol beta.1 through beta.4 are published, and TypeScript SDK beta.5 embeds the -exact beta.4 bundle. Before treating a test result as beta.4 integration -evidence, confirm that: +Protocol beta.1 through beta.5 are published. TypeScript SDK beta.7 and Python +SDK beta.7 embed the exact beta.5 bundle. Before treating a test result as +beta.5 integration evidence, confirm that: -- the support matrix names the exact SDK package and beta.4 protocol bundle; -- both peers advertise `"3.2-beta.4"` before calls use that pin; -- the integration scenarios below run against beta.4 and retain the exact +- the support matrix names the exact SDK package and beta.5 protocol bundle; +- both peers advertise `"3.2-beta.5"` before calls use that pin; +- the integration scenarios below run against beta.5 and retain the exact package and bundle versions; - copy/paste installation and validation commands have been executed as published; -- any unsupported Python or Go surface is reported as unavailable or raw-only, - rather than inferred from TypeScript support. +- any unsupported Python helper or Go surface is reported as unavailable or + raw-only rather than inferred from schema support. Later protocol betas repeat the same protocol-tag-then-SDK-confirmation flow. -The current TypeScript pairing does not make future beta ordinals compatible by -default. +The current SDK pairings do not make future beta ordinals compatible by default. ## Required integration evidence @@ -224,7 +228,7 @@ Include this information in a beta issue: - schema path or JSON Pointer involved; - expected and actual behavior; - minimal redacted payload and validation error; -- whether the problem blocks beta.4 interoperability. +- whether the problem blocks beta.5 interoperability. Never include credentials, signed URLs, bearer tokens, private keys, or live customer payloads. diff --git a/docs/reference/migration/3-1-to-3-2.mdx b/docs/reference/migration/3-1-to-3-2.mdx index 02e35a1db2..78ebf5772a 100644 --- a/docs/reference/migration/3-1-to-3-2.mdx +++ b/docs/reference/migration/3-1-to-3-2.mdx @@ -8,10 +8,11 @@ description: "Role-based migration checklist for adopting the AdCP 3.2 beta whil # Migrating from 3.1 to 3.2 -**3.2 is in beta.** Beta.4 is the current protocol checkpoint, with exact -TypeScript support in `@adcp/sdk@14.0.0-beta.5`. Python and Go support remain -pending. Keep production traffic pinned to `"3.1"` while validating 3.2 in -staging. +**3.2 is in beta.** Beta.5 is the current protocol checkpoint, with exact +TypeScript support in `@adcp/sdk@14.0.0-beta.7` and exact schema/type parity in +Python `adcp==8.0.0b7`. Python higher-level helpers remain partial, and Go exact +support remains pending. Keep production traffic pinned to `"3.1"` while +validating 3.2 in staging. 3.2 is a minor release over 3.1. Existing integrations do not need to adopt the @@ -27,20 +28,20 @@ For prerelease artifacts and SDK timing, use the [3.2 beta program](/docs/refere | Step | Who | Action | |---|---|---| | 1 | Everyone | Keep production on `"3.1"`; choose an exact 3.2 beta artifact for staging. | -| 2 | SDK and codegen maintainers | Generate from the signed `3.2.0-beta.3` protocol tarball, not `main` or `/schemas/latest/`. | +| 2 | SDK and codegen maintainers | Generate from the signed `3.2.0-beta.5` protocol tarball, not `main` or `/schemas/latest/`. | | 3 | Sellers and agents | Advertise the exact prerelease in `adcp.supported_versions` and echo the release actually served. | | 4 | Buyers | Send `adcp_version: "3.2-beta.N"` only after exact capability discovery; never silently move between beta pins. | | 5 | Media-buy implementations | Move create/update success handling completely to `media_buy_status`. | | 6 | Signing implementations | Require `content-digest` coverage and migrate request `Signature` and `Content-Digest` binary values to RFC 8941 padded Base64. | | 7 | Creative implementations | Adopt canonical format capability/product discovery and isolate legacy projection at compatibility boundaries. | -| 8 | Compliance operators | Pair `@adcp/sdk@14.0.0-beta.5` with exact beta.4 for TypeScript testing; require an equally explicit support statement for every other SDK. | +| 8 | Compliance operators | Pair `@adcp/sdk@14.0.0-beta.7` or Python `adcp==8.0.0b7` with exact beta.5; require an equally explicit support statement for every other SDK. | ## Required to claim 3.2 behavior ### Serve and echo the exact release -During beta, prerelease matching is exact. A seller serving beta.4 advertises -`"3.2-beta.4"`. Do not advertise stable `"3.2"` before GA, silently negotiate +During beta, prerelease matching is exact. A seller serving beta.5 advertises +`"3.2-beta.5"`. Do not advertise stable `"3.2"` before GA, silently negotiate between beta ordinals, or treat a major-only declaration as evidence of 3.2 support. @@ -158,8 +159,8 @@ See [Cross-role governance enforcement](/docs/reference/migration/cross-role-gov | Creative agent | Canonical supported-format capabilities, operation selection, and explicit compatibility handling for legacy named formats. | | Signals or measurement agent | Exact beta pin and only the attestation, targeting, or feedback capabilities actually implemented. | | Governance provider/service | Exact beta pin, `governance.campaign` experimental declaration, and the complete cross-role authorization migration. | -| SDK maintainer | Signed bundle ingestion, generated-type/validator tests, exact support matrix entry, and protocol feedback destined for beta.1. | -| Compliance operator | Matching beta assets end to end; no 3.2 badge from beta.0. | +| SDK maintainer | Signed beta.5 bundle ingestion, generated-type/validator tests, exact support matrix entry, and protocol feedback destined for a later beta. | +| Compliance operator | Matching beta.5 assets end to end; no 3.2 badge from a beta checkpoint. | ## Rollback diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 81fff2d23d..c1d9e73976 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -11,7 +11,7 @@ Authoritative version-by-version release record for AdCP, with cumulative change ## Version 3.2.0 -**Status:** Beta cycle — minor release targeting the 3.2.0 milestone. Beta.0 was the protocol-first cut used as the canonical input for SDK work. Beta.4 is the current checkpoint, with exact TypeScript support in `@adcp/sdk@14.0.0-beta.5`. Stable wire shapes remain backward compatible except for the ratified `media_buy_status` cleanup and request-signature encoding migration described below; explicitly experimental surfaces may carry noticed changes under the [experimental-status contract](/docs/reference/experimental-status). +**Status:** Beta cycle — minor release targeting the 3.2.0 milestone. Beta.0 was the protocol-first cut used as the canonical input for SDK work. Beta.5 is the current checkpoint, with exact TypeScript support in `@adcp/sdk@14.0.0-beta.7` and exact Python schema/type parity in `adcp==8.0.0b7`. Stable wire shapes remain backward compatible except for the ratified `media_buy_status` cleanup and request-signature encoding migration described below; explicitly experimental surfaces may carry noticed changes under the [experimental-status contract](/docs/reference/experimental-status). ### Beta checkpoints @@ -20,8 +20,9 @@ Authoritative version-by-version release record for AdCP, with cumulative change | `3.2.0-beta.0` | Publish signed schemas, compliance assets, skills, and manifest for implementation feedback | SDK support follows this cut; beta.0 is not an SDK-backed certification target | | `3.2.0-beta.1` | Incorporate the first round of integration corrections | Superseded by beta.2 for new beta testing | | `3.2.0-beta.2` | Publish the second integration checkpoint | Superseded by beta.3 for new beta testing | -| `3.2.0-beta.4` | Publish the current protocol checkpoint (flexible-window availability, outcome_target reverse forecasting, availability conformance) | Exact TypeScript support in `@adcp/sdk@14.0.0-beta.5`; Python and Go remain pending | | `3.2.0-beta.3` | Corrected protocol checkpoint, superseded by beta.4 | Superseded; remains available for pinned prerelease adopters | +| `3.2.0-beta.4` | Publish flexible-window availability, outcome_target reverse forecasting, and availability conformance | Superseded; remains available for pinned prerelease adopters | +| `3.2.0-beta.5` | Add cross-transport async identity and convergence, policy-backed creative rejection compliance, and outcome-target conformance | Exact TypeScript support in `@adcp/sdk@14.0.0-beta.7`; exact Python schema/type parity in `adcp==8.0.0b7`; Go remains pending | Each beta is immutable. The `3.2-beta` documentation selector advances to the latest beta, while pinned protocol, schema, compliance, and documentation artifacts remain available. See the [3.2 beta program](/docs/reference/3-2-beta). @@ -42,8 +43,8 @@ Each beta is immutable. The `3.2-beta` documentation selector advances to the la | A seller or service | Advertise and echo the exact prerelease served, emit canonical status/retry shapes, and declare only capabilities actually implemented. | | A signing implementation | Require `content-digest` coverage for every signed request body on a 3.2 signing endpoint. | | A creative implementation | Prefer canonical capability and product format discovery; isolate named-format conversion at compatibility boundaries. | -| An SDK maintainer | Generate from the current signed beta.3 tarball, publish an exact support statement, and route accepted protocol corrections into a later beta. | -| A compliance operator | Pair `@adcp/sdk@14.0.0-beta.5` with beta.4 for TypeScript testing; do not infer Python or Go support or issue a 3.2 badge from beta.4. | +| An SDK maintainer | Generate from the current signed beta.5 tarball, publish an exact support statement, and route accepted protocol corrections into a later beta. | +| A compliance operator | Pair `@adcp/sdk@14.0.0-beta.7` or Python `adcp==8.0.0b7` with beta.5; do not infer missing higher-layer helpers or Go support, and do not issue a 3.2 badge from a beta checkpoint. | ### Required 3.2 migrations diff --git a/docs/reference/versions.mdx b/docs/reference/versions.mdx index 81005a843c..701ff359cb 100644 --- a/docs/reference/versions.mdx +++ b/docs/reference/versions.mdx @@ -13,7 +13,7 @@ description: "Every published AdCP version, its status, and its end-of-life date | Status | Versions | Wire pin | What it means | |---|---|---|---| -| **Beta** | 3.2 (current prerelease: `beta.4`) | `"3.2-beta.4"` | Development and staging only. TypeScript SDK support is available in `@adcp/sdk@14.0.0-beta.5`; follow the [3.2 beta program](/docs/reference/3-2-beta). | +| **Beta** | 3.2 (current prerelease: `beta.5`) | `"3.2-beta.5"` | Development and staging only. Exact TypeScript and Python beta packages are available; follow the [3.2 beta program](/docs/reference/3-2-beta). | | **Active** | 3.1 (current stable: 3.1.15) | `"3.1"` | Current production minor release. See [What's New in AdCP 3.1](/docs/reference/whats-new-in-3-1) for the feature set. | | **Maintained** | 3.0 (current stable: 3.0.24) | `"3.0"` | Supported previous minor for existing production integrations. Receives compatibility and security patches. | | **End of life** | 2.0.0, 2.1.0, 2.5.0–2.5.3 | — | No patches. Migrate to 3.0. See [v2 sunset](/docs/reference/v2-sunset). | @@ -45,7 +45,8 @@ Supported patches don't change the wire contract. Upgrading within 3.0.x is alwa | Version | Released | Notes | |---|---|---| -| **3.2.0-beta.4** | 2026-08-20 | Current prerelease. `@adcp/sdk@14.0.0-beta.5` embeds this exact checkpoint. Use `"3.2-beta.4"` only when both peers explicitly advertise it. Adds flexible-window availability discovery and the outcome_target reverse-forecast input. | +| **3.2.0-beta.5** | 2026-08-22 | Current prerelease. `@adcp/sdk@14.0.0-beta.7` and Python `adcp==8.0.0b7` embed this exact checkpoint. Use `"3.2-beta.5"` only when both peers explicitly advertise it. | +| 3.2.0-beta.4 | 2026-08-20 | Integration checkpoint, superseded by beta.5 for new beta testing. Adds flexible-window availability discovery and the outcome_target reverse-forecast input. | | 3.2.0-beta.3 | 2026-08-19 | Integration checkpoint, superseded by beta.4 for new beta testing. | | 3.2.0-beta.2 | 2026-08-19 | Integration checkpoint, superseded by beta.3 for new beta testing. | | 3.2.0-beta.1 | 2026-08-19 | Integration-correction checkpoint, superseded by beta.2 for new beta testing. | @@ -89,8 +90,8 @@ Targeted for early 2027. 3.x is mid-cycle — see the [planned releases table](/ | If you are… | Use | |---|---| | Building a new production integration today | **3.1**. Pin `"3.1"` on the wire. | -| Implementing or testing 3.2 without an SDK | Use the signed **3.2 beta.3** artifacts with raw-wire or code-generation workflows. Follow the [beta program](/docs/reference/3-2-beta). | -| Running an SDK-backed 3.2 integration test | Use `@adcp/sdk@14.0.0-beta.5` with exact wire pin `"3.2-beta.4"`, or another SDK only after its support matrix row names an exact checkpoint. | +| Implementing or testing 3.2 without an SDK | Use the signed **3.2 beta.5** artifacts with raw-wire or code-generation workflows. Follow the [beta program](/docs/reference/3-2-beta). | +| Running an SDK-backed 3.2 integration test | Use `@adcp/sdk@14.0.0-beta.7` or Python `adcp==8.0.0b7` with exact wire pin `"3.2-beta.5"`; use Go only after its support matrix row names an exact checkpoint. | | Running an existing 3.0 integration | Stay on **3.0** while you migrate, or move to **3.1** when your SDK and validation are ready. | | Running a 2.5.x integration | Upgrade now — out of support. Start with the [migration guide](/docs/reference/migration). | | Running a 2.0 or 2.1 integration | Upgrade now — out of support. | diff --git a/docs/reference/whats-new-in-3-2.mdx b/docs/reference/whats-new-in-3-2.mdx index 0b360d2523..289389c83a 100644 --- a/docs/reference/whats-new-in-3-2.mdx +++ b/docs/reference/whats-new-in-3-2.mdx @@ -8,10 +8,11 @@ description: "Adopter overview of AdCP 3.2: compact media buying, targeting-awar # What's new in AdCP 3.2 -**3.2 is in beta.** The protocol beta.4 artifacts and -`@adcp/sdk@14.0.0-beta.5` now support the same exact checkpoint. Python and Go -3.2 packages are still pending. Later betas require a fresh SDK support -statement against their exact bundle. See the +**3.2 is in beta.** The protocol beta.5 artifacts, +`@adcp/sdk@14.0.0-beta.7`, and Python `adcp==8.0.0b7` support the same exact +checkpoint. Python higher-level helpers remain partial, and Go exact support is +still pending. Later betas require a fresh SDK support statement against their +exact bundle. See the [3.2 beta program](/docs/reference/3-2-beta) before testing. @@ -146,8 +147,8 @@ pin an exact 3.2 release, and follow the applicable migration notice. | A media-buy implementer | Use `media_buy_status` on create/update success payloads; root `status` is the task-envelope state. | | A signing implementation | Require `content-digest` coverage for every signed body on a 3.2 signing endpoint. | | A creative implementation | Prefer canonical capability and product format discovery; retain legacy conversion only at a proven compatibility boundary. | -| An SDK maintainer | Generate from the signed beta.0 tarball, name the exact supported bundle, and feed protocol corrections into beta.1. | -| A compliance operator | Treat beta.0 as an implementation input; treat beta.1 as SDK-backed only after the exact beta.1 SDK refresh and integration evidence publish. Do not issue a 3.2 badge from beta.0. | +| An SDK maintainer | Generate from the signed beta.5 tarball, name the exact supported bundle, and feed accepted protocol corrections into a later beta. | +| A compliance operator | Pair beta.5 assets with an SDK that names that exact checkpoint, retain end-to-end integration evidence, and do not issue a 3.2 badge from a beta. | ## Start here diff --git a/package-lock.json b/package-lock.json index bff7ae708e..3aa88e737e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "adcontextprotocol", "version": "3.2.0-beta.5", "dependencies": { - "@adcp/sdk": "14.0.0-beta.5", + "@adcp/sdk": "14.0.0-beta.7", "@anthropic-ai/sdk": "^0.117.1", "@asteasolutions/zod-to-openapi": "^8.5.0", "@contentauth/c2pa-node": "^0.8.3", @@ -131,9 +131,9 @@ } }, "node_modules/@adcp/sdk": { - "version": "14.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@adcp/sdk/-/sdk-14.0.0-beta.5.tgz", - "integrity": "sha512-SRUoJ8Qe6NlbFSQJzgEdH2nVpKHo2w4AeuAoq4BwgcyLCNsw9mdDWuI3GAhz8niFjmcMOclhNpmyIAI4Nbqe+g==", + "version": "14.0.0-beta.7", + "resolved": "https://registry.npmjs.org/@adcp/sdk/-/sdk-14.0.0-beta.7.tgz", + "integrity": "sha512-wE+ucLhBfkgnAU3yqWO3FOdKo/4rrqMgsP4XUr9prhlVRjB5KMiqXgd+mboRsPLmW7Kk6ZRaOwcrfbffaus7qg==", "license": "Apache-2.0", "workspaces": [ ".", diff --git a/package.json b/package.json index 27099a00ee..bb60597408 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,8 @@ "test:error-code-drift": "node scripts/lint-error-code-drift.cjs", "test:language-tag-refs": "node --test --test-force-exit --test-timeout=30000 tests/lint-language-tag-refs.test.cjs && node scripts/lint-language-tag-refs.cjs", "test:compliance-snippets": "node --test --test-force-exit --test-timeout=30000 tests/generate-compliance-snippets.node.mjs tests/link-compliance-symbols.node.mjs && npm run build:compliance -- --check", - "test:doc-compliance-drift": "node --test --test-force-exit --test-timeout=30000 tests/lint-doc-compliance-drift.test.cjs", + "test:doc-compliance-drift": "node --test --test-force-exit --test-timeout=30000 tests/lint-doc-compliance-drift.test.cjs && npm run test:release-instrumentation", + "test:release-instrumentation": "node --test --test-force-exit --test-timeout=30000 tests/release-instrumentation.test.cjs", "test:substitution-vector-names": "node scripts/lint-substitution-vector-names.cjs && node --test --test-force-exit --test-timeout=30000 tests/universal-macro-translation-vectors.test.cjs", "test:unit": "npm run test:canonical-pixel-ratio && npm run test:governance-jws-vectors && vitest run --dir tests/ --pool=threads", "test:redteam": "tsx server/src/addie/testing/redteam-cli.ts", @@ -162,7 +163,7 @@ "docs:json-field-audit": "node scripts/docs-json-field-audit.cjs" }, "dependencies": { - "@adcp/sdk": "14.0.0-beta.5", + "@adcp/sdk": "14.0.0-beta.7", "@anthropic-ai/sdk": "^0.117.1", "@asteasolutions/zod-to-openapi": "^8.5.0", "@contentauth/c2pa-node": "^0.8.3", diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index ee176943e8..2ddeb25def 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.08.14'; +export const CODE_VERSION = '2026.08.15'; // Types export interface ConfigVersion { diff --git a/server/src/addie/mcp/certification-tools.ts b/server/src/addie/mcp/certification-tools.ts index 60e459d52d..cb2d7f1eba 100644 --- a/server/src/addie/mcp/certification-tools.ts +++ b/server/src/addie/mcp/certification-tools.ts @@ -3021,7 +3021,7 @@ Tell ${codingTool}: "Build a buyer agent using @adcp/sdk that connects to the pu The SDK handles protocol details — the learner focuses on orchestration logic. -The targeting-aware discovery portion is a 3.2 schema-preview objective until https://github.com/adcontextprotocol/adcp/issues/6199 lands. Build schema fixtures that decompose one messy request into brief, filters, targeting_overlay, and required_overlay_support; exercise acceptance and rejection of targeting_resolution.modifications; and verify package targeting readback. Do not treat the current public test agent accepting or ignoring these preview fields as validation. +Use the exact 3.2 beta.5 wire pin with @adcp/sdk@14.0.0-beta.7 for the targeting-aware discovery portion. Decompose one messy request into brief plus criteria.offer_filters, criteria.targeting_overlay, and criteria.required_overlay_support; verify that unsupported future-selection requirements filter products; review any targeting_resolution.modifications before purchase; and verify effective package targeting on readback. Treat the get_products compatibility facade's equivalent fields as compatibility evidence, not as proof that the compact tasks work. Reference: ${SDKS_URL} @@ -3034,16 +3034,16 @@ PRESENT THESE INSTRUCTIONS TO THE LEARNER: Validate in two parts. -1. Run the legacy live buying workflow against the public test agent and share the output. Use the \`adcp\` CLI: +1. Run the compatibility buying workflow against the public test agent and share the output. Use the \`adcp\` CLI: \`\`\` -npx @adcp/sdk@latest test-mcp get_products '{"brief":""}' +npx @adcp/sdk@14.0.0-beta.7 test-mcp get_products '{"adcp_version":"3.2-beta.5","buying_mode":"brief","brief":""}' \`\`\` Replace \`\` with your actual brief. Then run the full buying flow: get_products (select a canonical \`format_options[]\` entry) → create_media_buy → get_adcp_capabilities on the chosen creative endpoint → sync_creatives with \`format_kind\` and optional \`format_option_ref\`. -2. Validate the 3.2 targeting-aware objectives with schema fixtures: request decomposition, required future targeting support, disclosed modification acceptance/rejection, and effective package readback. The public agent does not validate those fields until https://github.com/adcontextprotocol/adcp/issues/6199 lands. After that issue is complete, rerun the targeting-aware fixtures live. +2. Validate the 3.2 targeting-aware objectives live with \`list_products\` and \`request_proposals\`: request decomposition, required future targeting support, disclosed modification acceptance/rejection, and effective package readback. First retain a capability response advertising the exact served version and relevant lifecycle tools. Then retain one supported and one unsupported requirement result, the beta.5 request/response envelopes, and the post-purchase package readback. An empty result for the unsupported requirement is evidence only when the same seller returns an eligible product for the supported control request. -Paste the live output and fixture validation results. We'll verify both the current workflow and the 3.2 preview behavior without mistaking ignored fields for success. +Paste the live output and validation results. We'll verify both the compatibility workflow and the native 3.2 behavior. Reference: ${VALIDATE_URL}`; } diff --git a/server/src/db/migrations/549_idempotency_retention_boundary.sql b/server/src/db/migrations/549_idempotency_retention_boundary.sql new file mode 100644 index 0000000000..7864a95d08 --- /dev/null +++ b/server/src/db/migrations/549_idempotency_retention_boundary.sql @@ -0,0 +1,14 @@ +-- SDK 14.0.0-beta.7 separates logical replay expiry from the physical +-- retention boundary needed to honor the protocol clock-skew window. +ALTER TABLE adcp_idempotency + ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ; + +-- Existing rows predate the explicit boundary. Preserve the default 60-second +-- protocol clock-skew window rather than making them physically removable at +-- their logical expiry. +UPDATE adcp_idempotency +SET retain_until = expires_at + INTERVAL '60 seconds' +WHERE retain_until IS NULL; + +CREATE INDEX IF NOT EXISTS idx_adcp_idempotency_retain_until + ON adcp_idempotency(retain_until, expires_at); diff --git a/server/src/db/migrations/550_curriculum_3_2_criteria.sql b/server/src/db/migrations/550_curriculum_3_2_criteria.sql new file mode 100644 index 0000000000..663815b7f2 --- /dev/null +++ b/server/src/db/migrations/550_curriculum_3_2_criteria.sql @@ -0,0 +1,181 @@ +-- Add auditable AdCP 3.2 specialist criteria and the teaching actions that +-- produce evidence for them. Prior-holder targeting remains gated by the +-- GA/effective-date policy in docs/learning/policies/recertification.mdx. + +CREATE OR REPLACE FUNCTION _append_3_2_key_concept( + p_module_id text, + p_topic text, + p_teaching_notes text +) RETURNS void AS $$ +BEGIN + UPDATE certification_modules + SET lesson_plan = jsonb_set( + lesson_plan, + '{key_concepts}', + COALESCE(lesson_plan->'key_concepts', '[]'::jsonb) || jsonb_build_array( + jsonb_build_object('topic', p_topic, 'teaching_notes', p_teaching_notes) + ) + ) + WHERE id = p_module_id + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(lesson_plan->'key_concepts', '[]'::jsonb)) concept + WHERE concept->>'topic' = p_topic + ); + + IF NOT FOUND AND NOT EXISTS (SELECT 1 FROM certification_modules WHERE id = p_module_id) THEN + RAISE EXCEPTION 'Module % not found', p_module_id; + END IF; +END; +$$ LANGUAGE plpgsql; + +SELECT _append_3_2_key_concept('S1', 'AdCP 3.2 compact planning and buying', + 'Use advertised lifecycle_tools to choose list_products, request_proposals, refine_proposals, buy_products, accept_proposal, and control_media_buy. Separate offer filters, current targeting, and future targeting support; require readback evidence for effective targeting. Use availability_horizon and outcome_target only when advertised, and distinguish planning guidance from a finalized commercial obligation.'); + +SELECT _append_3_2_key_concept('S2', 'AdCP 3.2 creative interoperability', + 'Validate logical dimensions separately from intrinsic pixel density and distinguish accepted pixel_ratios from required_pixel_ratios rendition coverage. Apply the advertised VAST validation level, format-required accessibility detail, and provenance requirements; a declaration is not independent verification.'); + +SELECT _append_3_2_key_concept('S6', 'AdCP 3.2 request and webhook integrity', + 'On 3.2 request-signing endpoints, every signed body covers content-digest and request Signature/Content-Digest sf-binary values use RFC 8941 padded standard Base64 without a legacy-parser fallback. For webhook delivery, operation_id is buyer correlation while the payload idempotency_key is sender-generated dedup identity and remains stable across exact retries.'); + +DROP FUNCTION _append_3_2_key_concept(text, text, text); + +CREATE OR REPLACE FUNCTION _instrument_3_2_exercise( + p_module_id text, + p_exercise_id text, + p_tool text, + p_guidance text +) RETURNS void AS $$ +DECLARE + defs jsonb; + updated jsonb := '[]'::jsonb; + ex jsonb; + actions jsonb; + exercise_matched boolean := false; +BEGIN + SELECT exercise_definitions INTO defs + FROM certification_modules + WHERE id = p_module_id; + + IF defs IS NULL OR jsonb_typeof(defs) <> 'array' THEN + RAISE EXCEPTION 'Module % not found or has no exercise_definitions array', p_module_id; + END IF; + + FOR ex IN SELECT * FROM jsonb_array_elements(defs) + LOOP + IF ex->>'id' = p_exercise_id THEN + exercise_matched := true; + actions := COALESCE(ex->'sandbox_actions', '[]'::jsonb); + IF NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(actions) action + WHERE action->>'tool' = p_tool AND action->>'guidance' = p_guidance + ) THEN + actions := actions || jsonb_build_array( + jsonb_build_object('tool', p_tool, 'guidance', p_guidance) + ); + ex := jsonb_set(ex, '{sandbox_actions}', actions); + END IF; + END IF; + updated := updated || jsonb_build_array(ex); + END LOOP; + + IF NOT exercise_matched THEN + RAISE EXCEPTION 'Exercise % not found in module %', p_exercise_id, p_module_id; + END IF; + + UPDATE certification_modules + SET exercise_definitions = updated + WHERE id = p_module_id; +END; +$$ LANGUAGE plpgsql; + +SELECT _instrument_3_2_exercise('S1', 's1_ex1', 'request_proposals', + 'On the exact 3.2 beta wire, submit criteria with offer_filters, targeting_overlay, and required_overlay_support; compare supported and unsupported requirements, review sparse targeting_resolution modifications, and retain the proposal plus effective package targeting readback as evidence.'); + +SELECT _instrument_3_2_exercise('S1', 's1_ex1', 'list_products', + 'When the seller advertises the planning features, test a flexible availability_horizon and an outcome_target. Verify complete window coverage or disclosed incomplete gaps, total_budget_guidance, and a matching forecast metric without claiming that forecast guidance is a guarantee.'); + +SELECT _instrument_3_2_exercise('S2', 's2_ex1', 'sync_creatives', + 'Run the 3.2 canonical pixel-density and rendition reference cases, then submit VAST, accessibility, and provenance cases against the seller capabilities. Record the exact validation level and distinguish declared metadata from independently verified evidence.'); + +SELECT _instrument_3_2_exercise('S6', 's6_ex2', 'get_adcp_capabilities', + 'Read the 3.2 request_signing posture, then use the request-signing conformance vectors to demonstrate required content-digest coverage, exact-byte mismatch rejection, padded standard Base64 sf-binary parsing, and rejection of a legacy Base64URL request token without fallback.'); + +SELECT _instrument_3_2_exercise('S6', 's6_ex4', 'sync_accounts', + 'Capture webhook retry evidence showing buyer-supplied operation_id remains correlation while payload idempotency_key remains stable across exact retries; verify a changed logical fire gets a fresh payload key. Do not treat the SDK emitter delivery_id input as an MCP payload field.'); + +DROP FUNCTION _instrument_3_2_exercise(text, text, text, text); +CREATE OR REPLACE FUNCTION _append_criterion( + p_module_id text, + p_exercise_id text, + p_criterion_id text, + p_text text +) RETURNS void AS $$ +DECLARE + defs jsonb; + updated jsonb := '[]'::jsonb; + ex jsonb; + criteria jsonb; + exercise_matched boolean := false; +BEGIN + SELECT exercise_definitions INTO defs + FROM certification_modules + WHERE id = p_module_id; + + IF defs IS NULL OR jsonb_typeof(defs) <> 'array' THEN + RAISE EXCEPTION 'Module % not found or has no exercise_definitions array', p_module_id; + END IF; + + FOR ex IN SELECT * FROM jsonb_array_elements(defs) + LOOP + IF ex->>'id' = p_exercise_id THEN + exercise_matched := true; + criteria := COALESCE(ex->'success_criteria', '[]'::jsonb); + IF NOT EXISTS ( + SELECT 1 FROM jsonb_array_elements(criteria) c + WHERE c->>'id' = p_criterion_id + ) THEN + criteria := criteria || jsonb_build_array( + jsonb_build_object('id', p_criterion_id, 'text', p_text) + ); + ex := jsonb_set(ex, '{success_criteria}', criteria); + END IF; + END IF; + updated := updated || jsonb_build_array(ex); + END LOOP; + + IF NOT exercise_matched THEN + RAISE EXCEPTION 'Exercise % not found in module %', p_exercise_id, p_module_id; + END IF; + + UPDATE certification_modules + SET exercise_definitions = updated + WHERE id = p_module_id; +END; +$$ LANGUAGE plpgsql; + +-- S1 — compact buying, targeting-aware discovery, and planning inputs. +SELECT _append_criterion('S1', 's1_ex1', 's1_ex1_sc_compact_media_buy_lifecycle', + 'Discovers advertised lifecycle_tools and completes the immutable request_proposals → refine_proposals → finalize → accept_proposal flow, using buy_products for direct offers and control_media_buy only for in-envelope operational changes.'); + +SELECT _append_criterion('S1', 's1_ex1', 's1_ex1_sc_targeting_aware_discovery', + 'Separates concrete targeting_overlay values from required_overlay_support, filters products by binding overlay_support, reviews sparse targeting_resolution modifications before purchase, and verifies effective package targeting on readback.'); + +SELECT _append_criterion('S1', 's1_ex1', 's1_ex1_sc_availability_and_outcome_planning', + 'Uses availability_horizon for flexible-window discovery and outcome_target for reverse forecasting, distinguishes forecast guidance from a delivery guarantee, and carries the selected priced product snapshot into purchase.'); + +-- S2 — 3.2 creative quality, interoperability, and provenance. +SELECT _append_criterion('S2', 's2_ex1', 's2_ex1_sc_pixel_density_and_renditions', + 'Selects logical dimensions and pixel_ratio correctly, validates intrinsic pixel dimensions, and distinguishes a required rendition set from optional creative variants or delivery-time resizing.'); + +SELECT _append_criterion('S2', 's2_ex1', 's2_ex1_sc_vast_accessibility_provenance', + 'Applies the advertised VAST validation level, preserves required accessibility detail, and evaluates synthetic-depiction and provenance evidence while distinguishing declared metadata from independent verification.'); + +-- S6 — 3.2 request and webhook integrity changes. +SELECT _append_criterion('S6', 's6_ex2', 's6_ex2_sc_3_2_signed_body_integrity', + 'For a 3.2 request-signing endpoint, verifies that content-digest is covered on every body-bearing request and encodes Signature and Content-Digest binary values as RFC 8941 padded standard Base64 rather than the 3.0/3.1 Base64URL override.'); + +SELECT _append_criterion('S6', 's6_ex4', 's6_ex4_sc_webhook_delivery_identity', + 'Distinguishes buyer-supplied operation_id correlation from sender-generated webhook idempotency_key deduplication, preserves one payload key across retries of the same byte-identical logical fire, and uses a fresh payload key when the payload or lifecycle observation changes.'); + +DROP FUNCTION _append_criterion(text, text, text, text); diff --git a/server/src/db/migrations/551_webhook_delivery_outbox.sql b/server/src/db/migrations/551_webhook_delivery_outbox.sql new file mode 100644 index 0000000000..10b4043685 --- /dev/null +++ b/server/src/db/migrations/551_webhook_delivery_outbox.sql @@ -0,0 +1,54 @@ +-- SDK 14.0.0-beta.7 requires publisher-side webhook identity and the exact +-- retry snapshot to survive process crashes and replica changes. + +CREATE TABLE IF NOT EXISTS adcp_webhook_delivery_bindings ( + publisher_scope TEXT NOT NULL, + tenant_scope TEXT NOT NULL, + delivery_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('bound', 'retired')), + idempotency_key TEXT, + payload_fingerprint TEXT, + first_attempt_at TIMESTAMPTZ, + retain_until TIMESTAMPTZ, + PRIMARY KEY (publisher_scope, tenant_scope, delivery_id), + CHECK ( + (status = 'bound' + AND idempotency_key IS NOT NULL + AND payload_fingerprint IS NOT NULL + AND first_attempt_at IS NOT NULL + AND retain_until IS NOT NULL) + OR + (status = 'retired' + AND idempotency_key IS NULL + AND payload_fingerprint IS NULL + AND first_attempt_at IS NULL + AND retain_until IS NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_adcp_webhook_delivery_binding_retention + ON adcp_webhook_delivery_bindings(retain_until) + WHERE status = 'bound'; + +CREATE TABLE IF NOT EXISTS adcp_webhook_delivery_outbox ( + publisher_scope TEXT NOT NULL, + tenant_scope TEXT NOT NULL, + delivery_id TEXT NOT NULL, + snapshot_encrypted TEXT NOT NULL, + snapshot_iv TEXT NOT NULL, + snapshot_digest TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + lease_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (publisher_scope, tenant_scope, delivery_id) +); + +CREATE INDEX IF NOT EXISTS idx_adcp_webhook_delivery_outbox_pending + ON adcp_webhook_delivery_outbox(next_attempt_at, lease_until, created_at); + +COMMENT ON COLUMN adcp_webhook_delivery_outbox.snapshot_encrypted IS + 'AES-256-GCM encrypted exact WebhookDeliverySnapshot; may contain callback authentication credentials'; +COMMENT ON COLUMN adcp_webhook_delivery_outbox.snapshot_digest IS + 'Keyed SHA-256 equality evidence; never a plaintext credential hash'; diff --git a/server/src/training-agent/FRAMEWORK_MIGRATION.md b/server/src/training-agent/FRAMEWORK_MIGRATION.md index 5404eeee73..825f137c8c 100644 --- a/server/src/training-agent/FRAMEWORK_MIGRATION.md +++ b/server/src/training-agent/FRAMEWORK_MIGRATION.md @@ -60,7 +60,7 @@ working during the 3.x compatibility window. proposal negotiation is a 3.2 feature. - Capability projection removes lifecycle and proposal-refinement metadata for pre-3.2 callers. -- The current wire bundle is `3.2-beta.4`; `3.2-beta.0` remains only as the +- The current wire bundle is `3.2-beta.5`; `3.2-beta.0` remains only as the historical feature-introduction boundary for rejected discovery responses. ## Invariants for future changes diff --git a/server/src/training-agent/agent-notification-configs.ts b/server/src/training-agent/agent-notification-configs.ts index 9747821c9e..2c52b10ce7 100644 --- a/server/src/training-agent/agent-notification-configs.ts +++ b/server/src/training-agent/agent-notification-configs.ts @@ -191,7 +191,7 @@ export async function syncAgentNotificationConfigs( }; } -/** Adapter for the decisioning platform's custom-tool seam. SDK 14.0.0-beta.4 +/** Adapter for the decisioning platform's custom-tool seam. SDK 14.0.0-beta.7 * projects platform capabilities over low-level protocol capabilities, so the * built-in protocol handler cannot currently be mounted through * createAdcpServerFromPlatform. Keep the domain implementation identical and diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index 84fcb0adb9..bb6c3de04f 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -31,7 +31,6 @@ import { type IdempotencyCheckResult, } from '@adcp/sdk/server'; import { isDatabaseInitialized, getPool } from '../db/client.js'; -import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; export const REPLAY_TTL_SECONDS = 86400; @@ -45,6 +44,7 @@ const IN_FLIGHT_RETRY_HINT_CAP_SECONDS = 30; const CLAIM_SAVE_SAFETY_SECONDS = 30; const SCOPE_SEPARATOR = '\u001F'; const PENDING_OWNER_FIELD = '__adcp_pending_owner'; +const RETRYABLE_HASH_PREFIX = '__adcp_retryable__:'; const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_.:-]{16,255}$/; @@ -225,12 +225,23 @@ export function createHashAwareIdempotencyStore( const isPending = (response: unknown): boolean => response === null || pendingOwner(response) !== undefined; + const retryableRequestHash = (payloadHash: string): string | undefined => { + if (!payloadHash.startsWith(RETRYABLE_HASH_PREFIX)) return undefined; + const requestHash = payloadHash.slice(RETRYABLE_HASH_PREFIX.length); + return /^[0-9a-f]{64}$/.test(requestHash) ? requestHash : undefined; + }; + const classify = ( entry: Awaited>, expectedHash: string, ): Exclude | null => { if (!entry) return null; const nowSeconds = Math.floor(Date.now() / 1e3); + const releasedPayloadHash = retryableRequestHash(entry.payloadHash); + if (releasedPayloadHash !== undefined) { + if (entry.expiresAt + clockSkewSeconds < nowSeconds) return { kind: 'expired' }; + return releasedPayloadHash === expectedHash ? null : { kind: 'conflict' }; + } if (entry.expiresAt + clockSkewSeconds < nowSeconds) { return isPending(entry.response) ? null : { kind: 'expired' }; } @@ -256,16 +267,30 @@ export function createHashAwareIdempotencyStore( ): Promise => { const cacheKey = scopedKey(principal, key, extraScope); const expectedHash = hashPayload(payload); - const cached = classify(await backend.get(cacheKey), expectedHash); + const cachedEntry = await backend.get(cacheKey); + const cached = classify(cachedEntry, expectedHash); if (cached) return cached; const expiresAt = Math.floor(Date.now() / 1e3) + IN_FLIGHT_TTL_SECONDS; const owner = randomUUID(); - const claimed = await backend.putIfAbsent(cacheKey, { + const claimEntry = { payloadHash: expectedHash, response: { [PENDING_OWNER_FIELD]: owner }, expiresAt, - }); + retainUntil: expiresAt + clockSkewSeconds, + }; + const nowSeconds = Math.floor(Date.now() / 1e3); + const releasedPayloadHash = cachedEntry + ? retryableRequestHash(cachedEntry.payloadHash) + : undefined; + const expiredPending = cachedEntry + && isPending(cachedEntry.response) + && cachedEntry.expiresAt + clockSkewSeconds < nowSeconds; + const claimed = releasedPayloadHash !== undefined + ? await backend.replaceIfPayloadHash(cacheKey, cachedEntry!.payloadHash, claimEntry) + : expiredPending + ? await backend.replaceIfPayloadHashAndExpired(cacheKey, cachedEntry.payloadHash, claimEntry) + : await backend.putIfAbsent(cacheKey, claimEntry); if (claimed) return { kind: 'miss', payloadHash: expectedHash, claimToken: owner }; const rechecked = classify(await backend.get(cacheKey), expectedHash); @@ -281,25 +306,48 @@ export function createHashAwareIdempotencyStore( ? operation() : withBackendKeyLock(backend, cacheKey, operation); }, + async renew({ principal, key, extraScope, claimToken }) { + const cacheKey = scopedKey(principal, key, extraScope); + const expiresAt = Math.floor(Date.now() / 1e3) + IN_FLIGHT_TTL_SECONDS; + const renewed = await renewOwnedClaim(cacheKey, claimToken, backend, pendingOwner, { + payloadHash: '', + response: { [PENDING_OWNER_FIELD]: claimToken }, + expiresAt, + retainUntil: expiresAt + clockSkewSeconds, + }); + if (!renewed) throw new Error('Idempotency claim ownership was lost before its lease could be renewed.'); + }, async save({ principal, key, payloadHash, response, extraScope, claimToken }) { const cacheKey = scopedKey(principal, key, extraScope); + const expiresAt = Math.floor(Date.now() / 1e3) + ttlSeconds; const saved = await replaceOwnedClaim(cacheKey, claimToken, backend, pendingOwner, { payloadHash, response, - expiresAt: Math.floor(Date.now() / 1e3) + ttlSeconds, + expiresAt, + retainUntil: expiresAt + clockSkewSeconds, }); if (!saved) throw new Error('Idempotency claim ownership was lost before the response could be published.'); }, async release({ principal, key, extraScope, claimToken }) { const cacheKey = scopedKey(principal, key, extraScope); - await deleteOwnedClaim(cacheKey, claimToken, backend, pendingOwner); + const released = await markOwnedClaimRetryable( + cacheKey, + claimToken, + backend, + pendingOwner, + ttlSeconds, + clockSkewSeconds, + ); + if (!released) throw new Error('Idempotency claim ownership was lost before it could be released.'); }, async saveTransientError({ principal, key, payloadHash, response, extraScope, claimToken }) { const cacheKey = scopedKey(principal, key, extraScope); + const expiresAt = Math.floor(Date.now() / 1e3) + TRANSIENT_ERROR_TTL_SECONDS; const saved = await replaceOwnedClaim(cacheKey, claimToken, backend, pendingOwner, { payloadHash, response, - expiresAt: Math.floor(Date.now() / 1e3) + TRANSIENT_ERROR_TTL_SECONDS, + expiresAt, + retainUntil: expiresAt + clockSkewSeconds, }); if (!saved) throw new Error('Idempotency claim ownership was lost before the response could be published.'); }, @@ -322,6 +370,32 @@ export function createHashAwareIdempotencyStore( }; } +async function renewOwnedClaim( + cacheKey: string, + owner: string, + backend: FencedIdempotencyBackend, + pendingOwner: (response: unknown) => string | undefined, + entry: Parameters[1], +): Promise { + const current = await backend.get(cacheKey); + if (!current || pendingOwner(current.response) !== owner) return false; + const renewedEntry = { ...entry, payloadHash: current.payloadHash }; + if (backend.replaceIfPendingOwner) { + return backend.replaceIfPendingOwner( + cacheKey, + owner, + renewedEntry, + Math.floor(Date.now() / 1e3) - 1, + ); + } + return withBackendKeyLock(backend, cacheKey, async () => { + const lockedCurrent = await backend.get(cacheKey); + if (!lockedCurrent || pendingOwner(lockedCurrent.response) !== owner) return false; + await backend.put(cacheKey, { ...entry, payloadHash: lockedCurrent.payloadHash }); + return true; + }); +} + async function replaceOwnedClaim( cacheKey: string, owner: string, @@ -344,17 +418,33 @@ async function replaceOwnedClaim( }); } -async function deleteOwnedClaim( +async function markOwnedClaimRetryable( cacheKey: string, owner: string, backend: FencedIdempotencyBackend, pendingOwner: (response: unknown) => string | undefined, + ttlSeconds: number, + clockSkewSeconds: number, ): Promise { - if (backend.deleteIfPendingOwner) return backend.deleteIfPendingOwner(cacheKey, owner); + const current = await backend.get(cacheKey); + if (!current || pendingOwner(current.response) !== owner) return false; + const expiresAt = Math.floor(Date.now() / 1e3) + ttlSeconds; + const releasedEntry = { + payloadHash: `${RETRYABLE_HASH_PREFIX}${current.payloadHash}`, + response: null, + expiresAt, + retainUntil: expiresAt + clockSkewSeconds, + }; + if (backend.replaceIfPendingOwner) { + return backend.replaceIfPendingOwner(cacheKey, owner, releasedEntry, 0); + } return withBackendKeyLock(backend, cacheKey, async () => { - const current = await backend.get(cacheKey); - if (!current || pendingOwner(current.response) !== owner) return false; - await backend.delete(cacheKey); + const lockedCurrent = await backend.get(cacheKey); + if (!lockedCurrent || pendingOwner(lockedCurrent.response) !== owner) return false; + await backend.put(cacheKey, { + ...releasedEntry, + payloadHash: `${RETRYABLE_HASH_PREFIX}${lockedCurrent.payloadHash}`, + }); return true; }); } @@ -388,11 +478,12 @@ function fencedPgBackend(): FencedIdempotencyBackend { `UPDATE adcp_idempotency SET payload_hash = $3, response = $4::jsonb, - expires_at = TO_TIMESTAMP($5) + expires_at = TO_TIMESTAMP($5), + retain_until = TO_TIMESTAMP($6) WHERE scoped_key = $1 AND response ->> '__adcp_pending_owner' = $2 - AND expires_at > TO_TIMESTAMP($6)`, - [cacheKey, owner, entry.payloadHash, JSON.stringify(entry.response), entry.expiresAt, minimumLeaseExpiry], + AND expires_at > TO_TIMESTAMP($7)`, + [cacheKey, owner, entry.payloadHash, JSON.stringify(entry.response), entry.expiresAt, entry.retainUntil, minimumLeaseExpiry], ); return (result.rowCount ?? 0) > 0; }, @@ -421,60 +512,9 @@ export function getIdempotencyStore(): OwnedIdempotencyStore { return storeInstance; } -const SDK_CLAIM_SEPARATOR = '.'; -const sdkClaimContext = new AsyncLocalStorage>(); - -function sdkClaimKey(params: { principal: string; key: string; extraScope?: string }): string { - return `${params.principal}${SCOPE_SEPARATOR}${params.extraScope ?? ''}${SCOPE_SEPARATOR}${params.key}`; -} - -function decodeSdkClaim(encoded: string): { payloadHash: string; claimToken: string } { - const separator = encoded.lastIndexOf(SDK_CLAIM_SEPARATOR); - if (separator <= 0 || separator === encoded.length - 1) { - throw new Error('SDK idempotency claim is missing its fencing token.'); - } - return { - payloadHash: encoded.slice(0, separator), - claimToken: encoded.slice(separator + 1), - }; -} - -/** Adapt the token-aware store to the SDK's payloadHash-only claim contract. */ +/** Expose the token-aware store through the SDK beta.7 idempotency contract. */ export function adaptOwnedIdempotencyStoreForSdk(owned: OwnedIdempotencyStore): IdempotencyStore { - return { - ...owned, - check(params) { - // enterWith must happen synchronously, before returning the Promise, so - // the SDK's awaiting continuation inherits this request-local claim map. - const claims = new Map(sdkClaimContext.getStore()); - sdkClaimContext.enterWith(claims); - return owned.check(params).then(result => { - if (result.kind !== 'miss') return result; - claims.set(sdkClaimKey(params), result.claimToken); - return { - ...result, - payloadHash: `${result.payloadHash}${SDK_CLAIM_SEPARATOR}${result.claimToken}`, - }; - }); - }, - async save(params) { - await owned.save({ ...params, ...decodeSdkClaim(params.payloadHash) }); - sdkClaimContext.getStore()?.delete(sdkClaimKey(params)); - }, - async release(params) { - const claims = sdkClaimContext.getStore(); - const key = sdkClaimKey(params); - const claimToken = claims?.get(key); - if (!claimToken) return; - await owned.release({ ...params, claimToken }); - claims?.delete(key); - }, - async saveTransientError(params) { - if (!owned.saveTransientError) return; - await owned.saveTransientError({ ...params, ...decodeSdkClaim(params.payloadHash) }); - sdkClaimContext.getStore()?.delete(sdkClaimKey(params)); - }, - }; + return owned; } export function getSdkIdempotencyStore(): IdempotencyStore { diff --git a/server/src/training-agent/state.ts b/server/src/training-agent/state.ts index 44dd659b77..15e6164f62 100644 --- a/server/src/training-agent/state.ts +++ b/server/src/training-agent/state.ts @@ -272,6 +272,8 @@ function createSession(): SessionState { contentStandards: new Map(), rightsGrants: new Map(), negotiatedPricingOptions: new Map(), + configuredProducts: new Map(), + configuredProductTargeting: new Map(), proposalLifecycleLinks: new Map(), proposalRefinementRecords: new Map(), creatives: new Map(), @@ -516,6 +518,11 @@ function deserializeSession(data: Record): SessionState { contentStandards: asMap(hydrated.contentStandards, fresh.contentStandards), rightsGrants: asMap(hydrated.rightsGrants, fresh.rightsGrants), negotiatedPricingOptions: asMap(hydrated.negotiatedPricingOptions, fresh.negotiatedPricingOptions), + configuredProducts: asMap(hydrated.configuredProducts, fresh.configuredProducts), + configuredProductTargeting: asMap( + hydrated.configuredProductTargeting, + fresh.configuredProductTargeting, + ), proposalLifecycleLinks: asMap(hydrated.proposalLifecycleLinks, fresh.proposalLifecycleLinks), proposalRefinementRecords: asMap(hydrated.proposalRefinementRecords, fresh.proposalRefinementRecords), buildVariantTargets: asMap(hydrated.buildVariantTargets, fresh.buildVariantTargets), diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 7eb09283c5..6d013cf788 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -2629,10 +2629,11 @@ import { } from './source-schema.js'; const SUPPORTED_MAJOR_VERSIONS = [3] as const; -const SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.4'] as const; +const SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.5'] as const; const DEFAULT_ADCP_VERSION = '3.0'; -const CURRENT_ADCP_VERSION = '3.2-beta.4'; +const CURRENT_ADCP_VERSION = '3.2-beta.5'; const MAX_PACKAGES_PER_BUY = 50; +const MAX_CONFIGURED_PRODUCTS_PER_SESSION = 128; interface ParsedAdcpReleaseVersion { raw: string; @@ -4848,6 +4849,17 @@ function overlaySeededProducts( } } +/** Keep request-scoped targeting configurations resolvable throughout their + * advertised lifetime and in the downstream lifecycle that accepted them. */ +function overlayConfiguredProducts( + session: SessionState, + productMap: Map, +): void { + for (const [productId, product] of session.configuredProducts) { + productMap.set(productId, structuredClone(product)); + } +} + /** Overlay proposal-specific pricing created by successful refine asks. */ function overlayNegotiatedPricingOptions( session: import('./types.js').SessionState, @@ -4984,6 +4996,8 @@ function applyAvailabilityHorizonForecasts( products: Product[], horizon: unknown, session: import('./types.js').SessionState, + sourceProductIds?: ReadonlyMap, + immutableConfiguredProductIds?: ReadonlySet, ): Product[] { if ( !isRecord(horizon) @@ -4999,7 +5013,10 @@ function applyAvailabilityHorizonForecasts( const generatedAt = toUtcSecondsIso(now); const validUntil = toUtcSecondsIso(now + 5 * 60 * 1000); return products.map(product => { - const availability = seededProductAvailability.get(product.product_id); + if (immutableConfiguredProductIds?.has(product.product_id)) return product; + const availability = seededProductAvailability.get( + sourceProductIds?.get(product.product_id) ?? product.product_id, + ); if (!availability) return product; const points = computeAvailabilityForecastPoints(horizonRange, availability); if (!points) return product; @@ -5732,7 +5749,8 @@ const COMPACT_PRODUCT_FIELDS = new Set([ 'performance_standards', 'audience_evidence', 'audience_evidence_selections', 'demographic_targeting', 'exclusivity', 'audio_distribution_types', 'video_placement_types', 'social_placement_surfaces', - 'sponsored_placement_types', 'ext', + 'sponsored_placement_types', 'is_custom', 'overlay_support', + 'targeting_resolution', 'ext', ]); const COMPACT_FORMAT_OPTION_FIELDS = new Set([ @@ -5753,9 +5771,10 @@ function pickCompactFields(source: Record, fields: ReadonlySet< function compactLifecycleProduct( product: Record, requestedFields?: ReadonlySet, + requiredFields: ReadonlySet = new Set(), ): Record { const selectedFields = requestedFields - ? new Set(['product_id', 'name', ...requestedFields]) + ? new Set(['product_id', 'name', ...requestedFields, ...requiredFields]) : COMPACT_PRODUCT_FIELDS; const projected = pickCompactFields(product, selectedFields); if (selectedFields.has('format_options') && Array.isArray(product.format_options)) { @@ -5771,6 +5790,459 @@ function compactLifecycleProduct( return projected; } +/** Match a buyer's future targeting requirement against a Product's binding + * overlay support. `true` means unrestricted protocol-valid support; arrays + * use subset semantics; nested objects use recursive containment. Seller-only + * limits and provenance fields are ignored because the buyer did not request + * them. */ +function overlaySupportContains(support: unknown, requirement: unknown): boolean { + if (support === true) return true; + if (requirement === true) return support === true || isRecord(support); + if (Array.isArray(requirement)) { + return Array.isArray(support) + && requirement.every(value => support.some(candidate => canonicalize(candidate) === canonicalize(value))); + } + if (!isRecord(requirement) || !isRecord(support)) return Object.is(support, requirement); + if (support.all_values === true && Array.isArray(requirement.values)) return true; + return Object.entries(requirement).every(([field, requiredValue]) => { + if (field === 'ext') return true; + return support[field] !== undefined + && overlaySupportContains(support[field], requiredValue); + }); +} + +function concreteTargetingSupported(field: string, support: unknown, value: unknown): boolean { + if (support === true) return true; + if (!isRecord(support)) return false; + if (field === 'placement_selection') { + if (!isRecord(value) || value.mode === 'default') return isRecord(value); + if (value.mode !== 'selected' || !Array.isArray(value.placement_refs)) return false; + return typeof support.max_values_per_package !== 'number' + || value.placement_refs.length <= support.max_values_per_package; + } + if (Array.isArray(value)) { + const valueCount = value.reduce((count, entry) => ( + count + (isRecord(entry) && Array.isArray(entry.values) ? entry.values.length : 1) + ), 0); + if (typeof support.max_values_per_package === 'number' + && valueCount > support.max_values_per_package) return false; + if (field === 'geo_regions' || field === 'geo_regions_exclude') { + const countries = support.countries; + if (!isRecord(countries)) return false; + return value.every(region => { + if (typeof region !== 'string') return false; + const countrySupport = countries[region.slice(0, 2)]; + if (countrySupport === true) return true; + if (!isRecord(countrySupport)) return false; + return countrySupport.all_values === true + || (Array.isArray(countrySupport.values) && countrySupport.values.includes(region)); + }); + } + if (field === 'geo_metros' || field === 'geo_metros_exclude') { + const systems = support.systems; + return Array.isArray(systems) && value.every(entry => ( + isRecord(entry) && typeof entry.system === 'string' && systems.includes(entry.system) + )); + } + if (field === 'geo_places' || field === 'geo_places_exclude') { + const systems = support.systems; + if (!isRecord(systems)) return false; + return value.every(entry => { + if (!isRecord(entry) + || typeof entry.system !== 'string' + || typeof entry.country !== 'string' + || typeof entry.place_type !== 'string') return false; + const catalogSupport = systems[entry.system]; + if (!isRecord(catalogSupport) || !isRecord(catalogSupport.countries)) return false; + const supportedTypes = catalogSupport.countries[entry.country]; + if (!Array.isArray(supportedTypes) || !supportedTypes.includes(entry.place_type)) return false; + return entry.system_version === undefined + || (Array.isArray(catalogSupport.system_versions) + && catalogSupport.system_versions.includes(entry.system_version)); + }); + } + if (field === 'geo_postal_areas' || field === 'geo_postal_areas_exclude') { + return value.every(entry => { + if (!isRecord(entry) || typeof entry.system !== 'string') return false; + if (typeof entry.country === 'string') { + const countrySystems = support[entry.country]; + return Array.isArray(countrySystems) && countrySystems.includes(entry.system); + } + return support[entry.system] === true; + }); + } + if (field === 'keyword_targets' || field === 'negative_keywords') { + const supportedMatchTypes = support.supported_match_types; + return Array.isArray(supportedMatchTypes) && value.every(entry => ( + isRecord(entry) + && typeof entry.match_type === 'string' + && supportedMatchTypes.includes(entry.match_type) + )); + } + if (field === 'geo_proximity') { + const supportedTransportModes = support.transport_modes; + return value.every(entry => { + if (!isRecord(entry)) return false; + if (entry.radius !== undefined) return support.radius === true; + if (entry.geometry !== undefined) return support.geometry === true; + if (entry.travel_time !== undefined) { + return support.travel_time === true + && typeof entry.transport_mode === 'string' + && (!Array.isArray(supportedTransportModes) + || supportedTransportModes.includes(entry.transport_mode)); + } + return false; + }); + } + const allowlist = Array.isArray(support.families) + ? support.families + : Array.isArray(support.values) + ? support.values + : undefined; + return !allowlist || value.every(candidate => ( + allowlist.some(allowed => canonicalize(allowed) === canonicalize(candidate)) + )); + } + return true; +} + +const EXCLUSION_TARGETING_FIELDS = new Set([ + 'geo_countries_exclude', + 'geo_regions_exclude', + 'geo_metros_exclude', + 'geo_places_exclude', + 'geo_postal_areas_exclude', + 'audience_exclude', + 'property_list_exclude', + 'collection_list_exclude', + 'device_platform_exclude', + 'device_type_exclude', + 'browser_exclude', + 'negative_keywords', +]); + +/** Treat grouped targeting entries such as metro `{ system, values }` as a + * set of individual values. That lets a buyer narrow an include group or + * widen an exclusion group without requiring an artificial object split. */ +function targetingArrayAtoms(values: unknown[]): string[] { + return values.flatMap(value => { + if (!isRecord(value) || !Array.isArray(value.values)) return [canonicalize(value)]; + const identity = Object.fromEntries(Object.entries(value).filter(([field]) => field !== 'values')); + return value.values.map(member => canonicalize({ ...identity, value: member })); + }); +} + +function durationSeconds(value: unknown): number | undefined { + if (!isRecord(value) || typeof value.interval !== 'number' || typeof value.unit !== 'string') { + return undefined; + } + const multipliers: Record = { + seconds: 1, + minutes: 60, + hours: 60 * 60, + days: 24 * 60 * 60, + }; + return multipliers[value.unit] === undefined + ? undefined + : value.interval * multipliers[value.unit]; +} + +function frequencyWindowStrength(value: unknown): number | undefined { + if (isRecord(value) && value.unit === 'campaign' && value.interval === 1) { + return Number.POSITIVE_INFINITY; + } + return durationSeconds(value); +} + +function intersectFrequencyCap( + bound: Record, + requested: Record, + path: string, + allowAdditions: boolean, +): { value?: unknown; errorPath?: string } { + const value = structuredClone(bound); + const hasBoundImpressionCap = bound.max_impressions !== undefined; + const hasRequestedImpressionCap = requested.max_impressions !== undefined + || requested.per !== undefined + || requested.window !== undefined; + if (!hasBoundImpressionCap && hasRequestedImpressionCap) { + if (!allowAdditions + || typeof requested.max_impressions !== 'number' + || requested.per === undefined + || frequencyWindowStrength(requested.window) === undefined) { + return { errorPath: `${path}.max_impressions` }; + } + value.max_impressions = requested.max_impressions; + value.per = structuredClone(requested.per); + value.window = structuredClone(requested.window); + } else if (hasBoundImpressionCap) { + const boundMax = bound.max_impressions; + const requestedMax = requested.max_impressions ?? boundMax; + const boundPer = bound.per; + const requestedPer = requested.per ?? boundPer; + const boundWindow = bound.window; + const requestedWindow = requested.window ?? boundWindow; + const boundStrength = frequencyWindowStrength(boundWindow); + const requestedStrength = frequencyWindowStrength(requestedWindow); + if (typeof boundMax !== 'number' + || typeof requestedMax !== 'number' + || requestedMax > boundMax + || canonicalize(requestedPer) !== canonicalize(boundPer) + || boundStrength === undefined + || requestedStrength === undefined + || requestedStrength < boundStrength) { + return { errorPath: `${path}.max_impressions` }; + } + value.max_impressions = requestedMax; + value.per = structuredClone(requestedPer); + value.window = structuredClone(requestedWindow); + } + for (const [field, requestedValue] of Object.entries(requested)) { + const boundValue = bound[field]; + if (boundValue === undefined) { + if (!allowAdditions) return { errorPath: `${path}.${field}` }; + value[field] = structuredClone(requestedValue); + continue; + } + if (field === 'max_impressions' || field === 'per' || field === 'window') continue; + if (field === 'suppress_minutes' + && typeof boundValue === 'number' + && typeof requestedValue === 'number' + && requestedValue >= boundValue) { + value[field] = requestedValue; + continue; + } + if (field === 'suppress') { + const boundSeconds = durationSeconds(boundValue); + const requestedSeconds = durationSeconds(requestedValue); + if (boundSeconds !== undefined && requestedSeconds !== undefined && requestedSeconds >= boundSeconds) { + value[field] = structuredClone(requestedValue); + continue; + } + } + if (canonicalize(boundValue) !== canonicalize(requestedValue)) { + return { errorPath: `${path}.${field}` }; + } + } + return { value }; +} + +function intersectBoundTargeting( + bound: unknown, + requested: unknown, + path: string, + allowAdditions = false, + arrayMode: 'subset' | 'superset' = 'subset', +): { value?: unknown; errorPath?: string } { + if (Array.isArray(bound) && Array.isArray(requested)) { + const boundAtoms = targetingArrayAtoms(bound); + const requestedAtoms = targetingArrayAtoms(requested); + const requiredAtoms = arrayMode === 'subset' ? requestedAtoms : boundAtoms; + const containingAtoms = arrayMode === 'subset' ? boundAtoms : requestedAtoms; + const narrows = requiredAtoms.every(candidate => containingAtoms.includes(candidate)); + return narrows + ? { value: structuredClone(requested) } + : { errorPath: path }; + } + if (isRecord(bound) && isRecord(requested)) { + if (path.endsWith('.frequency_cap')) { + return intersectFrequencyCap(bound, requested, path, allowAdditions); + } + const value = structuredClone(bound); + for (const [field, requestedValue] of Object.entries(requested)) { + if (bound[field] === undefined) { + if (!allowAdditions) return { errorPath: `${path}.${field}` }; + value[field] = structuredClone(requestedValue); + continue; + } + const intersection = intersectBoundTargeting( + bound[field], + requestedValue, + `${path}.${field}`, + allowAdditions, + arrayMode, + ); + if (intersection.errorPath) return intersection; + value[field] = intersection.value; + } + return { value }; + } + if (typeof bound === 'number' && typeof requested === 'number') { + const field = path.split('.').at(-1); + if ((field === 'min' && requested >= bound) || (field === 'max' && requested <= bound)) { + return { value: requested }; + } + } + return canonicalize(bound) === canonicalize(requested) + ? { value: structuredClone(bound) } + : { errorPath: path }; +} + +function resolveConfiguredPurchaseTargeting( + bound: Record | undefined, + requested: Record | undefined, + support: unknown, + path: string, +): { targeting?: Record; errorPath?: string } { + if (!bound && requested) { + const supportRecord = isRecord(support) ? support : {}; + for (const [field, requestedValue] of Object.entries(requested)) { + if (!concreteTargetingSupported(field, supportRecord[field], requestedValue)) { + return { errorPath: `${path}.${field}` }; + } + } + return { targeting: structuredClone(requested) }; + } + if (!bound) return {}; + if (!requested) return { targeting: structuredClone(bound) }; + const resolved = structuredClone(bound); + const supportRecord = isRecord(support) ? support : {}; + for (const [field, requestedValue] of Object.entries(requested)) { + if (bound[field] !== undefined) { + const supportAllowsAdditions = concreteTargetingSupported( + field, + supportRecord[field], + requestedValue, + ); + const intersection = intersectBoundTargeting( + bound[field], + requestedValue, + `${path}.${field}`, + supportAllowsAdditions, + EXCLUSION_TARGETING_FIELDS.has(field) ? 'superset' : 'subset', + ); + if (intersection.errorPath) return { errorPath: intersection.errorPath }; + resolved[field] = intersection.value; + continue; + } + if (!concreteTargetingSupported(field, supportRecord[field], requestedValue)) { + return { errorPath: `${path}.${field}` }; + } + resolved[field] = structuredClone(requestedValue); + } + return { targeting: resolved }; +} + +function pruneConfiguredProducts(session: SessionState): void { + const now = Date.now(); + for (const [productId, product] of session.configuredProducts) { + const expiry = typeof product.expires_at === 'string' ? Date.parse(product.expires_at) : NaN; + const referencedByMediaBuy = [...session.mediaBuys.values()].some(mediaBuy => ( + mediaBuy.packages.some(pkg => pkg.productId === productId) + )); + const referencedByDiscovery = session.lastGetProductsContext?.products?.some( + candidate => candidate.product_id === productId, + ) || session.lastGetProductsContext?.proposals?.some(proposal => ( + proposal.allocations.some(allocation => allocation.product_id === productId) + )); + const referencedByNegotiation = [...session.proposalRefinementRecords.values()].some(record => ( + record.proposal.commercial_terms.purchases.some(purchase => purchase.product_id === productId) + )); + if (Number.isFinite(expiry) + && expiry < now + && !referencedByMediaBuy + && !referencedByDiscovery + && !referencedByNegotiation) { + session.configuredProducts.delete(productId); + session.configuredProductTargeting.delete(productId); + } + } +} + +/** Execute the 3.2 discovery targeting contract for the deterministic training + * catalog. Future-selection requirements are strict capability filters. + * Concrete targeting produces a distinct, time-bound configured offer whose + * pricing and forecast are the snapshots the learner can carry into purchase. */ +function applyDiscoveryTargeting( + products: Product[], + req: GetProductsRequest, + session: SessionState, + lineageKey: string, + sourceProductIds: Map, + reusedConfiguredProductIds: Set, +): { products: Product[]; capacityDrops: number } { + const request = req as unknown as Record; + const targetingOverlay = isRecord(request.targeting_overlay) + ? request.targeting_overlay + : undefined; + const requiredOverlaySupport = isRecord(request.required_overlay_support) + ? request.required_overlay_support + : undefined; + let targeted = products; + if (requiredOverlaySupport) { + targeted = targeted.filter(product => { + const support = (product as unknown as Record).overlay_support; + return overlaySupportContains(support, requiredOverlaySupport); + }); + } + if (!targetingOverlay) return { products: targeted, capacityDrops: 0 }; + pruneConfiguredProducts(session); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const identityInput = Object.fromEntries(Object.entries(request).filter(([field]) => ![ + 'idempotency_key', + 'pagination', + 'fields', + 'push_notification_config', + ].includes(field))); + const requestIdentity = canonicalize(identityInput); + const validityPeriod = Math.floor(Date.now() / (24 * 60 * 60 * 1000)); + const plannedProducts = targeted.map(product => { + const configuredId = `configured_${createHash('sha256') + .update(`${lineageKey}\0${requestIdentity}\0${validityPeriod}\0${product.product_id}`) + .digest('hex') + .slice(0, 24)}`; + return { product, configuredId, existing: session.configuredProducts.get(configuredId) }; + }); + const additionalProducts = plannedProducts.filter(({ existing }) => existing === undefined).length; + const capacityDrops = Math.max( + 0, + session.configuredProducts.size + additionalProducts - MAX_CONFIGURED_PRODUCTS_PER_SESSION, + ); + if (capacityDrops > 0) return { products: [], capacityDrops }; + + const configuredProducts = plannedProducts.map(({ product, configuredId, existing }) => { + if (existing && (!existing.expires_at || new Date(existing.expires_at) >= new Date())) { + sourceProductIds.set(configuredId, product.product_id); + reusedConfiguredProductIds.add(configuredId); + return structuredClone(existing); + } + if (existing) { + session.configuredProducts.delete(configuredId); + session.configuredProductTargeting.delete(configuredId); + } + const configured = { + ...structuredClone(product), + product_id: configuredId, + is_custom: true, + expires_at: product.expires_at ?? expiresAt, + } as Product; + session.configuredProducts.set(configuredId, configured); + session.configuredProductTargeting.set(configuredId, structuredClone(targetingOverlay)); + sourceProductIds.set(configuredId, product.product_id); + return configured; + }); + return { products: configuredProducts, capacityDrops }; +} + +function bindConfiguredTargetingToProposal(proposal: Proposal, session: SessionState): Proposal { + const internal = proposal as unknown as Record; + if (!isRecord(internal.__canonical_commercial_terms)) return proposal; + const terms = structuredClone(internal.__canonical_commercial_terms); + if (!Array.isArray(terms.purchases)) return proposal; + let changed = false; + terms.purchases = terms.purchases.map(purchase => { + if (!isRecord(purchase) || typeof purchase.product_id !== 'string') return purchase; + const bound = session.configuredProductTargeting.get(purchase.product_id); + if (!bound || purchase.targeting_overlay !== undefined) return purchase; + changed = true; + return { ...purchase, targeting_overlay: structuredClone(bound) }; + }); + if (!changed) return proposal; + internal.__canonical_commercial_terms = terms; + internal.__canonical_terms_digest = proposalTermsDigest(terms); + return proposal; +} + function outwardProposal(proposal: Record, products: Map): Record { const brand = { domain: typeof proposal.__brand_domain === 'string' ? proposal.__brand_domain : 'advertiser.example', @@ -5928,8 +6400,17 @@ export function projectProductDiscoveryResult( ? new Set(criteria.product_ids.filter((id): id is string => typeof id === 'string')) : undefined; if (requestedProductIds) { + const matchesRequestedProduct = (productId: string): boolean => { + if (requestedProductIds.has(productId)) return true; + const product = proposalProducts.get(productId) as unknown as Record | undefined; + return product?.is_custom === true; + }; products = products.filter(product => ( - typeof product.product_id === 'string' && requestedProductIds.has(product.product_id) + typeof product.product_id === 'string' + && ( + requestedProductIds.has(product.product_id) + || product.is_custom === true + ) )); if (toolName === 'request_proposals') { proposals = proposals.filter(proposal => ( @@ -5937,7 +6418,7 @@ export function projectProductDiscoveryResult( && proposal.allocations.every(allocation => ( isRecord(allocation) && typeof allocation.product_id === 'string' - && requestedProductIds.has(allocation.product_id) + && matchesRequestedProduct(allocation.product_id) )) )); } @@ -5956,9 +6437,18 @@ export function projectProductDiscoveryResult( const requestedFields = Array.isArray(originalArgs.fields) ? new Set(originalArgs.fields.filter((field): field is string => typeof field === 'string')) : undefined; + const requiredProductFields = new Set(); + if (isRecord(criteria?.required_overlay_support)) requiredProductFields.add('overlay_support'); + if (isRecord(criteria?.targeting_overlay)) { + requiredProductFields.add('is_custom'); + requiredProductFields.add('expires_at'); + if (products.some(product => isRecord(product.targeting_resolution))) { + requiredProductFields.add('targeting_resolution'); + } + } return { outcome: 'listed', - products: products.map(product => compactLifecycleProduct(product, requestedFields)), + products: products.map(product => compactLifecycleProduct(product, requestedFields, requiredProductFields)), ...(pagination && typeof pagination.cursor === 'string' && { next_cursor: pagination.cursor }), ...(typeof result.wholesale_feed_version === 'string' && { feed_version: result.wholesale_feed_version }), ...(typeof result.pricing_version === 'string' && { pricing_version: result.pricing_version }), @@ -6244,15 +6734,6 @@ export function validateProductDiscoveryAliasInput( return { message: 'if_pricing_version requires if_feed_version', field: 'if_feed_version' }; } const criteria = isRecord(args.criteria) ? args.criteria : undefined; - if (criteria?.targeting_overlay !== undefined || criteria?.required_overlay_support !== undefined) { - return { - code: 'UNSUPPORTED_FEATURE', - message: 'The training agent does not execute split-task targeting criteria until the 3.2 SDK rollout; use schema fixtures for preview validation.', - field: criteria.targeting_overlay !== undefined - ? 'criteria.targeting_overlay' - : 'criteria.required_overlay_support', - }; - } if (isRecord(criteria?.catalog) && args.brand === undefined && !hasNaturalAccountBrand) { return { message: 'brand is required when catalog criteria are present', field: 'brand' }; } @@ -6268,15 +6749,6 @@ export function validateProductDiscoveryAliasInput( return { message: 'brand is required for request_proposals', field: 'brand' }; } const criteria = isRecord(args.criteria) ? args.criteria : undefined; - if (criteria?.targeting_overlay !== undefined || criteria?.required_overlay_support !== undefined) { - return { - code: 'UNSUPPORTED_FEATURE', - message: 'The training agent does not execute split-task targeting criteria until the 3.2 SDK rollout; use schema fixtures for preview validation.', - field: criteria.targeting_overlay !== undefined - ? 'criteria.targeting_overlay' - : 'criteria.required_overlay_support', - }; - } if (isRecord(criteria?.catalog) && args.brand === undefined && !hasNaturalAccountBrand) { return { message: 'brand is required when catalog criteria are present', field: 'brand' }; } @@ -7031,6 +7503,7 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P const buyingMode = req.buying_mode ?? 'brief'; const proposalLifecycleWrite = buyingMode === 'refine' || (req as unknown as Record).__require_proposals === true; + const concreteTargetingWrite = isRecord((req as unknown as Record).targeting_overlay); const compactLifecycleWrite = proposalLifecycleWrite && (req as unknown as Record).__compact_proposal_lifecycle === true; const sessionScope = productDiscoverySessionKey(args, ctx); @@ -7066,7 +7539,7 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P }; } - if (!proposalLifecycleWrite) { + if (!proposalLifecycleWrite && !concreteTargetingWrite) { let directives: GetProductsReadDirectives = {}; try { const session = await getSession( @@ -7106,8 +7579,8 @@ export async function handleGetProducts(args: ToolArgs, ctx: TrainingContext): P try { if (compactLifecycleWrite) evictSessionFromRequestCache(sessionScope); const result = await handleGetProductsUnlocked(args, ctx, paginationOffset); - // Keep the mutex until every refine mutation is durable, not just proposal - // holds, so a following refine request observes the committed context. + // Keep the mutex until every proposal/configured-product mutation is + // durable so concurrent discovery cannot lose a returned configured ID. await flushDirtySessions(); return result; } finally { @@ -7240,8 +7713,7 @@ async function handleGetProductsUnlocked( } if (buyingMode !== 'wholesale') overlayNegotiatedPricingOptions(session, productMap); products = Array.from(productMap.values()); - const registryProducts = products; - + let registryProducts = [...products]; const requestedProductIds = Array.isArray((req as unknown as Record).product_ids) ? new Set(((req as unknown as Record).product_ids as unknown[]) .filter((id): id is string => typeof id === 'string')) @@ -7307,6 +7779,34 @@ async function handleGetProductsUnlocked( }); } } + const discoverySourceProductIds = new Map(); + const reusedConfiguredProductIds = new Set(); + const discoveryTargeting = applyDiscoveryTargeting( + products, + req, + session, + productDiscoverySessionKey(req, ctx), + discoverySourceProductIds, + reusedConfiguredProductIds, + ); + if (discoveryTargeting.capacityDrops > 0) { + return { + errors: [{ + code: 'LIMIT_EXCEEDED', + message: `Configured-product session limit reached (max ${MAX_CONFIGURED_PRODUCTS_PER_SESSION}); ${discoveryTargeting.capacityDrops} matching product(s) could not be configured. Start a new session or retry after prior offers expire.`, + field: 'targeting_overlay', + recovery: 'correctable', + details: { + limit: MAX_CONFIGURED_PRODUCTS_PER_SESSION, + dropped_products: discoveryTargeting.capacityDrops, + }, + }] as TaskError[], + }; + } + products = discoveryTargeting.products; + registryProducts = [...new Map( + [...registryProducts, ...products].map(product => [product.product_id, product]), + ).values()]; // Flexible-window availability discovery (offer_filters.availability_horizon) // is not an eligibility filter — products stay in the result set even when // the seller can only cover part of the horizon — so it is read here but @@ -7967,9 +8467,9 @@ async function handleGetProductsUnlocked( && exactProductIds?.size && [...exactProductIds].every(productId => session.complyExtensions.seededProducts.has(productId)) ) { - const requestedProducts = [...exactProductIds] - .map(productId => productsById.get(productId)) - .filter((product): product is Product => product !== undefined); + const requestedProducts = products.filter(product => ( + exactProductIds.has(discoverySourceProductIds.get(product.product_id) ?? product.product_id) + )); if (requestedProducts.length === exactProductIds.size) { const allocationPercentage = 100 / requestedProducts.length; const firstPricing = requestedProducts[0].pricing_options[0]; @@ -7994,14 +8494,16 @@ async function handleGetProductsUnlocked( } if (exactProductIds) { proposals = proposals.filter(proposal => proposal.allocations.every(allocation => ( - exactProductIds.has(allocation.product_id) + exactProductIds.has(discoverySourceProductIds.get(allocation.product_id) ?? allocation.product_id) ))); // Exact product selection asks the seller to quote those published // offers. Seeded/conformance products have no pre-authored proposal // catalog row, so construct one indicative plan before assigning the // caller-scoped immutable proposal ID below. if (proposals.length === 0) { - const selectedProducts = products.filter(product => exactProductIds.has(product.product_id)); + const selectedProducts = products.filter(product => ( + exactProductIds.has(discoverySourceProductIds.get(product.product_id) ?? product.product_id) + )); if (selectedProducts.length === exactProductIds.size && selectedProducts.length > 0) { const allocationPercentage = 100 / selectedProducts.length; proposals = [{ @@ -8082,8 +8584,9 @@ async function handleGetProductsUnlocked( __outcome_target_forecast: outcomeTargetPlan.forecast, }), } as unknown as Proposal; - return existingById.get(proposalId) - ?? withCanonicalProposalEnvelope( + const existing = existingById.get(proposalId); + if (existing) return existing; + return bindConfiguredTargetingToProposal(withCanonicalProposalEnvelope( draftProposalSnapshot(snapshot), productsById, { @@ -8094,7 +8597,7 @@ async function handleGetProductsUnlocked( ...(Array.isArray(requestBrand?.countries) && { countries: [...requestBrand.countries].filter(country => typeof country === 'string').sort() }), }, - ); + ), session); }); if (proposals.length === 0) { return { @@ -8193,7 +8696,18 @@ async function handleGetProductsUnlocked( return [successor]; }); } - products = applyAvailabilityHorizonForecasts(products, availabilityHorizon, session); + products = applyAvailabilityHorizonForecasts( + products, + availabilityHorizon, + session, + discoverySourceProductIds, + reusedConfiguredProductIds, + ); + for (const product of products) { + if (session.configuredProducts.has(product.product_id)) { + session.configuredProducts.set(product.product_id, structuredClone(product)); + } + } const canonicalFormatAdvisories = collectCanonicalFormatAdvisories(products); const staleDirective = readDirectives ? readDirectives.staleDirective @@ -10471,6 +10985,7 @@ export async function handleValidateInput(args: ToolArgs, ctx: TrainingContext): productsById.set(catalogProduct.product.product_id, { ...catalogProduct.product }); } overlaySeededProducts(session, productsById); + overlayConfiguredProducts(session, productsById); } const results: ValidateInputResult[] = await Promise.all(targets.map(target => { @@ -10764,6 +11279,7 @@ async function handleCreateMediaBuyUnlocked( const catalog = getCatalog(); const productMap = new Map(catalog.map(cp => [cp.product.product_id, cp.product])); overlaySeededProducts(session, productMap); + overlayConfiguredProducts(session, productMap); overlayNegotiatedPricingOptions(session, productMap); // Validate metric-kind optimization_goals against the package's product @@ -11054,6 +11570,10 @@ async function handleCreateMediaBuyUnlocked( }] as TaskError[], }; } + if (executedCompactProposalSession && executedCompactProposalSession !== session) { + overlayConfiguredProducts(executedCompactProposalSession, productMap); + overlayNegotiatedPricingOptions(executedCompactProposalSession, productMap); + } const internalProposal = proposal as unknown as Record; const compactProposal = typeof internalProposal.__brand_domain === 'string' @@ -11952,6 +12472,7 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon const catalog = getCatalog(); const productMap = new Map(catalog.map(cp => [cp.product.product_id, { ...cp.product }])); overlaySeededProducts(session, productMap); + overlayConfiguredProducts(session, productMap); overlayNegotiatedPricingOptions(session, productMap); const mediaBuyId = req.media_buy_id || req.media_buy_ids?.[0] || ''; const mb = session.mediaBuys.get(mediaBuyId) ?? getComplianceMediaBuy(mediaBuyId); @@ -12461,6 +12982,7 @@ export async function handleSyncCreatives(args: ToolArgs, ctx: TrainingContext) const effectivePolicy = aggregateCreativePolicy(session); const productValidationMap = new Map(getCatalog().map(cp => [cp.product.product_id, cp.product])); overlaySeededProducts(session, productValidationMap); + overlayConfiguredProducts(session, productValidationMap); const enforcedPolicies = aggregateEnforcedPolicies(session); const results: SyncCreativeResult[] = []; @@ -13085,6 +13607,7 @@ async function handleUpdateMediaBuyUnlocked( const productMap = new Map(getCatalog().map(cp => [cp.product.product_id, cp.product])); overlaySeededProducts(session, productMap); + overlayConfiguredProducts(session, productMap); const actionRejection = options.acceptedProposalExecution ? undefined @@ -15644,6 +16167,7 @@ function roundCurrency(value: number, currency: string): number { function effectiveProductMapForSession(session: SessionState): Map { const productMap = new Map(getCatalog().map(cp => [cp.product.product_id, { ...cp.product }])); overlaySeededProducts(session, productMap); + overlayConfiguredProducts(session, productMap); overlayNegotiatedPricingOptions(session, productMap); return productMap; } @@ -16712,8 +17236,10 @@ export async function handleBuyProducts( const catalog = new Map(getCatalog().map(entry => [entry.product.product_id, entry.product])); overlaySeededProducts(session, catalog); + overlayConfiguredProducts(session, catalog); const purchaseStartedAt = new Date().toISOString(); const canonicalPurchases: CompactProductPurchase[] = []; + const targetedPackageCounts = new Map(); for (let index = 0; index < args.purchases.length; index++) { const purchase = args.purchases[index]!; const product = catalog.get(purchase.product_id); @@ -16766,10 +17292,63 @@ export async function handleBuyProducts( }; } } + const boundTargeting = session.configuredProductTargeting.get(purchase.product_id); + const requestedTargeting = isRecord(purchase.targeting_overlay) + ? purchase.targeting_overlay + : undefined; + const overlaySupport = isRecord(productTerms.overlay_support) + ? productTerms.overlay_support + : {}; + if (requestedTargeting) { + for (const field of [ + 'geo_regions', 'geo_regions_exclude', + 'geo_metros', 'geo_metros_exclude', + 'geo_places', 'geo_places_exclude', + 'placement_selection', + ]) { + if (requestedTargeting[field] === undefined) continue; + const fieldSupport = overlaySupport[field]; + if (!isRecord(fieldSupport) || typeof fieldSupport.max_packages !== 'number') continue; + const limitKey = `${purchase.product_id}\0${field}`; + const count = (targetedPackageCounts.get(limitKey) ?? 0) + 1; + if (count > fieldSupport.max_packages) { + return { + errors: [{ + code: 'UNSUPPORTED_FEATURE', + message: `Purchase batch exceeds overlay_support.${field}.max_packages for this configured product.`, + field: `purchases[${index}].targeting_overlay.${field}`, + recovery: 'correctable', + details: { max_packages: fieldSupport.max_packages }, + }], + }; + } + targetedPackageCounts.set(limitKey, count); + } + } + const resolvedTargeting = resolveConfiguredPurchaseTargeting( + boundTargeting, + requestedTargeting, + overlaySupport, + `purchases[${index}].targeting_overlay`, + ); + if (resolvedTargeting.errorPath) { + return { + errors: [{ + code: 'UNSUPPORTED_FEATURE', + message: 'Purchase targeting must narrow targeting bound to the configured product or use a dimension declared in overlay_support.', + field: resolvedTargeting.errorPath, + recovery: 'correctable', + }], + }; + } + const effectiveTargeting = resolvedTargeting.targeting; const purchaseStartTime = purchase.start_time ?? args.start_time; canonicalPurchases.push({ ...structuredClone(purchase), pricing: canonicalPricing as unknown as CompactProductPurchase['pricing'], + ...(effectiveTargeting && { + targeting_overlay: effectiveTargeting as CompactProductPurchase['targeting_overlay'], + }), start_time: purchaseStartTime === 'asap' ? purchaseStartedAt : purchaseStartTime, end_time: purchase.end_time ?? args.end_time, ...(purchase.measurement_terms === undefined diff --git a/server/src/training-agent/tenants/registry.ts b/server/src/training-agent/tenants/registry.ts index c9ed99acdd..e80bd1541e 100644 --- a/server/src/training-agent/tenants/registry.ts +++ b/server/src/training-agent/tenants/registry.ts @@ -37,7 +37,7 @@ import { } from '@adcp/sdk/server'; import { getPool } from '../../db/client.js'; import { getSdkIdempotencyStore, scopedPrincipal } from '../idempotency.js'; -import { emitFrameworkTaskWebhook, getWebhookSigningMaterial } from '../webhooks.js'; +import { getWebhookSigningMaterial } from '../webhooks.js'; import { isWebhookTestOrDevelopment } from '../webhook-fetch.js'; import { buildSignalsTenantConfig } from './signals.js'; import { buildSalesTenantConfig } from './sales.js'; @@ -189,15 +189,11 @@ function buildDefaultServerOptions( return { name: 'adcp-training-agent', version: '1.0.0', - adcpVersion: storyboardCompat?.version === '3.0' ? '3.0' : '3.2-beta.4', + adcpVersion: storyboardCompat?.version === '3.0' ? '3.0' : '3.2-beta.5', idempotency: getSdkIdempotencyStore(), webhooks: getWebhookSigningMaterial(), - taskWebhookEmitter: { - emit: emitFrameworkTaskWebhook, - }, - // The SDK no longer emits webhooks for terminal inline responses by - // default. Preserve the training agent's existing integration contract - // while its consumers migrate to inline-terminal handling. + // Preserve terminal inline callbacks when supported by the SDK; actual + // task handoffs always use the durable framework emitter configured above. autoEmitCompletionWebhooks: true, taskRegistry, taskStore: sharedTrainingTaskStore, diff --git a/server/src/training-agent/tenants/router.ts b/server/src/training-agent/tenants/router.ts index 203ed81073..7d354b7e41 100644 --- a/server/src/training-agent/tenants/router.ts +++ b/server/src/training-agent/tenants/router.ts @@ -127,8 +127,8 @@ const SALES_CURRENT_SCENARIOS = [ 'evaluate_distributed_brand_resolution', ] as const; -const TRAINING_AGENT_SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.4'] as const; -const TRAINING_AGENT_CURRENT_ADCP_VERSION = '3.2-beta.4'; +const TRAINING_AGENT_SUPPORTED_RELEASE_VERSIONS = ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.5'] as const; +const TRAINING_AGENT_CURRENT_ADCP_VERSION = '3.2-beta.5'; const TRAINING_AGENT_DEFAULT_ADCP_VERSION = '3.0'; const PRODUCT_DISCOVERY_LIFECYCLE_TOOL_NAMES = [ 'list_products', @@ -744,8 +744,8 @@ function projectTenantCapabilities( }; if (tenantId === 'sales' && storyboardCompat?.version !== '3.0') { structured.adcp.capability_changes = { - capabilities_version: 'training-agent-3.2-beta.4', - last_modified: '2026-08-20T00:00:00.000Z', + capabilities_version: 'training-agent-3.2-beta.5', + last_modified: '2026-08-23T00:00:00.000Z', cache_ttl_seconds: 300, notifications: { supported: true, diff --git a/server/src/training-agent/tenants/tenant-smoke.test.ts b/server/src/training-agent/tenants/tenant-smoke.test.ts index db3013659b..6e69e45351 100644 --- a/server/src/training-agent/tenants/tenant-smoke.test.ts +++ b/server/src/training-agent/tenants/tenant-smoke.test.ts @@ -462,7 +462,7 @@ describe('tenant routing smoke', () => { ); const capabilitiesResponse = await callTenantTool(url, 3, 'get_adcp_capabilities', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, }) as { result?: { structuredContent?: { @@ -475,8 +475,8 @@ describe('tenant routing smoke', () => { }; } }; }; - expect(capabilitiesResponse.result?.structuredContent?.adcp_version).toBe('3.2-beta.4'); - expect(capabilitiesResponse.result?.structuredContent?.adcp?.supported_versions).toContain('3.2-beta.4'); + expect(capabilitiesResponse.result?.structuredContent?.adcp_version).toBe('3.2-beta.5'); + expect(capabilitiesResponse.result?.structuredContent?.adcp?.supported_versions).toContain('3.2-beta.5'); const mediaBuy = capabilitiesResponse.result?.structuredContent?.media_buy; expect(mediaBuy?.supports_proposals).toBe(true); expect(mediaBuy?.lifecycle_tools).toEqual([ @@ -521,7 +521,7 @@ describe('tenant routing smoke', () => { } const requested = await callTenantTool(url, 4, 'request_proposals', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, idempotency_key: 'tenant-profile-request-0001', account: { @@ -535,12 +535,12 @@ describe('tenant routing smoke', () => { proposals?: Array<{ proposal_id?: string }>; } }; }; - expect(requested.result?.structuredContent?.adcp_version).toBe('3.2-beta.4'); + expect(requested.result?.structuredContent?.adcp_version).toBe('3.2-beta.5'); const sourceProposalId = requested.result?.structuredContent?.proposals?.[0]?.proposal_id; expect(sourceProposalId, JSON.stringify(requested)).toBeTruthy(); const partial = await callTenantTool(url, 5, 'refine_proposals', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, idempotency_key: 'tenant-profile-refine-three-0001', account: { @@ -561,14 +561,14 @@ describe('tenant routing smoke', () => { } }; }; const counteroffer = partial.result?.structuredContent; - expect(counteroffer?.adcp_version).toBe('3.2-beta.4'); + expect(counteroffer?.adcp_version).toBe('3.2-beta.5'); expect(counteroffer?.adcp_error).toBeUndefined(); expect(counteroffer?.results?.[0]?.outcome, JSON.stringify(counteroffer)).toBe('partial'); expect(counteroffer?.results?.[0]?.reason_code).toBe('alternatives_unavailable'); expect(counteroffer?.results?.[0]?.proposals).toHaveLength(2); const refined = await callTenantTool(url, 6, 'refine_proposals', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, idempotency_key: 'tenant-profile-refine-two-0001', account: { @@ -589,7 +589,7 @@ describe('tenant routing smoke', () => { } }; }; const refinement = refined.result?.structuredContent; - expect(refinement?.adcp_version).toBe('3.2-beta.4'); + expect(refinement?.adcp_version).toBe('3.2-beta.5'); expect(refinement?.adcp_error).toBeUndefined(); expect(refinement?.results?.[0]?.outcome).toBe('revised'); expect(refinement?.results?.[0]?.proposals).toHaveLength(2); @@ -597,7 +597,7 @@ describe('tenant routing smoke', () => { const revisedProposalId = refinement?.results?.[0]?.proposals?.[0]?.proposal_id; expect(revisedProposalId).toEqual(expect.any(String)); const finalized = await callTenantTool(url, 7, 'refine_proposals', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, idempotency_key: 'tenant-profile-finalize-0001', refinements: [{ proposal_id: revisedProposalId, action: 'finalize' }], @@ -879,6 +879,7 @@ describe('tenant routing smoke', () => { }; const listed = await callTenantTool(url, 2, 'list_products', { account, + criteria: { targeting_overlay: { geo_countries: ['US'] } }, fields: ['pricing_options', 'format_options', 'measurement_terms', 'performance_standards'], }) as { result?: { structuredContent?: { @@ -933,7 +934,7 @@ describe('tenant routing smoke', () => { }; const commitment = bought.result?.structuredContent; expect(commitment?.adcp_error, JSON.stringify(commitment)).toBeUndefined(); - expect(commitment).toMatchObject({ + expect(commitment, JSON.stringify(commitment)).toMatchObject({ status: 'completed', media_buy_id: expect.any(String), revision: 1, @@ -1736,9 +1737,9 @@ describe('tenant routing smoke', () => { }; }; const mediaBuy = body.result?.structuredContent?.media_buy; - expect(body.result?.structuredContent?.adcp_version).toBe('3.2-beta.4'); + expect(body.result?.structuredContent?.adcp_version).toBe('3.2-beta.5'); expect(body.result?.structuredContent?.adcp?.major_versions).toContain(3); - expect(body.result?.structuredContent?.adcp?.supported_versions).toEqual(['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.4']); + expect(body.result?.structuredContent?.adcp?.supported_versions).toEqual(['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.5']); expect(mediaBuy?.features?.inline_creative_management).toBe(true); expect(mediaBuy?.supported_optimization_metrics).toContain('clicks'); expect(mediaBuy?.vendor_metric_optimization?.supported_targets).toContain('threshold_rate'); @@ -2642,7 +2643,7 @@ describe('tenant routing smoke', () => { field: 'adcp_version', details: { adcp_version: '4.0', - supported_versions: ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.4'], + supported_versions: ['3.0', '3.1-beta.5', '3.1-beta.7', '3.1-rc.4', '3.1-rc.6', '3.1-rc.7', '3.1-rc.8', '3.1-rc.9', '3.1-rc.10', '3.1-rc.14', '3.1-rc.15', '3.2-beta.5'], }, }); expect(unsupportedBody.result?.structuredContent?.context?.correlation_id).toBe('tenant-local-version-unsupported'); @@ -3043,7 +3044,7 @@ describe('tenant routing smoke', () => { }; const payload = { idempotency_key: 'tenant-products-idempotency-0001', - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', buying_mode: 'wholesale', account, }; @@ -3161,7 +3162,7 @@ describe('tenant routing smoke', () => { brand: account.brand, }) as { result?: { structuredContent?: { adcp_version?: string; products?: Array<{ product_id?: string }>; replayed?: boolean } } }; expect(aliasReplay.result?.structuredContent).not.toHaveProperty('adcp_error'); - expect(aliasReplay.result?.structuredContent?.adcp_version).toBe('3.2-beta.4'); + expect(aliasReplay.result?.structuredContent?.adcp_version).toBe('3.2-beta.5'); expect(aliasReplay.result?.structuredContent?.products?.map(product => product.product_id)) .toEqual(first.result?.structuredContent?.products?.map(product => product.product_id)); expect(aliasReplay.result?.structuredContent?.replayed).toBeUndefined(); @@ -3330,7 +3331,7 @@ describe('tenant routing smoke', () => { sandbox: true, }; const directive = await callTenantTool(url, 91, 'comply_test_controller', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account, scenario: 'force_get_products_arm', params: { @@ -3342,7 +3343,7 @@ describe('tenant routing smoke', () => { expect(directive.result?.structuredContent?.success).toBe(true); const rejected = await callTenantTool(url, 92, 'get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, idempotency_key: 'tenant-products-rejected-0001', buying_mode: 'brief', diff --git a/server/src/training-agent/types.ts b/server/src/training-agent/types.ts index 56156780e0..50c150fe88 100644 --- a/server/src/training-agent/types.ts +++ b/server/src/training-agent/types.ts @@ -409,6 +409,12 @@ export interface SessionState { productId: string; option: Product['pricing_options'][number]; }>; + /** Request-scoped configured offers minted by targeting-aware discovery. + * Kept resolvable for their advertised lifetime and for downstream direct + * purchase, creative validation, and delivery flows in the same account. */ + configuredProducts: Map; + /** Concrete discovery targeting bound to each configured product ID. */ + configuredProductTargeting: Map>; /** Durable proposal-successor receipts kept outside immutable proposal * snapshots. Finalization uses this to recover an exact idempotent retry * after domain state was flushed but before the idempotency receipt was diff --git a/server/src/training-agent/v6-sales-platform.ts b/server/src/training-agent/v6-sales-platform.ts index a299f53302..d99610b24c 100644 --- a/server/src/training-agent/v6-sales-platform.ts +++ b/server/src/training-agent/v6-sales-platform.ts @@ -22,6 +22,7 @@ import { type AudiencePlatform, type SyncAudiencesRow, type AudienceStatus, + type CreateMediaBuyHandlerResult, } from '@adcp/sdk/server'; import { packageRefsForFormatOptions, @@ -58,6 +59,8 @@ import { proposalCapabilitiesForProfile } from './proposal-negotiation-profiles. import { sessionKeyFromArgs } from './state.js'; import type { ToolArgs, TrainingContext } from './types.js'; import { accountScopeFromRef, canonicalizeAccountRef } from './account-scope.js'; +import { maybeEmitCompletionWebhook } from './webhooks.js'; +import { scopedPrincipal } from './idempotency.js'; interface TrainingSalesMeta { brand_domain?: string; @@ -214,6 +217,59 @@ function buildTrainingCtx( }; } +/** + * Derive the delivery partition for synchronous compatibility webhooks from + * framework-resolved state only. Request fields are buyer-controlled and must + * never select another caller's durable webhook namespace. + */ +function trustedWebhookPrincipal(ctx: { + account?: unknown; + authInfo?: { clientId?: string }; + callerMutationScope?: Readonly<{ tenant_id: string; principal_id: string; account_id?: string }>; +}): string { + const scope = ctx.callerMutationScope; + if (scope) { + return JSON.stringify([ + 'caller', + scope.tenant_id, + scope.principal_id, + scope.account_id ?? null, + ]); + } + const account = ctx.account as { + id?: unknown; + authInfo?: { principal?: unknown }; + ctx_metadata?: { account_ref?: unknown }; + } | undefined; + const accountId = account?.id; + if (typeof accountId !== 'string' || accountId.length === 0) { + throw new Error('create_media_buy completion webhook requires a framework-resolved caller or account scope'); + } + // RequestContext currently omits authInfo, but the trusted AccountStore + // projection retains the authenticated principal on the resolved account. + // Prefer a future first-class bridge when available and keep the account + // boundary in both cases. This mirrors the registry's scoped-principal + // delimiter and prevents two authenticated buyers using the same request + // key against one account from sharing a webhook delivery identity. + const authenticatedPrincipal = ctx.authInfo?.clientId ?? account?.authInfo?.principal; + if (typeof authenticatedPrincipal !== 'string' || authenticatedPrincipal.length === 0) { + throw new Error('create_media_buy completion webhook requires an authenticated principal'); + } + if (authenticatedPrincipal === 'static:public' || authenticatedPrincipal === 'static:public:shared') { + const ref = account?.ctx_metadata?.account_ref as { + account_id?: unknown; + brand?: { domain?: unknown }; + } | undefined; + const publicAccountScope = typeof ref?.account_id === 'string' + ? `a:${ref.account_id}` + : typeof ref?.brand?.domain === 'string' + ? `b:${ref.brand.domain.toLowerCase()}` + : `a:${accountId}`; + return scopedPrincipal(authenticatedPrincipal, publicAccountScope); + } + return scopedPrincipal(authenticatedPrincipal, `a:${accountId}`); +} + /** * Extract the brand domain from a resolved v6 Account so v5 handlers can * derive the correct session key via sessionKeyFromArgs. The v6 SDK resolves @@ -481,6 +537,47 @@ function canonicalMediaBuyPlatformResult(result: T): T { return next as T; } +/** + * Mirror the SDK's AdCP 3.0 create-media-buy response projection for the + * compatibility completion webhook. The platform consumes a canonical result, + * then the framework restores the selected legacy format tuple on the wire and + * adds the synchronous terminal status. Webhook `result` must be identical to + * that buyer-visible response, not either intermediate representation. + */ +function projectCreateMediaBuyCompatibilityWebhookResult( + legacyResult: Record, + canonicalResult: Record, +): Record { + const legacyPackages = Array.isArray(legacyResult.packages) ? legacyResult.packages : []; + const canonicalPackages = Array.isArray(canonicalResult.packages) ? canonicalResult.packages : []; + const packages = canonicalPackages.map((value, index) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const { + format_option_refs: _canonicalRefs, + __selected_legacy_format_ids: _canonicalSelected, + ...wirePackage + } = value as Record; + const legacyPackage = legacyPackages[index]; + const legacyRecord = legacyPackage && typeof legacyPackage === 'object' && !Array.isArray(legacyPackage) + ? legacyPackage as Record + : undefined; + const selected = Array.isArray(legacyRecord?.__selected_legacy_format_ids) + ? legacyRecord.__selected_legacy_format_ids + : Array.isArray(legacyRecord?.format_ids) + ? legacyRecord.format_ids + : undefined; + return { + ...wirePackage, + ...(selected !== undefined && { format_ids: selected }), + }; + }); + return { + ...canonicalResult, + status: 'completed', + ...(canonicalPackages.length > 0 && { packages }), + }; +} + /** * Synthetic-account constructor — same posture as the signals tenant. * v6 mandates `accounts.resolve()` on every request; we synthesize an @@ -710,7 +807,31 @@ export class TrainingSalesPlatform { task_id: submitted.task_id }, ); } - return translateV5Result(canonicalMediaBuyPlatformResult(v5Result)); + const platformResult = translateV5Result( + canonicalMediaBuyPlatformResult(v5Result), + ); + // SDK 14 intentionally keeps synchronous terminal responses silent in + // AdCP 3.2. Released 3.0 storyboards, however, require the historical + // inline completion callback. Emit only on that compatibility surface, + // through the same signed durable outbox used by all other training-agent + // webhooks. Submitted task handoffs return above and remain framework-owned. + if (this.storyboardCompat?.version === '3.0') { + const webhookArgs = args as unknown as Record; + const webhookResult = projectCreateMediaBuyCompatibilityWebhookResult( + v5Result as Record, + platformResult as unknown as Record, + ); + maybeEmitCompletionWebhook({ + toolName: 'create_media_buy', + args: webhookArgs, + response: webhookResult, + requestIdempotencyKey: typeof webhookArgs.idempotency_key === 'string' + ? webhookArgs.idempotency_key + : undefined, + principal: trustedWebhookPrincipal(ctx), + }); + } + return platformResult; }, updateMediaBuy: async (buyId, patch, ctx) => { diff --git a/server/src/training-agent/webhook-delivery-store.ts b/server/src/training-agent/webhook-delivery-store.ts new file mode 100644 index 0000000000..c01ac804d1 --- /dev/null +++ b/server/src/training-agent/webhook-delivery-store.ts @@ -0,0 +1,296 @@ +/** + * Durable publisher-side webhook delivery state for the training agent. + * + * The SDK deliberately separates the immutable delivery binding from the + * recoverable outbox snapshot. PostgreSQL supplies the authoritative clock + * and unique-key arbitration for the former; the latter is encrypted as one + * value so callback credentials (and any credential-like payload fields) + * never land in plaintext. + */ + +import { createHmac } from 'node:crypto'; +import type { + WebhookDeliveryKey, + WebhookDeliveryProposal, + WebhookDeliveryRecord, + WebhookDeliveryRecovery, + WebhookDeliverySnapshot, + WebhookDeliveryStore, + WebhookEmitParams, +} from '@adcp/sdk/server'; +import { query as databaseQuery } from '../db/client.js'; +import { decrypt, deriveKey, encrypt } from '../db/encryption.js'; + +interface QueryResultLike { + rows: T[]; + rowCount?: number | null; +} + +export type WebhookDeliveryQuery = ( + text: string, + params?: unknown[], +) => Promise>; + +const defaultWebhookDeliveryQuery: WebhookDeliveryQuery = async (text: string, params?: unknown[]) => { + const result = await databaseQuery(text, params as never[]); + return result as unknown as QueryResultLike; +}; + +interface BindingRow { + status: 'bound' | 'retired'; + idempotency_key: string | null; + payload_fingerprint: string | null; + first_attempt_at_ms: string | number | null; + retain_until_ms: string | number | null; +} + +interface RecoverableRow { + publisher_scope: string; + tenant_scope: string; + delivery_id: string; + snapshot_encrypted: string; + snapshot_iv: string; + created_at_ms: string | number; +} + +export interface RecoverableWebhookDelivery { + key: WebhookDeliveryKey; + params: WebhookEmitParams; + createdAtMs: number; +} + +// One drain can replay 25 deliveries sequentially, each with its original +// multi-attempt policy. Keep the cross-replica lease comfortably beyond that +// bounded work so a slow receiver cannot cause a second worker to overlap it. +const DEFAULT_LEASE_MS = 30 * 60_000; + +function snapshotSalt(key: Readonly): string { + const namespace = JSON.stringify([key.publisherScope, key.tenantScope, key.deliveryId]); + return `adcp-webhook-outbox:${createHmac('sha256', 'namespace-v1').update(namespace).digest('hex')}`; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Webhook recovery snapshots must contain only finite numbers'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; + } + throw new TypeError(`Webhook recovery snapshots cannot contain ${typeof value}`); +} + +function serializeSnapshot(snapshot: Readonly): string { + return canonicalJson(snapshot); +} + +function snapshotDigest(serialized: string, salt: string): string { + // A keyed digest proves exact-snapshot equality without leaving an offline + // guessing oracle for low-entropy bearer/HMAC credentials in the database. + return createHmac('sha256', deriveKey(salt)).update(serialized, 'utf8').digest('hex'); +} + +function asBinding(row: BindingRow): WebhookDeliveryRecord { + if (row.status === 'retired') return { status: 'retired' }; + if ( + typeof row.idempotency_key !== 'string' + || typeof row.payload_fingerprint !== 'string' + || row.first_attempt_at_ms === null + || row.retain_until_ms === null + ) { + throw new Error('Durable webhook delivery binding is incomplete'); + } + return { + status: 'bound', + idempotencyKey: row.idempotency_key, + payloadFingerprint: row.payload_fingerprint, + firstAttemptAtMs: Number(row.first_attempt_at_ms), + retainUntilMs: Number(row.retain_until_ms), + }; +} + +function assertSnapshot(value: unknown): WebhookDeliverySnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid durable webhook recovery snapshot'); + } + const snapshot = value as Partial; + if ( + typeof snapshot.url !== 'string' + || !snapshot.payload + || typeof snapshot.payload !== 'object' + || Array.isArray(snapshot.payload) + || !snapshot.retries + || typeof snapshot.retries !== 'object' + || !('authentication' in snapshot) + ) { + throw new Error('Invalid durable webhook recovery snapshot'); + } + return snapshot as WebhookDeliverySnapshot; +} + +/** Shared atomic binding store plus encrypted recovery outbox. */ +export class PostgresWebhookDeliveryPersistence implements WebhookDeliveryStore, WebhookDeliveryRecovery { + readonly durability = 'durable' as const; + + constructor(private readonly runQuery: WebhookDeliveryQuery = defaultWebhookDeliveryQuery) {} + + async claim( + key: Readonly, + proposed: Readonly, + retentionMs: number, + ): Promise { + if (!Number.isInteger(retentionMs) || retentionMs <= 0) { + throw new TypeError('Webhook delivery retentionMs must be a positive integer'); + } + const identity = [key.publisherScope, key.tenantScope, key.deliveryId]; + const inserted = await this.runQuery(` + INSERT INTO adcp_webhook_delivery_bindings ( + publisher_scope, tenant_scope, delivery_id, status, + idempotency_key, payload_fingerprint, first_attempt_at, retain_until + ) VALUES ($1, $2, $3, 'bound', $4, $5, NOW(), NOW() + ($6 * INTERVAL '1 millisecond')) + ON CONFLICT (publisher_scope, tenant_scope, delivery_id) DO NOTHING + RETURNING status, idempotency_key, payload_fingerprint, + EXTRACT(EPOCH FROM first_attempt_at) * 1000 AS first_attempt_at_ms, + EXTRACT(EPOCH FROM retain_until) * 1000 AS retain_until_ms + `, [...identity, proposed.idempotencyKey, proposed.payloadFingerprint, retentionMs]); + if (inserted.rows[0]) return asBinding(inserted.rows[0]); + + // Expiry never deletes a claimed identity. The first claimant after the + // retention boundary atomically turns it into its permanent tombstone. + const retired = await this.runQuery(` + UPDATE adcp_webhook_delivery_bindings + SET status = 'retired', idempotency_key = NULL, payload_fingerprint = NULL, + first_attempt_at = NULL, retain_until = NULL + WHERE publisher_scope = $1 AND tenant_scope = $2 AND delivery_id = $3 + AND status = 'bound' AND NOW() > retain_until + RETURNING status, idempotency_key, payload_fingerprint, + NULL::double precision AS first_attempt_at_ms, + NULL::double precision AS retain_until_ms + `, identity); + if (retired.rows[0]) return asBinding(retired.rows[0]); + + const existing = await this.runQuery(` + SELECT status, idempotency_key, payload_fingerprint, + EXTRACT(EPOCH FROM first_attempt_at) * 1000 AS first_attempt_at_ms, + EXTRACT(EPOCH FROM retain_until) * 1000 AS retain_until_ms + FROM adcp_webhook_delivery_bindings + WHERE publisher_scope = $1 AND tenant_scope = $2 AND delivery_id = $3 + `, identity); + if (!existing.rows[0]) throw new Error('Durable webhook delivery claim disappeared after conflict'); + return asBinding(existing.rows[0]); + } + + async checkpoint( + key: Readonly, + snapshot: Readonly, + ): Promise { + const salt = snapshotSalt(key); + const serialized = serializeSnapshot(snapshot); + const digest = snapshotDigest(serialized, salt); + const sealed = encrypt(serialized, salt); + const identity = [key.publisherScope, key.tenantScope, key.deliveryId]; + const inserted = await this.runQuery<{ snapshot_digest: string }>(` + INSERT INTO adcp_webhook_delivery_outbox ( + publisher_scope, tenant_scope, delivery_id, + snapshot_encrypted, snapshot_iv, snapshot_digest + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (publisher_scope, tenant_scope, delivery_id) DO NOTHING + RETURNING snapshot_digest + `, [...identity, sealed.encrypted, sealed.iv, digest]); + if (inserted.rows[0]) return; + + const existing = await this.runQuery<{ snapshot_digest: string }>(` + SELECT snapshot_digest + FROM adcp_webhook_delivery_outbox + WHERE publisher_scope = $1 AND tenant_scope = $2 AND delivery_id = $3 + `, identity); + if (!existing.rows[0]) { + // A concurrent successful delivery may settle between the insert and + // read. Re-checkpointing is safe: the immutable binding still prevents + // payload rebinding, and a receiver deduplicates the stable wire key. + return this.checkpoint(key, snapshot); + } + if (existing.rows[0].snapshot_digest !== digest) { + throw new Error(`Webhook delivery_id "${key.deliveryId}" was checkpointed with a different snapshot`); + } + } + + async settle( + key: Readonly, + _disposition: 'delivered' | 'terminal', + ): Promise { + await this.runQuery(` + DELETE FROM adcp_webhook_delivery_outbox + WHERE publisher_scope = $1 AND tenant_scope = $2 AND delivery_id = $3 + `, [key.publisherScope, key.tenantScope, key.deliveryId]); + } + + /** Lease pending snapshots atomically across Fly replicas. */ + async claimRecoverable( + publisherScope: string, + limit = 25, + leaseMs = DEFAULT_LEASE_MS, + ): Promise { + const result = await this.runQuery(` + WITH candidates AS ( + SELECT publisher_scope, tenant_scope, delivery_id + FROM adcp_webhook_delivery_outbox + WHERE publisher_scope = $1 + AND next_attempt_at <= NOW() + AND (lease_until IS NULL OR lease_until < NOW()) + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT $2 + ) + UPDATE adcp_webhook_delivery_outbox AS outbox + SET lease_until = NOW() + ($3 * INTERVAL '1 millisecond'), updated_at = NOW() + FROM candidates + WHERE outbox.publisher_scope = candidates.publisher_scope + AND outbox.tenant_scope = candidates.tenant_scope + AND outbox.delivery_id = candidates.delivery_id + RETURNING outbox.publisher_scope, outbox.tenant_scope, outbox.delivery_id, + outbox.snapshot_encrypted, outbox.snapshot_iv, + EXTRACT(EPOCH FROM outbox.created_at) * 1000 AS created_at_ms + `, [publisherScope, limit, leaseMs]); + + return result.rows.map(row => { + const key: WebhookDeliveryKey = { + publisherScope: row.publisher_scope, + tenantScope: row.tenant_scope, + deliveryId: row.delivery_id, + }; + const serialized = decrypt(row.snapshot_encrypted, row.snapshot_iv, snapshotSalt(key)); + const snapshot = assertSnapshot(JSON.parse(serialized)); + return { + key, + params: { + url: snapshot.url, + payload: snapshot.payload, + delivery_id: key.deliveryId, + authentication: snapshot.authentication, + retries: snapshot.retries, + }, + createdAtMs: Number(row.created_at_ms), + }; + }); + } + + async releaseRecoverable( + key: Readonly, + retryDelayMs: number, + ): Promise { + await this.runQuery(` + UPDATE adcp_webhook_delivery_outbox + SET lease_until = NULL, + next_attempt_at = NOW() + ($4 * INTERVAL '1 millisecond'), + attempt_count = attempt_count + 1, + updated_at = NOW() + WHERE publisher_scope = $1 AND tenant_scope = $2 AND delivery_id = $3 + `, [key.publisherScope, key.tenantScope, key.deliveryId, retryDelayMs]); + } +} diff --git a/server/src/training-agent/webhooks.ts b/server/src/training-agent/webhooks.ts index 7ad2a7efc3..5bbbe529b8 100644 --- a/server/src/training-agent/webhooks.ts +++ b/server/src/training-agent/webhooks.ts @@ -14,21 +14,25 @@ import { createHash, createPublicKey, generateKeyPairSync, randomUUID } from 'node:crypto'; import { createWebhookEmitter, - memoryWebhookKeyStore, + memoryWebhookDeliveryStore, type WebhookEmitter, type WebhookAuthentication, + type WebhookDeliveryRecovery, + type WebhookDeliveryStore, type WebhookEmitParams, type WebhookEmitResult, } from '@adcp/sdk/server'; import type { SignerKey, SigningProvider } from '@adcp/sdk/signing'; import type { AdcpJsonWebKey } from '@adcp/sdk/signing'; import { createLogger } from '../logger.js'; +import { isDatabaseInitialized } from '../db/client.js'; import { createTrainingWebhookFetch } from './webhook-fetch.js'; import { getWebhookSigningProvider } from '../security/gcp-kms-signer.js'; import { WEBHOOK_SIGNING_KID, WEBHOOK_SIGNING_PUBLIC_KEY_PEM, } from '../security/expected-public-key.js'; +import { PostgresWebhookDeliveryPersistence } from './webhook-delivery-store.js'; const logger = createLogger('training-agent-webhooks'); @@ -154,37 +158,44 @@ function extractWebhookAuthentication(args: Record): WebhookAut return undefined; } -/** Derive a stable scope key for the **webhook idempotency-key store** — - * NOT the wire-level `operation_id`. Two emissions with the same scope key - * reuse the same `idempotency_key` across retries. Prefers a buyer-facing - * entity id from the response so retries from the same buyer collapse; - * falls back to the request's idempotency_key. +/** Derive a stable scope key for the **webhook delivery store** — NOT the + * wire-level `operation_id`. Two emissions with the same scope key reuse the + * same payload `idempotency_key` across retries. Prefers the request's + * idempotency key because it names one logical fire; falls back to a + * buyer-facing response entity only when the request has no such identity. * * Scoped by the caller's principal so two buyers sharing the public sandbox * token who happen to land on the same deterministic response entity id * (e.g. both get `mb_abc123`) produce distinct webhook idempotency_keys. - * Without the prefix, a receiver that dedupes across tenants on + * Without the principal input, a receiver that dedupes across tenants on * `idempotency_key` would drop the second buyer's event as a duplicate of * the first. The principal is the same scoped string the request-side * idempotency cache uses (`scopedPrincipal(auth, accountScope)`), so both * caches partition identically. * - * This value is **never** placed on the wire — it embeds the seller-side - * principal token and is used only to key the idempotency-key store. The - * wire `operation_id` field comes from the buyer-supplied - * `push_notification_config.operation_id` (see `extractBuyerOperationId`). */ + * The returned delivery ID is an opaque digest: neither the seller-side + * principal nor the request key is persisted or placed on the wire. The + * wire `operation_id` field comes from the buyer-supplied configuration + * (see `extractBuyerOperationId`). */ export function deriveWebhookIdempotencyScope( toolName: string, response: Record, requestIdempotencyKey: string | undefined, principal: string, ): string { + const opaqueDeliveryId = (kind: string, value: string): string => `whd_${createHash('sha256') + .update(JSON.stringify([principal, toolName, kind, value]), 'utf8') + .digest('hex')}`; + // A request idempotency key identifies the exact logical fire. Prefer it + // over response entity IDs: two intentional updates to one media buy must + // not collide merely because both responses contain the same media_buy_id. + // Exact request retries, conversely, retain one delivery identity. + if (requestIdempotencyKey) return opaqueDeliveryId('request', requestIdempotencyKey); for (const field of ['media_buy_id', 'creative_id', 'activation_id', 'signal_activation_id', 'task_id', 'list_id', 'account_id']) { const v = response[field]; - if (typeof v === 'string' && v.length > 0) return `${principal}|${toolName}.${v}`; + if (typeof v === 'string' && v.length > 0) return opaqueDeliveryId(field, v); } - if (requestIdempotencyKey) return `${principal}|${toolName}.${requestIdempotencyKey}`; - return `${principal}|${toolName}.${randomUUID()}`; + return opaqueDeliveryId('random', randomUUID()); } /** @@ -221,10 +232,10 @@ export function maybeEmitCompletionWebhook(opts: { if (!webhookUrl || !(opts.toolName in TOOL_TO_TASK_TYPE)) return; const tool = opts.toolName as WebhookEmittingTool; - const emitter = getWebhookEmitter(); + const emitter = getWebhookEmitter().forTenantScope(tenantScopeFromTrustedValue(opts.principal)); const idempotencyScope = deriveWebhookIdempotencyScope(opts.toolName, opts.response, opts.requestIdempotencyKey, opts.principal); const webhookTaskId = (opts.response.task_id as string | undefined) - ?? `tsk_${idempotencyScope.slice(0, 32).replace(/[^A-Za-z0-9_.:-]/g, '_')}`; + ?? `tsk_${idempotencyScope.slice(4, 36)}`; // Wire `operation_id` MUST be the buyer-supplied value. When the buyer // registers without one (non-conformant per push-notification-config.json, // but tolerated for sandbox testing), fall back to `task_id` — a buyer- @@ -247,7 +258,7 @@ export function maybeEmitCompletionWebhook(opts: { void emitter.emit({ url: webhookUrl, payload, - operation_id: idempotencyScope, + delivery_id: idempotencyScope, ...(authentication !== undefined && { authentication }), }) .catch(err => logger.warn({ err, tool: opts.toolName, url: webhookUrl }, 'Webhook emission failed')); @@ -260,11 +271,13 @@ export async function emitAccountNotificationWebhook(opts: { notificationType: string; authentication?: WebhookAuthentication; }): Promise { - const emitter = getWebhookEmitter(); + const emitter = getWebhookEmitter().forTenantScope(tenantScopeFromTrustedValue( + `${opts.notificationType}:${opts.operationId.split(':', 1)[0] ?? 'unknown'}`, + )); return emitter.emit({ url: opts.url, payload: opts.payload, - operation_id: opts.operationId, + delivery_id: opts.operationId, ...(opts.authentication !== undefined && { authentication: opts.authentication }), }); } @@ -300,10 +313,10 @@ export function emitPropertyListChangedWebhook(opts: PropertyListChangedWebhookP signature: 'rfc9421', }; - void getWebhookEmitter().emit({ + void getWebhookEmitter().forTenantScope('property-list-notifications').emit({ url: opts.url, payload, - operation_id: opts.operationId, + delivery_id: opts.operationId, }).catch(err => logger.warn( { err, listId: opts.listId, url: opts.url }, 'Property-list change webhook emission failed', @@ -319,6 +332,27 @@ type WebhookMaterial = let material: WebhookMaterial | null = null; let emitter: WebhookEmitter | null = null; +let durablePersistence: PostgresWebhookDeliveryPersistence | null = null; +let recoveryTimer: ReturnType | null = null; +let recoveryRunning = false; + +const WEBHOOK_PUBLISHER_SCOPE = 'adcp-training-agent'; +const DEFAULT_WEBHOOK_TENANT_SCOPE = 'training-agent-system'; +const DELIVERY_RETRY_HORIZON_SECONDS = 86_400; +const RECOVERY_POLL_MS = 60_000; + +function requiresDurableWebhookState(): boolean { + return process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'development'; +} + +function getDurablePersistence(): PostgresWebhookDeliveryPersistence { + durablePersistence ??= new PostgresWebhookDeliveryPersistence(); + return durablePersistence; +} + +function tenantScopeFromTrustedValue(value: string): string { + return `tenant-${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} function generateEphemeralKey(): { signer: SignerKey; publicJwk: AdcpJsonWebKey } { const { publicKey, privateKey } = generateKeyPairSync('ed25519'); @@ -455,12 +489,94 @@ export function getPublicJwks(): { keys: AdcpJsonWebKey[] } { /** Expose the webhook signer to framework-server config — exactly one of * `signerKey` or `signerProvider` per the SDK's discriminated config. */ export function getWebhookSigningMaterial(): - | { signerKey: SignerKey } - | { signerProvider: SigningProvider } { + | ({ signerKey: SignerKey } & { + publisherScope?: string; + deliveryStore?: WebhookDeliveryStore; + deliveryRecovery?: WebhookDeliveryRecovery; + deliveryRetryHorizonSeconds?: number; + fetch?: typeof fetch; + userAgent?: string; + }) + | ({ signerProvider: SigningProvider } & { + publisherScope?: string; + deliveryStore?: WebhookDeliveryStore; + deliveryRecovery?: WebhookDeliveryRecovery; + deliveryRetryHorizonSeconds?: number; + fetch?: typeof fetch; + userAgent?: string; + }) { const m = ensureMaterial(); + const durableConfig = requiresDurableWebhookState() + ? { + publisherScope: WEBHOOK_PUBLISHER_SCOPE, + deliveryStore: getDurablePersistence(), + deliveryRecovery: getDurablePersistence(), + deliveryRetryHorizonSeconds: DELIVERY_RETRY_HORIZON_SECONDS, + } + : {}; + const emitterConfig = { + ...durableConfig, + userAgent: 'adcp-training-agent/1.0', + fetch: createTrainingWebhookFetch(), + }; + if (requiresDurableWebhookState() && !emitter) { + // Tenant servers are constructed before the database pool. Queue the + // recovery emitter so it exists for restart replay without making tenant + // registration perform I/O; the worker waits for DB initialization. + queueMicrotask(() => { getWebhookEmitter(); }); + } return m.kind === 'kms' - ? { signerProvider: m.signerProvider } - : { signerKey: m.signerKey }; + ? { signerProvider: m.signerProvider, ...emitterConfig } + : { signerKey: m.signerKey, ...emitterConfig }; +} + +async function recoverPendingWebhookDeliveries(): Promise { + if (!emitter || !durablePersistence || recoveryRunning || !isDatabaseInitialized()) return; + recoveryRunning = true; + try { + const deliveries = await durablePersistence.claimRecoverable(WEBHOOK_PUBLISHER_SCOPE); + for (const delivery of deliveries) { + try { + const result = await emitter.forTenantScope(delivery.key.tenantScope).emit(delivery.params); + if (!result.delivered) { + const horizonEnd = delivery.createdAtMs + DELIVERY_RETRY_HORIZON_SECONDS * 1_000; + if (Date.now() >= horizonEnd) { + await durablePersistence.settle(delivery.key, 'terminal'); + logger.warn( + { deliveryId: delivery.key.deliveryId }, + 'Webhook recovery horizon elapsed; terminalized pending delivery', + ); + } else { + await durablePersistence.releaseRecoverable(delivery.key, RECOVERY_POLL_MS); + } + } + } catch (err) { + const horizonEnd = delivery.createdAtMs + DELIVERY_RETRY_HORIZON_SECONDS * 1_000; + if (Date.now() >= horizonEnd) { + await durablePersistence.settle(delivery.key, 'terminal'); + } else { + await durablePersistence.releaseRecoverable(delivery.key, RECOVERY_POLL_MS); + } + logger.warn( + { err, deliveryId: delivery.key.deliveryId }, + 'Pending webhook recovery attempt failed', + ); + } + } + } catch (err) { + logger.warn({ err }, 'Webhook recovery outbox scan failed'); + } finally { + recoveryRunning = false; + } +} + +function startWebhookRecoveryWorker(): void { + if (!requiresDurableWebhookState() || recoveryTimer) return; + void recoverPendingWebhookDeliveries(); + recoveryTimer = setInterval(() => { + void recoverPendingWebhookDeliveries(); + }, RECOVERY_POLL_MS); + recoveryTimer.unref?.(); } /** Return the only training-agent webhook emitter. @@ -472,18 +588,24 @@ export function getWebhookSigningMaterial(): export function getWebhookEmitter(): WebhookEmitter { if (emitter) return emitter; const m = ensureMaterial(); + const durable = requiresDurableWebhookState() ? getDurablePersistence() : undefined; emitter = createWebhookEmitter({ ...(m.kind === 'kms' ? { signerProvider: m.signerProvider } : { signerKey: m.signerKey }), - idempotencyKeyStore: memoryWebhookKeyStore(), + deliveryStore: durable ?? memoryWebhookDeliveryStore(), + ...(durable !== undefined && { deliveryRecovery: durable }), + publisherScope: WEBHOOK_PUBLISHER_SCOPE, + tenantScope: DEFAULT_WEBHOOK_TENANT_SCOPE, + deliveryRetryHorizonSeconds: DELIVERY_RETRY_HORIZON_SECONDS, userAgent: 'adcp-training-agent/1.0', fetch: createTrainingWebhookFetch(), }); + startWebhookRecoveryWorker(); return emitter; } export async function emitFrameworkTaskWebhook(params: WebhookEmitParams): Promise { const taskId = typeof params.payload.task_id === 'string' ? params.payload.task_id : undefined; - return getWebhookEmitter().emit({ + return getWebhookEmitter().forTenantScope('framework-task-notifications').emit({ ...params, payload: { ...params.payload, @@ -494,6 +616,10 @@ export async function emitFrameworkTaskWebhook(params: WebhookEmitParams): Promi /** Reset state — tests only. */ export function resetWebhookSigning(): void { + if (recoveryTimer) clearInterval(recoveryTimer); + recoveryTimer = null; + recoveryRunning = false; material = null; emitter = null; + durablePersistence = null; } diff --git a/server/tests/integration/training-agent-3-0-compat-tools.test.ts b/server/tests/integration/training-agent-3-0-compat-tools.test.ts index bc07529da6..f815b20a0f 100644 --- a/server/tests/integration/training-agent-3-0-compat-tools.test.ts +++ b/server/tests/integration/training-agent-3-0-compat-tools.test.ts @@ -21,7 +21,7 @@ const { stopSessionCleanup } = await import('../../src/training-agent/state.js') const COMPAT_CTX = { mode: 'open' as const, storyboardCompat: { version: '3.0' as const } }; const AUTH = 'Bearer compat-tools-token'; -const CURRENT_ADCP_VERSION = '3.2-beta.4'; +const CURRENT_ADCP_VERSION = '3.2-beta.5'; async function simulateListTools(server: ReturnType): Promise { const requestHandlers = (server as any)._requestHandlers as Map; diff --git a/server/tests/integration/training-agent-webhooks.test.ts b/server/tests/integration/training-agent-webhooks.test.ts index 0cb9b6799d..a350013239 100644 --- a/server/tests/integration/training-agent-webhooks.test.ts +++ b/server/tests/integration/training-agent-webhooks.test.ts @@ -105,48 +105,47 @@ describe('Training Agent webhook emission', () => { const deliveries: CapturedDelivery[] = []; let srv: http.Server | undefined; try { - const done = new Promise(resolve => { - startReceiver((d, res) => { - deliveries.push(d); - res.writeHead(200); res.end(); - resolve(); - }).then(s => { - srv = s; - const addr = s.address() as AddressInfo; - const webhookUrl = `http://127.0.0.1:${addr.port}/hook/create_media_buy`; - const catalog = buildCatalog(); - const product = catalog[0].product as { product_id: string; pricing_options: Array<{ pricing_option_id: string }> }; - return request(app) - .post('/api/training-agent/sales/mcp') - .set('Authorization', AUTH) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json, text/event-stream') - .send({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'create_media_buy', - arguments: { - idempotency_key: randomUUID(), - adcp_major_version: 3, - account: { brand: { domain: 'webhook-test.example' }, operator: 'webhook-test.example' }, - brand: { domain: 'webhook-test.example' }, - start_time: '2027-06-01T00:00:00Z', - end_time: '2027-07-01T00:00:00Z', - packages: [{ - product_id: product.product_id, - pricing_option_id: product.pricing_options[0].pricing_option_id, - budget: 50000, - start_time: '2027-06-01T00:00:00Z', - end_time: '2027-07-01T00:00:00Z', - }], - push_notification_config: { url: webhookUrl, operation_id: 'op_completion_webhook' }, - }, - }, - }); - }); + let resolveDelivery!: () => void; + const done = new Promise(resolve => { resolveDelivery = resolve; }); + srv = await startReceiver((d, res) => { + deliveries.push(d); + res.writeHead(200); res.end(); + resolveDelivery(); }); + const addr = srv.address() as AddressInfo; + const webhookUrl = `http://127.0.0.1:${addr.port}/hook/create_media_buy`; + const catalog = buildCatalog(); + const product = catalog[0].product as { product_id: string; pricing_options: Array<{ pricing_option_id: string }> }; + const toolResponse = await request(app) + .post('/api/training-agent/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'create_media_buy', + arguments: { + idempotency_key: randomUUID(), + adcp_major_version: 3, + account: { brand: { domain: 'webhook-test.example' }, operator: 'webhook-test.example' }, + brand: { domain: 'webhook-test.example' }, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + packages: [{ + product_id: product.product_id, + pricing_option_id: product.pricing_options[0].pricing_option_id, + budget: 50000, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + }], + push_notification_config: { url: webhookUrl, operation_id: 'op_completion_webhook' }, + }, + }, + }); + expect(structuredToolResult(toolResponse)).not.toHaveProperty('adcp_error'); await Promise.race([ done, @@ -185,6 +184,220 @@ describe('Training Agent webhook emission', () => { } }, 20000); + it('preserves 3.0 /sales inline create_media_buy completion webhooks', async () => { + const compatApp = express(); + compatApp.use(express.json()); + compatApp.use('/api/training-agent', createTrainingAgentRouter({ + storyboardCompat: { version: '3.0' }, + })); + const deliveries: CapturedDelivery[] = []; + let srv: http.Server | undefined; + try { + let resolveDelivery!: () => void; + const delivered = new Promise(resolve => { resolveDelivery = resolve; }); + srv = await startReceiver((delivery, res) => { + deliveries.push(delivery); + res.writeHead(200); + res.end(); + resolveDelivery(); + }); + const addr = srv.address() as AddressInfo; + const webhookUrl = `http://127.0.0.1:${addr.port}/hook/three-zero-create`; + const account = { + brand: { domain: 'three-zero-webhook.example' }, + operator: 'pinnacle-agency.example', + sandbox: true, + }; + const productId = 'three_zero_webhook_legacy_product'; + const pricingOptionId = 'three_zero_webhook_legacy_cpm'; + const legacyFormat = { + agent_url: 'https://creative.adcontextprotocol.org/', + id: 'display_300x250_image', + width: 300, + height: 250, + }; + const callTool = (id: number, name: string, args: Record) => request(compatApp) + .post('/api/training-agent/sales/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }); + await callTool(28, 'comply_test_controller', { + adcp_version: '3.0', + account, + brand: account.brand, + scenario: 'seed_product', + params: { + product_id: productId, + fixture: { + name: 'Webhook legacy product', + description: 'Exercises callback wire projection', + delivery_type: 'guaranteed', + channels: ['display'], + format_ids: [legacyFormat], + }, + }, + }); + await callTool(29, 'comply_test_controller', { + adcp_version: '3.0', + account, + brand: account.brand, + scenario: 'seed_pricing_option', + params: { + product_id: productId, + pricing_option_id: pricingOptionId, + fixture: { pricing_model: 'cpm', currency: 'USD', fixed_price: 12 }, + }, + }); + const idempotencyKey = `three-zero-webhook-${randomUUID()}`; + const response = await request(compatApp) + .post('/api/training-agent/sales/mcp') + .set('Authorization', AUTH) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id: 30, + method: 'tools/call', + params: { + name: 'create_media_buy', + arguments: { + adcp_version: '3.0', + idempotency_key: idempotencyKey, + account, + brand: account.brand, + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-07-01T00:00:00Z', + packages: [{ + product_id: productId, + pricing_option_id: pricingOptionId, + budget: 50_000, + format_ids: [legacyFormat], + }], + push_notification_config: { + url: webhookUrl, + operation_id: 'op_three_zero_create', + }, + }, + }, + }); + + const result = structuredToolResult(response); + expect(response.status).toBe(200); + expect(result).not.toHaveProperty('adcp_error'); + expect(result.media_buy_id).toEqual(expect.any(String)); + expect((result.packages as Array>)[0].format_ids) + .toEqual([legacyFormat]); + await Promise.race([ + delivered, + new Promise((_, reject) => setTimeout( + () => reject(new Error('3.0 inline completion webhook never arrived')), + 10_000, + )), + ]); + + expect(deliveries).toHaveLength(1); + const payload = JSON.parse(deliveries[0].body) as Record; + expect(payload).toMatchObject({ + operation_id: 'op_three_zero_create', + task_type: 'create_media_buy', + protocol: 'media-buy', + status: 'completed', + }); + expect(payload.result).toEqual(result); + expect(payload.idempotency_key).toMatch(/^[A-Za-z0-9_.:-]{16,255}$/); + expect(deliveries[0].headers['signature-input']).toBeDefined(); + expect(deliveries[0].headers.signature).toBeDefined(); + } finally { + if (srv) { + srv.closeAllConnections?.(); + await new Promise(resolve => srv!.close(() => resolve())); + } + } + }, 20000); + + it('partitions 3.0 delivery identity by authenticated caller on the same account and request key', async () => { + const compatApp = express(); + compatApp.use(express.json()); + compatApp.use('/api/training-agent', createTrainingAgentRouter({ + storyboardCompat: { version: '3.0' }, + })); + const deliveries: CapturedDelivery[] = []; + let srv: http.Server | undefined; + try { + let resolveDeliveries!: () => void; + const delivered = new Promise(resolve => { resolveDeliveries = resolve; }); + srv = await startReceiver((delivery, res) => { + deliveries.push(delivery); + res.writeHead(200); + res.end(); + if (deliveries.length === 2) resolveDeliveries(); + }); + const addr = srv.address() as AddressInfo; + const webhookUrl = `http://127.0.0.1:${addr.port}/hook/three-zero-callers`; + const product = buildCatalog()[0].product as { + product_id: string; + pricing_options: Array<{ pricing_option_id: string }>; + }; + const idempotencyKey = `shared-caller-key-${randomUUID()}`; + const toolArguments = { + adcp_version: '3.0', + idempotency_key: idempotencyKey, + account: { + brand: { domain: 'shared-account-webhook.example' }, + operator: 'pinnacle-agency.example', + }, + brand: { domain: 'shared-account-webhook.example' }, + start_time: '2027-08-01T00:00:00Z', + end_time: '2027-09-01T00:00:00Z', + packages: [{ + product_id: product.product_id, + pricing_option_id: product.pricing_options[0].pricing_option_id, + budget: 50_000, + }], + push_notification_config: { + url: webhookUrl, + operation_id: 'op_shared_account_key', + }, + }; + const callAs = (authorization: string, id: number) => request(compatApp) + .post('/api/training-agent/sales/mcp') + .set('Authorization', authorization) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'create_media_buy', arguments: toolArguments }, + }); + + const [first, second] = await Promise.all([ + callAs(BILLABLE_AUTH, 31), + callAs(OTHER_BILLABLE_AUTH, 32), + ]); + expect(structuredToolResult(first)).not.toHaveProperty('adcp_error'); + expect(structuredToolResult(second)).not.toHaveProperty('adcp_error'); + await Promise.race([ + delivered, + new Promise((_, reject) => setTimeout( + () => reject(new Error('caller-partitioned 3.0 webhooks never arrived')), + 10_000, + )), + ]); + + expect(deliveries).toHaveLength(2); + const payloads = deliveries.map(delivery => JSON.parse(delivery.body) as Record); + expect(new Set(payloads.map(payload => payload.idempotency_key)).size).toBe(2); + expect(payloads.every(payload => payload.operation_id === 'op_shared_account_key')).toBe(true); + } finally { + if (srv) { + srv.closeAllConnections?.(); + await new Promise(resolve => srv!.close(() => resolve())); + } + } + }, 20000); + it('emits token-correlated callbacks across the split proposal lifecycle', async () => { const deliveries: CapturedDelivery[] = []; let srv: http.Server | undefined; @@ -200,7 +413,7 @@ describe('Training Agent webhook emission', () => { const addr = srv.address() as AddressInfo; const webhookUrl = `http://127.0.0.1:${addr.port}/hook/split-proposals`; const call = (name: string, args: Record) => request(app) - .post('/api/training-agent/sales/mcp') + .post('/api/training-agent/mcp') .set('Authorization', AUTH) .set('Content-Type', 'application/json') .set('Accept', 'application/json, text/event-stream') @@ -348,7 +561,7 @@ describe('Training Agent webhook emission', () => { const catalog = buildCatalog(); const product = catalog[0].product as { product_id: string; pricing_options: Array<{ pricing_option_id: string }> }; return request(app) - .post('/api/training-agent/sales/mcp') + .post('/api/training-agent/mcp') .set('Authorization', BILLABLE_AUTH) .set('Content-Type', 'application/json') .set('Accept', 'application/json, text/event-stream') @@ -412,7 +625,7 @@ describe('Training Agent webhook emission', () => { await emitFrameworkTaskWebhook({ url: `http://127.0.0.1:${addr.port}/hook/framework-fallback`, - operation_id: unsafeScope, + delivery_id: unsafeScope, payload: { task_id: 'tsk_framework_fallback', task_type: 'create_media_buy', diff --git a/server/tests/unit/comply-test-controller.test.ts b/server/tests/unit/comply-test-controller.test.ts index b45b66fd4c..0cec2036aa 100644 --- a/server/tests/unit/comply-test-controller.test.ts +++ b/server/tests/unit/comply-test-controller.test.ts @@ -226,7 +226,7 @@ describe('comply_test_controller', () => { it('advertises force_get_products_arm for the 3.2 beta release', async () => { const { result } = await simulateCallTool(server, 'comply_test_controller', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', adcp_major_version: 3, scenario: 'list_scenarios', account: ACCOUNT, @@ -1126,7 +1126,7 @@ describe('comply_test_controller', () => { }); const rejectedCompactControl = await simulateCallTool(server, 'control_media_buy', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: ACCOUNT, media_buy_id: 'created_from_allowed_actions', revision: updated.revision, diff --git a/server/tests/unit/idempotency-store.test.ts b/server/tests/unit/idempotency-store.test.ts index a3e33d7069..a89ca7b8b6 100644 --- a/server/tests/unit/idempotency-store.test.ts +++ b/server/tests/unit/idempotency-store.test.ts @@ -52,6 +52,38 @@ describe('hash-aware training-agent idempotency store', () => { await store.close(); }); + it('renews an owned claim without changing its request hash', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-09T12:00:00Z')); + const backend = memoryBackend({ sweepIntervalMs: 0 }); + const store = createHashAwareIdempotencyStore({ backend }); + const payload = { operation: 'long-running' }; + + const claim = await store.check({ principal: PRINCIPAL, key: KEY, payload }); + if (claim.kind !== 'miss') throw new Error('expected initial claim'); + await vi.advanceTimersByTimeAsync(100_000); + await store.renew({ + principal: PRINCIPAL, + key: KEY, + claimToken: claim.claimToken, + }); + + expect(await backend.get(SCOPED_KEY)).toMatchObject({ + payloadHash: hashPayload(payload), + expiresAt: Math.floor(Date.now() / 1e3) + 120, + retainUntil: Math.floor(Date.now() / 1e3) + 180, + }); + expect(await store.check({ principal: PRINCIPAL, key: KEY, payload })).toMatchObject({ + kind: 'in-flight', + }); + expect(await store.check({ principal: PRINCIPAL, key: KEY, payload: { operation: 'other' } })).toEqual({ + kind: 'conflict', + }); + + await store.release({ principal: PRINCIPAL, key: KEY, claimToken: claim.claimToken }); + await store.close(); + }); + it('preserves expiry skew and the save, release, and transient-error lifecycle', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-09T12:00:00Z')); @@ -87,6 +119,11 @@ describe('hash-aware training-agent idempotency store', () => { expect(releasable.kind).toBe('miss'); if (releasable.kind !== 'miss') throw new Error('expected releasable claim'); await store.release({ principal: PRINCIPAL, key: releaseKey, claimToken: releasable.claimToken }); + expect(await store.check({ + principal: PRINCIPAL, + key: releaseKey, + payload: { operation: 'changed-after-release' }, + })).toEqual({ kind: 'conflict' }); const reclaimed = await store.check({ principal: PRINCIPAL, key: releaseKey, payload }); expect(reclaimed.kind).toBe('miss'); if (reclaimed.kind !== 'miss') throw new Error('expected release to make the key claimable'); @@ -157,7 +194,11 @@ describe('hash-aware training-agent idempotency store', () => { response: { resource_id: 'stale' }, claimToken: first.claimToken, })).rejects.toThrow('claim ownership was lost'); - await store.release({ principal: PRINCIPAL, key: KEY, claimToken: first.claimToken }); + await expect(store.release({ + principal: PRINCIPAL, + key: KEY, + claimToken: first.claimToken, + })).rejects.toThrow('claim ownership was lost'); await store.save({ principal: PRINCIPAL, @@ -174,7 +215,7 @@ describe('hash-aware training-agent idempotency store', () => { await store.close(); }); - it('serializes fallback release with an expired takeover', async () => { + it('fences fallback release when an expired claim is taken over concurrently', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-09T12:00:00Z')); const memory = memoryBackend({ sweepIntervalMs: 0 }); @@ -217,7 +258,7 @@ describe('hash-aware training-agent idempotency store', () => { expect(takeoverFinished).toBe(false); resumeRead(); - await releasing; + await expect(releasing).rejects.toThrow('claim ownership was lost'); const successor = await takeover; expect(successor.kind).toBe('miss'); if (successor.kind !== 'miss') throw new Error('expected successor claim'); @@ -228,7 +269,7 @@ describe('hash-aware training-agent idempotency store', () => { await store.close(); }); - it('lets the SDK adapter release the claim owned by its async request context', async () => { + it('passes the SDK beta.7 claim token through the adapter', async () => { const backend = memoryBackend({ sweepIntervalMs: 0 }); const owned = createHashAwareIdempotencyStore({ backend }); const adapter = adaptOwnedIdempotencyStoreForSdk(owned); @@ -236,7 +277,8 @@ describe('hash-aware training-agent idempotency store', () => { const first = await adapter.check({ principal: PRINCIPAL, key: KEY, payload }); expect(first.kind).toBe('miss'); - await adapter.release({ principal: PRINCIPAL, key: KEY }); + if (first.kind !== 'miss') throw new Error('expected adapter claim'); + await adapter.release({ principal: PRINCIPAL, key: KEY, claimToken: first.claimToken }); const reclaimed = await owned.check({ principal: PRINCIPAL, key: KEY, payload }); expect(reclaimed.kind).toBe('miss'); diff --git a/server/tests/unit/training-agent-get-products-rejected.test.ts b/server/tests/unit/training-agent-get-products-rejected.test.ts index 223bc17b3e..1e3309ad0c 100644 --- a/server/tests/unit/training-agent-get-products-rejected.test.ts +++ b/server/tests/unit/training-agent-get-products-rejected.test.ts @@ -39,7 +39,7 @@ describe('get_products rejected compliance arm', () => { ['3.1', false], ['3.2-beta.1', false], ['3.2-beta.2', true], - ['3.2-beta.4', true], + ['3.2-beta.5', true], ['3.2', true], ] as const)('gates support at the exact %s release boundary', (version, expected) => { expect(supportsGetProductsRejected(version)).toBe(expected); @@ -67,7 +67,7 @@ describe('get_products rejected compliance arm', () => { const reason = 'The requested budget is below the minimum for this inventory.'; const forced = await call('comply_test_controller', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: controllerAccount('primary-account'), scenario: 'force_get_products_arm', params: { @@ -81,12 +81,12 @@ describe('get_products rejected compliance arm', () => { data: { success: true, forced: { arm: 'rejected', reason }, - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', }, }); const otherAccountResult = await call('get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'rejected-other-account-0001', account: otherAccount, buying_mode: 'brief', @@ -95,7 +95,7 @@ describe('get_products rejected compliance arm', () => { expect(otherAccountResult.data).not.toMatchObject({ status: 'rejected' }); const otherPrincipalResult = await call('get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'rejected-other-principal-0001', account: primaryAccount, buying_mode: 'brief', @@ -104,7 +104,7 @@ describe('get_products rejected compliance arm', () => { expect(otherPrincipalResult.data).not.toMatchObject({ status: 'rejected' }); const wholesaleResult = await call('get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'rejected-wholesale-0001', account: primaryAccount, buying_mode: 'wholesale', @@ -112,7 +112,7 @@ describe('get_products rejected compliance arm', () => { expect(wholesaleResult.data).not.toMatchObject({ status: 'rejected' }); const rejected = await call('get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'rejected-primary-0001', account: primaryAccount, buying_mode: 'brief', @@ -123,7 +123,7 @@ describe('get_products rejected compliance arm', () => { success: true, data: { status: 'rejected', - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', reason, suggestions: ['Increase the campaign budget.'], context: { correlation_id: 'rejected-once' }, @@ -131,7 +131,7 @@ describe('get_products rejected compliance arm', () => { }); const consumed = await call('get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: 'rejected-consumed-0001', account: primaryAccount, buying_mode: 'brief', @@ -144,7 +144,7 @@ describe('get_products rejected compliance arm', () => { const primaryAccount = account('parallel-rejection-account'); const reason = 'Only one concurrent request may consume this rejection.'; const forced = await call('comply_test_controller', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: controllerAccount('parallel-rejection-account'), scenario: 'force_get_products_arm', params: { arm: 'rejected', reason }, @@ -153,7 +153,7 @@ describe('get_products rejected compliance arm', () => { const replayScope = randomUUID(); const outcomes = await Promise.all([0, 1].map(index => call('get_products', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', idempotency_key: `parallel-rejection-${replayScope}-${index}`, account: primaryAccount, buying_mode: 'brief', @@ -170,7 +170,7 @@ describe('get_products rejected compliance arm', () => { it('rejects empty suggestion arrays instead of emitting a schema-invalid response', async () => { const result = await call('comply_test_controller', { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: controllerAccount('invalid-suggestions'), scenario: 'force_get_products_arm', params: { arm: 'rejected', reason: 'Declined.', suggestions: [] }, diff --git a/server/tests/unit/training-agent-idempotency.test.ts b/server/tests/unit/training-agent-idempotency.test.ts index 442840482f..77266aed95 100644 --- a/server/tests/unit/training-agent-idempotency.test.ts +++ b/server/tests/unit/training-agent-idempotency.test.ts @@ -509,7 +509,7 @@ describe('training agent idempotency middleware', () => { it('keeps keyless list_products independent from legacy discovery replay identity', async () => { const key = `products-list-alias-${randomUUID()}`; const identity = { - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', brand: BRAND, }; @@ -543,7 +543,7 @@ describe('training agent idempotency middleware', () => { const conflict = await call(server, 'get_products', { idempotency_key: key, - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', buying_mode: 'wholesale', account: ACCOUNT, }); @@ -569,7 +569,7 @@ describe('training agent idempotency middleware', () => { const key = `products-recommend-task-alias-${randomUUID()}`; const shared = { idempotency_key: key, - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', account: ACCOUNT, brand: BRAND, brief: 'cross-channel news video and display', @@ -585,7 +585,7 @@ describe('training agent idempotency middleware', () => { const split = await call(server, 'request_proposals', { idempotency_key: key, - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', brand: BRAND, brief: shared.brief, }); @@ -596,7 +596,7 @@ describe('training agent idempotency middleware', () => { it('projects one cached product result across inline then task execution modes', async () => { const shared = { idempotency_key: `products-inline-task-${randomUUID()}`, - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', brand: BRAND, brief: 'cross-channel sports', }; @@ -616,7 +616,7 @@ describe('training agent idempotency middleware', () => { it('projects one cached product result across task then inline execution modes', async () => { const shared = { idempotency_key: `products-task-inline-${randomUUID()}`, - adcp_version: '3.2-beta.4', + adcp_version: '3.2-beta.5', brand: BRAND, brief: 'cross-channel news', }; @@ -767,7 +767,7 @@ describe('training agent idempotency middleware', () => { .toMatchObject({ proposal_status: 'committed' }); }); - it('allocates a fresh task when a released error key is retried with corrected input', async () => { + it('keeps a released error key bound to its original payload', async () => { const key = `products-task-correction-${randomUUID()}`; const failed = await callAsTask(server, 'get_products', { idempotency_key: key, @@ -778,19 +778,25 @@ describe('training agent idempotency middleware', () => { const failedTaskId = (failed.parsed.task as { taskId?: string })?.taskId; expect(failedTaskId).toBeTruthy(); - const correctedPayload = { + const changedPayload = { idempotency_key: key, buying_mode: 'wholesale', account: ACCOUNT, }; - const corrected = await callAsTask(server, 'get_products', correctedPayload); - const correctedTaskId = (corrected.parsed.task as { taskId?: string })?.taskId; - expect(correctedTaskId).toBeTruthy(); - expect(correctedTaskId).not.toBe(failedTaskId); + const changed = await call(server, 'get_products', changedPayload); + expect(changed.isError).toBe(true); + expect((changed.parsed as any).adcp_error).toMatchObject({ + code: 'IDEMPOTENCY_CONFLICT', + }); - const replay = await callAsTask(server, 'get_products', correctedPayload); - expect((replay.parsed.task as { taskId?: string })?.taskId).toBe(correctedTaskId); - expect(replay.parsed.replayed).toBe(true); + const exactRetry = await callAsTask(server, 'get_products', { + idempotency_key: key, + buying_mode: 'refine', + account: ACCOUNT, + refine: [{ scope: 'proposal', action: 'finalize', proposal_id: 'proposal-does-not-exist' }], + }); + expect((exactRetry.parsed.task as { taskId?: string })?.taskId).toBeTruthy(); + expect((exactRetry.parsed.task as { taskId?: string })?.taskId).not.toBe(failedTaskId); }); it('allocates a fresh task when the exact failed payload later succeeds', async () => { diff --git a/server/tests/unit/training-agent-webhook-delivery-store.test.ts b/server/tests/unit/training-agent-webhook-delivery-store.test.ts new file mode 100644 index 0000000000..cc24c99195 --- /dev/null +++ b/server/tests/unit/training-agent-webhook-delivery-store.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { WebhookDeliveryKey, WebhookDeliverySnapshot } from '@adcp/sdk/server'; +import { + PostgresWebhookDeliveryPersistence, + type WebhookDeliveryQuery, +} from '../../src/training-agent/webhook-delivery-store.js'; + +const KEY: WebhookDeliveryKey = { + publisherScope: 'adcp-training-agent', + tenantScope: 'tenant-abc', + deliveryId: 'delivery-00000001', +}; + +const SNAPSHOT: WebhookDeliverySnapshot = { + url: 'https://buyer.example/webhook', + payload: { operation_id: 'op-1', status: 'completed' }, + authentication: { type: 'bearer', token: 'super-secret-token' }, + retries: { maxAttempts: 5, initialDelayMs: 1_000, maxDelayMs: 60_000, jitter: 0.25 }, +}; + +function queryMock(): ReturnType> { + return vi.fn(); +} + +describe('PostgresWebhookDeliveryPersistence', () => { + it('uses a unique insert and the database clock for the first immutable binding', async () => { + const runQuery = queryMock(); + runQuery.mockResolvedValueOnce({ + rows: [{ + status: 'bound', + idempotency_key: 'wire-key-00000001', + payload_fingerprint: 'a'.repeat(64), + first_attempt_at_ms: '1787500000000', + retain_until_ms: '1787586400000', + }], + }); + const store = new PostgresWebhookDeliveryPersistence(runQuery); + + await expect(store.claim(KEY, { + idempotencyKey: 'wire-key-00000001', + payloadFingerprint: 'a'.repeat(64), + }, 86_400_000)).resolves.toEqual({ + status: 'bound', + idempotencyKey: 'wire-key-00000001', + payloadFingerprint: 'a'.repeat(64), + firstAttemptAtMs: 1787500000000, + retainUntilMs: 1787586400000, + }); + + expect(runQuery).toHaveBeenCalledOnce(); + expect(runQuery.mock.calls[0][0]).toContain('ON CONFLICT (publisher_scope, tenant_scope, delivery_id) DO NOTHING'); + expect(runQuery.mock.calls[0][0]).toContain('NOW()'); + expect(runQuery.mock.calls[0][1]).toEqual([ + KEY.publisherScope, + KEY.tenantScope, + KEY.deliveryId, + 'wire-key-00000001', + 'a'.repeat(64), + 86_400_000, + ]); + }); + + it('atomically retires an expired binding instead of making its delivery id reusable', async () => { + const runQuery = queryMock(); + runQuery + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ + status: 'retired', + idempotency_key: null, + payload_fingerprint: null, + first_attempt_at_ms: null, + retain_until_ms: null, + }] }); + const store = new PostgresWebhookDeliveryPersistence(runQuery); + + await expect(store.claim(KEY, { + idempotencyKey: 'wire-key-00000002', + payloadFingerprint: 'b'.repeat(64), + }, 86_400_000)).resolves.toEqual({ status: 'retired' }); + expect(runQuery.mock.calls[1][0]).toContain("SET status = 'retired'"); + expect(runQuery).toHaveBeenCalledTimes(2); + }); + + it('encrypts the complete recovery snapshot and keeps plaintext credentials out of SQL parameters', async () => { + const runQuery = queryMock(); + runQuery.mockResolvedValueOnce({ rows: [{ snapshot_digest: 'stored' }] }); + const store = new PostgresWebhookDeliveryPersistence(runQuery); + + await store.checkpoint(KEY, SNAPSHOT); + + const params = runQuery.mock.calls[0][1] ?? []; + expect(params.slice(0, 3)).toEqual([KEY.publisherScope, KEY.tenantScope, KEY.deliveryId]); + expect(params[3]).toEqual(expect.any(String)); + expect(params[4]).toEqual(expect.any(String)); + expect(params[5]).toMatch(/^[0-9a-f]{64}$/); + expect(JSON.stringify(params)).not.toContain('super-secret-token'); + expect(JSON.stringify(params)).not.toContain('buyer.example'); + }); + + it('rejects a changed exact snapshot for an already-checkpointed delivery id', async () => { + const runQuery = queryMock(); + runQuery + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ snapshot_digest: 'not-the-new-digest' }] }); + const store = new PostgresWebhookDeliveryPersistence(runQuery); + + await expect(store.checkpoint(KEY, SNAPSHOT)).rejects.toThrow( + 'was checkpointed with a different snapshot', + ); + }); + + it('leases recoverable snapshots and reconstructs the exact emit parameters', async () => { + const insertQuery = queryMock(); + insertQuery.mockResolvedValueOnce({ rows: [{ snapshot_digest: 'stored' }] }); + const writer = new PostgresWebhookDeliveryPersistence(insertQuery); + await writer.checkpoint(KEY, SNAPSHOT); + const checkpointParams = insertQuery.mock.calls[0][1] ?? []; + + const recoveryQuery = queryMock(); + recoveryQuery.mockResolvedValueOnce({ rows: [{ + publisher_scope: KEY.publisherScope, + tenant_scope: KEY.tenantScope, + delivery_id: KEY.deliveryId, + snapshot_encrypted: checkpointParams[3], + snapshot_iv: checkpointParams[4], + created_at_ms: '1787500000000', + }] }); + const reader = new PostgresWebhookDeliveryPersistence(recoveryQuery); + + await expect(reader.claimRecoverable(KEY.publisherScope)).resolves.toEqual([{ + key: KEY, + params: { + url: SNAPSHOT.url, + payload: SNAPSHOT.payload, + delivery_id: KEY.deliveryId, + authentication: SNAPSHOT.authentication, + retries: SNAPSHOT.retries, + }, + createdAtMs: 1787500000000, + }]); + expect(recoveryQuery.mock.calls[0][0]).toContain('FOR UPDATE SKIP LOCKED'); + expect(recoveryQuery.mock.calls[0][1]).toEqual([KEY.publisherScope, 25, 1_800_000]); + }); +}); diff --git a/server/tests/unit/training-agent-webhook-operation-id.test.ts b/server/tests/unit/training-agent-webhook-operation-id.test.ts index 1a9ea4cf94..ae51f9a6f5 100644 --- a/server/tests/unit/training-agent-webhook-operation-id.test.ts +++ b/server/tests/unit/training-agent-webhook-operation-id.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest'; import { deriveWebhookIdempotencyScope } from '../../src/training-agent/webhooks.js'; describe('deriveWebhookIdempotencyScope', () => { - it('prefixes the scope key with the caller principal so two buyers on the shared sandbox token producing the same response entity id get distinct webhook idempotency_keys', () => { + it('includes the caller principal in the opaque identity so shared-sandbox buyers remain distinct', () => { const response = { media_buy_id: 'mb_abc123' }; const a = deriveWebhookIdempotencyScope('create_media_buy', response, undefined, 'static:publicb:buyer-a.example'); const b = deriveWebhookIdempotencyScope('create_media_buy', response, undefined, 'static:publicb:buyer-b.example'); expect(a).not.toBe(b); - expect(a).toContain('mb_abc123'); - expect(b).toContain('mb_abc123'); + expect(a).toMatch(/^whd_[0-9a-f]{64}$/); + expect(b).toMatch(/^whd_[0-9a-f]{64}$/); }); it('returns the same scope key for the same principal + entity id (so retries collapse)', () => { @@ -19,29 +19,45 @@ describe('deriveWebhookIdempotencyScope', () => { expect(first).toBe(second); }); - it('falls back to the request idempotency_key when no entity id is present, still scoped by principal', () => { + it('uses the request idempotency_key when no entity id is present, still scoped by principal', () => { const a = deriveWebhookIdempotencyScope('sync_creatives', {}, 'idemp-key-1', 'static:publicb:buyer-a.example'); const b = deriveWebhookIdempotencyScope('sync_creatives', {}, 'idemp-key-1', 'static:publicb:buyer-b.example'); expect(a).not.toBe(b); - expect(a).toContain('idemp-key-1'); - expect(b).toContain('idemp-key-1'); + expect(a).toMatch(/^whd_[0-9a-f]{64}$/); + expect(b).toMatch(/^whd_[0-9a-f]{64}$/); + }); + + it('prefers the exact request idempotency key so distinct updates to one entity do not collide', () => { + const response = { media_buy_id: 'mb_1' }; + const first = deriveWebhookIdempotencyScope('update_media_buy', response, 'update-key-0000001', 'workos:org_x'); + const second = deriveWebhookIdempotencyScope('update_media_buy', response, 'update-key-0000002', 'workos:org_x'); + const retry = deriveWebhookIdempotencyScope('update_media_buy', response, 'update-key-0000001', 'workos:org_x'); + + expect(first).not.toBe(second); + expect(retry).toBe(first); + expect(first).toMatch(/^whd_[0-9a-f]{64}$/); }); it('walks the entity-id field list in order (media_buy_id wins over creative_id)', () => { const response = { media_buy_id: 'mb_1', creative_id: 'cr_1' }; const id = deriveWebhookIdempotencyScope('create_media_buy', response, undefined, 'p'); - expect(id).toBe('p|create_media_buy.mb_1'); + expect(id).toBe(deriveWebhookIdempotencyScope( + 'create_media_buy', + { media_buy_id: 'mb_1' }, + undefined, + 'p', + )); }); - it('still applies the principal prefix on the random-UUID fallback (no entity id, no request idempotency key)', () => { + it('keeps the random-UUID fallback opaque when no stable request or entity id exists', () => { const a = deriveWebhookIdempotencyScope('create_media_buy', {}, undefined, 'p'); const b = deriveWebhookIdempotencyScope('create_media_buy', {}, undefined, 'p'); expect(a).not.toBe(b); // different UUIDs - expect(a).toMatch(/^p\|create_media_buy\./); - expect(b).toMatch(/^p\|create_media_buy\./); + expect(a).toMatch(/^whd_[0-9a-f]{64}$/); + expect(b).toMatch(/^whd_[0-9a-f]{64}$/); }); - it('does not collide when a principal contains the same `|` character used as the prefix separator', () => { + it('does not collide when one principal contains another principal plus a separator', () => { const a = deriveWebhookIdempotencyScope('create_media_buy', { media_buy_id: 'mb_1' }, undefined, 'a|b'); const b = deriveWebhookIdempotencyScope('create_media_buy', { media_buy_id: 'mb_1' }, undefined, 'a'); expect(a).not.toBe(b); diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 5ad6ff3786..8792a3bb87 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -100,7 +100,7 @@ const VALID_PRICING_MODELS = [ ] as const; const TEST_AGENT_URL = 'http://localhost:3000/api/training-agent'; -const CURRENT_ADCP_VERSION = '3.2-beta.4'; +const CURRENT_ADCP_VERSION = '3.2-beta.5'; const DEFAULT_CTX: TrainingContext = { mode: 'open', authenticatedAgentUrl: 'https://buyer.example' }; @@ -12943,6 +12943,7 @@ describe('get_products refine mode', () => { refine: [{ scope: 'product', action: 'include', product_id: source.product_id }], }); + expect(refined).toMatchObject({ refinement_applied: expect.any(Array) }); const applied = refined.refinement_applied as Array>; expect(applied[0]).toMatchObject({ scope: 'product', @@ -16601,32 +16602,620 @@ describe('proposal lifecycle', () => { }); }); - it('fails closed on split targeting until the 3.2 training runtime ships', async () => { + it('executes targeting-aware discovery and filters future overlay support', async () => { const server = createTrainingAgentServer(DEFAULT_CTX); - const listed = await simulateCallTool(server, 'list_products', { + const targetedProductId = 'targeting_aware_training_product'; + const proximityAllowlistProductId = 'targeting_proximity_allowlist_product'; + const seeded = await simulateCallTool(server, 'comply_test_controller', { + account, brand: account.brand, - criteria: { targeting_overlay: { geo_countries: ['US'] } }, + scenario: 'seed_product', + params: { + product_id: targetedProductId, + fixture: { + channels: ['display'], + delivery_type: 'non_guaranteed', + overlay_support: { + geo_countries: { max_values_per_package: 2 }, + geo_countries_exclude: { max_values_per_package: 3 }, + geo_metros: { systems: ['nielsen_dma'] }, + geo_regions: { countries: { FR: { all_values: true } } }, + geo_proximity: { + radius: true, + travel_time: true, + max_values_per_package: 1, + }, + frequency_cap: true, + placement_selection: { max_values_per_package: 1, max_packages: 1 }, + }, + }, + }, }); - expect(listed).toMatchObject({ - isError: true, - result: { code: 'UNSUPPORTED_FEATURE', field: 'criteria.targeting_overlay' }, + expect(seeded.result.success).toBe(true); + const seededPricing = await simulateCallTool(server, 'comply_test_controller', { + account, + brand: account.brand, + scenario: 'seed_pricing_option', + params: { + product_id: targetedProductId, + pricing_option_id: 'targeting_fixed_cpm', + fixture: { pricing_model: 'cpm', currency: 'USD', fixed_price: 8 }, + }, + }); + expect(seededPricing.result.success).toBe(true); + const allowlistSeeded = await simulateCallTool(server, 'comply_test_controller', { + account, + brand: account.brand, + scenario: 'seed_product', + params: { + product_id: proximityAllowlistProductId, + fixture: { + channels: ['display'], + delivery_type: 'non_guaranteed', + overlay_support: { + geo_proximity: { travel_time: true, transport_modes: ['walking'] }, + }, + }, + }, + }); + expect(allowlistSeeded.result.success).toBe(true); + const allowlistPricing = await simulateCallTool(server, 'comply_test_controller', { + account, + brand: account.brand, + scenario: 'seed_pricing_option', + params: { + product_id: proximityAllowlistProductId, + pricing_option_id: 'proximity_allowlist_cpm', + fixture: { pricing_model: 'cpm', currency: 'USD', fixed_price: 8 }, + }, + }); + expect(allowlistPricing.result.success).toBe(true); + + const baseline = await simulateCallTool(server, 'list_products', { + account, + criteria: { product_ids: [targetedProductId] }, + }); + expect(baseline.isError, JSON.stringify(baseline.result)).toBeFalsy(); + expect(baseline.result).toMatchObject({ + outcome: 'listed', + products: [{ product_id: targetedProductId, overlay_support: expect.any(Object) }], + }); + const listed = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [targetedProductId], + targeting_overlay: { geo_countries: ['US'] }, + required_overlay_support: { + geo_metros: { systems: ['nielsen_dma'] }, + }, + }, + fields: ['description', 'pricing_options'], + }); + expect(listed.isError, JSON.stringify(listed.result)).toBeFalsy(); + expect(listed.result).toMatchObject({ + outcome: 'listed', + products: [{ + product_id: expect.stringMatching(/^configured_[a-f0-9]{24}$/), + is_custom: true, + expires_at: expect.any(String), + overlay_support: { + geo_countries: { max_values_per_package: 2 }, + geo_metros: { systems: ['nielsen_dma'] }, + }, + }], + }); + const configuredProduct = (listed.result.products as Array>)[0]!; + const configuredPricing = (configuredProduct.pricing_options as Array>) + .find(option => option.pricing_option_id === 'targeting_fixed_cpm')!; + const purchased = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: [{ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 1_000, + }], + start_time: '2027-01-01T00:00:00Z', + end_time: '2027-01-31T00:00:00Z', + }, DEFAULT_CTX); + expect(purchased.success, purchased.error).toBe(true); + expect(purchased.data).toMatchObject({ status: 'completed', media_buy_id: expect.any(String) }); + const readback = await executeTrainingAgentTool('get_media_buys', { + adcp_version: CURRENT_ADCP_VERSION, + account, + media_buy_ids: [purchased.data!.media_buy_id], + }, DEFAULT_CTX); + expect(readback.success, readback.error).toBe(true); + expect(readback.data).toMatchObject({ + media_buys: [{ + packages: [{ targeting_overlay: { geo_countries: ['US'] } }], + }], + }); + + const conflictingPurchase = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-conflict-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: [{ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 1_000, + targeting_overlay: { geo_countries: ['CA'] }, + }], + start_time: '2027-01-01T00:00:00Z', + end_time: '2027-01-31T00:00:00Z', + }, DEFAULT_CTX); + expect(conflictingPurchase.success, conflictingPurchase.error).toBe(true); + expect(conflictingPurchase.data).toMatchObject({ + errors: [{ + code: 'UNSUPPORTED_FEATURE', + field: 'purchases[0].targeting_overlay.geo_countries', + }], + }); + + const unsupportedPurchaseTargeting = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-unsupported-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: [{ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 1_000, + targeting_overlay: { browser: ['safari'] }, + }], + start_time: '2027-01-01T00:00:00Z', + end_time: '2027-01-31T00:00:00Z', + }, DEFAULT_CTX); + expect(unsupportedPurchaseTargeting.success, unsupportedPurchaseTargeting.error).toBe(true); + expect(unsupportedPurchaseTargeting.data).toMatchObject({ + errors: [{ + code: 'UNSUPPORTED_FEATURE', + field: 'purchases[0].targeting_overlay.browser', + }], + }); + + const broadListed = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [targetedProductId], + targeting_overlay: { geo_countries: ['US', 'CA'] }, + }, + fields: ['pricing_options'], + }); + const broadProduct = (broadListed.result.products as Array>)[0]!; + const narrowedPurchase = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-narrowed-${randomUUID()}`, + account, + feed_version: broadListed.result.feed_version, + pricing_version: broadListed.result.pricing_version, + purchases: [{ + product_id: broadProduct.product_id, + pricing_option_id: 'targeting_fixed_cpm', + budget: 1_000, + targeting_overlay: { geo_countries: ['US'] }, + }], + start_time: '2027-02-01T00:00:00Z', + end_time: '2027-02-28T00:00:00Z', + }, DEFAULT_CTX); + expect(narrowedPurchase.success, narrowedPurchase.error).toBe(true); + expect(narrowedPurchase.data).toMatchObject({ status: 'completed' }); + + const exclusionListed = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [targetedProductId], + targeting_overlay: { geo_countries_exclude: ['CA'] }, + }, + fields: ['pricing_options'], + }); + const exclusionProduct = (exclusionListed.result.products as Array>)[0]!; + const narrowedExclusionPurchase = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-exclusion-${randomUUID()}`, + account, + feed_version: exclusionListed.result.feed_version, + pricing_version: exclusionListed.result.pricing_version, + purchases: [{ + product_id: exclusionProduct.product_id, + pricing_option_id: 'targeting_fixed_cpm', + budget: 1_000, + targeting_overlay: { geo_countries_exclude: ['CA', 'MX'] }, + }], + start_time: '2027-03-01T00:00:00Z', + end_time: '2027-03-31T00:00:00Z', + }, DEFAULT_CTX); + expect(narrowedExclusionPurchase.success, narrowedExclusionPurchase.error).toBe(true); + expect(narrowedExclusionPurchase.data).toMatchObject({ status: 'completed' }); + + const frequencyListed = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [targetedProductId], + targeting_overlay: { + frequency_cap: { + max_impressions: 10, + per: 'devices', + window: { interval: 7, unit: 'days' }, + }, + }, + }, + fields: ['pricing_options'], + }); + expect(frequencyListed.isError, JSON.stringify(frequencyListed.result)).toBeFalsy(); + const frequencyProduct = (frequencyListed.result.products as Array>)[0]!; + const narrowedFrequencyPurchase = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-frequency-${randomUUID()}`, + account, + feed_version: frequencyListed.result.feed_version, + pricing_version: frequencyListed.result.pricing_version, + purchases: [{ + product_id: frequencyProduct.product_id, + pricing_option_id: 'targeting_fixed_cpm', + budget: 1_000, + targeting_overlay: { + frequency_cap: { + max_impressions: 5, + per: 'devices', + window: { interval: 7, unit: 'days' }, + }, + }, + }], + start_time: '2027-04-01T00:00:00Z', + end_time: '2027-04-30T00:00:00Z', + }, DEFAULT_CTX); + expect(narrowedFrequencyPurchase.success, narrowedFrequencyPurchase.error).toBe(true); + expect(narrowedFrequencyPurchase.data).toMatchObject({ status: 'completed' }); + + const broadenedFrequencyPurchase = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-frequency-broadened-${randomUUID()}`, + account, + feed_version: frequencyListed.result.feed_version, + pricing_version: frequencyListed.result.pricing_version, + purchases: [{ + product_id: frequencyProduct.product_id, + pricing_option_id: 'targeting_fixed_cpm', + budget: 1_000, + targeting_overlay: { + frequency_cap: { + max_impressions: 5, + per: 'devices', + window: { interval: 1, unit: 'days' }, + }, + }, + }], + start_time: '2027-04-01T00:00:00Z', + end_time: '2027-04-30T00:00:00Z', + }, DEFAULT_CTX); + expect(broadenedFrequencyPurchase.success, broadenedFrequencyPurchase.error).toBe(true); + expect(broadenedFrequencyPurchase.data).toMatchObject({ + errors: [{ code: 'UNSUPPORTED_FEATURE', field: expect.stringContaining('frequency_cap') }], + }); + + const unsupportedStructuredTargeting = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-unsupported-metro-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: [{ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 1_000, + targeting_overlay: { geo_metros: [{ system: 'uk_itl2', values: ['UKI'] }] }, + }], + start_time: '2027-05-01T00:00:00Z', + end_time: '2027-05-31T00:00:00Z', + }, DEFAULT_CTX); + expect(unsupportedStructuredTargeting.success, unsupportedStructuredTargeting.error).toBe(true); + expect(unsupportedStructuredTargeting.data).toMatchObject({ + errors: [{ + code: 'UNSUPPORTED_FEATURE', + field: 'purchases[0].targeting_overlay.geo_metros', + }], + }); + + const supportedProximityWithoutModeAllowlist = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-unsupported-proximity-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: [{ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 1_000, + targeting_overlay: { + geo_proximity: [{ + lat: 51.5, + lng: -0.1, + travel_time: { value: 20, unit: 'min' }, + transport_mode: 'driving', + }], + }, + }], + start_time: '2027-05-01T00:00:00Z', + end_time: '2027-05-31T00:00:00Z', + }, DEFAULT_CTX); + expect(supportedProximityWithoutModeAllowlist.success, supportedProximityWithoutModeAllowlist.error).toBe(true); + expect(supportedProximityWithoutModeAllowlist.data).toMatchObject({ status: 'completed' }); + + const allowlistCatalog = await simulateCallTool(server, 'list_products', { + account, + criteria: { product_ids: [proximityAllowlistProductId] }, + fields: ['pricing_options'], + }); + const unsupportedProximityMode = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-unsupported-proximity-mode-${randomUUID()}`, + account, + feed_version: allowlistCatalog.result.feed_version, + pricing_version: allowlistCatalog.result.pricing_version, + purchases: [{ + product_id: proximityAllowlistProductId, + pricing_option_id: 'proximity_allowlist_cpm', + budget: 1_000, + targeting_overlay: { + geo_proximity: [{ + lat: 51.5, + lng: -0.1, + travel_time: { value: 20, unit: 'min' }, + transport_mode: 'driving', + }], + }, + }], + start_time: '2027-05-01T00:00:00Z', + end_time: '2027-05-31T00:00:00Z', + }, DEFAULT_CTX); + expect(unsupportedProximityMode.success, unsupportedProximityMode.error).toBe(true); + expect(unsupportedProximityMode.data).toMatchObject({ + errors: [{ code: 'UNSUPPORTED_FEATURE', field: 'purchases[0].targeting_overlay.geo_proximity' }], + }); + + const overLimitPlacementSelection = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-placement-limit-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: [{ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 1_000, + targeting_overlay: { + placement_selection: { + mode: 'selected', + placement_refs: [ + { publisher_domain: 'publisher.example', placement_id: 'placement-1' }, + { publisher_domain: 'publisher.example', placement_id: 'placement-2' }, + ], + }, + }, + }], + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-06-30T00:00:00Z', + }, DEFAULT_CTX); + expect(overLimitPlacementSelection.success, overLimitPlacementSelection.error).toBe(true); + expect(overLimitPlacementSelection.data).toMatchObject({ + errors: [{ code: 'UNSUPPORTED_FEATURE', field: 'purchases[0].targeting_overlay.placement_selection' }], + }); + + const overLimitPlacementPackages = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `targeted-buy-placement-package-limit-${randomUUID()}`, + account, + feed_version: listed.result.feed_version, + pricing_version: listed.result.pricing_version, + purchases: ['placement-1', 'placement-2'].map(placementId => ({ + product_id: configuredProduct.product_id, + pricing_option_id: configuredPricing.pricing_option_id, + budget: 500, + targeting_overlay: { + placement_selection: { + mode: 'selected', + placement_refs: [{ publisher_domain: 'publisher.example', placement_id: placementId }], + }, + }, + })), + start_time: '2027-06-01T00:00:00Z', + end_time: '2027-06-30T00:00:00Z', + }, DEFAULT_CTX); + expect(overLimitPlacementPackages.success, overLimitPlacementPackages.error).toBe(true); + expect(overLimitPlacementPackages.data).toMatchObject({ + errors: [{ code: 'UNSUPPORTED_FEATURE', field: 'purchases[1].targeting_overlay.placement_selection' }], }); const targetedRequest = await simulateCallTool(server, 'request_proposals', { idempotency_key: `test-${randomUUID()}`, - brand: account.brand, - brief: 'social engagement display', + account, + brief: 'Use the selected targeting-aware display offer.', criteria: { + product_ids: [targetedProductId], + targeting_overlay: { geo_countries: ['US'] }, required_overlay_support: { geo_metros: { systems: ['nielsen_dma'] }, }, }, }); - expect(targetedRequest).toMatchObject({ - isError: true, - result: { code: 'UNSUPPORTED_FEATURE', field: 'criteria.required_overlay_support' }, + expect(targetedRequest.isError, JSON.stringify(targetedRequest.result)).toBeFalsy(); + expect(targetedRequest.result).toMatchObject({ + outcome: 'proposed', + proposals: [{ + commercial_terms: { + purchases: [{ + product_id: expect.stringMatching(/^configured_[a-f0-9]{24}$/), + targeting_overlay: { geo_countries: ['US'] }, + }], + }, + }], + }); + const targetedProposal = (targetedRequest.result.proposals as Array>)[0]!; + const committedTargetedProposal = await finalizeCompactProposal(server, targetedProposal); + const acceptedTargetedProposal = await runWithSessionContext(() => handleAcceptProposal({ + idempotency_key: `targeted-accept-${randomUUID()}`, + account, + proposal_id: committedTargetedProposal.proposal_id, + proposal_terms_digest: committedTargetedProposal.terms_digest, + total_budget: { amount: 1_000, currency: 'USD' }, + }, DEFAULT_CTX)); + expect(acceptedTargetedProposal).toMatchObject({ + status: 'completed', + media_buy_id: expect.any(String), + }); + const acceptedReadback = await executeTrainingAgentTool('get_media_buys', { + adcp_version: CURRENT_ADCP_VERSION, + account, + media_buy_ids: [acceptedTargetedProposal.media_buy_id], + }, DEFAULT_CTX); + expect(acceptedReadback.success, acceptedReadback.error).toBe(true); + expect(acceptedReadback.data).toMatchObject({ + media_buys: [{ packages: [{ targeting_overlay: { geo_countries: ['US'] } }] }], }); + const allRegions = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [targetedProductId], + required_overlay_support: { + geo_regions: { countries: { FR: { values: ['FR-49'] } } }, + }, + }, + }); + expect(allRegions.isError, JSON.stringify(allRegions.result)).toBeFalsy(); + expect(allRegions.result).toMatchObject({ + outcome: 'listed', + products: [{ product_id: targetedProductId }], + }); + + const unsupported = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [targetedProductId], + required_overlay_support: { browser: { families: ['safari'] } }, + }, + }); + expect(unsupported.isError).toBeFalsy(); + expect(unsupported.result).toMatchObject({ outcome: 'listed', products: [] }); + + const concurrentListings = await Promise.all(['US', 'CA'].map(country => simulateCallTool( + server, + 'list_products', + { + account, + criteria: { + product_ids: [targetedProductId], + targeting_overlay: { geo_countries: [country] }, + }, + fields: ['pricing_options'], + context: { correlation_id: `concurrent-targeting-${country}` }, + }, + ))); + expect(concurrentListings.every(result => !result.isError)).toBe(true); + const concurrentProducts = concurrentListings.map(result => ( + (result.result.products as Array>)[0]! + )); + expect(new Set(concurrentProducts.map(product => product.product_id)).size).toBe(2); + for (const [index, product] of concurrentProducts.entries()) { + const concurrentPurchase = await executeTrainingAgentTool('buy_products', { + adcp_version: CURRENT_ADCP_VERSION, + idempotency_key: `concurrent-targeted-buy-${index}-${randomUUID()}`, + account, + feed_version: concurrentListings[index]!.result.feed_version, + pricing_version: concurrentListings[index]!.result.pricing_version, + purchases: [{ + product_id: product.product_id, + pricing_option_id: 'targeting_fixed_cpm', + budget: 1_000, + }], + start_time: `2027-0${index + 3}-01T00:00:00Z`, + end_time: `2027-0${index + 3}-28T00:00:00Z`, + }, DEFAULT_CTX); + expect(concurrentPurchase.success, concurrentPurchase.error).toBe(true); + expect(concurrentPurchase.data).toMatchObject({ status: 'completed' }); + } + }); + + it('returns a correctable error when configured-product capacity would truncate discovery', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const productIds = [ + 'targeting_capacity_training_product_a', + 'targeting_capacity_training_product_b', + ]; + for (const productId of productIds) { + const seeded = await simulateCallTool(server, 'comply_test_controller', { + account, + brand: account.brand, + scenario: 'seed_product', + params: { + product_id: productId, + fixture: { + channels: ['display'], + delivery_type: 'non_guaranteed', + overlay_support: { geo_countries: { max_values_per_package: 2 } }, + }, + }, + }); + expect(seeded.result.success).toBe(true); + } + + const first = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: [productIds[0]], + targeting_overlay: { geo_countries: ['US'] }, + }, + }); + expect(first.isError, JSON.stringify(first.result)).toBeFalsy(); + expect(first.result.products).toHaveLength(1); + + let configuredIdsBeforeOverflow: string[] = []; + await runWithSessionContext(async () => { + const session = await getSession(sessionKeyFromArgs({ account }, DEFAULT_CTX.mode)); + const template = [...session.configuredProducts.values()][0]!; + expect(template).toBeDefined(); + for (let index = session.configuredProducts.size; index < 127; index += 1) { + const configuredId = `configured_capacity_fixture_${index}`; + session.configuredProducts.set(configuredId, { + ...structuredClone(template), + product_id: configuredId, + }); + session.configuredProductTargeting.set(configuredId, { geo_countries: ['US'] }); + } + configuredIdsBeforeOverflow = [...session.configuredProducts.keys()].sort(); + await flushDirtySessions(); + }); + + const capped = await simulateCallTool(server, 'list_products', { + account, + criteria: { + product_ids: productIds, + targeting_overlay: { geo_countries: ['CA'] }, + }, + }); + expect(capped.isError).toBe(true); + expect(capped.result).toMatchObject({ + code: 'LIMIT_EXCEEDED', + field: 'targeting_overlay', + recovery: 'correctable', + details: { limit: 128, dropped_products: 1 }, + }); + await runWithSessionContext(async () => { + const session = await getSession(sessionKeyFromArgs({ account }, DEFAULT_CTX.mode)); + expect([...session.configuredProducts.keys()].sort()).toEqual(configuredIdsBeforeOverflow); + }); + }); + + it('keeps criteria refinements gated to typed negotiation profiles', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); const { result: requested, isError: requestError } = await simulateCallTool(server, 'request_proposals', { idempotency_key: `test-${randomUUID()}`, brand: account.brand, diff --git a/specs/release-instrumentation/3.2.json b/specs/release-instrumentation/3.2.json new file mode 100644 index 0000000000..916cdead29 --- /dev/null +++ b/specs/release-instrumentation/3.2.json @@ -0,0 +1,150 @@ +{ + "release": "3.2.0-beta.5", + "surfaces": ["docs", "training_agent", "compliance", "training"], + "features": [ + { + "id": "compact_media_buy_lifecycle", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/media-buy/product-discovery/proposal-negotiation.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/task-handlers.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/protocols/media-buy/scenarios/typed_proposal_negotiation.yaml"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/550_curriculum_3_2_criteria.sql"] } + } + }, + { + "id": "targeting_aware_discovery", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/reference/migration/targeting-aware-discovery.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/task-handlers.ts", "server/tests/unit/training-agent.test.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/protocols/media-buy/scenarios/targeting_aware_discovery.yaml"] }, + "training": { "status": "covered", "evidence": ["docs/learning/specialist/media-buy.mdx", "server/src/db/migrations/550_curriculum_3_2_criteria.sql"] } + } + }, + { + "id": "availability_and_outcome_planning", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/reference/whats-new-in-3-2.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/task-handlers.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/protocols/media-buy/scenarios/availability_windows.yaml", "static/compliance/source/protocols/media-buy/scenarios/outcome_target.yaml"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/550_curriculum_3_2_criteria.sql"] } + } + }, + { + "id": "canonical_creative_quality", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/creative/canonical-formats.mdx", "docs/creative/channels/video.mdx", "docs/creative/accessibility.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/task-handlers.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/protocols/creative/index.yaml"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/550_curriculum_3_2_criteria.sql"] } + } + }, + { + "id": "request_signing_profile_3_2", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/building/by-layer/L1/request-signing.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/tenants/signing.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/test-vectors/request-signing/profile-3.2/positive/001-post-with-content-digest.json", "static/compliance/source/test-vectors/request-signing/profile-3.2/negative/001-base64url-sf-binary.json"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/550_curriculum_3_2_criteria.sql"] } + } + }, + { + "id": "agent_notifications", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/protocol/sync_agent_notification_configs.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/agent-notification-configs.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/universal/agent-notification-configs.yaml"] }, + "training": { "status": "not_applicable", "reason": "Operational notification registration is not a standalone specialist competency in the 3.2 curriculum." } + } + }, + { + "id": "idempotency_retention_and_retry_binding", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/learning/specialist/security.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/idempotency.ts", "server/src/db/migrations/549_idempotency_retention_boundary.sql", "server/tests/unit/idempotency-store.test.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/universal/idempotency.yaml"] }, + "training": { "status": "covered", "evidence": ["docs/learning/specialist/security.mdx"] } + } + }, + { + "id": "webhook_delivery_identity_and_recovery", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/learning/specialist/security.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/webhooks.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/universal/webhook-emission.yaml"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/550_curriculum_3_2_criteria.sql", "docs/learning/specialist/security.mdx"] } + } + }, + { + "id": "portable_attestations", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/reference/whats-new-in-3-2.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/task-handlers.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/test-vectors/attestations/vectors.json", "static/compliance/source/test-vectors/rights-attestations/vectors.json"] }, + "training": { "status": "not_applicable", "reason": "Portable attestation transport is not a standalone certification competency in the current specialist curriculum." } + } + }, + { + "id": "delivery_reconciliation", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/learning/specialist/media-buy.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/task-handlers.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/protocols/media-buy/scenarios/delivery_reporting.yaml", "static/compliance/source/protocols/media-buy/scenarios/billing_finality_delivery.yaml"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/477_broadcast_delivery_criteria.sql"] } + } + }, + { + "id": "campaign_governance_experimental", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/governance/campaign/specification.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/governance-handlers.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/protocols/media-buy/scenarios/governance_conditions.yaml"] }, + "training": { "status": "covered", "evidence": ["server/src/db/migrations/407_curriculum_3_0_criterion_ids.sql"] } + } + }, + { + "id": "trusted_match_experimental", + "surfaces": { + "docs": { "status": "covered", "evidence": ["docs/trusted-match/specification.mdx"] }, + "training_agent": { "status": "covered", "evidence": ["server/src/training-agent/tenants/router.ts"] }, + "compliance": { "status": "covered", "evidence": ["static/compliance/source/universal/trusted-match-publisher-authentication.yaml"] }, + "training": { "status": "not_applicable", "reason": "Trusted Match remains experimental; certification targeting waits for stabilization." } + } + }, + { + "id": "creative_revisions_candidate", + "surfaces": { + "docs": { "status": "deferred", "reason": "Issue #6347 and PR #6781 are not merged into the 3.2 beta.5 protocol artifact." }, + "training_agent": { "status": "deferred", "reason": "Runtime revision staging waits for the final protocol and merged schema contract." }, + "compliance": { "status": "deferred", "reason": "Conformance scenarios must follow the final creative revision state machine and fields." }, + "training": { "status": "deferred", "reason": "Certification criteria wait until creative revision behavior is stable and shipped." } + } + }, + { + "id": "measurement_tracking_candidate", + "surfaces": { + "docs": { "status": "deferred", "reason": "Issue #6207 and PR #6786 remain under review and are not part of beta.5." }, + "training_agent": { "status": "deferred", "reason": "Tracking capability declarations wait for the working-group-approved schema." }, + "compliance": { "status": "deferred", "reason": "Vectors and scenarios wait for final tracker execution and exposure-data semantics." }, + "training": { "status": "deferred", "reason": "Measurement capability teaching waits for the final discoverability contract." } + } + }, + { + "id": "audience_activation_candidate", + "surfaces": { + "docs": { "status": "deferred", "reason": "Issue #4324 and PR #6549 remain unmerged and are not part of beta.5." }, + "training_agent": { "status": "deferred", "reason": "Activation-method discovery waits for the final product and capability schemas." }, + "compliance": { "status": "deferred", "reason": "Activation-method validation and filtering scenarios wait for the merged contract." }, + "training": { "status": "deferred", "reason": "Audience activation curriculum waits for the supported method vocabulary to ship." } + } + }, + { + "id": "radio_and_place_based_audio_candidates", + "surfaces": { + "docs": { "status": "deferred", "reason": "Issues #6167, #6139, and #5878 still require explicit 3.2 ship-or-defer decisions." }, + "training_agent": { "status": "deferred", "reason": "No approved runtime contract yet exists for delayed radio reconciliation or place-based audio." }, + "compliance": { "status": "deferred", "reason": "Conformance cannot be authored until the working group resolves the open radio contracts." }, + "training": { "status": "deferred", "reason": "Certification remains unchanged until the radio and place-based audio semantics stabilize." } + } + } + ] +} diff --git a/tests/release-instrumentation.test.cjs b/tests/release-instrumentation.test.cjs new file mode 100644 index 0000000000..4b2d3d18b7 --- /dev/null +++ b/tests/release-instrumentation.test.cjs @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const manifestPath = path.join(root, 'specs/release-instrumentation/3.2.json'); +const expectedFeatureFamilies = [ + 'agent_notifications', + 'audience_activation_candidate', + 'availability_and_outcome_planning', + 'campaign_governance_experimental', + 'canonical_creative_quality', + 'compact_media_buy_lifecycle', + 'creative_revisions_candidate', + 'delivery_reconciliation', + 'idempotency_retention_and_retry_binding', + 'measurement_tracking_candidate', + 'portable_attestations', + 'radio_and_place_based_audio_candidates', + 'request_signing_profile_3_2', + 'targeting_aware_discovery', + 'trusted_match_experimental', + 'webhook_delivery_identity_and_recovery', +]; + +test('3.2 feature families have explicit cross-surface instrumentation', () => { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.match(manifest.release, /^3\.2\.0-beta\.\d+$/); + assert.deepEqual(manifest.surfaces, ['docs', 'training_agent', 'compliance', 'training']); + assert.ok(manifest.features.length >= 10, 'expected a comprehensive 3.2 feature-family inventory'); + + const ids = new Set(); + for (const feature of manifest.features) { + assert.match(feature.id, /^[a-z0-9_]+$/); + assert.ok(!ids.has(feature.id), `duplicate feature id: ${feature.id}`); + ids.add(feature.id); + assert.deepEqual(Object.keys(feature.surfaces).sort(), [...manifest.surfaces].sort(), `${feature.id} must disposition every surface`); + + for (const surfaceName of manifest.surfaces) { + const surface = feature.surfaces[surfaceName]; + assert.ok(['covered', 'not_applicable', 'deferred'].includes(surface.status), `${feature.id}.${surfaceName} has an invalid status`); + if (surface.status === 'covered') { + assert.ok(Array.isArray(surface.evidence) && surface.evidence.length > 0, `${feature.id}.${surfaceName} needs evidence`); + for (const relativePath of surface.evidence) { + assert.equal(path.isAbsolute(relativePath), false, `${feature.id}.${surfaceName} evidence must be repo-relative`); + assert.ok(fs.existsSync(path.join(root, relativePath)), `${feature.id}.${surfaceName} evidence does not exist: ${relativePath}`); + } + } else { + assert.equal(typeof surface.reason, 'string', `${feature.id}.${surfaceName} needs a reason`); + assert.ok(surface.reason.length >= 20, `${feature.id}.${surfaceName} reason is too terse`); + } + } + } + assert.deepEqual([...ids].sort(), expectedFeatureFamilies, '3.2 feature-family inventory drifted'); +}); diff --git a/tests/targeting-aware-discovery.test.cjs b/tests/targeting-aware-discovery.test.cjs index 35d39be432..99811bac00 100644 --- a/tests/targeting-aware-discovery.test.cjs +++ b/tests/targeting-aware-discovery.test.cjs @@ -1169,8 +1169,9 @@ test("buyer teaching surfaces explain structured-first targeting", () => { } assert.match(skill, /fewer tokens/); assert.match(addieKnowledge, /No targeting-resolution echo confirms only/); - assert.match(certificationTools, /3\.2 targeting-aware objectives with schema fixtures/); - assert.match(certificationTools, /issues\/6199/); + assert.match(certificationTools, /exact 3\.2 beta\.5 wire pin with @adcp\/sdk@14\.0\.0-beta\.7/); + assert.match(certificationTools, /3\.2 targeting-aware objectives live/); + assert.doesNotMatch(certificationTools, /issues\/6199/); assert.match( certificationTools, /learning\/supplements\/buyer-briefs-and-get-products/