Prima Get an API key

Module kit / Ads

Cross-Platform Campaign Creation

ads/cross-platform-campaign-creation

Kind backend
Domain ads
Maturity prod
Reusable extract
Stack fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2, httpx-0.28, asyncio
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout
Frontends ads/ui/ads-manager
Reference implementation
  • alchemy_labs/backend/services/campaign_setup/campaign_setup_service.py
  • alchemy_labs/backend/services/campaign_setup/adapters/base.py
  • alchemy_labs/backend/services/campaign_setup/adapters/meta_adapter.py
  • alchemy_labs/backend/services/campaign_setup/adapters/google_adapter.py
  • alchemy_labs/backend/services/campaign_setup/adapters/linkedin_adapter.py
  • alchemy_labs/backend/services/campaign_setup/mappings.py
  • alchemy_labs/backend/schemas/campaign_setup_schemas.py
  • alchemy_labs/backend/api/routers/campaign_setup_router.py
  • alchemy_labs/backend/models/multiplatform_campaign_model.py
  • alchemy_labs/backend/services/retargeting_deployer.py
  • alchemy_labs/backend/services/meta_ads/campaign_service.py
  • alchemy_labs/backend/migrations/versions/20260710_0001_multiplatform_campaigns.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

One CampaignBrief in, real PAUSED campaigns on Meta, Google and LinkedIn out: the orchestrator normalizes the brief once, fans it out through three per-platform adapters (validate -> build_payload -> create), runs the three create calls in parallel, and never lets one platform's failure block or corrupt the others' results (module docstring, campaign_setup_service.py 1 to 15). The invariant that makes it trustworthy is persistence-before-network: the MultiPlatformCampaign row is written with status=creating and committed before any adapter's create is ever called (setup_campaign, 167 to 191), so a crash mid-flow always leaves an inspectable row rather than a silent gap, and every created campaign is created PAUSED on all three platforms, never live, by construction (meta_adapter.py 1 to 5 via deploy_flat_campaign's "status": "PAUSED", retargeting_deployer.py 716 and 758; google_adapter.py 222 "status": "PAUSED"; linkedin_adapter.py 127 "status": "PAUSED").

When to use

Read it before touching setup_campaign, retry_campaign, _create_platforms, _prepare_platforms, _safe_create, compute_brief_hash, aggregate_status, or any of MetaAdapter/GoogleAdapter/LinkedInAdapter's validate/build_payload/create; before changing what counts as a "created" vs "failed" vs "skipped" vs "preview" platform result; and when someone asks "why did only two of three platforms create", "why is this idempotency key rejected", "why didn't google/linkedin create anything", or "what does dry_run actually check". Trigger phrases: "cross-platform campaign", "multiplatform campaign setup", "CampaignBrief", "campaign setup dry run", "retry failed platforms", "PAUSED campaign creation", "per-platform isolation".

