Graph requires targeting on every ad set. An empty targeting dict reached
Meta and failed opaquely part-way into building the object chain, leaving
a campaign and nothing else. This names the missing field before any
network call, matching how the module already handles a missing objective
and a missing daily budget.
The wizard defaults age and countries, so this only fires on campaigns
built through the API without targeting.
Also fixes the live smoke test, which used Graph's nested geo_locations
shape rather than the flat spec.targeting["countries"] the mapper reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Launch set only the campaign to ACTIVE. Sync creates the ad set and ad
PAUSED, and Meta delivers only when the ad, its ad set and its campaign
are all active — so Launch reported "live" while nothing ran. This was
the central promise of Phase 2 and it did not work.
The chain is now activated children-first, campaign last. Nothing under a
paused campaign delivers, so a failure part-way leaves the campaign unable
to spend. That ordering is also why pause and stop only flip the campaign.
An imported campaign has no stored ad set or ad ids, so launching it still
touches only the campaign and its children keep the statuses set in Ads
Manager.
Deleting an imported campaign is now refused rather than silently
pointless: deleting it on Meta would destroy work MaskanX did not author,
and deleting only the local row achieved nothing because the reconciler
re-imported it on the next cycle. Deleting it in Ads Manager is what
sticks, after which the reconciler archives the record.
Also corrects the README's reconciler interval: it is 120s, not 300s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README still described Phase 1's "creates nothing in Meta" contract,
which is no longer true. It now covers the lifecycle, that sync creates
only paused objects and why that is enforced in three places, what launch
requires, adopt/delete semantics for imported campaigns, and the
reconciler interval.
Also documents the separate campaign_test database. Without it the
repository integration tests skip and the suite still passes, so the skip
count matters as much as the exit code.
The live smoke test is the only thing that proves Meta accepts what the
client sends: every other test runs against a fake transport. It syncs a
real campaign, reads back from Graph that all three objects are PAUSED,
and deletes them in a finally block. Opt-in via MASKANX_LIVE_TESTS=1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting a campaign only removed the local row. A live campaign deleted
in MaskanX kept spending on Meta with nothing left here recording that it
existed. Meta is now deleted first, and a failure there keeps the local
row and answers 502, since forgetting it here while it still spends there
is the worse of the two outcomes.
An imported campaign is never deleted on Meta. MaskanX did not author it,
and forgetting the import must not destroy work done in Ads Manager.
Deleting a campaign cascades to its ad sets and ads, so only the campaign
id is sent. The creative is left behind deliberately: it is an
account-level asset that other ads may reference.
Meta reports a delete of an absent object as code 100, which delete_object
swallows, so retrying a partially-failed cleanup is safe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Meta publishes no webhooks for campaign create or delete, so this polls every
120s by default (MASKANX_CAMPAIGN_RECONCILE_SECONDS; 0 disables).
Meta is authoritative for delivery state; MaskanX keeps its own metadata.
Guardrails, approvals and audit history are never touched by reconciliation,
and campaigns still in a local-only status are skipped entirely so a draft
that has never reached Meta cannot be overwritten.
A campaign that disappears from Meta is archived, not deleted: its spend
history has to stay reportable.
The loop swallows and logs a failed cycle rather than dying, and is cancelled
on shutdown alongside the watchdog. It only starts when META_ADS_ACCOUNT_ID
is set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaigns can be authored on either side. Anything MaskanX did not create is
origin="imported": MaskanX reports on it and can pause or stop it, but it was
built elsewhere.
Adoption is keyed on meta_campaign_id, which carries a unique index, so
adopting the same campaign twice returns the existing record instead of
violating the constraint. Discover excludes anything already adopted.
The literal /discover and /adopt routes are declared before /{campaign_id} so
they cannot be captured as campaign ids; a test pins that ordering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Launch is the only action that starts real spend, so it refuses unless every
precondition holds: the campaign is synced, its status allows launching, an
identified operator approved it, and the ad account has a payment method.
Each refusal names what to do rather than letting Meta fail opaquely later.
An approval recorded as "unauthenticated" does not authorise spend. With
MASKANX_OPERATOR_TOKENS unset every approval is unattributable, so launch is
blocked until operator auth is configured.
Stop and pause set the Meta object PAUSED before recording the local change,
so a Meta failure cannot leave a campaign that is stopped in MaskanX but
still delivering.
Replaces the Phase 1 test asserting /launch did not exist with one asserting
it is unreachable from draft; the guarded property is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creates the campaign, ad set, creative and ad chain, every object PAUSED.
Nothing here starts delivery; Launch is a separate explicit action.
Each Meta id is persisted as soon as it is obtained, so a failure part-way
through is resumable: a retry reuses the stored ids and creates only what is
still missing. Without that, retrying a partial failure would create a second
set of ad objects in a real advertising account.
Page id and ad account are resolved before any network call, so missing
configuration cannot leave a half-built chain in the account. A Meta failure
records sync_status=failed with Meta's code and subcode, leaves the status at
approved, and keeps the ids already obtained.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 4 review: Approved with no Critical findings, but three Important
and two hardening items. All five addressed:
- objects.build_campaign_payload: normalise a bare string
special_ad_categories value to a single-element list instead of
exploding it into characters via list() - the field controls
regulated-advertising compliance and advanced is unvalidated input.
- objects.build_ad_set_payload: validate optimization_goal and
billing_event against new allowlists before forwarding them, since
billing_event determines how the ad account is charged and both come
from client-controlled advanced.
- validation.validate_budget: reject a lifetime-only budget (Meta's
create_ad_set only accepts daily_budget) so it fails at validation
time instead of passing approval and only failing at sync.
- client.py: create_campaign/create_ad_set/create_ad now send the
CREATE_STATUS constant rather than the caller's status object, so a
str subclass with a lying __ne__ can no longer slip "ACTIVE" past the
guard.
- client._post: refuse any POST to a campaign/adset/ad creation path
with a non-PAUSED status before touching the transport, as defence in
depth if a future caller bypasses the typed create_* guards.
10 new tests (8 in test_meta_writes.py, 2 in test_campaign_validation.py).
Adds the write half of the Meta Graph API client: create_campaign,
create_ad_set, upload_ad_image, create_ad_creative, create_ad,
update_object_status, and list_campaigns/list_ad_sets/list_ads. Every
create_* helper hard-codes status="PAUSED" and raises ValueError with
zero network calls if asked for anything else; update_object_status is
the only method allowed to send ACTIVE. Dict params Graph expects as
JSON strings (targeting, object_story_spec, special_ad_categories) are
json.dumps-encoded before being sent.
Also adds src/adclaw/meta/objects.py with pure functions mapping a
CampaignSpec's budget/targeting/advanced fields onto Graph parameter
names, keeping that mapping unit-testable without a transport.
26 new tests in tests/test_meta_writes.py, all against a FakeTransport
(no live network calls, no real Meta objects created).
Phase 1 covered only the pure row mapper, so every SQL path in
CampaignRepository was untested — parameter ordering, RETURNING clauses and
transaction behaviour had never been executed. Phase 2 creates real Meta
objects on top of this layer, so the gap is closed first.
Seven tests cover the CRUD round trip, LookupError on unknown ids, event
recording, and that update_campaign_with_event writes both rows or neither.
They skip rather than fail when PostgreSQL is unreachable, and delete every
row they create in a finally block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
approved_by was client-supplied, making approval forgeable ahead of
Phase 2 pushing real spend to Meta. Add a proportionate operator-token
check (no login system) via a new require_operator FastAPI dependency:
MASKANX_OPERATOR_TOKENS maps name:token pairs, and approve_campaign now
derives its actor solely from the resolved operator, never from the
client-supplied ActorPayload.actor. Unset env resolves to
"unauthenticated" so local dev and existing tests are not blocked;
set-but-unrecognised tokens 401. submit and reject remain
unauthenticated since neither authorises spend.
Updates test_submit_then_approve_moves_through_states to assert
approved_by == "unauthenticated" (env var unset in tests) instead of
the previously-trusted client actor, since a forged actor must now be
ignored.
_transition previously called update_campaign then add_event as two
separate commits, so a failed add_event left a status change with no
audit row. Phase 2 launches campaigns that authorise spend, so a
compliance hole like that has to close first.
Add CampaignRepository.update_campaign_with_event, which runs the
UPDATE and the event INSERT on one connection inside one transaction
and commits once. Route campaign transitions (submit/approve/reject)
through it instead of the update_campaign + add_event pair; update_campaign
and add_event themselves are unchanged for other callers.
Also update test_campaign_api.py's FakeRepo/monkeypatch to implement the
new repository method, since production code now calls it in the
submit/approve/reject path.
Campaign records, approval workflow, budget guardrails against the ad
account minimum, and Meta ad preview. Creates nothing in Meta.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what Phase 1 does and deliberately does not do: no Meta writes, no
launch or sync endpoint, previews rendered without creating objects, budget
minimums enforced locally, and the fact that a payment method can only be
attached in Meta Business Manager.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PUT /campaigns/{id} validated the raw request payload instead of the
merged campaign state, so omitting ad_account_id skipped the minimum
check and a full model_dump() overwrote every unset field (targeting,
guardrails, objective, etc.) with its default, silently destroying
data. Validate against the effective post-merge budget/guardrails/
account and only apply fields the client actually sent
(exclude_unset=True).
Also: persist company_id on UPDATE (was accepted but dropped), log
a warning when Meta account lookups fail so the budget-minimum
fail-open is observable, reword the non-editable-status 409 message
to not prescribe an impossible reject-to-draft action, and add
coverage for Meta error surfacing, LookupError-to-404 on PUT/submit,
reject's pending_approval->draft transition, and GET-unknown-id 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_campaign/update_campaign previously committed then opened a
second connection via get_campaign() just to re-read the row they had
just written, adding an extra round-trip and a race window where a
concurrent delete between commit and re-fetch was misreported as
"disappeared immediately after insert/update". Both now RETURNING the
row from the same statement/cursor that wrote it, using a shared
_RETURNING_CLAUSE derived from _SELECT_COLUMNS so the two column lists
cannot drift apart. update_campaign now raises LookupError for an
unknown id (0 rows affected) instead of misdiagnosing it as a race.
Also: delete_campaign uses the (cur.rowcount or 0) > 0 idiom already
established in postgres_repo.py, the module docstring documents that
this repository's SQL paths rely on integration tests rather than
unit tests, and a new DB-free test asserts the RETURNING and SELECT
column lists stay identical.
Adds CampaignRepository with list/get/create/update/delete for
campaigns plus add_event/list_events for the audit trail, following
the existing postgres_repo.py connection pattern. Replaces the
brief's two bare `assert created/updated is not None` checks with
explicit RuntimeError raises so the guard survives python -O.
Address code review findings on the read-only Meta Graph client: add
monkeypatch-based tests for access_token_from_env/MetaNotConfiguredError,
assert generate_previews JSON-encodes the creative param, pin the
existing fail-fast-on-partial-error contract with a dedicated test, and
declare httpx as an explicit dependency since it was only resolving
transitively.
validate_guardrails used a truthy check on daily_budget, which silently
skipped the auto-pause-above-daily-budget check when daily_budget was 0 -
exactly the case where a positive auto-pause threshold can never trigger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Independent FastAPI backend for the MaskanX agentic growth platform.
Includes the agent runtime, MCP client integrations (Meta Ads, LinkedIn,
HubSpot, Tavily, Exa, xAI, Citedy, image generation), PostgreSQL storage
for chats and cron jobs, provider and secret management, and the CLI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>