Skip to content

Add Deals Protocol for inventory package discovery and activation (formerly known as Curation Protocol)#218

Closed
bmilekic wants to merge 0 commit into
adcontextprotocol:mainfrom
bmilekic:deals-protocol
Closed

Add Deals Protocol for inventory package discovery and activation (formerly known as Curation Protocol)#218
bmilekic wants to merge 0 commit into
adcontextprotocol:mainfrom
bmilekic:deals-protocol

Conversation

@bmilekic

@bmilekic bmilekic commented Nov 18, 2025

Copy link
Copy Markdown
Contributor

Summary

This PR implements the complete Deals Protocol (renamed from Curation Protocol) to enable buyer agents to discover and activate pre-packaged and ad-hoc inventory deal packages from SSPs and curation platforms.

New Protocol: Deals Protocol

The Deals Protocol provides an alternative to the Media Buy Protocol for package-based buying, enabling:

  • Natural language deal discovery
  • Activation on SSP platforms (Magnite, Pubmatic, OpenX, Index Exchange, Microsoft Curate, etc.)
  • Support for PMP and Curated deal types
  • Integration with Signals Protocol for audience-targeted inventory packages

Tasks Implemented

get_deals

  • Discover deal packages using natural language descriptions
  • Simplified API: single deal_spec parameter (no filters, no deliver_to)
  • Returns deals with platforms array showing where they can be activated

activate_deal

  • Activate deals on specific SSP platforms
  • Atomic success/error responses
  • Uses deployment model similar to Signals Protocol

Core Schemas Added

Data Models (/static/schemas/v1/core/)

  • deal.json: Deal package with platforms, pricing, targeting, status
  • deal-pricing.json: Floor/Fixed/Market pricing with optional margins for curated deals
  • deal-targeting.json: Geo, devices, allow/block lists, segments (can reference Signal IDs)

Task Schemas (/static/schemas/v1/deals/)

  • get-deals-request.json: Natural language spec only
  • get-deals-response.json: Returns deals array with platform availability
  • activate-deal-request.json: Deal ID + destination platforms
  • activate-deal-response.json: Atomic response with deployments or errors

Architecture

Integration Flow:
Buyer Agent → Deals Agent → Signals Agent
(Deals Protocol) (Signals Protocol)

  • Deals Agents integrate with Signals Agents (using Signals Protocol)
  • Buyer Agents interact with Deals Agents (using Deals Protocol)
  • Parallel architecture to Media Buy Protocol's Sales Agent integration

Documentation

  • /docs/deals/overview.mdx: User-friendly protocol introduction with use cases
  • /docs/deals/specification.mdx: Complete RFC specification with task definitions and data models
  • Updated intro, README, and navigation to reflect Deals Protocol

Key Design Decisions

  1. Simplified get_deals: Natural language handles all filtering (no separate filters object)
  2. Platform availability in deals: Each deal includes platforms array showing where it can be activated
  3. SSP-focused: All examples use SSPs (not DSPs) as activation destinations
  4. Draft deals: Discovered deals have status "draft" until activated

Testing

✅ All schema validation tests pass (111 schemas validated)
✅ All example data validation tests pass
✅ TypeScript type checking passes

Changeset

A changeset has been included for minor version bump automation.

🤖 Generated with Claude Code

@bmilekic bmilekic force-pushed the deals-protocol branch 2 times, most recently from 22580e9 to 9ca575e Compare November 19, 2025 01:45
Comment thread .changeset/deals-protocol.md Outdated
@@ -0,0 +1,33 @@
---
"adcontextprotocol": minor

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should be major

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we assume the next significant release is 3.0 - thinking January - then that would make sense. we may want to do a beta branch before that. I started a product roadmap doc if you want to sketch this in there

@bmilekic

bmilekic commented Dec 5, 2025

Copy link
Copy Markdown
Contributor Author

This PR has been replaced by #273

bokelley added a commit that referenced this pull request Apr 25, 2026
… 2 of #3177) (#3218)

