The step-1 header decision is formally reopened (AGENTS.md (ak)): its record attributed the residual to the wrong cause and read a measured 944px functional failure as a robustness margin. Mechanism — wrapping, plus a gated sticky offset: - `flex-wrap: nowrap` removed from `.header-inner` (measured necessary AND sufficient) and from `.nav-list` (measured inert; removed so the prohibition is not contradicted by a `nowrap` in the same file), with a dead `flex: none`. - `inset-block-start` becomes a two-band, two-term saturating clamp() so the header is sticky only while the masthead is one row. A media query cannot express this: its `rem` resolves against the browser DEFAULT font size, a property's against the root element. The second term catches a root BELOW the default, where the 80rem content cap shrinks faster than the header's px minimums; without it 65px of `#main` sat behind the header at 9px. - Wrapping is the only mechanism that reflows under all THREE enlargement paths, because Chrome's minimum-font-size setting is invisible to @media, to @container and to every length unit. Measured, 22 pages x 16 widths = 352 page-widths per method: root-style 32px 175/352 -> 0/352 (1280px: 944px overflow -> 0) minimum-font-size 32 219/352 -> 88/352 (residual is the footer email) default-font-size 32 0/352 -> 0/352 default (root 16) 0/352 -> 0/352 Nav items and CTA on-screen in 1408/1408. A further 762 points across roots 9-32, both thresholds, the band seam and all five Chrome presets: 0 failing. Normal-settings identity: 0 differences on 352 page-widths across six metrics, with six nav items and with a seventh injected. Header 81.00px at all eight widths >= 1056, CTA gap 0.00px. Lighthouse: 22 pages, no category below 95, CLS 0.000 on every page. --header-h is reworded as a FLOOR, not a constant; value unchanged. Also in this step, per ruling: - /bio/ print `font-weight` frozen at 400 — the circulated PDF's typography changes only when its content is deliberately revised, never as a side effect of a screen refactor. Declaration byte-identical; the constraint is recorded. - CLAUDE.md: the two-simulation rule for enlarged text, the zsh no-word-splitting rule, the third (minimum-font-size) mechanism, and "state the grid with the count". Two rounds of adversarial-reviewer, eleven findings, all resolved; round 2's blocking finding was a defect in round 1's own fix. Two suggested fixes declined with reasons in (ak). claims-auditor deliberately not run — D20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
620 lines
37 KiB
Markdown
620 lines
37 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` 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
|
||
|
||
**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`
|
||
- [ ] **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
|
||
18–30. **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 9–15 (70), roots 16–32 (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
|
||
- [ ] ⚠️ **TWO REFLOW RESIDUALS SURVIVE, BOTH UNDER CHROME'S MINIMUM-FONT-SIZE
|
||
SETTING ONLY, AND BOTH NEED A RULING RATHER THAN A RE-MEASUREMENT.**
|
||
*(a) The footer, and it is OUT OF the scope Pouya set for the header step.*
|
||
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, 0 from 1200 px up. Cause:
|
||
`info@smlcompany.ca` has no break opportunity and needs 310 px inside a
|
||
224–243 px footer-contact column; at 320 px the `.footer-col` box itself is
|
||
310 px in a 272 px content area. **Pre-existing: the footer's OWN overflow is
|
||
byte-identical before and after** (`div.footer-grid` 78 px at 1056, 67 px at
|
||
1100). The 1056/1100 *document* figures did change, 414 → 30 and 370 → 19,
|
||
because the header stopped contributing its own — a document figure and a
|
||
footer contribution are different measurements.
|
||
*(b) The skip link, and it CANNOT be fixed in CSS.* The header stays sticky
|
||
at **164.58–270.56 px** under that setting, so "Skip to content" lands with
|
||
**68–174 px of `#main` behind the header** on 22 of 22 pages from 1056 px
|
||
up — it was 10–26 px before, so the header fix widened it, deliberately, to
|
||
buy the removal of 830 px of horizontal overflow with four nav items and the
|
||
CTA unreachable. **Nothing in CSS can detect that mechanism**: media queries,
|
||
container queries AND length units all keep resolving `rem`/`em` at 16 px
|
||
while `getComputedStyle` reports 32 px (measured; `docs/02` §Reflow). The
|
||
remaining candidates are a fitted constant tied to the nav item count, or a
|
||
behaviour change at normal settings. **Ticked by Pouya fixing (a) and ruling
|
||
on (b) — not by re-measuring with a method that reports zero**
|
||
|
||
- [ ] ⚠️ **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 < 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
|
||
|
||
**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
|