Not this recipe: the Meta-only first-class campaign/ad-set CRUD API in services/meta_ads/campaign_service.py (create_campaign, update_campaign, objective/bid validation against objective_matrix.py) - a separate, older Meta management surface that this flow does not call (see Gotchas); the 5-stage Schwartz-funnel retargeting deploy (retargeting_deployer.py's create_campaign/create_custom_audience/create_adset/create_ad, stages 1 to 4 of that file, STAGE_OBJECTIVE_MAP/STAGE_OPTIMIZATION_MAP) - a different, older Meta flow in the same module; this recipe covers only its deploy_flat_campaign (step 8, 665 to 884), the one the Meta adapter calls; single-ad and single-video-ad retry helpers (add_ad_to_adset, add_video_ad_to_adset) - those retry one ad inside an existing Meta ad set, not a cross-platform setup; the Google/LinkedIn native management routers (api/routers/ads_router.py, linkedin_ads_router.py, google_ads_management_router.py) that let a user browse/edit campaigns already on those platforms, this recipe only creates new ones; audience/creative asset upload endpoints that feed CreativeAsset.image_b64/image_path, out of scope here.

Stack assumptions

At 62ba4fc: fastapi==0.118.x mounted with no route prefix of its own, mounted by app_fastapi.py at /api/ads (app_fastapi.py 868: mount_router(app, campaign_setup_router.router, prefix="/api/ads", ..., surfaces=LABS_SURFACES)); synchronous handlers on a sqlalchemy.orm.Session (S4, fastapi-project-layout) for everything except the three adapter create coroutines, which are genuinely async def and run concurrently under asyncio.gather (_create_platforms, 233 to 244); pydantic==2.x for CampaignBrief and its nested specs; httpx for the Meta Graph calls in retargeting_deployer.py (API_VERSION = "v18.0", line 18); Google Ads and LinkedIn calls go through sibling clients this recipe does not own the internals of (services/google_ads_manage.GoogleAdsClient, services/linkedin_ads_service.LinkedInAdsService), imported with a try/except guard in each adapter module (google_adapter.py 27 to 30, linkedin_adapter.py 31 to 34) so a missing module degrades the platform to status="skipped" rather than crashing the app; at 62ba4fc both sibling modules exist on disk, so the guard is not exercised in this deployment. Foundations assumed: S4 (fastapi-project-layout) for the app and mount_router; S5 (auth-dependency-pattern) for CurrentUser (api/dependencies.py 565, ed25519-JWT backed get_current_user); S6 (workspace-scoping) for _require_workspace, which reads X-Workspace-ID off request state via middleware.workspace_middleware.get_workspace_id (ads_router.py 247 to 252); S8 (alembic-migrations) for the one additive migration under Data model.

Environment: none dedicated to this recipe in core/config.py - credentials are per-workspace, not per-deployment env vars (see Security & tenancy); the only module-level constant with deployment-wide effect is GoogleAdapter.DEFAULT_MCC_ACCOUNT_ID (google_adapter.py 33), a hardcoded fallback login-customer-id used only when a workspace's Google connection has no MCC recorded (_login_customer_id, 71 to 92) - Alchemy's own value is not reproduced here, a rebuild sets its own MCC id as this constant. Fail behaviour: _safe_create (75 to 85) wraps every adapter's create and turns any raised exception into a PlatformSetupResult(status="failed") rather than propagating; _create_platforms's own asyncio.gather(..., return_exceptions=True) (234) is belt-and-braces on top of that; setup_campaign and retry_campaign themselves do not catch broadly, an exception before the per-platform fan-out (e.g. a db.commit() failure) propagates as a 500, unlike the auto-topup engine's outer catch-all.

Data model

multiplatform_campaigns, one row per orchestration run (models/multiplatform_campaign_model.py 35 to 91; one additive migration, see Deploy & ops):

multiplatform_campaigns (
  id                   uuid PRIMARY KEY,
  workspace_id         uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  created_by           uuid REFERENCES users(id),
  name                 varchar NOT NULL,
  objective            varchar(32) NOT NULL,
  status               multiplatformcampaignstatus NOT NULL DEFAULT 'draft',  -- draft|creating|created|partial|failed
  brief                jsonb NOT NULL DEFAULT '{}',      -- the submitted CampaignBrief, minus dry_run
  platform_results     jsonb NOT NULL DEFAULT '[]',      -- [{platform,status,campaign_id,error,warnings,details}, ...]
  meta_campaign_id     varchar,
  google_campaign_id   varchar,
  linkedin_campaign_id varchar,
  idempotency_key      varchar(128),
  brief_hash           varchar(64),                      -- sha256 of the canonical brief (dry_run + idempotency_key excluded)
  total_daily_budget   numeric(12,2) NOT NULL DEFAULT 0,
  currency             varchar(3) NOT NULL DEFAULT 'CHF',
  created_at           timestamptz DEFAULT now(),
  updated_at           timestamptz DEFAULT now()
);
UNIQUE (workspace_id, idempotency_key)               -- uq_multiplatform_campaigns_ws_idempotency
INDEX (workspace_id, status)                          -- ix_multiplatform_campaigns_workspace_status

No table stores a platform access token or refresh token (those live in WorkspaceSettings.settings->'tracking', decrypted per call by _get_tracking_settings, out of this recipe's scope). No table stores raw creative image bytes; image_b64 passes through the brief's jsonb column as submitted (a genuine gap, not scrubbed at rest - see Gotchas) but is scrubbed from preview responses (_preview_payload, 117 to 132, replaces any image_b64 key with the literal string "<base64 image omitted>" recursively).

What is NOT determinable from this recipe's source files: whether brief.jsonb's stored image_b64 is ever purged after the campaign creates; there is no code path in the files read that clears it post-creation.

API surface

Mounted with no router-level prefix at /api/ads (app_fastapi.py 868), so:

RouteAuthTenancyCodesNotes
POST /api/ads/campaigns/setup (campaign_setup_router.py 88 to 134)CurrentUser (S5)X-Workspace-ID via _require_workspace (S6)200 CampaignSetupResponse even for partial/failed; 422 pydantic (budget below platform minimums, bad split, etc.); 409 idempotency key reused with a different briefBody is CampaignBrief; dry_run: true returns a per-platform payload preview and persists nothing (105 gates the idempotency check off for dry runs too)
GET /api/ads/campaigns/setup (137 to 156)CurrentUserX-Workspace-ID200 {"total": int, "setups": [...]}limit clamped to 1 to 200, offset clamped to >= 0
GET /api/ads/campaigns/setup/{setup_id} (159 to 175)CurrentUserX-Workspace-ID, row filtered by both id and workspace_id200 record dict; 404 not found (or found but a different workspace's row, _get_record 68 to 81)Docstring TODO: no live rollup of platform-side status/spend at this commit (169 to 171)
POST /api/ads/campaigns/setup/{setup_id}/retry (178 to 199)CurrentUserX-Workspace-ID200 CampaignSetupResponse; 400 status == "created" (nothing to retry); 409 status == "creating" (already in flight); 404 via _get_recordOnly platforms whose stored result is failed/skipped, or missing, are retried; already-created platforms keep their stored result untouched

Idempotency (campaign_setup_router.py 104 to 126): a POST carrying idempotency_key and dry_run=false looks up an existing row by (workspace_id, idempotency_key); if found and brief_hash (sha256 of the canonical brief, compute_brief_hash, 53 to 58) matches, the stored outcome is replayed at 200 with no new platform calls; if found with a different hash, 409. A dry run never participates in idempotency (105: if brief.idempotency_key and not brief.dry_run).

Application logic

setup_campaign (139 to 204), in commit order:

  1. Build context = {"workspace_id", "tracking"} and call _prepare_platforms(brief, context, platforms) (149; 88 to 114): for every requested platform, adapter.validate() runs first, and only if it returns no errors does adapter.build_payload() run (pure, no I/O); a build_payload exception is caught and turned into an error string (110 to 112), never raised past this step.
  2. Dry run branch (152 to 165): if brief.dry_run, return a CampaignSetupResponse(id=None, status="preview", ...) built entirely from prepared, with _preview_payload scrubbing image_b64. Nothing is persisted, no adapter create is ever called (this is the flow's whole safety property for previews, confirmed by test_dry_run_previews_without_persisting's db.add.assert_not_called()).
  3. Persist before network (167 to 188): construct MultiPlatformCampaign(status=creating, brief=..., idempotency_key=..., brief_hash=compute_brief_hash(brief), ...), db.add, db.commit, db.refresh - all before any platform is contacted.
  4. Fan out (191, delegating to _create_platforms, 207 to 245): platforms whose _prepare_platforms step produced an error or no payload become PlatformSetupResult(status="failed") immediately, with no external call (219 to 228, test_validation_failure_never_calls_create asserts create_calls == 0); the rest are wrapped in _safe_create (75 to 85, catches any exception from adapter.create and returns a failed result) and run together via asyncio.gather(*tasks, return_exceptions=True) (234); build-time warnings are merged onto the create-time result's warnings (242).
  5. Aggregate + persist (194 to 200): aggregate_status (61 to 68) is pure: "created" iff every result's status == "created"; "failed" iff none did; else "partial". The record's status, platform_results (as JSON) and the three native *_campaign_id columns are updated and committed once.

retry_campaign (248 to 299): re-validates the stored brief (CampaignBrief.model_validate(record.brief)), reads record.platform_results back into PlatformSetupResults, computes retry_platforms as every platform whose stored result is missing or has status in ("failed", "skipped") (271 to 274) - an already-"created" platform is never touched, its stored result is reused verbatim in the final response (285 to 288). If there is anything to retry, the record flips to creating and commits before the retry's _prepare_platforms/_create_platforms call, the same persist-before-network shape as setup_campaign. Aggregation and the final commit are identical to setup_campaign's tail.

Per-adapter build_payload (pure, no I/O, shared helpers in mappings.py): map_objective(brief.objective, platform) (71 to 76) looks up a static OBJECTIVE_MAP (42 to 68, five brief objectives × three platforms) and raises ValueError for an unmapped pair; split_budget(brief) (225 to 238) applies budget.split percentages or an even split across brief.platforms; to_platform_budget_unit(platform, amount) (241 to 255) converts to the platform's native unit (meta: int cents; google: int micros; linkedin: "%.2f" decimal string); translate_audience(platform, audience) (132 to 218) maps the brief's ISO geo/language codes through static DACH-focused tables (META_COUNTRY_CODES, GOOGLE_GEO_TARGETS, GOOGLE_LANGUAGE_CONSTANTS, LINKEDIN_GEO_URNS, LINKEDIN_LOCALES, 84 to 127) and warns-and-drops any unrecognized code rather than failing; apply_creative_limits(platform, creative) (301 to 332) truncates headlines/primary text/descriptions to per-platform CREATIVE_LIMITS (263 to 267) at a word boundary (truncate_at_word_boundary, 270 to 283) and warns when Google ends up with fewer than 3 headlines or 2 descriptions.

Meta create (meta_adapter.py 126 to 159): delegates to services.retargeting_deployer.meta_deployer.deploy_flat_campaign (retargeting_deployer.py 665 to 884), which does its own 3-step create chain against the Graph API (v18.0): campaign (711 to 731, always "status": "PAUSED"), one ad set sharing all ads' audience/budget/targeting (733 to 775, also "status": "PAUSED", bid_strategy: "LOWEST_COST_WITHOUT_CAP" hardcoded at 756), then per-ad image upload + creative + ad create in a loop (781 to 874) where each ad's own exception is caught and appended to an errors list rather than aborting the loop (871 to 873) - this is a second, inner layer of partial-failure isolation, at the ad level, nested inside the platform-level isolation the orchestrator provides. MetaAdapter.create reads result["errors"] back into PlatformSetupResult.warnings (148 to 149), so a campaign with some failed ads still reports Meta as "created" overall with warnings, not "failed".

Google create (google_adapter.py 180 to 312): a 4-step Google Ads mutate chain - campaignBudgets (208 to 216), campaigns with "status": "PAUSED" (218 to 235), optional campaignCriteria for geo/language (237 to 248), then per ad group: adGroups with "status": "PAUSED" (253 to 264), adGroupCriteria for keywords (266 to 273), and adGroupAds (responsiveSearchAd, "status": "PAUSED", 275 to 294) - an RSA with fewer than 3 headlines or 2 descriptions is skipped with a warning rather than sent (276 to 280). Returns status="skipped" with no network call at all if GoogleAdsClient failed to import (183 to 189), or status="failed" if the workspace has no refresh token for the resolved customer_id (195 to 199).

LinkedIn create (linkedin_adapter.py 155 to 203): a 2-step chain - create_campaign_group (176 to 178, group status: "ACTIVE", the group itself is never paused, only the campaign inside it) then create_campaign (182 to 185, "status": "PAUSED"). Every successful create carries a fixed warning that no creative was attached (191 to 194) - LinkedIn ad creatives (sponsored post + image) are explicitly out of scope for this flow (module docstring, 9 to 11). Same status="skipped"/status="failed" degradation pattern as Google (158 to 164, 166 to 203).

Invariants

  • Every campaign this flow creates on any platform is created PAUSED, never ACTIVE, with no code path that flips that at create time (meta: retargeting_deployer.py 716, 758; google: google_adapter.py 222; linkedin: linkedin_adapter.py 127). No test in test_campaign_setup.py asserts this directly against the real Meta/Google/LinkedIn create chains, since those are exercised only through FakeAdapter doubles (see Tests) - this invariant is measured by reading the adapter/deployer source, not by a passing test.
  • One platform's create raising never blocks or corrupts another platform's result: _safe_create (75 to 85) plus asyncio.gather(..., return_exceptions=True) (234), proven by test_partial_failure_isolation (google raises, meta and linkedin still report created).
  • A build-time validation failure never reaches a platform's create at all: _prepare_platforms/_create_platforms route straight to a failed result (219 to 228), proven by test_validation_failure_never_calls_create (create_calls == 0).
  • dry_run persists nothing and calls no adapter's create: proven by test_dry_run_previews_without_persisting (db.add/db.commit never called, all create_calls == 0).
  • The MultiPlatformCampaign row exists with status=creating before any platform is contacted, on both setup_campaign and retry_campaign (167 to 188; 276 to 278) - not independently proven by a dedicated ordering test at this commit, test_partial_failure_isolation only asserts db.add.assert_called_once_with(record) and db.commit.call_count >= 2, which is consistent with but does not by itself rule out a different ordering.
  • retry_campaign never re-touches a platform whose stored result is already "created" (271 to 274, 285 to 288) - not covered by any test in test_campaign_setup.py at this commit (see Tests).
  • An idempotency key replay with an identical brief hash returns the stored outcome without a new platform call; the same key with a different brief hash is refused at 409 (campaign_setup_router.py 116 to 126) - not covered by any test file read for this recipe (see Tests).

Frontend

ads/ui/ads-manager   frontend/src/pages/AdsManager.tsx calls POST /api/ads/campaigns/setup twice
                      (buildBrief(true) for a dry-run preview at line 535, buildBrief(false) for the
                      real create at line 1205-1207); frontend/src/services/adsService.ts wraps
                      GET /api/ads/campaigns/setup, GET .../{id} and POST .../{id}/retry (lines 86,
                      91, 96). This is the only surface that calls this recipe's endpoints in the repo.

Minimum admin/debug surface (no dedicated route exists at this commit): SELECT id, status, platform_results, idempotency_key FROM multiplatform_campaigns WHERE workspace_id = :ws ORDER BY created_at DESC (what GET /campaigns/setup returns, run by hand for a workspace not in front of you); SELECT id, status FROM multiplatform_campaigns WHERE status IN ('creating') AND updated_at < now() - interval '1 hour' (rows that started a fan-out and never got an aggregate update - no code path in the files read explains how such a row would be reconciled, a genuine gap, see Gotchas).

Security & tenancy

Auth dependency: CurrentUser on every route (S5, ed25519-JWT backed get_current_user, api/dependencies.py 565) - this is a normal authenticated Labs surface, not a public/HMAC seam. Workspace derivation: _require_workspace(request) (ads_router.py 247 to 252, imported into campaign_setup_router.py) reads X-Workspace-ID off request state via middleware.workspace_middleware.get_workspace_id, raising 400 if absent; every query and write is filtered or scoped by that workspace_id (_get_record's .filter(id==setup_id, workspace_id==workspace_id), 71 to 77; the unique constraint on (workspace_id, idempotency_key)).

Fail direction: a missing workspace header is a hard 400 before any query runs; a platform with no credentials configured fails closed at validate() (each adapter returns a blocking error string, never proceeds to build_payload/create); a platform whose optional integration module is absent (GoogleAdsClient, LinkedInAdsService) degrades to status="skipped" rather than raising past the request (this is a deliberate availability trade-off, not a security fail-closed - a skipped platform silently creates nothing and the caller must read results[].status to notice).

Secrets at rest: no platform access/refresh token is stored on multiplatform_campaigns itself; every adapter's _creds/credential resolution reads from context["tracking"], which _get_tracking_settings (ads_router.py 232 to 244) populates by decrypting WorkspaceSettings.settings["tracking"] via services.security.oauth_token_crypto.decrypt_tracking_tokens (dual-read: ciphertext decrypted, legacy plaintext passed through) - this recipe consumes that decrypted dict but does not itself own encryption at rest, that is _shared/a neighbouring recipe's concern. What IS stored at rest, unscrubbed, in multiplatform_campaigns.brief (jsonb): the full submitted brief including any creative.assets[].image_b64 - the preview response scrubs image_b64 (_preview_payload, 122 to 130) but the persisted brief column on a non-dry-run request does not go through that scrub (brief=brief.model_dump(mode="json", exclude={"dry_run"}), 179, no image_b64 exclusion) - a genuine gap, not determinable from these files whether this is intentional or an oversight (see Gotchas).

Attacker-controlled inputs, all behind CurrentUser + workspace scoping: every field of CampaignBrief is user-submitted and pydantic-validated (budget floats, geo/language strings, free-text creative copy, overrides.* raw per-platform dicts that are merged directly into platform payloads, e.g. overrides.get("objective")/overrides.get("optimization_goal") in meta_adapter.py 116 to 117 - these are an intentional escape hatch, not sanitized beyond being plain dict values passed to the platform SDK); idempotency_key is a free string up to 128 chars, scoped per-workspace by the unique constraint so one workspace cannot collide another's key.

Tests

Run 2026-09-12 at 62ba4fc, from alchemy_labs/backend with the project's own .venv:

python -m pytest tests/test_campaign_setup.py -q --no-cov -o addopts="" -p no:cacheprovider

prints 26 passed (plus pydantic-deprecation warnings unrelated to this recipe). No database, no network: every test in this file is pure-function or uses FakeAdapter/MagicMock doubles for the database and the three adapters (fake_adapters fixture monkeypatches campaign_setup_service.ADAPTERS, 77 to 85).

Hermetic vs live: entirely hermetic. There is no test file for retargeting_deployer.deploy_flat_campaign (the real Meta create chain the Meta adapter calls) in this repo at this commit - test_meta_ads.py's 27 tests (TestBuildTargetingSpec, TestObjectiveMatrix, TestInsightsValidation, TestObjectStorySpec, TestMetaGraphClient, TestSchemas) exercise services/meta_ads/ (the separate first-class campaign API described under When to use), none of them touch retargeting_deployer.py or deploy_flat_campaign. There is likewise no test for GoogleAdapter.create's 4-step mutate chain or LinkedInAdapter.create's 2-step chain beyond what FakeAdapter doubles cover at the orchestration layer. This is a genuine, measured gap: the PAUSED-everywhere invariant and the real per-platform create chains are verified by reading the source, not by a passing test suite.

Known-answer vectors (test_campaign_setup.py): to_platform_budget_unit (130 to 133, meta cents/google micros/linkedin decimal string, exact values 2400/18_000_000/"18.00"); split_budget explicit (117 to 120) and even (122 to 128) splits; truncate_at_word_boundary (172 to 180) cuts on a real word boundary and never trims mid-word; translate_audience for all three platforms plus the unknown-geo-code drop-and-warn path (206 to 227).

What a rebuild adds and this suite lacks at 62ba4fc: a test for retry_campaign's "never re-touch an already-created platform" invariant (no test references campaign_setup_service.retry_campaign anywhere in test_campaign_setup.py); a test for the router-level idempotency replay (identical hash -> 200 stored outcome) and conflict (different hash -> 409) paths in campaign_setup_router.py; any test, hermetic or live, of the real Meta/Google/LinkedIn create chains this recipe's create methods call.

Deploy & ops

Config keys: none dedicated (see Stack assumptions; the one deployment-wide constant is GoogleAdapter.DEFAULT_MCC_ACCOUNT_ID, google_adapter.py 33). Migration: 20260710_0001_multiplatform_campaigns.py (revision multiplatform_campaigns_001, down-revision spark_work_001) is the single, additive migration - creates multiplatform_campaigns, its multiplatformcampaignstatus enum, and the two constraints/indexes listed under Data model; nothing else is altered. Measured 2026-09-12: multiplatform_campaigns_001 is an ancestor of the current alembic head (prima_device_codes_001, via python -m alembic history), so this table is live in any environment migrated to head.

Mount: campaign_setup_router.router is mounted unconditionally at /api/ads on LABS_SURFACES (app_fastapi.py 868) alongside the sibling ads routers - no feature flag gates this route in core/config.py. The feature landed on main in commit 293534d (2026-07-10), well before the commit this recipe is measured on.

Deviation from the module's own docstring: models/multiplatform_campaign_model.py's header comment (10 to 12) says the model "is intentionally NOT registered in models/__init__.py" - at 62ba4fc it IS registered (models/__init__.py line 66 imports MultiPlatformCampaign, and line 204 lists it in __all__). The docstring is stale; the model is a normal, discoverable member of the shared declarative Base like any other.

Rollback: the migration's downgrade() drops only multiplatform_campaigns and its enum (not independently re-read line-by-line for this recipe, inferred from the upgrade's own "additive only" docstring, 1 to 5); no other table is touched, so a code rollback needs no data unwind. A row stuck in status='creating' past a crash (see Frontend's admin query) has no measured reconciliation path in the files read for this recipe.

Gotchas

  • 2026-09-12 (measured at 62ba4fc, models/multiplatform_campaign_model.py 10 to 12 vs models/__init__.py 66/204): the model's own docstring claims it is not registered in models/__init__.py; it is. Treat the docstring as stale, not as documentation of current behaviour.
  • 2026-09-12 (measured at 62ba4fc, campaign_setup_service.py 179 vs 117 to 132): the persisted brief jsonb column on a real (non-dry-run) setup does not scrub creative.assets[].image_b64, only the preview path does. Whether this is intentional (previews are user-facing responses, the stored row is not) or an oversight is not determinable from these files; a rebuild should decide deliberately rather than copy the asymmetry blind.
  • 2026-09-12 (measured at 62ba4fc): no test file exercises the real Meta (retargeting_deployer.deploy_flat_campaign), Google (GoogleAdapter.create's mutate chain) or LinkedIn (LinkedInAdapter.create's two-step chain) create paths. Everything in test_campaign_setup.py doubles the adapter layer. A rebuild that only reruns this suite has not verified the platforms actually receive the payloads this recipe describes.
  • 2026-09-12 (measured at 62ba4fc, retargeting_deployer.py 756): bid_strategy: "LOWEST_COST_WITHOUT_CAP" is hardcoded in deploy_flat_campaign's ad-set payload with a comment claiming it is "required for OUTCOME_SALES + OFFSITE_CONVERSIONS" - it is applied unconditionally to every objective this flow can produce (awareness/traffic/leads/conversions/sales all route through the same ad-set payload), not only sales/conversions. Not independently verified against Meta's actual API requirements in this session; flagged as asserted-in-comment, not measured against a live Meta response.
  • 2026-09-12 (measured at 62ba4fc): retry_campaign has no dedicated test and no docstring-level invariant test proving an already-created platform is never re-created; the guarantee described under Invariants is read from the source (271 to 274, 285 to 288), not confirmed by a passing assertion.

Reference implementation

Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.

  • alchemy_labs/backend/services/campaign_setup/campaign_setup_service.py (300 lines): module docstring (1 to 15); compute_brief_hash (53 to 58); aggregate_status (61 to 68); _brief_echo (71 to 72); _safe_create (75 to 85); _prepare_platforms (88 to 114); _preview_payload (117 to 132); setup_campaign (139 to 204); _create_platforms (207 to 245); retry_campaign (248 to 299).
  • alchemy_labs/backend/services/campaign_setup/adapters/base.py (42 lines): PlatformAdapter protocol (20 to 41).
  • alchemy_labs/backend/services/campaign_setup/adapters/meta_adapter.py (160 lines): MetaAdapter (23 to 159); _creds (29 to 38); validate (43 to 54); build_payload (56 to 124); create (126 to 159).
  • alchemy_labs/backend/services/campaign_setup/adapters/google_adapter.py (313 lines): guarded import (27 to 30); DEFAULT_MCC_ACCOUNT_ID (33); GoogleAdapter (36 to 313); _customer_id (43 to 54); _refresh_token (56 to 69); _login_customer_id (71 to 92); _is_connected (94 to 99); validate (104 to 113); build_payload (115 to 175); create (180 to 312).
  • alchemy_labs/backend/services/campaign_setup/adapters/linkedin_adapter.py (204 lines): module docstring (1 to 11); guarded import (31 to 34); LinkedInAdapter (37 to 203); _account_id (41 to 52); validate (55 to 65); build_payload (67 to 153); create (155 to 203).
  • alchemy_labs/backend/services/campaign_setup/mappings.py (359 lines): OBJECTIVE_MAP/map_objective (42 to 76); geo tables (84 to 127); translate_audience (132 to 218); split_budget (225 to 238); to_platform_budget_unit (241 to 255); CREATIVE_LIMITS/truncate_at_word_boundary/apply_creative_limits (263 to 332); build_landing_url (339 to 358).
  • alchemy_labs/backend/schemas/campaign_setup_schemas.py (214 lines): PLATFORM_MINIMUMS (21 to 25); BudgetSpec, AudienceSpec, CreativeAsset, CreativeSpec, ScheduleSpec, OverridesSpec (35 to 124); CampaignBrief and _validate_budget (131 to 191); PlatformSetupResult (198 to 206); CampaignSetupResponse (209 to 213).
  • alchemy_labs/backend/api/routers/campaign_setup_router.py (200 lines): module docstring with the route table (1 to 16); _record_to_dict/_stored_response/_get_record (37 to 81); POST /campaigns/setup (88 to 134); GET /campaigns/setup (137 to 156); GET /campaigns/setup/{id} (159 to 175); POST /campaigns/setup/{id}/retry (178 to 199).
  • alchemy_labs/backend/models/multiplatform_campaign_model.py (91 lines, whole file in scope): stale non-registration docstring (10 to 12); MultiPlatformCampaignStatus (27 to 32); MultiPlatformCampaign (35 to 91).
  • alchemy_labs/backend/services/retargeting_deployer.py (889 lines): deploy_flat_campaign (665 to 884, the only part of this file this recipe covers); meta_deployer singleton (888).
  • alchemy_labs/backend/services/meta_ads/campaign_service.py (284 lines): create_campaign (45 to 83) - cited for the "not this recipe" boundary under When to use, not part of this flow.
  • alchemy_labs/backend/migrations/versions/20260710_0001_multiplatform_campaigns.py: the one additive migration.
  • alchemy_labs/backend/app_fastapi.py: the router mount (868).
  • alchemy_labs/backend/api/routers/ads_router.py: _get_tracking_settings (232 to 244), _require_workspace (247 to 252), imported by campaign_setup_router.py.
  • alchemy_labs/backend/models/__init__.py: MultiPlatformCampaign import (66) and __all__ entry (204), contradicting the model's own docstring.
  • Tests: alchemy_labs/backend/tests/test_campaign_setup.py (332 lines, 26 def test_ across TestObjectiveMapping, TestBudget, TestBriefValidators, TestCreativeLimits, TestAudienceTranslation, TestDryRun, TestAggregation); alchemy_labs/backend/tests/test_meta_ads.py (367 lines, 27 tests, covers services/meta_ads/ only, cited for the "not this recipe" boundary).
  • Frontend consumers (not this recipe's file set, cited for the Frontend section): alchemy_labs/frontend/src/pages/AdsManager.tsx (dry-run call ~535, real create ~1205 to 1207); alchemy_labs/frontend/src/services/adsService.ts (list/get/retry wrappers, 86, 91, 96).

Deviations of the reference from an ideal implementation, all dated 2026-09-12: the model's own docstring is stale (Gotchas); the persisted brief does not scrub image_b64 while the preview path does; bid_strategy is hardcoded across all objectives in the Meta deploy call with a comment justifying it only for two of them; no test exercises any of the three real platform create chains; no test covers retry_campaign's already-created-platform guarantee or the router's idempotency replay/conflict paths; a status='creating' row stuck by a crash has no measured reconciliation job (unlike the scheduler-backed retry in the billing-credits auto-topup recipe).

Acceptance checks

Run from alchemy_labs/backend at 62ba4fc (or an app built to this recipe) with the project's own .venv active.

  1. python -m pytest tests/test_campaign_setup.py -q --no-cov -o addopts="" -p no:cacheprovider prints 26 passed.
  2. grep -c "^ def test_" tests/test_campaign_setup.py prints 26.
  3. grep -n "\"status\": \"PAUSED\"" services/campaign_setup/adapters/google_adapter.py prints three lines (222 the campaign, 258 the ad group, 284 the RSA ad), confirming every level of the Google create chain is created paused.
  4. grep -n '"status": "PAUSED"' services/campaign_setup/adapters/linkedin_adapter.py prints one line (127), the campaign; grep -n '"status": "ACTIVE"' services/campaign_setup/adapters/linkedin_adapter.py prints one line (141), the campaign group, confirming the group/campaign PAUSED-vs-ACTIVE split described under Application logic.
  5. grep -n '"status": "PAUSED"' services/retargeting_deployer.py prints multiple lines including 716 and 758 (the flat-campaign campaign and ad set) among the file's other (out-of-scope) stage flows.
  6. grep -n "return_exceptions=True" services/campaign_setup/campaign_setup_service.py prints line 234, confirming the platform-level isolation mechanism.
  7. python -c "import ast; ast.parse(open('models/multiplatform_campaign_model.py').read())" succeeds with no output, and grep -n "MultiPlatformCampaign" models/__init__.py prints two lines (66, 204), confirming the model IS registered despite its own docstring's claim otherwise.
  8. grep -rn "deploy_flat_campaign" tests/ prints no output, confirming the real Meta create chain has no dedicated test at this commit.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.