A complete implementation proposal for a machine-only advertisement catalog powered by the Machine Payments Protocol over HTTP 402. Machines pay. Humans browse. The leaderboard is the bid.
Every MPP transaction follows three steps: the server challenges with a 402, the client fulfills the challenge with a payment proof, and the server confirms with a receipt. The resource is only delivered after payment is verified.
| Method | Settlement | Best For madcatalog |
|---|---|---|
| stripe | Fiat card / bank (Shared Payment Token) | Primary — widest agent adoption, existing Stripe account |
| tempo | USDC stablecoin on L2 | Secondary — native on-chain for crypto-native agents |
| lightning | Bitcoin BOLT11 invoice | Exploratory — microtransaction-capable |
| custom | Developer-defined | Future — extensibility point |
madcatalog is an advertisement catalog with a single, machine-enforced rule: only machines may pay for placement. No human checkout flow exists. Ads are ranked by cumulative spend — the more an agent bids on its ad, the higher it rises. It is a natural price-discovery leaderboard with no human operators setting prices.
| Rule | Mechanism | Enforcement |
|---|---|---|
| Machines only may pay | POST /ads/bid is MPP-protected; no human payment flow exists | HTTP 402 — no human UI to complete payment |
| Rank = cumulative spend | Each bid increments cumulative_spend_usd_cents | DynamoDB atomic counter; GET /catalog sorts descending |
| Minimum bid $1.00 | Lambda validates amount_usd_cents >= 100 | 422 Unprocessable Entity if below minimum |
| Any machine may bid | No allowlist; only valid MPP credential required | MPP credential verification in PlaceBid Lambda |
| Humans view for free | GET /catalog is unauthenticated and open | No auth on GetCatalog Lambda route |
madcatalog's revenue model is intentionally minimal: 100% of every bid goes to madcatalog. There is no platform fee on top of the bid amount. Stripe's standard processing fee (~2.9% + 30¢ per transaction) is madcatalog's cost of doing business — not passed through to advertisers. The bid amount IS the placement price.
madcatalog is designed to be talked about. The gimmick — only machines can buy ads, using a payment protocol most technologists have not heard of — is interesting enough to explain at a conference, post to Hacker News, or describe on a podcast. The catalog itself is a live artifact: real companies, real money, ranked in real time by autonomous agents. When someone asks "what is MPP?", madcatalog is the answer that makes the question feel tangible.
| Function | Route | Auth | Responsibility |
|---|---|---|---|
| GetCatalog | GET /catalog | None | Returns ads sorted by cumulative_spend_usd_cents DESC. Public, unauthenticated. |
| GetAd | GET /ads/{ad_id} | None | Returns a single ad record with its current catalog rank. |
| UpdateAd | PATCH /ads/{ad_id} | X-Ad-Api-Key | Updates enrichment fields (title, description, image_url, logo_url, etc.) without placing a new bid. |
| GetAdBids | GET /ads/{ad_id}/bids | None | Returns the full bid history for an ad, ordered chronologically. Queries via GSI ad_id-created_at-index. |
| GetAdRankStatus | GET /ads/{ad_id}/rank-status | MPP ($1.00) | MPP-gated rank intelligence: current rank, spend-to-next-rank, spend-to-rank-1, overtaken-by alert. |
| PlaceBid | POST /ads/bid | MPP (server) | MPP challenger. Issues 402 on missing credential; verifies Stripe SPT; upserts ad + bid record; claims advertiser slug. |
| CreateSpt | POST /payments/stripe/spt | None | Helper for MPP Stripe clients. Creates a PaymentIntent and returns a Shared Payment Token. |
| GetAdvertisers | GET /advertisers | None | Returns all advertiser profiles ranked by total cumulative spend. |
| GetAdvertiser | GET /advertisers/{slug} | None | Full advertiser profile with embedded ads list. Content-negotiated: returns HTML for browsers, JSON for machines. |
| PatchAdvertiser | PATCH /advertisers/{slug} | X-Advertiser-Api-Key | Updates advertiser profile enrichment (name, logo, tagline, description, key_stat, etc.). |
| StripeWebhook | POST /webhooks/stripe | Stripe HMAC | Receives Stripe events (payment_intent.succeeded, payment_intent.payment_failed). Updates bid status in madcatalog-bids. |
| Table | Primary Key | Key Attributes | Access Pattern |
|---|---|---|---|
| madcatalog-ads | PK: ad_id (UUID) | title, url, description, category, advertiser_slug, image_url, logo_url, brand_color, tagline, key_stat, cta_label, docs_url, hero_url, cumulative_spend_usd_cents, bid_count, api_key_hash, created_at, last_bid_at | Scan (sorted client-side) for catalog; PutItem/UpdateItem on bid; GetItem by ad_id |
| madcatalog-bids | PK: bid_id (UUID) | ad_id, advertiser_slug, amount_usd_cents, cumulative_spend_usd_cents, rank, status, payment_intent_id, created_at | PutItem on each bid; Query by ad_id via GSI ad_id-created_at-index; UpdateItem on Stripe webhook (status, payment_intent_id) |
| madcatalog-advertisers | PK: slug (string) | name, url, description, logo_url, brand_color, tagline, key_stat, cta_label, docs_url, total_spend_usd_cents, total_bid_count, ad_count, api_key_hash, first_bid_at, last_bid_at, created_at | GetItem by slug for profile; Scan for advertiser leaderboard; UpdateItem on each bid to increment spend/bid counters |
| Variable | Local Dev | Production Source |
|---|---|---|
| STRIPE_SECRET_KEY | Shell export (sk_test_...) | SSM /madcatalog/stripe-secret-key |
| MPP_SECRET_KEY | Shell export (random 32+ bytes) | SSM /madcatalog/mpp-secret-key |
| STRIPE_NETWORK_ID | From Stripe MPP dashboard | SSM /madcatalog/stripe-network-id |
| DYNAMODB_ENDPOINT | http://localhost:4566 | (empty — uses AWS default) |
| DYNAMODB_TABLE_ADS | madcatalog-ads | madcatalog-ads |
| Method | Path | Auth | Summary |
|---|---|---|---|
| GET | /catalog | None (humans); MPP planned for machines | Returns all active ads ranked by cumulative_spend_usd_cents DESC. Machine reads will be MPP-gated post-launch (see ADR-010). |
| GET | /advertisers | None | Returns all advertisers ranked by total cumulative spend across all their ads. |
| GET | /advertisers/{slug} | None | Full advertiser profile: enrichment, aggregate spend/bid stats, embedded ads list. Content-negotiated. |
| GET | /ads/{ad_id}/rank-status | MPP (HTTP 402) for machines | Current rank, spend-to-next-rank, spend-to-rank-1, overtaken-by. Machine re-bid prompt in one call. |
| POST | /ads/bid | MPP (HTTP 402) + optional X-Advertiser-Api-Key | Place or top up an ad bid. Optional advertiser_slug in body claims or references an advertiser entity. |
| POST | /payments/stripe/spt | None | Helper: create Stripe Shared Payment Token for MPP Stripe credential |
| POST | /webhooks/stripe | Stripe sig | Receives Stripe payment events; updates bid status in DynamoDB |
In addition to the REST API, madcatalog can expose an MCP (Model Context Protocol) server. AI agents using MCP-capable hosts (Claude Desktop, Continue, etc.) can call place_ad as a tool call — the MCP server handles the MPP 402 challenge transparently, so the agent only needs to provide the bid details and have a configured payment method. [3]
| Tool Name | MPP? | Parameters | Returns |
|---|---|---|---|
| place_ad | Yes | title, url, amount_usd_cents, advertiser_slug, enrichment? | { ad_id, rank, cumulative_spend_usd_cents, advertiser_api_key? } |
| get_catalog | No | None | Array of Ad objects ranked by spend |
| get_ad | No | ad_id | Single Ad object with current rank |
| get_ad_bids | No | ad_id | Full bid history with per-bid totals |
| update_ad | No | ad_id, api_key, enrichment fields | Updated Ad object |
| get_advertisers | No | None | All advertisers ranked by total spend |
| get_advertiser | No | slug | Full advertiser profile with all ads |
| patch_advertiser | No | slug, advertiser_api_key, enrichment fields | Updated advertiser profile |
| get_rank_status | Yes | ad_id | { rank, spend_to_next_rank, spend_to_rank_1, overtaken_by } |
When a tool call requires payment, the MCP server returns a JSON-RPC error with code -32042 (Payment Required) and the MPP challenge in error.data. The MCP client pays and retries the tool call with the credential in _meta.credential.
| Service | Port | Purpose | Start Command |
|---|---|---|---|
| LocalStack | 4566 | DynamoDB (only service enabled) | task localstack:up |
| SAM local API | 3000 | Lambda functions via HTTP API Gateway emulator | task sam:local |
| Hugo dev server | 1317 | Catalog frontend (live rebuild) | hugo server -p 1317 |
| Stripe CLI | N/A | Forwards Stripe webhooks to SAM :3000 | task stripe:listen |
| Redocly preview | 8081 | Interactive OpenAPI / Swagger UI for api/openapi.yml | task swagger |
| MCP Inspector (opt.) | 5173 | MCP tool explorer for local mcp server | npx @modelcontextprotocol/inspector |
No .env files. Export these in your shell profile (~/.zshrc):
| Variable | Value | Source |
|---|---|---|
| STRIPE_SECRET_KEY | sk_test_... | stripe config --list |
| STRIPE_WEBHOOK_SECRET | whsec_... | Printed by task stripe:listen on start |
| MPP_SECRET_KEY | random 32+ bytes | openssl rand -base64 32 |
| STRIPE_NETWORK_ID | From Stripe MPP network setup | Stripe Dashboard / MPP onboarding |
| DYNAMODB_ENDPOINT | http://localhost:4566 | Static |
| Layer | Tool | What It Tests | Runs In |
|---|---|---|---|
| Unit | go test ./... | Lambda business logic, MPP challenge generation, DynamoDB marshalling | CI (no infra needed) |
| Integration | go test -tags integration | PlaceBid end-to-end against real LocalStack DynamoDB | CI + local (needs LocalStack) |
| Contract | npx @redocly/cli lint | OpenAPI spec validity; all routes match schema | Every commit (precommit + CI) |
| MPP protocol | mppx test client (TypeScript) | Full 402 challenge-credential-receipt cycle against SAM local | Local integration suite |
| Stripe webhook | stripe trigger + SAM local | Webhook signature verification; event handler idempotency | Local only (stripe CLI) |
| Dev environment | tests/dev-env.mjs (task dev:env-test) | Asserts known seeded fixture state against the live REST API — 2 ads (minimal rank 2, maximal rank 1), 2 advertisers, correct spend totals, all maximal enrichment fields populated, 3 bids on maximal ad, all bids settled | Appended to task dev after seed completes; requires full dev stack |
| File | Type | Purpose |
|---|---|---|
| sam/functions/place-bid/main_test.go | Unit | Challenge generation, schema validation, 402 response shape |
| sam/functions/place-bid/integration_test.go | Integration (+build tag) | Full bid flow against LocalStack; verifies DynamoDB writes |
| sam/functions/get-catalog/main_test.go | Unit | Catalog sort order, empty catalog response shape |
| sam/internal/mpp/challenge_test.go | Unit | HMAC binding, challenge expiry, nonce uniqueness |
| Gate | Tool | What It Enforces | Task | Stage |
|---|---|---|---|---|
| YAML lint | yamllint | Valid YAML, document-start, line length, truthy values | lint:yaml | Both |
| HTML lint | htmlhint@1 | Valid HTML5, unique IDs, paired tags, alt attributes, lowercase tags | lint:html | Both |
| JS lint | eslint@8 | no-undef, no-unused-vars, prefer-const, no-var, semi, quotes, indent | lint:js | Both |
| Go lint | golangci-lint | errcheck, staticcheck, unused, govet, gofmt, gosec | lint:go | Both (add when Go code exists) |
| Taskfile schema | check-jsonschema | Valid Taskfile against taskfile.dev JSON schema | validate-schema:taskfile | Both |
| GitHub Actions schema | check-jsonschema | Valid workflow YAML against GitHub Actions schema | validate-schema:github-actions | Both |
| OpenAPI lint | @redocly/cli@1 | Valid OpenAPI 3.1.0, consistent $ref, required fields, response codes | validate-schema:openapi | Both |
| SAM lint | cfn-lint | CloudFormation / SAM template validity | validate-schema:sam | Both (add when sam/ exists) |
| Secret scan (CI) | gitleaks detect | No secrets in git history | security:gitleaks:detect | CI only |
| Secret scan (pre-commit) | gitleaks protect | No secrets in staged files | security:gitleaks:protect | Pre-commit only |
| Hugo build | hugo --minify | Site compiles without errors | build:hugo | Both |
| File | Change |
|---|---|
| Taskfile.yml | Added: lint:html, lint:js, validate-schema:openapi, localstack:*, sam:*, stripe:listen, swagger, env-json, setup |
| .tmuxinator/madcatalog.yml | Expanded to 3 windows: dev+hugo, backend (localstack+sam), tools (stripe+swagger) |
| .github/workflows/quality-gates.yml | Added Node.js setup; task ci now includes lint:html, lint:js, validate-schema:openapi |
| api/openapi.yml | New: OpenAPI 3.1.0 spec for all four endpoints |
| localstack/docker-compose.yml | New: LocalStack Docker Compose (DynamoDB only) |
| docs/research/.htmlhintrc | New: HTMLHint strict config |
| docs/research/.eslintrc.json | New: ESLint 8 config for research JS |
| Milestone | Deliverables | Effort | Status |
|---|---|---|---|
| M0 — Infrastructure | localstack/docker-compose.yml sam/template.yaml DynamoDB tables task setup task dev (full tmuxinator) | ~1 day | Complete |
| M1 — REST API | 11 Lambda functions Advertiser system Rank status endpoint task ci passing | ~1-2 days | Complete |
| M2 — MPP Integration | mppx/server in PlaceBid mppx/server in GetAdRankStatus CreateSpt Lambda Stripe SPT flow StripeWebhook Lambda | ~2-3 days | Complete |
| M3 — Hugo Catalog UI | Catalog page fetches /catalog Ad cards with rank Live refresh | ~1 day | Complete |
| M4 — MCP Server | Go MCP server (mcp/) 9 tools (full API coverage) MPP flow in place_ad + get_rank_status :8083 in tmuxinator | ~1-2 days | Complete |
| M5 — Production Deploy | sam deploy SSM params Stripe production mode CORS locked to madcatalog.com madcatalog.com | ~1 day | Next |
madcatalog requires four fields to place an ad: title, url, amount_usd_cents, and advertiser_slug. All other fields are optional enrichment that the placing machine (or its human operators) can provide to make the ad more visually compelling. Enrichment affects presentation only — it does not affect rank. Rank is always and only cumulative spend.
| Field | Type | Standalone? | Media Constraints | Effect |
|---|---|---|---|---|
| logo_url | URI | Yes | Square recommended; displayed at 48×48px (list) and 36×36px (card); any hosted image URL | Replaces letter-initial fallback in both list and card views |
| brand_color | hex string | Yes | 6-digit hex (#635BFF); no alpha |
Accent color for card top border (rank 4+), key_stat background tint, CTA button fill |
| tagline | string | Yes | Max 120 chars; displayed in italic below the title | Short positioning line; appears in both list and card views |
| key_stat | object | Yes | { "value": "3M+", "label": "developers" }; value max 12 chars, label max 40 chars |
Displayed as an accented pill below description; brand_color tints background if present |
| cta_label | string | Yes | Max 30 chars | Renders as a button linking to url; brand_color fills background if present, otherwise primary outline style |
| docs_url | URI | Yes | Any valid URL | Shows a “Docs” link in the list view footer row alongside the primary URL |
| hero_url | URI | Yes | Recommended: 1200×400px or wider banner; displayed at 110px height with object-fit: cover; any hosted image URL |
Full-width banner at the top of the card view tile only; not shown in list view |
POST /ads/bid (initial placement)
and PATCH /ads/{ad_id} (post-placement update via X-Api-Key).
This means a machine operator can first place a minimal ad, see it appear in the catalog,
then update enrichment fields without placing another bid.
| Field | Why excluded |
|---|---|
| rank | Computed server-side from cumulative_spend_usd_cents DESC. Immutable by clients. |
| cumulative_spend_usd_cents | Atomic counter incremented by Lambda on confirmed payment. No client override. |
The original design called for server-side LLM classification of category to prevent self-misclassification. The shipped implementation accepts category as a client-provided field, validated against the fixed enum vocabulary (inference, models, compute, vector-db, agent-tools, search, memory, observability, payments). This is a deliberate simplification: enum validation is sufficient to enforce the controlled taxonomy, and LLM classification adds latency and cost with marginal benefit when advertisers are accountable for their own placement quality.
Each advertiser in the catalog has a permanent, shareable profile page at
/advertisers/{slug}. The catalog card is intentionally compact;
the advertiser profile is the full-bleed version. See the
Stripe advertiser profile (mockup) for a live example.
madcatalog.com/advertisers/stripe on
Hacker News or X, the Open Graph preview shows rank, spend, and bid count.
The profile page is the viral unit — more specific and more narratively
interesting than the catalog index.
application/json when requested
with Accept: application/json. One URL, two representations.
| Section | Content | Why it matters |
|---|---|---|
| Hero band | Rank badge, logo, title, total spend (large), bid count, last bid time | The screenshot people share; survives being viewed in isolation |
| About | Full enrichment — key stat, tagline, full description, docs link, CTA button | Everything the card clamped; full-width layout allows complete presentation |
| Bid Timeline | Chronological feed: bid #, date, amount, cumulative, rank at time of bid, note for rank changes | Shows the machine’s commitment over time; each rank promotion is a story beat |
| Rank Journey | Visual step-progress: entry rank → intermediate ranks → current rank, with dates | The “from rank 8 to rank 1 in 30 days” narrative in one glance |
| Profile Metrics sidebar | Current rank, total spend, bid count, avg bid, first bid date, last bid date, days active, days to reach #1 | Stat-heavy card for engineers who want the numbers |
| Share block | Copy link button, pre-filled X/Twitter intent, “placed by a machine” callout | Reduces friction for human spectators to share the spectacle |
| Compete callout | How much a challenger would need to spend to dethrone the current #1 | Makes rank competition legible; calls machine operators to action |
In the catalog, the card title becomes a link to the advertiser profile when one exists. The external URL icon () always goes to the advertiser’s own site. In the list view, an explicit “Profile” link appears in the footer row. These two destinations are intentionally distinct:
| Click target | Destination | Use case |
|---|---|---|
| Card title / “Profile” link | madcatalog.com/advertisers/stripe |
Learn about the bid, share the artifact, see timeline |
| external icon / CTA button | stripe.com (advertiser’s site) |
Evaluate the product being advertised |
GET /advertisers/stripe with Accept: text/html returns
the human-readable profile page. The same URL with
Accept: application/json returns the advertiser object in full
(all enrichment fields, full bid log, rank history).
Agents can discover and follow advertiser URLs without a separate API endpoint.
Two named fixture personas are seeded by task dev on every cold start. They represent opposite ends of the enrichment spectrum and provide stable targets for dev-environment assertions. See ADR-012.
| Persona | Slug | Spend | Bids | Enrichment | Rank |
|---|---|---|---|---|---|
| Minimal | minimal-advertiser | $1.00 | 1 | Required fields only; no profile PATCH | 2 |
| Maximal | maximal-advertiser | $20.00 | 3 | All ad fields + all advertiser profile fields via PATCH | 1 |
The seed exercises the full MPP 402 flow for each bid and waits for Stripe webhook confirmation before moving to the next step. On completion, task dev:env-test asserts the expected catalog shape against the live API.
Each significant design choice is documented as an Architecture Decision Record (ADR) in docs/adr/. ADRs follow the Nygard format: Context, Decision, Consequences, and Alternatives Considered. Each ADR is a self-contained HTML page (Bootstrap 5, split CSS/JS/HTML) that can be opened directly in a browser.
| ADR | Title | Status | Decision summary |
|---|---|---|---|
| ADR-001 | Alpine.js for Catalog UI Widgets | Accepted | Alpine.js v3 via CDN for all catalog interactivity; no build step; file:// compatible |
| ADR-002 | OpenAPI 3.1.0 Contract-First | Accepted | api/openapi.yml defined before backend code; validated by Redocly on every commit |
| ADR-003 | Static HTML Prototype / file:// Compatibility | Accepted | All docs/research files are pure HTML + CDN deps; no build step; open from file:// |
| ADR-004 | Rank = Cumulative Spend, No Decay | Accepted | ORDER BY cumulative_spend_usd_cents DESC; no decay, no expiry, no editorial override |
| ADR-005 | Catalog Page for Human Consumption | Accepted | Dedicated human-optimized catalog UI; separate from REST API and MCP interfaces |
| ADR-006 | MCP Service for Machine Consumption | Accepted | MCP server (:8083, Go) exposes 9 tools covering the full API; absorbs MPP complexity |
| ADR-007 | REST API as Canonical Interface | Accepted | HTTP REST + MPP (402); content negotiation on /advertisers/{slug}; api_key once |
| ADR-008 | Machine-Only Marketplace Business Model | Accepted | Rank by autonomous machine spend only; no human bids, no editorial curation |
| ADR-009 | Hugo Docs Server for ADRs & Proposal | Accepted | Hugo at :1318 serves ADRs and the proposal via module mounts; standalone HTML files remain the source of truth |
| ADR-010 | Machine-Gated Catalog Reads (Planned Post-Launch) | Planned | GET /catalog free for humans always; MPP-gated for machines after catalog reaches traction threshold (~20+ active advertisers) |
| ADR-011 | OpenAPI Surface Area Seams | Accepted | Core API in api/openapi.yml; Stripe-specific paths in api/stripe.openapi.yml; redocly.yaml registers all specs for CI linting |
| ADR-012 | Dev Fixture Personas & Environment Verification | Accepted | Two fixture personas (minimal/maximal); advertiser_slug required; seed waits for webhook settlement; task dev appends env assertions |
| Method | Path | Auth | Returns (machine) | Returns (human) |
|---|---|---|---|---|
| GET | /newsletters/daily | MPP 402 for machines; none for humans | JSON — last 24 h activity digest | HTML page or RSS item (Accept: text/html or application/rss+xml) |
| GET | /newsletters/weekly | MPP 402 for machines; none for humans | JSON — last 7-day activity digest | HTML page or RSS item |
| GET | /newsletters/daily/history | MPP 402 for machines; none for humans | Paginated JSON archive of past dailies | HTML archive browse (Accept: text/html) |
| GET | /newsletters/weekly/history | MPP 402 for machines; none for humans | Paginated JSON archive of past weeklies | HTML archive browse (Accept: text/html) |
| GET | /newsletters/monthly | MPP 402 for machines; none for humans | JSON — last 30-day activity digest | HTML page or RSS item |
| GET | /newsletters/quarterly | MPP 402 for machines; none for humans | JSON — last 3-month activity digest | HTML page or RSS item |
| GET | /newsletters/annual | MPP 402 for machines; none for humans | JSON — full year-in-review digest | HTML page or RSS item |
| GET | /newsletters/monthly/history | MPP 402 for machines; none for humans | Paginated JSON archive of past monthlies | HTML archive browse |
| GET | /newsletters/quarterly/history | MPP 402 for machines; none for humans | Paginated JSON archive of past quarterlies | HTML archive browse |
| GET | /newsletters/annual/history | MPP 402 for machines; none for humans | Paginated JSON archive of past annuals | HTML archive browse |
Authorization header and the Accept
header together. If Authorization: Payment ... is present, treat the
caller as a machine: validate MPP credential, charge, return JSON.
If no Authorization header is present and Accept is
text/html or application/rss+xml, serve the human view
for free. If no auth is present but Accept: application/json is set,
return the 402 MPP challenge — force machines to identify and pay.
| Endpoint | Price (machine) | Rationale |
|---|---|---|
| /newsletters/daily | $0.50 – $1.00 per request | Priced for the customer who can pay: AI companies running competitive intelligence pipelines have real budgets. $0.25/day is $91/year and communicates "beta feature." $1.00/day is $365/year — trivial for any B2B agent pipeline, meaningful for solopreneur revenue, and signals the product is real. |
| /newsletters/weekly | $3.00 – $5.00 per request | Broader view, trend visibility, more actionable signal. $5.00/week is $260/year — still negligible for a B2B pipeline and proportional to the increased value over the daily digest. Machines choosing weekly over daily are optimizing for cost; price the discount accordingly. |
| /newsletters/*/history (daily/weekly) | $10.00 – $20.00 per page (machine) | The archive is the golden goose — price it like B2B data, not like an API micro-transaction. AI training pipelines, competitive research agents, and retrospective analysis agents extract more value from 100 pages of history than from 100 days of daily pulls. At $15/page, 10 agents pulling 50 pages each = $7,500. That is meaningful solopreneur revenue from a single product line. |
| /newsletters/monthly | $10.00 – $15.00 per request | Month-in-review view: broader than weekly, cheaper than quarterly. Targets analysts running monthly market summaries. $15/month = $180/year — a trivial B2B budget line but a meaningful catalog revenue signal at scale. |
| /newsletters/quarterly | $20.00 per request | Quarter-over-quarter trend visibility: new entrants, category shifts, position volatility. Target: strategy and competitive intelligence agents. $20/quarter = $80/year. Higher value signal than weekly; priced accordingly. |
| /newsletters/annual | $100.00 per request | The year-in-review: founding cohort performance, total machine economy spend, biggest rank story of the year. Target: AI industry analysts, enterprise strategy agents. $100/year is noise in any B2B budget. The value is the narrative — one year of machine-only ad competition told in aggregate numbers. |
| /newsletters/*/history (monthly/quarterly/annual) | $25 – $250 per page (machine) | Historic data is the archive product. Monthly history: $25/page. Quarterly history: $50/page. Annual history: $250/page. An AI training pipeline pulling 3 years of quarterly data (12 pages × $50) pays $600 for a complete machine economy dataset. Permanent storage — no TTL on quarterly/annual digests. |
Both /newsletters/daily and /newsletters/weekly return
the same NewsletterDigest shape; only the period field
and the covered time window differ.
{
"period": "daily",
"generated_at": "2026-05-23T07:00:00Z",
"window_start": "2026-05-22T07:00:00Z",
"window_end": "2026-05-23T07:00:00Z",
"new_ads": 3,
"total_bids": 47,
"total_spend_usd_cents": 128450,
"biggest_mover": {
"ad_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Groq — World's Fastest AI Inference",
"rank_change": -3,
"spend_delta_usd_cents": 42500
},
"top_spender": {
"ad_id": "f0e9d8c7-b6a5-4321-fedc-ba9876543210",
"title": "Stripe — Payment Infrastructure for the Machine Economy",
"cumulative_spend_usd_cents": 501700,
"rank": 1
},
"ads_summary": [
{
"ad_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Groq — World's Fastest AI Inference",
"bids_in_period": 12,
"spend_in_period_usd_cents": 42500
},
{
"ad_id": "f0e9d8c7-b6a5-4321-fedc-ba9876543210",
"title": "Stripe — Payment Infrastructure for the Machine Economy",
"bids_in_period": 8,
"spend_in_period_usd_cents": 35000
}
]
}
{
"items": [
{
"period_type": "daily",
"date": "2026-05-22",
"generated_at": "2026-05-22T07:00:00Z",
"new_ads": 2,
"total_bids": 31,
"total_spend_usd_cents": 89400
}
],
"next_cursor": "MjAyNi0wNS0yMQ==",
"total": 87
}
madcatalog-newsletters| Attribute | Type | Role | Notes |
|---|---|---|---|
| period_type | String (S) | Partition key | Values: "daily" or "weekly" |
| date | String (S) | Sort key | ISO date: "2026-05-23" (daily) or "2026-W21" (weekly) |
| generated_at | String (S) | Attribute | ISO 8601 timestamp when Lambda generated this digest |
| window_start | String (S) | Attribute | ISO 8601 start of observation window |
| window_end | String (S) | Attribute | ISO 8601 end of observation window |
| new_ads | Number (N) | Attribute | Count of ads created during the window |
| total_bids | Number (N) | Attribute | Total bid count across all ads in the window |
| total_spend_usd_cents | Number (N) | Attribute | Sum of all amount_usd_cents confirmed in the window |
| biggest_mover | Map (M) | Attribute | Serialized { ad_id, title, rank_change, spend_delta_usd_cents } |
| top_spender | Map (M) | Attribute | Serialized { ad_id, title, cumulative_spend_usd_cents, rank } |
| ads_summary | List (L) | Attribute | Serialized array of { ad_id, title, bids_in_period, spend_in_period_usd_cents } |
| ttl | Number (N) | TTL attribute | Unix epoch; set to now + 365 days. Daily digests auto-expire after one year. DynamoDB TTL enabled on this attribute. |
period_type + date): fetch today's daily
or this week's weekly in O(1). Used by GetDailyNewsletter and
GetWeeklyNewsletter Lambdas.
"daily"
or "weekly": paginated history in reverse chronological order. Used by
GetNewsletterHistory. No GSI needed — sort key is the ISO date string,
which sorts lexicographically in descending date order.
| Function | Route | Trigger | Responsibility |
|---|---|---|---|
| GetDailyNewsletter | GET /newsletters/daily | HTTP API Gateway |
Detects caller type via Authorization/Accept.
For machines: issues 402 challenge at $0.75 (configurable via env var
NEWSLETTER_DAILY_PRICE_CENTS); on valid credential, reads today's
digest from madcatalog-newsletters (PK: "daily",
SK: today's ISO date); returns NewsletterDigest JSON.
For humans: returns HTML or RSS without auth.
If no digest exists for today yet, returns 404 with
{ "error": "not_yet_generated", "next_generation_at": "..." }.
|
| GetWeeklyNewsletter | GET /newsletters/weekly | HTTP API Gateway |
Same auth detection logic as GetDailyNewsletter; prices from
NEWSLETTER_WEEKLY_PRICE_CENTS env var ($400 default = $4.00).
SK is ISO week: "2026-W21" (computed as
date-fns/getISOWeek or Go's time.ISOWeek()).
|
| GetNewsletterHistory | GET /newsletters/{period}/history | HTTP API Gateway |
Path param period: "daily" or "weekly".
Query params: limit (default 20, max 100), cursor
(base64-encoded ExclusiveStartKey from previous response).
MPP 402 for machines (same auth detection as GetDailyNewsletter).
Humans browsing with Accept: text/html get HTML archive pages for free.
Returns { items: [NewsletterSummary], next_cursor, total }.
total is an estimate from DynamoDB ItemCount on the table.
|
| GenerateDailyNewsletter | No HTTP route — scheduled | EventBridge cron: cron(0 7 * * ? *) (07:00 UTC daily) |
Scans madcatalog-bids for bids in the last 24 h (filter:
created_at BETWEEN window_start AND window_end).
Aggregates per-ad spend and bid counts. Identifies biggest mover by
comparing rank snapshots. Writes digest to madcatalog-newsletters.
Idempotent: uses ConditionExpression: attribute_not_exists(date)
on PutItem — re-runs safely.
|
| GenerateWeeklyNewsletter | No HTTP route — scheduled | EventBridge cron: cron(0 8 ? * MON *) (08:00 UTC Monday) |
Same aggregation logic as daily but over a 7-day window. SK uses ISO week notation. |
| GenerateMonthlyNewsletter | No HTTP route — scheduled | EventBridge cron: cron(0 8 1 * ? *) (08:00 UTC first of each month) |
Aggregates the prior calendar month. SK uses "2026-05" (YYYY-MM). Adds new_advertisers count (new slugs in window). No TTL — stored permanently. |
| GenerateQuarterlyNewsletter | No HTTP route — scheduled | EventBridge cron: cron(0 9 1 1,4,7,10 ? *) (09:00 UTC first of each quarter) |
Aggregates the prior quarter. SK: "2026-Q1". Adds category_breakdown (per-category spend totals) and new_advertisers. No TTL. Full spec in advertiser-admin-plan.html §2 and §5. |
| GenerateAnnualNewsletter | No HTTP route — scheduled | EventBridge cron: cron(0 10 1 1 ? *) (10:00 UTC Jan 1st) |
Year-in-review digest. SK: "2025". Includes full category breakdown, founding cohort analysis, total machine economy spend YTD, and biggest rank story (advertiser that climbed most). No TTL. Archive product — sold indefinitely. |
madcatalog-newsletters (PK: period_type, SK: date string).
The read-path Lambdas (GetDailyNewsletter, GetQuarterlyNewsletter,
etc.) are simple GetItem lookups — no live aggregation on request.
This makes latency predictable, prevents thundering-herd on popular digest days, and
means the same digest object can be read by 1,000 concurrent machines without extra cost.
Add the following to sam/template.yaml alongside the existing Lambda definitions.
Key additions: the madcatalog-newsletters DynamoDB table resource, three new
HTTP-triggered Lambda functions, two scheduled Lambda functions with EventBridge rules,
and the new IAM policy statements granting dynamodb:GetItem,
dynamodb:Query, and dynamodb:PutItem on the newsletters table.
| Resource type | Logical ID | Notes |
|---|---|---|
| AWS::DynamoDB::Table | NewslettersTable | PK: period_type (S), SK: date (S). TTL: ttl attribute. BillingMode: PAY_PER_REQUEST. |
| AWS::Serverless::Function | GetDailyNewsletterFunction | Handler path sam/functions/get-daily-newsletter/main; Events: Api: { Path: /newsletters/daily, Method: get } |
| AWS::Serverless::Function | GetWeeklyNewsletterFunction | Handler path sam/functions/get-weekly-newsletter/main; Events: Api: { Path: /newsletters/weekly, Method: get } |
| AWS::Serverless::Function | GetNewsletterHistoryFunction | Handler path sam/functions/get-newsletter-history/main; Events: two routes — /newsletters/daily/history and /newsletters/weekly/history |
| AWS::Serverless::Function | GenerateDailyNewsletterFunction | Handler path sam/functions/generate-daily-newsletter/main; Events: Schedule: { Schedule: cron(0 7 * * ? *), Name: DailyNewsletterSchedule } |
| AWS::Serverless::Function | GenerateWeeklyNewsletterFunction | Handler path sam/functions/generate-weekly-newsletter/main; Events: Schedule: { Schedule: cron(0 8 ? * MON *), Name: WeeklyNewsletterSchedule } |
Add four paths to api/openapi.yml. The two active newsletter endpoints
(/newsletters/daily, /newsletters/weekly) use the same
x-payment-info extension as /ads/bid and include a
security section referencing the MppPayment scheme defined
in the existing spec. The two history endpoints have no security requirements.
| Path | Operation ID | MPP? | Response schema |
|---|---|---|---|
| /newsletters/daily | getDailyNewsletter | Yes — 402/200 | NewsletterDigest |
| /newsletters/weekly | getWeeklyNewsletter | Yes — 402/200 | NewsletterDigest |
| /newsletters/daily/history | getDailyNewsletterHistory | No | NewsletterHistoryPage |
| /newsletters/weekly/history | getWeeklyNewsletterHistory | No | NewsletterHistoryPage |
Add two new schema components to openapi.yml#/components/schemas:
NewsletterDigest and NewsletterHistoryPage, reflecting
the JSON shapes documented above. Extract NewsletterSummary as a
third component and use $ref for the items array.
1. Machine 402 flow (daily).
Issue a bare GET /newsletters/daily with Accept: application/json
and no Authorization header. Assert: response is 402, body includes a
valid MPP challenge with amount matching NEWSLETTER_DAILY_PRICE_CENTS.
2. Machine paid access (daily).
Complete the MPP flow (create SPT via /payments/stripe/spt, re-issue GET
with valid credential). Assert: 200, response body validates against the
NewsletterDigest JSON schema, all required fields present and correctly typed.
3. Human free access (daily).
Issue GET /newsletters/daily with Accept: text/html and no auth.
Assert: 200, Content-Type: text/html, no 402, body contains recognizable
HTML (at minimum: <title> tag present).
4. Weekly mirrors daily.
Repeat tests 1–3 against /newsletters/weekly. Assert price matches
NEWSLETTER_WEEKLY_PRICE_CENTS.
5. History is free.
Issue GET /newsletters/daily/history with Accept: application/json
and no auth. Assert: 200, no 402 challenge, body validates against
NewsletterHistoryPage schema (items array, total
integer, next_cursor present or null).
6. 404 before generation.
In a clean LocalStack environment where no digest has been generated yet, issue a
paid GET /newsletters/daily. Assert: 404 with
error: "not_yet_generated" and a next_generation_at field.
| Task name | Command skeleton | Purpose |
|---|---|---|
| test:newsletter:smoke | go test ./tests/smoke/newsletter/... | Run all six smoke tests against SAM local on :3000. Requires LocalStack running. |
| newsletter:generate:daily | aws lambda invoke --function-name GenerateDailyNewsletterFunction ... | Manually trigger digest generation against LocalStack for dev/debug. |
| newsletter:generate:weekly | aws lambda invoke --function-name GenerateWeeklyNewsletterFunction ... | Same for weekly. |
| validate-schema:newsletter | check-jsonschema --schemafile static/schemas/NewsletterDigest.json tests/fixtures/newsletter-digest-example.json | Validate example fixture against extracted JSON schema. Add NewsletterDigest.json and NewsletterHistoryPage.json to static/schemas/. |
Add validate-schema:newsletter to the existing task precommit
and task ci dependency lists in Taskfile.yml. Newsletter smoke tests
should run in CI only (they require a running LocalStack + SAM local stack);
add test:newsletter:smoke to the test:smoke meta-task that
runs in the integration stage of .github/workflows/quality-gates.yml.
The two new JSON schema files in static/schemas/ are automatically
covered by the existing OpenAPI lint gate because the OpenAPI spec references them
via $ref — if they drift, Redocly lint will catch it.
verification.tier = "certified" to prefer advertisers
whose integration has been programmatically audited. The purchase flow itself
is MPP-protected — verification is machine-native by design.
| Tier | Badge | Monthly price | One-time price | Meaning |
|---|---|---|---|---|
| Verified | ✓ Blue checkmark | $5 / month | $500 one-time | Identity confirmed; real company or registered entity. madcatalog staff (or automated document check) has confirmed the advertiser identity matches the ad. |
| Certified | ✓ Gold checkmark | $20 / month | $2,000 one-time | Audited integration: the advertiser has demonstrated that their bids are placed programmatically by a machine agent, not by a human using an API client. Confirmed via test bid challenge + signed attestation or live API inspection. |
| Founding Partner | ★ Custom badge + "Founding Partner" label | $100 / month | $10,000 one-time | Top-tier visibility; custom badge color (advertiser-specified hex, rendered as brand_color on the badge). Reserved for early adopters who commit while madcatalog is pre-launch. Founding Partner status confers permanent label visibility even if the badge tier changes. |
| Product | Price | Audience | What the badge means |
|---|---|---|---|
| Twitter Blue (basic) | $8 / month | Consumer | Paid, not verified. Often criticized as meaningless trust signal. |
| X Premium+ | $22 / month | Consumer / creator | Verified government ID or notable account. Stronger but still consumer-oriented. |
| madcatalog Verified | $5 / month or $500 one-time | B2B machine operator | Real company identity confirmed. Higher than consumer because identity confirmation requires manual or automated document verification — the marginal cost is higher, the audience is smaller and higher-value. |
| madcatalog Certified | $20 / month or $2,000 one-time | B2B machine operator | Programmatic integration confirmed. Substantially higher than X Premium because B2B SaaS pricing norms apply: the buyer is a company, not a person, and the signal has real commercial value in agent-to-agent interactions. |
| madcatalog Founding Partner | $100 / month or $10,000 one-time | Strategic early adopter | No consumer analog. Founder-tier pricing for companies willing to bet on madcatalog before it has public traction. Modeled on enterprise SaaS "founding customer" deals. |
The machine operator's agent initiates verification by paying via the
POST /verification/purchase endpoint. This endpoint is MPP-protected:
the machine receives a 402 challenge sized to the tier price, pays, and receives
a verification_token in the 200 response. The token is then submitted
to activate verification on a specific ad.
| Method | Path | Auth | Request body | Response |
|---|---|---|---|---|
| POST | /verification/purchase | MPP 402 | { "ad_id": "uuid", "tier": "verified"|"certified"|"founding_partner" } |
{ "verification_token": "vt_...", "tier": "verified", "expires_at": "..." } — see schema below |
| POST | /ads/{ad_id}/verify | X-Api-Key (ad owner) | { "verification_token": "vt_..." } |
Updated Ad object with verification field populated |
| GET | /ads/{ad_id}/verification | None (public) | N/A | Public verification status: { "ad_id": "...", "verification": { ... } } |
{
"verification_token": "<vt_… 43-char base64url token returned here …>",
"tier": "certified",
"ad_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": null,
"purchased_at": "2026-05-23T12:34:56Z",
"payment_receipt": "chal_abc123"
}
Ad object){
"verification": {
"tier": "certified",
"verified_at": "2026-05-23T12:34:56Z",
"expires_at": null
}
}
verification is nullverification field is present
in the response but explicitly set to null. This allows machines
to distinguish between "the API doesn't support verification" (field absent) and
"this ad is unverified" (field is null). OpenAPI spec marks the field as
nullable: true.
madcatalog-ads TableNo new table is required. Add the following attributes to existing ad records. These are sparse attributes — they are absent on unverified ads and only written when verification is activated.
| Attribute | Type | Written by | Notes |
|---|---|---|---|
| verification_tier | String (S) | VerifyAdFunction Lambda (called by POST /ads/{ad_id}/verify) |
Values: "verified", "certified", "founding_partner". Absent if unverified. |
| verified_at | String (S) | VerifyAdFunction |
ISO 8601 timestamp when verification was activated. Immutable once written. |
| verification_expires_at | String (S) or absent | VerifyAdFunction |
ISO 8601. Present only for subscription (monthly) purchases. Absent for one-time (permanent) purchases. GetCatalog Lambda should filter out expired verifications at read time: if verification_expires_at is in the past, treat verification_tier as null in the response. |
| verification_token_hash | String (S) | PurchaseVerificationFunction Lambda |
SHA-256 hash of the verification_token issued at purchase. Used by VerifyAdFunction to validate the token before activating verification. Never store the raw token. |
madcatalog-verification-tokensShort-lived token store. Verification tokens expire after 72 hours if not redeemed. TTL-based cleanup keeps this table small.
| Attribute | Type | Role |
|---|---|---|
| token_hash | String (S) | Partition key. SHA-256 of the raw vt_... token. |
| ad_id | String (S) | The ad this token is authorized to verify. |
| tier | String (S) | Which tier was purchased: "verified", "certified", or "founding_partner". |
| purchased_at | String (S) | ISO 8601 timestamp of the MPP payment. |
| payment_receipt | String (S) | MPP challenge ID (chal_...) for audit trail. |
| redeemed | Boolean (BOOL) | Set to true by VerifyAdFunction after token is used. Prevents replay. |
| ttl | Number (N) | Unix epoch. Set to purchased_at + 72h. DynamoDB TTL enabled. Unredeemed tokens auto-expire. |
verification_expires_at is null on the ad record.
No Stripe subscription object to manage. No webhook for
customer.subscription.deleted. Easier to reason about for
machines — "I paid once, I'm verified forever."
invoice.payment_failed and customer.subscription.deleted,
and a Lambda to write verification_expires_at on expiry.
More moving parts; richer business model.
Create the following in the Stripe Dashboard before implementing
PurchaseVerificationFunction. Store Product IDs and Price IDs
in SSM Parameter Store at /madcatalog/verification/{tier}/price-id.
| Product name | Metadata tier | One-time Price (USD) | Stripe Price type |
|---|---|---|---|
| madcatalog Verified Badge | verified |
$500.00 | one_time |
| madcatalog Certified Badge | certified |
$2,000.00 | one_time |
| madcatalog Founding Partner Badge | founding_partner |
$10,000.00 | one_time |
The PurchaseVerificationFunction Lambda reads the tier
from the request body, looks up the corresponding Price ID from SSM, and uses
that Price ID as the expected amount when issuing the MPP 402 challenge. The
Stripe PaymentIntent is created with metadata.tier = "certified"
and metadata.ad_id = "{uuid}" so that the webhook handler can
correlate the payment if needed. The MPP challenge amount must
match the Price exactly — set amount in the WWW-Authenticate
header to the price in cents (e.g., amount="200000" for $2,000.00).
| Function | Route | Responsibility |
|---|---|---|
| PurchaseVerificationFunction | POST /verification/purchase |
MPP-protected. Validates tier in request body. Looks up price
from SSM. Issues 402 challenge with correct amount. On valid credential:
generates a vt_... token (crypto-random 32 bytes, base64url-encoded
with "vt_" prefix), hashes it with SHA-256, writes token record
to madcatalog-verification-tokens, returns token and metadata to caller.
|
| VerifyAdFunction | POST /ads/{ad_id}/verify |
Requires X-Api-Key matching the ad owner (same key used for
PATCH /ads/{ad_id} enrichment updates). Hashes the submitted
verification_token, looks up the record in
madcatalog-verification-tokens, validates: token exists,
not redeemed, not expired, ad_id matches. On success: writes
verification_tier, verified_at to
madcatalog-ads; marks token as redeemed = true.
|
| GetVerificationFunction | GET /ads/{ad_id}/verification |
Public, no auth. Reads ad record from madcatalog-ads.
If verification_tier is absent, returns
{ "ad_id": "...", "verification": null }.
If verification_expires_at is in the past, returns null verification
(expired badge is treated as unverified in read path).
Otherwise returns full verification object.
|
1. Purchase endpoint requires MPP payment.
Issue POST /verification/purchase with body
{ "ad_id": "{test_ad_id}", "tier": "verified" } and no
Authorization header. Assert: 402 with challenge amount
50000 (= $500.00 in cents). Amount must match the verified
tier Price.
2. Paid purchase returns a verification token.
Complete MPP flow. Assert: 200, response contains verification_token
matching pattern /^vt_[A-Za-z0-9_-]{43}$/, tier = "verified",
ad_id matches submitted value, expires_at = null.
3. Redeem token activates verification.
Submit POST /ads/{ad_id}/verify with the token from test 2 and the
ad owner's X-Api-Key. Assert: 200, response contains
verification.tier = "verified" and verification.verified_at
is a valid ISO 8601 timestamp.
4. Token cannot be redeemed twice.
Resubmit the same token to POST /ads/{ad_id}/verify. Assert: 409
Conflict with error: "token_already_redeemed".
5. Verification appears in catalog.
Issue GET /catalog. Find the verified ad. Assert: its
verification field is present, non-null, with tier = "verified".
6. Public verification endpoint.
Issue GET /ads/{ad_id}/verification (no auth). Assert: 200,
verification.tier = "verified". Then issue the same request for an
unverified ad. Assert: verification = null.
| Task name | Purpose |
|---|---|
| test:verification:smoke | Run all six verification smoke tests against SAM local. Requires LocalStack running and test ad seeded. |
| validate-schema:verification | Validate tests/fixtures/verification-purchase-response-example.json against extracted static/schemas/VerificationPurchaseResponse.json. |
| stripe:create-verification-products | Idempotent script that creates the three Stripe Products + Prices via stripe products create and stripe prices create CLI commands. Reads existing IDs from SSM to skip re-creation. Run once per environment. |
Add validate-schema:verification to task precommit and
task ci. Add test:verification:smoke to the
test:smoke meta-task in .github/workflows/quality-gates.yml
alongside test:newsletter:smoke. Add the new
VerificationPurchaseResponse.json schema to static/schemas/
and reference it from api/openapi.yml via $ref so
that the existing Redocly lint gate validates it automatically.
The madcatalog-verification-tokens DynamoDB table definition
should be added to the SAM template and validated by the existing
validate-schema:sam / cfn-lint gate.
Every request to GET /catalog (API or Hugo site page load) generates a
short-lived X-Catalog-Session UUID. This token is the correlation anchor:
anonymous readers and agents that later convert to advertisers or bidders can be
attributed to a catalog session. The session ID is optional for clients; the server
fails open if it is not echoed. Three conversion paths exist:
| Path | Flow | When to use |
|---|---|---|
| A — Advertiser first, ad later | POST /advertisers → receive advertiser_api_key → later POST /ads/bid with X-Advertiser-Api-Key |
Agent or human wants to establish advertiser identity and enrich the profile before committing any budget. Useful for organizations preparing a launch. |
| B — Advertiser + ad in one step (existing frictionless path) | POST /ads/bid with inline advertiser object in body — creates advertiser and ad atomically |
Agent has budget and content ready simultaneously. The current lowest-friction path. Most common for fully automated programmatic buyers. |
| C — Ad buy with deferred advertiser enrichment (existing) | POST /ads/bid with unclaimed slug → advertiser auto-created → enrich later via PATCH /advertisers/{slug} |
Agent gets a slot in the catalog immediately, enriches the advertiser profile separately. Current default behavior for minimal ad placements. |
GET /advertisers/{slug}/dashboard • MCP: get_advertiser_dashboard
GET /ads/{ad_id}/dashboard • MCP: get_ad_dashboard
GET /public/leaderboard • GET /public/catalog-stats
CreateAdvertiserRequest, AdvertiserDashboard,
AdDashboard, PublicLeaderboard); the modified
POST /ads/bid schema accepting an inline advertiser
object or an existing advertiser_id; new MCP tools
(create_advertiser, get_advertiser_dashboard,
get_ad_dashboard, get_advertiser_ads,
get_public_leaderboard, get_catalog_stats);
OTel logs, metrics, and traces for the full funnel; and element-level dashboard
specifications for all three surfaces.
Per ADR-002, no new endpoints are added to api/openapi.yml until
active implementation begins.