Advertiser Admin Implementation Plan

Technical specification for F3: Advertiser Admin Portal & Top-of-Funnel Tracking. This document covers OpenAPI changes, schema definitions, MCP tools, and OTel instrumentation. It is the implementation contract; the proposal section is the design rationale.

1 Funnel Model — Session ID for Top-of-Funnel Tracking
The tracking problem
An anonymous agent reads GET /catalog. Later — same session, different session, maybe minutes later — that agent places a bid. We want to know: did the catalog read convert to a bid? Without a correlation token, we can't answer that. With one, we get a coarse but useful funnel signal.
Session ID Mechanism
StageActionToken flow
Catalog read GET /catalog or website page load Server generates a short-lived X-Catalog-Session UUID (v4) and returns it in the response header. TTL: 60 minutes. Client MAY echo it back on subsequent requests.
Bid challenge POST /ads/bid (no auth → 402) Client echoes X-Catalog-Session in the request header. Server logs the session ID alongside the challenge ID. No enforcement — entirely optional, fail-open.
Bid placement POST /ads/bid (with credential → 200) Client echoes same X-Catalog-Session. Server includes it in the bid record (session_id sparse attribute) and in OTel trace attributes.
Advertiser creation POST /advertisers (new) Same echo pattern. Links advertiser creation to the originating catalog session.
Limitations — intentional
This is correlation, not identity. The session ID is not authenticated and can be forged or dropped. Agents that ignore the header produce no funnel signal. This is acceptable: the goal is aggregate conversion rate signal (e.g., "15% of sessions that generate a challenge also complete a bid"), not per-user tracking. Do not build features that rely on session IDs being unforgeable.
2 New REST Endpoints
MethodPathAuthSummaryLambda
POST /advertisers None (creates new) Create an advertiser profile without placing an ad. Returns advertiser_slug + advertiser_api_key. Idempotent on slug (returns 409 if slug already claimed). CreateAdvertiserFunction
GET /advertisers/{slug}/dashboard X-Advertiser-Api-Key Aggregate performance dashboard for all ads owned by this advertiser. Returns structured JSON (see §10). Machine-readable; the catalog site renders the human view. GetAdvertiserDashboardFunction
GET /advertisers/{slug}/ads X-Advertiser-Api-Key Paginated list of all ads associated with this advertiser slug. Includes current rank, cumulative spend, and view count per ad. GetAdvertiserAdsFunction (or extend GetAdvertisersFunction)
GET /ads/{ad_id}/dashboard X-Api-Key (ad owner) or X-Advertiser-Api-Key Individual ad performance dashboard. Returns rank history, spend trend, bid history, and competitive intelligence (spend to next rank). See §11. GetAdDashboardFunction
GET /public/leaderboard None (public) Public rank leaderboard. Returns top N ads by cumulative spend with sanitized fields (title, rank, spend, rank_change). No PII, no advertiser_slug. Rate-limited. GetPublicLeaderboardFunction
GET /public/catalog-stats None (public) Aggregate catalog health stats: total ads, total spend, total bids, newest ad created_at, most recent bid. Cached 60s. Powers the public dashboard. GetCatalogStatsFunction
GET /newsletters/quarterly MPP 402 for machines; none for humans (HTML/RSS) Most recent quarterly digest. $20 per machine request. Covers 3-month trends: top movers, new entrants, category shifts, total volume. GetQuarterlyNewsletterFunction
GET /newsletters/annual MPP 402 for machines; none for humans (HTML/RSS) Most recent annual digest. $100 per machine request. Year-in-review: founding cohort performance, emerging categories, machine economy volume story. GetAnnualNewsletterFunction
GET /newsletters/quarterly/history MPP 402 for machines ($50/page); free HTML for humans Paginated archive of all quarterly digests. No TTL — stored permanently for historic analysis and AI training use cases. GetNewsletterHistoryFunction (extend existing)
GET /newsletters/annual/history MPP 402 for machines ($250/page); free HTML for humans Paginated archive of all annual digests. Permanent archive. AI training pipelines pulling the full catalog year history pay per page. GetNewsletterHistoryFunction (extend existing)
GET /newsletters/monthly MPP 402 for machines ($10); none for humans (HTML/RSS) Most recent monthly digest. Covers prior calendar month: top movers, new ads, category spend totals, new advertisers count. Permanent storage — no TTL. GetMonthlyNewsletterFunction
GET /newsletters/monthly/history MPP 402 for machines ($25/page); free HTML for humans Paginated archive of all monthly digests. Permanent archive. Sort key "2026-05" (YYYY-MM). Useful for year-over-year machine economy trend analysis. GetNewsletterHistoryFunction (extend existing)
3 Modified Endpoints
POST /ads/bid — Accept Advertiser Creation OR Advertiser ID MODIFIED

