# M14 — Security Hardening Plan

> Implements the security promises of SPEC §15 (mTLS) and closes
> the M11.5 wishlist (mTLS + secrets management + network policies).
> Makes broad-announce defensible to enterprise customers without
> re-architecting the data plane.

**Status:** planning (post-M11, parallel to M12/M13)
**Target:** SPEC §15 (mTLS opt-in per source) + gRPC internal mTLS
(today a TODO) + cert lifecycle UI + secrets rotation
**Estimate:** 16-24 days with one engineer; 10-15 days with two
engineers in parallel with M13c

---

## 0. Recap — what M14 must prove

| Surface | Acceptance criteria |
|---|---|
| **CA hierarchy** | Internal CA (root + intermediate) provisioned via cert-manager, root offline, intermediate serves cluster + sources |
| **Source-side mTLS** | A source with `mtls_required=true` is rejected at ingestd if the client cert is missing, expired, signed by an untrusted CA, or has the wrong CN/SAN. HMAC + API key still work for non-mTLS sources. |
| **Internal gRPC mTLS** | `ingestd`, `routerd`, `deliverd-*` talk over mTLS with cert-manager-issued certs. The `TODO(m11)` in `internal/grpcserver/server.go:61` is closed. |
| **Cert lifecycle UI** | Super-admin can: list source certs, see expiration, revoke, generate a CSR (or auto-issue), download the cert bundle. Expiration < 30 days triggers a banner. |
| **Rotation** | Intermediate CA rotates annually. Certs rotate every 90 days. No downtime during rotation. Documented runbook. |
| **PromQL alerts** | `CertExpiringSoon` (>30d, < 7d, < 24h buckets), `CertRevoked`, `mTLSHandshakeErrorsHigh` |
| **Audit log** | Cert issue, rotate, revoke, expiration all produce audit rows. |

**What M14 is NOT:**
- **Not a SOC2 / ISO 27001 certification.** M14 is a prerequisite, not
  the certification. Compliance certification is M15+ (separate
  project, separate budget, separate auditor).
- **Not a key-vault replacement.** M14 uses cert-manager secrets.
  HashiCorp Vault integration is v2.
- **Not customer-facing PKI.** Sources that need mTLS get a cert
  signed by OUR intermediate CA. We don't issue public certs.
- **Not a redesign of auth.** mTLS is layered on top of HMAC + API key
  in a "AND" relation: cert proves identity, HMAC proves payload
  integrity, API key is the rate-limit token. All three can be
  required for the strictest sources.

---

## 1. Threat model — what mTLS actually buys

| Threat | Without mTLS | With mTLS (opt-in) |
|---|---|---|
| **Stolen API key from a leaked source** | Attacker forges alerts forever | Cert pinned to the source machine — rotate the source cert, attack stops |
| **Replay of captured HMAC** | Within 5-min window per Stripe-style | Cert auth is per-connection; replay needs both cert AND HMAC |
| **Network MITM (compromised router)** | TLS protects in transit, but anyone with the API key can call | Cert pinned; MITM can't produce a valid client cert without the private key |
| **Insider abuse (operator with DB access)** | Can read API keys | Certs are on the source machine, not in the DB |
| **Credential stuffing** | Common API keys, brute force | Each cert is unique; no shared secret to stuff |

**M14 doesn't fix:** compromised source machine (attacker has the
private key), compromised root CA (game over, but offline), social
engineering of operator (cert issuance is gated by super-admin).

---

## 2. Workstream overview

