From 547c24cdeb58891f8c58299509998dedcf3873a0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 1 Jul 2026 13:47:21 +0100 Subject: [PATCH] feat(openapi): describe the property-catalog fact-contribution endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog fact surface (POST /api/registry/resolve, /catalog/disputes) is live and serves real traffic, but catalog-api.ts registered zero OpenAPI paths — the whole router was absent from static/openapi/registry.yaml. Since SDK clients generate their types from the published OpenAPI (adcp-client's types.generated.ts sources https://agenticadvertising.org/openapi/registry.yaml), the "send us facts" surface could not be typed. This makes it part of the contract. - New server/src/schemas/catalog-openapi.ts (mirrors the member-agents-openapi.ts pattern — standalone so the generator imports it without route-factory deps). Registers: resolveIdentifiers (POST /api/registry/resolve), fileCatalogDispute (POST /api/registry/catalog/disputes), getCatalogDispute (GET .../disputes/{id}). - Schemas mirror the Zod validators + result types in catalog-api.ts / catalog-db.ts / catalog-governance.ts exactly: the provenance enum, the identifiers[1..10000] cap, ResolveResponse.summary's five fields (incl not_found), and the dispute TriageResult ({dispute_id, action_taken, reason}). - property_rid is documented as a non-authoritative join/match handle, never an authorization credential (mirrors the #5750 identity-not-authorization lesson). - Wired into scripts/generate-openapi.ts + a "Property Catalog" tag; regenerated static/openapi/registry.yaml. Prerequisite for the SDK fact-contribution surface (specs/sdk-fact-contribution.md, #5782): once this publishes, @adcp/client and adcp regenerate types and add the reportIdentifiers()/disputeFact() methods. Read-side (browse/sync) OpenAPI is a fast follow-on. No behavior change — documentation of live endpoints only. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/generate-openapi.ts | 2 + server/src/schemas/catalog-openapi.ts | 163 ++++++++++++++ static/openapi/registry.yaml | 299 ++++++++++++++++++++++++++ 3 files changed, 464 insertions(+) create mode 100644 server/src/schemas/catalog-openapi.ts diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index b51721b4f0..2323bc7a40 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -25,6 +25,7 @@ process.env.RESEND_API_KEY ??= "re_dummy_openapi_only"; await import("../server/src/routes/registry-api.js"); await import("../server/src/schemas/member-agents-openapi.js"); await import("../server/src/schemas/onboarding-openapi.js"); +await import("../server/src/schemas/catalog-openapi.js"); const { registry } = await import("../server/src/schemas/registry.js"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -112,6 +113,7 @@ const TAG_DESCRIPTIONS: Record = { "Brand Discovery": "Discover and crawl brand.json files across domains.", "Agent Compliance": "Agent compliance status, storyboard test results, and compliance history.", "Policy Registry": "Browse, resolve, and contribute governance policies for campaign compliance.", + "Property Catalog": "Contribute facts to the property fact-graph: resolve identifiers to stable property_rids (which also contributes them, with provenance) and dispute catalog claims.", }; const TAG_ORDER = Object.keys(TAG_DESCRIPTIONS); diff --git a/server/src/schemas/catalog-openapi.ts b/server/src/schemas/catalog-openapi.ts new file mode 100644 index 0000000000..22301f80df --- /dev/null +++ b/server/src/schemas/catalog-openapi.ts @@ -0,0 +1,163 @@ +/** + * OpenAPI registrations for the property-catalog fact-contribution surface + * (`/api/registry/resolve` and the dispute endpoints), served by + * `routes/catalog-api.ts`. + * + * Kept separate from the route file — the same reason as member-agents-openapi.ts + * — so the spec generator can import these registrations without pulling in the + * route factory's runtime dependencies. Schemas mirror the Zod validators and + * result types in `routes/catalog-api.ts` / `db/catalog-db.ts` / + * `services/catalog-governance.ts`. This makes the "send us facts" surface part + * of the typed OpenAPI contract, so SDK clients can generate against it. + */ + +import { z } from 'zod'; +import { registry, ErrorSchema } from './registry.js'; + +// ── Shared shapes ─────────────────────────────────────────────── + +const CatalogIdentifierSchema = z + .object({ + type: z.string().openapi({ example: 'domain' }), + value: z.string().openapi({ example: 'nytimes.com' }), + }) + .openapi('CatalogIdentifier'); + +const FactProvenanceSchema = z + .object({ + type: z.enum([ + 'agency_allowlist', + 'publisher_declaration', + 'impression_log', + 'ssp_inventory', + 'deal_history', + 'crawl', + 'data_partner', + 'member_assertion', + ]).openapi({ + description: + 'How the caller knows these identifiers — the trust/audit envelope on the fact. Determines the confidence the catalog assigns. `crawl` is reserved for server-side pipelines; callers use the others.', + }), + context: z.string().optional().openapi({ example: 'unilever_q3', description: 'Optional free-text annotation (campaign, dataset).' }), + }) + .openapi('FactProvenance'); + +// ── POST /api/registry/resolve ────────────────────────────────── + +const ResolveRequestSchema = z.object({ + identifiers: z.array(CatalogIdentifierSchema).min(1).max(10000).openapi({ + description: 'Identifiers to resolve (and, in resolve mode, contribute). Max 10,000 per call for all callers.', + }), + provenance: FactProvenanceSchema, + mode: z.enum(['resolve', 'lookup']).default('resolve').openapi({ + description: + "`resolve` (default) contributes the identifiers, auto-creates missing catalog entries, logs demand activity, and returns rids — requires authentication. `lookup` is a pure read: no write, no activity log, no auth.", + }), +}).openapi('ResolveRequest'); + +const ResolvedEntrySchema = z.object({ + identifier: CatalogIdentifierSchema, + property_rid: z.string().nullable().openapi({ + description: 'Stable catalog handle for joining/dedup and TMP matching. NOT an authorization credential. `null` for excluded (ad_infra / publisher_mask) or unresolved-in-lookup identifiers.', + }), + classification: z.string().openapi({ example: 'property' }), + status: z.enum(['existing', 'created', 'excluded']), + source: z.string().nullable(), +}).openapi('ResolvedEntry'); + +const ResolveResponseSchema = z.object({ + resolved: z.array(ResolvedEntrySchema), + summary: z.object({ + total: z.number().int(), + resolved: z.number().int(), + created: z.number().int(), + excluded: z.number().int(), + not_found: z.number().int(), + }), + server_timestamp: z.string(), +}).openapi('ResolveResponse'); + +registry.registerPath({ + method: 'post', + path: '/api/registry/resolve', + operationId: 'resolveIdentifiers', + summary: 'Resolve identifiers to property_rids (and contribute them)', + description: + 'The primary fact-contribution path. Takes identifiers plus a provenance envelope and returns stable `property_rid`s. In `resolve` mode (default) it auto-creates missing catalog entries and logs demand activity — so resolving your own identifier list IS the contribution. `property_rid` is a non-authoritative join/match handle, never an authorization credential. Re-resolving is idempotent on the identifier→rid mapping but additive on the activity log.', + tags: ['Property Catalog'], + security: [{ bearerAuth: [] }, { oauth2: [] }], + request: { + body: { content: { 'application/json': { schema: ResolveRequestSchema } } }, + }, + responses: { + 200: { description: 'Resolve/lookup result', content: { 'application/json': { schema: ResolveResponseSchema } } }, + 400: { description: 'Invalid request (bad identifiers, unknown provenance type, batch > 10,000)', content: { 'application/json': { schema: ErrorSchema } } }, + 401: { description: 'Authentication required for resolve mode', content: { 'application/json': { schema: ErrorSchema } } }, + }, +}); + +// ── POST /api/registry/catalog/disputes ───────────────────────── + +const DisputeRequestSchema = z.object({ + dispute_type: z.enum(['identifier_link', 'classification', 'property_data', 'false_merge']), + subject_type: z.string().openapi({ example: 'identifier', description: 'What is being disputed — e.g. `identifier` or `property_rid`.' }), + subject_value: z.string().openapi({ example: 'com.example.app' }), + claim: z.string().min(10).max(2000).openapi({ description: 'The dispute claim (10–2000 chars).' }), + evidence: z.string().max(5000).optional().openapi({ description: 'Optional supporting evidence (≤5000 chars).' }), +}).openapi('CatalogDisputeRequest'); + +const DisputeTriageResultSchema = z.object({ + dispute_id: z.string(), + action_taken: z.enum(['link_suspended', 'queued_for_review', 'escalated']).openapi({ + description: 'What filing the dispute did: a medium/weak link is suspended immediately; otherwise the dispute is queued or escalated for review.', + }), + reason: z.string(), +}).openapi('CatalogDisputeTriageResult'); + +registry.registerPath({ + method: 'post', + path: '/api/registry/catalog/disputes', + operationId: 'fileCatalogDispute', + summary: 'Dispute a catalog fact', + description: + "Challenge or correct a catalog claim — the community disavow/challenge verb. Adding links is hard; suspending suspicious ones is easy: a disputed medium/weak link is suspended immediately (`action_taken: 'link_suspended'`); stronger claims queue for review. Poll status with getCatalogDispute.", + tags: ['Property Catalog'], + security: [{ bearerAuth: [] }, { oauth2: [] }], + request: { + body: { content: { 'application/json': { schema: DisputeRequestSchema } } }, + }, + responses: { + 200: { description: 'Dispute filed and triaged', content: { 'application/json': { schema: DisputeTriageResultSchema } } }, + 400: { description: 'Invalid dispute request', content: { 'application/json': { schema: ErrorSchema } } }, + 401: { description: 'Authentication required', content: { 'application/json': { schema: ErrorSchema } } }, + }, +}); + +// ── GET /api/registry/catalog/disputes/{id} ───────────────────── + +const DisputeRecordSchema = z.object({ + id: z.string(), + dispute_type: z.enum(['identifier_link', 'classification', 'property_data', 'false_merge']), + subject_type: z.string(), + subject_value: z.string(), + claim: z.string(), + evidence: z.string().nullable().optional(), + status: z.string().openapi({ example: 'suspended', description: 'Current dispute status.' }), + created_at: z.string(), +}).passthrough().openapi('CatalogDisputeRecord'); + +registry.registerPath({ + method: 'get', + path: '/api/registry/catalog/disputes/{id}', + operationId: 'getCatalogDispute', + summary: 'Get a catalog dispute', + description: 'Fetch the current state of a filed dispute by id.', + tags: ['Property Catalog'], + request: { + params: z.object({ id: z.string().openapi({ example: '019539a0-b1c2-7d3e-8f4a-5b6c7d8e9f0a' }) }), + }, + responses: { + 200: { description: 'Dispute record', content: { 'application/json': { schema: DisputeRecordSchema } } }, + 404: { description: 'Dispute not found', content: { 'application/json': { schema: ErrorSchema } } }, + }, +}); diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index b3a3f4fd5f..56b3bcc5d7 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -3213,6 +3213,208 @@ components: - 250m_1b - 1b_plus description: Annual revenue band, USD. Drives membership-tier eligibility for company-tier seats. + ResolveResponse: + type: object + properties: + resolved: + type: array + items: + $ref: "#/components/schemas/ResolvedEntry" + summary: + type: object + properties: + total: + type: integer + resolved: + type: integer + created: + type: integer + excluded: + type: integer + not_found: + type: integer + required: + - total + - resolved + - created + - excluded + - not_found + server_timestamp: + type: string + required: + - resolved + - summary + - server_timestamp + ResolvedEntry: + type: object + properties: + identifier: + $ref: "#/components/schemas/CatalogIdentifier" + property_rid: + type: + - string + - "null" + description: Stable catalog handle for joining/dedup and TMP matching. NOT an authorization credential. `null` for excluded (ad_infra / publisher_mask) or unresolved-in-lookup identifiers. + classification: + type: string + example: property + status: + type: string + enum: + - existing + - created + - excluded + source: + type: + - string + - "null" + required: + - identifier + - property_rid + - classification + - status + - source + CatalogIdentifier: + type: object + properties: + type: + type: string + example: domain + value: + type: string + example: nytimes.com + required: + - type + - value + ResolveRequest: + type: object + properties: + identifiers: + type: array + items: + $ref: "#/components/schemas/CatalogIdentifier" + minItems: 1 + maxItems: 10000 + description: Identifiers to resolve (and, in resolve mode, contribute). Max 10,000 per call for all callers. + provenance: + $ref: "#/components/schemas/FactProvenance" + mode: + type: string + enum: + - resolve + - lookup + default: resolve + description: "`resolve` (default) contributes the identifiers, auto-creates missing catalog entries, logs demand activity, and returns rids — requires authentication. `lookup` is a pure read: no write, no activity log, no auth." + required: + - identifiers + - provenance + FactProvenance: + type: object + properties: + type: + type: string + enum: + - agency_allowlist + - publisher_declaration + - impression_log + - ssp_inventory + - deal_history + - crawl + - data_partner + - member_assertion + description: How the caller knows these identifiers — the trust/audit envelope on the fact. Determines the confidence the catalog assigns. `crawl` is reserved for server-side pipelines; callers use the others. + context: + type: string + example: unilever_q3 + description: Optional free-text annotation (campaign, dataset). + required: + - type + CatalogDisputeTriageResult: + type: object + properties: + dispute_id: + type: string + action_taken: + type: string + enum: + - link_suspended + - queued_for_review + - escalated + description: "What filing the dispute did: a medium/weak link is suspended immediately; otherwise the dispute is queued or escalated for review." + reason: + type: string + required: + - dispute_id + - action_taken + - reason + CatalogDisputeRequest: + type: object + properties: + dispute_type: + type: string + enum: + - identifier_link + - classification + - property_data + - false_merge + subject_type: + type: string + example: identifier + description: What is being disputed — e.g. `identifier` or `property_rid`. + subject_value: + type: string + example: com.example.app + claim: + type: string + minLength: 10 + maxLength: 2000 + description: The dispute claim (10–2000 chars). + evidence: + type: string + maxLength: 5000 + description: Optional supporting evidence (≤5000 chars). + required: + - dispute_type + - subject_type + - subject_value + - claim + CatalogDisputeRecord: + type: object + properties: + id: + type: string + dispute_type: + type: string + enum: + - identifier_link + - classification + - property_data + - false_merge + subject_type: + type: string + subject_value: + type: string + claim: + type: string + evidence: + type: + - string + - "null" + status: + type: string + example: suspended + description: Current dispute status. + created_at: + type: string + required: + - id + - dispute_type + - subject_type + - subject_value + - claim + - status + - created_at + additionalProperties: {} AdagentsJson: type: object properties: @@ -10350,6 +10552,101 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /api/registry/resolve: + post: + operationId: resolveIdentifiers + summary: Resolve identifiers to property_rids (and contribute them) + description: The primary fact-contribution path. Takes identifiers plus a provenance envelope and returns stable `property_rid`s. In `resolve` mode (default) it auto-creates missing catalog entries and logs demand activity — so resolving your own identifier list IS the contribution. `property_rid` is a non-authoritative join/match handle, never an authorization credential. Re-resolving is idempotent on the identifier→rid mapping but additive on the activity log. + tags: + - Property Catalog + security: + - bearerAuth: [] + - oauth2: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveRequest" + responses: + "200": + description: Resolve/lookup result + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResponse" + "400": + description: Invalid request (bad identifiers, unknown provenance type, batch > 10,000) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required for resolve mode + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/registry/catalog/disputes: + post: + operationId: fileCatalogDispute + summary: Dispute a catalog fact + description: "Challenge or correct a catalog claim — the community disavow/challenge verb. Adding links is hard; suspending suspicious ones is easy: a disputed medium/weak link is suspended immediately (`action_taken: 'link_suspended'`); stronger claims queue for review. Poll status with getCatalogDispute." + tags: + - Property Catalog + security: + - bearerAuth: [] + - oauth2: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogDisputeRequest" + responses: + "200": + description: Dispute filed and triaged + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogDisputeTriageResult" + "400": + description: Invalid dispute request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/registry/catalog/disputes/{id}: + get: + operationId: getCatalogDispute + summary: Get a catalog dispute + description: Fetch the current state of a filed dispute by id. + tags: + - Property Catalog + parameters: + - schema: + type: string + example: 019539a0-b1c2-7d3e-8f4a-5b6c7d8e9f0a + required: true + name: id + in: path + responses: + "200": + description: Dispute record + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogDisputeRecord" + "404": + description: Dispute not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" tags: - name: Onboarding description: Explicitly bootstrap a third-party integration into the AAO registry. Most callers don't need this tag — `POST /api/me/agents` auto-creates the org (for fresh users) and the member profile (for first-time agent registration) without a separate round trip. Use `POST /api/organizations` only when you need to override the auto-derived org name / company_type / revenue_tier. Tier transitions happen via the billing flow only; the Stripe webhook is the sole writer of `organizations.membership_tier`. @@ -10379,3 +10676,5 @@ tags: description: Agent compliance status, storyboard test results, and compliance history. - name: Policy Registry description: Browse, resolve, and contribute governance policies for campaign compliance. + - name: Property Catalog + description: "Contribute facts to the property fact-graph: resolve identifiers to stable property_rids (which also contributes them, with provenance) and dispute catalog claims."