Today POST /ads/bid auto-creates a new advertiser if the slug is unclaimed, or requires X-Advertiser-Api-Key if the slug is already claimed. The request body does not accept structured advertiser metadata. Three cases must be cleanly supported:

CaseRequest shapeBehavior
New advertiser + new ad (existing today) advertiser_slug only (unclaimed slug, no api key header) Auto-creates advertiser, creates ad, returns both api_key and advertiser_api_key. Backward-compatible — no change needed for this path.
Existing advertiser + new ad (existing today) advertiser_slug + X-Advertiser-Api-Key header Validates API key, creates ad under existing advertiser. Backward-compatible.
New advertiser with enrichment (new) advertiser_slug + advertiser object (see schema §5) in request body, no api key header Auto-creates advertiser with the provided enrichment fields (display_name, website_url, contact_email, description). Creates ad. Returns both keys.
Backward-compatibility guarantee
Existing calls to POST /ads/bid without the new advertiser object field continue to work exactly as before. The advertiser field is optional. No breaking change.
GET /catalog — Return Session ID MODIFIED

Add X-Catalog-Session response header containing a new UUID v4 generated per request. This is the top-of-funnel session token. No DynamoDB write required — the session ID is included in OTel log attributes on the response and used for correlation only. No change to the response body schema.

GET /ads/{ad_id}/bids — Include Session ID MODIFIED

Add session_id to each bid object in the response. Value is the X-Catalog-Session echoed by the bidder at placement time, or null if not provided. Allows advertisers to see which of their bids originated from a tracked catalog session.