```
┌──────────────────────────┐    ┌──────────────────────────┐
│  W1: cert-manager + CA   │    │  W2: ingestd mTLS        │
│  (root offline,          │───▶│  (opt-in per source,     │
│   intermediate in K8s)   │    │   cert validation,       │
│                          │    │   CN/SAN extraction)     │
└──────────────┬───────────┘    └──────────────┬───────────┘
               │                               │
               ▼                               ▼
┌──────────────────────────┐    ┌──────────────────────────┐
│  W3: gRPC internal mTLS  │    │  W4: cert lifecycle UI   │
│  (server + client,       │    │  (M13 frontend: list,    │
│   closes the TODO)       │    │   issue, rotate, revoke) │
└──────────────┬───────────┘    └──────────────┬───────────┘
               │                               │
               ▼                               ▼
┌──────────────────────────────────────────────────────────────┐
│  W5: rotation + PromQL alerts + audit + runbook              │
└──────────────────────────────────────────────────────────────┘
                                               │
                                               ▼
┌──────────────────────────────────────────────────────────────┐
│  W6: smoke E2E mTLS path + M14_VERIFICATION.md               │
└──────────────────────────────────────────────────────────────┘
```

Six workstreams. W1 is the only hard blocker. W4 (UI) can be split:
the API changes land in M14, the UI lands in M13b/c as a feature
incremental on top of the M13 sources module.

---

## 3. Workstream details

### W1: cert-manager + CA hierarchy

**Goal:** Provision a working internal PKI in the K8s cluster. Root
CA offline. Intermediate CA issues certs. cert-manager handles
renewal.

**Scope:**
- Choose CA tooling: **`step-ca` or `cfssl` for the offline root,
  `cert-manager` for the intermediate and serving certs.**
  - `step-ca` if you want a single binary and JSON-based config.
  - `cfssl` if you want the Cloudflare-style Go binary, lighter.
  - cert-manager is the standard for K8s and gives you
    `Certificate` CRDs, Issuer/ClusterIssuer, renewal controller.
- **Root CA (offline):**
  - Generate root key + cert on an air-gapped machine or a
    passphrase-encrypted USB stick.
  - 10-year validity. RSA 4096 or ECDSA P-384.
  - Stored in a K8s `Secret` of type `kubernetes.io/tls` in a
    `cert-manager` namespace, with sealed-secrets or external-secrets
    (NOT plaintext git).
  - Used ONLY to sign the intermediate. Never used to serve.
- **Intermediate CA (in-cluster):**
  - cert-manager `ClusterIssuer` backed by the root.
  - 1-year validity, rotated annually (cron triggers re-issue).
  - RSA 2048 or ECDSA P-256. ECDSA preferred (smaller, faster).
- **Serving certs (per-service):**
  - `Certificate` CRD per service (ingestd, routerd, deliverd-*,
    admind, authd).
  - DNS SANs: `<svc>.<namespace>.svc.cluster.local`, plus any
    public DNS name.
  - 90-day validity, auto-renewed at 30 days.
  - Mounted as files via `volumeMounts` from a Secret.
- **cert-manager deployment:**
  - Helm chart, `cert-manager v1.16+`.
  - RBAC for the controller.
  - DNS-01 or HTTP-01 challenge for public certs (none needed for
    in-cluster).
- **Source cert issuance path (used by W2):**
  - `Issuer` of type `CA` (cert-manager) backed by the
    intermediate.
  - Super-admin POSTs a CSR via API → cert-manager signs → cert
    bundle returned.
- **Tooling scripts:**
  - `scripts/ca-init.sh` — generates root + intermediate from a
    fresh machine. One-time use. Writes encrypted tarball.
  - `scripts/ca-rotate-intermediate.sh` — annual cron. Issues
    new intermediate from root, swaps cert-manager's
    ClusterIssuer, re-issues all serving certs.

**Exit criteria:**
- [ ] Root CA exists, offline, encrypted.
- [ ] Intermediate CA exists in K8s, valid for 1 year.
- [ ] cert-manager running, `kubectl get clusterissuer` shows
      `broad-announce-intermediate` Ready.
- [ ] 5 sample `Certificate` resources exist (ingestd, routerd,
      deliverd-fcm, deliverd-telegram, admind, authd) all
      Ready with valid serving certs.
- [ ] Auto-renewal tested: manually expire a cert at 30 days
      remaining, watch it renew without downtime.
