CTA half-pair drift detector + Braze validator now recognises standard profile fields
Two payloads in one release. The Stripo module-bindings inspector gains a half-pair drift check — catches a CTA where only one half of the text/link Smart Property pair is registered, leaving the other half hardcoded in the module HTML. This was a quiet authoring slip with loud production consequences: every compose call looked successful while shipping the master-template default for the unregistered half. Scope is gated to base names ending in cta / button / btn so image-as-link patterns don't false-positive, and an ACK comment marker is available for intentional half-pairs (split CTAs where one half is design-by-intent static). Separately, orbit_validate_braze_data now recognises Braze's 26 standard user-profile fields (first_name, country, time_zone, push_token, etc.) alongside your custom attributes, so it stops false-flagging well-known profile fields as missing. Each found attribute is now tagged with whether it's standard or custom and the exact Liquid syntax to use, so the assistant can stop guessing whether to wrap a name in custom_attribute. orbit_audit_braze_instance surfaces the same 26 standard fields alongside the custom inventory. And orbit_validate_test_users no longer 400s when you pass an array of email addresses — Braze's /users/export/ids endpoint quietly accepts an array of external_ids but only a single email_address per request, so the tool now loops emails serially. The expanded response surfaces every standard field's value plus populated / empty arrays per profile, so verifying "is first_name actually populating in this test profile?" no longer requires inspecting the raw user object.
What shipped
•New detector — orbit_inspect_stripo_module_bindings now flags CTA half-pair drift. When a Smart Property ends in `_text` or `_link` and its base name resolves to a CTA shape (anything ending in cta, button, or btn — bare like `p_cta_link`, indexed like `p_cta_link_1`, or prefixed like `p_secondary_cta_link`), the inspector checks that its companion half exists. If only the link half is registered and the text half is missing (or vice versa), the unregistered half is silently hardcoded in the module HTML and ships unchanged on every compose. The note names the present variable, the expected companion, and which half (text or link) is currently registered. Six new fixture cases cover the surface: link-only drift, indexed-pair drift, full pairs (no fire), image-as-link false-positive guard, and ACK suppression.
•Suppression path — half-pair drift accepts an ACK comment marker for intentional half-pair authoring. Some modules deliberately bind only one half — e.g. a split-CTA module where one side's label is set in the master template and never personalised. Paste `<!-- ACK: p_cta_link half-pair is intentional -->` (or the equivalent for indexed variants) directly into the module HTML and the inspector skips the note for that variable. The wizard preserves HTML comments across re-opens, so the marker survives every subsequent edit in the Stripo editor.
•Validator coverage — orbit_validate_braze_data now checks Braze's 26 standard user-profile fields alongside your custom attributes. Before this release, asking the validator to check `first_name` against a Braze workspace that had no custom `first_name` attribute would return missing — even though first_name is a standard Braze field every user profile carries. The 26 fields come straight from Braze's API docs: identity (external_id, email, phone), profile basics (first_name, last_name, gender, date_of_birth), localisation (country, home_city, language, time_zone), subscription/consent (email_subscribe, push_subscribe, email_open_tracking_disabled, email_click_tracking_disabled), session timing (last_used_app, first_used_app), and social (facebook, twitter). Real impact: validator stops false-flagging well-known profile fields, and the validation message now spells out which fields it covered so authors aren't second-guessing the result.
•Validator response shape — found_attributes now carries `{ name, type, liquid }` per attribute. The previous shape was a bare array of names, which left the assistant guessing whether to wrap a value in `{{custom_attribute.${...}}}` or just `{{${...}}}` when generating Liquid downstream. Now each found attribute carries its type (`"custom"` or `"standard"`) and the exact Liquid syntax to use — so compose tools, brand-guideline drafting, and any downstream Liquid generation can read the right syntax directly off the validator result instead of reconstructing it from first principles. missing_attributes stays as bare names — there's no metadata to attach to something the workspace doesn't have.
•Test-user lookup — orbit_validate_test_users no longer 400s on arrays of email addresses. Braze's `/users/export/ids` endpoint accepts an array of external_ids per request but only a single email_address per request — a quirk that wasn't documented anywhere and silently surfaced as `email_address must be a string` whenever you handed the tool more than one test-profile email. The tool now loops email lookups serially (sharing the global rate limiter), and the response surfaces every standard profile field's value alongside `populated_standard_fields` and `empty_standard_fields` arrays per profile. So verifying "is country actually populating on this test profile, and is push_token there?" is now one tool call, not a hand-rolled API trip and a JSON dive into the raw user object.
•Audit coverage — orbit_audit_braze_instance summary now surfaces the same 26 standard profile fields alongside the custom-attribute inventory. The audit's attribute inventory was previously custom-only, which meant a fresh Braze workspace with zero custom attributes appeared empty even though every profile still carries 26 standard fields. The summary now shows both inventories side by side, so the answer to 'what fields can I actually use in this workspace?' is the union of standard + custom, not just whatever's been added on top.