{
  "_generated": "scripts/generate-workflow-catalog.py \u2014 do not hand-edit",
  "scheme": {
    "convention": "3-digit category-first: first digit = the API/system the workflow targets",
    "categories": {
      "1": "Cloudflare / DNS / Domain",
      "2": "WHMCS",
      "3": "Microsoft (M365 / Azure / Graph)",
      "4": "Zeffy",
      "5": "Google",
      "6": "WPMUDEV",
      "7": "GitHub (Website + Repo)",
      "8": "Candid (GuideStar)",
      "9": "Reserved"
    },
    "tagRule": "the [TAG] lists ALL APIs the workflow CALLS (not services configured-for, not plumbing like KV auth or issue comments)"
  },
  "workflows": [
    {
      "number": 101,
      "title": "Domain - Status (All Sources)",
      "apis": [
        "CF",
        "M365"
      ],
      "file": "101-domain-status.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "m365-prod"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "cloudflare-prod-read / \u2705 m365-prod",
      "guard": "M365 job reads FFC-tenant status; waits on m365-prod approval",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 102,
      "title": "Domain - Add to FFC Cloudflare + WHMCS Nameservers (Admin)",
      "apis": [
        "CF",
        "WHMCS"
      ],
      "file": "102-domain-add-ffc-cloudflare-and-whmcs.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "cloudflare-prod-write",
        "whmcs-prod"
      ],
      "description": "Serialize runs: a second dispatch queues behind the first instead of racing it.",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 cloudflare-prod-write / \u2705 whmcs-prod",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 103,
      "title": "Domain - Enforce Standard (GitHub Apex + M365)",
      "apis": [
        "CF",
        "M365"
      ],
      "file": "103-enforce-domain-standard.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write",
        "m365-prod"
      ],
      "description": "HYBRID - Cloudflare DNS + FFC-tenant M365. The cloudflare_enforce job writes tenant-agnostic DNS (apex/www, MX/SPF/DMARC) and is safe for any domain. The exo_check/exo_enable jobs act on the FFC Microsoft tenant's Exchange Online (DKIM create/enable) - for a charity running their OWN Microsoft tenant, dispatch with skip_m365=true so only DNS is touched. See docs/m365-domain-and-dkim.md (internal vs external).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 cloudflare-prod-write / \u2705 m365-prod",
      "guard": "`dry_run` (default true); exo jobs act on FFC tenant \u2014 `skip_m365=true` for external-tenant charities",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 104,
      "title": "Domain - Export Inventory (All Sources)",
      "apis": [
        "CF",
        "M365",
        "WHMCS",
        "WPMUDEV"
      ],
      "file": "104-domain-export-inventory.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "m365-prod",
        "whmcs-prod-read",
        "wpmudev-prod"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read / \u2705 m365-prod / \u2705 wpmudev-prod (+ cf-read)",
      "guard": "waits on m365-prod and wpmudev-prod approvals (WHMCS job ungated)",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 105,
      "title": "DNS - Manage Record (Manual / Issue Label)",
      "apis": [
        "CF"
      ],
      "file": "105-manage-record.yml",
      "triggers": [
        "issues",
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "cloudflare-prod-write"
      ],
      "description": "Serialize runs: a second dispatch queues behind the first instead of racing it.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "`dry_run` (default true); issue-label trig",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 106,
      "title": "DNS - Enforce Standard (DNS-only)",
      "apis": [
        "CF"
      ],
      "file": "106-enforce-standard.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write"
      ],
      "description": "Serialize runs: a second dispatch queues behind the first instead of racing it.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "`dry_run` (default true)",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 107,
      "title": "DNS - Audit Compliance (Report)",
      "apis": [
        "CF"
      ],
      "file": "107-audit-compliance.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "cloudflare-prod-read",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 108,
      "title": "DNS - Export Cloudflare Zones (Report)",
      "apis": [
        "CF"
      ],
      "file": "108-export-summary.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "cloudflare-prod-read",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 109,
      "title": "DNS - Export All Records (Full, per-record)",
      "apis": [
        "CF"
      ],
      "file": "109-dns-export-all-records.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read"
      ],
      "description": "Read-only full per-record export for a single zone. Unlike \"08. DNS - Export Cloudflare Zones\" (a per-zone apex/www SUMMARY), this dumps EVERY record (id, type, name, content, proxied, ttl, priority) to a CSV artifact via Update-CloudflareDns.ps1 -ExportAll. Uses the same read-only KV-OIDC token path as the audit workflow.",
      "safetyLevel": "Reads",
      "approvalEnv": "cloudflare-prod-read",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 110,
      "title": "DNS - Create Zone (Admin)",
      "apis": [
        "CF"
      ],
      "file": "110-cloudflare-zone-create.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write"
      ],
      "description": "Serialize runs: a second dispatch queues behind the first instead of racing it.",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 111,
      "title": "DNS - Create Redirect Rule (Admin)",
      "apis": [
        "CF"
      ],
      "file": "111-dns-create-redirect-rule.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "cloudflare-prod-write"
      ],
      "description": "Serialize runs: a second dispatch queues behind the first instead of racing it.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "`dry_run` (default true)",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 112,
      "title": "DNS - Bulk Replace A-record IP (All Zones)",
      "apis": [
        "CF"
      ],
      "file": "112-dns-bulk-replace-a-ip.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write"
      ],
      "description": "Serialize: this rewrites A records across many zones; parallel dispatches must not race.",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "high blast radius; serialized",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 113,
      "title": "Domain - Registrar Search / Check / Register (Admin, DRAFT)",
      "apis": [
        "CF"
      ],
      "file": "113-cloudflare-domain-register.yml",
      "triggers": [
        "issues",
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write"
      ],
      "description": "DRAFT: Wraps the Cloudflare Registrar API (beta) via scripts/cloudflare-domain-search.ps1     (suggest available names; read-only) scripts/cloudflare-domain-register.ps1   (availability check + register)  Entry points: * workflow_dispatch  -> pick a mode: search | check | dry-run-register | execute-register * issues: labeled    -> applying 'domain-purchase-approved' to a purchase issue runs a CHECK ONLY (availability + price) and comments the result back. A label NEVER purchases; buying requires an explicit workflow_dispatch with mode=execute-register + confirm_domain.  Money safety: only mode=execute-register (dispatch) spends money, and it requires confirm_domain to exactly match domain. See docs/cloudflare-domain-registration.md.",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "`mode` (default `check`); register needs `mode=execute-register` + `confirm_domain`",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 114,
      "title": "Domain - Validate Cloudflare Registrar API Access (Read-only)",
      "apis": [
        "CF"
      ],
      "file": "114-cloudflare-registrar-access-check.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write"
      ],
      "description": "READ-ONLY: probes whether the selected Cloudflare token has Registrar API rights (read + write). Never registers or changes anything. Wraps scripts/cloudflare-registrar-access-check.ps1.",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "never charges",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 115,
      "title": "Domain - Transfer Readiness Preflight (Report)",
      "apis": [
        "WHMCS"
      ],
      "file": "115-domain-transfer-preflight.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "READ-ONLY: exports WHMCS domains, then runs scripts/domain-transfer-preflight.ps1 offline to classify each domain's readiness to transfer to Cloudflare Registrar (eNOM -> Cloudflare; project #157). Initiates no transfer and spends no money.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 116,
      "title": "Domain - Transfer EPP/Auth Code Probe (Admin)",
      "apis": [
        "WHMCS"
      ],
      "file": "116-domain-transfer-epp-probe.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Probes whether a domain's EPP/auth (transfer) code is returned INLINE by the WHMCS API (copy-pasteable) or only EMAILED to the registrant. Wraps scripts/domain-transfer-epp-probe.ps1.  Safety: mode=dry-run performs read-only WHMCS calls (to locate the domain) and has no side effects. mode=execute calls DomainRequestEPP, which may trigger the registrar to email the registrant. The literal code is NOT printed unless show_code is true.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry-run` vs `execute`",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 117,
      "title": "Domain - Post-Transfer Verification (Report)",
      "apis": [
        "CF"
      ],
      "file": "117-domain-transfer-verify.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read"
      ],
      "description": "READ-ONLY: confirms a domain transferred to Cloudflare Registrar landed cleanly (registrar = Cloudflare, nameservers on Cloudflare, site still reachable). Wraps scripts/domain-transfer-verify.ps1. Changes nothing.",
      "safetyLevel": "Reads",
      "approvalEnv": "cloudflare-prod-read",
      "guard": "\u2014",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 118,
      "title": "Domain - Registrar Lock / Unlock",
      "apis": [
        "WHMCS"
      ],
      "file": "118-whmcs-domain-lock.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Reads or changes a domain's registrar lock (clientTransferProhibited) in WHMCS, ahead of an eNOM -> Cloudflare transfer (project #157). dry_run only reads the current state and changes nothing.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry_run` (default true)",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 119,
      "title": "DNS - Bulk Staging CNAME -> GitHub Pages (FFC-EX)",
      "apis": [
        "CF"
      ],
      "file": "119-bulk-staging-cname-github-pages.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write"
      ],
      "description": "Serialize: this writes CNAMEs across many FFC-EX zones; parallel dispatches must not race.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 cloudflare-prod-write",
      "guard": "`dry_run` (default true); serialized",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 120,
      "title": "DNS + GH Pages - Bulk Cutover staging -> Apex (FFC-EX)",
      "apis": [
        "CF",
        "GH"
      ],
      "file": "120-bulk-cutover-to-github-pages.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write",
        "github-prod"
      ],
      "description": "Serialize cutover runs: parallel dispatches would race on the same FFC-EX CNAMEs / Cloudflare DNS.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 cloudflare-prod-write / \u2705 github-prod",
      "guard": "`dry_run` (default true); serialized",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 121,
      "title": "DNS + GH Pages - Fleet Cutover Preflight (FFC-EX)",
      "apis": [],
      "file": "121-fleet-cutover-preflight.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Read-only go/no-go preflight for the FFC-EX DNS cutover \u2014 the checked companion to workflow 120 (bulk cutover staging -> apex). For each domain it confirms the GitHub Pages origin is healthy, reads live apex/www DNS over DoH, verifies CAA won't block Let's Encrypt issuance, and probes HTTPS on the target domain, then prints one verdict table (also written to the job summary). It changes nothing and loads no credentials, so it needs no environment approval gate. Run it before 120 to confirm readiness and after 120 to confirm the cutover landed.",
      "safetyLevel": "Reads",
      "approvalEnv": "none (no credentials)",
      "guard": "read-only DoH/HTTPS probes; go/no-go for 120",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 122,
      "title": "Cloudflare - Zone Member Add (Domain Admin)",
      "apis": [
        "CF"
      ],
      "file": "122-cloudflare-zone-member-add.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "cloudflare-prod-write"
      ],
      "description": "Invite an email address as a Cloudflare account member whose access is scoped to a single zone (IAM member policies: permission group + zone resource group). Used to hand a charity's contact domain-admin rights over just their own domain, e.g. so they can run their Microsoft 365 DNS setup themselves. Dry-run by default; idempotent (an existing member is reported, never changed).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "cloudflare-prod-read / \u2705 cloudflare-prod-write",
      "guard": "`dry_run` (default true); preview job always runs read-only; idempotent (existing member = no change)",
      "category": "Cloudflare / DNS / Domain",
      "categoryCode": "CF"
    },
    {
      "number": 201,
      "title": "WHMCS - Export Domains (Report)",
      "apis": [
        "WHMCS"
      ],
      "file": "201-whmcs-export-domains.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "gated only by the env approval",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 202,
      "title": "WHMCS - Export Products (Report)",
      "apis": [
        "WHMCS"
      ],
      "file": "202-whmcs-export-products.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "gated only by the env approval",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 203,
      "title": "WHMCS - Export Payment Methods (Research)",
      "apis": [
        "WHMCS"
      ],
      "file": "203-whmcs-export-payment-methods.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "gated only by the env approval",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 204,
      "title": "WHMCS - Charity Onboard (client + contacts + order)",
      "apis": [
        "WHMCS"
      ],
      "file": "204-whmcs-charity-onboard.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Creates a WHMCS client, attaches Contacts, and places the onboarding order (pre-501c3 / 501c3) from an intake JSON. Defaults to dry-run (no writes).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry_run` (default true); idempotent",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 205,
      "title": "WHMCS - Open Ticket (manual)",
      "apis": [
        "WHMCS"
      ],
      "file": "205-whmcs-ticket-open.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Manually open a WHMCS support ticket (new request or break/fix logging). Defaults to dry-run (no ticket created).",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "one-way GitHub\u2192WHMCS",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 206,
      "title": "WHMCS - Issue to Ticket (one-way)",
      "apis": [
        "WHMCS"
      ],
      "file": "206-whmcs-issue-to-ticket.yml",
      "triggers": [
        "issues"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "When a charity issue is labeled whmcs:new-request or whmcs:break-fix, open a WHMCS support ticket from the issue form fields and comment the ticket number back to the issue. One-way (GitHub issue -> WHMCS ticket).",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "one-way GitHub\u2192WHMCS",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 207,
      "title": "WHMCS - Ticket Respond (templated)",
      "apis": [
        "WHMCS"
      ],
      "file": "207-whmcs-ticket-respond.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Post a templated reply or internal note to one WHMCS ticket. Dry-run by default: a live (dry_run=false) client-visible reply requires a real WHMCS admin username and is intentionally human-gated.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry_run` (default true); live reply needs admin username",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 208,
      "title": "WHMCS - Export Tickets (Report)",
      "apis": [
        "WHMCS"
      ],
      "file": "208-whmcs-tickets-export.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "\u2014",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 209,
      "title": "WHMCS - Tickets Triage (Open/Customer-Reply)",
      "apis": [
        "WHMCS"
      ],
      "file": "209-whmcs-tickets-triage.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Read-only. Surfaces WHMCS tickets that need attention (default Open + Customer-Reply) into the job summary + a CSV artifact, and can optionally upsert ONE rolling tracking issue. No ticket writes are performed.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "summary masks PII",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 210,
      "title": "WHMCS - Orders Triage (Pending/Fraud/Active)",
      "apis": [
        "WHMCS"
      ],
      "file": "210-whmcs-orders-triage.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Read-only. Summarizes WHMCS orders by status into the job summary + CSV artifacts, lists actionable Pending orders, and can optionally upsert ONE rolling tracking issue. No order writes are performed.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "summary masks PII",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 211,
      "title": "WHMCS - Order Update (accept/cancel/fraud)",
      "apis": [
        "WHMCS"
      ],
      "file": "211-whmcs-order-update.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Change one WHMCS order's state. Dry-run by default. There is intentionally NO bulk / automatic accept-or-cancel: each live state change is a single explicit human dispatch (dry_run=false).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry_run` (default true); one order at a time",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 212,
      "title": "WHMCS - Product Add (catalog)",
      "apis": [
        "WHMCS"
      ],
      "file": "212-whmcs-product-add.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Create a catalog (status-marker) product. Dry-run by default. A live (dry_run=false) create requires the target product group id (gid) and the API credential's AddProduct permission (see whmcs-product-capability-check.ps1).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry_run` (default true); idempotent",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 213,
      "title": "WHMCS -> Zeffy Payments Import (Draft)",
      "apis": [
        "WHMCS"
      ],
      "file": "213-whmcs-zeffy-payments-import-draft.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Serialize runs so two dispatches can't race on the artifacts/zeffy/* outputs.",
      "safetyLevel": "Reads (builds a file)",
      "approvalEnv": "whmcs-prod-read",
      "guard": "output is a draft; serialized",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 214,
      "title": "WHMCS - Clients Metrics (aggregate, no PII)",
      "apis": [
        "WHMCS"
      ],
      "file": "214-whmcs-clients-metrics.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Per-year WHMCS client-membership aggregates (new signups, cumulative members, currently-active cumulative) for the Candid Platinum \"active members\" metric series and the freeforcharity.org impact metrics. Reads GetClients but tallies only {datecreated, status} \u2014 the artifact and step summary contain aggregate integer counts only (no names, emails, or client ids). Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "aggregate counts only \u2014 no PII in artifact or summary",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 215,
      "title": "WHMCS - Nonprofit Clients Metrics (service evidence, no PII)",
      "apis": [
        "WHMCS"
      ],
      "file": "215-whmcs-nonprofit-clients-metrics.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Revalidation of the client-count metrics: classifies clients by the products/services they hold (each service has its own registration date and status), instead of the historyless client status flag \u2014 and surfaces the product catalog so \"which clients are nonprofits\" is decided from data, not assumption. Output is aggregate counts + product/group names only (no client names, emails, ids, or domains). Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "aggregate counts + product names only (no PII)",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 216,
      "title": "WHMCS - Activity Metrics (full history, no PII)",
      "apis": [
        "WHMCS"
      ],
      "file": "216-whmcs-activity-metrics.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Phase 1 of docs/whmcs-data-quality-and-metrics-plan.md: per-year ACTIVE nonprofit members from dated evidence (invoices, orders, service registrations) spanning the full 10+ years \u2014 the series status flags cannot provide. Aggregate integer counts only. Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "aggregate counts only (no PII)",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 217,
      "title": "WHMCS - Client Fields Survey (aggregate, no PII)",
      "apis": [
        "WHMCS"
      ],
      "file": "217-whmcs-client-fields-survey.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Phase 1 of docs/whmcs-data-quality-and-metrics-plan.md: throttled per-client GetClientsDetails loop measuring how the three classification signals line up \u2014 the Legal Organization Status custom field, the native client group, and charity-product evidence. Only exact legal-status vocabulary matches are counted; free-text values are never printed. Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "throttled per-client loop; vocabulary-matched values only (no PII)",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 218,
      "title": "WHMCS - Sites-List Reconciliation + Product Alignment",
      "apis": [
        "WHMCS"
      ],
      "file": "218-whmcs-siteslist-reconciliation.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Phase 1 of docs/whmcs-data-quality-and-metrics-plan.md: joins the sites-list (WHMCS + Cloudflare + WPMUDEV + health) with live WHMCS domains/services to measure (a) the coverage gap (legacy-WP orgs with no WHMCS record) and (b) the product-alignment gap \u2014 Cloudflare domains whose client lacks the 'Domain Registered in Cloudflare' product (pid 39) and GitHub Pages sites whose client lacks 'Hosted by GitHub Pages' (pid 40). Summary is aggregate counts; the artifact carries the gap domain lists (org domains, public project data) as the Phase 2 backfill work-list. Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "aggregate summary; artifact carries org-domain gap lists only",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 219,
      "title": "WHMCS - Application Detail (Client + Orders, PII-masked)",
      "apis": [
        "WHMCS"
      ],
      "file": "219-whmcs-application-detail.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Read-only. Fetches ONE client's charity-application detail (GetClientsDetails + GetOrders) and posts the website-publishable projection (organization name, custom fields, orders/products; personal contact data masked) to the job summary and optionally back to a GitHub issue. Built so an agent/operator can read a specific application without downloading artifacts. No writes.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "single-client projection; personal contact fields always masked",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 220,
      "title": "WHMCS - Served-Per-Year Metrics (span evidence, no PII)",
      "apis": [
        "WHMCS"
      ],
      "file": "220-whmcs-served-metrics.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "\"216 v2\": FFC's services are free, so billing events undercount service delivery. This counts a member as SERVED in year Y when a charity service or registered domain was IN FORCE during Y (span from regdate to next-due/expiry; Active runs through today), emits org-level (domain-keyed) counts with current sites-list-only orgs merged in, keeps the old billing-active series for comparison, and shows invoice $0-vs-paid composition per year. Aggregate counts only. Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "aggregate counts only (no PII)",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 221,
      "title": "WHMCS - Application Search (find by domain/org)",
      "apis": [
        "WHMCS"
      ],
      "file": "221-whmcs-application-search.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Read-only. Sweeps GetClientsProducts (all clients) and returns the onboarding application(s) whose product custom fields contain the query substring (a domain or organization name), with personal contact fields masked. This is the domain/org -> client-id lookup that 219 (which needs a client id) and the masked triage tables cannot do. Posts the matches to the job summary and optionally back to a GitHub issue. No writes.  NOTE: uses the gated `whmcs-prod` so it runs today with a single approval; once `whmcs-prod-read` is set up it can move there (read-only workflow).",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "GetClientsProducts sweep; personal contact fields masked; gated until whmcs-prod-read setup",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 222,
      "title": "WHMCS - Cloudflare Registrar Product Alignment",
      "apis": [
        "WHMCS",
        "CF"
      ],
      "file": "222-whmcs-product-alignment.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "whmcs-prod"
      ],
      "description": "Aligns a WHMCS status-marker product (default pid 39 \"Domain Registered in Cloudflare\") onto the clients who own each domain in the FFC Cloudflare Registrar account. Read-only preview by default; a live run gates on whmcs-prod.  Two jobs so each uses the identity/scope it is entitled to: 1. resolve-domains  - cloudflare-prod-read (kv-reader) pulls the authoritative registrar-domain list from the Cloudflare API. 2. align            - whmcs-prod (kv-writer) resolves each domain to its WHMCS client and, unless dry_run, places a $0 AddOrder for any client that does not already hold the product. Idempotent.  WHMCS API calls route through the APIM gateway (its IP is allow-listed at WHMCS).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "dry_run default true; $0 AddOrder of pid 39 onto registrar-domain clients; idempotent",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 223,
      "title": "WHMCS - Import Cloudflare Registrar Domains as Domain Records",
      "apis": [
        "WHMCS",
        "CF"
      ],
      "file": "223-whmcs-import-cloudflare-domains.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Creates a real WHMCS domain record for each Cloudflare-registered domain in the supplied domain->client map, populated with live registrar facts (expiry, nameservers, status) from the Cloudflare Registrar API. Cloudflare has no WHMCS registrar module, so the record's registrar is left None; every other field is accurate and the domain shows in the client's Domains list. Idempotent: already-populated domains are skipped; partial records are completed. Optionally accepts the client's pending onboarding order.  The domain->client map is a dispatch input (not committed) so client ids are not stored in this public repo. WHMCS calls route through the APIM gateway.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "dry_run default true; creates/populates domain records (registrar None) from CF facts",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 224,
      "title": "WHMCS - GitHub Pages Product Alignment",
      "apis": [
        "WHMCS",
        "GH"
      ],
      "file": "224-whmcs-github-pages-product-alignment.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Aligns the pid-40 \"Hosted by GitHub Pages\" marker onto the WHMCS clients whose sites are hosted on GitHub Pages (one per FreeForCharity/FFC-EX-<domain> repo). Sibling of 222 (Cloudflare registrar -> pid 39): same alignment engine, different source list. dry-run is a drift report (#580); a live run assigns the product (#579). Read-only preview by default; a live run gates on whmcs-prod.  1. list-domains - GitHub API (ambient token) lists the FFC-EX domains. 2. align        - whmcs-prod resolves each domain to its client and, unless dry_run, places a $0 AddOrder for any client lacking pid 40. Idempotent per (client, domain). Domains with no WHMCS record are reported, never guessed. Routes via the APIM gateway.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "dry_run default true; $0 AddOrder of pid 40 onto FFC-EX GitHub-Pages-site clients; idempotent",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 225,
      "title": "WHMCS - Domain Order URL Verify",
      "apis": [
        "WHMCS"
      ],
      "file": "225-whmcs-domain-order-url-verify.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod-read"
      ],
      "description": "Read-only liveness check for pending domain orders (pids 39 register / 41 transfer). Pulls the \"Live GitHub Pages URL of your validated website\" custom field (ids 171/173) from each pending order's service and verifies the URL is live: host must be *.github.io and HTTP GET must return 200 (PASS/FAIL). A footer-marker regex is also matched against the body as a WARN-only heuristic (cut-over sites don't carry the literal footer text). The Gate-3 checklist and the WHMCS attestation field (id 172) are not machine-read yet (future work). Emits a per-order table to the job summary + a CSV artifact. No order writes are performed (never accepts/cancels; see issue #681).",
      "safetyLevel": "Reads",
      "approvalEnv": "whmcs-prod-read",
      "guard": "report-only; GETs charity-supplied URLs; never accepts/cancels an order",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 226,
      "title": "WHMCS - Application Triage (rank + accept onboarding orders)",
      "apis": [
        "WHMCS"
      ],
      "file": "226-whmcs-application-triage.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Ranks PENDING charity onboarding applications by the mission-weighted FFC READINESS score (the operator's definition of \"best\"), separately for pre-501(c)(3) (pid 16) and full 501(c)(3) (pid 33). It can ACCEPT specific $0 onboarding orders (approve) so the charity gets the acceptance email, and can RECONCILE duplicate / miscategorized orders (reconcile) by cancelling only the explicitly-listed orders and emailing those applicants a paste-ready re-file email. Modes 'report' and 'reconcile-report' are READ-ONLY. The mutating modes 'approve' and 'reconcile' each require an explicit comma-separated orderids list and act ONLY on those ids (no accept-all / cancel-all). Bound to the whmcs-prod environment, so every run pauses at the approval gate (the safety checkpoint); a read-only run pausing there is expected and fine.",
      "safetyLevel": "Writes (report default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "report + reconcile-report read-only; approve accepts ONLY listed $0 orderids; reconcile cancels ONLY listed orderids + emails those clients a re-file email (email-first, no cancel-all)",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 227,
      "title": "cPanel - Deploy WHMCS Hooks (FTPS/lftp)",
      "apis": [
        "cPanel"
      ],
      "file": "227-whmcs-hooks-deploy.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Deploys the version-controlled WHMCS hooks in whmcs/hooks/*.php to the production cPanel host, replacing the manual-FTPS runbook. It uses the SAME proven transport as the freeforcharity.org website deploy (deploy-cpanel.yml): GH OIDC -> Azure -> Key Vault (kv-ffc-admin-prod-cbm) -> MAIN cPanel FTP creds -> lftp over FTPS (AUTH TLS). The MAIN account is homed at the account root, so it can write into ~/public_html/hub/includes/hooks/. Creds are piped to lftp via stdin (never argv).  Target:   public_html/hub/includes/hooks/<file>.php  (relative to the cPanel home) KV creds: wr-all-cbm-cpanel-ffc-interserver-ftp-{host,port,user,password}  mode=verify (default) is READ-ONLY: it connects (proving auth+connectivity), downloads the deployed hooks, and reports whether each matches the repo (or is not deployed yet). mode=deploy uploads each hook first, then downloads + compares and fails on any mismatch.",
      "safetyLevel": "Writes (verify default)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "mode=verify (read-only) vs deploy; hooks input is basename-validated",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 228,
      "title": "WHMCS - Fraud Review (FraudLabs Pro)",
      "apis": [
        "FRAUDLABS",
        "WHMCS"
      ],
      "file": "228-whmcs-fraud-review.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "fraudlabspro-prod-read",
        "whmcs-prod-read"
      ],
      "description": "Read-only against external systems: for each WHMCS Fraud-status order, reads its stored FraudLabs Pro verdict and emits a triage row with a RECOMMENDED action (clear-recommended / hold-for-human / review-manually). It performs NO WHMCS/order writes \u2014 clearing a false positive is a separate, gated action via workflow 211. The scheduled run additionally writes a rolling GitHub tracking issue (issues:write, ambient token) listing the current Fraud queue, self-closing when it clears (the tracking_issue job below). See issue #813.  Scaffolding is inert until provisioned (docs/fraudlabspro-api.md): the FraudLabs Pro key must be loaded into Key Vault as 'read-all-ffc-fraudlabspro-api-key' and the 'fraudlabspro-prod-read' environment + federated credential created. Until then the review job fails fast with a clear message, exactly like the Candid scaffolding (801/802).",
      "safetyLevel": "Writes (issues only)",
      "approvalEnv": "whmcs-prod-read + fraudlabspro-prod-read",
      "guard": "read-only external APIs; masks PII; recommends only (never clears \u2014 clearing is via gated 211); scaffold inert until the FraudLabs Pro key + fraudlabspro-prod-read env are provisioned; daily weekday schedule upserts one rolling GitHub tracking issue (issues:write, ambient token) that self-closes when the Fraud queue clears",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 230,
      "title": "WHMCS - Record Field Set (client/contact/service/domain)",
      "apis": [
        "WHMCS"
      ],
      "file": "230-whmcs-record-field-set.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "whmcs-prod"
      ],
      "description": "Set ONE field on ONE existing WHMCS record. Supersedes 229 (service domain set), which was the same operation for a single field on a single target.  This is one dispatch surface that can write to four record types, so the per-target ALLOWLIST in scripts/whmcs-record-field-set.ps1 is the safety mechanism: a field not on the list cannot be written, and adding one is a reviewed code change rather than a dispatch input. Dry-run by default; no bulk loop.",
      "safetyLevel": "Writes (record field)",
      "approvalEnv": "\u2705 whmcs-prod",
      "guard": "`dry_run` (default true); one record + one field per dispatch; strict per-target allowlist of writable fields; refuses to replace a different existing value without `force`; reports `previousValue`",
      "category": "WHMCS",
      "categoryCode": "WHMCS"
    },
    {
      "number": 301,
      "title": "M365 (FFC Tenant) - Domain Preflight (Read-only)",
      "apis": [
        "M365",
        "CF"
      ],
      "file": "301-m365-domain-preflight.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-read",
        "m365-prod"
      ],
      "description": "INTERNAL (FFC Microsoft tenant): reads domain status from the FFC M365 tenant via Graph. For charities running their OWN Microsoft tenant this preflight does not apply - their tenant, their admin center. See docs/m365-domain-and-dkim.md (internal vs external).",
      "safetyLevel": "Reads",
      "approvalEnv": "cloudflare-prod-read / \u2705 m365-prod",
      "guard": "M365 job waits on m365-prod approval",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 302,
      "title": "M365 (FFC Tenant) - List Tenant Domains",
      "apis": [
        "M365"
      ],
      "file": "302-m365-list-domains.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "m365-prod"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 m365-prod",
      "guard": "waits on m365-prod approval",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 303,
      "title": "M365 (FFC Tenant) - Domain Status + DKIM (Toolbox)",
      "apis": [
        "M365"
      ],
      "file": "303-m365-domain-and-dkim.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "m365-prod"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 m365-prod",
      "guard": "read-oriented toolbox; waits on m365-prod approval",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 304,
      "title": "M365 (FFC Tenant) - Enable DKIM (Exchange Online)",
      "apis": [
        "M365",
        "CF"
      ],
      "file": "304-m365-dkim-enable.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "cloudflare-prod-write",
        "m365-prod"
      ],
      "description": "INTERNAL (FFC Microsoft tenant): enables DKIM in FFC's Exchange Online and writes the selector CNAMEs to Cloudflare. Only valid for domains that live in the FFC tenant. A charity with their own Microsoft tenant enables DKIM in their own admin center; FFC only carries the DNS records (or the charity manages DNS themselves after a workflow-122 domain-admin invite).",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 cloudflare-prod-write / \u2705 m365-prod",
      "guard": "\u2014",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 305,
      "title": "M365 (FFC Tenant) - Add Tenant Domain (INTERNAL ONLY)",
      "apis": [
        "M365"
      ],
      "file": "305-m365-add-tenant-domain.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "m365-prod"
      ],
      "description": "INTERNAL ONLY: adds the domain to the FFC Microsoft 365 tenant. NEVER run this for a charity that has (or will have) their own Microsoft tenant - a domain can only be verified in ONE tenant at a time, so adding it here blocks the charity from adding it to theirs. External charities add the domain in their own admin.microsoft.com first; FFC's role is DNS records only (workflow 105/103) or a Cloudflare domain-admin invite (workflow 122) so they can self-serve. See docs/m365-domain-and-dkim.md (internal vs external).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 m365-prod",
      "guard": "`dry_run` (default true); also gated by m365-prod approval",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 306,
      "title": "Discover - Uncaptured Comms (FFC Tenant M365, PII masked)",
      "apis": [
        "M365"
      ],
      "file": "306-discover-uncaptured-comms.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "m365-prod"
      ],
      "description": "Automated track of Gap B (#492): scan the ORG-OWNED Microsoft 365 shared mailboxes for charity inquiries, reconcile candidate domains against the sites-list (WHMCS reconciliation planned), and emit a PII-masked pipeline of leads not yet captured. The personal Google Voice source is excluded here \u2014 it is human-in-the-loop only (see docs/runbooks/google-voice-metrics.md). Dispatch-only.  Requires a Graph app registration with Mail.Read scoped to the listed mailboxes via an Exchange Online application access policy (so the tenant-wide Mail.Read grant only reads those mailboxes).",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 m365-prod",
      "guard": "PII masked; dispatch-only; org mailboxes only; waits on m365-prod approval",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 320,
      "title": "Azure - Key Vault Secret Inventory (audit)",
      "apis": [
        "MS"
      ],
      "file": "320-azure-kv-secret-inventory.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-read"
      ],
      "description": "Read-only audit of kv-ffc-admin-prod-cbm: lists every secret's name, updated date, and whether it still holds the scaffolding placeholder. Secret VALUES are never printed \u2014 only booleans/lengths derived in-process. Flags stale (>12 months) and placeholder secrets so scaffolding gaps (like the 6-week placeholder Google set) surface quickly. Runs on the google-prod-read environment because it federates the same ffc-admin-kv-reader identity; a dedicated azure-prod-read environment would need a new federated credential.",
      "safetyLevel": "Reads",
      "approvalEnv": "google-prod-read (reader identity)",
      "guard": "values never printed; placeholder/stale flags only",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 321,
      "title": "Azure - KV Credential Liveness + Expiry Monitor",
      "apis": [
        "MS",
        "GH",
        "CF"
      ],
      "file": "321-azure-kv-credential-liveness.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-read"
      ],
      "description": "Daily health check of the credentials in kv-ffc-admin-prod-cbm that back the automation lanes, on two independent signals:  1. EXPIRY/ENABLED for every secret, read from `az keyvault secret list`, which returns attributes WITHOUT values. So write-scope credentials are monitored without this workflow ever reading their material. 2. LIVENESS for the credentials that can verify themselves read-only \u2014 the two GitHub PATs (GET /user) and the two Cloudflare tokens (/user/tokens/verify). Probes cover `read-all-*` secrets ONLY; the library refuses a `wr-all-*` target before anything is read.  Why (#848): both `read-all-*` GitHub PATs in the vault were dead with 401 Bad credentials, for an unknown period, and nothing noticed because nothing consumed them yet. Expiry alone would not have caught it \u2014 a REVOKED token still carries a future expiry date \u2014 which is why liveness is a separate signal here and why a probe that cannot be evaluated is reported as unverifiable rather than passing (the #726/#855 lesson: never render an unreadable response as a green check).  Secret values are never printed, never written to the artifact, and never sent anywhere except the probe host, which is re-validated against an allowlist in this file immediately before the request. Findings are upserted into one rolling issue that auto-closes when the vault is clean.  Runs on google-prod-read because it federates the ffc-admin-kv-reader identity and has no approval gate (a monitor must not wait on a human, and a dedicated azure-prod-read environment would need a new federated credential). Under the per-secret RBAC migration (#848 part A) that identity needs `getSecret` on the four probed `read-all-*` secrets, plus list on the vault; without those grants the run reports UNVERIFIABLE, not green.  Classification + report logic lives in scripts/kv-credential-liveness-lib.js (unit-tested under tests/workflow-logic/).",
      "safetyLevel": "Reads",
      "approvalEnv": "google-prod-read (reader identity)",
      "guard": "daily two-signal credential health check: expiry/enabled for every vault secret from the attribute listing (values never read), plus a read-only liveness probe (`GET /user`, Cloudflare `/user/tokens/verify`) for the four `read-all-*` credentials that can verify themselves \u2014 the library refuses a `wr-all-*` probe target before anything is read, so write-scope material is expiry-monitored without ever being fetched; a probe that cannot be evaluated is reported as unverifiable, never as passing; values never printed or persisted; rolling issue upsert/close; ungated by design (a credential monitor must not wait on a human)",
      "category": "Microsoft (M365 / Azure / Graph)",
      "categoryCode": "MS"
    },
    {
      "number": 401,
      "title": "Zeffy - Campaigns Export",
      "apis": [
        "ZEFFY"
      ],
      "file": "401-zeffy-campaigns-export.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod",
        "zeffy-prod"
      ],
      "description": "Read-only export of Zeffy campaigns/events (GET /api/v1/campaigns) via OIDC -> Key Vault. This endpoint returns NO personal data (forms/events only: title, target, volume, dates, URLs), so the uploaded artifact is inherently safe to keep on this public repo. Dispatch-only (no schedule) while we are testing the API. One workflow per endpoint.  The `deliver` job additionally publishes a public, PII-free campaign list (docs/data/ffc-zeffy-campaigns.json \u2014 title/url/status only) as a reviewable data-update PR, so the fleet smoke engine can classify a detected donation surface as ffc-interim (FFC-owned campaign) vs charity-specific (#744). Mirrors the 502 export->deliver split (github-prod gate, CBM_TOKEN) and the 703 same-repo peter-evans data-PR pattern.",
      "safetyLevel": "Reads (+ PR delivery)",
      "approvalEnv": "zeffy-prod \u2192 \u2705 github-prod (deliver)",
      "guard": "PII masked; the published `docs/data/ffc-zeffy-campaigns.json` is title/url/status only (no financials); `deliver` opens a reviewable data PR via CBM_TOKEN",
      "category": "Zeffy",
      "categoryCode": "ZEFFY"
    },
    {
      "number": 402,
      "title": "Zeffy - Payments Export (PII masked)",
      "apis": [
        "ZEFFY"
      ],
      "file": "402-zeffy-payments-export.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "zeffy-prod"
      ],
      "description": "Read-only export of Zeffy payments (GET /api/v1/payments) via OIDC -> Key Vault. The payments payload carries donor PII (buyer email / name / company, receipt URL); this workflow runs the export WITHOUT -IncludePii, so those columns are written blank. The PII passes through the ephemeral runner only and is never persisted, so the uploaded artifact is safe to keep on this public repo. Dispatch-only (no schedule) while we are testing the API. One workflow per endpoint.",
      "safetyLevel": "Reads",
      "approvalEnv": "zeffy-prod",
      "guard": "PII masked; never `-IncludePii`",
      "category": "Zeffy",
      "categoryCode": "ZEFFY"
    },
    {
      "number": 403,
      "title": "Zeffy - Contacts Export (PII masked)",
      "apis": [
        "ZEFFY"
      ],
      "file": "403-zeffy-contacts-export.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "zeffy-prod"
      ],
      "description": "Read-only export of Zeffy contacts/donors (GET /api/v1/contacts) via OIDC -> Key Vault. The contacts endpoint is the donor directory and is PII end-to-end (email / name / phone / address); this workflow runs the export WITHOUT -IncludePii, so those columns are written blank and only the pseudonymous contact id (UUID), donor type, giving totals/counts/dates, currency, and country are kept. The PII passes through the ephemeral runner only and is never persisted, so the uploaded artifact is safe to keep on this public repo. Dispatch-only (no schedule). One workflow per endpoint.",
      "safetyLevel": "Reads",
      "approvalEnv": "zeffy-prod",
      "guard": "PII masked; never `-IncludePii`",
      "category": "Zeffy",
      "categoryCode": "ZEFFY"
    },
    {
      "number": 501,
      "title": "Google - API Smoke (GA4 connectivity)",
      "apis": [
        "GOOGLE"
      ],
      "file": "501-google-api-smoke.yml",
      "triggers": [
        "workflow_call",
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-read"
      ],
      "description": "Proves the full OIDC -> Azure Key Vault -> Google chain end to end before any real reporting runs. Read-only: a 1-day activeUsers report. Fails closed if credentials are missing or the API errors. Reusable via workflow_call so reporting workflows can require it as a gate.",
      "safetyLevel": "Reads",
      "approvalEnv": "google-prod-read",
      "guard": "read-only; fails closed; reusable via `workflow_call`",
      "category": "Google",
      "categoryCode": "GOOGLE"
    },
    {
      "number": 502,
      "title": "Google - Analytics Report (GA4 -> JSON)",
      "apis": [
        "GOOGLE"
      ],
      "file": "502-google-analytics-report.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod",
        "google-prod-read"
      ],
      "description": "Read-only GA4 reporting for the FFC primary sites. Generates aggregate JSON per site and delivers it to FreeForCharity/FFC-IN-ffcadmin.org as a reviewable PR (never a direct push). PII-safe: aggregate metrics only. Requires the google-prod-read environment (report) and the approval-gated github-prod environment (delivery) + the Wave 0 foundation. The delivery PAT comes from Key Vault (`wr-all-cbm-github-pat`) over OIDC, never a GitHub secret \u2014 see #844.",
      "safetyLevel": "Reads",
      "approvalEnv": "google-prod-read / \u2705 github-prod",
      "guard": "delivers JSON to ffcadmin via PR (CBM_TOKEN, github-prod approval); PII-safe aggregates. Same PR also syncs the workflow catalog and the Agentic OS status feed (`agentic-os-status.json`, generated by `scripts/generate-agentic-os-status.py`, REST-only)",
      "category": "Google",
      "categoryCode": "GOOGLE"
    },
    {
      "number": 503,
      "title": "Google - GTM Provision (per-charity container)",
      "apis": [
        "GOOGLE"
      ],
      "file": "503-google-gtm-provision.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-write"
      ],
      "description": "Provision a per-charity GTM container: create it, seed GA4 + optional Clarity/Meta tags on All Pages, publish, and delegate Edit/Publish to the charity POC. Gated write: google-prod-write + dry_run defaults to true. See docs/google-api.md (GTM: one container per charity).  SETUP REQUIRED before first run: create the `google-prod-write` environment + a federated credential (repo:...:environment:google-prod-write) on ffc-admin-kv-writer, and set env secrets WR_ALL_FFC_AZURE_KV_CLIENT_ID / WR_ALL_FFC_AZURE_TENANT_ID (mirrors the google-prod-read setup).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 google-prod-write",
      "guard": "dry_run default true; seeds GA4/Clarity/Meta; delegates POC access",
      "category": "Google",
      "categoryCode": "GOOGLE"
    },
    {
      "number": 504,
      "title": "Google - GTM Container Backups (weekly export)",
      "apis": [
        "GOOGLE"
      ],
      "file": "504-google-gtm-backup.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-read"
      ],
      "description": "Weekly disaster-recovery export: the LIVE version of every FFC GTM container as JSON artifacts (90d retention). Read-only Tag Manager calls via DWD; charity self-service editing (container-per-charity model) makes restore points essential.",
      "safetyLevel": "Reads",
      "approvalEnv": "google-prod-read",
      "guard": "read-only exports; live-version JSON artifacts (90d)",
      "category": "Google",
      "categoryCode": "GOOGLE"
    },
    {
      "number": 505,
      "title": "Google - GA4 Property Provision (per-charity)",
      "apis": [
        "GOOGLE"
      ],
      "file": "505-google-ga-property-provision.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-write"
      ],
      "description": "Provision a per-charity GA4 property: one property under the FFC Supported Sites GA account + one web data stream for the charity's domain, reporting the measurement id (G-XXXX) that workflow 503 seeds into the charity's GTM container. Gated write: google-prod-write + dry_run defaults to true. Idempotent \u2014 an existing stream for the domain is reported instead of duplicated. See docs/google-api.md (one property per charity).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 google-prod-write",
      "guard": "dry_run default true; one property per charity; idempotent by stream defaultUri",
      "category": "Google",
      "categoryCode": "GOOGLE"
    },
    {
      "number": 506,
      "title": "Google - Fleet Telemetry Reachability",
      "apis": [
        "GOOGLE"
      ],
      "file": "506-google-fleet-telemetry-reachability.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "google-prod-read"
      ],
      "description": "Answers four questions the fleet could not previously answer, in one read-only pass: 1. which domains have a GTM container (and is any container SHARED between sites) 2. which domains have a GA4 property + web stream 3. which domains are verified in Search Console 4. how much traffic each gets \u2014 ranked, so rollout order is measured rather than alphabetical  Two properties of the report matter more than the raw numbers:  * It compares the SERVED HTML against what the APIs say is provisioned. A site can have a container provisioned and never load it, or load a hardcoded template container the API does not associate with it. Both are live today among the convergence set, and neither is visible from provisioning records alone.  * It never conflates \"no data\" with \"no traffic\". A site with no GA4 property is UNMEASURED; a site with a property and zero sessions has NO TRAFFIC. Collapsing the two sorts unmeasured sites to the bottom of a traffic-ordered rollout \u2014 which is how a site that matters ends up treated as if it does not.  Read-only end to end: analytics.readonly, tagmanager.readonly and webmasters are all already in the delegated scope grant (docs/google-api.md), so this needs no new credential or scope. Ref #865, epic #861.",
      "safetyLevel": "Reads",
      "approvalEnv": "google-prod-read",
      "guard": "read-only; enumerates GTM containers, GA4 properties/streams and Search Console sites, cross-checks them against each site's SERVED HTML, and ranks traffic. Reports \"no data\" distinctly from \"no traffic\" so an unmeasured site never sorts as unpopular. Weekly Mon 07:23 + dispatch",
      "category": "Google",
      "categoryCode": "GOOGLE"
    },
    {
      "number": 601,
      "title": "WPMUDEV - Export Sites/Domains (Read-only)",
      "apis": [
        "WPMUDEV"
      ],
      "file": "601-wpmudev-export-sites.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "wpmudev-prod"
      ],
      "description": "",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 wpmudev-prod",
      "guard": "waits on wpmudev-prod approval",
      "category": "WPMUDEV",
      "categoryCode": "WPMUDEV"
    },
    {
      "number": 701,
      "title": "Website - Provision (Issue Assigned)",
      "apis": [
        "CF",
        "Repo"
      ],
      "file": "701-website-provision.yml",
      "triggers": [
        "issues"
      ],
      "environments": [
        "cloudflare-prod-read",
        "cloudflare-prod-write",
        "github-prod"
      ],
      "description": "Serialize runs: a second dispatch queues behind the first instead of racing it.",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 cloudflare-prod-write / \u2705 github-prod",
      "guard": "`repo` chained behind `dns` approval",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 702,
      "title": "Domain - Deploy Static Clone to FFC-EX Repo",
      "apis": [],
      "file": "702-ffc-ex-clone-deploy.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Regenerates an FFC-EX site as a faithful static clone of the live WordPress site and opens a PR on the target FFC-EX repo. Pipeline (all proven locally): clone-site-static.mjs (httrack) -> integrate-clone-into-nextjs.mjs -> next build (output: 'export') so the Next.js app serves the exact clone. See docs/ffc-ex-static-clone-runbook.md.  Auth: uses CBM_TOKEN (cross-repo PAT, same secret create-repo.yml uses) to check out the FFC-EX repo and push a branch + open a PR there. Non-destructive: it never pushes to the FFC-EX default branch, only opens a PR for review.",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 github-prod",
      "guard": "ungated preflight (repo must exist; refuses live sites and sibling-domain repos unless `force=true`); opens a draft PR (never pushes); serialized",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 703,
      "title": "Sites List - Generate (CSV + JSON)",
      "apis": [
        "GH"
      ],
      "file": "703-sites-list-generate.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Generates the merged FFC sites list from the WHMCS / Cloudflare / WPMUDEV exports plus the curated base list, runs per-domain health checks, and commits both sites-list/sites_list.csv and sites-list/sites_list.json.  This repo owns the export environments (whmcs-prod, cloudflare-prod-read, wpmudev-prod) and the GitHub PAT (CBM_TOKEN in github-prod) needed to dispatch the sibling export workflows, so generation lives here. Downstream consumers (e.g. ffcadmin.org) pull the published files from this repo.",
      "safetyLevel": "Writes (data PR only)",
      "approvalEnv": "\u2705 github-prod",
      "guard": "dispatches read-only exports 201/108/601 (601 waits on its own wpmudev-prod gate); regenerates `sites-list/` CSV+JSON; opens a data PR (never pushes to `main`); weekly Mon 08:00Z + dispatch; serialized concurrency group",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 704,
      "title": "Website - Analytics Wire",
      "apis": [
        "GH"
      ],
      "file": "704-website-analytics-wire.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Wire provisioned GA4 / GTM ids into one FFC-EX-<domain> website repo by opening a PR (never pushing the default branch). Detects the site type and delegates the file edits to scripts/analytics-wire.ps1: template sites get src/lib/analytics.config.ts + a rewired GTM component; static WordPress exports get the GTM snippet injected across every *.html. Idempotent: an already-wired repo yields no PR. Gated write: github-prod (uses CBM_TOKEN). See docs (analytics-provisioning skill) and issue #629 (fleet rollout).",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 github-prod",
      "guard": "opens a draft PR (never pushes); `dry_run` (default true); idempotent",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 720,
      "title": "Repo - Create GitHub Repo",
      "apis": [
        "Repo"
      ],
      "file": "720-create-repo.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "",
      "safetyLevel": "Writes (gated)",
      "approvalEnv": "\u2705 github-prod",
      "guard": "dispatch-only; creates an FFC-EX repo from a template (no dry_run \u2014 gate is the only stop); duplicate-repo warning text in inputs; visibility/Pages options set at creation",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 721,
      "title": "Repo - Deploy GitHub Pages",
      "apis": [
        "Repo"
      ],
      "file": "721-deploy-pages.yml",
      "triggers": [
        "push",
        "workflow_dispatch"
      ],
      "environments": [
        "github-pages"
      ],
      "description": "",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 722,
      "title": "Repo - CI Validate and Test",
      "apis": [
        "Repo"
      ],
      "file": "722-ci.yml",
      "triggers": [
        "merge_group",
        "pull_request",
        "push"
      ],
      "environments": [],
      "description": "",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 723,
      "title": "Repo - CodeQL Security Analysis",
      "apis": [
        "Repo"
      ],
      "file": "723-codeql-analysis.yml",
      "triggers": [
        "merge_group",
        "pull_request",
        "push",
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 724,
      "title": "Repo - Initialize Labels",
      "apis": [
        "Repo"
      ],
      "file": "724-initialize-labels.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 725,
      "title": "Repo - Sync Labels",
      "apis": [
        "Repo"
      ],
      "file": "725-sync-labels.yml",
      "triggers": [
        "push",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 726,
      "title": "Repo - Rulesets + Settings Drift Audit",
      "apis": [
        "Org"
      ],
      "file": "726-repo-rulesets-drift-audit.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Daily read-only audit of FreeForCharity org repository rulesets and settings drift (branch protections, merge queues, Actions policies) against the documented standard, reported as issues under the github-prod approval gate. The PAT comes from Key Vault (`wr-all-cbm-ffc-copilot-mcp-github-pat`) over OIDC, never a GitHub secret \u2014 see #844. That token (not `wr-all-cbm-github-pat`) is the one with Organization Administration read+write, which GitHub requires even to READ org rulesets via a fine-grained PAT.  Supersede-on-newer (#722): these daily runs pause at an approval gate; without this, unapproved runs pile up ~12-deep until the janitor reaps them at 7 days. cancel-in-progress cancels the older queued/waiting run the moment the next scheduled run starts, so at most ONE run ever waits at the gate. Ref-scoped so a dispatch from a PR branch can never cancel main's scheduled run. Trade-off (accepted): cancel-in-progress could interrupt an EXECUTING run, but execution lasts minutes against a 24h cadence \u2014 in practice only gate-waiting runs are ever superseded; janitor 734 remains the backstop.",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 github-prod",
      "guard": "report only; CBM_TOKEN via github-prod approval",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 727,
      "title": "Repo - Phantom Revert Guard",
      "apis": [
        "Repo"
      ],
      "file": "727-phantom-revert-guard.yml",
      "triggers": [
        "merge_group",
        "pull_request",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Detects PRs that would silently revert work that landed on main while the PR was open. The classic failure mode (we hit it twice on 2026-05-03) is:  1. Branch B is forked from main at commit M0. 2. PR P1, P2, ... merge into main between M0 and now (HEAD = M_now). 3. Branch B is not updated; CI still passes because it tests B merged with M_now (the virtual merge commit), which is fine as a build -- but a subsequent squash- or rebase-merge of the stale branch writes B's old file tree, silently reverting everything P1, P2, ... added to files B never touched. 4. The PR's \"diff vs main\" shows those files as deleted/reverted.  This guard explicitly compares \"files modified on main since fork\" against \"files changed by this PR\" and fails when untouched-but-updated files fall in critical paths or the branch is sufficiently far behind.  Note: this guard reruns only when a new commit is pushed to the PR branch. If main advances while a PR is open and the PR author pushes nothing new, the guard will not automatically rerun. For full coverage, pair this guard with branch protection's \"Require branches to be up to date before merging\" rule, which forces a fresh CI pass after updating the branch. Manual reruns are supported via workflow_dispatch; supply head_sha and base_ref inputs to analyze a specific branch (defaults: HEAD of the dispatched ref, compared against the repository's default branch).",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 728,
      "title": "Repo - AI Agent Hooks Validate",
      "apis": [
        "Repo"
      ],
      "file": "728-ai-agent-hooks-validate.yml",
      "triggers": [
        "pull_request",
        "push"
      ],
      "environments": [],
      "description": "",
      "safetyLevel": "",
      "approvalEnv": "",
      "guard": "",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 729,
      "title": "Repo - Add Collaborator",
      "apis": [
        "Repo"
      ],
      "file": "729-repo-add-collaborator.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Reusable workflow that adds (or updates) one or more GitHub users as collaborators on a repository at a chosen permission level.  Two ways to use it: 1. Manually via the Actions tab (workflow_dispatch). 2. From another workflow via `uses:` (workflow_call) -- e.g. the website provisioning workflow (15) calls this instead of inlining the gh api PUT.  The privileged step runs with a PAT from Key Vault (environment: github-prod), so callers do not need `actions: write` themselves -- they only need to be able to create/assign issues or dispatch this workflow.",
      "safetyLevel": "Writes (**live default**)",
      "approvalEnv": "\u2705 github-prod",
      "guard": "\u26a0\ufe0f `dry_run` defaults to **false**",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 730,
      "title": "Repo - Audit Environment Approval Gates",
      "apis": [
        "Repo"
      ],
      "file": "730-repo-audit-environment-gates.yml",
      "triggers": [
        "push",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Read-only. Lists every GitHub Environment's required reviewers, wait timer, and deployment branch policy to the job summary (and the log). Answers \"which environments gate a deployment behind a human approval?\" \u2014 which the docs hedge on because environment protection rules can't be read from the agent sandbox proxy. Uses the built-in GITHUB_TOKEN (repo read is enough to read protection rules), so it needs no environment and no approval. Dispatch-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2014",
      "guard": "report only (environment reviewer config)",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 731,
      "title": "Repo - Actions Run Metrics (30d per-workflow stats)",
      "apis": [
        "GH"
      ],
      "file": "731-actions-run-metrics.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Aggregates the last 30 days of Actions runs into per-workflow stats (runs, success rate, avg duration) as a JSON artifact \u2014 feeds the ffcadmin automation-metrics dashboard. Read-only against the GitHub API using the ambient GITHUB_TOKEN; no environment gate.",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2014",
      "guard": "GITHUB_TOKEN read-only; JSON artifact",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 733,
      "title": "Repo - Credential Rotation Reminders (quarterly)",
      "apis": [
        "GH"
      ],
      "file": "733-credential-rotation-reminders.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Opens (or refreshes) one rotation-reminder issue per credential family each quarter, each embedding its runbook link. GitHub issues only; the rotations themselves stay human/gated.",
      "safetyLevel": "Writes (issues only)",
      "approvalEnv": "\u2014",
      "guard": "quarterly reminder issues; rotations stay human/gated",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 734,
      "title": "Repo - Stale Waiting-Run Janitor",
      "apis": [
        "Repo"
      ],
      "file": "734-stale-waiting-run-janitor.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Cancels (or, in dry-run, just reports) workflow runs left in the `waiting` state \u2014 i.e. paused at an environment approval gate \u2014 for longer than max_age_days. Scheduled read/triage runs that no one approves otherwise stack up indefinitely and the Actions history stops reflecting real state (see the 209/210 backlog cleared manually on 2026-07-07, issue #637).  Read + cancel only; never approves a gate. Uses the ambient GITHUB_TOKEN.",
      "safetyLevel": "Writes (cancels runs)",
      "approvalEnv": "\u2014",
      "guard": "cancels runs left waiting >N days at a gate; never approves; dispatch dry-run supported",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 735,
      "title": "Repo - Dependabot Affected Repos",
      "apis": [
        "Org"
      ],
      "file": "735-repo-dependabot-affected-repos.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Regenerates data/dependabot-affected-repos.json (the org repo inventory that feeds the smoke-protected Dependabot waves) and delivers it as an auto-merging PR. Migrated from FFC-IN-ClarkeMoyerAdmin. Uses the github-prod environment so the PR is opened with a real PAT \u2014 a PR opened by GITHUB_TOKEN does not trigger the pull_request checks / Copilot review this repo requires, so it would never satisfy branch protection. The PAT comes from Key Vault (`wr-all-cbm-github-pat`) over OIDC, never a GitHub secret \u2014 see #844. Read-only against the org; PR-only.",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2705 github-prod",
      "guard": "weekly org inventory (feeds smoke-protected waves); PR-only + auto-merge",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 736,
      "title": "Repo - Archive / Application Denied (Admin)",
      "apis": [
        "Repo"
      ],
      "file": "736-repo-archive.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Destructive-adjacent, so heavily guarded. This workflow ONLY archives a repository (`archived: true`) \u2014 it NEVER deletes one. Archiving is fully reversible (a maintainer can unarchive from Settings), which is the deliberate safety choice: the worst case is a repo that is temporarily read-only, not data loss.  Five guards, all must pass for a live archive: 1. dispatch-only (never issue/label triggered \u2014 no accidental auto-archive) 2. dry_run defaults to true (preview only; you must pass dry_run=false) 3. typed confirmation: confirm_repo must exactly equal repo 4. org lock + hard denylist (refuses anything outside FreeForCharity, and always refuses the automation repo, the website template, and the org-wide .github repo) 5. github-prod environment approval gate (a human reviewer must approve the deployment)",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 github-prod",
      "guard": "ungated preflight (live archive requires a matching successful dry-run within 48h; fails fast on missing/already-archived; warns on recent push/Pages/open-issue references); archive-only (reversible, never deletes); `dry_run` default true; typed `confirm_repo`; org-locked + denylist",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 737,
      "title": "Repo - Claim Sync",
      "apis": [
        "Repo"
      ],
      "file": "737-claim-sync.yml",
      "triggers": [
        "pull_request",
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Mechanizes the AGENTS.md work-claiming protocol (available = `is:open -label:claimed`). Two independent triggers:  1. Event-driven label sync (pull_request): parse Closes/Fixes/Refs #N from the PR body and add `claimed` to those open hub issues while the PR is active; remove it when the last open linked PR closes/merges. 2. Daily sweep (schedule): release stale hand-labeled claims \u2014 issues labeled `claimed` with no open linked PR and no activity for 48h \u2014 in this repo, so an abandoned claim frees itself. (ffcadmin needs its own sweep: cross-repo writes require CBM_TOKEN, which lives only in the gated github-prod environment \u2014 unusable for an ungated daily job.)  Writes issues/labels only; no external API; ungated. The decision logic lives in scripts/claim-sync-lib.js (unit-tested in tests/workflow-logic/).",
      "safetyLevel": "Writes (issues/labels only)",
      "approvalEnv": "\u2014",
      "guard": "syncs `claimed` label from linked PRs (pull_request event, GITHUB_TOKEN) + daily sweep of expired claims (no open linked PR + 48h idle) in this repo (ambient GITHUB_TOKEN \u2014 CBM_TOKEN is gated-env-only and empty on schedule); no external API, ungated; sweep `dry_run` via dispatch",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 738,
      "title": "Repo - Fleet Smoke Engine Drift Audit",
      "apis": [
        "Org"
      ],
      "file": "738-fleet-smoke-engine-drift-audit.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Weekly byte-identity audit of the canonical post-deploy smoke engine across the FFC fleet. Two fix variants once raced into the fleet (2026-07-19), so this mechanically compares every deployed `.github/workflows/post-deploy-smoke.yml` against the canonical copy in FFC-IN-FFC_Single_Page_Template@main and upserts a single rolling issue on divergence (auto-closing when the fleet is uniform).  Read-only: GITHUB_TOKEN reads public repo contents org-wide and lists the org's public repos; the only write is the rolling issue in THIS repo. No external API, no environment gate. Comparison/report logic lives in scripts/fleet-smoke-drift-lib.js (unit-tested under tests/workflow-logic/).",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2014",
      "guard": "weekly SHA-256 byte-identity audit of `post-deploy-smoke.yml` across the fleet vs canonical (FFC-IN-FFC_Single_Page_Template@main); rolling issue upsert/close on divergence; GITHUB_TOKEN (public reads + own-repo issue), no external API, ungated",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 739,
      "title": "Repo - Process Health Metrics Report",
      "apis": [
        "GH"
      ],
      "file": "739-process-health-metrics.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Weekly, read-only \"monitor the monitors\" report for the Agentic OS (process-assurance epic #752, issue #756). A REST sweep of THIS repo turns the backlog + data-pipeline signals into a metrics object and posts it as a new comment on the Conductor log (#719) \u2014 the comment thread is the visible trend history. Each comment embeds its metrics in a hidden data block; the next run reads the most recent block to render week-over-week deltas (\"trends over absolute numbers\"). The same JSON is uploaded as an artifact for later ffcadmin rendering (#723).  Read-only: the ambient GITHUB_TOKEN reads issues + Actions runs and writes ONE comment on #719 in this repo. No external API, no environment gate, no push to `main`. Aggregation/trend/render logic lives in scripts/process-health-metrics-lib.js (unit-tested under tests/workflow-logic/).",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2014",
      "guard": "weekly \"monitor the monitors\" REST sweep (backlog + data-pipeline signals) posted as a new comment on #719 with week-over-week trends (baseline read from the prior comment's hidden data block); JSON artifact; GITHUB_TOKEN (own-repo issue/Actions reads + one comment), no external API, ungated",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 740,
      "title": "Repo - Scheduled Workflow Failure Alert (rolling issue)",
      "apis": [
        "GH"
      ],
      "file": "740-scheduled-workflow-failure-alert.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Watches every scheduled hub workflow (726 drift audit, 734 janitor, 738 smoke drift, Google 502 analytics + 504 GTM backups, and the other cron-driven monitors \u2014 the authoritative list is WATCHED_WORKFLOWS below). For each one it upserts ONE rolling alert issue PER WATCHED WORKFLOW, and closes that issue on the next success of that same workflow. The marker is keyed by workflow name precisely so a green run of one watched workflow can never close another's alert \u2014 with a dozen workflows watched, a shared marker would let a weekly job's success silently clear a daily job's outage.  POLL, NOT `workflow_run` (#843). This workflow used to trigger on the `workflow_run` completed event. That event has NEVER fired in this repository \u2014 `?event=workflow_run` returns 0 runs all time, while sibling repos show hundreds \u2014 so both this alerter and the Google one (732, now retired into this file) were `active` and had produced exactly zero runs. The alerting layer was dead for six days and nothing could tell us, because a `workflow_run` watcher cannot be tested on demand. The poll can: `workflow_dispatch` below is REQUIRED, not a convenience \u2014 it is what makes this alerter provable.  BEHAVIOUR CHANGE, deliberate: the event saw *every* run; the poll sees only the LATEST completed default-branch run per workflow, so a failure that self-heals between polls is missed. That is an acceptable \u2014 arguably better \u2014 fit, because the rolling issue answers \"is this workflow currently broken?\", which is exactly what latest-run state models. Do not add cursor/state tracking to chase the transient case; it would trade away the simplicity that makes this verifiable.  Consequence worth knowing: the poll model only makes sense for SCHEDULED workflows. For a dispatch-only workflow the \"latest run\" is whatever a human last ran by hand \u2014 a stale red would alert forever and a stale green would mean nothing. `test_watched_workflows_are_actually_scheduled` enforces that, and it is why 501 (workflow_dispatch/workflow_call only) is excluded even though retired 732 watched it.  Ambient GITHUB_TOKEN only, no environment: \u2014 the alerter must never be blocked by the very gate it is watching. Reports `cancelled` as well as `failure`: a run cancelled before it reaches a runner (the 2026-07-24 726 incident) executes zero steps and is exactly the kind of silent breakage this workflow exists to surface.  It does NOT alert on a declined or expired approval gate. Those also surface as run-level `failure`, so the run conclusion alone cannot tell \"the job broke\" from \"a human said no\"; the job list can, and `actions: read` already allows reading it. See the discriminator in the step below.",
      "safetyLevel": "Writes (issues only)",
      "approvalEnv": "\u2014",
      "guard": "**polls** every scheduled hub workflow twice hourly (`schedule` + `workflow_dispatch`; the `workflow_run` event has never fired in this repo \u2014 #843) and upserts one rolling issue per watched workflow (marker keyed by workflow name, so only that workflow's own green run closes it); absorbs the retired 732 Google lane (502/504); reports `cancelled`/`timed_out` as well as `failure`, latest completed default-branch run only; does **not** alert on a declined/expired approval gate (no job `failure` + \u22651 job `cancelled` \u21d2 logged via `core.notice`, not an alert) \u2014 an unreadable job list still alerts, and a watched name matching no workflow fails the run loudly; ambient GITHUB_TOKEN, no external API, ungated by design (must not be blocked by the gate it watches)",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 741,
      "title": "Repo - Fleet Security Audit Coverage",
      "apis": [
        "Org"
      ],
      "file": "741-fleet-security-audit-coverage.yml",
      "triggers": [
        "schedule",
        "workflow_dispatch"
      ],
      "environments": [],
      "description": "Weekly audit of whether each Node app in the FFC-EX fleet has dependency-vulnerability detection wired at all. A repo counts as covered only when it has BOTH `.github/workflows/security-audit.yml` and an `audit:high` script in package.json \u2014 the workflow without the script fails on every run, the script without the workflow never runs. Findings are upserted into a single rolling issue that auto-closes once the fleet is fully covered.  Why (#838): 34 of the 44 Node FFC-EX repos had no detection of any kind, so a published advisory against a charity site was invisible until somebody swept by hand \u2014 and nothing was measuring that, which is why #822's scope was estimated at 13 repos when the real number was 38. This workflow re-derives the fleet from the org listing every run rather than trusting a count carried between sessions.  Read-only: GITHUB_TOKEN reads public repo contents org-wide and lists the org's public repos; the only write is the rolling issue in THIS repo. No external API, no environment gate. It reports coverage only and never bumps a dependency \u2014 remediation belongs to #822. Classification/report logic lives in scripts/fleet-audit-coverage-lib.js (unit-tested under tests/workflow-logic/).",
      "safetyLevel": "Reads",
      "approvalEnv": "\u2014",
      "guard": "weekly coverage audit of dependency-vulnerability detection across the FFC-EX fleet (a repo counts as covered only with BOTH `security-audit.yml` and an `audit:high` script); rolling issue upsert/close on any gap; reports only \u2014 never bumps a dependency or writes to a fleet repo; GITHUB_TOKEN (public reads + own-repo issue), no external API, ungated",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 742,
      "title": "Repo - Fleet Security Audit Backfill",
      "apis": [
        "Org"
      ],
      "file": "742-fleet-security-audit-backfill.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "github-prod"
      ],
      "description": "Closes the coverage gap that workflow 741 measures: opens one pull request per FFC-EX repo that lacks dependency-vulnerability detection, adding BOTH halves together \u2014 `.github/workflows/security-audit.yml` and the `audit:high` script it runs.  Why a hub workflow and not 34 hand-made PRs (#838): the fleet keeps growing, and a rollout that lives here also covers the 35th repo provisioned next month. The target list is re-derived from the org listing on every dispatch, never read from a checklist, which is the failure #838 was opened about (its scope was estimated at 13 repos when the real number was 38).  Never pushes a default branch \u2014 every change goes through a PR on the target repo. Never bumps a dependency: making the vulnerabilities visible is this workflow's job, upgrading them is #822's. A newly-wired repo whose audit goes red is this working correctly.  Two-job split so a preview costs nothing and needs nobody: - plan  \u2014 ungated, GITHUB_TOKEN, read-only. `dry_run=true` (the default) stops here, so anyone can see the exact planned diff without an approval. - apply \u2014 `dry_run=false` only, gated `github-prod` (a cross-repo write genuinely needs CBM_TOKEN). Acts on the plan the gate approver saw.  Decision logic lives in scripts/fleet-audit-backfill-lib.js behind scripts/fleet-audit-backfill.js, unit-tested under tests/workflow-logic/.",
      "safetyLevel": "Writes (dry-run default)",
      "approvalEnv": "\u2705 github-prod (apply job only)",
      "guard": "closes the gap 741 measures: opens one PR per uncovered FFC-EX repo adding BOTH halves of the audit pair (`security-audit.yml` + the `audit:high` script) in a single commit, never a `partial`; cron staggered deterministically by repo name across 06:00\u201306:59, skipping the crowded `:17`; `dry_run` (default true) runs the ungated `plan` job only, so a preview needs no approval; never pushes a target's default branch and never bumps a dependency (#822 owns remediation); repos with no `package-lock.json` are reported, not wired",
      "category": "GitHub (Website + Repo)",
      "categoryCode": "GH"
    },
    {
      "number": 801,
      "title": "Candid - Charity Check (EIN)",
      "apis": [
        "CANDID"
      ],
      "file": "801-candid-charity-check.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "candid-prod-read"
      ],
      "description": "Read-only: validates a nonprofit's IRS 501(c)(3) / Pub78 / BMF / OFAC standing via the Candid Charity Check v1 API. Used to verify an applicant's EIN during charity onboarding and to re-verify existing partners. Key comes from Azure Key Vault at run time via OIDC (candid-keys-from-kv); see docs/candid-api-and-mcp.md for the one-time setup.",
      "safetyLevel": "Reads",
      "approvalEnv": "candid-prod-read",
      "guard": "read-only IRS-status lookup; public org data only",
      "category": "Candid (GuideStar)",
      "categoryCode": "CANDID"
    },
    {
      "number": 802,
      "title": "Candid - Essentials Search",
      "apis": [
        "CANDID"
      ],
      "file": "802-candid-essentials-search.yml",
      "triggers": [
        "workflow_dispatch"
      ],
      "environments": [
        "candid-prod-read"
      ],
      "description": "Read-only: searches Candid's nonprofit database (Essentials v4) by EIN, organization name, or keywords and reports name / EIN / location / transparency-seal level / profile URL. Used during charity onboarding to locate an applicant's Candid (GuideStar) profile and seal level. Key comes from Azure Key Vault at run time via OIDC (candid-keys-from-kv); see docs/candid-api-and-mcp.md.",
      "safetyLevel": "Reads",
      "approvalEnv": "candid-prod-read",
      "guard": "read-only profile search; public org data only",
      "category": "Candid (GuideStar)",
      "categoryCode": "CANDID"
    }
  ]
}
