Files
adr-sml/docs/06-deployment.md
T
Pouya LajevardiandClaude Opus 5 ca1c2524e1 fix: footer email reflow to zero under all four methods; reopen the skip-link residual as Q61
The footer mailto was the last recorded overflow: `info@smlcompany.ca` demanded
310px of min-content in a 224-243px column. One declaration —
`overflow-wrap: anywhere` on `.footer-contact a[href^='mailto:']`. `anywhere`
and not `break-word`, established with a negative control rather than from the
rule: only `anywhere` reduces min-content, and `break-word` injected in its
place failed the same 88 rows as the unfixed baseline.

Minimum-font-size 32 goes 88/352 -> 0/352. All four methods now read 0 of 352
(22 pages x 16 widths). Normal-settings identity: 0 differences across 8
metrics, with a positive control proving the comparison can detect one. It also
closed 57 element-level cases the page-level table reports as clean, hidden
inside `.wrap`'s 96px gutter.

The item-2 ruling is NOT applied, and this is the deviation to read first. The
acceptance rested on "no CSS mechanism can see minimum-font-size", which is
false: the font-metric units — `ch`, `ex`, `cap`, `lh`, `rlh` — read the used
font size and double, in property values, in `@media` and in `@container`. Only
`rem`, `em`, `ic` and `px` are blind. And the cost is not a convenience loss:
keyboard focus lands entirely behind the opaque header on 290 of 1,455 stops,
36 of them inside `#main`, which is WCAG 2.2 SC 2.4.11 at AA — the same level as
the 1.4.10 failure it was traded against. A build of fce89d4~1 measures 0, so
the header fix created it. Opened as Q61 with a verified candidate; docs/06
restored to unticked.

R20's gate is now a build failure rather than three prose cross-references,
which demonstrably did not gate it: with two articles published the build and
all five checks passed while both header defects shipped. SiteHeader throws.

R11's two majors (@astrojs/mdx 7->8, typescript 6->7) move to a new cutover-prep
group in docs/06 with the 19-pin currency sweep.

Two review rounds, eight findings, all resolved; four of round 2's five were
defects in round 1's own fixes. Stopped at two per D19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-01 14:51:37 -04:00