- [ ] `scripts/ca-init.sh` and `scripts/ca-rotate-intermediate.sh`
      are documented in `cmd/certmanager/README.md`.

**Estimated:** 3-4 days.

---

### W2: ingestd source-side mTLS (opt-in per source)

**Goal:** A source with `mtls_required=true` is rejected at ingestd
if the client cert is missing, expired, signed by an untrusted CA,
or has the wrong CN/SAN. HMAC + API key still work for non-mTLS
sources.

**Scope:**
- `internal/auth/mtls.go` (new package):
  - `ClientCertVerifier` that wraps a `*x509.CertPool` (the
    intermediate) and a CN/SAN allowlist.
  - Extracts `r.TLS.PeerCertificates[0]` from the request.
  - Verifies: not expired, signed by intermediate, CN matches
    `source.<source_id>.<company_slug>` or SAN
    `source:<source_id>`, key usage `ClientAuth`.
  - Returns 401 with a structured error if any check fails.
- `cmd/ingestd/main.go`:
  - Two listeners: `:443` (TLS, with mTLS opt-in) and `:80`
    (plaintext, redirect to :443 for mTLS-required sources).
  - In practice, two HTTP servers on different ports. The
    mTLS one uses `tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool}`.
  - When `source.mtls_required=true`, route through the TLS
    server. Else, plain HTTP (existing behavior).
  - For sources with both HMAC and mTLS, both must succeed
    (AND, not OR).
- `internal/config/config.go`:
  - New env vars: `BA_INGESTD_TLS_ADDR` (default `:8443`),
    `BA_INGESTD_TLS_CERT` (path), `BA_INGESTD_TLS_KEY` (path),
    `BA_INGESTD_TLS_CA` (path to intermediate cert PEM).
  - `BA_INGESTD_REQUIRE_MTLS_DEFAULT` (default `false` —
    controlled per source).
- `internal/grpcserver/server.go`:
  - Close the `TODO(m11): grpc.Creds(tlsCredentials())` by
    wiring the cert paths from config.
  - The gRPC server is internal-only (not exposed to sources),
    so the cert is the serving cert from W1.
- Migrations: `migrations/NNNN_add_source_cert_columns.sql`:
  - `sources.mtls_required` (already in schema, just wire it).
  - `sources.mtls_cn` (CN expected, auto-derived from source_id).
  - `sources.cert_id` (FK to a new `source_certs` table).
  - `source_certs(id, source_id, serial, not_before, not_after,
    revoked_at, cert_pem)`.
- `cmd/admind/main.go` (new endpoints in M14):
  - `POST /v1/sources/:id/cert/csr` — accept a CSR (PEM), sign it
    with the intermediate, return the cert bundle.
  - `GET /v1/sources/:id/cert` — current cert + chain.
  - `DELETE /v1/sources/:id/cert` — revoke.
  - `GET /v1/certs/expiring?days=30` — list of certs expiring soon.
- New audit log events: `cert.issue`, `cert.revoke`, `cert.expire_soon`.

**Exit criteria:**
- [ ] `source.mtls_required=true` → ingestd rejects requests
      without a valid client cert (curl with `--cert` succeeds,
      curl without fails with 401).
- [ ] Cert signed by an untrusted CA → 401.
- [ ] Expired cert → 401.
- [ ] Wrong CN/SAN → 401.
- [ ] Cert revoked via `DELETE /v1/sources/:id/cert` → 401 within
      60s (cert-manager CRL updates).
- [ ] `source.mtls_required=false` → existing HMAC + API key flow
      unchanged.
- [ ] Both required (mTLS + HMAC) → both must succeed.
- [ ] Internal gRPC between ingestd, routerd, deliverd uses
      mTLS (W3) — verified with `tcpdump` or by
      `kubescape`/`kube-bench`.

**Estimated:** 5-7 days.

---

### W3: gRPC internal mTLS (server + client)

