feat: SES production access and monitoring; §7 as single source of operational truth

Q19 is closed — SES production access granted in ca-central-1, confirmed in
writing. Nothing now blocks /contact/.

The structural change is the important one. Specs in docs/ carried their own
copies of resource IDs, regions, DNS records and service state. AGENTS.md §7
is now the single source of truth for operational facts and docs/ cite it
rather than restating it, with the rule recorded in CLAUDE.md under
Conventions.

The reason is the previous commit's DKIM inversion, generalised: the same
fact lived in §7 and docs/05, a correction reached one of them, and the stale
copy told an operator to delete the records that authenticate outbound mail.
A duplicated fact is one that will eventually be wrong in one place, and the
copy that goes stale is the one nobody re-reads. Verified by grep over
docs/*.md — no operational identifier remains.

Also in this change:

- §7 records the SES monitoring: SNS topic ses-alerts, alarms
  SES-BounceRate-High (>= 0.03) and SES-ComplaintRate-High (>= 0.001), and
  the deliberate choice of email feedback forwarding over an SNS feedback
  topic at this volume. The ses-alerts email subscription is stamped PENDING
  CONFIRMATION — the alarms currently notify nobody, now tracked as R9 and on
  the cutover checklist.
- docs/05 records why those alarms are a real control: SES suspends above
  roughly a 5% bounce rate, and under 100 messages a month five bounces
  crosses it.
- Q29: the deploy guard now covers AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
  (emptiness only, never echoed) and INTAKE_ENDPOINT, promoted to job-level
  env. An empty intake endpoint ships a live form posting to nothing, which
  is worse than a failed build. Executed under sh -e across four input
  states; fails closed, leaks nothing.
- docs/06: account ID removed from the backup-bucket callout, pointing at §10
  instead, as README already does.
- astro.config.mjs: prefetch removed entirely. Any setting ships Astro's
  prefetch script to every page against the zero-JS convention. Recorded as a
  decision; revisit against real Lighthouse numbers.

AGENTS.md entry (r) records the full reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
Pouya Lajevardi
2026-08-26 11:47:17 -04:00
co-authored by Claude Opus 5
parent 6bf1167624
commit 2b6176e4d7
6 changed files with 244 additions and 70 deletions
+131 -5
View File
@@ -249,7 +249,7 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
| Intake Lambda | `adr-intake-handler`, `nodejs24.x` `[verified 2026-08-26]` |
| Intake table | `adr-intake-submissions` (DynamoDB, ca-central-1) `[verified 2026-08-26]` |
| SES identities | Domain `smlcompany.ca` **verified for sending** `[verified 2026-08-26]`; addresses `info@`, `intake@`, `adr@` |
| SES account | **In the sandbox**`ProductionAccessEnabled: false` `[verified 2026-08-26]`. Production access required (Q19) |
| SES account | **Production access GRANTED** — out of the sandbox in `ca-central-1`, confirmed by AWS in writing and effective immediately `[verified 2026-08-26 — Q19 closed]`. Mail now reaches unverified recipients, so the inquirer confirmation in D18 works |
| Mail hosting | **Google Workspace** — MX `1 smtp.google.com`; `google._domainkey` present, so Google DKIM is configured `[verified 2026-08-26 — DNS query]` |
| **SPF** | **PRESENT.** `v=spf1 include:_spf.google.com include:amazonses.com ~all` on `@`, added by Pouya and independently verified `[verified 2026-08-26 — DNS query, Q20]` |
| **DMARC** | **PRESENT.** `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` on `_dmarc`, added by Pouya and independently verified `[verified 2026-08-26 — DNS query, Q20]`. Still at `p=none`; tighten to `quarantine` only after reports come back clean |
@@ -258,8 +258,11 @@ the audience it targets. Revisit at month 1218. `[verified 2026-08-25 — dec
| SES DKIM — **the set that is BROKEN** | `3zsnvsjg3ddi6hjyndjy6jgjoitvhydl`, `jejgp7na3wdpprsanamct4uxxgw2tyqw`, `xpiwyftpodmgpnzfof3ee7t7fzmlmhnh` — listed in the SES console, present in Namecheap, but **NXDOMAIN**. They were entered with the full name in Namecheap's Host field, so the zone doubled the domain: `<token>._domainkey.smlcompany.ca.smlcompany.ca` resolves, the correct name does not `[verified 2026-08-26 — DNS]` |
| SES identity — **`ca-central-1` (the one that matters)** | `Verified: true`, `DkimStatus: SUCCESS`, tokens `kznn…` / `jdue…` / `f5pu…` — the set that resolves. **Healthy. Change nothing** `[verified 2026-08-26]` |
| SES identity — `us-east-1` (unused) | `Verified: false`, `DkimStatus: PENDING`, tokens `3zsn…` / `jejgp…` / `xpiwy…` — the doubled-domain set. A stray identity in a region this project does not use `[verified 2026-08-26]` |
| ⚠️ **SES sandbox is per-region** | Production access must be requested **in `ca-central-1`**. The AWS console defaults to `us-east-1`; requesting there grants access to the wrong region and the intake email still fails `[verified 2026-08-26]` |
| ⚠️ **SES sandbox is per-region** | Recorded as a standing caution, not an open task — production access **was** granted in `ca-central-1` (Q19). The trap: the AWS console defaults to `us-east-1`, and granting access there leaves `ca-central-1` in the sandbox with the intake email still failing. Applies again to any future SES request `[verified 2026-08-26]` |
| SES custom MAIL FROM | **Not configured** — SES reports "No MAIL FROM records found". This is why SES's SPF is unaligned and DMARC rests on DKIM. Optional future improvement, not required `[verified 2026-08-26]` |
| SES monitoring — SNS topic | **`ses-alerts`**, `ca-central-1`. Email subscription to `info@smlcompany.ca` is **PENDING CONFIRMATION, not confirmed** `[verified 2026-08-26]`. **Until the subscription is confirmed the alarms below fire into nothing** — confirm the link in the subscription email |
| SES monitoring — alarms | **`SES-BounceRate-High`** (bounce rate ≥ `0.03`) and **`SES-ComplaintRate-High`** (complaint rate ≥ `0.001`), both `ca-central-1`, `treat-missing-data: notBreaching` `[verified 2026-08-26]` |
| SES bounce/complaint handling | **Email feedback forwarding** — SES's default, on unless disabled. **Deliberately not an SNS feedback topic:** at under 100 messages a month there is nothing to consume a programmatic feed. Revisit when code needs to act on a bounce `[verified 2026-08-26]` |
| **DMARC alignment** | SES satisfies DMARC via **DKIM**, not SPF — without a custom MAIL FROM domain its envelope sender is at `amazonses.com` and is unaligned. The SPF record's real job is authenticating **Google Workspace** mail `[verified 2026-08-26]` |
| **Do not delete** | Two ACM validation CNAMEs → `jkddzztszm.acm-validations.aws`. They renew the `adr.smlcompany.ca` certificate; deleting them breaks HTTPS silently at the next renewal `[verified 2026-08-26]` |
| Root domain | `@` carries a Namecheap **URL Redirect Record** to `sml-coming-soon.ca`. TXT records coexist with it — adding SPF does not disturb it `[verified 2026-08-26]` |
@@ -309,14 +312,14 @@ Nothing below can be invented. Each needs an answer from Pouya.
| ~~Q9~~ | **ANSWERED 2026-08-26.** Gitea, repo `adr-sml`, clone at `/Users/pouya/Dev/Websites/adr-sml` | — |
| ~~Q10~~ | **ANSWERED 2026-08-26.** Full inventory captured; values in §7 | — |
| ~~Q18~~ | **ANSWERED 2026-08-26.** `smlcompany.ca` is verified for sending — `true`. The `None` in the earlier listing was a query artefact, not a fault | — |
| **Q19** | **CONFIRMED AS A BLOCKER 2026-08-26. Request it in `ca-central-1`, not the console's default `us-east-1` — the sandbox is per-region.** `ProductionAccessEnabled: false` — the SES account is in the **sandbox**, so mail reaches only pre-verified addresses. Confirmed with Pouya (D18) that the inquirer receipt is wanted, so production access **is** required. ~24 h AWS turnaround. **The only item on this project with external lead time** | The confirmation half of `/contact/` |
| ~~Q19~~ | **ANSWERED 2026-08-26. Production access granted** in `ca-central-1`, confirmed by AWS in writing, effective immediately. The per-region trap was real and was avoided — it was requested in `ca-central-1`, not the console's default `us-east-1`. **Nothing now blocks `/contact/`** | — |
| ~~Q20~~ | **ANSWERED 2026-08-26.** Both records added by Pouya and independently verified: SPF `v=spf1 include:_spf.google.com include:amazonses.com ~all` on `@`, DMARC `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` on `_dmarc`. Both existing `@` TXT records, Google DKIM, MX, and the `adr` CNAME all confirmed intact | — |
| ~~Q21~~ | **ANSWERED 2026-08-26.** `ca-central-1` is healthy (`SUCCESS`) on the resolving token set. The broken records belong to a stray `us-east-1` identity this project does not use. **Nothing to fix.** The three dead CNAMEs are inert; leaving them is the low-risk choice | — |
| ~~Q11~~ | **ANSWERED 2026-08-26.** Privacy-first, cookieless — Plausible or Fathom. No GA4, no consent banner | — |
| ~~Q12~~ | **ANSWERED 2026-08-26.** `https://www.linkedin.com/in/pouyalajevardi/` | — |
| **Q27** | **Is a one-business-day response time a commitment you want published?** `src/data/site.ts` carried `responseTime: 'Inquiries are answered within one business day.'` — a service level a reader can hold you to, and not in the §4 register. Set to `null` with a `TODO(pouya)` until confirmed. Raised by `claims-auditor`, 2026-08-26 | The `/contact/` page copy |
| **Q28** | **Which OBA sections?** §4 verifies OBA membership but marks the *section list* `[assumed]` — the brief names Construction & Infrastructure, ADR, and Civil Litigation. `src/data/site.ts` was publishing all three under a blanket `[verified]` stamp; now marked and flagged. Raised by `claims-auditor`, 2026-08-26 | The `/about/` credentials list |
| **Q29** | **Should the deploy guard cover the secrets and `INTAKE_ENDPOINT` too?** As built it covers the three deploy-target variables only. Two gaps, both real: an unset `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**the most likely first-run failure, since Q22 is unanswered** — passes the guard and burns a full build before dying at `sts get-caller-identity`; and an empty `vars.INTAKE_ENDPOINT` passes silently and **ships a live form posting to nothing**. The second needs the variable promoted to job-level `env:`. Not done: you specified three variables and this widens the guard. Raised by `adversarial-reviewer`, 2026-08-26 | Nothing yet — `src/pages/` is empty. Blocks `/contact/` shipping safely |
| ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — |
| **Q23** | **Is a Gitea `act_runner` registered to this repository or its organisation?** Without one the workflow queues silently and never runs — indistinguishable from a broken pipeline (`docs/06-deployment.md`). *The Gitea-version half of this question is closed:* rather than record a version fact that goes stale, `.gitea/workflows/deploy.yml` now runs a guard as its **first** step that fails the run if `AWS_REGION`, `S3_BUCKET`, or `CLOUDFRONT_DISTRIBUTION_ID` is empty — which is how a pre-1.21 instance manifests. Pouya's decision, 2026-08-26. Runner registration is not something a guard can cover | The first deploy |
## 10. Risks
@@ -330,7 +333,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
| Cutover breaks the live site (D11 is a single-shot deploy) | Medium | Full pre-cutover checklist in `docs/06-deployment.md`; CloudFront can be rolled back to the prior origin path |
| Twenty pages of thin copy rank worse than six good ones | Medium | Each page must justify itself with substantive content. Ship fewer pages rather than padded ones |
| **Deploy-credential blast radius.** AWS account `327082975128` is not a single-project account. It also holds `meshkinilaw.ca` and its preview site, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and **`mlp-clientdb-prod-backups-327082975128`** (recorded in an earlier entry with the account suffix; the short form `mlp-clientdb-prod-backups` is used elsewhere in the docs) — which **by its name** holds production client-database backups. Only the name was ever observed; the contents were not inspected and the owner is not established | **High** | A static deploy key for a personal website must never be able to reach a client database. The scoped IAM policy in `docs/06-deployment.md` grants four actions on one bucket and one distribution and nothing else — that narrowness is now load-bearing, not hygiene. Never widen it. Never reuse the `user/pouya` credentials in CI `[verified 2026-08-26 — inventory]` |
| **SES is in the sandbox** — confirmed, not hypothetical | **High** | Q19. Until production access is granted, every confirmation email to an inquirer fails silently. The site appears to work; the inquirer concludes they were ignored. Request access early — it is the only dependency with an external turnaround |
| ~~SES is in the sandbox~~ | ~~High~~**RESOLVED 2026-08-26** | Q19 closed: production access granted in `ca-central-1`, confirmed in writing. Residual, tracked in §7: the `ses-alerts` SNS email subscription is **pending confirmation**, so the bounce and complaint alarms currently notify nobody `[verified 2026-08-26]` |
| ~~No SPF and no DMARC on `smlcompany.ca`~~ | ~~High~~**RESOLVED 2026-08-26** | Both records added by Pouya and independently verified (Q20). SPF authenticates Google Workspace mail; SES satisfies DMARC through DKIM alignment on the three resolving CNAMEs. DMARC sits at `p=none` — the residual task is to review reports and tighten to `quarantine`, which is monitoring, not a risk `[verified 2026-08-26 — DNS query]` |
## 11. Glossary
@@ -370,6 +373,7 @@ never being raised again.
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
| R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive | 2026-08-26 | D14 is priced for where the practice is going, not where it is |
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
| ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — |
| ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — |
@@ -377,6 +381,128 @@ never being raised again.
# Change Log
## 2026-08-26 (r) — Q19 closed; SES monitoring recorded; §7 made the single source of operational truth
**Who:** Pouya closed Q19, supplied the monitoring configuration, ruled on Q29,
and made the structural call below. Claude Code implemented.
### Q19 — CLOSED. Nothing now blocks `/contact/`
**Production access granted** in `ca-central-1`, confirmed by AWS in writing,
effective immediately. §7's SES account row and §9's Q19 both record it; §10's
**High** risk row for the sandbox is struck as resolved.
The per-region trap is kept in §7 as a **standing caution rather than an open
task** — it was avoided, and it applies again to any future SES request. Deleting
the row would delete the lesson.
### Monitoring exists, and one part of it does not work yet
Recorded in §7 (the facts) and `docs/05-backend-spec.md` (why it matters):
- **SNS topic `ses-alerts`**, `ca-central-1`. The email subscription to
`info@smlcompany.ca` is **PENDING CONFIRMATION**, stamped that way deliberately.
An unconfirmed SNS subscription **drops every message**, so as things stand the
alarms below fire into nothing. That is now a cutover checklist item in its own
right, checked with `sns list-subscriptions-by-topic` rather than assumed.
- **`SES-BounceRate-High`** (≥ `0.03`) and **`SES-ComplaintRate-High`**
(≥ `0.001`), both `ca-central-1`, `treat-missing-data: notBreaching`.
- **Bounce and complaint handling is SES email feedback forwarding** — the
default — **not an SNS feedback topic**, deliberately. Under 100 messages a
month there is nothing to consume a programmatic feed, and an unused topic is
one more thing to keep correct. Revisit when code needs to *act* on a bounce:
suppression lists, retry logic, marking a record undeliverable.
`docs/05` records why these are a real control rather than a formality: **SES
suspends sending above roughly a 5% bounce rate, and at this volume five bounces
crosses it.** An intake form is exactly where mistyped addresses arrive. The
alarms sit well below that line so there is room to react.
### The structural fix — §7 is the single source of truth for operational facts
**Old → new.** Specs in `docs/` carried their own copies of resource IDs,
regions, DNS records, and service state. They now **cite** §7 instead.
The reasoning is entry (q)'s DKIM inversion, generalised: the same operational
fact lived in §7 and in `docs/05`, a correction reached one of them, and the
stale copy ended up instructing an operator to delete the three records that
authenticate outbound mail — under the heading "Never delete". Same class as the
D3 amendment surviving three sweeps. **A duplicated fact is a fact that will
eventually be wrong in one place, and the copy that goes stale is the one nobody
re-reads.**
The rule is now in `CLAUDE.md` under Conventions, with that incident as its
stated reason so it is not softened later by someone who does not know the cost.
**Swept.** `docs/05` no longer restates the DKIM token sets, the DNS records, the
MX, the region, or the table name — it cites §7 and keeps only the two facts it
*depends* on, stated as dependencies. `docs/06` no longer restates the region,
bucket, distribution ID, intake endpoint, or account ID; its variable table now
points at §7 rows, and the IAM substitutions say explicitly that they are not
repeated and why. Verified by command:
```
grep -rn "ca-central-1\|adr-smlcompany-site\|E1OK7G98KNKUTA\|4tl0m5igkj\|327082975128\|adr-intake-submissions\|d26v23dhgsp2ta\|jkddzztszm" docs/*.md
```
Output read: **no matches.** Every operational identifier in `docs/` is now a
reference.
### Q29 — the guard is widened on both counts
Both gaps `adversarial-reviewer` found are closed:
- **`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are guarded.** By Q22 nobody
has confirmed the IAM user or its key exists, so an unset key is the single
likeliest first-run failure — excluding it defeated the guard's stated purpose.
**Only emptiness is tested and no value is ever echoed**, so nothing can leak
into a run log.
- **`INTAKE_ENDPOINT` promoted to job-level `env:` and guarded.** An empty one
does not fail a build; it **ships a live contact form posting to nothing**. A
silent production defect is worse than a failed build.
The build step still reads `${{ vars.INTAKE_ENDPOINT }}` rather than the `env`
context — depending on Gitea's expression-context support is the exact thing the
guard exists not to do. Coverage checked against every `vars.*` and `secrets.*`
the workflow consumes: all guarded except `BOOKING_URL`, which is empty by
decision (R6).
### Also done
**`docs/06`: the account ID is out of the backup-bucket callout**, pointing at
§10 instead — the same removal entry (m) made in `README.md`. Operational
usefulness does not outweigh pairing a live account number with a statement about
what the account holds, in a file that gets pasted around. The billing check now
derives the account from `sts get-caller-identity` rather than hard-coding it.
**`astro.config.mjs`: `prefetch` removed entirely** — recorded here as a decision,
not a silent deletion. *Any* prefetch setting ships Astro's prefetch script to
every page, against CLAUDE.md's "default to zero JS", for a marginal gain on a
small static site already served from CloudFront. **Revisit only against real
Lighthouse numbers**, once pages exist to measure. Entry (q) had set it to
`hover`; that was still JS on every page.
**Gitea version stays `[assumed]`** at Pouya's direction — he is running the
check. The guard is designed not to need the answer.
### Verification
The widened guard's `run:` block was **executed** under `sh -e` across four input
states — all set, all empty, secret empty, intake empty. Exit 0 only when
everything is present; exit 1 naming exactly the missing items otherwise; no
value printed in any case. `node --check astro.config.mjs` passes. The
duplication sweep was run as the `grep` above and its output read — no matches.
`npm run lint`, `check` and `build` still **cannot run**: `node_modules` is
absent and `npm ci` needs a lockfile. Installing dependencies is the first
concrete task of build step 1.
**Closed:** Q19 (and with it the last blocker on `/contact/`). **Still open:**
Q22 (deploy credential), Q23 (runner registration), Q27, Q28, R6 (parked).
**Q29 closed by implementation.**
---
## 2026-08-26 (q) — Guard built; and the audit found an inverted DKIM table that would have broken production mail
**Who:** Pouya ruled on entry (o)'s escalations, ratified R7, promoted R8, and