719 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` AND `BOOKING_URL` WERE ROWS HERE AND ARE GONE, 2026-08-31.**
Build step 8 moved the intake form to the same-origin path `/api/intake`, after
which nothing in the build read either one — and both deploy paths were still
refusing to run without `INTAKE_ENDPOINT`. **The removal was made in the two
scripts and not here**, so this table went on telling an operator to set a
variable no guard checked and no build consumed. Found by
`adversarial-reviewer` round 2, whose sharper point is about the evidence: the
removal was justified with `git grep PUBLIC_INTAKE_ENDPOINT -- src/` — a
**path-scoped** grep offered as proof of a repo-wide removal, and `git grep`
additionally cannot see untracked files, which at that moment was most of the new
work. That is `CLAUDE.md`'s *a sweep is a command, not a claim* and *sweep the
vocabulary, not only the subject*, both at once.
⚠️ **AND ONE PLACE STILL CARRIES IT: `.env.example` sets
`PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname, and
`PUBLIC_BOOKING_URL`.** That falsifies a live claim in `src/data/intake.ts`
which said the endpoint id *"is not restated in the repo either"* — and the claim
has been corrected there rather than left standing. **The file itself was not
edited: this environment denies read access to it, and nothing in this repo may
edit a file it cannot read.** It is in the batched list for Pouya: delete both
lines, and `PUBLIC_ANALYTICS_*` with them, since `ANALYTICS` is a literal in
`src/data/site.ts`.
The three values above 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 still not one of the checks that can be wired — and the reason
changed on 2026-08-31.** `npm run lighthouse` exists again (step 7a, `AGENTS.md`
§7), so the blocker is no longer a missing script: it is that standalone
Lighthouse drives an **installed browser** and the runner has none. Wiring it
would need Chrome on the `act_runner` host (Q23). Wire the other four; do not
write a workflow step that shells out to a browser that is not there. The gate
lives locally and on the cutover checklist below.
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
**Cutover prep — deferred maintenance, done BEFORE the checklist below**
This group exists because deferring a thing and forgetting it look identical
three weeks later. Each item carries the date it was deferred and the reason, so
the decision is re-readable rather than re-litigated.
- [ ] ⚠️ **TWO DEPENDENCY MAJORS ARE ONE BEHIND, DEFERRED 2026-09-01 BY RULING.**
`@astrojs/mdx` **^7.0.8 → 8.0.0** and `typescript` **^6.0.3 → 7.0.2**.
Pouya's reasoning: *"npm audit is clean and majors mid-walkthrough add churn
without user value."* `npm audit` reports **0 vulnerabilities**
`[verified 2026-09-01]`, so this is a churn decision and not a security one —
and it would become a security decision the moment that stops being true.
**Take both majors here, before cutover, and read the changelogs rather than
the version numbers**: an MDX major can change how `docs/03`'s copy renders,
and a TypeScript major can change what `astro check` accepts, which is a
gate on both deploy paths. **`AGENTS.md` R11 keeps firing at every phase
boundary as designed** — this item is the deferral, not a replacement for the
reminder, and R11 is what will raise the next pin that drifts.
**The sweep behind this item, `npm view <pkg> version` across all 19 pins
`[verified 2026-09-01]`:** those two are the only majors behind. Four are a
minor or patch behind and are already satisfied by their own carets, so they
need no edit — `astro` 7.2.9 → **7.2.10**, `@astrojs/sitemap` 3.7.3 → 3.7.4,
`globals` 17.11.0 → 17.12.0, `typescript-eslint` 8.68.0 → 8.69.0. The
remaining 13 are current. Re-run the sweep here rather than trusting this
list: it is dated for that reason
**Content and compliance**
> ⚠️ **THE FIRST TWO ITEMS ARE THE PROJECT'S ONLY FULL CLAIMS PASS — D20, Pouya,
> 2026-08-30.** `claims-auditor` no longer runs per build step; `/build` Phase 3
> is `adversarial-reviewer` alone. Everything the register is meant to prevent
> therefore lands here. `AGENTS.md` D20 records both the reasoning and what
> deferring it costs — read it before treating either item as a formality, and do
> not tick one because `npm run check:claims` is green. That script is a greppable
> tripwire; it cannot read a page.
- [ ] **`claims-auditor` run over EVERY page in `dist/`, findings resolved.**
**This is the project's only full claims pass. Nothing publishes until it
is clean.** Not per-page in isolation — the pass exists here because the
defects worth catching late are the ones that only exist once the pages sit
next to each other. Give it the whole built site and the reading order a
visitor takes.
- [ ] **Pouya has read every page against `AGENTS.md` §4.** The human pass. It is
the other half of D20 and it is not delegable — his reading is what the
per-step audit was traded for.
- [ ] **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. **And the reservation sentence sits adjacent to the overtime row** — Q59, ruled 2026-08-31: a reader who takes the $500 and skips *"a full day reserves the day; half-day overtime is subject to availability"* has read a different offer, and the arithmetic then makes the full-day rate look strictly worse
- [ ] Privacy policy matches the backend as actually built. **Three of its statements are derived and cannot drift** — the collected-data list from `INTAKE_FIELDS`, the retention period from the handler's own figure, the analytics paragraph from `ANALYTICS.installed`. **If analytics are installed, that flag flips and the policy's last-updated date moves on the same build**; a policy naming a processor that processes nothing is a false disclosure
- [ ] **`/insights/` — check which state it is in.** While no article is published the page emits `noindex` (derived from the collection) and `SiteHeader` keeps Insights out of the primary nav until **two** are live. Both clear themselves on publication. D9 means the flip is Pouya's: `src/content.config.ts` refuses `draft: false` without `reviewedByPouya: true`
- [ ] ⚠️ **AND PUBLISHING THE SECOND ARTICLE IS GATED — `AGENTS.md` R20, ruled
2026-09-01.** The flip that makes `/insights/` indexable is the same flip that
adds the **seventh nav item**, and with seven items under fallback font
metrics the masthead measures 141 px across 10561091 px: a 60 px layout shift
on every page and 44 px of `#main` behind the sticky header after the skip
link, both at the **default** text size. **No seventh nav item ships until
that is fixed**, so the fix is a prerequisite of article #2 — not a follow-up
to it. The measurements and the two candidate fixes are on the
seventh-nav-item item under **Technical** below
- [ ] **The five drafted articles are `draft: true` and therefore invisible to `check:claims`**, which reads `dist/`. They were checked once, on 2026-08-31, by temporarily publishing all five and running it — clean on 27 pages. **Re-run it that way before any of them publishes**, because a draft that changes in between is unchecked
**Technical**
- [ ] **`npm run lighthouse` on the full built site**, from a machine with
Chrome — it is not in CI and cannot be (see above). Read the exit status.
Every category ≥ 95 on every page; the accessibility figure is measured
with `prefers-reduced-motion` forced and that qualification is reported
with it (`AGENTS.md` §7)
- [ ] 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
- [ ] **`npm run og:proof`** — every page's `og:image` resolves to a file in
`dist/`, and every generated card's headline is its page's own `<h1>`.
R15 is discharged (step 7b) so this is a regression check rather than a
blocker on the interim; it stays on the list because a broken preview image
is invisible from inside the repo, and because card text cannot be reached
by `check:claims`. Run `npm run og:proof -- --sheet` and **look at the
contact sheet** as well — the check compares strings, not rendering
- [ ] 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.)*
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
IT DOES — build step 8 shipped the page and not the pipe.**
1. **A CloudFront behaviour routing `/api/*`** to the HTTP API origin
`AGENTS.md` §7 records. The form posts to the same-origin path
`/api/intake` — deliberately, so the CSP can be `form-action 'self'`,
so there is no cross-origin POST, so the endpoint id stays out of the
HTML, and so submitting the form from a laptop does nothing. Without
this behaviour the form 404s.
2. **Deploy `backend/intake/handler.mjs` + `backend/intake/fields.mjs`**,
replacing the hand-built `adr-intake-handler` §7 records. It needs
**SIX** variables — `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`,
`MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`; it throws at cold
start on any missing one, on purpose. ⚠️ **This list said five until
2026-08-31 and the handler required six.** `NO_RETAINER_NOTICE` became a
`requireEnv` in the same change set and was added to no document, so an
operator working this list would have set five, and the Lambda would
have thrown on **every** invocation — API Gateway answering 5xx and every
inquiry from counsel lost from the moment `/api/*` was wired. Loud in
CloudWatch, silent to Pouya, who has no reason to submit his own form.
Found by `adversarial-reviewer` round 2.
**Two of the six must be verbatim from `src/data/site.ts`:**
`RESPONSE_TIME` from `CONTACT.responseTime` and `NO_RETAINER_NOTICE`
from `NO_RETAINER_NOTICE`. Both are published commitments — the first is
§4/Q27's two-business-day response, the second is the no-retainer notice
`docs/01` §`/contact/` requires, **including its fourth clause about
not itself creating a conflict check**, which a hand-typed copy in the
handler had dropped. `RESPONSE_TIME` must read identically on `/contact/`,
in the confirmation email, and in the bio.
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
(`docs/05`). Not expressible in handler code, and it is carrying load
the unimplementable 3-second timing check was specified to carry — see
`docs/05` §Three deviations.
✅ **BOTH DEPLOY PATHS NOW WARN IF ITEM 1 IS MISSING**, so this does not
rest on someone reading this list. `scripts/deploy-local.sh` and
`.gitea/workflows/deploy.yml` each end with an **Intake route check**: a
`POST` to `/api/intake` with no `Origin`. **404 means not routed. 403 means
routed and refused**, which is the handler's `Origin` check doing its job
and is therefore a pass — and is why the probe is safe against production:
it is rejected before any DynamoDB write or any email. It warns rather than
failing, because by that point the site is already deployed and failing the
job would not un-deploy it. This replaced an `INTAKE_ENDPOINT` guard that
blocked deploys on a variable nothing read.
- [ ] **DYNAMODB TTL ENABLED ON THE TABLE, AND VERIFIED BY A TEST RECORD. THIS
ONE BACKS A PUBLISHED PROMISE AND MUST NOT BE TICKED FROM THE HANDLER
CODE.** `/legal/privacy/` states that a record is *"deleted automatically
by the database rather than by someone remembering to do it"* after 24
months. The handler writes the `ttl` attribute; **TTL must also be enabled
on the table** — ⚠️ **§7 records whether it is, and it has not always
been**, which is why this item cannot be ticked from the handler code: the
handler only writes the attribute. On a privacy policy an unbacked
mechanism is a false statement to the public. `aws dynamodb
describe-time-to-live --table-name <the table §7 records>` and confirm
`TimeToLiveStatus: ENABLED` — then, because **`ENABLED` proves the setting
and not the behaviour**, write a record with a near-future `ttl` and
confirm it actually disappears. Both halves, or the item stays open. §9
Q60 and §12 R19 track it; `src/pages/legal/privacy.astro` carries the
matching `TODO(pouya)`.
- [ ] **`npm run check:intake`** — the form's field table and the Lambda's agree
on names, requiredness, caps and option sets. They are two independent
tables on purpose (`docs/05`); this is what stops them drifting
- [ ] **Both POST-redirect-GET landing pages reachable and `noindex`** —
`/contact/received/` and `/contact/could-not-send/`. Both are excluded from
the sitemap in `astro.config.mjs`
- [ ] **Regenerate and re-commit the bio PDF** — `npm run build && npm run bio:pdf`.
Nothing in the build does this, so a change to `/bio/`, to §4, to the rate
card or to the print styles leaves the committed PDF stale. It asserts one
page and writes nothing if the count is wrong (R16). ⚠️ **It is not
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
identical content differ in digest and every re-render is a binary diff.
Re-commit it when something actually changed, and say what in the message
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**, via a CloudFront response-headers
policy. **This is the PDF half of a decision already taken for the page.**
`/bio/` is `noindex` and excluded from the sitemap because it is a condensed
duplicate of `/about/` and `/fees/`, and *"two URLs competing on the same
content is the one thing `docs/04` is most concerned with."* The committed
PDF is a rendering of that same page, it is linked from `/about/` which is
`index,follow`, `robots.txt` allows everything, and **Google indexes PDFs** —
so the duplicate the page-level `noindex` prevents arrives as a file
instead. A `Disallow` will not do it: a blocked URL can still be listed.
Found by `adversarial-reviewer`, 2026-08-31
- [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `CONTACT.bookingUrl` is `null`, so nothing renders and this passes vacuously until a tool is chosen. **Nothing on `/contact/` mentions booking**, deliberately
- [ ] Favicon set complete
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
- [ ] Tested at 320 px and at 200% zoom
- [x] ✅ **THE 200%-TEXT NAV OVERFLOW IS FIXED, 2026-09-01 — THIS ITEM IS
DISCHARGED.** Pouya reopened the step-1 header decision and ruled *fix, do
not accept*. `flex-wrap: nowrap` is gone from `.header-inner` (the necessary
and sufficient one, measured) and from `.nav-list`, and the masthead is
sticky only while it is one row — a saturating `clamp()` gate in two bands,
66 rem and 76 rem. Measured across 22 pages × 16 widths, before and after on
the same grid: **root-style 32 px went from 175 of 352 page-widths over to
0**; minimum font size from 219 to 88, all of the remainder being the footer;
default font size stays 0. Nav items and CTA on-screen in **1408 of 1408**
measurements. The shipped rendering at the default size is
**geometry-identical — 0 differences on 352 page-widths**, header 81.00 px,
CTA on the content edge, with six nav items and with a seventh.
⚠️ **AND IT WAS VERIFIED ACROSS THE INTERMEDIATE ROOT SIZES, WHICH IS WHAT
ROUND 1 DID NOT DO.** WCAG 1.4.4 is a requirement up TO 200 %, not AT 200 %:
round 1 sampled root 16 and root 32 only, both clean, and shipped a gate that
left `#main` behind the header at **20 of 300** grid points across roots
1830. **And round 2 found the gate was one-sided**: Chrome's "Font size" has
five presets (9 / 12 / 16 / 20 / 24 px) and only 16 had been swept, so at
**9 px the masthead wrapped for the opposite reason** — the `80rem` content
cap shrinks while the header's px minimums do not — leaving **65 px of `#main`
behind the header** on 21 of 22 pages at every width. A second gate term
(`1rem - 16px`) closes it, and closes a **pre-existing 12 px** shortfall on
all 22 pages at that setting as well. Now **0 failing across 762 points**:
roots 915 (70), roots 1632 (300), the band seam (198), both thresholds
(144) and all five default-font-size presets (50). Both findings by
`adversarial-reviewer`; `docs/02` §Reflow carries all of it
- [x] ✅ **THE FOOTER REFLOW RESIDUAL IS FIXED, 2026-09-01.** Document overflow of
**14 px at 320, 38 px at 1024, 30 px at 1056 and 19 px at 1100 CSS px** on all
22 pages under Chrome's minimum-font-size setting, 0 from 1200 px up. One
cause at all four: `info@smlcompany.ca` has no break opportunity, so its
**min-content width is 310 px**. One declaration closes it —
`.footer-contact a[href^='mailto:'] { overflow-wrap: anywhere }` — measured
**88 of 352 → 0 of 352**, the other three enlargement methods unchanged at 0
of 352, and the normal-settings identity check at **0 differences on 352
page-widths across 8 metrics**. It also closed **57 cases of the link
overrunning its own box** at root style 2632 px that the document-level
table reports as clean. ⚠️ **The earlier note here said the 320 px half "is a
column-sizing question and is not the same fix" — that was wrong**; the
column sizing at 320 px *is* that same 310 px min-content, arriving through
`.footer-grid`'s `auto` track instead of through text spilling out of a
`minmax(0, 1fr)` one. `docs/02` §Reflow carries both mechanisms, the negative
control proving `break-word` would not have worked, and the candidates
declined as broader than the cause
- [ ] 🛑 **THE MINIMUM-FONT-SIZE STICKY RESIDUAL — REOPENED THE SAME DAY IT WAS
TICKED, AND IT IS A WCAG 2.2 SC 2.4.11 (AA) FAILURE RATHER THAN THE
SKIP-LINK INCONVENIENCE IT WAS ACCEPTED AS. NEEDS A FRESH RULING.**
It was ruled accepted on 2026-09-01 on the basis *"you proved no CSS
mechanism can see minimum-font-size; the only fix is JS and zero-JS is a
founding decision"*, and this checklist item was ticked accordingly. Both
supporting facts are wrong, and `adversarial-reviewer` found it the same day.
**(1) The premise.** The proof covered `rem`/`em` and queries written in them.
The font-metric units **`ch`, `ex`, `cap`, `lh`, `rlh`** read the *used* font
size and double under the setting — in property values, in `@media` **and** in
`@container`. A pure-CSS detector exists, so "the only fix is JS" does not
hold. **(2) The cost.** Not a short skip-link landing but **ordinary keyboard
focus landing entirely behind an opaque sticky header**: a Shift+Tab walk over
6 pages × 5 widths ≥ 1056 px gives **290 entirely-hidden focus stops of 1,455**
under the setting — **254 in the footer and 36 inside `#main`** — against **0**
at the default size. Hit-tested with `elementFromPoint`, not inferred from
geometry: geometry alone said 43 at the default size and all 43 were the skip
link, which is stacked *above* the header on purpose. **(3) And the header fix
created it:** a build of `fce89d4~1` measures **0** under the same setting,
because `flex-wrap: nowrap` kept the masthead one row and short. So the trade
was **830 px of horizontal overflow (SC 1.4.10) for 290 obscured focus stops
(SC 2.4.11)** — both Level AA, and the asymmetry that was the whole argument
does not exist. **A candidate fix is measured but deliberately not
implemented:** a third gate term in the shipping idiom, `calc((15px - 1ch) *
100000)`, un-sticks the header under the setting and leaves
`inset-block-start: 0px` at the default — but 15 px is fitted to Geist's `ch`
and needs checking under fallback metrics and Chrome's other font presets.
**This item is ticked only by a ruling from Pouya, not by a re-measurement.**
The decision is `AGENTS.md` **Q61**; `docs/02` §Reflow carries the measurements
- [ ] ⚠️ **A FOOTER NAV LABEL OVERRUNS ITS COLUMN BY 24 px AT 640 px UNDER
MINIMUM FONT SIZE, WITH 7.7 px OF CLEARANCE TO THE NEXT COLUMN.** No document
overflow at any width, so it fails no success criterion and is cosmetic
today — filed because **7.7 px is one label rename away from two columns
colliding**, and nothing in the build measures it. `.footer-col a`
"Construction & Infrastructure" overruns its 176 px box by **24 px** at
640 px and by 4 px at 700 px (clearance 27.7 px); "Shareholder & Family
Business" by 2 px at 640 px, and — at widths that 12-column grid steps over —
**17 px at 660 px and 11 px at 680 px**. Clean at every other width sampled,
including 768 and 834 px; `minimumFontSize=32`, 22 pages × 12 widths = 264
rows. **A fourth case of the same family: `nav.crumbs > ol` on
`/practice/construction/` @320 px overruns its own 272 px box by 7 px**, also
with zero document overflow. **Rule on both together** — if
`.footer-col a { overflow-wrap: anywhere }` is ruled in, the same call covers
`.crumbs`; if it is declined, decline both and the record is complete.
**`.footer-col a { overflow-wrap: anywhere }` is measured to close it and to
be inert at normal settings**, and is not applied because it changes how six
practice-area names break on a marketing surface under enlargement — a
copy-adjacent call, not a technical one. ⚠️ **And it falsified the reason
given for scoping the email fix to the address** (*"every other footer string
has spaces"*): min-content is set by the longest **word**, not by whether a
space exists. Corrected in `docs/02` §Reflow. Raised by
`adversarial-reviewer`, 2026-09-01
- [ ] ⚠️ **THE SEVENTH NAV ITEM PLUS FALLBACK FONT METRICS — A LAYOUT SHIFT *AND*
A SKIP-LINK FAILURE, AT THE DEFAULT TEXT SIZE, ARMING ITSELF WHEN THE SECOND
INSIGHTS ARTICLE PUBLISHES.** No reader setting is involved: with every
`.woff2` blocked — what a reader on the Slow 4G profile `docs/04` budgets
against sees first, since the fonts are `font-display: swap` — and a seventh
nav item present, the header measures **141 px at every width from 1056 to
1091 px** (a contiguous **36 px** band) and 81 px from 1092 px up; with the
webfonts loaded it is 81 px throughout. Two consequences: **(i)** a **60 px
collapse** when Geist swaps in, on all 22 pages, against the CLS &lt; 0.05
budget — *larger, not new*, since the previous build shifts ~5.6 px in the
same band; and **(ii) 44 px of `#main` behind the sticky header** after "Skip
to content", because 141 px exceeds the 97 px `scroll-padding-top` — **this
half IS new**, the previous build's 86.97 px stayed under 97 px and covered 0.
**Latent, not shipped:** six items never wrap, and `showInsights` turns the
seventh on at two published articles. Two candidate fixes, both Pouya's call
because both are outside "the header and its consumers": raise the desktop
breakpoint past 1091 px, which changes the normal-settings layout in that
band, or give Geist a metric-matched `size-adjust` fallback. Raised by
`adversarial-reviewer`, 2026-09-01; `docs/02` §Reflow carries the measurements.
⚠️ **THIS IS NOT A CUTOVER BLOCKER AND IT IS NOT TICKABLE HERE — IT IS A HARD
PRE-PUBLICATION GATE. Pouya's ruling, 2026-09-01: no seventh nav item ships
until the fallback-metrics defect is fixed**, which makes fixing it a
**prerequisite of publishing the second Insights article**, since that is what
adds the item. It is left unchecked on purpose: a checklist that fires once, at
cutover, cannot discharge a defect that arms itself later on an editorial
decision. ⚠️ **AND THE GATE IS A BUILD FAILURE, NOT THIS CHECKLIST AND NOT A
COMMENT.** `SiteHeader.astro` throws when `published.length >= 2`, with the
measurements and the instruction in the message, so it fires on both deploy
paths on the machine of whoever publishes. **Proven rather than assumed:** two
articles were temporarily flipped to `draft: false` and `npm run build` exited
**1** naming R20; the files were restored and the restoration verified by
`git diff --exit-code` and an unchanged `dist` digest. It was first written as
three prose cross-references and **did not gate** — the build and all five
checks passed while both defects shipped. **Owner: `AGENTS.md` R20**
**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