backbone-mailing 0.6.1

Email marketing — mass-mail engine: audiences, subscriptions, hand-set mailing lifecycle, seeded A/B testing, per-recipient traces, cycle-44 bridge targets (Odoo mass_mailing port)
# =============================================================================
# mailing — the campaign-grain A/B control (the mass_mailing A/B machinery,
#   re-homed MAILING-LOCAL per the chair design call). Upstream hosted these
#   fields on utm.campaign by inheritance; the port keeps engagement's
#   EngagementCampaign as the pure attribution master (no engagement release
#   for this feature) and puts the A/B machine's moving parts where they
#   live: fragments ARE Mailings, the winner IS a Mailing, and the sampling
#   seed guards THIS module's send engine.
#
# PORT DECISIONS:
#  - ONE live test per campaign: UNIQUE(campaign_id) among live rows fences
#    the cross-module consistency risk the re-homing creates (engagement
#    knows nothing about A/B; the invariant is enforced here).
#  - sampling_seed is minted ONCE at creation (random 128-bit, hex) and
#    NEVER regenerated: fragment membership is DETERMINISTIC —
#    member iff HMAC-SHA256(seed, recipient_email) mod 100 < ab_testing_pc
#    (each sibling fragments its own share keyed by its mailing id salt).
#    Stable across restarts, identical across concurrent workers, disjoint
#    across siblings — the upstream random.sample re-sampling class closes
#    by construction.
#  - completed is a FLAG driven by the promotion step (winner promotion
#    stamps winner_mailing_id + completed together), NOT a state machine;
#    promotion is idempotent (keyed on completed + winner_mailing_id).
#  - Upstream's DORMANT second cron (the daily A/B winner promotion job) is
#    NOT ported as its own cron: winner auto-promotion is an ordered,
#    idempotent step inside the ONE send sweep (schema/hooks/index.hook.yaml).
#  - CYCLE-44 BRIDGE METRICS (MVX-1/MVX-4): the winner-metric fields are
#    DECLARATIVE columns here (upstream monkey-patches selection values onto
#    utm.campaign per bridge addon; the port closes the enum once). Only
#    metrics backed by STORED numbers or a DECLARED seam rank: the trace
#    ratios (opened/clicks/replied) sort stored numbers, and the bridge's
#    invoiced-amount axis reads through the sale_invoiced_amount PORT
#    (billing-side, deny-by-default, zero billing Cargo edge — never a
#    cross-schema raw read). Upstream's crm_lead_count / sale_quotation_count
#    winner axes do NOT port: they are live sudo'ed grouped reads over the
#    bridge host's tables (MVX-1's sort-sudo'ed-recordset-by-field-NAME class)
#    and no stored mailing-side number backs them.
#  - THE PARALLEL SMS AXIS (MVX-2): winner_selection_sms is the typed twin of
#    upstream's ab_testing_sms_winner_selection — the SAME enum on a parallel
#    nullable column, no twin module. It declares the sms channel's ranking
#    axis for the mixed-channel compare action; an UNSET axis means the sms
#    variants rank by the mail axis's stored numbers. Honest limitation: sms
#    variants rank on whatever traces exist — the sms send walk is not
#    composed at the mail seam and PARKS loudly (see mailing_write_service),
#    so an sms variant typically carries zero traces and ranks last (None
#    ratio, never a fake 0%) until that walk lands.
# =============================================================================

models:

  - name: MailingAbTest
    collection: mailing_ab_tests
    description: "The campaign-grain A/B control — one live test per campaign (UNIQUE among live rows), carrying the persisted deterministic sampling seed, the winner-selection metric, and the promotion state. Fragment fields live on each sibling Mailing; the winner is promoted as a NEW Mailing at 100% citing the same campaign. Winner auto-promotion rides the single send cron as an idempotent step (upstream's dormant second cron does not port)."
    fields:
      id:
        type: uuid
        attributes: ["@id", "@default(uuid)"]
        description: "Unique record id"
      campaign_id:
        type: uuid
        attributes: ["@required", "@foreign_key(engagement.EngagementCampaign.id)", "@exclude_from_foreign_key_check", "@indexed"]
        description: "The campaign under test # logical FK engagement.EngagementCampaign.id — ONE live test per campaign (partial UNIQUE; the v19 collapse holds — engagement stays the attribution master and ships no A/B fields)."
      winner_selection:
        type: AbWinnerSelection
        attributes: ["@required", "@default(opened_ratio)"]
        description: "The metric the promotion step ranks done siblings by (manual defers to the operator verb). Trace ratios sort STORED numbers (MVX-1); the bridge's sale_invoiced_amount axis attributes by UTM SOURCE through the declared billing-side seam port (MVX-4 — never a cross-schema raw read)."
      winner_selection_sms:
        type: AbWinnerSelection?
        description: "The PARALLEL sms-channel ranking axis (MVX-2 — upstream's ab_testing_sms_winner_selection): the same enum on its own nullable column, no twin module. Declares how sms-type variants rank in the mixed-channel compare action; unset means the sms variants rank by the mail axis. Sms variants rank on whatever traces exist (the sms send walk parks loudly today — zero traces rank last as a None ratio, never a fake 0%)."
      promote_at:
        type: datetime?
        description: "Earliest auto-promotion time — the sweep's promotion step picks tests past this stamp with at least one done sibling."
      completed:
        type: boolean
        attributes: ["@default(false)"]
        description: "Terminal FLAG (not a machine): stamped together with winner_mailing_id by the promotion step/verb — promotion is idempotent (re-runs no-op once completed)."
      winner_mailing_id:
        type: uuid?
        attributes: ["@foreign_key(Mailing.winner_of)", "@exclude_from_foreign_key_check"]
        description: "The promoted winner # logical FK Mailing.id — the copy at ab_testing_pc=100 citing the same campaign. Setting it marks the test completed."
      sampling_seed:
        type: string
        attributes: ["@required", "@max(64)"]
        description: "Minted ONCE at creation (random 128-bit hex), never regenerated — the deterministic-fragment key: member iff HMAC-SHA256(seed, recipient_email) mod 100 < the sibling's ab_testing_pc. Identical fragments across runs and workers; disjoint across siblings."
      metadata:
        type: Metadata
        attributes: ["@audit_metadata"]
        description: "Audit metadata"

    indexes:
      - type: unique
        fields: [campaign_id]
        where: "deleted_at IS NULL"
        description: "One live A/B test per campaign — the mailing-side fence for the re-homed invariant (fires on raw SQL)"

# =============================================================================
# Enums (A/B)
# =============================================================================

enums:
  - name: AbWinnerSelection
    description: "The winner-selection metric — the promotion step ranks done siblings by this key (manual defers to the operator). A CLOSED enum declared once (MVX-1: no per-bridge selection_add); every ranking axis is either stored trace numbers or a declared seam"
    variants:
      - name: manual
        description: "An operator promotes explicitly (the promotion verb)"
      - name: opened_ratio
        description: "Highest opened ratio wins"
        default: true
      - name: clicks_ratio
        description: "Highest clicks ratio wins (computed from trace links_click_datetime)"
      - name: replied_ratio
        description: "Highest replied ratio wins"
      - name: sale_invoiced_amount
        description: "Highest invoiced total attributed by UTM SOURCE wins (the mass_mailing_sale bridge axis, MVX-4): each variant ranks by the invoiced amount its cited engagement source carries, read through the DECLARED billing-side seam port — deny-by-default, zero billing Cargo edge, never a cross-schema raw read. A variant citing no source ranks last; an uncomposed seam skips promotion loudly rather than promoting on a fake zero."