**Goal:** Close the `TODO(m11)` in `internal/grpcserver/server.go:61`
and the analogous client path. All service-to-service gRPC (and
HTTP/2 streams) use mTLS with cert-manager-issued certs.

**Scope:**
- `internal/grpcserver/server.go`:
  - Read `BA_GRPC_TLS_CERT`, `BA_GRPC_TLS_KEY`, `BA_GRPC_TLS_CA`
    from env.
  - `grpc.Creds(credentials.NewServerTLSFromCert(&cert))` — or the
    mTLS variant for client certs (if any service authenticates
    the other).
  - Hot-reload: SIGHUP or file watcher on the cert files.
    cert-manager renews → kubelet updates the Secret → file
    changes → service reloads without restart.
- `internal/grpcclient/client.go` and `options.go`:
  - Wire the `TransportCredentials` from the same env vars.
  - For mTLS to other services: load the cert-manager-issued
    client cert + CA.
- `internal/grpcclient/options.go:44`:
  - The "Production deployments should use mTLS or at minimum
    TLS" comment becomes code.
- `deploy/k8s/base/*/deployment.yaml`:
  - Mount the cert-manager Secret as a volume at
    `/etc/broad-announce/tls/`.
  - `lifecycle.preStop` hook to drain in-flight requests
    gracefully before pod termination (avoid 5xx during
    cert rotation).
- `internal/observability/metrics.go`:
  - `ba_grpc_tls_handshakes_total{result=ok|error,peer=...}`.
  - `ba_grpc_tls_cert_age_seconds{service=...}` (gauge).

**Exit criteria:**
- [ ] Internal gRPC between any two services uses TLS.
- [ ] `tcpdump` shows TLS handshake (not plaintext) for a sample
      gRPC call between ingestd and routerd.
- [ ] Cert rotation (manually expire a cert) doesn't drop a single
      in-flight request (verified with a load test).
- [ ] Hot-reload works: SIGHUP picks up the new cert without a
      pod restart.
- [ ] Metrics exported for handshake success/failure.

**Estimated:** 2-3 days.

---

### W4: cert lifecycle UI (M13 frontend)

**Goal:** Super-admin can manage source certs from the M13 UI. CSR
upload, cert download, revocation, expiration warnings.

**Scope:**
- Routes (added to M13b W2 — Sources module):
  - `/sources/:id/cert` — current cert info, expiration, status.
  - Modal: "Generate CSR" (paste a CSR, get a signed cert back) or
    "Auto-generate" (server generates a key + CSR, signs it,
    returns the cert bundle as a downloadable .zip).
  - Modal: "Revoke" with typed confirmation.
- Component: `CertStatusBadge` — green/amber/red based on
  expiration.
- Component: `CertExpirationBanner` — global banner in the top
  bar when any source cert is < 30 days from expiry. Clicks
  navigate to the cert list.
- New endpoints in `cmd/admind`:
  - `POST /v1/sources/:id/cert/csr` (from W2).
  - `GET /v1/sources/:id/cert`.
  - `DELETE /v1/sources/:id/cert`.
  - `GET /v1/certs/expiring?days=30`.
- Bundle: cert-management code-split (< 15 KB gzipped).

**Exit criteria:**
- [ ] Super-admin can upload a CSR and download a signed cert.
- [ ] Super-admin can use "Auto-generate" and get a `.zip` with
      cert + chain + private key (one-time download).
- [ ] Revoke button removes the cert; subsequent requests with
      that cert fail within 60s.
- [ ] Expiration banner appears 30 days before any cert expires.
- [ ] Tenant-admin sees cert info for their sources (read-only,
      no revoke).
- [ ] Bundle size < 15 KB gzipped.

**Estimated:** 3-5 days (in parallel with M13b, lands in M13c W7).

---

### W5: rotation + PromQL alerts + audit + runbook

**Goal:** Automated cert rotation. PromQL alerts for expiration and
revocation. Audit log for every cert event. Runbook for incident
response.

