# Gap list and change requests (area 8)

The audit ends in two hand-over files: a gap list a colleague can act on without asking a question, and a change request for every P0 and P1 item. Nothing in either file is applied by the agent. Start from the header row of [../assets/gap-list-template.csv](../assets/gap-list-template.csv) and from [../assets/change-request-template.md](../assets/change-request-template.md); the filled Acme files show the finished form.

## Priorities

- P0, contain now: sending without permission (PC02, PC08); duplicate-profile consent leaks (PC09); rejection-level authentication failures and provider rejections on bulk streams (DL05, DL06, DL07, DL16); spam rate at or above 0.30%; wrong-recipient or wrong-account content.
- P1, next release cycle (about 30 days): every other FAIL; spam rate WARN (0.10% to below 0.30%); missing revenue-critical journeys (JC01 P1).
- P2, plan (about 90 days): WARNs that need an owner's decision; recommended journey gaps; measurement gaps.
- P3, hygiene: documentation, naming and ownership gaps with no customer risk.

P0 containment is a drafted pause of the specific journey, campaign or sync field. Never a deletion: deleting loses the evidence and the configuration needed to roll back. Tell the user about a P0 as soon as it is confirmed, with the evidence and the drafted containment for the owner to approve; do not hold it for the final report.

## Score

Within a priority, order by:

`score = reach per month x severity weight x confidence / effort`

- Reach per month: people or messages exposed to the defect in a month, from the inventory counts or the send log, with the source named.
- Severity weight: FAIL 3, WARN 1.
- Confidence: confirmed by a seed test or data 1.0; configuration-only 0.6; unconfirmed 0.3.
- Effort: S 1, M 2, L 4.

Show the score in the list. An owner may reorder within a priority with a written reason in the row's `override_reason`. Do not dress up a guess as precision: a score is an ordering aid, not a forecast, and a known harmful defect is contained at P0 whatever its score.

## Gap list columns

- `id`: G-01, G-02, in order.
- `priority`: P0 to P3. `area`: one of the eight areas. `rule_id`: one or more rule IDs separated by semicolons.
- `severity`: FAIL or WARN. `finding`: one sentence a colleague understands.
- `evidence`: the script line, configuration quote, header excerpt or dashboard reading with its date.
- `confidence`, `reach_per_month`, `effort`, `score`: as above.
- `owner`: a role. `proposed_change`: the CR ID and a short description. `test`: the seed or check that proves the fix.
- `status`: open, in progress, verified, accepted risk. `decision_needed`: the question and who answers it, or blank.
- `override_reason` (optional): the owner's written reason for moving a row out of score order, or the decisions.md exception that changes its priority. Blank otherwise.

Every FAIL and WARN appears once. Group findings that share one fix into one row with several rule IDs. INFO items (recommended journey types that are missing) stay in the report's coverage map and are not scored.

## Check the list before hand-over

```bash
python3 scripts/gap_list_check.py gaps.csv
```

- ERROR lines block hand-over: a blank finding, evidence or owner; a value outside the allowed lists; a score that does not match `reach x weight x confidence / effort`; a P0 or P1 row without a CR ID or a test; a FAIL below P1; a FAIL on PC02, PC08, PC09, DL05, DL06, DL07, DL10 or DL16 below P0 without an `override_reason`.
- NOTE lines need a look: rows out of priority or score order, a blank status, a WARN at P0, a cell that looks like an email address (the value is never printed).
- It prints the list in priority and score order. Exit status 1 means at least one ERROR; 2 means bad input, for example a file that is not a UTF-8 CSV (save Excel files as CSV UTF-8).
- The full fictional list for Acme passes with 0 ERROR and 0 NOTE: [../assets/example-acme-gap-list.csv](../assets/example-acme-gap-list.csv).

## Change request rules

- One hypothesis per request. Separate fixes get separate requests, so each can be tested and rolled back alone.
- Name the system, the object and its ID, and quote the current setting from the baseline.
- State the proposed change field by field, the evidence for it, and what could break.
- Name the numbers that will move and tell their owners before release: send volume falls when caps are enforced; attributed revenue falls when the holdout is excluded everywhere; unsubscribes rise when one-click starts working.
- Give the test with seed IDs and the evidence each step produces, the approval, and the records the test creates.
- Give the rollback and what rollback cannot undo: messages already sent, preferences already propagated to other senders, suppressions removed, Braze control group reshuffles, profile merges, DNS caches until the TTL expires.
- Name the owner who applies it and the approver.
- Writes through an MCP server or API go through agent-write-safety. This skill never applies a change.

## Worked change requests (fictional Acme Reports, baseline read 2026-10-14 09:00 UTC)

