Fourth failure in the same chain, one object further each time: "Advantage
audience flag required ... setting the advantage_audience flag to either 1
or 0 within the targeting_automation field" (code 100, subcode 1870227).
We were never sending targeting_automation at all.
Defaulting to 0 (off) for the same reason bid_strategy defaults to
LOWEST_COST_WITHOUT_CAP: an ad set should reach the audience it was told
to — the countries/age/gender actually set on the campaign — not whatever
Meta's Advantage+ expansion additionally decides to include.
advanced.advantage_audience (0 or 1) overrides it per campaign.
Ordering note: targeting_automation is added unconditionally, so the
existing "no audience" guard (raises when targeting is empty) had to move
before it — otherwise targeting would never be empty and that guard would
go silently dead. A test pins this: an empty CampaignSpec.targeting must
still be refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third failure in the same chain, one object further each time: "Performance
goal isn't available: You can't use the selected performance goal with your
campaign objective" (code 100, subcode 2490408).
DEFAULT_OPTIMIZATION_GOAL was a single global constant, LEAD_GENERATION,
applied whatever the objective was. That is correct for OUTCOME_LEADS and
wrong for every other objective Meta offers — including OUTCOME_TRAFFIC,
which is what the connection test builds.
Defaults now come from a per-objective table, and an unknown objective
falls back to LINK_CLICKS: valid for the widest range of objectives, where
a lead goal is valid for exactly one. An explicit advanced.optimization_goal
still wins, and is still checked against the allowlist.
Deliberately not validating goal-against-objective beyond the defaults.
Meta's full compatibility matrix is not something this code can assert
confidently, and a wrong matrix would reject setups that actually work —
worse than the failure it would prevent, which sync already surfaces
legibly now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the campaign-level field fixed, the live run got one object further
and failed on the ad set: "Bid amount or bid constraints required for bid
strategy" (code 100, subcode 2490487). We were sending no bid_strategy at
all, and Graph requires one on every ad set that carries its own budget —
which, since MaskanX never uses campaign budget, is all of them.
LOWEST_COST_WITHOUT_CAP is the only strategy that needs nothing else from
us: COST_CAP and LOWEST_COST_WITH_BID_CAP need a bid amount, and
LOWEST_COST_WITH_MIN_ROAS needs bid constraints plus a VALUE optimisation
goal. CampaignSpec carries none of those, so the allowlist holds exactly
one value and build_ad_set_payload refuses the rest before any network
call — a rejection at sync time would land after the campaign object
already exists, leaving a half-built chain behind.
It also suits the guardrails: automatic bidding spends the daily budget
and never exceeds it, where the cap strategies bound unit price rather
than total spend.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The connection test finally said what was wrong: "Must specify True or
False in is_adset_budget_sharing_enabled ... if you are not using campaign
budget." Code 100, subcode 4834011 — the same failure that has been
blocking a live sync, previously reported only as "Invalid parameter".
MaskanX always puts the budget on the ad set, so the campaign never
carries one and Graph never treats this field as optional. It was never
sent at all, so no campaign could be created in this account.
Defaulting to false, not true. True lets ad sets lend each other up to 20%
of their budget, which means an ad set can outspend the daily budget we
set for it — and validate_guardrails treats that number as a ceiling.
Predictable spend beats Meta's optimisation. Overridable per call.
Sent as the lowercase literal "false": form-encoding a Python bool would
put "True" on the wire, which Graph rejects.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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).