feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s

Five items of Pouya's production run, 2026-09-01.

Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.

Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.

Four launch blockers found by reading the running system:
  - handler.mjs wrote pk/sk; the live table's key is submissionId with no
    sort key, so every submission would have failed validation silently
  - the Lambda invoke permission is scoped to the old route path
  - 22 of 23 pages 403 without the router function
  - there was no 404 page; src/pages/404.astro adds it

Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.

Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-02 06:52:20 -04:00
co-authored by Claude Opus 5
parent ca1c2524e1
commit bd282aa47d
30 changed files with 3256 additions and 143 deletions
+253 -48
View File
@@ -39,6 +39,30 @@ 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.
> ✅ **AND THAT IS NOW A DECISION RATHER THAN A BLOCKER — Pouya's ruling,
> 2026-09-01: THE FIRST PRODUCTION DEPLOY GOES OUT VIA `scripts/deploy-local.sh`,
> AND THE GITEA RUNNER IS DEFERRED UNTIL AFTER LAUNCH.**
>
> His reason, and it is the reason the deferral is safe: enabling Actions and
> registering `act_runner` depends on the instance's **second administrator**, so
> the date it happens is not his to set — and **nothing about the deploy depends
> on it.** The script and the workflow are one artefact in two places; they run
> the same guard, the same three passes with the same cache headers, the same
> invalidation and the same intake route check. The pipeline changes only **how a
> deploy is triggered**. So a launch that waits for a runner is a launch waiting
> on someone else's calendar for no gain.
>
> **What the deferral actually costs, stated rather than waved past:** every
> deploy until then is a person at a keyboard with a long-lived access key in
> their environment, and nothing gates a merge to `main` (there is no
> `pull_request` trigger). Both were already true; the ruling does not add a
> risk, it declines to remove one yet. **`AGENTS.md` R17's rotation date does not
> move** — the key is live either way.
>
> It is **not** a cutover-checklist item and must not become one: a checklist
> fires once, at cutover, and this is deliberately scheduled for after it. The
> `docs/09-cutover-runbook.md` Part 8 sequence is the deploy path that ships.
> ✅ **`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
@@ -364,6 +388,27 @@ Then invalidate `/*`.
## Cutover checklist — D11 is a single shot, so run all of it
> **The commands are in `docs/09-cutover-runbook.md`.** This list is what must be
> true; that file is how, in order, with each command's verification and the
> output to expect. It also records the order dependency this list cannot show:
> **the distribution work goes first and the site sync goes last**, because
> reversing them puts 22 of 23 pages behind a 403 for as long as a CloudFront
> deployment takes.
> 🛑 **THREE THINGS BLOCK THIS ENTIRE LIST AS AT 2026-09-01, AND TWO OF THEM WERE
> FOUND BY READING THE RUNNING SYSTEM RATHER THAN THE SPECS.**
>
> 1. **`/legal/privacy/` states something false about who can read the intake
> table** — §9 **Q62**, evidence in
> `docs/reference/intake-table-access-verification.md`. A privacy policy is
> the wrong page to be wrong on, and this one is wrong about third parties'
> dispute information.
> 2. **Q60 is still open** — TTL is `ENABLED` and no record has been watched to
> disappear, and the same page asserts the mechanism. `docs/09` Part 10 is the
> test and its answer arrives days after it starts, so **start it early**.
> 3. **`claims-auditor`'s D20 pass returned six copy findings on 2026-09-01;
> five are corrected and one is a ruling** — the `/med-arb/` gloss, below.
**Cutover prep — deferred maintenance, done BEFORE the checklist below**
This group exists because deferring a thing and forgetting it look identical
@@ -406,6 +451,33 @@ the decision is re-readable rather than re-litigated.
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.
✅ **IT HAS RUN ONCE, 2026-09-01, OVER ALL 23 PAGES — AND IT RETURNED
FAIL WITH EIGHT FINDINGS, WHICH IS THE ARGUMENT FOR D20 RATHER THAN
AGAINST IT.** Five are corrected (`/practice/energy/`'s lede asserted a
change to how *generation* connects, which the extract's own quotation
denies; `/practice/insurance/` stated an absolute adjudicator
disqualification where LAT Rule 14.3 says *"except with the consent of the
parties"*, and glossed one permissive sentence as *"the Tribunal's own
materials point parties toward mediation"*; `/practice/technology/` asserted
*"no federal or Ontario statute"* — a universal over the **four**
instruments its extract checked — and its own note then disclaimed that
conclusion as the Commissioner's words; `/med-arb/` said the ADRIC Med-Arb
Rules were presented to the membership where the source says a **discussion
draft** was). Three remain and each has its own item below or above:
the `/med-arb/` gloss (a ruling), Q60, and Q62.
**Two things about that result are worth carrying forward.** The pass found
no defect in any claim about Pouya, his credentials or his designations —
every one traced. What it found was **five over-reaches in glosses on
sourced legal material**, which is the failure mode a per-step claims run on
a single page is least able to see and the one `check:claims` cannot grep
for at all. **And it re-derived a stale instruction inside its own brief:**
`.claude/agents/claims-auditor.md` still tells it Q.Arb is *"commenced
August 2026"*, which §4 struck on 2026-08-29 — applied literally it would
have flagged correct copy and demanded the struck form. It read §4 instead.
That is the fifth stale claim found in that file and it is not the agent's
to fix
- [ ] **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.
@@ -467,7 +539,27 @@ the decision is re-readable rather than re-litigated.
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)
with it (`AGENTS.md` §7).
✅ **Run 2026-09-01 on the 23-page build: exit 0, worst of 23 is
99 / 100 / 100 / 100.** Re-run at cutover — this is dated, not discharged.
⚠️ **The script was enumerating 22 of 23 and its header claimed it
enumerated the site.** It collected `index.html` only, and `404.astro` is
emitted as `dist/404.html`, outside `build.format: 'directory'`. So the
budget would have been asserted over every page except the new one, and
silently. `og:proof` had the identical blind spot and reported it
**backwards** — as the 404 card being orphaned ("generated, but no built
page references it") rather than the page being unmeasured. Both fixed; the
two use different path forms for the same file on purpose, and each says why
beside the code. **`check:claims` was never affected** — it takes any
`.html`, so the new page's copy was inside the claim register from its first
build. One instrument of three saw the page, which is the argument for
having three.
⚠️ **One reported number sits at its budget: LCP on `/` is 2.03 s against
`docs/04`'s < 2.0 s.** The script reports LCP and CLS without asserting
them, deliberately — simulated throttling on a loopback server is not the
Slow 4G field measurement `docs/04` budgets against — so this is not a
failing gate and must not be reported as a passing one either. CLS is
0.000 on all 23
- [ ] Every page renders fully with JavaScript disabled
- [ ] `curl` of each URL returns real content, not a shell
- [ ] All internal links resolve; no orphan pages
@@ -480,12 +572,44 @@ the decision is re-readable rather than re-litigated.
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
- [ ] **404 returns a 404 status AND serves the 404 page. Both halves, and until
2026-09-01 neither existed.** There was **no 404 page in the site at all**
— no `src/pages/404.astro`, 22 pages, and `docs/04` had asked for one
("Real, styled, with search-intent links out") since before build step 1 —
**and** no custom error response on the distribution, so a missing URL
returned S3's `AccessDenied` XML. Measured, not inferred: `/about/` and
`/definitely-not-a-page/` both answered **403, `application/xml`, 111
bytes** on the live distribution. The page now exists (`/404/`, `noindex`,
out of the sitemap, with its own OG card because the registry has one rule
and no exceptions) and `docs/09` Parts 13 configure the distribution. **Tick
this after `docs/09` Part 8.4**, which checks the status *and* greps the body
for the page's own `<h1>` — a 404 status serving an empty body passes the
status half and fails the point
- [ ] 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.)*
- [x] **SES identities verified for sending** — `VerifiedForSendingStatus: true`, `DkimAttributes.Status: SUCCESS`, signing enabled, and no custom MAIL FROM (so DMARC rests on DKIM alignment, which is what §7 records) `[re-verified 2026-09-01 — sesv2 get-email-identity]`
- [x] ✅ **SES bounce/complaint alarms DO notify someone — R9 DISCHARGED, 2026-09-01.** `aws sns list-subscriptions-by-topic` on `ses-alerts` returns the email subscription to `info@smlcompany.ca` with a **real subscription ARN**, not `PendingConfirmation`. §7 recorded it as pending, and §12 R9 said *"this is the first thing to check if `/contact/` ships"* — it had been confirmed at some point before this reading and the record had not moved, which is the same staleness in the safe direction. *(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.**
⚠️ **THE COMMANDS ARE `docs/09-cutover-runbook.md` PARTS 5 AND 6, AND
WRITING THEM FOUND TWO MORE THINGS, EACH OF WHICH WOULD HAVE LOST EVERY
SUBMISSION.** Both were found by reading the running system rather than the
specs, which is the only way either could have been found.
**(a) The handler wrote the wrong key.** It built its item with
`pk: INTAKE#<uuid>` / `sk: <timestamp>` from `docs/05`'s table, and the
live table's key schema is **`submissionId` (S), no sort key** — a
`PutItem` missing the key attribute fails the whole write with
`ValidationException`, which the handler catches and answers with the
failure page. **The form would have looked broken to every inquirer while
the record went nowhere**, from the moment `/api/*` was wired. A DynamoDB
key schema cannot be altered after creation, so the handler moved to the
table; `docs/05` §Storage records the alternative that was declined.
**(b) The Lambda's invoke permission is `SourceArn`-scoped to the OLD
route.** The one statement on the function conditions
`apigateway.amazonaws.com` on `…/4tl0m5igkj/*/*/submissions`. Add a route at
`/api/intake` without adding a permission and API Gateway is refused: the
caller gets a **500**, and **the Lambda logs nothing at all** because it is
never entered — the hardest possible thing to diagnose from CloudWatch,
which is where anyone would look first.
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'`,
@@ -512,21 +636,36 @@ the decision is re-readable rather than re-litigated.
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.
3. **An AGGREGATE API Gateway route throttle** — `docs/09` Part 6.3. Not
expressible in handler code, and it carries load the unimplementable
3-second timing check was specified to carry (`docs/05` §Three
deviations). ⚠️ **THIS ITEM SAID "5 requests / 5 minutes per source IP"
AND THAT IS NOT BUILDABLE AT API GATEWAY** — throttling there is per
route and per stage across all callers. It contradicted this same file's
own correction further down the list, and it is a to-do list an operator
works through, so it would have cost an hour in the API Gateway console
looking for a control that is not there. Per-IP needs AWS WAF, which is
a cost decision and has its own item.
✅ **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.
`.gitea/workflows/deploy.yml` each end with an **Intake route check**.
⚠️ **AND THIS PARAGRAPH DESCRIBED A PROBE THE SCRIPTS NO LONGER RUN.** It
said the probe was *"a `POST` to `/api/intake` with **no `Origin`**"* where
*"404 means not routed, 403 means routed and refused… therefore a pass"*.
Both scripts send **`Origin: https://adr.smlcompany.ca`** and assert **303
to `/contact/could-not-send/`**; 403 is a **warning**, not a pass, because
it cannot be told apart from CloudFront rejecting a disallowed method. A
stale description of a live control, in the spec that governs it — found by
`adversarial-reviewer`, 2026-09-01.
**What the probe actually is:** the handler validates, finds an empty
submission, and redirects — which happens **before any DynamoDB write and
before any email**, and is what makes it safe against production. It warns
rather than fails, because by that point the site is deployed and failing
the job would not un-deploy it. **404 is ambiguous between three causes**
and `docs/09` Part 7.1 lists them with the one command that separates them.
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
@@ -542,6 +681,21 @@ the decision is re-readable rather than re-litigated.
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)`.
✅ **THE SETTING IS RE-CONFIRMED AND THE PROCEDURE NOW EXISTS:**
`TimeToLiveStatus: ENABLED` on attribute `ttl` `[re-verified 2026-09-01 —
describe-time-to-live]`, and `docs/09-cutover-runbook.md` **Part 10** is the
behaviour test — write a row with a near-future `ttl`, record its id to a
file, and check on a **different day**.
⚠️ **START IT EARLY. DynamoDB's TTL sweeper is not prompt and AWS does not
promise that it is** — deletion typically lands within a couple of days of
the timestamp passing, so the runbook sets the earliest useful check at
**48 hours** and refuses to call a failure before **7 days**. A check run
five minutes after the `ttl` that finds the row present has measured
nothing. This is the one blocking item on this list whose answer cannot be
hurried, so it should be started before anything else here, not last.
⚠️ **And `get-item` on a missing key exits 0 with an empty body** — read the
output, not the exit status; a script checking only the status reports
present and absent identically.
- [ ] **`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
@@ -613,35 +767,86 @@ the decision is re-readable rather than re-litigated.
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
- [x] **THE MINIMUM-FONT-SIZE SC 2.4.11 (AA) FAILURE IS FIXED, 2026-09-01 —
Q61 RULED *fix now* AND CLOSED.** Two declarations on `html` inside the
existing `@media (min-width: 66rem)` block: the plain
`calc(var(--header-h) + var(--space-4))` first, then
`max(calc(var(--header-h) + var(--space-4)), calc(10lh - 83px))`.
**`1lh` and not `rem`, because the font-metric units read the USED font
size** — that is the mechanism the earlier ruling's premise denied existed.
**Measured, with the pre-fix build rebuilt in a worktree and put through the
identical probe:**
the Shift+Tab walk over 6 pages × 5 widths ≥ 1056 px goes from **290
entirely-hidden focus stops of 1,455 to 0** — same grid, same 1,455 steps,
same 344 header-internal stops skipped — while the pre-fix control still
reports **290** (footer 254, `#main` 36), so the probe is proven able to see
the failure it now reports gone.
**The default-settings rendering is unchanged: 0 differences across 352
page-widths × 17 fields = 5,984 comparisons**, full-page geometry
fingerprint included, with a positive control detecting exactly 1 injected
difference. `scroll-padding-top` computes to **97 px** at the default and
**287 px** under the setting against a 270.56 px header; `1lh` on `<html>`
is **18 px / 37 px with every `.woff2` blocked**, identical, because
`<html>` keeps the UA family — the property that the `ch` candidate could
not claim.
**A wider grid than the ruling asked for, and it found a residual: 777
cells over 37 settings went from 63 failing to 12, and nothing got worse in
any cell.** The remainder is the next item, it is **pre-existing**, and it
was deliberately not fixed — Pouya's instruction was to stop and report
anything beyond the one declaration's blast radius
- [ ] ⚠️ **MINIMUM FONT SIZE AT 16 AND 20 STILL LEAVES `#main` BEHIND THE HEADER —
PRE-EXISTING, REDUCED BY Q61'S FIX, AND OUTSIDE ITS MECHANISM.** At
`minimumFontSize=16`, **52 px** of `#main` sits behind the sticky header
after "Skip to content" on `/about/` and `/contact/` at 1280, 1440 and
1920 px — 6 cells; at `minimumFontSize=20`, **14 px** on the same 6. Before
Q61's fix the same grid gave 6 cells at 52 px, **12 at 64 px**, 15 at 75 px
and 15 at 174 px, so every remaining cell is unchanged or better and none
is new.
**Why the `lh` ramp cannot see it, and this is the interesting part:** the
setting *floors* computed sizes, so at `minimumFontSize=16` the root is
genuinely 16 and `1lh` on `<html>` is 18 px — identical to the default —
while the header grows to **149.38 px** because the sub-16 px type (the
11 px tagline, the 14 px eyebrow and nav) is floored up. The ramp reads a
quantity the setting did not move. **A different measurement would be
needed** — `1lh` taken from an element that actually carries the small type,
or a floor on the header — and that is a header change with site-wide reach,
which is why it is filed rather than taken inside a capped review loop.
Not a conformance failure of the same shape as Q61: a partial obscuring is
not SC 2.4.11, which is about a component **entirely** hidden. `docs/02`
§Reflow carries the 777-cell grid
- [ ] ⚠️ **THE `/med-arb/` GLOSS NEEDS A RULING — `claims-auditor`, D20 pass,
finding 1.** §Why this practice says *"Med-arb is not a third service
bolted onto mediation and arbitration; it is the two of them run under one
agreement"*, and one section above the page publishes ADRIC's own words:
med-arb is *"not merely the merging of separate mediation and arbitration
processes, but a unique process designed to meet the needs of particular
disputants"*. **The gloss asserts what its own cited authority denies in
terms, on the same page.** Remove one of the two — either the *"not merely
the merging"* clause from the quotation, or the *"it is the two of them run
under one agreement"* sentence. ⚠️ **Do not resolve it by adding a
competence claim:** ADRIC answers its own competence sentence with the
Chartered Med-Arb designation and the Med-Arb Foundational Course, neither
of which is held, and C.Med-Arb is struck from this site entirely (§4). The
2026-08-30 fix took the competence proposition out of the **heading** and
left it in the **body**, which is why this is a second pass at the same
paragraph
- [ ] ⚠️ **FOUR PRE-EXISTING DYNAMODB ITEMS HAVE NO `ttl` AND WILL NEVER EXPIRE.**
The table held 4 items before any of this work `[verified 2026-09-01 —
describe-table ItemCount]`, written by the hand-built handler, whose code
is in `docs/reference/AWS-Hosting-Guide.md` Part 8.3 and **writes no `ttl`
attribute at all**. So they are retained indefinitely while
`/legal/privacy/` says a record is deleted automatically after 24 months.
**Pouya's call and not scriptable**, because it turns on something no agent
can determine: whether they are the guide's own smoke test (`Test User`) or
real inquiries that arrived through the old site. Read them, then delete the
test rows or write a `ttl` onto the real ones. `docs/09` Part 7.3
- [ ] ⚠️ **PER-IP RATE LIMITING NEEDS AWS WAF AND IS A COST DECISION — NOT A
LAUNCH BLOCKER.** `docs/05` asked for "5 requests / 5 minutes per source
IP at API Gateway"; **API Gateway throttling is aggregate, not per-IP**, so
that spec line is struck and an aggregate route throttle ships instead
(`docs/09` Part 6.3). A rate-based WAF rule on the distribution is what
would do per-IP. Decide it on price, not on the spec's old wording — and do
not let anything describe the throttle that ships as per-IP
- [ ] ⚠️ **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
@@ -704,10 +909,10 @@ the decision is re-readable rather than re-litigated.
checks passed while both defects shipped. **Owner: `AGENTS.md` R20**
**Infrastructure**
- [ ] S3 versioning enabled
- [ ] Bucket not publicly readable; OAC in force
- [x] **S3 versioning enabled** — `Status: Enabled` `[re-verified 2026-09-01 — get-bucket-versioning]`. This is what makes pass 3's `--delete` recoverable at cutover
- [x] **Bucket not publicly readable; OAC in force** — all four public-access blocks `true`, and a direct `GET` on the S3 hostname returns **403** `[re-verified 2026-09-01 — get-public-access-block, curl]`. ⚠️ **Re-check this after `docs/09` Part 1**, which adds an `s3:ListBucket` statement for the CloudFront service principal so that missing keys return 404 rather than 403. Part 1 asserts the two facts that actually make the grant safe: **no viewer path resolves to the bucket root** (the default root object, then the router, always turn `/` into a key), and the default behaviour **forwards no query strings** to the origin with **no origin request policy** overriding that. ⚠️ **This line used to prescribe `curl "$SITE/?list-type=2"` as "the check that matters"** — and Part 1 was rewritten in the same change set to explain that that check **cannot fail** and is therefore evidence of nothing. Two documents, one fix, and the discarded check survived in the one an operator ticks. Found by `adversarial-reviewer` round 2
- [ ] ACM certificate valid; Namecheap validation CNAME still present
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
- [ ] **CloudWatch alarms: Lambda `Errors`, API Gateway `5xx`.** ⚠️ **"DLQ depth" was on this line and is struck: a dead-letter queue on this function would never receive anything.** `DeadLetterConfig` applies to **asynchronous** invocations, and API Gateway invokes synchronously — so the alarm would have sat permanently green while telling everyone it was watching, which is this project's most expensive recurring shape (`AGENTS.md` Q22, the Lighthouse row, and now this). `docs/05` §Notification carries the replacement and why there are two alarms rather than one: Lambda `Errors` catches a synchronous failure, and API Gateway `5xx` catches the case where the function is never entered at all
- [ ] 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**