**Scope:**
- Rotation:
  - cert-manager handles serving certs automatically (90-day,
    renew at 30).
  - `cert-manager.io/renew-before: 720h` annotation.
  - Source certs: 90-day validity, super-admin is notified
    30/7/1 days before via the banner.
  - Annual intermediate CA rotation: `scripts/ca-rotate-intermediate.sh`
    (from W1).
- PromQL alerts (in `deploy/prometheus/alerts.yml`):
  - `CertExpiringSoon` (severity warning): `cert_not_after -
    time() < 30 * 86400` for any source cert.
  - `CertExpiringCritical` (severity critical): `< 7 * 86400`.
  - `CertExpired` (severity critical): `cert_not_after < time()`.
  - `CertRevoked` (severity info): counter increment.
  - `mTLSHandshakeErrorsHigh`: rate of failed handshakes > 1%
    for 5 min.
- Audit log:
  - `cert.issue` — who, which source, cert serial.
  - `cert.rotate` — auto-rotation, no actor.
  - `cert.revoke` — who, which source, reason.
  - `cert.expire` — auto, when cert is detected expired.
- Runbook (`docs/runbooks/mtls-incident.md`):
  - "Cert expired and source is down" — reissue flow.
  - "Private key compromise" — revoke + reissue + audit.
  - "CA compromise" — generate new root offline, re-issue
    intermediate, re-issue all certs. Documented as M14.5
    emergency procedure.
  - "Handshake errors spiking" — likely clock skew or CA
    pool issue. Check cert chain, check issuer.

**Exit criteria:**
- [ ] All 5 PromQL alerts fire in a test scenario (expire a
      cert, revoke a cert, simulate clock skew).
- [ ] Annual rotation script tested in a staging cluster.
- [ ] Runbook reviewed by 2 people (you + me, or you + a
      peer).
- [ ] Audit log entries for issue, rotate, revoke, expire.

**Estimated:** 2-3 days.

---

### W6: smoke E2E mTLS path + M14_VERIFICATION.md

**Goal:** End-to-end smoke that exercises the full mTLS path:
cert issuance → cert-bound source → ingestd validation → alert
accepted → cert revocation → alert rejected.

**Scope:**
- `scripts/m14_smoke.sh`:
  1. Bring up stack with cert-manager + intermediate CA.
  2. Super-admin creates a company.
  3. Super-admin creates a source with `mtls_required=true`.
  4. Super-admin generates a cert for the source via API.
  5. `curl` with `--cert` and `--key` to ingestd using that
     cert + HMAC → 200.
  6. `curl` without cert → 401.
  7. `curl` with expired cert → 401.
  8. `curl` with HMAC-only (cert stripped) → 401.
  9. Revoke the cert via API.
  10. `curl` with revoked cert → 401.
  11. Send a real alert via the working path → see it in
      live tail (M13c).
- `M14_VERIFICATION.md`:
  - Smoke log.
  - Cert chain diagram (root → intermediate → serving).
  - Bundle size breakdown.
  - The 5 PromQL alerts with sample output.
  - Runbook link.
  - Sign-off: "broad-announce v1 supports opt-in mTLS per
    source, with cert-manager-managed rotation, lifecycle
    UI, and incident response runbook."

**Exit criteria:**
- [ ] `make m14-smoke` exits 0 from a clean state.
- [ ] 3 consecutive green runs.
- [ ] M13 functionality not regressed.
- [ ] M14_VERIFICATION.md published.

**Estimated:** 1-2 days.

---

## 4. Sequencing & parallelism

```
W1 (3-4d) ──┬──▶ W2 (5-7d) ──┬──▶ W4 UI portion (3-5d, parallel with M13)
            │                 │
            │                 ├──▶ W3 (2-3d, parallel with W2)
            │                 │
            │                 └──▶ W5 (2-3d) ──▶ W6 (1-2d)
            │
            └── (W1 is the only hard blocker)
```