4 New JSON Schemas
CreateAdvertiserRequest
Used by: POST /advertisers
FieldTypeRequiredConstraints
slugstringYes3–64 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphen. Must be globally unique.
display_namestringNoMax 120 chars. Human-readable name (e.g., "Stripe, Inc."). Shown on public advertiser profile page.
website_urlstring (URI)NoMust be HTTPS. Must not be a private/loopback address. Subject to the same HEAD-fetch reachability check as ad URLs.
contact_emailstring (email)NoRFC 5322 email. Not publicly exposed. Used for newsletter delivery and admin contact. Stored as PII — do not log.
descriptionstringNoMax 500 chars. Short business description. Rendered on public advertiser profile page.
CreateAdvertiserResponse
FieldTypeNotes
slugstringEcho of the requested slug.
advertiser_api_keystringUUID v4. Plain text, returned once only. Store this — it cannot be recovered. Used in X-Advertiser-Api-Key header for all subsequent admin operations.
display_namestring | nullEcho of submitted value, or null.
created_atstring (ISO 8601)Timestamp of creation.
AdvertiserDashboard
Used by: GET /advertisers/{slug}/dashboard
FieldTypeDescription
slugstringAdvertiser slug.
display_namestring | nullAdvertiser display name.
ad_countintegerTotal ads currently in catalog.
total_spend_usd_centsintegerAll-time cumulative spend across all ads.
total_bid_countintegerAll-time total bids placed by this advertiser.
spend_last_30d_usd_centsintegerSum of confirmed bid amounts in the last 30 days.
best_rankinteger | nullCurrent minimum rank across all active ads. Null if no ads.
avg_ranknumber | nullMean rank across all active ads. Lower = better.
total_view_countintegerSum of view_count across all ads — total times any of this advertiser's ads appeared in a catalog response.
payment_methodsobject{ "stripe": N, "tempo": M } — count of bids by payment method across all ads.
adsarray of AdSummaryLightweight summary of each ad: ad_id, title, current_rank, cumulative_spend_usd_cents, bid_count, view_count.
generated_atstring (ISO 8601)Timestamp of when this response was computed.
AdDashboard
Used by: GET /ads/{ad_id}/dashboard
FieldTypeDescription
ad_idstringAd identifier.
titlestringAd title.
current_rankintegerCurrent position in the catalog (1 = top).
rank_change_7dintegerChange in rank over the last 7 days. Negative = improved (moved up). Requires rank history snapshot storage — see §7.
rank_change_30dintegerChange in rank over the last 30 days.
cumulative_spend_usd_centsintegerAll-time confirmed spend for this ad.
spend_last_7d_usd_centsintegerSpend from bids confirmed in last 7 days.
spend_last_30d_usd_centsintegerSpend from bids confirmed in last 30 days.
bid_countintegerTotal confirmed bids for this ad.
view_countintegerTotal times this ad appeared in a catalog response.
spend_to_next_rank_usd_centsinteger | nullMinimum additional spend needed to surpass the next-higher-ranked ad. Null if already rank 1.
last_bid_atstring | nullISO 8601 timestamp of most recent confirmed bid.
last_bid_payment_methodstring | null"stripe" or "tempo".
recent_bidsarrayLast 10 bids: bid_id, amount_usd_cents, payment_method, status, created_at.
generated_atstring (ISO 8601)Response computation timestamp.
PublicLeaderboard
Used by: GET /public/leaderboard
FieldTypeDescription
adsarray of LeaderboardEntryTop N ads sorted by rank. Each entry: rank, title, cumulative_spend_usd_cents, rank_change_24h, category. Excludes advertiser_slug, url, contact info.
total_adsintegerTotal ads in catalog.
generated_atstringCached response timestamp.
5 Modified JSON Schemas
PlaceBidRequest — Add optional advertiser object MODIFIED
New fieldTypeRequiredDescription
advertiser object (optional) No Inline advertiser creation fields. Accepted only when advertiser_slug is not yet claimed and no X-Advertiser-Api-Key header is present (new advertiser path). Schema: { display_name?, website_url?, contact_email?, description? }. Ignored if slug already exists (existing advertiser path uses the header instead).
Bid (response object) — Add session_id MODIFIED
New fieldTypeDescription
session_id string | null The X-Catalog-Session token echoed by the bidder at placement time, or null. Returned by GET /ads/{ad_id}/bids. Allows advertisers to correlate bids with catalog sessions. Not included in the POST /ads/bid 200 response — only exposed through the bids history endpoint.
Ad (catalog response) — Add payment_method to last bid info MODIFIED

Add last_bid_payment_method (string | null) to the Ad object returned by GET /catalog and GET /ads/{ad_id}. Value is "stripe" or "tempo" based on the most recent confirmed bid. Null for ads with no confirmed bids. This is publicly readable — machine agents can use this to understand which payment rails are active in the catalog.

NewsletterDigest — Expand for quarterly/annual MODIFIED

Add period values "quarterly" and "annual" to the existing NewsletterDigest enum. Add two new fields:

