Pouya's five rulings of 2026-08-31, after 64bce10. One commit, because AGENTS.md
entry (aj) covers all five and the review fixes interleave across them —
recorded here rather than left to be inferred.
1. THE EYEBROW, 13 -> 14px, at the one token definition. `--text-eyebrow` is
deliberately NOT an alias of `--text-sm` despite sharing its value: the two move
for different reasons, and aliasing would mean a change to body-meta type
silently moving every eyebrow. Sweep by rendering, 22 pages x 2 widths, 801 mono
instances: 441 carry `.eyebrow`, 420 now at 14px, 21 held at 11px. Zero of 801
below 4.5:1.
Header check, which Pouya asked for explicitly: nothing header-side consumes the
token, `--header-h` is 81px, and the sticky header measures exactly 81.00px at
every width from 66rem up with the CTA on `.header-inner`'s content edge. Nothing
overflowed, so nothing was improvised.
2. /contact/'s LABELS RAISED, AND THE FLOOR CLAIM REWRITTEN BECAUSE THE RULING'S
PREMISE WAS FALSE. The ruling asked docs/02 to record the tagline as "the ONLY
sub-14px text on the site". It is not: the `--text-xs` 12px rung has ten
declaration sites and 130 elements rendering at 12px. So docs/02 records the
claim that IS verified — `--text-2xs` has one consumer in the repository — and
enumerates the 12px rung as a separate treatment. Writing a false claim into the
spec because the conclusion was handed down is the move the rules forbid.
A third block moved that the ruling did not name: /bio/'s ten <h2>s and its strap
were copies of five of `.eyebrow`'s six declarations at 11px — the same escape the
footer headings were. docs/02 justified holding them by the one-page PDF
constraint; measured, `@media print` sets both to 7pt, so the screen size never
reached the PDF and that was never the reason.
Consolidating them DID change the printed sheet, because the missing sixth
declaration was `font-weight`: 500 grew the PDF 89,496 -> 91,151 bytes. Print
therefore freezes 400, the committed PDF stays byte-identical (10 differing
bytes, all /CreationDate and /ModDate), and unifying is Pouya's call.
3. THE REFLOW DEFECT WAS SITE-WIDE AND /bio/ WAS NOT THE BINDING CAUSE.
`white-space: nowrap` on `SiteHeader .brand-name` held all 22 pages 63px over at
320px/root 32. Eight cause-specific fixes, no `overflow-x` anywhere; all eight
produce byte-identical geometry at root 16. `Pill` is marked in docs/02 as the
backstop it is — the real cause was `PracticeCard`'s rem-based padding, and with
it clamped "Construction" goes from 94x220 in six two-character lines to 158x85 in
two.
Three instrument findings now in docs/02, each of which hid a real defect:
`break-word` does not reduce min-content and `anywhere` does;
`getBoundingClientRect()` reports border boxes, so an element sweep cannot see
text spilling outside its own box; and `mobile: true` emulation expands the
layout viewport, so `scrollWidth - innerWidth` reads 0 while the reader still
scrolls sideways.
4. TTL STAMPED `ENABLED` in §7, with `DISABLED` at first verification recorded
rather than overwritten. Q60 narrowed to its second half and OPEN: no record has
been watched to vanish, and `ENABLED` proves the setting, not the behaviour.
R19's sweep found three stale copies outside §7; all now defer to it. R19 itself
was not edited — it points at §7 rather than carrying state, which is the
property that made it work.
5. §4 GAINS THE STRUCK-UNIVERSAL ROW, citing the committed ontario.ca extract and
cross-referenced to `check:claims`'s `struck-universal-q39`. It bars the claim in
BOTH directions: the commercial half is Pouya's attributed position, not a
verified fact. The row immediately caught two places asserting it flatly —
`SiteHeader.astro` and §9 Q33 — both now attributed. `check:claims` unmodified;
still frozen.
REVIEW: adversarial-reviewer, two rounds, 16 findings, ALL ACCEPTED, NONE
DECLINED. claims-auditor did not run (D20). Eight of round 2's ten were defects
in round 1's own fixes.
Round 1's blocking finding was a defect in my own record: I wrote that every
reflow residual was zero at "root 16 and root 32, 286 measurements". Two ways of
simulating 200% text are NOT equivalent, because media-query `rem` resolves
against the DEFAULT font size, not the root element's. Under the method docs/02
itself prescribes the site is 944px over on 21 pages and 508px on / at 1280, and
304px at 1920 — nav clipped mid-word, Practice/Fees/Contact and the CTA
off-screen, WCAG 1.4.4 with loss of functionality. My 286 excluded exactly the
widths where the defect lives.
NOT FIXED, DELIBERATELY: the cause is `flex-wrap: nowrap` on `.nav-list` above
66rem, a locked step-1 decision, and the standing instruction is to stop and
report rather than improvise a header change. It is now a blocking item on
docs/06's cutover checklist, ticked only by ruling on it — "not by re-measuring
it with the method that reports zero".
Round 2 also caught: a 63,743-byte figure that was the `cmp -l` differing-byte
count rather than the 1,655-byte size delta; a lost-navigation list naming
Med-Arb, which is not in the masthead at any width, while omitting Contact; the
tagline's justification left recorded at 13px, where one clause of it is false at
14px (20px of document overflow at 1216 with a seventh nav item); my own label
raise reintroducing a 38px element overflow at the width just ruled on; two
copies of the eyebrow treatment left at weight 400; three stale residual tables;
and the min-content explanation duplicated six times in src/.
VERIFIED, exit statuses read directly, never through a pipe: build 0 (22 pages),
check 0 (0 errors/0 warnings/0 hints), check:claims 0, og:proof 0, check:intake 0,
lint 0, minifier tripwire clean, TODO in dist 0 with a source sanity check.
Overflow: 374 measurements over 22 pages, every one zero, row counts asserted
before reading, with positive controls (119px at width 200, 319px at root 64).
Lighthouse 0, run three times with identical category scores: perf 99 on / and
100 elsewhere, a11y 100, best practices 100, SEO 100 on all indexable pages,
CLS 0.000. / sits at LCP 2.03s against a 2.0s budget, unchanged by this work.
The zsh `$VAR` word-split trap fired twice more and both times read as a clean
pass; caught only by asserting row counts first. Two of my own instruments were
wrong before they were right: a `grep -F '0.875rem'` state check that could never
match because Lightning CSS writes `.875rem`, and a probe whose inline
`!important` was outranked by a running CSS transition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
566 lines
33 KiB
Markdown
566 lines
33 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
|
|
- [ ] ⚠️ **THE 200%-DEFAULT-TEXT RESIDUAL AT 1280 px AND 1920 px IS RULED ON,
|
|
EITHER WAY. BLOCKING, AND IT IS A DECISION RATHER THAN A TASK.** With
|
|
`documentElement.style.fontSize = '32px'` the site is over by **944 px on 21
|
|
pages and 508 px on `/` at 1280 px**, and **304 px on 21 pages at 1920 px**
|
|
— and it is not a scroll margin: the nav clips mid-word and **Practice, Fees,
|
|
Contact and the header CTA are entirely off-screen — including the route to
|
|
the inquiry form**, WCAG 1.4.4 with loss of functionality. The cause is `flex-wrap: nowrap` on `.nav-list` above
|
|
66 rem, a **locked step-1 header decision**, so no fix was improvised — see
|
|
`docs/02` §Reflow for the measurement, the two non-equivalent ways of
|
|
simulating 200% text, and the two candidate fixes. **This item is ticked by
|
|
Pouya either fixing it or recording it as knowingly accepted with the
|
|
measured consequence — not by re-measuring it with the method that reports
|
|
zero.** The narrow widths (320/360/390/414/640/1024) are fixed and are 0.
|
|
Raised by `adversarial-reviewer`, 2026-08-31, against a record that had
|
|
declared the whole thing fixed
|
|
|
|
**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
|