Docs / Decisions

ADR 0023: Paid promotion on any network, with spend that cannot exceed a cap

Context

ADR 0001 left ads out of scope. Two things changed:

The first real use is a developer product in a closed alpha. Its audience is on Reddit (communities about open models and self-hosting) and in search ("OpenAI batch API alternative"), not on Facebook. So the networks cannot be fixed in advance, and they do not all work the same way:

Decision

Scope

  1. Araldo runs small, bounded promotions; it is not an ads manager. A promotion is one of two kinds:

    • a boost: a budget behind a post Araldo published, where the network can promote an existing post;
    • a campaign: one ad (text, a link and optionally images from /v1/media) with a budget and an end, where the ad is not a post.

    Each promotion maps to the smallest structure the network needs (on Meta one campaign, ad set and ad; on Google one campaign, ad group and ad), which Araldo creates and owns. No audience builder, no bidding strategies, no editing of structures Araldo did not create.

  2. Ad text goes through the rules engine. Networks limit ad text the way they limit posts (a Google headline is 30 characters, a description 90). Each network's limits are rules with their sources (ADR 0009), so a preview lists every violation before anything is created, as for posts.

  3. Reporting covers whole ad accounts: spend and results for every campaign in a connected account, including ones made in the network's own tools, by brand. One page answers "what are my ads doing" across brands and networks.

Networks

  1. Adapters per network, under internal/ads/, with two optional interfaces: Reporter (read accounts, campaigns and results) and Promoter (create, pause, resume and end a promotion, saying which kinds it supports). The core (accounts, promotions, caps, the outbox, readings) is the same for all.
  2. A network gets reporting when someone runs ads there, and promotions when someone will spend through Araldo there. The order follows real spend, not a plan; the first adapter is the network the first real budget goes to. Reporting can come first because read access is easier to get approved and cannot cost anything.

Accounts and access

  1. An ad account belongs to a brand (ad_accounts): network, the network's account ID, name, currency and time zone as the network reports them, and credentials encrypted like a channel's (ADR 0008). It connects through an org's developer app for that network's ads API, a separate provider from posting (meta_ads, reddit_ads, google_ads…): ads access brings its own review, and trouble there must not touch posting.
  2. Starting spend is an explicit-only power (ADR 0019): ads:write is never in a key's default scopes, and only admins and owners hold it. ads:read is an ordinary scope.

Money

  1. Every promotion has a lifetime budget and an end time, never a daily budget and never open-ended; the longest is 30 days. Where a network only takes daily budgets, Araldo sets the daily budget to the lifetime budget divided by the days and counts the network's allowed daily overspend against the cap. Amounts are integers in the account currency's minor units.
  2. Each brand has a monthly cap per currency (default zero, so nothing spends until someone sets one). A promotion commits its whole budget, plus any allowed overspend, to the month it starts in. Creating one checks in the same transaction that committed plus new stays within the cap; otherwise 409 ad_cap_exceeded. The commitment is the worst case, so the cap holds even if every promotion spends in full.
  3. The network's own account limit is a backstop, not the guard: Araldo shows it where the network has one, and recommends setting it.
  4. Nothing goes live half-built. A promotion is an outbox row, like a target (ADR 0011). The worker creates every part paused, records each network ID as it goes, and only then turns it on. Creation calls are not idempotent on most networks, so a call whose result is unknown goes to needs_attention, never retried blindly; a later attempt resumes from the last recorded ID.
  5. Stopping always works. Pausing or ending a promotion, or every promotion of a brand at once, needs only posts:write: anyone who can post can stop spending; only ads:write can start it.
  6. Test mode spends nothing. It runs against the sandbox adapter, with invented results. Networks' own test accounts (Google Ads test accounts, Meta sandbox ad accounts) can be connected in live mode to check the real calls without delivery.

Measurement

  1. No pixels. Araldo never asks for a network's tracking pixel or conversion API. Landing links are tagged with UTM parameters (ADR 0016) using utm_medium=paid and the promotion's ID as utm_content, so the brand's own, privacy-friendly analytics measure signups. Promotions optimize for clicks or reach, which networks measure on their side.
  2. Results are read on a schedule, like engagement: hourly while running, then daily for 7 days after the end (networks attribute late), then stop. Each reading keeps spend, impressions, reach where reported, clicks and the network's result count. A boost shows its paid results next to the organic engagement of the same post.

Lifecycle

  1. A promotion's status follows the network's: pending (being built), in_review, active, rejected (with the network's reason), paused, completed, canceled, needs_attention. Each change is an event (promotion.*, ADR 0012) and each action an audit entry. A rejection is a normal outcome, reported with its reason, not an error.
  2. Targeting is the network's simplest useful form, and only that: locations everywhere, plus the one thing that makes each network worth using (communities on Reddit, keywords on Google search, job functions on LinkedIn, the automatic audience on Meta). A brand declares whether its ads fall in a special category (housing, employment, credit, politics) where networks require it.

Surfaces

  1. API: /v1/ad_accounts, /v1/promotions (preview, create, list, read, pause, resume, cancel) and /v1/ads/summary (spend and results by brand, network, account or promotion). Contract first, as always (ADR 0005).
  2. Dashboard: an Ads page per brand (accounts, cap, spend this month, promotions, results), and "Promote" on a published post where a connected network can boost it.
  3. MCP is read-only for ads at first: an agent can report on spend and suggest what to promote, but not spend.

Phases

Alternatives considered

Consequences

Edit this page on GitHub