New fieldTypeDescription
category_breakdownarray | nullPer-category spend totals for the period. Shape: [{ category, total_spend_usd_cents, bid_count, ad_count }]. Present in quarterly and annual only.
new_advertisersinteger | nullCount of distinct new advertiser_slugs that placed their first bid in the period. Present in quarterly and annual only.
6 New MCP Tools
Tool nameDescriptionAuth requiredBacking endpoint
create_advertiser Create an advertiser profile (slug + optional metadata) without placing an ad. Returns advertiser_api_key. Use when an agent wants to establish identity before placing any bids, or when a human operator is setting up an advertiser account programmatically. None (creates new account) POST /advertisers
get_advertiser_dashboard Retrieve aggregate performance metrics for all ads owned by an advertiser: total spend, bid counts, best rank, view count, payment method breakdown, and a summary table of all active ads. advertiser_api_key argument GET /advertisers/{slug}/dashboard
get_ad_dashboard Retrieve detailed performance metrics for a single ad: current rank, rank change over 7/30 days, spend trends, bid history (last 10), spend to next rank, and competitive gap to rank 1. api_key or advertiser_api_key argument GET /ads/{ad_id}/dashboard
get_advertiser_ads List all ads associated with an advertiser slug, with their current rank, spend, and view count. Paginated. Useful for an agent managing multiple ads to enumerate what it owns before deciding where to allocate budget. advertiser_api_key argument GET /advertisers/{slug}/ads
get_public_leaderboard Read the public rank leaderboard (no auth required). Returns top N ads with rank, title, cumulative spend, and 24-hour rank change. Use for competitive research, to understand the bid landscape before deciding on a bid amount. None GET /public/leaderboard
get_catalog_stats Get aggregate catalog health metrics (total ads, total spend, total bids, most recent activity). No auth required. Use for a quick market-health check before committing to a bid. None GET /public/catalog-stats
MCP tool input schema convention
All new tools follow the same input schema pattern as existing tools in mcp/internal/mcp/tools.go: JSON object with named string/integer fields, described in the inputSchema property. Auth keys (advertiser_api_key, api_key) are passed as tool arguments — never in environment variables. Claude agents invoking these tools can store the keys in conversation context after a create_advertiser or place_ad call.
7 OTel — Structured Logs to Capture
Governed by ADR-014: OpenTelemetry Unified Observability
All logs use console.log(JSON.stringify({...})) bridged to the OTel Logger API via the existing bridge in sam/shared/otel.mjs. Logs flow to Loki via the OTel Collector. Do not add new logging frameworks — extend the existing bridge.
Event nameLambdaKey fieldsPurpose
session.createdGetCatalogFunctionsession_id, source: "catalog"|"website"Top-of-funnel: anonymous catalog read that generated a session token.
session.bid_challengePlaceBidFunctionsession_id (if echoed), challenge_id, advertiser_slugSession converted to a bid challenge — mid-funnel signal.
session.bid_placedPlaceBidFunctionsession_id (if echoed), bid_id, ad_id, payment_method, amount_usd_centsSession converted to a confirmed bid — bottom-of-funnel conversion.
advertiser.createdCreateAdvertiserFunction or PlaceBidFunctionslug, session_id, via: "direct"|"bid_inline", has_enrichment: boolNew advertiser profile created — critical conversion event.
advertiser.dashboard.queriedGetAdvertiserDashboardFunctionslug, ad_count, duration_msAdvertiser is actively monitoring their account — engagement signal.
ad.dashboard.queriedGetAdDashboardFunctionad_id, slug, current_rankIndividual ad being monitored — bid optimization intent signal.
leaderboard.queriedGetPublicLeaderboardFunctiontotal_ads, requester_type: "agent"|"human" (inferred from Accept header)Competitive research signal — who is looking at the landscape.
newsletter.quarterly.generatedGenerateQuarterlyNewsletterFunctionperiod, total_spend_usd_cents, new_advertisers, duration_msQuarterly digest generation completed successfully.
newsletter.annual.generatedGenerateAnnualNewsletterFunctionyear, total_spend_usd_cents, total_advertisers, duration_msAnnual digest generation completed successfully.
8 OTel — Metrics to Capture

All metrics use the meter exported from sam/shared/otel.mjs. Counters and histograms flow to Prometheus via the OTel Collector (namespace: madcatalog_). Follow the existing payment.succeeded pattern.

Metric name (OTel)TypeLabelsDescription
funnel.session.createdCountersource (catalog|website)Top-of-funnel catalog sessions created.
funnel.session.convertedCounterto_stage (bid_challenge|bid_placed|advertiser_created)Sessions that advanced to a downstream stage. Denominator for conversion rate panels.
advertiser.createdCountervia (direct|bid_inline), has_enrichment (true|false)New advertiser profiles created. Tracks advertiser acquisition velocity.
dashboard.advertiser.query_durationHistogram (ms)ad_count (bucket of ads per advertiser)Latency of aggregate dashboard computation. High ad counts are slow — track the long tail.
dashboard.ad.query_durationHistogram (ms)Latency of individual ad dashboard computation.
leaderboard.queryCounterrequester_type (agent|human)Public leaderboard query volume. Tracks organic machine discovery.
newsletter.generation.durationHistogram (ms)period (daily|weekly|quarterly|annual)How long each generation Lambda takes. Alert if quarterly exceeds 25s (Lambda timeout risk).
newsletter.generation.failedCounterperiod, reasonFailed generation runs (DynamoDB error, idempotency skip, timeout). Alert on non-zero.
bid.payment_methodCountermethod (stripe|tempo), is_new_advertiserBids broken down by payment method. Tracks Tempo adoption rate over time.
9 OTel — Traces to Capture

