Skip to content

feat(registry): type the change-feed event payload as a discriminated union#5818

Merged
bokelley merged 2 commits into
mainfrom
feat/registry-feed-typed-event-payloads
Jul 6, 2026
Merged

feat(registry): type the change-feed event payload as a discriminated union#5818
bokelley merged 2 commits into
mainfrom
feat/registry-feed-typed-event-payloads

Conversation

@bokelley

@bokelley bokelley commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Why

The registry change feed (GET /api/registry/feed, GET /api/registry/feed/stream) exposed event_type: string and payload: object (opaque additionalProperties: {}). Every consumer — including @adcp/sdk's own RegistrySync — has to hand-cast payload to reach routing keys like publisher_domain / agent_url, and codegen has nothing to work with.

What Changed

Typed the feed event at the Zod source (RegistryFeedPageSchema in server/src/routes/registry-api.ts); static/openapi/registry.yaml is regenerated from it via build:openapi.

  • event_type → enum of the known types across six families: agent.*, brand.*, property.*, collection.*, publisher.*, authorization.*.
  • payload → discriminated union keyed on event_type. Each event is an arm tying its event_type literal to a named family payload: AgentEventPayload, BrandEventPayload, PropertyEventPayload, CollectionEventPayload, AuthorizationEventPayload, PublisherEventPayload.
  • Fields that appear on only some members of a family (e.g. *.merged carries alias_rid/canonical_rid in place of identifiers; authorization.revoked carries only agent_url + publisher_domain) are optional on the family schema. PropertyEventPayload.identifiers reuses the existing PropertyIdentifier component.

Enum coverage is grounded in the actual contract, not just the docs table: the crawler emit sites (server/src/crawler.ts), the server's own reference consumer (server/src/registry-sync/index.ts), and the SDK's RegistrySync switch (which also handles brand.* and agent profile/compliance events). The generated oneOf uses per-arm enum discriminators, matching the repo's existing union style (no formal discriminator: keyword); openapi-typescript renders it as a proper discriminated union, so consumers narrow payload by switching on event_type.

No changeset: this is a registry-release-scoped change (static/openapi/registry.yaml), continuously deployed, not part of the adcontextprotocol protocol release.

Verification

  • npm run typecheck (server) — clean
  • npm run test:openapi (OpenAPI freshness) — regenerated, in sync
  • npm run test:oneof-discriminators — no new undiscriminated oneOf
  • Full precommit suite — passing
  • Downstream @adcp/sdk regen against this spec: types generate, check-no-loose-oneof passes, tsc on the lib project is clean, and CatalogEvent becomes a discriminated union (event_type: "…" → typed payload).

Backward-compatible enrichment: event values are unchanged; consumers reading known fields are unaffected, and payload narrows instead of being opaque.

🤖 Generated with Claude Code

… union

The `GET /api/registry/feed` (and `/feed/stream`) events previously exposed
`event_type: string` and `payload: object` (opaque), forcing every consumer to
hand-cast `payload` to reach `publisher_domain` / `agent_url`.

Type them at the Zod source (`RegistryFeedPageSchema`): `event_type` is now an
enum of the known types across the agent, brand, property, collection,
publisher, and authorization families, and each event is a discriminated-union
arm tying its `event_type` literal to a named family payload
(`AgentEventPayload`, `BrandEventPayload`, `PropertyEventPayload`,
`CollectionEventPayload`, `AuthorizationEventPayload`, `PublisherEventPayload`).
Payload fields that appear on only some members of a family (e.g. `*.merged`
carries `alias_rid`/`canonical_rid` in place of `identifiers`) are optional.

