Skip to content

feat(catalog-sync): v3.1 catalog sync cluster (#4761, #4762, #4763)#4767

Merged
bokelley merged 17 commits into
mainfrom
bokelley/catalog-change-feeds
May 19, 2026
Merged

feat(catalog-sync): v3.1 catalog sync cluster (#4761, #4762, #4763)#4767
bokelley merged 17 commits into
mainfrom
bokelley/catalog-change-feeds

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Three companion v3.1 proposals for catalog mirroring between AdCP agents and consumers (storefronts, federated marketplaces, registries). Independent and complementary — agents MAY adopt any subset.

Additive across the board — new optional fields, new conditional schemas, new capability stanzas, new spec doc. No breaking changes; safe in a minor release.

#4762get_signals wholesale mode

  • signals/get-signals-request.json: discovery_mode enum (brief default, wholesale); replaced existing anyOf gate with a oneOf so wholesale bans signal_spec / signal_ids and brief still requires one.
  • signals/get-signals-response.json: incomplete[] with scopes signals / pricing / catalog — partial completion signalled inline, not via async/Submitted handoff.
  • protocol/get-adcp-capabilities-response.json: signals.discovery_modes. Agents not declaring "wholesale" MAY return INVALID_REQUEST for wholesale calls.
  • Docs: wholesale enumeration section, authorization/provenance preservation for marketplace signals, pricing scope rules, capability probing, refinement-table addition.

#4761catalog_version conditional fetch

  • if_catalog_version + if_pricing_version request fields added to both get-products-request and get-signals-request.
  • catalog_version, pricing_version, unchanged response fields added to both responses.
  • Encoded as an explicit oneOf so the unchanged response (with products / signals omitted) is schema-valid without breaking the standard required-payload contract.
  • Tokens are opaque and scoped to the request-parameter tuple that produced them. Pre-v3.1 agents that ignore the conditional fields simply return the full payload — semantically correct, just inefficient.
  • Pagination interaction: if the catalog mutates mid-pagination, sellers SHOULD return the new catalog_version on each page; consumers SHOULD restart from cursor: null on a mid-pagination version change.

#4763 — Per-agent catalog change feed

  • New specs/catalog-change-feed.md modeled on specs/registry-change-feed.md: UUID-v7 cursor feed, denormalized payloads, optional webhook subscriptions.
  • Event taxonomy: product.{created,updated,priced,removed}, signal.{created,updated,priced,removed}, catalog.bulk_change (fast-forward for rate-card sweeps).
  • protocol/get-adcp-capabilities-response.json: top-level catalog_change_feed stanza (supported, retention_window_days ≥7, webhooks_supported, event_types[]).
  • Endpoints (GET /catalog/events, POST /catalog/subscriptions) live on the agent itself, not the registry. Authorization scope mirrors wholesale enumeration.

How they compose

A consumer mirroring a stable catalog uses catalog_version alone for cheap "anything new?" probes.
A consumer needing near-real-time tracking bootstraps via wholesale enumeration (get_products or get_signals), persists the returned catalog_version, then steady-states on the change feed for event-level deltas. catalog.bulk_change is the fast-forward back to wholesale resync.

Test plan

  • npm run test:schemas — all 7 suites pass (548 schemas)
  • npm run test:json-schema — 270 passed, 0 failed (all $schema-tagged JSON blocks in docs)
  • npm run test:examples — 36 passed
  • npm run test:composed — 43 passed
  • npm run test:pagination-invariant — 16 passed
  • npm run test:version-envelope — passing
  • npm run build:schemas — clean
  • Snippet tests have ~10 pre-existing failures from unreachable test-agent.adcontextprotocol.org — not caused by these changes (verified by stashed baseline)
  • Reference implementation in prebid salesagent (follow-up, tracked as v3.1 conformance prep)

Refs #4761, #4762, #4763.

🤖 Generated with Claude Code

bokelley and others added 4 commits May 19, 2026 04:02
Three companion v3.1 proposals for catalog mirroring between AdCP agents
and consumers. Independent and complementary — agents MAY adopt any subset.

#4762 — get_signals wholesale mode
- discovery_mode enum on get-signals-request (brief default | wholesale)
- oneOf gate so wholesale bans signal_spec/signal_ids and brief still
  requires one (replaces existing anyOf)
- incomplete[] on get-signals-response (scopes: signals, pricing, catalog)
- signals.discovery_modes capability declaration
- Docs: wholesale section with auth/provenance, pricing scope, capability
  probe, refinement table addition

#4761 — catalog_version conditional fetch (ETag-style)
- if_catalog_version + if_pricing_version on get-products-request and
  get-signals-request
- catalog_version, pricing_version, unchanged on both responses
- oneOf preserves the required-payload contract while allowing unchanged:
  true responses to omit products[] / signals[]
- Docs: Catalog versioning section in both task references with unchanged
  example and pagination-interaction rule

#4763 — Per-agent catalog change feed
- specs/catalog-change-feed.md modeled on specs/registry-change-feed.md:
  UUID-v7 cursor feed, denormalized payloads, catalog.bulk_change
  fast-forward, optional webhook subscriptions
- catalog_change_feed top-level capability stanza (supported,
  retention_window_days >=7, webhooks_supported, event_types[])

Additive across the board — no breaking changes; safe in a minor release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's audit-oneof check flags new undiscriminated oneOf entries (#3917).
The three new conditional shapes added in this PR express the same
constraints without oneOf:

- get-signals-request: discovery_mode=wholesale bans signal_spec/signal_ids;
  brief mode requires one of them
- get-products-response, get-signals-response: when unchanged=true, the
  catalog payload is omitted and catalog_version is required; otherwise
  the payload is required

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. if_pricing_version requires if_catalog_version (schema-level via
   dependencies block + explicit evaluation order in docs and field
   descriptions). Pricing has no structural baseline of its own.

2. Port the three load-bearing security sections from the registry feed
   spec into specs/catalog-change-feed.md: advisory event payloads,
   re-fetch coalescing, feed-event content signing on the 4.0 track.

4. Replace the soft >100-entities example for catalog.bulk_change with a
   SHOULD: emit when an operation affects >5% of catalog OR >100 entities,
   whichever is smaller. Prevents flood-attack via deliberately-high
   threshold on small agents.

5. Document the X-AdCP-Catalog-* vs X-Registry-* header asymmetry — the
   catalog feed lives on each agent's origin (shares HTTP space with
   other AdCP surfaces), the registry feed lives on the central registry
   origin. Subscribers dispatch on header namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses concerns raised by ad-tech-protocol-expert, security-reviewer,
adtech-product-expert, and docs-expert on the catalog-sync cluster
(#4761, #4762, #4763).

Cache layering (the big addition)

Adds cache_scope: "public" | "account" to get_products and get_signals
responses. REQUIRED when the request includes `account`; structurally
"public" otherwise. catalog_version and pricing_version tokens are now
explicitly scope-keyed — buyers cache (cache_scope, version) pairs and
present the matching token on the next request. Sellers MAY downgrade an
account from "account" back to "public" by returning the public version
on a previously-account-scoped tuple. The change feed declares
applies_to.scope on *.priced and *.updated events to invalidate the
right cache layer.

Removes the silent-stale-mirror failure mode in the prior shape, where
a public-layer mirror over N accounts would either miscache wrong prices
or refetch per-account on every event. Most accounts at most sellers
land in "public" and share one cache entry.

New `Cache layering` doc sections in both task references and full event
side at specs/catalog-change-feed.md §"Cache layering and event scoping".

Security blockers

- Per-caller scope filtering MUST apply at event-emission time, not just
  authentication (closes multi-tenant leak between brand A and brand B
  on a shared agent)
- Anti-replay: webhook envelope now signs X-AdCP-Catalog-Timestamp +
  X-AdCP-Catalog-Delivery-Seq into the HMAC; receivers reject skew >5min
  and out-of-order deliveries
- SSRF guards on POST /catalog/subscriptions: HTTPS-only, DNS rebinding
  defense at registration AND each delivery, metadata-service IPs
  blocked, delivery-target challenge SHOULD
- Subscription CRUD scoped to creator principal (GET/DELETE/PATCH); per-
  principal cap (suggest 10); secret rotation via PATCH with overlap
- Jittered coalescing 60-300s to prevent catalog.bulk_change thundering-
  herd amplification
- effective_at in the future is a pre-announce; consumers MAY warm cache
  but MUST NOT bind, MUST re-verify post-effective, seller MAY retract
- Honest posture text: re-verify defends transport tamper, NOT operator
  compromise (the agent re-confirms its own lie); operator-compromise
  defense lives in signed create_media_buy and the R-1 4.0 track

Protocol blockers

- RETENTION_EXPIRED added to enums/error-code.json with enumDescriptions
  + enumMetadata per the dual-surface convention from #3738
- media_buy.buying_modes capability added symmetric with new
  signals.discovery_modes (closes the asymmetry buyers couldn't probe
  against)
- unchanged: false explicitly documented as permitted no-op-affirmation
  but MUST carry the payload; absent unchanged is equivalent to false
- catalog_version MUST be returned on every paginated page (not only
  the first) when feed is declared; SHOULD otherwise. Buyer MUST restart
  pagination on mid-stream version change.

Protocol tighten

- *.removed events carry optional removal_reason (withdrawn |
  cancellation | depublication | policy_takedown); downstream UX differs
- filters canonicalization rule: keys sorted lexicographically, defaults
  treated identically, set-semantic arrays sorted. Closes silent stale-
  mirror bugs from buyer SDKs hashing equivalent filters differently.

Docs

- Fixed orphaned "This tells buyers..." prose that my prior change moved
  away from extensions_supported
- Fixed "Returning 312 products" example that paired with products: []
- Discoverability note in get_adcp_capabilities pointing to the
  if_catalog_version tokens on the task pages
- catalog-changed example mirrored into get_signals.mdx; pagination +
  canonicalization rules unified across both task pages

Refs review threads from four expert agents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-merge handled get-products-response.json (main added `extensions`
property, this branch added cache_scope/catalog_version/etc — disjoint).

Manual resolution in static/schemas/source/enums/error-code.json:
keep my RETENTION_EXPIRED alongside main's seven new FORMAT_*/PIXEL_*
codes in the enum list, enumDescriptions, and enumMetadata blocks.

Validation green: schemas, json-schema, oneof-discriminators, examples,
composed, error-codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EmmaLouise2018
EmmaLouise2018 previously approved these changes May 19, 2026
bokelley and others added 3 commits May 19, 2026 07:25
Closes the pre-flight discoverability gap on conditional fetch — agents
that support catalog_version probes but not the full change feed had no
capability declaration, so buyers building a session across N agents
couldn't fast-path which agents to bother caching versions for.

New top-level catalog_versioning capability:
- supported (required boolean) — whether catalog_version is returned and
  if_catalog_version is honored
- pricing_version_separate (boolean) — whether pricing_version is tracked
  independently. When false, sending if_pricing_version is wasted bytes.
- cache_scope_account (boolean) — whether the agent publishes per-account
  overlays. When false, all responses are cache_scope: public regardless
  of whether account was provided.

Symmetric with catalog_change_feed — independent stanzas, agents MAY
declare either, both, or neither.

Refs #4767, follow-ups #4787 (storyboards), #4788 (training agent),
#4789 (SDK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The error-code-drift lint requires every code present in source but
missing from origin/3.0.x to carry a disposition entry. RETENTION_EXPIRED
is new in 3.1 (catalog change-feed, #4763) and was added to the enum
without the matching disposition row. CI caught it on the prior push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Protocol:
- Canonicalize signals scoping tuple between request and response (was
  (agent, account, filters, discovery_mode) on request vs the correct
  (agent, discovery_mode, filters, destinations, countries [+account_id])
  on response — implementers reading request side would silently miscache
  when destinations/countries varied)
- Mirror the products-request scoping description to match the response-
  side scope-keyed model
- Add `expired` to removal_reason enum (flight-end / seasonal retirement)
- Clarify policy_takedown covers regulator/legal takedowns too
- Specify unchanged: true behavior mid-pagination (whole-catalog-vs-cached,
  not per-page)
- Add consumer-precedence note in capabilities (feed > versioning > poll)
- Clarify *.created MUST carry applies_to: account when introducing an
  account-only entity (preventing accidental public-cache leak)

Security:
- Independent per-(subscription_id, event_id) jitter — closes the timing-
  oracle on withheld account_ids (deterministic seed would let collusive
  observers triangulate the affected set)
- Explicit TLS floor: TLS 1.2+ (1.3 RECOMMENDED), cert chain validation
  against system trust store, SNI match, reject self-signed/expired
- Bind challenge-flow POST to the same URL constraints (challenge is not
  a carve-out from anti-SSRF rules)
- delivery_seq MUST NOT reset on secret rotation (closes anti-replay gap
  during overlap window)
- Retraction effective_at semantics: MUST be <= original effective_at;
  retraction identified by latest-event-for-entity, not payload match
- Note that cache_scope_account: true is a small market-posture signal;
  agents preferring confidentiality MAY omit and detect-on-call

Docs:
- get_signals.mdx now documents cache_scope, catalog_version,
  pricing_version, unchanged in a Response Metadata table parallel to
  get_products.mdx (closes the symmetry break the docs reviewer flagged)
- Fix intra-doc anchor: #catalog_versioning → #catalog-versioning
  (Mintlify slugifies underscores)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 9 commits May 19, 2026 08:20
Adds discriminated event-payload schemas so /catalog/events response is
schema-validated, not just markdown-described. Closes the conformance gap
the protocol reviewer flagged on the second-pass review (#4767).

- static/schemas/source/core/catalog-event.json — discriminated on
  event_type with 9 oneOf branches: product.{created,updated,priced,
  removed}, signal.{created,updated,priced,removed}, catalog.bulk_change.
  Each variant declares required+const event_type so the audit-oneof
  discriminator check passes.
- static/schemas/source/core/catalog-events-response.json — feed-poll
  response wrapper (events[], next_cursor, has_more, retention_window_days).
- Reusable $defs:
  - appliesTo — discriminated on scope (public | account); account
    variant carries optional account_ids
  - removalReason — enum: withdrawn | cancellation | expired |
    depublication | policy_takedown
- specs/catalog-change-feed.md cites both schemas from the GET
  /catalog/events section as the conformance contract.

The registry feed has the same gap (markdown-only event payloads); the
parallel registry-event schema work tracked in #4792.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TypeScript Build CI step regenerates static/openapi/registry.yaml
from Zod source and fails on any git diff. PR #4771 added 685 lines of
brand-registry endpoint documentation directly to registry.yaml because
those routes (brand.json, brand-logos, brand-ownership, brand-wiki) are
docs-only — the Express routes exist but were never given Zod schemas.
That hand-edit fails the freshness lint on every PR, and main itself is
currently red on the same step.

Two ways to fix: (a) write Zod schemas for the 7 brand-registry paths
(real engineering, ~9 operations with multipart upload + full error
matrices), or (b) make the generator merge-preserve hand-authored
content. This PR takes (b) — generator now reads the on-disk yaml and
unions its tracked output with anything already there. Paths, component
schemas, and tag descriptors are all preserved when the generator
doesn't own them; generator output wins on conflicts so Zod-backed
paths remain authoritative.

Brand Logos and Brand Wiki tag descriptions moved into TAG_DESCRIPTIONS
so they emit in their documented position (between Brand Resolution and
Property Resolution) rather than appended at the tag list's tail.

registry.yaml carries a 2-line whitespace normalization from YAML re-
serialization — quoted strings collapse to unquoted where YAML permits,
semantically identical.

After this PR: build:openapi && git diff --exit-code is clean on main
and the lint stops blocking PRs that haven't touched openapi at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeQL flagged scripts/generate-openapi.ts:128 (existsSync followed by
readFileSync). The file could in principle change between the check and
the read. Trivial in this generator's actual environment, but the fix
is a single-syscall pattern: readFileSync directly, treat ENOENT as
"no prior yaml," rethrow anything else.

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

# Conflicts:
#	static/openapi/registry.yaml
Three quiet-failure paths the spec prose acknowledged but schemas didn't
enforce, plus the minor copy items.

1. cache_scope now REQUIRED on every response (was: required-when-account
   in the prose only). Schema couldn't see the request, so the safer
   formulation is unconditional: every response declares its cache layer.
   When the request had no account, MUST be 'public'. When the request
   had account, seller declares 'public' (this account prices off the
   public rate card — buyer dedupes) or 'account' (custom overrides).
   Without this, a seller silently omitting cache_scope on an account-
   scoped response would cause buyers to mis-key the cache and serve
   account-overlay payloads to other accounts — the canonical safety
   invariant of the entire two-layer model. Both products and signals
   if/then/else `else` and `then` branches now require cache_scope.

2. incomplete[].scope on get-products-response gains 'catalog' for
   parity with signals — sellers in buying_mode: 'wholesale' that can't
   enumerate the full catalog in time_budget previously had no precise
   scope to declare.

3. applies_to now REQUIRED on *.created events (was: optional with
   default-public). Same class of bug as #1 — a forgotten applies_to on
   an account-only created event leaks the new entity into every
   consumer's public cache. Schema-required explicit declaration prevents
   the quiet-failure path. Sellers MUST emit { scope: 'public' }
   explicitly for public-layer additions rather than rely on a default.

4. retracts_event_id added (optional) to product.priced and signal.priced.
   Lets consumers detect pre-announce retraction deterministically by
   event_id lookup rather than maintaining per-entity 'latest-event'
   state. When present, retraction effective_at MUST be <= retracted
   event's effective_at.

5. RETENTION_EXPIRED description tightened — UUID v7 carries no agent
   identity, so 'cursor I never issued' and 'cursor I retired' collapse
   to the same not-in-log lookup. Spelled out: any well-formed cursor
   the agent cannot locate returns this code; remediation is identical.

6. event_id description notes the format: uuid schema constraint accepts
   any UUID version while the spec MUSTs v7 — schema validators alone
   won't catch v4-vs-v7 confusion.

7. catalog.bulk_change.recommendation enum description explains the
   single-value-enum-not-boolean choice as forward-compat affordance for
   future recommendation vocabulary (cursor_advance, subscribe_to_subscope).

8. /.well-known/adcp-catalog-webhook-challenge callout: AdCP-defined,
   intentionally not in IANA registry, scoped to this protocol.

Examples in get_signals.mdx updated to include cache_scope: 'public'
(7 example blocks) since the schema now requires it.

External-review thread on PR #4767.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The catalog-sync cluster (#4761 conditional fetch + #4762 wholesale
signals + #4763 per-agent change feed, landed via #4767) is one of the
larger additive surfaces in 3.1 and was missing from the what's-new
overview.

Adds:
- New "you can mirror catalogs without burning bandwidth" bullet in
  Why upgrade
- Three rows in the At a glance table (catalog mirroring, wholesale
  signals, per-agent change feed)
- Full Headline feature section (Catalog mirroring — conditional fetch,
  wholesale signals, per-agent change feed) positioned between Webhook
  foundation and Canonical creative formats, covering the three
  mechanisms, cache layering, honest security posture, and capability
  declarations + new event-payload JSON Schemas
- Three rows in the Adopter action table (mirror-maintaining consumer,
  signals agent, sales/signals agent serving mirrors at scale)
- Description meta updated to mention catalog-sync

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

The bundled-schema validation fixture in composed-schema-validation.test.cjs
exercised the get-products-response bundle without cache_scope. After the
external-review fix making cache_scope schema-required on every response,
the fixture failed the bundle's `else` branch. Local `test:composed` passed
because it loads the source schema (which re-resolves $refs at runtime);
CI's bundled-schema path loads the dist artifact, which is where the
required: ["products", "cache_scope"] branch lives self-contained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six spec items and one nit from the pre-merge review.

1. Version-pin cache_scope. Schema stays REQUIRED on every response (the
   safety invariant of the two-layer cache depends on it), but the
   description and changeset now explicitly document the validator
   obligation: SDKs MUST select the validator based on the server-
   declared adcp_version. For 3.0.x-declared responses, the 3.1 cache_
   scope-required constraint MUST be relaxed. This is a tightening
   within 3.1, not a 3.0 break — but adopter SDKs that hardcode the 3.1
   schema without version-pinned validation will reject correct 3.0
   traffic, so the obligation is normative.

2. next_cursor always echoes the request cursor when events[] is empty.
   Removes the dual state machine (was: "absent OR echoes the request
   cursor") so SDKs do unconditional `cursor = response.next_cursor`.
   Only absent on the initial poll (no cursor was sent and no events
   returned).

3. Drop unchanged: false. Tightened to const: true | absent — the
   absence of the field IS the "response carries products" signal.
   Removes the footgun where some sellers emit { unchanged: false,
   products: [...] } and some emit { products: [...] } for the same
   state. unchanged: false is now a schema error.

4. types= ignore-unsupported. GET /catalog/events?types=signal.priced
   against a products-only agent returns an empty events[], not
   INVALID_REQUEST (HTTP Accept semantics). Consumers SHOULD pre-filter
   against the agent's declared event_types[] capability.

5. filters canonicalization forward-compat. New filter fields in 3.x
   minor versions MUST declare set-vs-sequence semantics; absent an
   explicit declaration, the rule defaults to set-semantics (sort
   before hashing). Prevents silent drift between sellers and SDKs as
   new filters land.

6. Webhook as go-poll signal. Receivers MUST NOT consume the webhook
   body as a source of state — they MUST fetch via feed_url to apply
   events. Collapses the SDK's idempotency surface from two paths to
   one, prevents the footgun where webhook-arrival order != cursor
   order due to coalescing jitter.

Nit (already symmetric in current state, confirmed): incomplete[].items
on both get-products-response and get-signals-response use
additionalProperties: false. No change needed.

Examples in get_products.mdx and get_signals.mdx updated to drop
`unchanged: false` lines from the catalog-changed examples (the field
is now schema-prohibited at that value).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 9357289 into main May 19, 2026
17 checks passed
@bokelley bokelley deleted the bokelley/catalog-change-feeds branch May 19, 2026 21:13
bokelley added a commit to adcontextprotocol/adcp-client that referenced this pull request May 19, 2026
…g-sync cluster) (#1879)

* feat(types, validation): opt-in support for AdCP 3.1.0-beta.1 (catalog-sync cluster)

Adopters pin `adcpVersion: '3.1.0-beta.1'` (or '3.1-beta') to validate
against the beta schemas including the catalog-sync cluster from
adcontextprotocol/adcp#4767: `if_catalog_version` / `if_pricing_version`
conditional fetch on `get_products` / `get_signals`, wholesale
`discovery_mode` for signals, the `catalog_change_feed` capability stanza,
and the new `core/catalog-event.json` + `core/catalog-events-response.json`.

The SDK's primary pin stays at the GA `ADCP_VERSION` — this is a
side-bundle, not a default move. The `latest` symlink in `schemas/cache/`
continues to point at the GA pin.

Plumbing:
- scripts/sync-3-1-beta-schemas.ts wraps syncSchemas() with `latest`
  symlink restoration + HEAD-restore of tracked side-effect paths
  (`schemas/registry/registry.yaml`, protocol-managed skills) so an opt-in
  beta sync never bumps GA-pinned check-in surfaces.
- scripts/generate-3-1-beta-types.ts emits the parallel
  `src/lib/types/v3-1-beta/tools.generated.ts` surface, mirroring the v2.5
  codegen pipeline with two additions: intra-schema `$ref` reseating
  (`#/oneOf/...` → `#/definitions/<Name>/oneOf/...`) for the schemas that
  self-reference inside their own tree, and index-signature widening on
  inline anonymous objects so optional named properties don't collide
  with the index type (TS2411).
- COMPATIBLE_ADCP_VERSIONS extended via `COMPATIBLE_PREFIX`. The major/minor
  gate in `buildCompatibleVersions` stays closed for primary-pin moves —
  opt-in betas land in the prefix list, GA bumps still require an explicit
  range extension.
- package.json `./types/v3-1-beta` export + typesVersions entry mirror
  the v2.5 pattern. CatalogSync client (the consumer-facing change-feed
  mirror) lands in a follow-on PR.

Verified:
- tsc --noEmit clean (0 errors)
- format:check clean
- npm run build:lib clean; dist/lib/schemas-data/3.1.0-beta.1/ ships
  alongside 3.0 and v2.5
- 21/21 schema-loader-per-version + v2-5-types-import tests pass;
  21/21 validation tests pass
- End-to-end: resolveBundleKey('3.1-beta') resolves; getValidator
  ('get_products', 'sync', '3.1.0-beta.1') compiles; a beta-shaped
  response (unchanged: true, catalog_version, cache_scope: 'public')
  validates against the beta schema

Closes adcontextprotocol/adcp#4794 (the SDK-side schema/types prerequisite —
CatalogSync client lands separately).

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

* fix(types, codegen): address expert convergence on PR #1879

Five fixes from the parallel DX/protocol/code/security expert reviews of
the catalog-sync opt-in PR. Convergent blockers from three of the four
reviewers; security findings were inherited from `sync-schemas.ts` and
filed for follow-up rather than this PR.

1. Protocol-safety on CatalogChangeEvent (the most important fix).
   `core/catalog-event.json` declares `payload` as root-required but lists
   only the discriminator (`event_type`) in each branch's local `required`.
   `json-schema-to-typescript` emitted `payload?: BranchShape` in each
   union branch, so intersection with the wrapper's `payload: {}` produced
   `payload: {}` — load-bearing branch-specific shape lost at the type
   level.

   Added `propagateRootRequiredIntoOneOfBranches` to the codegen pipeline:
   for any schema with `oneOf` + root-level `required`, lift the root
   required fields into every branch's required array before handing to
   jsts. Each branch now emits `payload: BranchShape` (non-optional), the
   intersection narrows correctly, and discriminated-union safety survives
   codegen.

   Ajv runtime validation already enforced the spec semantics (Ajv reads
   the original schema with `required` intact); this fixes only the TS
   surface adopters see.

2. WIRE-VERSION-COMPAT.md playbook now describes both compat shapes.
   Added a "Two compat patterns — pick the right one" comparison table at
   the top and a full "Recipe: opt-in side-bundle" section covering the
   newer-than-pin pattern: tarball-not-SHA, latest-symlink restore,
   RESTORE_PATHS from HEAD, COMPATIBLE_PREFIX extension, the three
   preprocessing passes, and the upgrade story for beta.N to beta.N+1.

3. Changeset now recommends `'3.1-beta'` (release-precision) as the
   canonical pin. Full-semver `'3.1.0-beta.1'` is now a trailing sentence
   for bit-fidelity in cross-version interop tests. Adds the
   namespace-import recommendation (`import * as V31Beta`) since flat-named
   imports will collide with the GA surface. Includes a 6-line poll-loop
   snippet so adopters can use this surface today without waiting for
   CatalogSync.

4. Removed dead `readdirSync` import + `void readdirSync` footer from
   `generate-3-1-beta-types.ts`. The defensive comment was factually wrong
   — `loadStandaloneCoreSchemas` uses only `existsSync` + `readFileSync`.

5. Runtime-validator test added at
   `test/lib/schema-loader-per-version.test.js`. Exercises
   `getValidator('get_products', 'request', '3.1.0-beta.1')` against an
   `if_catalog_version`-bearing payload (must validate) AND an
   `if_pricing_version`-without-`if_catalog_version` payload (must reject).
   The second assertion confirms Ajv still enforces the spec's
   `dependencies` constraint at runtime even though codegen strips it for
   TS-surface generation. Also exercises the release-precision
   `'3.1-beta'` resolution path.

Verified: tsc --noEmit clean, format:check clean, 19/19
schema-loader-per-version tests pass (up from 18), build clean.

Deferred to separate PRs per security reviewer's classification as
inherited from `sync-schemas.ts`:
- Cosign fail-closed in NODE_ENV=production publish path (medium)
- `assertSafeVersion` on `sync()` version parameter (low)
- `path.resolve` + boundary check in `refToCachePath` defense-in-depth (low)

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