Pouya's rulings: Q44 (memberships current — ship the group), Q45 (PDF bio deferred to step 9), Q46(a) (three offerings pass all gates), Q46(b) (§11 ratified as the source for definitional expansions, conditional on committing the source), Q47 (set jobTitle and worksFor), the legal-advice formulation ratified without change, and Q22 closed on his own execution evidence. Q46(b): docs/reference/adrio-designations.md commits all five ADRIO expansions with reproducible sha256 (four fetches each; both review agents independently reproduced every digest). It immediately caught the error Pouya raised in the same message — §11 had said "Chartered Mediator-Arbitrator" since the file was created, ADRIO's term is "Chartered Med-Arbitrator", and the wrong form was in dist/about/index.html. Four prior review passes read that string and checked only whether it was sourced, not whether it was right. Q47 REVERTED after both review agents struck it independently. jobTitle shipped for one pass as "Mediator and Commercial Arbitrator" — the string claims-auditor had removed from this same node's description the previous day, since §4 has no row for a completed arbitration. The implementation flagged the wrong defect: it argued about scoping and missed that the whole string was already struck. worksFor was reverted because ProfessionalService.provider is this Person, so provider -> Person -> worksFor asserts a same-entity claim transitively. Now jobTitle: 'Mediator', no worksFor. Q49 asks for the two §4 rows. Q44: memberships render with NO currency warranty (his condition), and R10 is re-armed with an event trigger rather than a date since he declined renewal-date tracking. Q22: §7 goes to PROVISIONED; R17 carries the first rotation date, 2026-11-26. New CLAUDE.md convention, his: never suppress stderr in a verification script — suppression turns "it failed" into "it found nothing". Two review rounds resolved 48 findings; the majority of the second round were defects in the first round's fixes, including a re-widening of §4's renewal period inside the comment that corrects that widening, a cited guard in schema.ts that does not exist, and a struck §9 row describing a reverted state. Three findings were one lift from a public page: docs/03's /for-parties/ directive breached D13 twice, docs/01 told an implementer an unpublishable strip could ship, and docs/07 priced three ungated offerings with no gate note. Also commits docs/reference/adrio-extract/ — the extraction script and its five outputs, since the digests covered raw HTML and said nothing about the text the term counts were run against. The equivalent gap for Q22's IAM verification is NOT closed and is Q52. Verified: astro check 0/0/0, eslint + prettier clean, build 2 pages, 0 .js emitted, minifier guard silent, animation-timeline intact, no currency-warranty phrasing in dist, JSON-LD correct on both pages. NOT run: Lighthouse (tool unavailable until step 7, R11); HTML validator. Opens Q48-Q53. Live reminders: R1, R10, R11, R13, R14, R15, R16, R17. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
433 lines
22 KiB
Markdown
433 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.
|
|
**Do NOT add `memberOf` to the Person JSON-LD** unless Pouya has said to.
|
|
It is withheld deliberately and on narrower grounds than the visible page:
|
|
the reason is recorded in `src/data/schema.ts` and **it is an open
|
|
judgement, not a settled one — `AGENTS.md` Q53.** Do not restate the
|
|
reasoning here; a third copy is how the first two went stale.
|
|
**§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.
|
|
- [ ] **`AGENTS.md` Q51 answered before cutover** — does listing the **OBA
|
|
sections** on `/about/` carry the licensure implication that excludes the
|
|
LSO? §4 excludes the Law Society precisely because *"listing the Law Society
|
|
among memberships implies licensure"*, and Forbidden bars *any phrasing that
|
|
**implies** entitlement to practise law*. The OBA sections **went live on
|
|
`/about/` on 2026-08-28** and Q51 was raised the same day, **after** Pouya's
|
|
Q44 ruling authorised the group — so the ruling did not consider it.
|
|
`adversarial-reviewer` rates it moderate-low confidence as a defect and
|
|
**high confidence that the question is unasked**: `grep -n OBA AGENTS.md`
|
|
returns rows on renewal, scope and stamping and nothing on implication, and
|
|
OBA membership eligibility is not established anywhere in this repo. It sits
|
|
beside **R1** — same subject, same page, and not settleable internally. If
|
|
the answer is yes, the fix is the LSO fix: exclude deliberately, and record
|
|
that it was excluded rather than omitted.
|
|
*(This item did not exist until 2026-08-28. Q48, which governs nothing
|
|
currently on the site, had a checklist item while Q51, which governs live
|
|
public copy, had none.)*
|
|
**Also re-check `AGENTS.md` Q48** before re-stamping: if `Q.Med` retention
|
|
turns out to depend on ADRIO membership currency, this item covers the
|
|
site's central credential and not just a list
|
|
- [ ] No `TODO(pouya)` remains in any shipped page
|
|
- [ ] No matter counts, rates, dollar figures, or testimonials anywhere
|
|
- [ ] Q.Arb described as **commenced August 2026** everywhere it appears — §4's
|
|
wording, not the looser "in progress"
|
|
- [ ] `/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
|