Enum coverage is grounded in the actual contract: the crawler's emit sites, the
server's own registry-sync reference consumer, and the SDK's RegistrySync
switch (which also handles the brand.* family and agent profile/compliance
events). The generated `oneOf` uses per-arm enum discriminators (matching the
repo's existing union style); openapi-typescript renders it as a proper
discriminated union so consumers narrow `payload` by switching on `event_type`.

Verified: server typecheck, OpenAPI freshness, oneof-discriminator audit, and a
full @adcp/sdk regen (types generate, no loose-oneof arms, lib typecheck clean).

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

@aao-release-bot aao-release-bot Bot left a comment

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.

Right shape, incomplete corpus. The discriminated-union mechanism is correct — the block is that the spec now makes a closed assertion about event_type that is narrower than what the server puts on the wire.

MUST FIX (blocking)

The oneOf omits three event types that are actively emitted onto this exact feed. server/src/routes/registry-api.ts:132-155 enumerates 20 arms; the backing catalog_events table receives at least 23 distinct event_type values, and queryFeed (server/src/db/catalog-events-db.ts:115) applies no allowlist — only the optional types glob and cursor/limit. Missing:

  • agent.verification_earnedserver/src/notifications/compliance.ts:300 (eventsDb.writeEvent)
  • agent.verification_lostserver/src/notifications/compliance.ts:314
  • authorization.modified — DB trigger in server/src/db/migrations/446_authorization_feed_emitter.sql:231

What breaks for adopters: the PR's stated goal is "consumers narrow payload by switching on event_type." A consumer that codegens the discriminated union from this spec and does an exhaustive switch will hit a live agent.verification_earned / authorization.modified event with no matching arm — falling through, or rejected outright by a strict oneOf validator. Before this PR (event_type: string, open payload) every event validated; this PR ships a spec that is precisely wrong for three real events on the wire. That is spec-vs-wire drift on a wire-facing surface.

Not a runtime risk on the server side — RegistryFeedPageSchema is doc/codegen-only, not runtime response validation (res.json direct), confirmed by both code-reviewer and ad-tech-protocol-expert. The break is downstream, at the adopters this PR is built to serve.

Fix: add three arms (agent.verification_earned, agent.verification_lostAgentEventPayload; authorization.modifiedAuthorizationEventPayload), and extend AgentEventPayloadSchema (registry-api.ts:18-37) with the fields those verification events carry — role, reason, verified_specialisms, adcp_version (compliance.ts:303-308,316-322) — then regenerate. Contained fix; no structural change.

Things I checked

  • Zod union is well-formed: z.discriminatedUnion("event_type", […]) with per-arm z.literal via the feedEventArm helper is the correct, supported pattern. Clean factoring of the shared envelope.
  • Nullability parity holds: .nullable().optional()type: [string, "null"] and omitted from required (e.g. collection_id, manager_domain). Matches source.
  • additionalProperties posture: all six new payload schemas stay open (no additionalProperties: false), so unknown payload keys still validate — the enrichment itself is backward-compatible. No tightening against [DR-0009].
  • Over-coverage (five brand.* arms, property.updated, collection.updated with no in-repo emit site) is harmless for a union — forward-declaring is fine. Only missing-but-emitted types matter.
  • No changeset is correct — do not add one. scripts/check-changeset-protocol-scope.cjs:14-19 lists ^static/openapi/registry[.]yaml$ in REGISTRY_RELEASE_SCOPED_PATHS, held separate from protocol-scoped paths. Registry OpenAPI is continuously deployed, not part of the adcontextprotocol semver release. The PR body's reasoning matches repo convention.
  • oneOf gate (scripts/audit-oneof.mjs) walks static/schemas/source/ only (line 31) — static/openapi/registry.yaml is outside its scope, so MUST FIX #7 does not fire here. Not a violation by this PR.

Follow-ups (non-blocking — file as issues)

  • Drift guard. Nothing runtime-checks emitted event_type values against the union, which is how this gap reached the PR in the first place. A test asserting every distinct event_type string in server/src emit sites appears in the union would fail CI on the next omission instead of shipping it. (code-reviewer)
  • Discriminator coverage. Consider extending audit-oneof.mjs to registry.yaml, or adding a formal discriminator: propertyName — tracked at #3917. (ad-tech-protocol-expert)
  • entity_type stays an open string per arm. Confirm that's intentional — it's the only cross-cutting field left untyped.

The commit message says the enum is "grounded in the actual contract, not just the docs table." Three emitters in the same source tree suggest the grounding stopped one directory short of notifications/ and migrations/.

Fix the three arms and this is a clean enrichment. Re-request review after regen.

@aao-release-bot aao-release-bot Bot left a comment

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.

Clean enrichment. Turns an opaque payload: object into a oneOf discriminated on event_type — the right shape, and the additive path (payloads stay open) done correctly.

Things I checked

  • Schema is generation-only, not a runtime gate. RegistryFeedPageSchema is referenced solely at server/src/routes/registry-api.ts:2006 inside registry.registerPath(...). No .parse()/.safeParse() on outbound feed responses anywhere. A missing arm would under-type the spec, not 500 the endpoint — so this is not MUST-FIX #1.
  • Enum covers every emitted type. Walked the emit sites: crawler.ts (agent.discovered/removed, authorization.granted/revoked, publisher.adagents_changed/discovered), notifications/compliance.ts:300,314 (agent.verification_earned/lost — both present as arms, contra one bot flag), collection-catalog-db.ts (collection.created/updated/removed/merged), catalog-api.ts projection (property.*). All land in the 20-arm enum. Narrowing open string → closed enum is safe because the enum is a superset of today's wire.
  • Payloads stay open. Every family schema is .passthrough().openapi(...); generated yaml carries additionalProperties: {}. Happy path for consumers reading known fields is unchanged.
  • oneOf discriminator is sound. Per-arm single-value event_type enum literals, mutually exclusive, no formal discriminator: keyword — matches repo union style; test:oneof-discriminators passes because the arms are separable by tag. No audit regression.
  • Changeset correctly omitted. scripts/check-changeset-protocol-scope.cjs places static/openapi/registry.yaml in REGISTRY_RELEASE_SCOPED_PATHS, explicitly disjoint from PROTOCOL_SCOPED_PATHS (static/schemas/source/**). Registry-release-scoped, continuously deployed — the author's rationale is the codebase's formal policy, not just plausible. Not MUST-FIX #6.

Follow-ups (non-blocking — file as issues)

  • Closed discriminant vs. open emit path. The write boundary is a free-form event_type: string (server/src/db/catalog-events-db.ts:24) and the feed filter is glob/LIKE, so producers can emit a new family faster than the spec tracks it. Under the closed enum, a strict consumer would reject an unknown event_type (payloads staying open doesn't rescue a closed discriminant). Today spec and emitter deploy in lockstep so blast radius is small — but consider a permissive catch-all arm (event_type: string + open payload) as the final oneOf member, or document tolerate-and-skip for unknown types. OpenRTB/VAST reserve an "other/unknown" code for exactly this.
  • Orphaned BrandEventPayload. Defined in components with no brand.* arm in the union. No server-side brand.* emit exists in server/src; the PR body attributes it to the SDK RegistrySync switch. Either wire the arms if the feed genuinely carries brand.*, or drop the unreferenced schema.

Minor nits (non-blocking)

  1. "Grounded in the actual contract" is doing some load-bearing work. agent.profile_updated and the brand payload are consumer/SDK-driven, not server-emitted — a notable framing for a claim about the emit surface. Harmless over-provisioning; worth a word in the PR body.

Approving on the strength of full enum coverage of the emitted set plus the confirmed registry-release scope.

@bokelley bokelley merged commit 2d1d76f into main Jul 6, 2026
14 checks passed
@bokelley bokelley deleted the feat/registry-feed-typed-event-payloads branch July 6, 2026 04:29
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.

1 participant