All Lambdas already produce a root span via withTracing() in sam/shared/otel.mjs. The additions below are span attributes and child spans that enrich existing traces, not new instrumentation frameworks.

Lambda / contextAdditionTypeValue
GetCatalogFunction catalog.session_id Root span attribute The newly generated X-Catalog-Session UUID. Links catalog reads to downstream bid traces when the client echoes the ID.
PlaceBidFunction catalog.session_id Root span attribute Echo of X-Catalog-Session header if present. Enables TraceQL queries like { .catalog.session_id = "abc" } to trace a session from catalog read through bid placement in Tempo.
PlaceBidFunction bid.payment_method Root span attribute "stripe" or "tempo". Already available from resolvePaymentMethod() — add to the span attributes.
PlaceBidFunction advertiser.is_new Root span attribute Boolean. True when this bid creates a new advertiser. Allows filtering traces for "new advertiser acquisition" events.
GetAdvertiserDashboardFunction DynamoDB child spans Child spans Separate spans for: dynamo.get_advertiser, dynamo.scan_ads, dynamo.query_bids. Tag each with table name and item count. Dashboard latency is dominated by DynamoDB — need visibility into which query is slow.
GenerateQuarterlyNewsletter / GenerateAnnualNewsletter Generation span Child spans Child spans for: dynamo.scan_bids_window (with window size attribute), dynamo.scan_ads, dynamo.put_digest. Newsletter generation is a batch operation — trace the DynamoDB work to catch scan timeouts early.
Cross-service session correlation with Tempo (tracing backend)
Add catalog.session_id as a baggage item in GetCatalogFunction so it propagates automatically via W3C TraceContext/Baggage headers if the catalog site or agent passes the trace context. This enables TraceQL queries in Grafana Tempo to find all traces associated with a session ID across the catalog read → bid challenge → bid placement flow. Implement via propagation.getBaggage(context.active())?.setEntry("session_id", id) in the OTel API.
10 Aggregate Advertiser Dashboard Elements All ads for one advertiser
Surfaces: machine API (GET /advertisers/{slug}/dashboard), catalog site (Alpine.js advertiser page), and Grafana (parameterized by slug variable)
Row 1 — Key Numbers (stat panels)
Lifetime Spend
Total USD confirmed across all ads. Color: green thresholds.
stat — currencyUSD
Active Ads
Count of ads currently in catalog.
stat — count
Best Rank
Current lowest (best) rank across all ads. Green if ≤3, yellow if ≤10, red otherwise.
stat — #N
Total Views
Aggregate view_count sum — how many times any ad appeared in a catalog read.
stat — count
Row 2 — Recent Activity
Spend (last 30d)
Sum of confirmed bid amounts in the last 30 days.
stat — currencyUSD
Bids (last 30d)
Count of confirmed bids in the last 30 days.
stat — count
Avg Bid Size
Mean bid amount (lifetime). Useful for understanding bid strategy.
stat — currencyUSD
Payment Methods
Stripe vs Tempo bid count breakdown. Badge showing primary method used.
stat / donut
Row 3 — Ad Rank Table
All Ads — Rank, Spend, Views
Table panel: columns = Title, Current Rank, Rank Change (7d), Cumulative Spend, Spend (30d), View Count, Last Bid. Sortable. Link to individual ad dashboard. Color-code rank change column (green = improved, red = dropped).
table — full width
Row 4 — Time Series
Spend Over Time (stacked by ad)
Daily spend per ad, stacked area chart. Shows how total advertiser spend has grown and which ads are receiving investment.
timeseries — stacked area
Rank History (by ad)
Each ad's rank over time. Multiple lines, lower = better. Shows rank volatility and trajectory.
timeseries — multi-line
Row 5 — Competitive Intelligence
Spend to Lead (Rank 1)
For the advertiser's best-ranked ad: USD needed to reach rank 1. Null if already #1. Surfaces the bid target.
stat — currencyUSD
Avg Rank (portfolio)
Mean rank across all active ads. Tracks overall catalog position over time.
stat — number
Funnel: Sessions → Bids
If session tracking enabled: catalog sessions that resulted in a bid, vs total sessions. Conversion rate.
stat — percent (if session data available)
11 Individual Ad Dashboard Elements One ad in detail
Row 1 — Position & Status (stat panels)
Current Rank
Large #N. Green if ≤3, yellow ≤10, red otherwise. Background color matches threshold.
stat — position
Rank Change (7d)
±N positions. Green arrow if improved (rank number decreased), red if dropped. 0 = stable.
stat — delta
Spend to Next Rank
USD to surpass the ad ranked one above. Null if rank 1. Red urgency when competitor is close.
stat — currencyUSD
Spend to Rank 1
USD to reach the top position. Null if already rank 1. Shows the full gap to dominance.
stat — currencyUSD
Row 2 — Volume Metrics
Cumulative Spend
All-time confirmed spend for this ad. Primary rank driver.
stat — currencyUSD
Spend (last 7d)
Recent spend velocity. Compare to total to see if bids are slowing or accelerating.
stat — currencyUSD
Total Bids
All confirmed bids for this ad (lifetime).
stat — count
Catalog Views
Times this ad appeared in a GET /catalog response. Impression count proxy.
stat — count
Row 3 — Time Series
Rank History
Rank over time (Y-axis inverted: 1 at top). Annotate each bid with a vertical line. Shows the direct relationship between bids and rank movement.
timeseries — with annotations
Cumulative Spend
Running cumulative sum over time. Step function — jumps at each confirmed bid. Shows bid cadence visually.
timeseries — step area
Row 4 — Bid History Table
Recent Bids (last 10)
Table: Bid #, Amount, Payment Method (badge: Stripe/Tempo), Status, Timestamp, Session ID (if tracked). Shows bid strategy at a glance — how much was bet, how often, and via which rail.
table — full width
Row 5 — Competitive Context
Rank Change (30d)
Position change over the last 30 days. Longer trend than 7d view.
stat — delta
Avg Bid Size
Mean confirmed bid amount for this ad. Helps understand typical bet size.
stat — currencyUSD
Days Since Last Bid
Recency of last bid. Red if >7 days (ad may be losing rank to more active competitors).
stat — days
Last Payment Method
Stripe or Tempo badge. Informational — tracks which payment rail was used most recently.
stat — badge
12 Public Catalog Intelligence Dashboard Elements Publicly visible — no auth required
The value proposition of public data
Publishing aggregate catalog activity publicly builds trust and creates a flywheel: potential advertisers see real dollars flowing through the system and real rank competition playing out. The catalog's credibility comes from transparency. Machines reading the public leaderboard discover the pricing signal organically and can present it to human decision-makers. Humans visiting the catalog site see social proof in the form of visible spend competition. Neither audience needs to pay to see this data — the conversion event is becoming an advertiser, not reading the leaderboard.
Row 1 — Catalog Health (stat panels)
Total Active Ads
Current number of ads in the catalog.
stat — count
Total Lifetime Spend
All confirmed dollars spent across the entire catalog's history. The headline number.
stat — currencyUSD
Total Bids Placed
All-time confirmed bid count. Shows catalog activity depth.
stat — count
Most Recent Bid
Relative timestamp (e.g., "3 minutes ago"). Shows the catalog is alive and being used.
stat — relative time
Row 2 — Live Leaderboard
Rank Leaderboard — Top 10 Ads
Table: Rank, Title, Cumulative Spend ($), 24h Rank Change (↑↓ with count), Category badge. Excludes advertiser_slug and URL for privacy. Updated on a 60s cache. Color-code the rank change column. Newcomers (created in last 7 days) get a "New" badge. This is the primary public-facing signal of catalog competition.
table — full width, 60s refresh
Row 3 — Activity & Trends
Catalog Spend per Day (last 90 days)
Daily confirmed spend bar chart. Shows growth trend, seasonal patterns, and activity spikes. This is the most compelling public data point: money flowing through the machine economy.
timeseries / bar chart
Biggest Mover (24h)
The ad that gained or lost the most rank positions in the last 24 hours. Shows catalog volatility and that the market is competitive.
stat — card with delta
Row 4 — Category Breakdown & Newcomers
Spend by Category
Donut or bar chart of cumulative spend by category (inference, models, compute, etc.). Shows which AI verticals are most active in the catalog.
pie / bar chart
Newcomers (last 7 days)
New ads created in the past 7 days with their current rank and spend. Shows catalog growth and new market entrants.
table — compact
Row 5 — Payment Rail Mix (bonus: Sankey data)
Stripe vs Tempo (bids)
Ratio of Stripe vs Tempo bids (last 30d). Shows crypto adoption rate in the machine economy.
stat / donut
Bids per Hour (24h)
Bid frequency time series over the last 24 hours. Spot active bidding periods and quiet hours.
timeseries — step
13 ADR References
ADRRelevance to this plan
ADR-002 — OpenAPI Contract-First All new endpoints in §2–3 must be added to api/openapi.yml before any Lambda implementation begins. The OpenAPI spec is the implementation contract; this document is the design input.
ADR-006 — MCP Service for Machine Consumers New MCP tools in §6 follow the same tool registration pattern as existing tools in mcp/internal/mcp/tools.go. No new MCP frameworks — extend the existing server.
ADR-007 — REST API Design New admin endpoints follow the same Lambda-per-route pattern, SAM template conventions, and JSON error response format established in ADR-007.
ADR-014 — OpenTelemetry Unified Observability Governs all instrumentation in §7–9. OTel is the only logging/metrics/tracing mechanism. The console.log-to-OTel bridge in sam/shared/otel.mjs handles log routing to Loki. The meter export handles metrics to Prometheus. The withTracing() wrapper handles root spans. No additional APM agents or sidecar collectors.
No new ADR required for this plan
Session-based funnel tracking (§1) is an incremental instrumentation decision, not a significant architectural change. ADR-014 already establishes OTel as the telemetry standard. A new ADR would be appropriate if: (a) a new storage backend is added (e.g., Redis for session state), (b) a new auth mechanism is introduced beyond the existing X-Advertiser-Api-Key pattern, or (c) the advertiser admin API introduces a fundamentally different access control model (e.g., OAuth2, JWT). None of those apply to the current scope.
14 Newsletter Generation Architecture — Backoffice Service
Backoffice pattern: schedule → generate → store → serve
Newsletter digests are never computed on-demand. A scheduled Lambda runs on an EventBridge cron, aggregates bid/ad data from DynamoDB, and writes the result to madcatalog-newsletters. The HTTP-facing read Lambda performs a single GetItem lookup. This keeps read-path latency predictable and allows the same digest to be served to thousands of concurrent machine readers without re-computing. Monthly, quarterly, and annual digests have no TTL — they are the archive product, stored permanently and sold indefinitely.
All Cadences — Generation Schedule
CadenceDynamoDB period_typeSort key formatEventBridge cron (UTC)TTL
Daily "daily" "2026-05-23" (ISO date) cron(0 7 * * ? *) — 07:00 daily 365 days (expires)
Weekly "weekly" "2026-W21" (ISO week) cron(0 8 ? * MON *) — 08:00 Monday 365 days (expires)
Monthly "monthly" "2026-05" (YYYY-MM) cron(0 8 1 * ? *) — 08:00 1st of month None — permanent
Quarterly "quarterly" "2026-Q1" cron(0 9 1 1,4,7,10 ? *) — 09:00 first of each quarter None — permanent
Annual "annual" "2025" (YYYY) cron(0 10 1 1 ? *) — 10:00 Jan 1st None — permanent
AWS EventBridge Scheduler vs. EventBridge Rules
AWS SAM supports scheduled Lambda functions natively via the Schedule event source type. SAM creates an EventBridge Rule automatically. This is simpler than creating a separate AWS::Events::Rule resource. The cron expressions above use SAM's schedule format: cron(minutes hours day-of-month month day-of-week year). No external scheduler, no Step Functions, no EventBridge Pipes — one Lambda per cadence, triggered by one EventBridge rule each. Lambda timeout for generation functions should be 5 minutes (quarterly/annual may scan large datasets).
Generation Lambda Functions
FunctionEventBridge triggerKey aggregationsIdempotency guard
GenerateDailyNewsletterFunction cron(0 7 * * ? *) Bids in last 24h: count, total spend, biggest mover (rank delta), top spender (cumulative). New ads created today. ConditionExpression: attribute_not_exists(#date) on PutItem. Re-runs safely.
GenerateWeeklyNewsletterFunction cron(0 8 ? * MON *) Same as daily but 7-day window. ISO week SK. Same ConditionExpression guard.
GenerateMonthlyNewsletterFunction cron(0 8 1 * ? *) Prior calendar month. Adds new_advertisers (distinct new slugs), category_breakdown (per-category spend totals and bid counts). ConditionExpression guard. No TTL on written item.
GenerateQuarterlyNewsletterFunction cron(0 9 1 1,4,7,10 ? *) Prior quarter (Jan-Mar, Apr-Jun, Jul-Sep, Oct-Dec). Full category breakdown, biggest rank journey (advertiser with most positions gained), new advertisers count, top-5 movers. Scans entire quarter of bid data — use DynamoDB GSI ad_id-created_at-index with filter for the quarter window. ConditionExpression guard. Lambda timeout: 5 min.
GenerateAnnualNewsletterFunction cron(0 10 1 1 ? *) Prior full year. All quarterly aggregations plus: founding cohort performance (first-year advertisers), total machine economy spend, largest single bid of the year, advertiser with most cumulative spend, total unique advertisers. The narrative digest — the "year in machine commerce." ConditionExpression guard. Lambda timeout: 5 min. Consider S3 for large payloads if DynamoDB item size limit (400KB) is a concern.
SAM Template Additions for Monthly/Quarterly/Annual

The daily/weekly generation Lambda SAM resources are described in the main proposal (F1 §SAM Template Additions). Add these resources alongside them:

Resource typeLogical IDNotes
AWS::Serverless::Function GetMonthlyNewsletterFunction HTTP route /newsletters/monthly. Same auth detection as daily/weekly. MPP price env var NEWSLETTER_MONTHLY_PRICE_CENTS (default: 1000).
AWS::Serverless::Function GenerateMonthlyNewsletterFunction Scheduled only. Event: cron(0 8 1 * ? *). Needs dynamodb:Query on bids table (GSI) + dynamodb:PutItem on newsletters table. Timeout: 300s.
AWS::Serverless::Function GetQuarterlyNewsletterFunction HTTP route /newsletters/quarterly. MPP price env var NEWSLETTER_QUARTERLY_PRICE_CENTS (default: 2000).
AWS::Serverless::Function GenerateQuarterlyNewsletterFunction Scheduled only. Event: cron(0 9 1 1,4,7,10 ? *). Timeout: 300s. Memory: 512MB (larger window scan).
AWS::Serverless::Function GetAnnualNewsletterFunction HTTP route /newsletters/annual. MPP price env var NEWSLETTER_ANNUAL_PRICE_CENTS (default: 10000).
AWS::Serverless::Function GenerateAnnualNewsletterFunction Scheduled only. Event: cron(0 10 1 1 ? *). Timeout: 300s. Memory: 512MB. If payload exceeds 400KB DynamoDB limit, write to S3 and store an S3 URI in the DynamoDB record; the read Lambda fetches from S3.
The archive product: historic machine economy data as a sellable signal
The permanent digests (monthly, quarterly, annual) are the long-term moat. As madcatalog accumulates history, the archive becomes a unique dataset: who spent what, when, in what categories, competing against whom — in the machine-only ad economy. The archive is sold to:
  • AI training pipelines building models that understand machine commerce patterns
  • Competitive intelligence agents comparing advertiser spend trajectories
  • Strategy analysts studying the machine economy's growth arc
  • Researchers documenting the first wave of autonomous AI spending behavior
Permanent storage means the archive only grows in value. The first quarterly digest ever generated is more valuable in year 5 than it was in year 1. Price the archive accordingly: MPP-gated at $25–$250/page depending on cadence.