Build and deploy / build-and-deploy (push) Failing after 4s
Pouya's ruling, 2026-08-29. Treated as D3-class and swept accordingly. §4 Q.Arb becomes a HELD designation. Obtained July 2026 — recorded in the register, deliberately not published. Public copy carries "Q.Arb (ADRIC / ADRIO)" beside Q.Med and nothing more. Every stage form is struck: "commenced", "in progress", "pathway", "not yet". C.Med-Arb as a stated goal is struck. This DEVIATES FROM THE STRATEGY BRIEF, which made it "the explicit long-term professional narrative"; the brief is not in this repository, so the deviation is recorded in four places so nobody reinstates it from a document no reader can open. The designation stays in §11 as a definition — what is struck is its status as a goal of his. The paired-disclosure condition dissolves. It existed only because Q.Arb was in progress; there is no stage left to disclose, and a condition with no subject cannot be half-kept. Pages /about/'s credentialing arc is DELETED, not rewritten — two held designations are not a journey. /arbitration/ loses its stage section and its "Available now, and open about the stage" h1. /med-arb/'s endpoint section is rewritten. Deleting CREDENTIALS.goal/goalName rather than emptying them turned every consumer into a build error, which is how the last two surfaced. hasCredential now maps CREDENTIALS.designations instead of indexing [0]; _DesignationRowParity pins the visible credential row to the same constant. check:claims q-arb-as-a-stage (inverted from the predecessor, which barred Q.Arb reading as HELD) and c-med-arb-struck. Each page is now scanned twice — as published, and with inline tags collapsed — because [^<] cannot cross <strong>, which this site sets in prose. Two open questions for Pouya, neither blocking: Q55 — the acquisition date. §4 says obtained July 2026; the Change Log of 2026-08-26 records "commenced August 2026", both stamped [verified — Pouya]. They cannot both be true. Nothing published turns on it. Q56 — is the mediation offering scoped commercial? §4 leaves it unscoped; four surfaces say "commercial mediation"; /practice/insurance/ offers mediation in insured-versus-insurer SABS matters. Two review rounds, all findings applied or declined with a stated reason. Round 2 found ten lines in docs/03 still instructing the struck form — the 2026-08-29 sweep missed them because `git grep 'Q.Arb'` is line-anchored and the block never names it. Sweep the vocabulary, not only the subject. Gates, exit statuses read directly, never through a pipe: npm run check exit=0 (0 errors, 0 warnings, 0 hints) npm run lint exit=0 npm run build exit=0 (12 pages) npm run check:claims exit=0 (11 patterns, 26 approved strings) npm audit exit=0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
427 lines
22 KiB
Markdown
427 lines
22 KiB
Markdown
# 06 — Deployment and cutover
|
|
|
|
Authority: `AGENTS.md` §3 **D3 as amended 2026-08-26** (git + **Gitea Actions**
|
|
→ existing S3/CloudFront) and D11 (build everything, one clean cutover).
|
|
Existing infrastructure: **`AGENTS.md` §7 is authoritative.**
|
|
`docs/reference/AWS-Hosting-Guide.md` records how that infrastructure was
|
|
originally built — it is a historical record carrying a do-not-execute banner,
|
|
not a procedure, and §7 wins wherever the two disagree (Q24).
|
|
|
|
---
|
|
|
|
## Topology
|
|
|
|
```
|
|
Gitea push to main
|
|
└─ Gitea Actions (act_runner)
|
|
├─ npm ci && npm run build → ./dist
|
|
├─ static scoped IAM user key (from Gitea secrets — NOT OIDC)
|
|
├─ aws s3 sync ./dist s3://<bucket> (three passes, see Cache policy)
|
|
└─ cloudfront create-invalidation
|
|
Namecheap DNS → CloudFront → S3 (OAC)
|
|
API Gateway → Lambda → DynamoDB / SES (intake, unchanged path)
|
|
```
|
|
|
|
DNS is at **Namecheap, not Route 53** `[verified 2026-08-25]`. Nothing in the
|
|
pipeline touches DNS. Certificate renewal is ACM-automatic as long as the
|
|
validation CNAME stays in place at Namecheap — **do not delete it.**
|
|
|
|
## Today, deploys run locally
|
|
|
|
**`npm run deploy`** (`scripts/deploy-local.sh`) is the current path. It runs
|
|
the same guard, the same three sync passes in the same order with the same
|
|
cache headers, and the same invalidation as the workflow — at this scale the
|
|
pipeline changes only **how a deploy is triggered**, not what it does. Treat the
|
|
script and the workflow as one artefact in two places: change one, change both.
|
|
|
|
One thing blocks the workflow, and it is not a fact to look up:
|
|
|
|
- **Actions are not enabled and no runner is registered** (Q23). The Gitea
|
|
instance is jointly administered, so both need its second administrator.
|
|
|
|
> ✅ **`adr-sml-deploy` EXISTS — created 2026-08-26, Q22 closed 2026-08-28.**
|
|
> **DO NOT CREATE IT.** This bullet said *"`adr-sml-deploy` does not exist —
|
|
> `aws iam get-user` returns `NoSuchEntity`… Create it from Create the user
|
|
> below"* until 2026-08-28, and an operator following it would have created a
|
|
> **second** IAM user, or hand-provisioned one with a different scope from the
|
|
> `adr-sml-deploy-minimal` policy §7 now records. §7 carries the inventory and
|
|
> the eight `simulate-principal-policy` results.
|
|
>
|
|
> *Found by `adversarial-reviewer`: the Q22 flip to PROVISIONED was swept in
|
|
> `AGENTS.md` and nowhere else, and the Change Log entry's sweep block covered
|
|
> the copy corrections only. Same shape as the SES-DKIM inversion — the stale
|
|
> copy instructed an action against a High-risk credential.*
|
|
>
|
|
> *Create the user* below is retained as the **record of how it was
|
|
> provisioned**, not as an instruction. Read it that way.
|
|
|
|
The script **refuses to run as `user/pouya`** — the broadly-permissioned
|
|
personal user that has been authenticating to this account. See §10.
|
|
|
|
## CI runs on Gitea, not GitHub
|
|
|
|
`AGENTS.md` D3 as amended, 2026-08-26: self-hosted **Gitea**. The instance,
|
|
version, and repository are recorded in §7 — the version is comfortably above
|
|
the floor for the `vars` context, so the first-step guard is belt-and-braces
|
|
rather than load-bearing.
|
|
|
|
**The live pipeline is `.gitea/workflows/deploy.yml`.** Gitea Actions speaks
|
|
GitHub Actions syntax, so it is a near-direct port — the build steps, the
|
|
three-pass sync, and the cache headers are unchanged. The GitHub Actions original,
|
|
with its OIDC role assumption, stays in the repo as
|
|
`docs/reference/github-actions-oidc.yml.example` — deliberately outside
|
|
`.github/workflows/`, because Gitea falls back to that directory when
|
|
`.gitea/workflows` is absent.
|
|
|
|
### The one real difference: no OIDC
|
|
|
|
Gitea is not an AWS OIDC provider. There is no role to assume, so deploys
|
|
authenticate with a **scoped IAM user** whose access key lives only in the
|
|
repository's Gitea secrets.
|
|
|
|
This is a genuine step down in security from an OIDC setup — which was designed
|
|
here but never built — and it should be treated as one. The mitigations are the
|
|
policy scope and the rotation schedule.
|
|
|
|
**Create the user:**
|
|
|
|
1. IAM → Users → `adr-sml-deploy`. **Programmatic access only** — no console
|
|
password, no MFA device, no group membership.
|
|
2. Attach this inline policy and nothing else. Substitute the real bucket name,
|
|
account ID, and distribution ID from `scripts/aws-discover.sh`:
|
|
|
|
```json
|
|
{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Sid": "ListSiteBucket",
|
|
"Effect": "Allow",
|
|
"Action": "s3:ListBucket",
|
|
"Resource": "arn:aws:s3:::BUCKET_NAME"
|
|
},
|
|
{
|
|
"Sid": "WriteSiteObjects",
|
|
"Effect": "Allow",
|
|
"Action": ["s3:PutObject", "s3:DeleteObject"],
|
|
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
|
|
},
|
|
{
|
|
"Sid": "InvalidateOneDistribution",
|
|
"Effect": "Allow",
|
|
"Action": "cloudfront:CreateInvalidation",
|
|
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Four actions on one bucket and one distribution. No `Action: "*"`, no
|
|
`Resource: "*"` — the only wildcard is `BUCKET_NAME/*`, which scopes to the
|
|
objects of that one bucket. `s3:PutObjectAcl` was dropped on 2026-08-26:
|
|
`aws s3 sync` does not use it without `--acl`, and it is inert under Origin
|
|
Access Control with ACLs disabled. If a deploy step needs a permission this
|
|
policy lacks, the correct response is to question the step, not to widen the
|
|
policy.
|
|
|
|
3. Create an access key. **Copy it once** — AWS will not show the secret again.
|
|
|
|
**`s3:AbortMultipartUpload` is deliberately absent, and here is the actual
|
|
reason.** `aws s3 sync` switches to multipart above its 8 MB
|
|
`multipart_threshold`; an interrupted multipart upload then cannot clean up its
|
|
own parts, and orphaned parts accrue storage charges that do not appear in the
|
|
bucket listing. What makes that safe today is simply that **nothing here comes
|
|
close to 8 MB** — the largest file the pipeline
|
|
uploads is well under it. The biggest source asset is
|
|
`src/assets/pouya-lajevardi.jpg` at 357,627 bytes `[verified 2026-08-26 — stat]`,
|
|
Astro emits it smaller still after AVIF/WebP conversion, and the self-hosted font
|
|
files are smaller again. **Re-measure `./dist` after the first successful build**
|
|
— that, not the repository, is what gets synced. No lifecycle rule exists; do not describe one
|
|
as the mitigation, because it is not there.
|
|
|
|
**Revisit if any single asset approaches 8 MB** — a video, a large PDF, an
|
|
un-optimised photograph. At that point either add an S3 lifecycle rule aborting
|
|
incomplete multipart uploads after 7 days (preferred — it costs no IAM
|
|
permission), or grant `s3:AbortMultipartUpload` on `BUCKET_NAME/*`.
|
|
|
|
### Gitea configuration
|
|
|
|
**Repository → Settings → Actions → Secrets:**
|
|
|
|
| Name | Value |
|
|
|---|---|
|
|
| `AWS_ACCESS_KEY_ID` | from the IAM user |
|
|
| `AWS_SECRET_ACCESS_KEY` | from the IAM user |
|
|
|
|
**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.
|
|
|
|
| Variable | Value |
|
|
|---|---|
|
|
| `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)* |
|
|
|
|
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.** 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
|
|
|
|
Gitea Actions needs `act_runner` registered to this repository or its
|
|
organisation, and Actions enabled both site-wide in `app.ini`
|
|
(`[actions] ENABLED = true`) and per-repository. Without a runner the workflow
|
|
queues silently and never runs — which looks exactly like a broken pipeline.
|
|
|
|
The workflow installs the AWS CLI if the runner image lacks it, and runs
|
|
`aws sts get-caller-identity` before touching anything. **That check is
|
|
narrower than it looks:** `sts:GetCallerIdentity` requires no IAM permission at
|
|
all, so it succeeds for any valid key regardless of policy. It catches a
|
|
missing, malformed, or revoked key; it does **not** catch an under-scoped
|
|
policy, which still fails halfway through a sync and leaves the bucket
|
|
partially updated. Read it as a key check, not a permissions check.
|
|
|
|
### The variable guard runs first
|
|
|
|
The workflow's first step — before checkout, before the build, before any AWS
|
|
call — fails the run if `AWS_REGION`, `S3_BUCKET`, or
|
|
`CLOUDFRONT_DISTRIBUTION_ID` is empty.
|
|
|
|
This exists because Gitea only added the `vars` context in 1.21. On an older
|
|
instance every `${{ vars.* }}` interpolates to an empty string with no warning,
|
|
the sync target becomes `s3://`, and the run dies halfway through with an error
|
|
that names nothing useful. The guard converts that into a clean failure that
|
|
says which variable is missing — **on every Gitea version**. A recorded version
|
|
number would have gone stale; the guard does not.
|
|
|
|
### Key rotation — an operational obligation
|
|
|
|
**Rotate `adr-sml-deploy` quarterly.** OIDC would have made this unnecessary;
|
|
with a static key it is a standing task:
|
|
|
|
1. Create a second access key on the same user.
|
|
2. Update the Gitea secrets.
|
|
3. Run the workflow and confirm it succeeds.
|
|
4. **Delete the old key.** Rotation that leaves the old key active is not
|
|
rotation.
|
|
|
|
Set a calendar reminder. A key that is never rotated is the failure mode this
|
|
whole section exists to bound.
|
|
|
|
## Finding the AWS identifiers
|
|
|
|
`scripts/aws-discover.sh` re-collects the inventory — bucket, distribution
|
|
ID, regions, API endpoint, certificate, SES identities, and whether S3 versioning
|
|
is on. Read-only; no call creates or mutates anything.
|
|
|
|
```bash
|
|
chmod +x scripts/aws-discover.sh
|
|
./scripts/aws-discover.sh > aws-inventory.txt
|
|
```
|
|
|
|
The output contains resource names and IDs but no secrets.
|
|
|
|
## Why OIDC would have been better — and why it is unavailable
|
|
|
|
> **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 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
|
|
token per run, scoped to one repository and one branch — strictly better, and the
|
|
reason the rotation schedule above is not optional here.
|
|
|
|
It needs an identity provider AWS will federate with. GitHub and GitLab both
|
|
publish one; **Gitea and Forgejo do not**, so there is nothing for AWS to trust
|
|
and no role to assume. That is the whole of the constraint (D3 as amended).
|
|
|
|
If the project ever moves to GitHub, the workflow to adopt is
|
|
`docs/reference/github-actions-oidc.yml.example`, and the setup is: register
|
|
`token.actions.githubusercontent.com` as an IAM OIDC provider with audience
|
|
`sts.amazonaws.com`; create a role trusting it, conditioned on the `sub` claim
|
|
matching the repository and `refs/heads/main`; attach the same four-action policy
|
|
given above; then delete `adr-sml-deploy` and its key.
|
|
|
|
## Cache policy
|
|
|
|
The mistake to avoid is caching HTML aggressively — a stale index page is a site
|
|
that does not update.
|
|
|
|
| Pattern | `Cache-Control` |
|
|
|---|---|
|
|
| `*.html` | `public, max-age=0, must-revalidate` |
|
|
| `/_astro/*` (hashed) | `public, max-age=31536000, immutable` |
|
|
| Fonts | `public, max-age=31536000, immutable` |
|
|
| Images | `public, max-age=604800` |
|
|
| `robots.txt`, `sitemap*.xml` | `public, max-age=0, must-revalidate` |
|
|
|
|
Sync in **three** passes, in this order: hashed assets and fonts with the long
|
|
TTL, then images, then everything else. Uploading HTML last means a user never
|
|
fetches a new page whose assets have not landed yet.
|
|
|
|
Two ordering dependencies are load-bearing and easy to break:
|
|
|
|
- Pass 3 re-walks the whole tree; the image headers from pass 2 survive only
|
|
because `aws s3 sync` skips objects it has just uploaded. Reordering the
|
|
passes silently overwrites them with the HTML header.
|
|
- Pass 3's `--exclude "_astro/*" --exclude "fonts/*"` also excludes those
|
|
prefixes from `--delete`, so hashed assets from previous deploys are kept
|
|
deliberately — pages still in a browser cache need them. Do not "fix" it.
|
|
|
|
`robots.txt` and `sitemap*.xml` fall through to pass 3 and get the HTML header.
|
|
That is the intended behaviour: both should be re-fetched, and the table above
|
|
records what the pipeline actually does rather than an unimplemented ideal.
|
|
|
|
Invalidate `/*` on deploy. At this traffic volume the cost is nil, and partial
|
|
invalidation paths are a reliable source of confusing bugs.
|
|
|
|
## CloudFront configuration
|
|
|
|
- Origin: S3 with **Origin Access Control**, bucket not public. Verify the
|
|
bucket policy grants access only to the CloudFront distribution's OAC
|
|
principal and to nothing else, and that public access is still blocked.
|
|
- Redirect HTTP → HTTPS. TLS 1.2 minimum.
|
|
- Default root object `index.html`.
|
|
- **Custom error response:** 404 → `/404.html` with **response code 404**, not
|
|
200. Returning 200 for a missing page tells crawlers every bad URL is real
|
|
content, and it is the single most common misconfiguration in this stack.
|
|
- Compression on. Response-headers policy from `05-backend-spec.md`.
|
|
- A CloudFront Function for trailing-slash normalisation, so `/about` and
|
|
`/about/` do not both resolve as separate indexable URLs.
|
|
|
|
## Branch model
|
|
|
|
`main` is production; a push to `main` is what triggers a deploy. Work on
|
|
short-lived branches, open a PR, merge.
|
|
|
|
**The CI pipeline has never run.** Not for want of a lockfile — `npm ci`,
|
|
`astro check` and `astro build` all work now — but because the deploy user does
|
|
not exist (Q22) and Actions are not enabled with a runner registered (Q23).
|
|
Treat "every push deploys" as the design; today the path is `npm run deploy`.
|
|
|
|
**Pull request checks — planned, not implemented:** `npm run build` ·
|
|
`astro check` · lint · Lighthouse CI against the budgets in `04-seo-spec.md` ·
|
|
link check. `.gitea/workflows/deploy.yml` has **no `pull_request` trigger**
|
|
(only `push` on `main` and `workflow_dispatch`), so nothing gates a merge today.
|
|
`npm run build`, `npm run check` and `npm run lint` all run clean locally.
|
|
|
|
**Lighthouse is not one of the checks that could be wired today.** `@lhci/cli`
|
|
was removed on 2026-08-26 and there is no `npm run lighthouse` script any more —
|
|
`AGENTS.md` §7 records why and what re-adding it at build step 7 requires. Wire
|
|
the other four; do not write a workflow step that calls a script that does not
|
|
exist.
|
|
|
|
Tag every production deploy `v<year>.<n>` so a rollback has something to name.
|
|
|
|
## Rollback
|
|
|
|
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 the site bucket
|
|
(`AGENTS.md` §7). It is the
|
|
difference between a rollback and a rebuild; do not turn it off.
|
|
|
|
Then invalidate `/*`.
|
|
|
|
## Cutover checklist — D11 is a single shot, so run all of it
|
|
|
|
**Content and compliance**
|
|
- [ ] Every claim traced to `AGENTS.md` §4 Verified
|
|
- [ ] **Memberships RE-CONFIRMED AGAIN, on the day of cutover** — `AGENTS.md`
|
|
§12 **R10**, which is now an **event trigger and cutover is one of its two
|
|
events.** Q44 closed 2026-08-28 and the group is published on `/about/`
|
|
(ADRIC, ADRIO, the three OBA sections, the CTF, `[verified 2026-08-28 —
|
|
Pouya]`), so this item is no longer "publish them" — it is **"ask him
|
|
again, then re-stamp §4 with the cutover date."**
|
|
**Pouya declined renewal-date tracking**, which is exactly why this sits on
|
|
the checklist: there is no date on which anyone would otherwise re-check.
|
|
OCNI lapsed quietly and §4 records it as "not current, do not publish" —
|
|
found roughly a year late. A stamp is not a renewal receipt.
|
|
**Do NOT add a currency sentence to the page while you are here** — his
|
|
ruling is *"list the memberships; promise nothing about their future
|
|
state"*, and the struck sentence stays struck.
|
|
**`memberOf` IS EMITTED on `/about/`** — Q53 answered 2026-08-28 and the
|
|
withholding is dropped, so the graph asserts the same four memberships the
|
|
page shows. **This item therefore covers both**: re-confirming before
|
|
cutover means `src/data/schema.ts` as well as the visible list, and they
|
|
must not be allowed to diverge.
|
|
**§4 records yearly renewal for the OBA sections and the CTF only** — it
|
|
says nothing about ADRIC's or ADRIO's period, and an earlier version of
|
|
this line asserted "all renew yearly", which §4 does not support.
|
|
- [ ] **The OBA sections stay listed; the LSO stays out** — a check that nobody
|
|
has tidied the two into one list, not an open question. `AGENTS.md` **Q51
|
|
answered 2026-08-28**: the Law Society is the **regulator**, so membership
|
|
*is* licensure; the OBA is a **voluntary association**, which admits
|
|
members it does not license. Structural, and independent of eligibility
|
|
details — which is what made the question unanswerable inside this repo
|
|
before the ruling. **R1 is still live**: same page, same subject, different
|
|
question
|
|
- [ ] No `TODO(pouya)` remains in any shipped page
|
|
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
|
|
- [ ] Q.Arb described as **held** everywhere it appears — `Q.Arb (ADRIC /
|
|
ADRIO)`, no acquisition date. **Every stage form is barred**: "commenced",
|
|
"in progress", "pathway", "not yet" (amended 2026-08-29). `npm run
|
|
check:claims` enforces the **stage words** and a **date near the
|
|
designation**, on `dist/`, in both deploy paths. It cannot catch a stage
|
|
expressed without naming the designation; that gap is `claims-auditor`'s to
|
|
close, and it is stated in the pattern itself
|
|
- [ ] **C.Med-Arb appears nowhere in `dist/`** — struck entirely 2026-08-29
|
|
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship
|
|
- [ ] Privacy policy matches the backend as actually built
|
|
|
|
**Technical**
|
|
- [ ] **Re-add `@lhci/cli`** (removed 2026-08-26 — `AGENTS.md` §7) with a pin
|
|
verified against the registry that day, and a `lighthouserc` carrying the
|
|
budgets from `04-seo-spec.md`. This box gates the next one
|
|
- [ ] Lighthouse ≥ 95 mobile on `/`, `/about/`, a practice page, an article
|
|
- [ ] Every page renders fully with JavaScript disabled
|
|
- [ ] `curl` of each URL returns real content, not a shell
|
|
- [ ] All internal links resolve; no orphan pages
|
|
- [ ] Sitemap generated and correct; `robots.txt` served, not 403
|
|
- [ ] Rich Results Test passes; OG previews render in LinkedIn and Slack
|
|
- [ ] **OG cards are per-page, not one portrait on all nineteen** — `AGENTS.md`
|
|
Q40 / **R15**. The portrait is the decided card for `/` and `/about/`; every
|
|
other page needs the generated typed card, built at step 7 with Insights.
|
|
**This blocks cutover.** A link preview is the surface a general counsel
|
|
actually sees when a colleague pastes the URL into Teams, and the interim
|
|
makes nineteen unique titles look identical
|
|
- [ ] 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 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
|
|
- [ ] Tested at 320 px and at 200% zoom
|
|
|
|
**Infrastructure**
|
|
- [ ] S3 versioning enabled
|
|
- [ ] 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 "$(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
|
|
- [ ] Live site fetched as an anonymous crawler to confirm indexable content
|
|
- [ ] LinkedIn profile and ADRIC/ADRIO listings updated to point here
|
|
- [ ] Archive the old single-file build to `_archive/` — do not delete it
|
|
- [ ] `AGENTS.md` Change Log entry recording the cutover
|