# OPS-008 audience source — provenance

File on production:
`/home/linder8/marketing.linderlake.com/sendgrid-ops/linderlake_audience_source.txt`

## Contents

49,302 lowercase-normalised email addresses, sorted, one per line, ending
with `\n`. This is **bucket A** from
`sendgrid-ops/audits/SENDGRID-OPS-007-HISTORICAL-AUDIENCE-RECONSTRUCTION.md`
§Phase 4 — the subset of the current OPS-005 UNKNOWN population that has
positive historical LINDERLAKE evidence in the 2026-08-27 13:34 SendGrid
backup and no historical BCS evidence.

## Integrity

```
SHA256 = e4325899c1055f2e777bc219b0817610492abeef9985da29711878167ee467cf
MD5    = c8da4ba66d35a80473b18833e2672b70
Bytes  = 1,226,959
Lines  = 49,302
```

Canonical sort: `LC_ALL=C sort -u` (POSIX byte order). The generator
uses this internally; any other sort order is a regeneration bug.

If the file on production does not match these hashes, do **not** run
`2-create_dynamic_sendgrid_lists.sh`. Re-derive the file via the
generator (see below) and re-check.

## Provenance

- Backup source:
  `/home/linder8/sendgrid_backups/20260827_133428/`
  - `sendgrid/contacts_export.csv` — 77,301 unique emails (SendGrid
    contact export at 2026-08-27 13:34 America/Los_Angeles).
  - `mysql/contact_list_memberships.tsv` — 35,312 rows (post-backup snapshot
    of the MySQL memberships table).
  - `mysql/contacts.tsv` — 101,194 rows (post-backup snapshot of the
    MySQL contacts table — used only to translate `contact_id` back to
    `email` for join purposes).
- Backup was written by `93-fullbackup_sendgrid.sh`
  (`backup_runs` id = 5, finished 2026-08-27 13:39:20).
- Current MySQL state is required only to define "UNKNOWN" — contacts
  present today with zero rows in `contact_list_memberships`. This makes
  the file **specific to the current MySQL state**; if `contacts` or
  `contact_list_memberships` change materially, regenerate.

## Bucket-A rule (verbatim from OPS-007 §Phase 4)

```
A = HISTORICAL_LINDERLAKE_CONFIRMED
  = current_UNKNOWN
      ∩ ( historical_LL_membership_aug27 ∪ historical_SG_export_aug27 )
      − historical_BCS_membership_aug27
```

Priority is enforced by set difference: an email with historical BCS
membership is excluded from bucket A even if it also has historical LL
evidence. That case is bucket D (CONFLICTING) and is intentionally kept
out of A.

The file **only** contains bucket A. The production script
`2-create_dynamic_sendgrid_lists.sh` reads this file and further filters
by:

- current-DB BCS membership (added since Aug 27) → excluded
- current-DB 5-excluded-list membership → excluded
- current SendGrid suppressions (asm, bounce, block, spam, invalid) →
  excluded
- email REGEXP → excluded if invalid
- DISTINCT LOWER(TRIM(email)) → dedup

So the file is a **positive-evidence allow-set for the recovery
audience**; the *disallow* logic remains inside the SQL and stays
current with production.

## Regenerator

```
sendgrid-ops/generators/generate_linderlake_audience_source.py
```

The generator reads the Aug 27 backup + current MySQL and re-emits the
same file. It is READ-ONLY (no writes to MySQL or SendGrid; only
writes the destination file). SHA256 must match the value above if
inputs are unchanged.

To regenerate on production:

```
cd ~/marketing.linderlake.com
source ./0-config_var_setup.sh
python3 sendgrid-ops/generators/generate_linderlake_audience_source.py \
    sendgrid-ops/linderlake_audience_source.txt
sha256sum sendgrid-ops/linderlake_audience_source.txt
```

Under no circumstances edit the file by hand.

## When to regenerate

- After any full `1-sync_sendgrid_mysql.sh` run (memberships change → the
  current-UNKNOWN input shifts).
- After any bulk import that changes the `contacts` row-set (e.g. the
  2026-09-02 event described in OPS-007 §Phase 1).
- Never immediately before a scheduled send unless you also re-run the
  read-only audience count.