```markdown
### CR-1 Sign the newsletter with an aligned DKIM key that covers both one-click headers (G-01: DL04, DL05, DL07, DL09, DL16, P0)
- System and object: HubSpot, Acme account, marketing email sending domain example.com; streams
  "Acme Monthly newsletter" [hs-05], "Webinar invite series" [hs-06], "Trial expiry nurture" [hs-02].
- Current setting: received newsletter of 2026-10-12, DKIM-Signature d=example.net s=esp1
  h=from:to:subject:date:message-id:list-unsubscribe; receiver's Authentication-Results (mx.example.org):
  spf=pass smtp.mailfrom=example.net; dkim=pass header.d=example.net; dmarc=fail header.from=example.com.
  Postmaster Compliance status: two requirements Needs work (read 2026-10-14).
- Change: connect example.com as a sending domain in HubSpot (menu path: verify live). The platform team
  publishes the DKIM records HubSpot generates. Return-Path stays on the provider domain; alignment comes
  from DKIM d=example.com.
- Why: about 7,000 personal Gmail recipients on each send day make example.com a permanent Gmail bulk sender.
  dmarc=fail and an unsigned List-Unsubscribe-Post break the bulk requirements, so Gmail can reject the
  mail and will not offer one-click. Gmail has started: 310 deferrals with 421 4.7.32 (From not aligned)
  on the 2026-10-12 send, delivered on retry.
- Could break: if the records are published wrongly, DKIM fails until corrected. Nothing correct today depends
  on the shared signing domain.
- Numbers that will move: one-click unsubscribes will rise once Gmail shows the button; content lead told.
- Depends on: none. The visible body unsubscribe link (DL07 body WARN) is G-13, a separate P2 template change.
- Test (approved by lifecycle lead and privacy owner; HubSpot list "QA seeds"; seed-01 and seed-02):
  1. Test send to seed-01; save the raw source; check_message_headers.py shows DL05 PASS, DL04 PASS
     (d=example.com) and DL07 headers PASS.
  2. Seed S04 on seed-02: one-click from the mail client; seed-02 suppressed in HubSpot and Customer.io
     within 1 hour; the next scheduled send skips seed-02.
  3. Re-read Postmaster Compliance status 7 days after the first send, and count the first send's bounces
     and deferrals by provider and code, with no recipient addresses: no Gmail 4.7.32 or 5.7.32 replies (DL16).
- Records the test creates: two test sends; an unsubscribe on seed-02, reversed afterwards through the
  preference page by the platform team and logged as a customer action.
- Rollback: disconnect the domain in HubSpot and remove the DNS records.
- Rollback cannot undo: unsubscribes already processed, DNS caches until the TTL expires, Postmaster history.
- Applies: content lead with the platform team (DNS). Approver: email delivery owner.

### CR-2 Stop the nightly sync from writing marketing_opt_in until one writer owns consent (G-02: PC01, PC02, P0)
- System and object: reverse ETL job "nightly_crm_sync" (warehouse to HubSpot and Customer.io), field
  mapping marketing_opt_in to the HubSpot "Acme Monthly" subscription status and the Customer.io topic newsletter.
- Current setting: daily at 02:00 UTC, upsert with full values, source column marketing_opt_in from the
  product database.
- Change (containment): remove the marketing_opt_in mapping from the job. Do not pause or delete the job;
  trial_end_date and holdout_group keep syncing.
- Why: message_pressure.py found a stale write (p017: off 2026-10-09T09:15Z preference_centre, on
  2026-10-10T02:00Z warehouse_sync) and a newsletter sent 76.8 hours after the opt-out (PC02). Seed S11
  reproduced it in the test workspace.
- Could break: opt-ins captured at product signup stop reaching HubSpot and Customer.io until the authority
  is fixed. This fails safe: fewer sends, not unwanted ones.
- Numbers that will move: newsletter audience falls by the profiles the sync had resubscribed; newsletter
  growth from signups pauses. Content lead and demand gen lead told.
- Depends on: the privacy owner naming the authoritative writer (follow-up request).
- Test (approved by lifecycle lead and privacy owner; Customer.io "Acme Sandbox" and HubSpot "QA seeds";
  seed-03): opt seed-03 out of the newsletter on the preference page; after the next 02:00 UTC run, the
  opt-out stands in both senders and the next test send skips seed-03. Rerun message_pressure.py on the next
  7 days of logs: PC01 and PC02 PASS.
- Records the test creates: one preference change on seed-03.
- Rollback: restore the mapping from the saved job configuration.
- Rollback cannot undo: newsletters already sent to resubscribed people; states already written by the sync.
  Restoring the reversed opt-outs is a separate data repair that needs the privacy owner's approval.
- Applies: data engineering. Approver: privacy owner.
```

## Release evidence

The person who applies a change request closes it with three answers:

- Did the configuration change as written? (a quote of the new setting, a job diff, the DNS answer)
- Did runtime behaviour match? (the seed retest with timestamps)
- Did the destination confirm it? (send logs, the script rerun on new exports, Postmaster after up to 7 days)

A yes to the first question does not stand in for the other two.
