# M32.x — Panel cert split: independent hostname + mail certs

**Status:** Blueprint
**ADR:** 0105 (amends 0066)
**Trigger:** mx incident 2026-05-17 — Panel SSL showed one blended
"Issuing…" badge; operator could not tell that mx.jabali-panel.com was
fine while mail.<panelhost> was the laggard. Current code disabled the
mail SAN entirely (`extra_hostnames:[]`) — wrong fix; correct fix is
*separation* + per-name status, not exclusion.

## 1. Goal

Panel SSL tracks + issues the panel **hostname** cert and the panel
**mail** cert as **two independent certs**, each with its own status,
retry/backoff, and Refresh. mx is NEVER blocked by mail. UI shows two
status rows. Mail issuance is gated on mail.<hostname> routability so
an unpointed mail record (e.g. Cloudflare-fronted) parks the *mail* row
with a clear reason instead of failing anything.

## 2. Locked decisions

- `panel_certificate` gains `kind` ∈ {`hostname`,`mail`}. Two rows.
  PK becomes `kind` (drop the id=1 singleton). Each row owns its full
  state machine (ADR-0066 statuses, unchanged) + cert path.
- Cert paths: hostname → `/etc/jabali/tls/panel.crt` (unchanged);
  mail → `/etc/jabali/tls/panel-mail.crt` / `panel-mail.key` (new).
- Agent `ssl.panel.issue` reused per-kind: single hostname, distinct
  deploy target per kind. Deploy-hook reload chain per cert
  (hostname: nginx+jabali-panel+bulwark; mail: nginx+stalwart).
  Wire contract pinned by test (`feedback_verify_wire_contract`).
- Mail row gated by `Routability.Check(mail.<hostname>)` preflight in
  the reconciler. Not routable → mail row `pending_acme` +
  `last_error="mail DNS not pointed at server"`, independent 3h retry.
  Hostname row reconciles + issues regardless.
- DB-as-truth (ADR-0002): seeding the two rows is app-side
  (`PanelCerts.EnsureDefault`), NOT the migration
  (`feedback_migration_data_seed_ordering`). Migration = schema only,
  `utf8mb4_unicode_ci` on the table.
- No regression: a host with only the hostname row (pre-migration)
  migrates to kind=hostname; EnsureDefault adds the mail row.

## 3. ADR

| ADR | Title |
|-----|-------|
| 0105 | Panel cert split — independent hostname + mail certs, mail routability-gated, per-name status (amends 0066) |

## 4. Waves (one branch `m32.x/panel-cert-split`, one PR, inline)

1. **Migration 000137 + model + repo.** Add `kind varchar(16) not
   null`, repoint PK to `kind`, backfill existing row → `hostname`.
   `PanelCertificate.Kind` field; drop singleton id default.
   `PanelCertRepository`: `GetByKind`, `ListAll`, `EnsureDefault`
   (ensures both kinds), `Upsert`/`MarkPendingRetry` keyed by kind.
   sqlmock repo tests. RED→GREEN.
2. **Reconciler.** Iterate both kinds. New `panelMailHostname(h)` =
   `mail.`+h. Mail kind: `Routability.Check` preflight → if not
   routable, park `pending_acme` + reason, schedule retry, DO NOT
   touch hostname row. Pure decision helper unit-tested.
3. **Agent wiring.** `ssl.panel.issue` gains explicit deploy target
   (cert/key path) per call; per-kind deploy-hook (hostname vs mail
   reload set). Golden/contract test on the envelope + deploy target.
4. **API + UI.** `/admin/panel-certificate` returns `[]` (both kinds)
   + per-kind issue/refresh. `PanelSSLCard` / `SSLManagerPage` Panel
   SSL block renders two status rows, each badge + Refresh + reason.
5. **ADR→Accepted, tests, runbook note, full build/vet/test, PR.**

## 5. Scars honored

Schema-only migration + collation; data seed app-side not migration;
wire contract pinned to real `ssl.panel.issue`; mail never blocks
hostname (independent rows/retry); branch-only until ship-ready;
inline; `npm run build` before UI green.

## 6. Risks

- PK change on a live table (singleton→kind). Migration must backfill
  the existing row's kind=hostname BEFORE switching PK; test against
  a populated table (mx has a row).
- Stalwart cert wiring for mail cert: confirm what serves
  mail.<hostname> TLS (Stalwart submission/IMAPS + mail nginx vhost)
  and that the deploy-hook reloads it. Verify in Wave 3.
- Reconciler must not double-dispatch across the two rows.