**With one engineer (sequential):**
- W1: 3-4d
- W2 + W3: 7-10d (can overlap, W3 short)
- W4: 3-5d (after M13b lands)
- W5: 2-3d
- W6: 1-2d
- **Total: 16-24 days**

**With two engineers (W4 lands in M13c):**
- Eng 1: W1 → W2 → W5 → W6 (9-12 days)
- Eng 2: M13c + W4 (parallel with W2) → W3 → W5 (10-15 days)
- **Total wall time: 10-15 days**

---

## 5. What M14 is NOT

(Recorded so a future reader doesn't re-litigate.)

- **Not a SOC2 certification.** M14 builds the technical controls.
  SOC2 is an audit process (M15+).
- **Not customer-issued public certs.** We sign with our internal
  intermediate. Sources that need a public cert bring their own.
- **Not Vault / KMS integration.** cert-manager secrets are K8s
  Secrets (sealed-secrets or external-secrets encrypted at rest).
  HashiCorp Vault + dynamic certs is v2.
- **Not HSM-backed root.** The root is on an encrypted USB stick
  (or air-gapped VM). HSM is v3.
- **Not a full PKI replacement.** External CAs (DigiCert, Let's
  Encrypt) are still used for any public-facing endpoints (the
  broad-announce marketing site, the public docs). M14 is for
  in-cluster + source-side certs.
- **Not a redesign of the data plane.** ingestd, routerd, deliverd
  keep their Go code. M14 layers TLS on top, doesn't rewrite.
- **Not retroactive.** Existing sources with HMAC + API key
  continue working. mTLS is opt-in per source. No flag day.

---

## 6. Dependencies

| From | What | Status | Affects |
|---|---|---|---|
| M12 W1 | K8s manifests exist | ⏳ in flight | W1 (cert-manager deploy) |
| M11 | gRPC infra stable | ✅ done | W3 (gRPC mTLS) |
| M13a | authd + JWT gate | ⏳ not started | W4 (cert UI uses auth) |
| M13b | Sources CRUD | ⏳ not started | W4 (cert UI is incremental on Sources) |
| Postgres | `source_certs` table | to be created in W2 migration | W2 |
| cert-manager | upstream chart | ✅ available | W1 |

---

## 7. Risks

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Root CA compromise | Very low | Catastrophic | Root offline + encrypted; documented re-issue procedure (full cluster cert rotation) |
| Cert rotation drops in-flight requests | Medium | High | PreStop hook + graceful drain + 30-day renewal window + W3 hot-reload |
| Operator issues cert to wrong source | Medium | Medium | Cert bound to source_id in CN/SAN; UI shows source label before issuance |
| `mtls_required=true` breaks an existing source | Medium | High | Opt-in per source; rollout plan: 1 source, 5 sources, all sources, default-on (3 quarters) |
| `cert-manager` operator upgrade breaks Issuer | Low | High | Pin chart version; test upgrade in staging first |
| CRL distribution lag → revoked cert still works | Medium | Medium | 60s SLA in W2; OCSP responder for real-time (v2) |
| Sources can't generate CSRs (legacy systems) | Medium | Medium | "Auto-generate" button in UI: server generates key+CSR, returns bundle |

---

## 8. Definition of done — M14

- [ ] All 6 workstreams have their per-workstream exit criteria
      checked.
- [ ] `make m14-smoke` green for 3 consecutive runs.
- [ ] M13 functionality not regressed.
- [ ] M14_VERIFICATION.md published.
- [ ] Runbook reviewed by 2 people.
- [ ] SPEC.md `M14` row flipped to **✅ shipped YYYY-MM-DD** with
      the entry: *"opt-in mTLS per source + cert-manager-managed
      rotation + cert lifecycle UI + internal gRPC mTLS + runbook.
      Closes M11.5 wishlist. See M14_VERIFICATION.md for evidence."*

---

## 9. What M14 buys the business

| Before (today) | After (M14) |
|---|---|
| "API key + HMAC" in marketing | "mTLS opt-in per source" in marketing |
| Stolen API key = game over | Cert rotation stops the attack |
| gRPC internal plaintext (TODO) | gRPC internal mTLS |
| Manual cert management (if any) | cert-manager + UI |
| "Enterprise" in pitch deck | "Enterprise" in feature matrix |
| No compliance trail | Audit log for every cert event |
| Not SOC2-ready | **SOC2-ready controls in place** (cert audit is one of the SOC2 controls) |

**What it does NOT buy:** SOC2 itself, FedRAMP, HIPAA. Those are
certifications, not features. M14 builds the technical controls
that the auditors will check; getting the certification is a
separate project (M15).

---

## 10. Addendum (2026-06-16) — execution ordering: M14-backend before M13

**Decision (option A, confirmed by user):** M14 is split into two
execution phases that decouple from M13:

| Phase | Workstreams | Type | Runs when | Estimates |
|---|---|---|---|---|
| **M14-backend** | W1 + W2 + W3 + W5 + W6 | Backend (K8s + Go + PromQL + smoke) | **Before M13a.** Parallel with M13a. | 13-18d |
| **M14-ui** | W4 | Frontend (Vite + React) | **Inside M13b W2** (Sources module), as a feature incremental | 3-5d |

**Why split:** user wants all backend work finished before the
frontend. 5 of 6 workstreams are backend-only; W4 is the only one
that touches the UI. M14-ui becomes a feature of the Sources module
in M13b, not a standalone milestone.

**Updated sequencing:**

```
M12 W1 (K8s) ─┬─▶ M14-backend W1 (cert-manager) ─▶ M14 W2 (ingestd mTLS)
              │                                       │
              │                                       ├─▶ M14 W3 (gRPC mTLS)
              │                                       │
              │                                       └─▶ M14 W5 (rotation + alerts) ─▶ M14 W6 (smoke)
              │
              └─▶ M13a W1 (authd) ─▶ M13a W2-W4 (web shell + JWT gate)
                                              │
                                              └─▶ M13b W1 (Companies) ─▶ M13b W2 (Sources + M14-ui W4) ─▶ M13b W3 (Telegram) ─▶ M13c
```

**What this means for M13 plans:**

- `M13b_PLAN.md` W2 (Sources CRUD) grows by ~3-5d to include the
  cert lifecycle UI (M14 W4). The cert UI is a new tab in the
  Source detail page.
- `M13c_PLAN.md` is unchanged (no cert work needed there).
- `M14 W4 exit criteria` move into `M13b_PLAN.md` W2 as a
  sub-section.

**What this means for M14-backend:**

- W1-W3, W5, W6 ship as **M14-backend** with its own
  `M14_BACKEND_VERIFICATION.md` and a SPEC.md row flip.
- M14-backend is independent of M13a. Both can run in parallel
  after M12 W1 lands.
- M14-ui ships as part of M13b; no separate verification doc.

**SPEC.md M14 row should be split** into:

- `M14 (backend)` — mTLS opt-in per source + cert-manager +
  rotation + alerts + runbook + smoke. Targets: enterprise
  technical controls.
- `M14 (ui)` — Cert lifecycle UI in Sources module. Ships as
  part of M13b.

**Exit criteria (whole M14, both phases):**

- [ ] M14-backend: `make m14-smoke` green for 3 consecutive runs
      (the existing W6 smoke, unchanged).
- [ ] M14-backend: M14_VERIFICATION.md published.
- [ ] M14-ui: cert UI ships inside M13b, covered by M13b smoke +
      Playwright E2E (M13c).
- [ ] SPEC.md rows: `M14 (backend)` and `M14 (ui)` both flipped
      to ✅ shipped.
- [ ] Runbook reviewed by 2 people.
- [ ] No regression on M11 / M12.

**Next step:** start M14-backend W1 (cert-manager + CA). M13a W1
(authd) can start in parallel.