* feat(registry): crawler caches adagents.json and projects catalog (PR 2 of #3177)

Wires the adagents.json crawler to write the publishers overlay (migration 432
from #3195) in lockstep with the existing discovered_properties /
agent_property_authorizations writes, and projects parsed properties into
catalog_properties + catalog_identifiers in the same transaction.

Closes the gap surfaced by Setupad escalation #218: gatavo.com lives in
discovered_properties but never reached the catalog because migration 336 was
a one-time seed. Every successful crawl now lands in both places.

Implementation mirrors brand registry's writer pattern:
- new server/src/db/publisher-db.ts with upsertAdagentsCache, modeled on
  brand-db.ts upsertDiscoveredBrand
- crawler.ts cacheAdagentsManifest helper called once per validated domain
  in both crawl paths (registered publishers, buying-agent claims), modeled
  on the upsertBrandProperties projection at crawler.ts ~200-237
- catalog rows tagged evidence='adagents_json', confidence='authoritative'
  to match the migration 336 seed so newly crawled properties are
  indistinguishable from seeded ones
- normalizeIdentifier applied before catalog_identifiers insert so
  www-prefixed and mixed-case publisher inputs collapse to the canonical
  property_rid

Old tables continue to be written via federated-index-db. Reader paths are
untouched; PR 4 will swap reads to the new tables, and PR 5 will drop the
legacy ones.

Tests cover: publishers cache shape and ON CONFLICT preservation of org
metadata; catalog_properties + catalog_identifiers materialization with the
right evidence/confidence; identifier normalization; property_rid stability
across re-crawls; and the dual-write fallback to discovered_properties +
agent_property_authorizations.

Refs #3177. Builds on #3195.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(registry): reviewer feedback on crawler cache projection

Code- and security-review on PR #3218 surfaced four real issues:

1. cacheAdagentsManifest was only called from populateFederatedIndex.
   crawlSingleDomain (on-demand HTTP API) and crawlSingleDomainForCatalog
   (catalog crawl-queue path) successfully validated adagents.json but
   skipped the new cache, so a domain crawled via either path landed only
   in legacy tables — the exact failure mode the changeset claims to close.
   Add the cache call to both paths.

2. Catalog identity hijack via shared identifiers. A malicious manifest
   could list a victim's identifier (domain:cnn.com) alongside its own and
   the writer's existence-check would reuse the victim's property_rid,
   silently binding the attacker's own identifier into the victim's
   property. The override layer (PR #3195) is agent-keyed, not
   property-rid-keyed, so it doesn't remediate this. The land-grab variant
   was equally bad: the attacker claims first, victim's later crawl
   inherits the attacker's adagents_url via COALESCE.

   Fix: existence query now joins catalog_properties.created_by. If any
   matching rid was authored by another publisher's adagents.json, refuse
   the projection rather than rebind the identifier or extend the
   conflicting property. If the identifier set spans multiple distinct
   own-side rids, also refuse — silent merging requires moderation
   (catalog_disputes).

3. rss_url silent rollback. normalizeRssUrl preserves URL path case;
   chk_identifier_lowercase requires the entire identifier_value to be
   lowercase. A publisher with a mixed-case path triggered a 23514
   check_violation mid-transaction and the whole crawl rolled back into a
   one-line warn log.

   Two-part fix: lowercase identifier values defensively after
   normalizeIdentifier (matches migration 336's lower(value) seed
   behavior); and wrap each property projection in a SAVEPOINT so a
   constraint violation skips the offending property without losing the
   rest of the manifest or the cache write.

4. Smaller items: ORDER BY on the existence query for determinism, drop
   the unused PublisherDatabase singleton export, strip the historical
   "PR 2 of #3177" framing from the writer's docstring (CLAUDE.md flags
   this as no-historical-naming).

Tests added for: cross-publisher claim refusal (the cnn.com hijack),
multi-rid conflation refusal, isolated property failure not aborting the
rest of the manifest, and rss_url lowercase. Cleanup widened to FK-safe
property_rid join so per-test fixtures can vary identifier values.
All 11 cases pass; existing 16 schema tests still green.

Refs #3177, #3218.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(registry): close land-grab and seed-rid takeover attack vectors

Second-pass security review found two more vectors the first round of
fixes didn't fully close:

1. Land-grab via first-claim. The cross-publisher refusal only blocked
   the rebind direction (victim crawled first, attacker rebinds later).
   Reverse ordering — attacker crawls first, claims the victim's domain
   alongside its own — was unblocked: attacker mints a rid pointing
   domain:victim.example at attacker.example/.well-known/adagents.json,
   victim's later crawl is then refused, and catalog readers permanently
   misattribute the victim's domain to the attacker.

2. Seed-rid takeover via adagents_url COALESCE. When a matched rid's
   created_by was anything other than adagents_json:* (system seeds,
   community/member_resolve, brand_json, emails), the conflicting filter
   skipped it and the reuse branch ran UPDATE catalog_properties SET
   adagents_url = COALESCE(NULL, $attacker_url). Attacker captured the
   authority pointer on a victim's seed rid via a single bundle ID.

The fix: add a publisher-anchor rule to projectPropertyToCatalog.

  - Cross-publisher domain claims are refused at the front of the function.
    A `domain` or `subdomain` identifier in the property must equal the
    publisher's domain or be a subdomain of it. This kills the land-grab.

  - Foreign-rid reuse now requires an anchor identifier in the manifest.
    A non-adagents seed/community rid can only be adopted when the
    publisher's manifest carries at least one domain/subdomain anchor
    proving authority. Without an anchor, the projection is refused
    rather than COALESCE-rebinding adagents_url.

Legitimate flows still work:
  - App publishers with no domain identifier still mint new rids for
    bundle-only properties. ON CONFLICT DO NOTHING drops bundle IDs
    already claimed (first-claimer wins on bundles is a known limitation
    that needs App Store corroboration; that's beyond this PR).
  - Re-crawls (created_by === adagents_json:{publisher}) skip the anchor
    requirement — re-crawl semantics unchanged.
  - Anchored manifests can adopt seed rids (modeling the migration 336
    backfill case where a seed row had both a domain and a bundle id).

Tests added:
  - Attacker-first land-grab: refused at the anchor stage; victim's later
    crawl still lands cleanly with own created_by.
  - Seed-rid takeover via unanchored bundle ID: refused; seed adagents_url
    stays NULL.
  - Seed-rid adoption with anchor: legitimate publisher takes ownership,
    seed adagents_url updates to publisher's URL.

All 14 cases pass; the four other registry test files (registry-overlay
schema, pipeline client, search, feed; 52 cases combined) still green.

Refs #3177, #3218.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Apr 26, 2026
… (#3244)

* feat(registry): swap property readers to catalog UNION (PR 4a of #3177)

Property-side readers in federated-index-db.ts and property-db.ts now
union the catalog-side publishers cache (PR 1 of #3177) with the legacy
discovered_properties / discovered_publishers tables. Crawl-sourced
properties that landed via the new writer (PR 2 / #3218) but missed the
legacy table — Setupad escalation #218's gatavo.com is the canonical
example — now surface on the registry endpoints alongside legacy data.

Functions changed:

- hasValidAdagents: returns true if either publishers (with
  source_type='adagents_json') or discovered_publishers has a positive
  signal. Three-state contract preserved (null when neither has the
  domain; false only when legacy says false and catalog has no row).

- getPropertiesForDomain (federated-index-db): UNION over
  discovered_properties and properties extracted from
  publishers.adagents_json JSONB, deduped on
  (publisher_domain, name, property_type) with legacy preferred so
  callers holding a discovered_properties.id keep working. Catalog-only
  rows surface property_rid as id (also a UUID; same call sites).

- getDiscoveredPropertiesByDomain (property-db): same UNION shape.

- getAllPropertiesForRegistry: three-way UNION (hosted + legacy crawl +
  catalog-only). Hosted wins over crawl (existing precedence carve-out
  preserved); legacy wins over catalog within the crawl side so the
  agent_count subquery against agent_property_authorizations remains
  meaningful during the dual-write window.

- getPropertyRegistryStats: mirrors the registry's three-way UNION so a
  domain with both legacy and catalog rows is counted exactly once.

Authorization-side readers (getPropertiesForAgent,
findAgentsForPropertyIdentifier, getPublisherDomainsForAgent) stay on
legacy tables — those need the authorization model decision still
deferred from PR 1 and ship in PR 4b.

PR 3 baseline coverage (registry-reader-baseline-* test files) all pass
unchanged: 27 properties + 12 public-endpoints + 24 authorizations + 3
mcp = 66 cases. Sibling registry tests (registry-overlay-schema,
registry-search, registry-feed, registry-pipeline-client,
registry-crawler-cache) also pass — 66 more cases, no regressions.

Refs #3177. Builds on #3195 (schema), #3218 (writer cutover), #3221
(reader baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(registry): harden property readers against malformed adagents.json

Code- and security-review on PR #3244 converged on one Must Fix and a
companion writer-side defense. publishers.adagents_json is publisher-
controlled JSONB (the migration 432 comment is explicit about it), and
the AdAgentsManager validator only type-checks `authorized_agents` —
nothing forces `properties`, `tags`, or `identifiers` to be arrays. A
publisher serving a JSON-valid manifest with `properties: "x"` (string)
or any non-array value used to crash the readers via:

  jsonb_array_elements(p.adagents_json->'properties')   -- 22023 error
  jsonb_array_length(p.adagents_json->'properties')     -- 22023 error
  jsonb_array_elements_text(prop->'tags')               -- 22023 error

For per-domain readers (getPropertiesForDomain, getDiscoveredProperties
ByDomain) that's a per-tenant 500. For the registry list/stats readers
the catalog_only CTE selects across every publisher, so one poisoned
manifest takes down the entire `properties://registry` MCP listing.

Two layers of defense:

1. SQL-side jsonb_typeof guards. Every jsonb_array_elements /
   jsonb_array_length call in the four readers now wraps its argument
   with `CASE WHEN jsonb_typeof(...) = 'array' THEN ... ELSE '[]'::jsonb
   END` (or `0` for length). Identifiers and tags get the same guard so
   a property with `tags: "shopping"` or `identifiers: null` no longer
   throws.

2. Writer-side normalization. publisher-db.upsertAdagentsCache now
   normalizes `properties` and `authorized_agents` to arrays before
   stringifying into JSONB. Defends against future validator regressions
   and rows that landed before this PR.

Both belt and suspenders so the readers are safe regardless of writer
hygiene, and the writer doesn't rely on validator details that may
loosen later.

Also adds:

- migration 433 — partial index on catalog_properties(created_by,
  property_id) WHERE created_by LIKE 'adagents_json:%'. The new readers
  do a per-property LATERAL join against catalog_properties; without
  this index the lookup is a sequential scan per row.

- registry-reader-poisoned-manifest.test.ts — 6 cases that seed
  malformed bodies directly via SQL (bypassing the writer's
  normalization) and assert each reader returns []/0 instead of
  throwing.

All 66 PR 3 baseline cases still pass. 14 crawler-cache + 16 overlay-
schema cases also pass. 6 new poisoned-manifest cases pass — 96 cases
total green.

Refs #3177, #3244.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants