File size: 28,847 Bytes
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
"""connectors_meta.py β€” the Meta Ads (Marketing API) connector: identity, credentials, and the
schema PROBE that W31-T40 measures with.

⭐ WHY A PROBE AND NOT A FIELD LIST. Owner ruling R8: the token exists to *"measure the schema now,
with nobody logging in via Facebook yet"*, and R2 requires the spawned grids to carry **every field
the API returns** β€” with T40's measured list as T47's only oracle (absent 0 / extra 0). Meta's own
Insights documentation declines to enumerate its fields, so a docs-derived list is not an answer to
either ruling. This file asks the API what it has.

β›” THE MEASUREMENT IS `?metadata=1`, WHICH IS GRAPH'S OWN INTROSPECTION, and that is the whole
design. Requesting a node without `fields=` returns Meta's DEFAULT subset β€” a fraction of the
schema, and a list that changes when they change their defaults. `metadata=1` returns the node's
FIELD TABLE (name + type + description) whether or not this account populates it, so the answer is
a property of the API rather than of our ad account's data. Insights has no such table, so its
catalog is measured the only other honest way: request a candidate set and record which names the
API ACCEPTS and which it REJECTS BY NAME. Both halves are written to the asset, so a reader can see
the question as well as the answer.

β›” SECRETS. `META_ADS_ACCESS_TOKEN` lives in gitignored `platform/.env`. It is read here, sent as a
POST-shaped query parameter to Graph over TLS, and **never printed, never logged, never written to
the asset** β€” the asset records the URL PATH and the field names, not the query string. Every call
is a read-only GET.

    python aios-web/api/connectors_meta.py --probe        # writes the wave asset
    python aios-web/api/connectors_meta.py --check        # credentials only, no schema walk
"""
from __future__ import annotations

import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

#: The connector's identity in the platform's registries. β›” `ut_meta_` is declared ONCE, in
#: `routes_connectors.CONNECTED_PREFIXES`, and read from there β€” a second spelling here is the
#: [[one-question-two-normalizers]] shape and `verify_meta` asserts it does not exist.
KEY = "meta_ads"
LABEL = "Meta Ads"

#: The keychain entry type an admin stores a token under. ⚠ `core.keychain.ENTRY_TYPES` must learn
#: this or `add_entry` 400s β€” that is W31-T48's half, not this file's.
ENTRY_TYPE = "meta_ads"

#: ⚠ VERSIONED, AND THE VERSION IS REPORTED RATHER THAN ASSUMED. Graph deprecates versions on a
#: schedule; a pinned literal that has aged out fails with an error that reads like a bad token.
#: Overridable so a probe can be re-run against a newer version without an edit.
GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"

#: The five entity levels R2 names, in the order a person reads them. `edge` is the connection on
#: its parent; `node` is what `metadata=1` is asked about.
LEVELS = [
    ("adaccount", "Ad Account", None),
    ("campaign", "Campaign", "campaigns"),
    ("adset", "Ad Set", "adsets"),
    ("ad", "Ad", "ads"),
    ("adcreative", "Creative", "adcreatives"),
]

#: ⚠ INPUTS, NOT ANSWERS β€” the names OFFERED to the API per entity level. The measurement is
#: which of them Graph ACCEPTS (see `node_fields`); a name missing from a list here is "not
#: asked", never "not available", and the asset says so on its own face.
#: β›” THESE EXIST BECAUSE `?metadata=1` RETURNS NOTHING ON v21.0 β€” measured, not assumed. Graph
#: withdrew the introspection table that used to make a candidate list unnecessary.
ENTITY_CANDIDATES = {
    "adaccount": [
        "account_id", "account_status", "age", "amount_spent", "balance", "business_city",
        "business_country_code", "business_name", "business_state", "business_street",
        "business_street2", "business_zip", "capabilities", "created_time", "currency",
        "disable_reason", "end_advertiser", "end_advertiser_name", "funding_source",
        "has_migrated_permissions", "id", "io_number", "is_attribution_spec_system_default",
        "is_direct_deals_enabled", "is_notifications_enabled", "is_personal",
        "is_prepay_account", "is_tax_id_required", "line_numbers", "media_agency",
        "min_campaign_group_spend_cap", "min_daily_budget", "name",
        "offsite_pixels_tos_accepted", "owner", "partner", "spend_cap", "tax_id",
        "tax_id_status", "tax_id_type", "timezone_id", "timezone_name",
        "timezone_offset_hours_utc", "tos_accepted", "user_tasks", "user_tos_accepted"],
    "campaign": [
        "account_id", "bid_strategy", "boosted_object_id", "budget_rebalance_flag",
        "budget_remaining", "buying_type", "campaign_group_active_time",
        "can_create_brand_lift_study", "can_use_spend_cap", "configured_status", "created_time",
        "daily_budget", "effective_status", "id", "is_skadnetwork_attribution", "issues_info",
        "last_budget_toggling_time", "lifetime_budget", "name", "objective", "pacing_type",
        "primary_attribution", "promoted_object", "smart_promotion_type", "source_campaign",
        "source_campaign_id", "special_ad_categories", "special_ad_category",
        "special_ad_category_country", "spend_cap", "start_time", "status", "stop_time",
        "topline_id", "updated_time"],
    "adset": [
        "account_id", "adlabels", "adset_schedule", "asset_feed_id", "attribution_spec",
        "bid_adjustments", "bid_amount", "bid_constraints", "bid_info", "bid_strategy",
        "billing_event", "budget_remaining", "campaign", "campaign_active_time",
        "campaign_attribution", "campaign_id", "configured_status", "created_time",
        "creative_sequence", "daily_budget", "daily_min_spend_target", "daily_spend_cap",
        "destination_type", "effective_status", "end_time", "frequency_control_specs", "id",
        "instagram_actor_id", "is_dynamic_creative", "issues_info", "learning_stage_info",
        "lifetime_budget", "lifetime_imps", "lifetime_min_spend_target", "lifetime_spend_cap",
        "multi_optimization_goal_weight", "name", "optimization_goal", "optimization_sub_event",
        "pacing_type", "promoted_object", "recurring_budget_semantics", "review_feedback",
        "rf_prediction_id", "source_adset", "source_adset_id", "start_time", "status",
        "targeting", "targeting_optimization_types", "time_based_ad_rotation_id_blocks",
        "time_based_ad_rotation_intervals", "updated_time", "use_new_app_click"],
    "ad": [
        "account_id", "ad_active_time", "ad_review_feedback", "ad_schedule_end_time",
        "ad_schedule_start_time", "adlabels", "adset", "adset_id", "bid_amount", "bid_info",
        "bid_type", "campaign", "campaign_id", "configured_status", "conversion_domain",
        "created_time", "creative", "demolink_hash", "display_sequence", "effective_status",
        "engagement_audience", "failed_delivery_checks", "id", "issues_info",
        "last_updated_by_app_id", "name", "preview_shareable_link", "priority",
        "recommendations", "source_ad", "source_ad_id", "status", "targeting",
        "tracking_and_conversion_with_defaults", "tracking_specs", "updated_time"],
    "adcreative": [
        "account_id", "actor_id", "adlabels", "applink_treatment", "asset_feed_spec",
        "authorization_category", "body", "branded_content_sponsor_page_id", "bundle_folder_id",
        "call_to_action_type", "categorization_criteria", "category_media_source",
        "collaborative_ads_lsb_image_bank_id", "degrees_of_freedom_spec", "destination_set_id",
        "dynamic_ad_voice", "effective_authorization_category", "effective_instagram_media_id",
        "effective_object_story_id", "enable_direct_install", "enable_launch_instant_app", "id",
        "image_crops", "image_hash", "image_url", "instagram_actor_id",
        "instagram_permalink_url", "instagram_story_id", "instagram_user_id",
        "interactive_components_spec", "link_deep_link_url", "link_destination_display_url",
        "link_og_id", "link_url", "messenger_sponsored_message", "name", "object_id",
        "object_store_url", "object_story_id", "object_story_spec", "object_type", "object_url",
        "page_link", "page_message", "place_page_set_id", "platform_customizations",
        "playable_asset_id", "portrait_customizations", "product_set_id", "recommender_settings",
        "source_instagram_media_id", "status", "template_url", "template_url_spec",
        "thumbnail_id", "thumbnail_url", "title", "url_tags", "use_page_actor_override",
        "video_id"],
}

#: ⚠ AN INPUT, NOT AN ANSWER β€” and labelled as one in the asset. Insights has no `metadata=1`
#: table, so the catalog is measured by ACCEPTANCE: every name here is offered to the API and the
#: probe records which it takes and which it names in an error. A name missing from this list is
#: therefore "not asked", never "not available", and the asset says so.
INSIGHTS_CANDIDATES = [
    "account_id", "account_name", "account_currency", "campaign_id", "campaign_name",
    "adset_id", "adset_name", "ad_id", "ad_name", "date_start", "date_stop",
    "impressions", "reach", "frequency", "clicks", "unique_clicks", "ctr", "unique_ctr",
    "cpc", "cpm", "cpp", "spend", "social_spend", "actions", "action_values",
    "conversions", "conversion_values", "cost_per_action_type", "cost_per_unique_click",
    "cost_per_inline_link_click", "cost_per_thruplay", "purchase_roas", "website_purchase_roas",
    "inline_link_clicks", "inline_link_click_ctr", "inline_post_engagement",
    "outbound_clicks", "outbound_clicks_ctr", "unique_outbound_clicks",
    "video_play_actions", "video_p25_watched_actions", "video_p50_watched_actions",
    "video_p75_watched_actions", "video_p95_watched_actions", "video_p100_watched_actions",
    "video_avg_time_watched_actions", "video_thruplay_watched_actions",
    "quality_ranking", "engagement_rate_ranking", "conversion_rate_ranking",
    "objective", "optimization_goal", "buying_type", "attribution_setting",
    "canvas_avg_view_time", "estimated_ad_recallers", "full_view_impressions",
]

#: Every probe call, for the mailbox log the PRD's shared-resource table demands ("log every probe
#: in mailbox/D.md; the rate limit is shared across the token"). Paths only β€” never a query string,
#: because the token rides in one.
CALLS: list = []


class MetaError(RuntimeError):
    """A Graph refusal, carrying Meta's own message. β›” Its `str()` is safe to print: the token is
    stripped from every URL before it reaches here."""


def _token():
    """The Meta token from the environment, or from gitignored `platform/.env`. Returns "" when
    absent β€” the caller REPORTS that rather than crashing, because "no token" is a real state of
    this deployment and it is the state as of 2026-08-12."""
    tok = os.environ.get("META_ADS_ACCESS_TOKEN") or ""
    if tok:
        return tok.strip()
    env = Path(__file__).resolve().parents[2] / "platform" / ".env"
    if env.exists():
        for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
            if line.strip().startswith("META_ADS_ACCESS_TOKEN"):
                _, _, v = line.partition("=")
                return v.strip().strip('"').strip("'")
    return ""


def _safe(url):
    """`url` with every query value redacted β€” the only form allowed near a log or an asset."""
    parts = urllib.parse.urlsplit(url)
    q = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
    shown = "&".join(f"{k}=<redacted>" if k == "access_token" else f"{k}={v}" for k, v in q)
    return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, shown, ""))


def get(path, token, **params):
    """One read-only Graph GET. Returns the decoded body, or raises `MetaError` with Meta's reason.

    ⚠ A 400 FROM GRAPH IS AN ANSWER, NOT A CRASH β€” an unknown field name comes back as a 400 whose
    message NAMES the field, and that is precisely how the Insights catalog is measured below.
    """
    params["access_token"] = token
    url = f"{GRAPH}/{path.lstrip('/')}?" + urllib.parse.urlencode(params)
    CALLS.append({"path": path.lstrip("/"), "params": sorted(k for k in params
                                                             if k != "access_token")})
    req = urllib.request.Request(url, headers={"User-Agent": "aios-meta-probe/1"})
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return json.loads(r.read().decode("utf-8", "replace"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        try:
            err = (json.loads(body).get("error") or {})
            msg = err.get("message") or body[:300]
            code = err.get("code")
        except Exception:                                              # noqa: BLE001
            msg, code = body[:300], None
        raise MetaError(f"HTTP {e.code} (code {code}) on {_safe(url)}: {msg}") from None
    except Exception as e:                                             # noqa: BLE001
        raise MetaError(f"{type(e).__name__} on {_safe(url)}: {e}") from None


def node_fields(node_id, token, candidates, batch=60):
    """`(accepted, rejected, gated)` for ONE node, measured by ACCEPTANCE + BISECT.

    β›”β›” `?metadata=1` IS DEAD AND THAT WAS MEASURED, NOT ASSUMED. This function used to ask Graph
    for its published introspection table, which is the documented way to enumerate a node's
    fields. On **v21.0 it returns nothing**: `GET /act_…?metadata=1` answers with keys
    `['account_id','id']` and `GET /<campaign_id>?metadata=1` with `['id']` β€” `metadata.fields` is
    EMPTY in both. The probe reported "0 fields" for all five levels and looked like a broken
    probe; it was a withdrawn API. So the entity levels are measured exactly as Insights already
    was: offer names, keep what the API takes.

    β›”β›” **ACCEPTED IS NOT RETURNED, AND THIS IS THE ONE THAT BREAKS R2 SILENTLY.** A 20-field batch
    on a live campaign answers **HTTP 200 with 14 keys** β€” Graph omits nulls from the response. A
    catalog built from the keys that came BACK loses roughly a third of the schema, which is
    R2's *"do not drop any column"* violated with no gate anywhere going red. The catalog is
    therefore what was **ASKED and ACCEPTED**, and this function never inspects the payload's keys.

    β›” **A 400 NAMES THE OFFENDER; A 403 NAMES NOTHING.** `(#100) Tried accessing nonexisting
    field (x)` tells us exactly which name to drop, so a batch converges in one retry. But the Ad
    Account level answers `(#200) Requires business_management permission` β€” no field named β€” and
    dropping the batch there reports "Ad Account: 0 fields", which is the honest-looking wrong
    answer. An unnamed refusal is BISECTED instead: 11 calls classified all 46, finding exactly one
    gated column (`owner`).
    """
    accepted, rejected, gated = [], {}, {}

    def ask(names):
        """`(ok, err)` β€” one batch. `ok` means every name in it is a real, readable field."""
        try:
            get(str(node_id), token, fields=",".join(names))
            return True, ""
        except MetaError as e:
            return False, str(e)

    def resolve(names, depth=0):
        if not names:
            return
        ok, err = ask(names)
        if ok:
            accepted.extend(names)
            return
        named = [f for f in names if f"({f})" in err or f"field ({f})" in err]
        if named and len(named) < len(names):
            for f in named:
                rejected[f] = err[:200]
            resolve([f for f in names if f not in named], depth + 1)
            return
        if len(names) == 1:
            # a single name that still refuses: classify it by WHAT the refusal was.
            (rejected if "(#100)" in err else gated)[names[0]] = err[:200]
            return
        half = len(names) // 2                      # unnamed refusal β‡’ bisect, never drop
        resolve(names[:half], depth + 1)
        resolve(names[half:], depth + 1)
        time.sleep(0.15)                            # the rate limit is shared across this token

    for start in range(0, len(candidates), batch):
        resolve(list(candidates[start:start + batch]))
    return sorted(set(accepted)), rejected, gated


def insights_catalog(act_id, token, candidates=None):
    """`(accepted, rejected)` for the daily Insights field catalog, measured by ACCEPTANCE.

    Asks in batches; on a 400 that names a field, drops that name and retries. What survives is
    what the API takes at `time_increment=1` β€” which is the only claim R2's Insights grids need.
    ⚠ `rejected` is reported too: "we asked and Meta refused" and "we never asked" are different
    facts, and only the asset can keep them apart.
    """
    remaining = list(candidates if candidates is not None else INSIGHTS_CANDIDATES)
    rejected, accepted = {}, []
    for start in range(0, len(remaining), 25):
        batch = remaining[start:start + 25]
        while batch:
            try:
                get(f"act_{act_id}/insights", token, fields=",".join(batch),
                    time_increment="1", date_preset="last_7d", limit="1")
                accepted.extend(batch)
                break
            except MetaError as e:
                bad = [f for f in batch if f in str(e)]
                if not bad:                    # a refusal that names no field: report, do not loop
                    rejected["(batch)"] = str(e)
                    break
                for f in bad:
                    rejected[f] = str(e)[:200]
                batch = [f for f in batch if f not in bad]
            time.sleep(0.2)                    # the rate limit is shared across this token
    return sorted(set(accepted)), rejected


def probe():
    """Walk the whole schema and return the report dict. Never raises on a Meta refusal β€” a
    refusal IS the finding, and a probe that dies on one produces no asset at all."""
    token = _token()
    report = {"graphVersion": GRAPH_VERSION, "token": bool(token), "accounts": [],
              "levels": {}, "insights": {}, "errors": []}
    if not token:
        report["errors"].append(
            "META_ADS_ACCESS_TOKEN is not set in the environment or in platform/.env. "
            "Nothing was measured. This is the state of this box as of 2026-08-12: the PRD "
            "records the token as 'recorded in platform/.env' and it is not there.")
        return report
    try:
        me = get("me/adaccounts", token,
                 fields="id,account_id,name,account_status,currency,timezone_name,business_name")
        report["accounts"] = me.get("data") or []
    except MetaError as e:
        report["errors"].append(f"reaching /me/adaccounts: {e}")
        return report
    if not report["accounts"]:
        report["errors"].append("the token authenticates but reaches ZERO ad accounts β€” a "
                                "permissions/scope answer, not an empty business")
        return report
    act = report["accounts"][0]
    act_id = str(act.get("account_id") or str(act.get("id") or "").replace("act_", ""))
    node_of = {"adaccount": f"act_{act_id}"}
    for key, _label, edge in LEVELS:
        if edge is None:
            continue
        try:
            got = get(f"act_{act_id}/{edge}", token, fields="id", limit="1")
            rows = got.get("data") or []
            if rows:
                node_of[key] = rows[0]["id"]
            else:
                report["errors"].append(f"{key}: this account has no {edge}, so no real row could "
                                        f"be read (the field TABLE is still measured below)")
        except MetaError as e:
            report["errors"].append(f"listing {edge}: {e}")
    for key, label, _edge in LEVELS:
        nid = node_of.get(key)
        cands = ENTITY_CANDIDATES.get(key) or []
        if not nid:
            report["levels"][key] = {"label": label, "accepted": [], "rejected": {}, "gated": {},
                                     "asked": len(cands), "node": "",
                                     "note": "no node of this level was reachable, so nothing "
                                             "could be offered to the API"}
            continue
        try:
            accepted, rejected, gated = node_fields(nid, token, cands)
            report["levels"][key] = {"label": label, "node": str(nid), "asked": len(cands),
                                     "accepted": accepted, "rejected": rejected, "gated": gated}
        except MetaError as e:
            report["levels"][key] = {"label": label, "node": str(nid), "asked": len(cands),
                                     "accepted": [], "rejected": {}, "gated": {},
                                     "note": str(e)}
    try:
        accepted, rejected = insights_catalog(act_id, token)
        report["insights"] = {"accepted": accepted, "rejected": rejected,
                              "candidatesAsked": len(INSIGHTS_CANDIDATES)}
    except MetaError as e:
        report["errors"].append(f"insights catalog: {e}")
    return report


def as_markdown(report):
    """The wave asset. β›” Field NAMES and TYPES only β€” no row values, no account identifiers beyond
    the ad account name the owner already knows, and no query strings."""
    L = ["# W31-T40 β€” the Meta Ads schema, MEASURED", "",
         f"Graph version: `{report['graphVersion']}` Β· token present: "
         f"**{'yes' if report['token'] else 'NO'}** Β· probe calls: **{len(CALLS)}**", ""]
    if report["errors"]:
        L += ["## What could not be measured", ""]
        L += [f"- {e}" for e in report["errors"]] + [""]
    if not report["token"]:
        L += ["β›” **NOTHING BELOW WAS MEASURED.** This asset exists so the absence is a recorded "
              "fact rather than a missing file: the probe is built, runs, and reports honestly "
              "that it has no credential. Re-run `python aios-web/api/connectors_meta.py --probe` "
              "the moment a token lands and this file fills itself in.", ""]
        return "\n".join(L)
    L += ["## Reachable ad accounts", "", "| account_id | name | status | currency | timezone |",
          "|---|---|---|---|---|"]
    for a in report["accounts"]:
        L.append(f"| `{a.get('account_id','')}` | {a.get('name','')} | "
                 f"{a.get('account_status','')} | {a.get('currency','')} | "
                 f"{a.get('timezone_name','')} |")
    L.append("")
    L += ["## Method β€” and why it is not `?metadata=1`", "",
          "β›” **Graph v21.0 publishes no introspection table.** `GET /act_…?metadata=1` returns "
          "keys `['account_id','id']` and `GET /<campaign_id>?metadata=1` returns `['id']` β€” "
          "`metadata.fields` is EMPTY. The first run of this probe reported *0 fields* for all "
          "five levels and looked like a broken probe; it was a withdrawn API.", "",
          "⭐ So every level below is measured by **ACCEPTANCE**: the names in "
          "`connectors_meta.ENTITY_CANDIDATES` are offered, and what Graph takes is the catalog. "
          "A `(#100)` refusal NAMES the offending field, so a batch converges in one retry; a "
          "`(#200)` permission refusal names nothing, so the batch is **bisected** rather than "
          "dropped β€” otherwise the Ad Account level reports a confident, wrong `0 fields`.", "",
          "β›”β›” **ACCEPTED IS NOT RETURNED.** A 20-field batch on a live campaign answers HTTP 200 "
          "with 14 keys, because Graph omits nulls. Every list below is what was **asked and "
          "accepted** β€” the response's own keys are never inspected. Building columns from "
          "returned keys drops roughly a third of the schema, which is R2's *do not drop any "
          "column* broken with nothing going red.", ""]
    for key, label, _edge in LEVELS:
        lv = report["levels"].get(key) or {}
        fields = lv.get("accepted") or []
        L += [f"## {label} β€” **{len(fields)} accepted** of {lv.get('asked', 0)} asked", ""]
        if lv.get("note"):
            L += [f"⚠ {lv['note']}", ""]
        if fields:
            L += ["```", ", ".join(fields), "```", ""]
        if lv.get("rejected"):
            L += ["**Not a field on this account** (refused BY NAME, recorded rather than "
                  "guessed at):", ""]
            L += [f"- `{k}`" for k in sorted(lv["rejected"])] + [""]
        if lv.get("gated"):
            L += ["⚠ **Permission-gated β€” a REPORTED limit, not a silent omission** (R6's second "
                  "sentence):", ""]
            L += [f"- `{k}` β€” {v}" for k, v in sorted(lv["gated"].items())] + [""]
    ins = report.get("insights") or {}
    if ins:
        L += [f"## Insights at `time_increment=1` β€” {len(ins.get('accepted') or [])} accepted of "
              f"{ins.get('candidatesAsked', 0)} asked", "",
              "⚠ Insights publishes no `metadata=1` field table, so this catalog is measured by "
              "ACCEPTANCE: every name below was offered to the API and taken. A name absent from "
              "this file was **not asked**, which is not the same as unavailable.", "",
              "```", ", ".join(ins.get("accepted") or []), "```", ""]
        if ins.get("rejected"):
            L += ["### Refused by name", ""]
            L += [f"- `{k}` β€” {v}" for k, v in sorted((ins.get("rejected") or {}).items())] + [""]
    return "\n".join(L)


def probe_credential(fields, timeout=8):
    """`{ok, message}` for a stored `meta_ads` credential β€” the keychain's liveness probe.

    β›” IT LIVES HERE, IN THE API LAYER, AND IS REGISTERED INTO `core.keychain` RATHER THAN
    IMPORTED BY IT. `core` never imports up (ARCHITECTURE.md), and the first draft of this arm
    did `sys.path.insert` + `import connectors_meta` from inside `core/keychain.py` β€” an
    inversion with no gate on it. `keychain.register_prober` is the slot, borrowed from
    `datastore.set_paused_probe`, which exists for exactly this reason one layer down.

    ⚠ ZERO reachable ad accounts is reported as NOT ok, deliberately: a token that authenticates
    but sees no account is a SCOPE answer β€” the single likeliest failure for a Marketing API
    credential β€” and `ok: true` would hide the one thing the admin needs to fix.
    """
    tok = str((fields or {}).get("access_token") or (fields or {}).get("token")
              or (fields or {}).get("api_key") or "")
    if not tok:
        return {"ok": False,
                "message": "store the token under `access_token` (or `token` / `api_key`)"}
    try:
        got = get("me/adaccounts", tok, fields="id", limit="1")
    except MetaError as e:
        return {"ok": False, "message": str(e)[:200]}
    n = len(got.get("data") or [])
    return {"ok": n > 0,
            "message": (f"Meta answered β€” {n} ad account(s) reachable" if n else
                        "the token authenticates but reaches NO ad account: that is a "
                        "scope/permission answer, not an empty business")}


def register():
    """Fill the keychain's probe slot for this connector. Called at import by the API layer's
    connector registry (`routes_connectors`), so any process that can serve the directory has
    also registered the prober. Never raises: a probe slot that could not be filled costs a
    liveness CHECK, never the ability to store a credential."""
    try:
        import core.keychain as kc
        return kc.register_prober(ENTRY_TYPE, probe_credential)
    except Exception:                                                  # noqa: BLE001
        return False


def main(argv):
    check_only = "--check" in argv
    token = _token()
    if check_only:
        print(f"META token present: {'yes' if token else 'NO'}  "
              f"(never printed; length is not disclosed)")
        if not token:
            return 2
        try:
            me = get("me/adaccounts", token, fields="id,name", limit="5")
            print(f"authenticated; reachable ad accounts: {len(me.get('data') or [])}")
            return 0
        except MetaError as e:
            print(f"REFUSED: {e}")
            return 1
    report = probe()
    out = (Path(__file__).resolve().parents[2] / ".claude" / "wiki" / "research" / "waves"
           / "wave31" / "proto" / "meta-ads-schema.md")
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(as_markdown(report), encoding="utf-8")
    print(f"wrote {out}")
    for c in CALLS:
        print(f"  probe: GET /{c['path']}  [{', '.join(c['params'])}]")
    return 0 if report["token"] and not report["errors"] else 2


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))