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:
co-authored by
Claude Opus 5
parent
6bf1167624
commit
2b6176e4d7
+46
-23
@@ -73,10 +73,11 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
||||
|
||||
## Storage
|
||||
|
||||
DynamoDB, `ca-central-1` — **Canadian data residency is a real selling point for
|
||||
a Canadian legal practice, and the privacy policy will say so.** Confirm the
|
||||
existing table's region and migrate if it is elsewhere. `AGENTS.md` §7 records
|
||||
`adr-intake-submissions` in `ca-central-1` `[verified 2026-08-26]`.
|
||||
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
|
||||
worth stating in the privacy policy**: parties describing a live dispute are
|
||||
handing over sensitive material, and where it comes to rest is a fair question
|
||||
for them to ask. Confirm the existing table's region and migrate if it is
|
||||
elsewhere — §7 has the table name and region.
|
||||
|
||||
| Attribute | |
|
||||
|---|---|
|
||||
@@ -109,30 +110,52 @@ SES on submission:
|
||||
of the no-retainer language, and a link to the privacy policy. This email is
|
||||
the reason the form beats a `mailto:` link.
|
||||
|
||||
**SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified
|
||||
recipients, so the inquirer confirmation works. See §7 for the account state.
|
||||
|
||||
### Bounce and complaint monitoring
|
||||
|
||||
Configured 2026-08-26; the resource names, thresholds and current state are in
|
||||
`AGENTS.md` §7. What matters here is why it is a real control rather than a
|
||||
formality:
|
||||
|
||||
**At this volume a single bad address is a threshold event.** SES suspends
|
||||
sending above roughly a 5% bounce rate. Under 100 messages a month, five bounces
|
||||
crosses it — and an intake form is exactly where mistyped addresses arrive. The
|
||||
alarms sit well below that line so there is room to react.
|
||||
|
||||
**Bounces and complaints are handled by SES email feedback forwarding**, which is
|
||||
on by default, **not** by an SNS feedback topic. That is deliberate: at this
|
||||
volume there is nothing to consume a programmatic feed, and an unused SNS 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.
|
||||
|
||||
> **The alarms currently notify nobody.** §7 records the `ses-alerts` email
|
||||
> subscription as **pending confirmation**. An unconfirmed SNS subscription
|
||||
> drops every message, so until the confirmation link is clicked the alarms
|
||||
> fire into nothing. This is the first thing to check if `/contact/` ships.
|
||||
|
||||
**Email authentication — in place as of 2026-08-26 (Q20).**
|
||||
|
||||
SPF and DMARC were both added by Pouya and independently verified on
|
||||
2026-08-26. Mail is on Google Workspace (MX `1 smtp.google.com`) with Google
|
||||
DKIM configured, and the SES domain identity is verified for sending in
|
||||
`ca-central-1`. **An earlier version of this spec said neither record existed;
|
||||
that was true when written and is no longer.** The records now live are:
|
||||
SPF and DMARC are both live and independently verified (Q20); mail is on Google
|
||||
Workspace with Google DKIM configured, and the SES domain identity is verified
|
||||
for sending. **The record values, the MX, and the region are in `AGENTS.md` §7 —
|
||||
not restated here.** An earlier version of this spec asserted that neither SPF
|
||||
nor DMARC existed; that was true when written and is no longer, which is the
|
||||
whole argument for citing §7 rather than copying it.
|
||||
|
||||
| Host | Type | Value |
|
||||
|---|---|---|
|
||||
| `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` |
|
||||
| `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` |
|
||||
**What is already in place.** `AGENTS.md` §7 is the record — resource IDs, DNS
|
||||
records, DKIM token sets, and their verification state all live there and are not
|
||||
restated here. Read §7 before touching DNS.
|
||||
|
||||
**What is already in place** (Namecheap DNS and the SES console, both inspected
|
||||
2026-08-26):
|
||||
Two points from §7 that this spec depends on, cited rather than copied:
|
||||
|
||||
| Record | Status |
|
||||
|---|---|
|
||||
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | **LIVE. Never delete.** All three resolve (NOERROR) and back the healthy `ca-central-1` SES identity — `DkimStatus: SUCCESS`. These are the records DMARC alignment rests on `[verified 2026-08-26 — DNS, AGENTS.md §7]` |
|
||||
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **BROKEN and inert.** Entered into Namecheap with the full name in the Host field, so the zone doubled the domain; they answer NXDOMAIN at the correct name. They belong to a stray `us-east-1` identity this project does not use. Harmless where they are — leaving them is the low-risk choice (Q21) `[verified 2026-08-26 — DNS, AGENTS.md §7]` |
|
||||
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
|
||||
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
|
||||
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
|
||||
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
|
||||
- **Only one of the two SES DKIM token sets resolves.** §7 names both sets and
|
||||
marks which is which. The resolving set is what DMARC alignment rests on;
|
||||
deleting it breaks intake mail authentication silently. The other set is
|
||||
NXDOMAIN and inert. **Do not act on any DKIM list that is not §7's.**
|
||||
- **SES has no custom MAIL FROM**, so SPF is unaligned and SES satisfies DMARC
|
||||
through DKIM alone.
|
||||
|
||||
Notes that mattered when these were added, kept because they matter again on
|
||||
any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one
|
||||
|
||||
+24
-22
@@ -121,28 +121,30 @@ permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`.
|
||||
|
||||
**Repository → Settings → Actions → Variables** — not secrets. These are not
|
||||
sensitive, and keeping them as variables means they appear in run logs where
|
||||
they are useful for debugging. Values captured 2026-08-26 by
|
||||
`scripts/aws-discover.sh`:
|
||||
they are useful for debugging.
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `AWS_REGION` | `ca-central-1` |
|
||||
| `S3_BUCKET` | `adr-smlcompany-site` |
|
||||
| `CLOUDFRONT_DISTRIBUTION_ID` | `E1OK7G98KNKUTA` |
|
||||
| `INTAKE_ENDPOINT` | `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` |
|
||||
| `AWS_REGION` | `AGENTS.md` §7 — Region |
|
||||
| `S3_BUCKET` | §7 — S3 bucket |
|
||||
| `CLOUDFRONT_DISTRIBUTION_ID` | §7 — CloudFront |
|
||||
| `INTAKE_ENDPOINT` | §7 — Intake API |
|
||||
| `BOOKING_URL` | *(empty — parked, R6)* |
|
||||
|
||||
IAM policy substitutions: `BUCKET_NAME` = `adr-smlcompany-site`,
|
||||
`ACCOUNT_ID` = `327082975128`, `DISTRIBUTION_ID` = `E1OK7G98KNKUTA`.
|
||||
The same four values fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
|
||||
`DISTRIBUTION_ID` placeholders. **They are deliberately not restated here** —
|
||||
§7 is the single source of truth for operational facts, and the copy that goes
|
||||
stale is always the one nobody re-reads. `scripts/aws-discover.sh` regenerates
|
||||
them from AWS if §7 ever needs re-verifying.
|
||||
|
||||
> **Read this before creating the key.** Account `327082975128` is shared across
|
||||
> `meshkinilaw.ca`, `demesne.media`, `orynenergy.ca`, `lajirugs.ca`, and
|
||||
> `mlp-clientdb-prod-backups` — which **by its name** holds another business's
|
||||
> production client-database backups; the contents were never inspected, only
|
||||
> the bucket name observed. A static deploy key for a marketing site lives in
|
||||
> the same account. The scoped policy is
|
||||
> what keeps a compromised Gitea runner from reaching any of that. Do not widen
|
||||
> it, and never put the `user/pouya` credentials in CI.
|
||||
> **Read this before creating the key.** The AWS account is **not** a
|
||||
> single-project account: it is shared with several unrelated sites and with a
|
||||
> bucket whose name indicates another business's production client-database
|
||||
> backups. `AGENTS.md` §10 has the specifics and the account identifier; they
|
||||
> are kept there rather than repeated here. A static deploy key for a marketing
|
||||
> site lives in that same account, and the scoped policy is what keeps a
|
||||
> compromised Gitea runner from reaching any of it. Do not widen it, and never
|
||||
> put the `user/pouya` credentials in CI.
|
||||
|
||||
### A runner must exist
|
||||
|
||||
@@ -204,7 +206,7 @@ The output contains resource names and IDs but no secrets.
|
||||
> **Do not execute this section.** It describes the design that was rejected
|
||||
> because Gitea cannot support it. The live procedure is *Create the user* above.
|
||||
> Nothing here should be created in AWS. Following it would add an unused GitHub
|
||||
> federation trust to account `327082975128`.
|
||||
> federation trust to the shared AWS account (`AGENTS.md` §10).
|
||||
|
||||
A static `AWS_ACCESS_KEY_ID` never expires, is invisible once set, and grants its
|
||||
permissions to anyone who can reach the repository. OIDC issues a short-lived
|
||||
@@ -292,8 +294,8 @@ Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
||||
|
||||
1. Re-run the workflow at the last good tag, or
|
||||
2. `git revert` and push, or
|
||||
3. Restore from S3 object versioning — **already Enabled** on
|
||||
`adr-smlcompany-site` `[verified 2026-08-26 — AGENTS.md §7]`. It is the
|
||||
3. Restore from S3 object versioning — **already Enabled** on the site bucket
|
||||
(`AGENTS.md` §7). It is the
|
||||
difference between a rollback and a rebuild; do not turn it off.
|
||||
|
||||
Then invalidate `/*`.
|
||||
@@ -318,8 +320,8 @@ Then invalidate `/*`.
|
||||
- [ ] 404 returns a 404 status
|
||||
- [ ] Security headers present (`securityheaders.com` A or better)
|
||||
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
|
||||
- [ ] **SES out of the sandbox** (Q19) — `aws sesv2 get-account --query 'ProductionAccessEnabled'`. In sandbox, mail reaches only pre-verified addresses and the inquirer's confirmation silently fails
|
||||
- [ ] Intake form tested end to end: DynamoDB record written to `adr-intake-submissions`, both emails delivered to a real inbox, TTL set
|
||||
- [ ] **SES bounce/complaint alarms actually notify someone** — `AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
|
||||
- [ ] Intake form tested end to end: DynamoDB record written to the intake table (`AGENTS.md` §7), both emails delivered to a real inbox, TTL set
|
||||
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `BOOKING_URL` is empty, so this passes vacuously until a tool is chosen
|
||||
- [ ] Favicon set complete
|
||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||
@@ -330,7 +332,7 @@ Then invalidate `/*`.
|
||||
- [ ] Bucket not publicly readable; OAC in force
|
||||
- [ ] ACM certificate valid; Namecheap validation CNAME still present
|
||||
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
|
||||
- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id 327082975128`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
|
||||
- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)"`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
|
||||
|
||||
**Post-cutover, same day**
|
||||
- [ ] Sitemap submitted to Google Search Console and Bing Webmaster Tools
|
||||
|
||||
Reference in New Issue
Block a user