feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s
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:
co-authored by
Claude Opus 5
parent
ca1c2524e1
commit
bd282aa47d
@@ -178,9 +178,25 @@ years in ADR practice, or time-to-award statistic is forbidden outright. The
|
||||
approved stat set is `Q.Med` / `JD + ML` / `EN · FA`, plus `Q.Arb` in a fourth
|
||||
slot.
|
||||
|
||||
**Q.Arb.** Commenced August 2026. Flag anything reading as held, imminent, or
|
||||
nearly complete. The Arbitration page must state plainly what is available now
|
||||
versus what follows designation.
|
||||
**Q.Arb — DO NOT HOLD ITS STATE HERE EITHER. Read §4's row at audit time.**
|
||||
This paragraph said *"Commenced August 2026. Flag anything reading as held,
|
||||
imminent, or nearly complete. The Arbitration page must state plainly what is
|
||||
available now versus what follows designation."* §4 recorded Q.Arb as **HELD** on
|
||||
2026-08-29, struck every stage form — `commenced`, `in progress`, `pathway`,
|
||||
`not yet` — struck the Forbidden row against *"held, imminent, nearly complete"*
|
||||
with it, and dissolved the paired-disclosure condition with an explicit
|
||||
instruction to leave no residue. **Applied literally, the struck text would have
|
||||
flagged correct copy and demanded the struck form**, and an imperative sentence
|
||||
about what a page "must state plainly" is the kind an agent obeys.
|
||||
|
||||
Found by this agent in the D20 cutover pass, 2026-09-01, which read §4 instead —
|
||||
**the fifth stale claim found inside this file**, after the memberships list
|
||||
below, and the shape is identical every time: a fact copied here, corrected in
|
||||
§4, never swept. The rule that follows from five instances is the one the
|
||||
memberships paragraph already states, generalised: **this file holds the
|
||||
questions to ask, not the answers.** Any state that can change — a designation, a
|
||||
membership, a date, a rate — is read from §4 at audit time. If you find yourself
|
||||
about to write a value here, write the §4 pointer instead.
|
||||
|
||||
**Memberships.** **Do not hold a list here. Read the memberships row in
|
||||
`AGENTS.md` §4 at audit time and use what it says.** This paragraph used to
|
||||
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
--max-time 15 \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' "$url" 2>/dev/null || true)
|
||||
--data 'deploy-route-probe=1' "$url" || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)."
|
||||
echo "The site is deployed and the contact form is unverified."
|
||||
@@ -205,9 +205,19 @@ jobs:
|
||||
else
|
||||
echo "WARNING: POST /api/intake returned $code, expected 303 to"
|
||||
echo "/contact/could-not-send/; redirect was '${location:-none}'."
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can"
|
||||
echo "mean the same thing, or the handler refusing the Origin."
|
||||
echo "See docs/06-deployment.md's cutover checklist."
|
||||
# Kept in step with scripts/deploy-local.sh — the two are one
|
||||
# artefact in two places. 404 is ambiguous between three causes and
|
||||
# the distribution's 404 mapping hides API Gateway's own body.
|
||||
echo "404: /api/* behaviour missing (docs/09 Part 3), OR the POST"
|
||||
echo "/api/intake route missing (Part 6.2), OR the route exists and"
|
||||
echo "the 404 mapping replaced the API's body. Separate them with"
|
||||
echo "aws apigatewayv2 get-routes --api-id <id> --query"
|
||||
echo "'Items[].RouteKey' — the --api-id is required; without it the"
|
||||
echo "CLI exits 252 on ParamValidation."
|
||||
echo "403: method rejected, or the handler refused the Origin —"
|
||||
echo "check Managed-AllViewerExceptHostHeader is on the behaviour."
|
||||
echo "500: the invoke permission for this route is missing (6.1)."
|
||||
echo "See docs/09-cutover-runbook.md Part 7.1."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
|
||||
@@ -46,3 +46,9 @@ test-results/
|
||||
|
||||
# generated inventory — safe to share, but not tracked
|
||||
aws-inventory.txt
|
||||
|
||||
# The pre-cutover archive of the old single-file build (docs/09 Part 8.1).
|
||||
# NOT committed: it is ~3.3 MB of the page that carried the fabricated founder
|
||||
# and the invented matter values, and Part 8.3 runs `git tag` eleven lines later.
|
||||
# `docs/06` says to keep it, not to version it.
|
||||
_archive/
|
||||
|
||||
@@ -718,11 +718,11 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec
|
||||
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
|
||||
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
|
||||
| S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` |
|
||||
| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com`, Deployed `[verified 2026-08-26]` |
|
||||
| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. ⚠️ **AND THREE THINGS THE SITE NEEDS ARE ABSENT: NO FUNCTION ASSOCIATIONS, NO CUSTOM ERROR RESPONSES, NO CACHE BEHAVIOURS** `[verified 2026-09-01 — get-distribution-config]`. The first is why **22 of the 23 pages do not serve**: `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist. Measured on the live distribution the same day: `/` **200**, `/about/` and `/definitely-not-a-page/` both **403 with an 111-byte `application/xml` body** — S3's `AccessDenied`, served raw. `docs/09-cutover-runbook.md` Parts 1–3 configure all three; `infra/cloudfront/` holds the function and the config script |
|
||||
| ACM certificate | `arn:aws:acm:us-east-1:327082975128:certificate/2b6d5bdf-6790-430c-9b82-c00ab66e6d87` — ISSUED `[verified 2026-08-26]` |
|
||||
| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` `[verified 2026-08-26]` |
|
||||
| Intake Lambda | `adr-intake-handler`, `nodejs24.x` `[verified 2026-08-26]` |
|
||||
| Intake table | `adr-intake-submissions` (DynamoDB, ca-central-1) `[verified 2026-08-26]`. **TTL IS `ENABLED`, `AttributeName: ttl`** ``[verified 2026-08-31 — Pouya ran `describe-time-to-live` and read `TimeToLiveStatus: ENABLED`]``. The handler side matches: `backend/intake/handler.mjs` writes `ttl` as a Number in **epoch seconds** at **24 months** (`RETENTION_MONTHS = 24`, added to `getUTCMonth()`), which is `docs/05` §Retention and the `ttl` row of its item table `[verified 2026-08-31 — read from the handler, not recalled]`. ⚠️ **IT WAS `DISABLED` AT FIRST VERIFICATION EARLIER THE SAME DAY, AND THAT IS RECORDED RATHER THAN OVERWRITTEN.** Pouya ran `describe-time-to-live` on **2026-08-31** and it returned `DISABLED`; he enabled it on **2026-08-31** and re-read `ENABLED` the same day. `/legal/privacy/` has stated since build step 10 that a record is *"deleted automatically by the database rather than by someone remembering to do it"* after 24 months, so **that promise was unbacked from the day it was written until the day it was enabled** — the handler wrote the attribute and nothing on the table consumed it. This is the Q22 shape on a public privacy commitment rather than on a deploy control: a documented mechanism that did not exist. ⚠️ **`ENABLED` PROVES THE SETTING, NOT THE BEHAVIOUR, AND THE BEHAVIOUR IS STILL UNPROVEN — §9 Q60 STAYS OPEN.** No record has been written with a near-future `ttl` and watched to disappear. `docs/06`'s cutover checklist carries that test as a blocking item, it is not ticked by reading this row or the handler code, and §12 R19 keeps it surfacing until a deletion has actually been observed |
|
||||
| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` `[verified 2026-08-26]`. **One route, `POST /submissions`** → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, which is the format `handler.mjs` reads). Stage `$default`, auto-deploy on, **no throttling**, no access log. CORS allows `POST` from the site origin `[verified 2026-09-01 — get-routes, get-api, get-stages, get-integrations]`. `DisableExecuteApiEndpoint` is **false** and must stay false: the CloudFront origin **is** that hostname. The form's route (`POST /api/intake`) does not exist yet — `docs/09` Part 6 |
|
||||
| Intake Lambda | `adr-intake-handler`, `nodejs24.x`, **arm64**, handler `index.handler`, timeout **10 s**, memory **128 MB**, role `adr-intake-lambda-role`, **no environment variables**, no DLQ, code **1,527 bytes**, last modified 2026-05-26 `[verified 2026-09-01 — get-function-configuration]`. **That is still the HAND-BUILT function, not `backend/intake/handler.mjs`** — nothing has been deployed (D11). Its resource policy has **one** statement, `apigateway.amazonaws.com` conditioned on `SourceArn` `…/4tl0m5igkj/*/*/submissions` — **the old route's path only**, so a new route needs its own permission or API Gateway is refused and answers 500 with nothing in the Lambda log. The execution role is **sufficient as it stands**: `dynamodb:PutItem` on the table (write-only — it cannot read it), `ses:SendEmail`/`SendRawEmail`, plus `AWSLambdaBasicExecutionRole`. Deployment commands: `docs/09-cutover-runbook.md` Part 5 |
|
||||
| Intake table | `adr-intake-submissions` (DynamoDB, ca-central-1) `[verified 2026-08-26]`. ⚠️ **KEY SCHEMA: PARTITION KEY `submissionId` (S), NO SORT KEY** ``[verified 2026-09-01 — `aws dynamodb describe-table`]``. **This contradicted `docs/05`, which specified `pk`/`sk`, and `backend/intake/handler.mjs` was written to the spec** — a `PutItem` missing the key attribute fails the whole write with `ValidationException`, the handler catches it and returns the failure page, so **every submission would have been lost while looking like a browser problem**. A DynamoDB key schema cannot be altered after creation; the handler was changed to the table on 2026-09-01 and `docs/05` §Storage carries the correction and the declined alternative. PITR **`ENABLED`**, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`. Encryption at rest uses the **AWS-owned key — there is no customer-managed KMS key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`, which `/legal/privacy/` does not claim, so nothing published depends on it. **4 items predate this repo**, written by the hand-built handler, which writes **no `ttl`** — so they never expire; `docs/06` carries that as Pouya's call. **TTL IS `ENABLED`, `AttributeName: ttl`** ``[verified 2026-08-31 — Pouya ran `describe-time-to-live` and read `TimeToLiveStatus: ENABLED`]``. The handler side matches: `backend/intake/handler.mjs` writes `ttl` as a Number in **epoch seconds** at **24 months** (`RETENTION_MONTHS = 24`, added to `getUTCMonth()`), which is `docs/05` §Retention and the `ttl` row of its item table `[verified 2026-08-31 — read from the handler, not recalled]`. ⚠️ **IT WAS `DISABLED` AT FIRST VERIFICATION EARLIER THE SAME DAY, AND THAT IS RECORDED RATHER THAN OVERWRITTEN.** Pouya ran `describe-time-to-live` on **2026-08-31** and it returned `DISABLED`; he enabled it on **2026-08-31** and re-read `ENABLED` the same day. `/legal/privacy/` has stated since build step 10 that a record is *"deleted automatically by the database rather than by someone remembering to do it"* after 24 months, so **that promise was unbacked from the day it was written until the day it was enabled** — the handler wrote the attribute and nothing on the table consumed it. This is the Q22 shape on a public privacy commitment rather than on a deploy control: a documented mechanism that did not exist. ⚠️ **`ENABLED` PROVES THE SETTING, NOT THE BEHAVIOUR, AND THE BEHAVIOUR IS STILL UNPROVEN — §9 Q60 STAYS OPEN.** No record has been written with a near-future `ttl` and watched to disappear. `docs/06`'s cutover checklist carries that test as a blocking item, it is not ticked by reading this row or the handler code, and §12 R19 keeps it surfacing until a deletion has actually been observed |
|
||||
| SES identities | Domain `smlcompany.ca` **verified for sending** `[verified 2026-08-26]`; addresses `info@`, `intake@`, `adr@` |
|
||||
| SES account | **Production access GRANTED** — out of the sandbox in `ca-central-1`, confirmed by AWS in writing and effective immediately `[verified 2026-08-26 — Q19 closed]`. Mail now reaches unverified recipients, so the inquirer confirmation in D18 works |
|
||||
| Mail hosting | **Google Workspace** — MX `1 smtp.google.com`; `google._domainkey` present, so Google DKIM is configured `[verified 2026-08-26 — DNS query]` |
|
||||
@@ -735,7 +735,7 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec
|
||||
| SES identity — `us-east-1` | **BEING DELETED — Pouya's instruction 2026-08-29, and the reason is a monitoring principle rather than a cleanup.** It was `Verified: false`, `DkimStatus: PENDING`, tokens `3zsn…` / `jejgp…` / `xpiwy…` — the doubled-domain set — a stray identity in a region this project does not use `[verified 2026-08-26]`. On **2026-08-29** it produced an AWS Health event, `AWS_SES_DKIM_PENDING_TO_FAILED`, which is the **expected** outcome of DKIM CNAMEs that never resolved. **That is its third false-alarm signal:** (1) a pending identity on the console dashboard, (2) a near-miss on requesting production access in the wrong region — the trap the row below records — and (3) this health event. Pouya's reason, kept because it generalises past SES: *"A monitoring surface that reports a failure you do not have trains you to discount the ones you do."* **No impact on mail.** Deletion removes the identity, not the records — the three live DKIM CNAMEs belong to the `ca-central-1` identity and are untouched by it |
|
||||
| ⚠️ **SES sandbox is per-region** | Recorded as a standing caution, not an open task — production access **was** granted in `ca-central-1` (Q19). The trap: the AWS console defaults to `us-east-1`, and granting access there leaves `ca-central-1` in the sandbox with the intake email still failing. Applies again to any future SES request `[verified 2026-08-26]` |
|
||||
| SES custom MAIL FROM | **Not configured** — SES reports "No MAIL FROM records found". This is why SES's SPF is unaligned and DMARC rests on DKIM. Optional future improvement, not required `[verified 2026-08-26]` |
|
||||
| SES monitoring — SNS topic | **`ses-alerts`**, `ca-central-1`. Email subscription to `info@smlcompany.ca` is **PENDING CONFIRMATION, not confirmed** `[verified 2026-08-26]`. **Until the subscription is confirmed the alarms below fire into nothing** — confirm the link in the subscription email |
|
||||
| SES monitoring — SNS topic | **`ses-alerts`**, `ca-central-1`. Email subscription to `info@smlcompany.ca` is **CONFIRMED** ``[verified 2026-09-01 — `aws sns list-subscriptions-by-topic` returns a real subscription ARN, `…:ses-alerts:44369cbe-1e56-494a-b0be-c20c0e09993e`, not the literal `PendingConfirmation`]``. ⚠️ **THIS ROW SAID `PENDING CONFIRMATION` UNTIL 2026-09-01 AND IS RECORDED RATHER THAN OVERWRITTEN.** It was written `[verified 2026-08-26]` and was true then; the link was clicked at some point after and the record did not move. So for some part of six days the alarms were working while four documents said they fired into nothing — **the same staleness as Q22 and the SES DKIM inversion, in the safe direction**, which is exactly why it survived: nothing broke, so nothing prompted a re-read. §12 **R9 closes on this**, and the lesson it leaves is that a row whose staleness is harmless is the row that stays stale longest |
|
||||
| SES monitoring — alarms | **`SES-BounceRate-High`** (bounce rate ≥ `0.03`) and **`SES-ComplaintRate-High`** (complaint rate ≥ `0.001`), both `ca-central-1`, `treat-missing-data: notBreaching` `[verified 2026-08-26]` |
|
||||
| SES bounce/complaint handling | **Email feedback forwarding** — SES's default, on unless disabled. **Deliberately not an SNS feedback topic:** at under 100 messages a month there is nothing to consume a programmatic feed. Revisit when code needs to act on a bounce `[verified 2026-08-26]` |
|
||||
| **DMARC alignment** | SES satisfies DMARC via **DKIM**, not SPF — without a custom MAIL FROM domain its envelope sender is at `amazonses.com` and is unaligned. The SPF record's real job is authenticating **Google Workspace** mail `[verified 2026-08-26]` |
|
||||
@@ -773,7 +773,8 @@ Nothing below can be invented. Each needs an answer from Pouya.
|
||||
|
||||
| # | Question | Blocks |
|
||||
|---|---|---|
|
||||
| **Q61** | 🛑 **THE MINIMUM-FONT-SIZE STICKY HEADER OBSCURES KEYBOARD FOCUS — SC 2.4.11 (AA). IT WAS RULED ACCEPTED ON 2026-09-01 AND THE RULING NEEDS RETAKING, BECAUSE BOTH FACTS IT RESTED ON ARE WRONG.** The ruling was *"you proved no CSS mechanism can see minimum-font-size; the only fix is JS and zero-JS is a founding decision"*, and the cost was recorded as a short skip-link landing traded against a WCAG 1.4.10 failure — *"one of the two is a WCAG failure while the other degrades the convenience of a skip link that still works"*. **(1) The premise is false.** Only the `rem`/`em` family is blind. 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` — measured, tabulated in `docs/02` §Reflow. A pure-CSS detector exists. **(2) The cost is a conformance failure of the same Level AA**, not a convenience loss: a Shift+Tab walk over 6 pages × 5 widths ≥ 1056 px finds **290 focus stops of 1,455 landing entirely behind the opaque sticky header** — 254 in the footer, **36 inside `#main`** — against **0** at the default text size, hit-tested with `elementFromPoint` rather than inferred from geometry. **(3) And the 2026-09-01 header fix created it:** a build of `fce89d4~1` measures **0** under the same setting. So the trade was 830 px of horizontal overflow (SC 1.4.10) for 290 obscured focus stops (SC 2.4.11), both AA, and the asymmetry that carried the decision does not exist. **WHAT IS NEEDED IS A RULING, AND THE RECOMMENDED CANDIDATE IS MEASURED AND WAITING.** `adversarial-reviewer` round 2 proposed `scroll-padding-top` — the property that already exists for this job — instead of un-sticking the header, and it is **strictly better than the `ch` gate on the one ground that deferred that gate.** Two declarations inside the existing `@media (min-width: 66rem)` block: `scroll-padding-top: calc(var(--header-h) + var(--space-4))` first as a fallback, then `scroll-padding-top: max(calc(var(--header-h) + var(--space-4)), calc(10lh - 83px))`. **Verified independently, values read from `getComputedStyle` rather than reasoned:** at the default size it computes **97 px — byte-identical to what ships** — and under the setting **287 px** against a header of at most 270.56 px, taking hidden focus stops to **0**. ⚠️ **`1lh` on `<html>` is IMMUNE TO THE WEBFONT FALLBACK**, which is what the `ch` candidate could not claim: `<html>` keeps the UA family (`--font-sans` is set on `body`, measured `Times`), so `1lh` is **18 px / 37 px identical with every `.woff2` request blocked**, and the offset still clears the header under Chrome's Georgia (277 px) and Verdana (307 px) presets. **Why it is NOT implemented:** `10lh - 83px` is still two fitted constants; the first declaration is load-bearing, because an engine without `lh` drops the whole `max()` as invalid and falls back to 16 px, which is *worse* than today; it needs the full 1,408-cell sweep plus the fallback-metrics and seven-item cases; the focus probe behind it is Chrome-only; and D19 caps the review at two rounds, so a header change made now would ship unreviewed. **This is Pouya's call, not a measurement gap.** Raised by `adversarial-reviewer` rounds 1 and 2, every figure reproduced independently | `docs/06`'s cutover checklist — the item was ticked as accepted this session and has been **restored to open**. It blocks no page and no other decision: the 22 pages build and the footer fix is unaffected. What it blocks is the claim that the site meets AA |
|
||||
| **Q62** | 🛑 **`/legal/privacy/` TELLS THE PUBLIC SOMETHING FALSE ABOUT WHO CAN READ THE INTAKE TABLE, AND IT IS A PRIVACY POLICY.** The page says: *"The table is reachable by the function that writes to it and by one administrative account, which is mine — nobody else has access to the table. There is no team, no assistant and no external administrator."* **The account has an IAM group `admins` carrying `AdministratorAccess` with TWO members**, and `iam simulate-principal-policy` returns **allowed** for `dynamodb:GetItem`, `dynamodb:Query` and `dynamodb:Scan` on the table for both of them — identical access, by the same route, the other user's own attachments being only `IAMUserChangePassword` `[verified 2026-09-01 — the five users, the group, and a five-row simulation, all commands in `docs/reference/intake-table-access-verification.md`]`. So **both halves of the sentence are wrong**: a second account has access, and it belongs to a second administrator of a shared account (§10). The three deploy users are `implicitDeny`; two CDK bootstrap roles carry `AdministratorAccess` but are assumable only by the same two administrators; the writing role holds **`PutItem` only and cannot read the table**, which is a stronger fact than the page currently claims and is the part of the sentence that is true. **THE QUESTION, AND IT IS ONE OF TWO THINGS.** (1) **Remove the access** — take that user out of `admins`, or deny DynamoDB on this table — after which the sentence becomes true as written. ⚠️ Note the likely collision: **Q23 records the Gitea instance as jointly administered and blocked on "its second administrator"**, so that access is probably not only for this account and removing it may cost something elsewhere. (2) **State the true number** — how many people hold administrative access, and that the function which writes cannot read. ⚠️ **DO NOT RESOLVE IT BY SOFTENING.** *"Access is limited to authorised administrators"* is the shape §4 exists to bar: defensible, uninformative, and it would replace a false specific with a true vacancy on the one page where a reader describing a live dispute is entitled to the specific. **Why this was invisible until now:** it is the only claim on the site whose subject lives entirely outside the repository, so R14 applies — *"unverifiable by construction"* — and there was no committed artefact to compare it against. There is now. Raised by `claims-auditor`, D20 cutover pass, finding 8 | **`/legal/privacy/` going public, and therefore the cutover.** `src/pages/legal/privacy.astro` carries the `TODO(pouya)` beside the sentence; `docs/06`'s checklist carries it as one of three items blocking the whole list. It blocks no other page and no other decision |
|
||||
| ~~**Q61**~~ | ✅ **CLOSED 2026-09-01 — RULED *fix now*, IMPLEMENTED AND MEASURED.** The minimum-font-size sticky header obscured keyboard focus: **290 entirely-hidden focus stops of 1,455** under `minimumFontSize=32`, a WCAG 2.2 **SC 2.4.11 (AA)** failure, created by the 2026-09-01 header fix. The fix is two declarations on `html` in the existing `@media (min-width: 66rem)` block — the plain `calc(var(--header-h) + var(--space-4))` first as a fallback, then `max(calc(var(--header-h) + var(--space-4)), calc(10lh - 83px))`. **`1lh`, not `rem`: the font-metric units read the *used* font size**, which is the mechanism the withdrawn ruling's premise denied existed. **Result, on the identical grid with the pre-fix tree rebuilt in a worktree as the control: 290 → 0, control still 290.** Default-settings rendering unchanged: **0 differences over 352 page-widths × 17 fields**, positive control detecting exactly 1 injected difference. `scroll-padding-top` 97 px at the default, 287 px under the setting against a 270.56 px header; `1lh` on `<html>` is 18 / 37 px **with every `.woff2` blocked**, identical, because `<html>` keeps the UA family. A wider grid than the ruling asked for — **777 cells over 37 settings — went from 63 failing to 12, with no cell worse.** The 12 are `minimumFontSize=16` and `=20`, they are **pre-existing and reduced**, and they were deliberately not fixed under Pouya's *"stop and report, do not widen"*: the setting floors sub-root type without moving the root, so `1lh` reads a quantity that did not change. `docs/06` carries it as its own item | *(closed)* |
|
||||
| **Q60** | ⚠️ **HAS A TEST RECORD BEEN OBSERVED TO DISAPPEAR FROM THE INTAKE TABLE?** **Half one closed 2026-08-31: TTL is `ENABLED` with `AttributeName: ttl`, verified by command — §7 holds that status and this row does not restate it.** The question is now the second half alone, and it was never the smaller half. `/legal/privacy/` does not merely publish a retention *period* — it asserts a **mechanism**: *"the record is deleted automatically by the database rather than by someone remembering to do it"*. **`ENABLED` proves the setting; only a record written with a near-future `ttl` and watched to vanish proves the behaviour.** Two things this may NOT be answered from: the handler code, which writes the attribute and nothing more (that side is verified and is not what is being asked); and the table setting, which is what was just confirmed. ⚠️ **AND THE FIRST HALF IS THE REASON TO TRUST THE SECOND LESS, NOT MORE:** `describe-time-to-live` returned **`DISABLED`** when Pouya first ran it on 2026-08-31, so the sentence above was published against a mechanism that was not running, and nothing in the repo, the build or AWS reported it. A setting that was off for as long as nobody looked is not evidence that the behaviour now works. `TODO(pouya)` sits on the retention section of `src/pages/legal/privacy.astro`; `docs/06`'s cutover checklist carries the test as blocking; §12 R19 keeps it surfacing. **Why this is a numbered question and not only a checklist line:** `CLAUDE.md` requires a `TODO(pouya)` plus a §9 row when a page needs a fact the repository does not have, and this page needs one — a cutover checklist fires once, at cutover, and §9 is what a person editing this page reads. Raised by `adversarial-reviewer` round 2, 2026-08-31 | **`/legal/privacy/` going public.** Nothing else — no other page states the mechanism, verified by sweeping `dist/` for the retention vocabulary and reading each hit in context |
|
||||
| ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling and `docs/07` carries it in full. ⚠️ **THIS ROW NAMED A CONSTANT THAT NO LONGER EXISTS** — `FEES.mediation.overtimeStartsAfterSessionHours` was deleted the same day as dead data: nothing read it, so reversing it would have changed nothing and failed nothing, which is Q22's shape at constant scope. **Where the ruling actually lives:** the trigger is rendered on `/fees/` from `halfDay.hours` / `fullDay.hours`, and `FEES.mediation.reservation` carries the half that publishes as prose. Found by `adversarial-reviewer` round 2 — §9 is what a later implementer reads to find where a ruling is recorded, so pointing it at a deleted identifier is the same defect one layer up. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped |
|
||||
| ~~Q58~~ | **RULED 2026-08-31 — `hours` IS THE SESSION, AND THE AMBIGUITY WAS IN `docs/07` RATHER THAN IN ANY COPY. Pouya owned it in terms:** *"the ambiguity is mine… My `docs/07` wording said "up to 3.5 h, including 2 h preparation", which is genuinely unclear: 3.5 was meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours in the room. Your arithmetic caught it: if prep sat inside, 3.5 and 7 wouldn't be exactly 2×, because preparation doesn't scale with session length. The intended reading is the market's, and my wording obscured it."* **THE CORRECTED CARD, in his words:** *"Half day — up to 3 hours of session. Fee includes up to 2 hours of preparation. $2,000. Full day — up to 6 hours of session. Fee includes up to 3 hours of preparation. $4,000."* His reason for 3 and 6: *"the market convention — Patey and Zuber both publish "all or part of 3 hours" and "all or part of 6 hours", and those were the comparables the rate was set against. Selling 1.5 hours of room time as a half day would be an outlier nobody would recognise."* ⚠️ **ONE PROVENANCE NOTE, and it is R14's rule rather than a doubt about the ruling:** `docs/07`'s committed extract records Patey and Zuber at **3 h** and **6 h** but **does not carry the phrase "all or part of"** — so `docs/07` cites the hours, not the phrase, and the phrase is not attributed to them anywhere in the repository. The hours corroborate the ruling on their own, and ADR Chambers' roster rate in the same table is the clearest corroboration of the *shape*: *"one half hour of preparation time per party **and** up to three hours of mediation"* — preparation counted separately from a three-hour session. **APPLIED:** `docs/07`'s two card rows and its §All parameters confirmed (which prescribed the flat *"including 2 hours"*, the form `/for-parties/` then shipped); `FEES.mediation.*.hours` 3.5 → 3 and 7 → 6 with the semantics in the constant's doc comment; `/for-parties/` now states the session length interpolated from the constant and the preparation allowance **as a cap**. **The preparation allowance is CAPPED and must be published as capped** — *"including **up to** 2 hours"*, never the flat form and never "preparation included". **`/fees/` is UNBLOCKED for build step 9.** **The question as raised is preserved below.** **DOES `hours` IN THE MEDIATION RATE CARD MEAN THE LENGTH OF THE DAY, OR THE BILLED ENVELOPE INCLUDING PREPARATION?** `docs/07-fees.md` reads *"Half day — **up to 3.5 h, including 2 h preparation**"* and *"Full day — up to 7 h, including 3 h preparation"*. Taken at face value, 3.5 is the whole billed envelope and the **time in the room is 1.5 h** for a half day and **4 h** for a full day. **Against that reading:** 3.5 and 7 are exactly 2×, which they would not be if preparation sat inside them (1.5 vs 4 is not 2×). So either the card's wording is wrong in the one document that is the authority on money, or `FEES.mediation.*.hours` in `src/data/site.ts` does not mean what a page would naturally publish it as. **This was one sentence from shipping.** A draft of `/for-parties/` answered *"What happens on the day?"* with *"A half day is about 3.5 hours"* — the envelope presented as the day, to the reader least able to check it. The sentence was removed rather than resolved by inference; the page now says only that you book a half day or a full day. **What is needed:** one line from Pouya saying which the 3.5 and 7 are. Then `docs/07`'s two rows or `site.ts`'s field gains the correction, and the semantics go in the constant's doc comment (a warning is there now). **`/fees/` at build step 9 publishes this table and cannot be built without the answer.** Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing.** No page stated a duration while the question was open — the one draft sentence that did was removed rather than reconciled, which is why the ruling had nothing to correct in public copy |
|
||||
@@ -921,7 +922,7 @@ never being raised again.
|
||||
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
|
||||
| R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive. ⚠️ **ONE ITEM IS ALREADY WAITING AND IT IS ARITHMETIC RATHER THAN JUDGEMENT — added 2026-08-31:** the half-day-plus-overtime route is cheaper than the full-day rate at **every** session length, by $2,000 at three hours narrowing to $500 from six on, because the half-to-full step is $2,000 and three hours of overtime is $1,500. Written out in `docs/07` §Recorded dissent with the table, which is the section built for this review to test against. The **trigger** for the overtime hour is a separate open question — §9 Q59 | 2026-08-26 | D14 is priced for where the practice is going, not where it is. And the anomaly above was assigned to this reminder twice in one change set and written into neither place the reminder lives, which is the failure §12 exists to prevent |
|
||||
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
|
||||
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
|
||||
| ~~R9~~ | ✅ **CLOSED 2026-09-01. The `ses-alerts` email subscription is CONFIRMED** — `aws sns list-subscriptions-by-topic` returns a real subscription ARN rather than the literal `PendingConfirmation` `[verified 2026-09-01]`, so the two bounce/complaint alarms reach `info@smlcompany.ca`. ⚠️ **It had been confirmed for some unknown part of six days while §7, `docs/05`, `docs/06` and this row all said the alarms fired into nothing.** That is the Q22 staleness in the **safe** direction, and the direction is why it lasted: nothing was broken, so nothing prompted anyone to re-read it. The generalisable half, and it is worth more than the row: **a record whose staleness is harmless is the record that stays stale longest**, because every other kind announces itself by breaking something. Re-read the harmless ones on a schedule or they are never re-read at all. *Previous text follows.* **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
|
||||
| R10 | ⚠️ **A THIRD SURFACE, 2026-08-30: `/process/` §Confidentiality renders `MEMBERSHIP_ORGS[0]` ("I am a member of the ADR Institute of Canada").** It is rendered from the constant rather than typed, so the sweep this reminder prescribes reaches it — that was `adversarial-reviewer`'s finding and the fix, in that order. **DISCHARGED AS WRITTEN 2026-08-28 — AND RE-ARMED WITH AN EVENT TRIGGER INSTEAD OF A DATE. STILL LIVE.** Pouya re-confirmed all four memberships as current (Q44), which discharges the prohibition this row carried, and `/about/` now publishes the Memberships group. **The row does not close, because he declined renewal-date tracking**, and that was his instruction for what to do about it: *"Without renewal months it cannot fire on a date, so make it fire on an event: re-confirm memberships before any cutover or major republish, and re-stamp §4 when confirmed."* **THE TRIGGER: re-confirm before any cutover, and before any major republish. Then re-stamp §4 the same day.** **His reason, kept verbatim because it is the general principle and not a membership detail:** *"§4 already carries OCNI as lapsed and unpublishable, and that was found roughly a year late. A stamp with no trigger behind it goes stale silently, which is exactly how OCNI got onto a list of things to feature."* **Two things the discharge did NOT license.** (1) **No currency warranty on the page** — list the memberships, promise nothing about their future state; the struck sentence stays struck and nothing replaces it. (2) ~~`memberOf` stays out of the JSON-LD~~ — **SUPERSEDED. Q53, ruled 2026-08-28: EMIT IT.** `/about/`'s Person node now carries the four memberships as `Organization` nodes. Pouya took `adversarial-reviewer`'s argument: they are already crawlable in `/about/`'s HTML, so withholding the triple reduced no exposure and only made the graph less complete than the page. **The consequence for THIS reminder is that it now covers two surfaces** — re-confirming before a cutover means `src/data/schema.ts` as well as the visible list, and they must not be allowed to diverge. **Renewal periods, stated once and not widened again:** the OBA sections and the CTF renew yearly; §4 records **nothing** about ADRIC's or ADRIO's period, and the widened form ("all four renew yearly") reached four files before it was swept. *Previous text described the prohibition and the withheld group; it held for one session and did its job.* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this. The group is on a public page now, which raises the cost of a lapse rather than lowering it — *(This rationale ended by pointing at **Q48** as a possible widening of the row. Q48 closed 2026-08-28 as not site-relevant — ADRIO retention governs whether Pouya keeps a designation, not what the site may say about holding one — so the clause is struck. §12 is read aloud every session; a live reminder pointing at a struck row produces a false surface every time.)*, not just a list |
|
||||
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7. ✅ **THE STEP-7 RE-ADD TRIGGER IS DISCHARGED, 2026-08-31 — and NOT as written.** It said *"at step 7, put `@lhci/cli` back"*. `@lhci/cli` is still 0.15.1, still `latest`, and still carries 10 findings (7 high) `[verified 2026-08-31]`, so the literal instruction would have re-added a tool with seven high-severity advisories. What shipped is **`lighthouse@13.4.1` + `chrome-launcher@1.2.1`, 0 vulnerabilities**, as `npm run lighthouse`. **The reason is that §7's own advisory attribution was wrong** — it blamed `lighthouse → puppeteer-core → extract-zip`; the carriers were `@lhci/cli`'s own `tmp` and `@puppeteer/browsers`' `extract-zip`, and neither exists in Lighthouse's tree. **The last clause of this trigger is the one that earned its place:** *"if the advisories are still unfixed, that is a decision to take deliberately, not a reason to leave the gap unstated."* They are still unfixed; the decision was taken; §7 records what it costs (no `lhci` assertion config, no server, no run history) and that the gate is local rather than CI, because standalone Lighthouse needs an installed browser and the runner has none. **All six UNAVAILABLE notices are deleted** — `docs/04` (budget table, Performance callout, post-launch checklist), `CLAUDE.md` (performance budget, definition of done), `/build` Phase 5, `docs/06` (PR checks, cutover checklist), `.claude/agents/adversarial-reviewer.md` §4. The **general** half of R11 — re-check every pin at every phase boundary — is untouched and still fires. ✅ **SWEPT AGAIN 2026-09-01, all 19 pins against `npm view`, and TWO MAJORS ARE DEFERRED BY RULING rather than left unstated:** `@astrojs/mdx` **^7.0.8 → 8.0.0** and `typescript` **^6.0.3 → 7.0.2**. Pouya's reasoning — *"npm audit is clean and majors mid-walkthrough add churn without user value"* — with `npm audit` at **0 vulnerabilities** `[verified 2026-09-01]`, which makes it a churn decision and not a security one, **and one that flips the moment that stops being true.** Both now sit on a new **Cutover prep** group at the head of `docs/06`'s cutover checklist, dated, because deferring a thing and forgetting it look identical three weeks later. Four more are a minor or patch behind and already satisfied by their carets, so they need no edit — `astro` 7.2.9 → 7.2.10, `@astrojs/sitemap` 3.7.3 → 3.7.4, `globals` 17.11.0 → 17.12.0, `typescript-eslint` 8.68.0 → 8.69.0; the other 13 are current. **This row is the deferral's reminder, not its replacement** — R11 fires at the next phase boundary regardless | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
|
||||
| R12 | **`compressHTML: true` is a deliberate deviation from the Astro 7 default (`'jsx'`).** Measured 2026-08-26: in an `.astro` template an inline pair split across two lines renders as `<em>a</em><strong>b</strong>` under the default — the space is silently deleted. MDX prose is unaffected | 2026-08-26 | It is a deviation, and undocumented deviations become folklore. Revisit **with a measurement**, not a preference — and re-measure after any Astro major, since the behaviour could change again |
|
||||
@@ -940,6 +941,512 @@ never being raised again.
|
||||
|
||||
# Change Log
|
||||
|
||||
## 2026-09-01 (am) — The production run: Q61 fixed and closed, the intake backend and distribution given an executable runbook — and reading the running system found four launch blockers the specs could not see, one of them a false statement on the privacy policy
|
||||
|
||||
**Pouya's direction:** *"get to production as soon as possible; nothing new gets
|
||||
opened unless it blocks launch."* Five items: fix Q61 with a full sweep; produce
|
||||
the exact command sequence for the intake backend; fold Q60's TTL test into it;
|
||||
run the D20 cutover claims audit; plan the first deploy and **do not deploy**.
|
||||
Everything below either discharges one of those or is a launch blocker found
|
||||
while doing so. Commit `ca1c252` (the reviewed footer step) opened the session.
|
||||
|
||||
### 1. Q61 — ruled *fix now*, fixed, closed
|
||||
|
||||
Two declarations on `html` inside the existing `@media (min-width: 66rem)` block:
|
||||
|
||||
```css
|
||||
scroll-padding-top: calc(var(--header-h) + var(--space-4)); /* fallback: no `lh` */
|
||||
scroll-padding-top: max(calc(var(--header-h) + var(--space-4)), calc(10lh - 83px));
|
||||
```
|
||||
|
||||
**The result, and the control is the part that makes it a result.** The Shift+Tab
|
||||
walk over 6 pages x 5 widths >= 1056 px goes from **290 entirely-hidden focus
|
||||
stops of 1,455 to 0** under `minimumFontSize=32`. The pre-fix tree was rebuilt in
|
||||
a git worktree and put through the identical probe: it **still reports 290**
|
||||
(footer 254, `#main` 36), geometry candidates 333 against the fixed build's 43.
|
||||
So the probe is proven able to see the failure it now reports gone — which
|
||||
`CLAUDE.md`'s rule about uniform passes demands, because both modes reading 0 with
|
||||
identical step counts is exactly the shape that ends a check instead of starting
|
||||
one. A second instrument check confirmed `minimumFontSize` was actually being
|
||||
applied (`bodyFont` 32 px) rather than silently ignored.
|
||||
|
||||
**Default settings byte-identical, as ruled: 0 differences over 352 page-widths x
|
||||
17 fields = 5,984 comparisons**, including the full-page geometry fingerprint,
|
||||
with a positive control detecting exactly 1 injected difference.
|
||||
|
||||
⚠️ **The first attempt at that comparison reported 4,224 differences**, because it
|
||||
keyed rows on `page`/`width` where the harness names them `url`/`w` — every row
|
||||
collapsed onto one map entry and was compared against an arbitrary single row. It
|
||||
was caught because the result was *uniformly bad*, which is the survivable half of
|
||||
that bug; the rewritten version **asserts the key fields exist and are unique**
|
||||
before comparing, and that assertion is the fix rather than the corrected field
|
||||
names.
|
||||
|
||||
**The sweep was wider than the ruling asked for, and it found a residual.** 777
|
||||
cells over 37 settings — roots 9–32, all five `defaultFontSize` presets, six
|
||||
`minimumFontSize` values, fonts blocked, and fonts blocked under the setting —
|
||||
measuring the **actual skip-link landing** rather than a proxy:
|
||||
|
||||
| setting | before | after |
|
||||
|---|---|---|
|
||||
| root-style 9..32 (504 cells) | 0 | 0 |
|
||||
| `defaultFontSize` 9/12/16/20/24 (105) | 0 | 0 |
|
||||
| `minimumFontSize=9`, `=12` | 0 | 0 |
|
||||
| `minimumFontSize=16` | 6 (52 px) | **6 (52 px)** |
|
||||
| `minimumFontSize=20` | 12 (64 px) | **6 (14 px)** |
|
||||
| `minimumFontSize=24` | 15 (75 px) | **0** |
|
||||
| `minimumFontSize=32` | 15 (174 px) | **0** |
|
||||
| fonts blocked | 0 | 0 |
|
||||
| fonts blocked + `minFont=32` | 15 (174 px) | **0** |
|
||||
| **total** | **63 of 777** | **12 of 777** |
|
||||
|
||||
**Nothing is worse in any cell.** The 12 remaining are pre-existing, and **they
|
||||
were not fixed** — Pouya's instruction was *"if the sweep surfaces anything beyond
|
||||
this one declaration's blast radius, stop and report — do not widen."* The reason
|
||||
the `lh` ramp cannot see them is the interesting part: `minimumFontSize=16` leaves
|
||||
the root at 16, so `1lh` on `<html>` is 18 px exactly as at the default, while the
|
||||
header grows to 149.38 px because the **sub-16 px** type (11 px tagline, 14 px
|
||||
eyebrow and nav) is floored up. The ramp reads a quantity the setting did not
|
||||
move. `docs/06` carries it as its own item.
|
||||
|
||||
⚠️ **And one column of that sweep was measuring nothing.** A summary column
|
||||
reported `min(scroll-padding-top − headerH)` of **−71.89** for root-style while
|
||||
the same rows reported **0 px covered** — two metrics of one quantity
|
||||
disagreeing. The cause: `insetBlockStart` is **−900 px** in those rows. The header
|
||||
is `position: sticky` and *not pinned*, because that is how the saturating
|
||||
`clamp()` gate un-sticks it, so comparing the offset to the header height is
|
||||
meaningless there. The `covered` metric — real geometry after a real hash
|
||||
navigation — is the valid one. Recorded because the diagnostic was written before
|
||||
the conclusion, which is the only reason the contradiction resolved instead of
|
||||
being averaged away.
|
||||
|
||||
### 2. Four launch blockers, none of which a spec could have told us
|
||||
|
||||
Every one came from reading the running system. Two would have lost every
|
||||
submission; one breaks 22 of 23 pages; one is a false public statement.
|
||||
|
||||
**(a) The handler wrote the wrong DynamoDB key — every submission lost, silently.**
|
||||
`handler.mjs` built its item as `pk: INTAKE#<uuid>` / `sk: <timestamp>`, from
|
||||
`docs/05`'s item table. The live table's key schema is **`submissionId` (S), no
|
||||
sort key** `[verified — describe-table]`. A `PutItem` missing the key attribute
|
||||
fails with `ValidationException`; the handler catches it and returns
|
||||
`/contact/could-not-send/`. **So the form would have looked broken to every
|
||||
inquirer while the record went nowhere**, from the moment `/api/*` was wired. A
|
||||
key schema cannot be altered after creation, so the handler moved to the table.
|
||||
The alternative — a new table matching the spec — was declined: it would re-open
|
||||
the §7-verified TTL and the verified PITR on a fresh resource at cutover, to buy
|
||||
a sort key nothing queries.
|
||||
|
||||
**(b) The Lambda's invoke permission is scoped to the old route's path.** One
|
||||
statement, `apigateway.amazonaws.com`, `SourceArn`
|
||||
`…/4tl0m5igkj/*/*/submissions`. Add `POST /api/intake` without adding a
|
||||
permission and API Gateway is refused: **500 to the caller, and nothing in the
|
||||
Lambda log at all**, because the function is never entered. The hardest possible
|
||||
shape to diagnose, since CloudWatch is where anyone looks first.
|
||||
|
||||
**(c) 22 of the 23 pages do not serve, and there is no 404 page.** `astro.config`
|
||||
sets `trailingSlash: 'always'` with `build.format: 'directory'`, and its own
|
||||
comment says the URLs are *"matched by the CloudFront trailing-slash function"* —
|
||||
**which does not exist.** The distribution has no function associations, no custom
|
||||
error responses and no cache behaviours. Measured on the live distribution rather
|
||||
than inferred: `/` **200**, `/about/` and `/definitely-not-a-page/` both **403,
|
||||
`application/xml`, 111 bytes** — S3's `AccessDenied` served raw to the reader.
|
||||
Separately, `docs/04` has asked for a 404 page since before build step 1 and
|
||||
**there was no `src/pages/404.astro` at all** — 22 pages, and the checklist item
|
||||
"404 returns a 404 status" was unsatisfiable in both halves.
|
||||
|
||||
**(d) `/legal/privacy/` tells the public something false about who can read the
|
||||
intake table.** Opened as **Q62**, found by `claims-auditor`. The page says
|
||||
*"nobody else has access to the table… no external administrator"*; the account's
|
||||
`admins` group carries `AdministratorAccess` and has **two members**, and
|
||||
`simulate-principal-policy` returns **allowed** for
|
||||
`GetItem`/`Query`/`Scan` for both. Evidence committed as
|
||||
`docs/reference/intake-table-access-verification.md` with every command, because
|
||||
this was **the only claim on the site whose subject lived entirely outside the
|
||||
repository** — R14's "unverifiable by construction", on a privacy policy. Left
|
||||
in place with a `TODO(pouya)` rather than rewritten: it is one of two decisions
|
||||
(remove the access, or state the true number) and both are his, and the
|
||||
softened middle — *"authorised administrators"* — is the shape §4 exists to bar.
|
||||
|
||||
### 3. What shipped for items 2, 3 and 5
|
||||
|
||||
- **`docs/09-cutover-runbook.md`** — the copy-paste sequence, ten parts, each
|
||||
command followed by its verification and the output to expect, with a rollback
|
||||
per part and the order dependency spelled out (**distribution first, site sync
|
||||
last**; reversing them puts 22 of 23 pages behind a 403 for the length of a
|
||||
CloudFront deployment). Part 10 is Q60's TTL test.
|
||||
- **`infra/cloudfront/router.js`** + **`router.test.mjs`** — the viewer-request
|
||||
function, 15 local cases passing, proven able to fail. One of those cases exists
|
||||
purely as documentation: `/api/intake` **must not** be associated with it,
|
||||
because a 301 turns a POST into a GET and the submission body is gone.
|
||||
- **`infra/cloudfront/configure.mjs`** — the three distribution changes as one
|
||||
reviewable, **idempotent, dry-run-by-default** transaction that sends the
|
||||
`IfMatch` ETag it read. It resolves both managed policy ids **by name from the
|
||||
account**, never as literals: `CLAUDE.md`'s registry rule applies to an AWS
|
||||
identifier as much as to an npm pin, and a wrong cache-policy id would ship a
|
||||
cached POST endpoint. Dry-run against the live distribution reports exactly the
|
||||
4 expected changes and writes nothing.
|
||||
- **`scripts/intake-env.mjs`** — emits the six Lambda variables, reading
|
||||
`RESPONSE_TIME` and `NO_RETAINER_NOTICE` **from `src/data/site.ts`** so that two
|
||||
published commitments are never retyped into a shell command. It asserts the
|
||||
notice still contains its fourth clause and its en dash, and the response time
|
||||
still says two business days. **Both guards probed**: the fourth-clause guard
|
||||
exits **1** with the right message when the clause is removed, and the file was
|
||||
restored and the restoration verified with `git diff --exit-code`.
|
||||
- **Item 5's dry run, executed:** three `--dryrun` passes — **51 / 56 / 52
|
||||
lines**, the third containing the two expected `delete:` lines for the old
|
||||
site's logos. Largest file in `dist` is **171,876 bytes**, comfortably under the
|
||||
8 MB multipart threshold the deploy user's missing `AbortMultipartUpload`
|
||||
depends on. **Nothing was deployed.**
|
||||
- **`docs/06`** records the Gitea-runner deferral as Pouya ruled it, with the
|
||||
reason (co-admin dependency, non-blocking) and — stated rather than waved past
|
||||
— what it costs: a long-lived key in a person's environment and no merge gate.
|
||||
|
||||
⚠️ **The dry-run counts are a superset of the real run and the runbook says so.**
|
||||
34 of pass 2's 56 lines are files under `_astro/` that pass 1 uploads first; each
|
||||
`--dryrun` compares against the bucket as it is now, so it cannot see the
|
||||
preceding pass's uploads. In the real sequential run `sync` skips them, and that
|
||||
skipping is what preserves pass 1's `immutable` header. Without the note, the
|
||||
smaller real counts read as a failed upload.
|
||||
|
||||
### 4. Two of three instruments could not see the new page
|
||||
|
||||
`check:claims` takes any `.html`, so the 404 page's copy was inside the claim
|
||||
register from its first build. **`og:proof` and `lighthouse` both collected
|
||||
`index.html` only**, and `404.astro` is emitted as `dist/404.html`, outside
|
||||
`build.format: 'directory'`.
|
||||
|
||||
**`og:proof` reported it backwards** — as the card being orphaned, *"generated,
|
||||
but no built page references it"* — rather than as the page being unchecked. That
|
||||
is the more dangerous shape: it points at the wrong artefact. `lighthouse` would
|
||||
have asserted the performance budget over 22 of 23 pages while its own header
|
||||
claimed it enumerated the site, and said nothing at all. Both fixed; the two use
|
||||
different path forms for the same file (`/404/` is an `OG_CARDS` key, `/404.html`
|
||||
is a URL to serve) and each says why beside the code.
|
||||
|
||||
**One instrument of three saw the page, which is the argument for having three.**
|
||||
|
||||
### 5. The D20 cutover claims audit — FAIL, eight findings
|
||||
|
||||
The one full claims pass this project gets, over all 23 pages in visitor order.
|
||||
**It 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 class a per-step run on one page
|
||||
is least able to see and that `check:claims` cannot grep for at all. Five
|
||||
corrected here, three open.
|
||||
|
||||
| # | the over-reach | corrected to |
|
||||
|---|---|---|
|
||||
| 3 | `/practice/energy/`: *"rewriting how large loads **and new generation** get connected"* — the extract's only quotation on generation says the normal System Impact Assessment applies to all generation *"equally"* | *"rewriting how large loads get connected"* |
|
||||
| 4 | `/practice/insurance/`: the case-conference adjudicator *"is then **disqualified** from hearing the matter"* — LAT Rule 14.3 says *"except with the consent of the parties"* | *"does not then sit on the hearing panel unless the parties consent"* |
|
||||
| 5 | `/practice/insurance/`: *"The Tribunal's **own materials point** parties toward mediation"* — a plural class and a direction, from one permissive sentence pairing mediation with negotiation | *"The Tribunal's accident-benefits page names mediation as something to consider before applying"* |
|
||||
| 2 | `/practice/technology/`: *"**no federal or Ontario statute** requires data to be stored in Canada"* — a universal over the **four** instruments the extract checked; and the section note then disclaimed it as the Commissioner's words, which is true of the PIPEDA half only | named instruments, and a note that says which half is quotation and which is a reading |
|
||||
| 6 | `/med-arb/`: the ADRIC Med-Arb Rules *"presented to the membership at ADRIC's 2019 annual conference"* — the source says **a discussion draft** was | *"presented to the membership as a discussion draft"* |
|
||||
|
||||
**Still open, and each has an item on `docs/06`'s checklist:** finding 1 (the
|
||||
`/med-arb/` gloss — the page's own voice says med-arb *"is the two of them run
|
||||
under one agreement"* one section below publishing ADRIC's *"not merely the
|
||||
merging of separate mediation and arbitration processes"*: **the gloss asserts
|
||||
what its cited authority denies, on the same page**, and it needs a ruling on
|
||||
which of the two goes); finding 7 (**Q60**); finding 8 (**Q62**).
|
||||
|
||||
Note on finding 1: the 2026-08-30 fix took the competence proposition out of the
|
||||
**heading** and left it in the **body**. Resolving it by adding a competence claim
|
||||
is barred — ADRIC answers its own competence sentence with the Chartered Med-Arb
|
||||
designation and the Med-Arb Foundational Course, neither held, and C.Med-Arb is
|
||||
struck from this site entirely.
|
||||
|
||||
⚠️ **And the auditor found a stale instruction inside its own brief — the fifth.**
|
||||
`.claude/agents/claims-auditor.md` still said *"**Q.Arb.** Commenced August 2026.
|
||||
Flag anything reading as held, imminent, or nearly complete. The Arbitration page
|
||||
must state plainly what is available now versus what follows designation."* §4
|
||||
recorded Q.Arb as **HELD** on 2026-08-29 and struck every stage form. **Applied
|
||||
literally it would have flagged correct copy and demanded the struck form**, and
|
||||
an imperative about what a page "must state plainly" is the kind an agent obeys.
|
||||
It read §4 instead, which is luck rather than process — the same conclusion
|
||||
`CLAUDE.md` already draws about that file. Rewritten to hold **the question and
|
||||
not the answer**, generalising the rule the memberships paragraph had already
|
||||
learned: any state that can change is read from §4 at audit time. Five instances
|
||||
of one shape in one file is no longer a coincidence.
|
||||
|
||||
### 6. Also corrected, and neither is a claim
|
||||
|
||||
**The DLQ is struck from `docs/05` and `docs/06`.** Both asked for a dead-letter
|
||||
queue on `adr-intake-handler` with an alarm on depth >= 1. **`DeadLetterConfig`
|
||||
applies only to asynchronous invocations**, and API Gateway invokes
|
||||
synchronously — so the queue would have sat at depth 0 for ever and the alarm
|
||||
would have been a permanently green light. That is this project's most expensive
|
||||
recurring shape, after Q22 and the Lighthouse row. Replaced by two alarms that can
|
||||
actually fire: **Lambda `Errors`** (a synchronous failure, which a DLQ cannot see)
|
||||
and **API Gateway `5xx`** (the case where the function is never entered — which is
|
||||
blocker (b) above).
|
||||
|
||||
**`docs/05`'s per-IP rate limit is struck.** It asked for "5 requests / 5 minutes
|
||||
per source IP **at API Gateway**". API Gateway throttling is **aggregate** — per
|
||||
route and per stage, across all callers. Per-IP needs AWS WAF, which is paid, so
|
||||
it is a decision on `docs/06`'s list and explicitly **not** a launch blocker. An
|
||||
aggregate route throttle ships instead and must never be described as per-IP.
|
||||
|
||||
**`docs/07` line 366** called Q59 *"open"* while line 144 of the same file records
|
||||
it ruled and closed. Non-blocking; corrected because §Recorded dissent is what
|
||||
R5's 12-month review reads.
|
||||
|
||||
### 7. Verified, and the stamps that moved
|
||||
|
||||
Read-only against AWS, `[verified 2026-09-01]`, exit status read on every call and
|
||||
stderr suppressed nowhere:
|
||||
|
||||
- **R9 CLOSES.** The `ses-alerts` email subscription is **confirmed** — a real
|
||||
subscription ARN, not `PendingConfirmation`. §7, `docs/05`, `docs/06` and R9
|
||||
itself had all said the alarms fired into nothing. It had been confirmed for
|
||||
some part of six days. **That is the Q22 staleness in the safe direction, and
|
||||
the direction is why it lasted**: nothing broke, so nothing prompted a re-read.
|
||||
The general form is worth more than the row — **a record whose staleness is
|
||||
harmless is the record that stays stale longest**, because every other kind
|
||||
announces itself by breaking something.
|
||||
- **PITR is `ENABLED`**, 35-day window. **No customer-managed KMS key** — the
|
||||
table uses the AWS-owned key, which `/legal/privacy/` does not claim, so nothing
|
||||
published depends on it.
|
||||
- **4 items predate this repo** and the hand-built handler writes **no `ttl`**, so
|
||||
they never expire while the policy says 24 months. Pouya's call, because whether
|
||||
they are the guide's smoke test or real inquiries is not determinable from here.
|
||||
- SES domain verified, DKIM `SUCCESS` and signing enabled, no custom MAIL FROM.
|
||||
S3 versioning `Enabled`, all four public-access blocks `true`, direct S3 object
|
||||
GET **403**. Managed policy ids for `CachingDisabled` and
|
||||
`AllViewerExceptHostHeader` confirmed by name against the account rather than
|
||||
recalled — `CachingOptimized` matching the live distribution was the check that
|
||||
the other two could be trusted.
|
||||
|
||||
**Gates, all run on the shipping bytes with exit status read:** `check` 0,
|
||||
`build` 0, `check:claims` 0, `og:proof` 0 (**23 pages**, 22 cards, 21 headlines
|
||||
matched), `check:intake` 0, `lint` 0, `router.test.mjs` 0 (15/15).
|
||||
**`npm run lighthouse` exit 0 — 23 pages, worst of 23 is 99 / 100 / 100 / 100**,
|
||||
CLS 0.000 everywhere. ⚠️ **One reported number sits at its budget: LCP on `/` is
|
||||
2.03 s against `docs/04`'s < 2.0 s.** The script reports LCP without asserting it,
|
||||
deliberately, because simulated throttling on a loopback server is not a Slow 4G
|
||||
field measurement — so it is not a failing gate and must not be called a passing
|
||||
one either.
|
||||
|
||||
### Adversarial review — two rounds, 24 findings, and round 2's first one was a defect in round 1's own fix
|
||||
|
||||
`adversarial-reviewer` only. **`claims-auditor` ran once, at cutover, per D20** —
|
||||
its result is §5 above.
|
||||
|
||||
**Round 1: 13 findings and 2 considers. Two blocking.**
|
||||
|
||||
1. 🛑 **A PROTOCOL-RELATIVE OPEN REDIRECT IN `router.js`, ON EVERY VIEWER
|
||||
REQUEST.** The 301 `Location` was built from `request.uri` unnormalised, so
|
||||
`//evil.example.com/x` produced `Location: //evil.example.com/x/` — a
|
||||
network-path reference (RFC 3986 §4.2) where the browser keeps the scheme and
|
||||
**replaces the authority**, on a link that reads as `adr.smlcompany.ca…` in a
|
||||
mail client. The reviewer settled the one open question by measurement:
|
||||
**CloudFront resolves dot-segments and does NOT collapse duplicate slashes**
|
||||
(`GET //index.html` → 403 with S3 receiving the key `/index.html`, while
|
||||
`/./index.html` and `/a/../index.html` both → 200). A backslash does the same
|
||||
through the URL Standard, which is why a `startsWith('//')` guard is not
|
||||
enough. **The 15-case test had no `//` case.**
|
||||
2. 🛑 **THE FALSE PRIVACY SENTENCE SHIPPED IN `dist/` WITH NOTHING STOPPING IT.**
|
||||
My resolution had been a `TODO(pouya)` plus three prose checklist items, on the
|
||||
reasoning that the existing machinery gates it. **It does not: Astro strips a
|
||||
JSX comment, so `grep -rn "TODO(pouya)" dist/` returns nothing**, and `check`,
|
||||
`build` and `check:claims` all exited 0 over the sentence. `npm run deploy`
|
||||
would have published it. The gate was human memory, which is the thing
|
||||
`CLAUDE.md` names: *"a build that fails on an unanswered question is a correct
|
||||
build."*
|
||||
|
||||
Also: the `?list-type=2` verification I wrote **could not fail** (the default root
|
||||
object means no viewer path is ever a bucket-root request, so the check was
|
||||
evidence of nothing — Q22's shape, in a runbook written to avoid it); the 404
|
||||
mapping is distribution-wide so it also covers `/api/*`, making three diagnostics
|
||||
wrong about what a 404 means; `sourceIp` records the CloudFront edge rather than
|
||||
the inquirer while `/legal/privacy/` says *"your IP address"*; three struck
|
||||
controls survived as live instructions in `handler.mjs` and in the `docs/06`
|
||||
bullet this change set had just edited; `docs/06` still described an intake probe
|
||||
neither script runs; `/practice/technology/`'s corrected lead was **still** a
|
||||
universal, over MFIPPA, which the extract never checked; `lat-case-conference.md`'s
|
||||
own commentary still asserted the absolute the page had just been corrected away
|
||||
from; `/404/`'s route list broke the 44 × 44 floor on one axis **while the comment
|
||||
beside it claimed compliance**; `404.astro`'s header said no credential claim
|
||||
appears on the page, which the JSON-LD contradicts; `docs/01` still said 22 pages
|
||||
and carried a reassurance this change set disproved; and **`_archive/` was not
|
||||
gitignored** while Part 8.1 writes 3.3 MB of the old fabricated-credentials site
|
||||
into the working tree eleven lines before a `git tag`.
|
||||
|
||||
**One finding declined.** That `/med-arb/`'s *"developed by a task force"* is
|
||||
unsourced. It is sourced: `docs/reference/adric-extract/rules-codes__adric-med-arb-rules.txt:166`
|
||||
reads *"a **Task Force** was formed with a dedicated working group of med-arb
|
||||
professionals. The Task Force completed an initial draft of the Rules."* The
|
||||
round-1 sweep was `git grep -n "task force"` — **case-sensitive**, against a
|
||||
source that capitalises it, so it returned one hit (the page) instead of two.
|
||||
`CLAUDE.md`'s rule that a grep is not a finding until you read what it matched,
|
||||
from the other side: **a grep that does not match is not a finding either.** Round
|
||||
2 read the file and agreed. The useful half was real, though — the *curated*
|
||||
`adric-rules.md` did not carry the sentence, so a reader consulting the curated
|
||||
extract alone would have drawn the same conclusion. It now quotes it.
|
||||
|
||||
**Round 2: 11 findings. One blocking, and it was mine.**
|
||||
|
||||
🛑 **`viewerIp()` — ROUND 1'S FIX WAS WORSE THAN WHAT IT REPAIRED.** It replaced
|
||||
`requestContext.http.sourceIp`, which a client cannot forge, with the **leftmost**
|
||||
entry of `x-forwarded-for`, which is whatever the client sent: **CloudFront
|
||||
appends** the viewer address to a client-supplied XFF rather than replacing it, so
|
||||
a submission with `X-Forwarded-For: 8.8.8.8` stored `8.8.8.8`. A field held for
|
||||
abuse investigation became one that **can be made to name an uninvolved third
|
||||
party**, on a page that promises it holds *"your IP address"*.
|
||||
|
||||
And the 14-line comment I wrote beside it contained its own refutation —
|
||||
*"CloudFront appends, so the leftmost is the viewer"* — appending puts the viewer
|
||||
**last**. **The comment was not a description of the code; it was an argument
|
||||
against it.** Reverted to the unforgeable value, with the limitation stated: it
|
||||
records the CDN and therefore cannot serve abuse investigation. The usable value
|
||||
is `CloudFront-Viewer-Address`, which needs a **custom** origin request policy
|
||||
(the managed `AllViewerAndCloudFrontHeaders` forwards `Host` and would 403 every
|
||||
request at API Gateway, which is why `AllViewerExceptHostHeader` was chosen) —
|
||||
deliberately not taken inside a capped review loop. **`docs/09` Part 7.2 now
|
||||
measures what the field actually contains, with a decision table for each of the
|
||||
three outcomes**, because the proxy chain has now been reasoned about wrongly
|
||||
twice and once is the limit.
|
||||
|
||||
**Round 2's other findings, and four more were defects in round 1's fixes:**
|
||||
|
||||
- **The open-redirect fix normalised as a *rewrite*, which closed the redirect and
|
||||
opened duplicate content.** `//about/`, `///about/` and `/\about/` each served
|
||||
the About page with a **200** — an unbounded family of duplicate URLs, against
|
||||
the same `docs/04` concern the 301 branch exists for. And the file branch
|
||||
returned `request` untouched, so `//robots.txt` reached S3 unnormalised and
|
||||
404'd. Now: normalise, and **redirect if anything changed**, on both branches.
|
||||
- **`safe()` stripped characters browsers legitimately send and missed the one
|
||||
that matters.** `| ^ ` { }` are not in WHATWG's query percent-encode set, so
|
||||
they go on the wire raw — and `|` is routine in ad-platform tracking values, so
|
||||
the 301 that exists *"so a campaign link survives"* silently corrupted exactly
|
||||
those, into a plausible-looking value rather than an error. Meanwhile `#` — the
|
||||
only character that changes the **structure** of the emitted header, dropping
|
||||
`&b=y` into a fragment — was not stripped. Set narrowed to C0 controls, DEL,
|
||||
space, `"`, `#`, `<`, `>`.
|
||||
- **The runtime test exercised none of the new code.** `docs/09` Part 2's
|
||||
`test-function` loop passed `"querystring":{}` on all six cases and tried
|
||||
neither redirect path — so in the only environment that runs the real
|
||||
`cloudfront-js-2.0` runtime, *"the authoritative check"* checked none of it.
|
||||
Four normalisation URIs and a query-string call added.
|
||||
- **The `docs/09` fix for the worthless `?list-type=2` check did not reach
|
||||
`docs/06`**, which went on prescribing it as *"the check that matters"* — and
|
||||
gave as its proof the very fact `docs/09` uses to show it cannot fail. **Two
|
||||
documents, one fix, and the discarded check survived in the one an operator
|
||||
ticks.**
|
||||
- **`.gitea/workflows/deploy.yml`'s new diagnostic printed a command that cannot
|
||||
run** — `aws apigatewayv2 get-routes --query 'Items[].RouteKey'` exits **252**
|
||||
on `ParamValidation` without `--api-id`. The sibling in `deploy-local.sh` has
|
||||
it. The two are described in their own headers as one artefact in two places and
|
||||
they diverged on the single command the diagnostic exists to supply — in the
|
||||
copy that runs in CI, where the reader has a log rather than a script.
|
||||
- Three stale `Expect` values in `docs/09`: `router: 15 of 15` (now 30; the count
|
||||
is no longer quoted, since it has moved three times in a day and an `Expect`
|
||||
line that never matches teaches the operator to stop reading them),
|
||||
and `CodeSize` *"around 23,000"* against a measured **10,819** — that figure was
|
||||
5.1's *uncompressed* number applied to the zip, and an operator seeing `10819`
|
||||
would reasonably conclude the wrong artefact went up.
|
||||
- **`docs/05`'s 3-second timestamp check was still an unstruck imperative**, in
|
||||
the same list whose rate-limit sibling had just been struck with nine lines of
|
||||
explanation, and recorded as unimplementable in three other places. Struck.
|
||||
- A permanent lint warning from a redundant `eslint-disable` whose stated reason
|
||||
was itself an argument for not needing it. Deleted.
|
||||
- **Comment bulk, and this one is D19's predicted cost realised rather than a
|
||||
style note:** `router.js` was **61%** comment and `handler.mjs` **49%**, and
|
||||
*the two longest comments in the change set were the two carrying a wrong
|
||||
constraint* — the 15-line normalisation block asserting a doubled slash resolves
|
||||
to the 404 page, and the 14-line `sourceIp` block with the XFF direction
|
||||
backwards. **An implementer who trusted either would have shipped both
|
||||
defects.** Trimmed to the live constraint; `router.js` is now 42%.
|
||||
- Two `consider`s taken: `configure.mjs` reported a *replaced* 404 mapping as an
|
||||
addition, and `tokens.css` told the next reader to re-measure `--header-h`
|
||||
without saying that `global.css`'s fitted `83px` moves with it.
|
||||
|
||||
**Round 2 also removed the `2>/dev/null || true` from both deploy paths' second
|
||||
`curl`** — the construct `CLAUDE.md` bans by name, in a verification script, which
|
||||
the reviewer noted rather than raised because the first `curl`'s status is read.
|
||||
Two characters, in files already open.
|
||||
|
||||
**STOPPING AT TWO ROUNDS — D19, and the reasoning is stated so a later reader does
|
||||
not read the cap as laxness.** Round 2 produced 11 findings **of which five were
|
||||
defects in round 1's own fixes**, including the blocking one. That ratio is the
|
||||
measurement the cap is built on: past two rounds, each further round manufactures
|
||||
defects at roughly the rate it finds them. What round 2 found is fixed; the round-3
|
||||
findings that would exist are not being sought.
|
||||
|
||||
⚠️ **AND THE MOST INSTRUCTIVE DEFECT OF THE SESSION WAS IN A FIX FOR A BLOCKING
|
||||
FINDING, CAUGHT BY MY OWN VERIFICATION RATHER THAN BY A REVIEWER.** The
|
||||
`check:claims` pattern added to close round 1's finding 2 used **literal spaces**.
|
||||
It ran, printed `ok sole-administrator-q62`, and exited 0 — with the breach
|
||||
sitting in `dist/legal/privacy/index.html` two directories away. `compressHTML`
|
||||
collapses whitespace **between** tags and leaves it alone **inside** a text node,
|
||||
so the published bytes read `nobody else has access to\n the table`. **A
|
||||
tripwire added to close a paper-control finding, which was itself a paper
|
||||
control.** Found by grepping the built page instead of believing the green line;
|
||||
the regex is `\s+` now and fires with 2 matches, exit 1.
|
||||
|
||||
### Gates on the final bytes
|
||||
|
||||
Every one run with its exit status read, no pipes over a verdict:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `npm run check` | **0** — 0 errors, 0 warnings, 0 hints |
|
||||
| `npm run build` | **0** — 23 pages |
|
||||
| `npm run check:claims` | **1**, and that is correct: the single `sole-administrator-q62` pattern, 2 matches in `dist/legal/privacy/index.html`. **Both deploy paths run it, so a deploy is blocked by a mechanism until Q62 is ruled** |
|
||||
| `npm run og:proof` | **0** — 23 pages, 22 cards, 21 headlines matched |
|
||||
| `npm run check:intake` | **0** |
|
||||
| `npm run lint` | **0**, and now with **no warnings** |
|
||||
| `npm run lighthouse` | **0** — 23 pages, worst of 23 **99 / 100 / 100 / 100**, CLS 0.000 everywhere, `/` LCP 2.03 s reported and not asserted |
|
||||
| `router.test.mjs` | **0** — **30 of 30**, and **8 of the 30 fail against the round-1 router**, which is what makes the pass a result |
|
||||
| `intake-env.mjs` | **0** — six variables, en dash and fourth clause intact |
|
||||
| `configure.mjs` dry run | **0** — 3 changes against the live distribution, nothing sent |
|
||||
| Q61, final bytes | **0 hidden focus stops of 1,455** under `minimumFontSize=32`; `scroll-padding-top` 97 px / 287 px / 287 px with fonts blocked |
|
||||
| minifier | the `@media (min-width: 66rem)` block in `dist` carries **exactly two** `scroll-padding-top` declarations **with the plain fallback first** — asserted, because an engine without `lh` drops the `max()` and the order is the whole safety |
|
||||
| `grep -rE 'animation:[^;}]*(scroll\(\)|view\(\))' dist` | **exit 1, no output** |
|
||||
| JS in `dist` | **0 files** |
|
||||
|
||||
⚠️ **Three of those checks were run once from the wrong working directory and
|
||||
reported `exit=2` and `0 files` — "no such directory", not "clean".** A compound
|
||||
command's `cd` had persisted. Re-run from the repository root, which is where the
|
||||
table above comes from. *A command that did not run is not evidence of absence*,
|
||||
committed and caught in the same session it is written down in.
|
||||
|
||||
### The sweeps, with their commands and output
|
||||
|
||||
Written after reading the output, not before.
|
||||
|
||||
**The five copy corrections reached `dist/` and the five originals are gone.**
|
||||
`grep -rl "<string>" dist --include='*.html' | wc -l` on each, ten runs:
|
||||
|
||||
```
|
||||
corrected "does not then sit on the hearing panel unless the parties consent" 1
|
||||
corrected "accident-benefits page names mediation as something to consider" 1
|
||||
corrected "rewriting how large loads get connected" 1
|
||||
corrected "presented to the membership as a" 1
|
||||
corrected "neither PIPEDA nor Ontario" 1
|
||||
original "who is then disqualified from hearing the matter" 0
|
||||
original "own materials point parties toward mediation" 0
|
||||
original "large loads and new" 0
|
||||
original "no federal or Ontario statute requires data" 0
|
||||
original ", developed by a task force and presented to the membership at" 0
|
||||
```
|
||||
|
||||
**The old item shape is gone from the source and the spec.**
|
||||
`grep -rn "pk: { S:\|'INTAKE#\|\`INTAKE#" backend/ src/` -> **exit 1, no output**.
|
||||
`grep -c '| \`pk\` |' docs/05-backend-spec.md` -> **0**.
|
||||
|
||||
**Q62 is cross-referenced in five files** — `AGENTS.md`, `docs/05`, `docs/06`,
|
||||
`docs/reference/intake-table-access-verification.md`,
|
||||
`src/pages/legal/privacy.astro`.
|
||||
|
||||
⚠️ **And `git grep -l 'Q62'` returned four of the five.** It cannot see untracked
|
||||
files, and the reference extract was untracked at that moment — `CLAUDE.md` names
|
||||
exactly this hole, and it appeared on the first sweep of this change set. `grep
|
||||
-rln` found all five. **A clean `git grep` across new work means nothing until the
|
||||
files are staged.**
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-01 (al) — The last recorded overflow is closed by one declaration, and half its cause had been misdiagnosed; the skip-link residual is accepted rather than fixed; R20 becomes a hard gate on publishing article #2; two majors deferred with a date
|
||||
|
||||
**The header tree of entry (ak) is committed as `fce89d4`** — eight files, 737
|
||||
|
||||
+61
-14
@@ -43,18 +43,24 @@
|
||||
* That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both
|
||||
* records of what a control that exists on paper and not in fact costs here. So
|
||||
* it is omitted, said out loud, and the load is carried by the honeypot, the
|
||||
* Origin check, the API Gateway rate limit and the validation below.
|
||||
* Origin check, the aggregate API Gateway route throttle and the validation
|
||||
* below. (Aggregate, not per-IP — see above; the earlier wording here said
|
||||
* "rate limit" and let the reader supply the stronger meaning.)
|
||||
*
|
||||
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
|
||||
*
|
||||
* - API Gateway throttling, 5 requests / 5 minutes per source IP (docs/05).
|
||||
* Not expressible in handler code.
|
||||
* - An AGGREGATE API Gateway route throttle. NOT per source IP: API Gateway
|
||||
* throttling is per route and per stage across all callers, so docs/05's
|
||||
* "5 requests / 5 minutes per source IP" is struck — per-IP needs AWS WAF.
|
||||
* Never describe what ships as per-IP. docs/09 Part 6.3.
|
||||
* - CloudFront behaviour: /api/* → the HTTP API origin §7 records.
|
||||
* - A dead-letter queue on this function and a CloudWatch alarm on DLQ depth
|
||||
* >= 1 (docs/05). This handler writes to DynamoDB BEFORE sending mail so a
|
||||
* DLQ replay cannot lose a submission.
|
||||
* - The `ses-alerts` SNS email subscription is PENDING CONFIRMATION (§7, R9).
|
||||
* Until it is confirmed the bounce and complaint alarms fire into nothing.
|
||||
* - CloudWatch alarms on Lambda `Errors` and on API Gateway 5xx for this
|
||||
* route. NOT a dead-letter queue: `DeadLetterConfig` is used only for
|
||||
* ASYNCHRONOUS invocations, API Gateway invokes synchronously, so a DLQ here
|
||||
* would sit at depth 0 for ever and an alarm on it would be a permanently
|
||||
* green light. docs/05 §Notification carries the replacement.
|
||||
* - The `ses-alerts` SNS email subscription is CONFIRMED (§7) — R9 closed
|
||||
* 2026-09-01, so the bounce and complaint alarms reach someone.
|
||||
*/
|
||||
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
|
||||
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
|
||||
@@ -199,6 +205,33 @@ function parseBody(event) {
|
||||
throw new Error(`unsupported content-type: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ THE UNFORGEABLE VALUE, AND NOT THE USEFUL ONE. `requestContext.http
|
||||
* .sourceIp` is the TCP peer, which behind the CloudFront behaviour that routes
|
||||
* /api/* is a CloudFront EDGE — so this records AWS rather than the inquirer.
|
||||
*
|
||||
* IT READ `x-forwarded-for` FOR ONE REVISION AND THAT WAS WORSE. CloudFront
|
||||
* APPENDS the viewer address to a client-supplied XFF rather than replacing it,
|
||||
* so the leftmost entry is whatever the client sent: a submission with
|
||||
* `X-Forwarded-For: 8.8.8.8` stored `8.8.8.8`. That turns a field held for abuse
|
||||
* investigation into one that can be made to name an uninvolved third party, and
|
||||
* /legal/privacy/ promises the record holds "your IP address". A forgeable value
|
||||
* presented as an identification is worse than an honest useless one.
|
||||
*
|
||||
* The right value is CloudFront's own `CloudFront-Viewer-Address`, which
|
||||
* CloudFront generates and overwrites — but reaching it needs a CUSTOM origin
|
||||
* request policy on the /api/* behaviour (the managed
|
||||
* AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API
|
||||
* Gateway, which is why AllViewerExceptHostHeader was chosen). That is an
|
||||
* infrastructure change, and `docs/09` Part 7.2 measures what this field
|
||||
* actually contains at cutover rather than reasoning about the proxy chain —
|
||||
* with a decision table for each outcome. Do not "fix" this from the header
|
||||
* again without that measurement.
|
||||
*/
|
||||
function viewerIp(event) {
|
||||
return event.requestContext?.http?.sourceIp ?? 'unknown';
|
||||
}
|
||||
|
||||
function headerOf(event, name) {
|
||||
const headers = event.headers ?? {};
|
||||
// API Gateway HTTP API lowercases header keys; a direct invoke or a test
|
||||
@@ -323,20 +356,31 @@ export async function handler(event) {
|
||||
/**
|
||||
* DYNAMODB FIRST, THEN MAIL — docs/05: "SES failure must never lose the
|
||||
* submission." The order is the whole guarantee. If SES fails after this
|
||||
* write, the record exists and the DLQ replay has something to replay; if the
|
||||
* write fails, nothing was accepted and the inquirer is told so.
|
||||
* write, the record exists and a resend has something to resend; if the write
|
||||
* fails, nothing was accepted and the inquirer is told so. (This said "the DLQ
|
||||
* replay" — there is no DLQ and there cannot usefully be one on a
|
||||
* synchronously invoked function; see the note at the top of this file.)
|
||||
*/
|
||||
try {
|
||||
await ddb.send(
|
||||
new PutItemCommand({
|
||||
TableName: TABLE,
|
||||
Item: {
|
||||
pk: { S: `INTAKE#${id}` },
|
||||
sk: { S: now.toISOString() },
|
||||
/* ⚠️ `submissionId` IS THE TABLE'S PARTITION KEY AND THERE IS NO SORT
|
||||
KEY. A DynamoDB key schema cannot be altered after creation, so this
|
||||
attribute name is fixed by the table `AGENTS.md` §7 names, not
|
||||
chosen here — and an item missing it fails the whole write with
|
||||
`ValidationException`, which this function converts into the failure
|
||||
page. Verify against `describe-table` before changing either name;
|
||||
`submittedAt` is an ordinary attribute and is free. */
|
||||
submissionId: { S: id },
|
||||
submittedAt: { S: now.toISOString() },
|
||||
ttl: { N: String(ttl) },
|
||||
// Abuse investigation only (docs/05). Named so a later reader does not
|
||||
// repurpose them: they are not analytics and not part of the reply.
|
||||
sourceIp: { S: event.requestContext?.http?.sourceIp ?? 'unknown' },
|
||||
/* Behind CloudFront this is the EDGE address, not the inquirer's.
|
||||
See `viewerIp()` — and read it before changing this. */
|
||||
sourceIp: { S: viewerIp(event) },
|
||||
userAgent: {
|
||||
S: (headerOf(event, 'user-agent') ?? 'unknown').slice(0, 400),
|
||||
},
|
||||
@@ -378,7 +422,10 @@ export async function handler(event) {
|
||||
Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` },
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Received ${now.toISOString()}\nRecord INTAKE#${id}\n\n${summaryLines}\n`,
|
||||
// The bare id, because it is the partition key: this line is
|
||||
// what gets pasted into the console to find the record, so it
|
||||
// must be the key and not a rendering of it.
|
||||
Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+30
-7
@@ -49,15 +49,15 @@ decision, not an aesthetic one.
|
||||
/legal/terms/ Terms of use
|
||||
```
|
||||
|
||||
Nineteen fixed URLs plus one per article — **and three more added at build steps
|
||||
8 and 9, all three `noindex` and all three excluded from the sitemap in
|
||||
`astro.config.mjs`.** They are utilities rather than pages in the IA above, which
|
||||
is why they are listed here rather than in it:
|
||||
Nineteen fixed URLs plus one per article — **and four more, all `noindex` and all
|
||||
excluded from the sitemap in `astro.config.mjs`.** They are utilities rather than
|
||||
pages in the IA above, which is why they are listed here rather than in it:
|
||||
|
||||
```
|
||||
/contact/received/ The intake form's success landing page
|
||||
/contact/could-not-send/ Its failure landing page
|
||||
/bio/ Source of the one-page PDF (R16)
|
||||
/404/ Emitted as dist/404.html — see below
|
||||
```
|
||||
|
||||
The two `/contact/` pages exist because the site ships **zero JavaScript**, so
|
||||
@@ -66,9 +66,32 @@ the form is a plain POST and the handler answers `303 See Other` to a real URL
|
||||
with an appointment proposal is a *rendering of a reviewed page* rather than a
|
||||
document outside this project's review apparatus.
|
||||
|
||||
**So: 22 built pages plus one per published article.** `npm run lighthouse`
|
||||
enumerates them from `dist/` rather than from this list, which is why this count
|
||||
being stale could never make the gate miss a page.
|
||||
⚠️ **`/404/` IS THE ONE ROUTE THAT BREAKS THIS DOCUMENT'S OWN URL RULES, AND IT
|
||||
HAS TO.** The rules above are lower-case, hyphenated, trailing slash, no file
|
||||
extensions. Astro emits `src/pages/404.astro` as **`dist/404.html`** — a file at
|
||||
the root, outside `build.format: 'directory'` — because that is the object name a
|
||||
CDN custom error response can point at. `SEO.astro` still sees the path as
|
||||
`/404/`, which is why its `OG_CARDS` key is `/404/` while the URL a tool fetches
|
||||
is `/404.html`. Added 2026-09-01; `docs/04` had asked for the page since before
|
||||
build step 1 and it did not exist.
|
||||
|
||||
**So: 23 built pages plus one per published article.**
|
||||
|
||||
⚠️ **AND THIS SENTENCE USED TO END WITH A REASSURANCE THAT WAS DISPROVEN THE DAY
|
||||
THE 404 PAGE WAS ADDED.** It read: *"`npm run lighthouse` enumerates them from
|
||||
`dist/` rather than from this list, which is why this count being stale could never
|
||||
make the gate miss a page."* Both `scripts/lighthouse.mjs` and
|
||||
`scripts/og-proof.mjs` enumerated **`index.html` under `dist/`**, not every page —
|
||||
so both missed `/404/`, and `og:proof` reported it backwards, as an orphaned card
|
||||
rather than an unchecked page. The count being stale was not the failure mode; the
|
||||
**definition of "a page"** was. Both now take any `.html` at the root as well, and
|
||||
`check:claims` always did, which is why the new page's copy was inside the claim
|
||||
register from its first build.
|
||||
|
||||
**The rule that replaces the reassurance:** a route that does not live at
|
||||
`<dir>/index.html` is invisible to anything that looks for `index.html`. If a
|
||||
future page is emitted outside the directory convention, grep the three scripts
|
||||
for `index.html` before trusting any of them.
|
||||
|
||||
### URL rules
|
||||
|
||||
|
||||
+87
-17
@@ -495,9 +495,13 @@ Not a polish pass. A build requirement.
|
||||
⚠️ **It is not a claim about anything outside that grid, and "no document
|
||||
overflow" is not "no accessibility defect".** Three known cases sit outside it
|
||||
deliberately. The **fallback-metrics case** below needs the webfonts blocked and
|
||||
a seventh nav item. The **focus-obscuring case** below is an **open WCAG 2.2
|
||||
SC 2.4.11 (AA) failure** — `AGENTS.md` Q61 — and document overflow cannot see it
|
||||
at all, because nothing overflows: the header simply covers what has focus. And a
|
||||
a seventh nav item. The **focus-obscuring case** below was a **WCAG 2.2
|
||||
SC 2.4.11 (AA) failure**, now **fixed and closed** — `AGENTS.md` Q61 — and it is
|
||||
the sharpest illustration of the warning above: **document overflow could not see
|
||||
it at all**, because nothing overflowed. The header simply covered what had
|
||||
focus, on 290 of 1,455 stops, while every zero in the table above stayed a zero.
|
||||
A residual at `minimumFontSize=16` and `=20` remains, is pre-existing, and is
|
||||
likewise invisible to this grid. And a
|
||||
**footer nav label** still overruns its own column by 24 px at 640 px under
|
||||
minimum font size with 7.7 px of clearance, again with zero document overflow.
|
||||
None of the three is contradicted by the zeros above, and none of them is
|
||||
@@ -1034,9 +1038,10 @@ Not a polish pass. A build requirement.
|
||||
97 → 199.4 px inside a header at 0 → 228.6 px, `position: sticky`,
|
||||
`inset-block-start: 0px`, background `rgb(250, 247, 242)` — opaque cream.
|
||||
|
||||
**THE RECOMMENDED FIX IS MEASURED AND DELIBERATELY NOT IMPLEMENTED — Q61.** Two
|
||||
candidates exist, and the second is better on the exact ground that deferred the
|
||||
first.
|
||||
✅ **FIXED AND CLOSED — Q61 RULED *fix now*, 2026-09-01. Candidate B SHIPPED.**
|
||||
Two candidates existed, and the second was better on the exact ground that had
|
||||
deferred the first. The measurements below are the pre-fix state; the acceptance
|
||||
sweep that closed it is at the end of this block.
|
||||
|
||||
*Candidate A, round 1:* a third gate term `calc((15px - 1ch) * 100000)`,
|
||||
un-sticking the header under the setting while leaving `inset-block-start: 0px`
|
||||
@@ -1076,20 +1081,85 @@ Not a polish pass. A build requirement.
|
||||
font file changes nothing, and the fallback-metrics caveat that deferred
|
||||
candidate A does not apply to B at all.
|
||||
|
||||
**Why B is still not implemented, and none of these is a measurement gap.**
|
||||
`10lh - 83px` is two fitted constants — the difference from A is that a `max()`
|
||||
ramp degrades by pixels if they are off, where A's `* 100000` step flips
|
||||
**What was said against B before it shipped, and what the sweep did with each.**
|
||||
`10lh - 83px` is two fitted constants — the difference from A being that a
|
||||
`max()` ramp degrades by pixels if they are off, where A's `* 100000` step flips
|
||||
stickiness the wrong way. **The first declaration is load-bearing:** an engine
|
||||
without `lh` support drops the whole `max()` as invalid and `scroll-padding-top`
|
||||
falls back to `var(--space-4)` = 16 px, which is *worse* than today. It needs the
|
||||
full 1,408-cell sweep plus the fallback-metrics and seven-item cases. The focus
|
||||
probe behind all of this is **Chrome-only**. And **D19 caps the review at two
|
||||
rounds**, so a header change made now would ship without review — the precise
|
||||
failure mode the cap exists to prevent. **The findings are the premise and the
|
||||
cost; the fix is Pouya's ruling.**
|
||||
falls back to `var(--space-4)` = 16 px, which is *worse* than no fix — so it is
|
||||
written first, as a plain declaration, and must stay there. The focus probe is
|
||||
**Chrome-only**, which is still true and is a limit on the evidence rather than
|
||||
on the fix. The two remaining objections were discharged by measurement:
|
||||
|
||||
**`docs/06` carries this as an OPEN item again**, restored from the `[x]` this
|
||||
session had written, and the decision is `AGENTS.md` **Q61**.
|
||||
**THE ACCEPTANCE SWEEP — 777 cells over 37 settings, plus the focus walk.**
|
||||
|
||||
Per cell: navigate, kill transitions *before* any root-size change, set the
|
||||
root, `location.hash = 'main'`, then read how much of `#main` sits behind the
|
||||
header. The cell count is asserted, so a silently-truncated grid cannot pass.
|
||||
|
||||
| setting | before | after |
|
||||
|---|---|---|
|
||||
| root style 9..32 (504 cells) | 0 | 0 |
|
||||
| `defaultFontSize` 9 / 12 / 16 / 20 / 24 (105) | 0 | 0 |
|
||||
| `minimumFontSize=9`, `=12` (42) | 0 | 0 |
|
||||
| `minimumFontSize=16` (21) | 6 cells, max 52 px | **6 cells, max 52 px** |
|
||||
| `minimumFontSize=20` (21) | 12 cells, max 64 px | **6 cells, max 14 px** |
|
||||
| `minimumFontSize=24` (21) | 15 cells, max 75 px | **0** |
|
||||
| `minimumFontSize=32` (21) | 15 cells, max 174 px | **0** |
|
||||
| fonts blocked (21) | 0 | 0 |
|
||||
| fonts blocked + `minFont=32` (21) | 15 cells, max 174 px | **0** |
|
||||
| **total** | **63 of 777** | **12 of 777** |
|
||||
|
||||
**The focus walk, which is the criterion itself: 290 entirely-hidden stops of
|
||||
1,455 → 0.** Same grid (6 pages × 5 widths ≥ 1056 px, 30 cells, 344
|
||||
header-internal stops skipped), hit-tested with `elementFromPoint`. **The
|
||||
pre-fix tree was rebuilt in a git worktree and put through the identical probe:
|
||||
it still reports 290** — footer 254, `#main` 36, geometry candidates 333 against
|
||||
the fixed build's 43. That control is what makes the 0 a result rather than a
|
||||
hope: both modes reading 0 with identical step counts is the shape `CLAUDE.md`
|
||||
warns *ends* a check instead of starting one.
|
||||
|
||||
**Default settings unchanged: 0 differences over 352 page-widths × 17 fields =
|
||||
5,984 comparisons**, full-page geometry fingerprint included, positive control
|
||||
detecting exactly 1 injected difference.
|
||||
|
||||
⚠️ **AND THE FIRST ATTEMPT AT THAT COMPARISON REPORTED 4,224 DIFFERENCES.** It
|
||||
keyed rows on `page`/`width`; this harness names them `url`/`w`, so every row
|
||||
collapsed onto one map entry and was compared against an arbitrary single row.
|
||||
It was caught only because the result was *uniformly bad*. **The fix is not the
|
||||
corrected field names — it is that the comparison now asserts the key fields
|
||||
exist and are unique before comparing anything.**
|
||||
|
||||
⚠️ **ONE COLUMN OF THE SWEEP WAS MEASURING NOTHING, AND TWO METRICS
|
||||
DISAGREEING IS WHAT SURFACED IT.** A summary column reported
|
||||
`min(scroll-padding-top − headerH)` of **−71.89 px** on root-style rows whose
|
||||
`covered` read **0**. Cause: `insetBlockStart` is **−900 px** in those rows —
|
||||
the header is `position: sticky` and **not pinned**, because that is exactly how
|
||||
the saturating `clamp()` gate un-sticks it. Comparing the offset to the header
|
||||
height is meaningless where the header is not pinned. `covered` — real geometry
|
||||
after a real hash navigation — is the valid metric. **A `position` of `sticky`
|
||||
is not the same fact as "the header is pinned", and any future probe that
|
||||
compares an offset against a header height has to read
|
||||
`inset-block-start` too.**
|
||||
|
||||
**THE 12 REMAINING CELLS ARE PRE-EXISTING, REDUCED, AND DELIBERATELY NOT
|
||||
FIXED.** `minimumFontSize=16` leaves 52 px of `#main` behind the header on
|
||||
`/about/` and `/contact/` at 1280, 1440 and 1920 px; `=20` leaves 14 px on the
|
||||
same six. Every one is unchanged or better than before the fix and none is new.
|
||||
**Why the ramp cannot see them, which is the part worth keeping:** 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.** Closing it needs a different measurement — `1lh` taken from an element
|
||||
that carries the small type, or a floor on the header — which is a header change
|
||||
with site-wide reach. Pouya's instruction on this step was *"if the sweep
|
||||
surfaces anything beyond this one declaration's blast radius, stop and report —
|
||||
do not widen"*, so it is reported: `docs/06` carries it as its own item. It is
|
||||
**not** an SC 2.4.11 failure, which is about a component *entirely* hidden.
|
||||
|
||||
**`docs/06`'s item is ticked and carries the figures.** The decision was
|
||||
`AGENTS.md` **Q61**, now closed.
|
||||
|
||||
⚠️ **AND ONE MORE LATENT CASE, AT THE DEFAULT TEXT SIZE — NO READER SETTING
|
||||
INVOLVED: FALLBACK FONT METRICS PLUS THE SEVENTH NAV ITEM.** With every `.woff2`
|
||||
|
||||
+92
-11
@@ -70,7 +70,9 @@ dynamic origin or client-side script, and the site has neither by design.
|
||||
A control that exists on paper and not in fact is worse than a stated gap — that
|
||||
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
|
||||
said out loud, and the load is carried by the honeypot, the `Origin` check, the
|
||||
API Gateway rate limit and server-side validation.
|
||||
**aggregate** API Gateway route throttle and server-side validation. (Aggregate,
|
||||
not per-IP — see §Validation. "Rate limit" was the wording here and let the reader
|
||||
supply the stronger meaning.)
|
||||
|
||||
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
|
||||
is a top-level navigation: it is exempt from CORS preflight, so an
|
||||
@@ -156,8 +158,26 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
||||
- Required fields present; email well-formed; lengths within bounds
|
||||
- Reject any field over its cap rather than truncating silently
|
||||
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
|
||||
- **Timestamp check** — reject submissions completed in under 3 seconds
|
||||
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
|
||||
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
|
||||
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
|
||||
while this line stayed an unqualified imperative** — the handler's header,
|
||||
§Three deviations above, and the definition of done below. §Three deviations
|
||||
has the reasoning: `/contact/` is a CDN-cached static file, so a build-time
|
||||
timestamp is the same value for every visitor and `now − served` is always
|
||||
large. **This is the unstruck-imperative shape `CLAUDE.md` names** — and it
|
||||
survived in the same list whose sibling bullet was struck correctly, which is
|
||||
the sweep failure exactly. Found by `adversarial-reviewer` round 2
|
||||
- ~~**Rate limit** by source IP at API Gateway: 5 requests / 5 minutes~~
|
||||
⚠️ **STRUCK 2026-09-01: API GATEWAY CANNOT RATE-LIMIT BY SOURCE IP, SO THIS
|
||||
ASKED FOR A CONTROL THAT CANNOT BE BUILT WHERE IT SAYS TO BUILD IT.** HTTP API
|
||||
throttling is **aggregate** — a rate and a burst, per route and per stage,
|
||||
across all callers. Per-IP limiting needs **AWS WAF** with a rate-based rule on
|
||||
the distribution, which is a paid service and therefore a decision rather than
|
||||
a step. What ships instead is the aggregate throttle
|
||||
(`docs/09-cutover-runbook.md` Part 6.3), and it must never be described as
|
||||
per-IP. This is deviation 1's own argument turned on this spec: *"a control that
|
||||
exists on paper and not in fact is worse than a stated gap"* — the throttle is
|
||||
real and bounds total volume; the per-IP claim was neither
|
||||
- No CAPTCHA. It is a third-party script on a page collecting legal information,
|
||||
and the two controls above stop the traffic that matters
|
||||
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
|
||||
@@ -171,18 +191,44 @@ handing over sensitive material, and where it comes to rest is a fair question
|
||||
for them to ask. Confirm the existing table's region and migrate if it is
|
||||
elsewhere — §7 has the table name and region.
|
||||
|
||||
⚠️ **THE KEY SCHEMA IS THE TABLE'S, NOT THIS SPEC'S — CORRECTED 2026-09-01, AND
|
||||
THE UNCORRECTED VERSION WOULD HAVE LOST EVERY SUBMISSION.** This table specified
|
||||
`pk: INTAKE#<uuid>` and `sk: <timestamp>`, and `handler.mjs` was written to it.
|
||||
The table `AGENTS.md` §7 names has a single partition key **`submissionId` (S)`
|
||||
and no sort key** `[verified 2026-09-01 — aws dynamodb describe-table]`. A
|
||||
`PutItem` missing the key attribute fails the whole write with
|
||||
`ValidationException`, the handler catches it and answers the failure page — so
|
||||
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 was changed to the table rather than the
|
||||
reverse; the alternative, a new table matching the old shape, was declined
|
||||
because it would re-open the §7-verified TTL and PITR state on a fresh resource
|
||||
at cutover to buy a sort key nothing queries. Verify with `describe-table`, not
|
||||
against this row.
|
||||
|
||||
| Attribute | |
|
||||
|---|---|
|
||||
| `pk` | `INTAKE#<uuid>` |
|
||||
| `sk` | `<ISO-8601 timestamp>` |
|
||||
| `submissionId` | `<uuid>` — **the partition key.** Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console |
|
||||
| `submittedAt` | `<ISO-8601 timestamp>` — an ordinary attribute, not a sort key |
|
||||
| fields | as above |
|
||||
| `sourceIp`, `userAgent` | abuse investigation only |
|
||||
| `consentAt` | `<ISO-8601 timestamp>` — when the consent box was submitted |
|
||||
| `ttl` | epoch seconds — **the input to automatic deletion; see §Retention for why writing it is not the mechanism** |
|
||||
|
||||
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
|
||||
on.** Table access limited to the Lambda role and one named administrative
|
||||
principal.
|
||||
|
||||
⚠️ **TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT.**
|
||||
PITR is **on** `[verified 2026-09-01 — describe-continuous-backups,
|
||||
PointInTimeRecoveryStatus: ENABLED, 35-day window]`. Encryption at rest is on
|
||||
with the **AWS-owned key, not a customer-managed KMS key** `[verified
|
||||
2026-09-01 — describe-table returns no SSEDescription]`. That gap is
|
||||
deliberately not a cutover blocker: `/legal/privacy/` says "encrypted at rest",
|
||||
which is unconditionally true of every DynamoDB table, and it does not claim a
|
||||
customer-managed key — so nothing published depends on it. It stays on
|
||||
`docs/06`'s checklist as the improvement it is.
|
||||
|
||||
### Retention
|
||||
|
||||
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
|
||||
@@ -286,8 +332,32 @@ no visibility.
|
||||
— silently, months later.
|
||||
|
||||
Failure handling: SES failure must never lose the submission. Write to DynamoDB
|
||||
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||
DLQ depth ≥ 1.
|
||||
first, then send. ~~A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||
DLQ depth ≥ 1.~~
|
||||
|
||||
⚠️ **THE DLQ IS STRUCK, 2026-09-01, AND IT WOULD HAVE BEEN A CONTROL THAT
|
||||
RECEIVED NOTHING.** Lambda's `DeadLetterConfig` is used **only for asynchronous
|
||||
invocations** (and event-source failures). API Gateway invokes this function
|
||||
**synchronously** and the error is returned to the caller, so a DLQ configured on
|
||||
`adr-intake-handler` would sit at depth 0 for ever and an alarm on it would be a
|
||||
green light that means nothing — the third instance of this project's most
|
||||
expensive shape, after `AGENTS.md` Q22 and the Lighthouse row.
|
||||
|
||||
What actually protects a submission is already built and is not a queue: the
|
||||
handler **writes to DynamoDB before sending mail**, so a mail failure cannot lose
|
||||
a record, and a write failure returns the visitor to `/contact/could-not-send/`
|
||||
rather than telling them an inquiry was received. What is missing is **detection**,
|
||||
and the replacement is two CloudWatch alarms rather than one:
|
||||
|
||||
- **Lambda `Errors` ≥ 1** on `adr-intake-handler` — this is what a DLQ alarm was
|
||||
reaching for and it fires on a synchronous failure, which a DLQ cannot see.
|
||||
- **API Gateway `5xx` ≥ 1** on the `POST /api/intake` route — it catches the one
|
||||
failure the Lambda cannot report, a permission or integration fault where the
|
||||
function is never entered at all (`docs/09-cutover-runbook.md` Part 6.1 is the
|
||||
step whose omission causes exactly that).
|
||||
|
||||
Both notify the `ses-alerts` topic, whose email subscription is **confirmed** as
|
||||
of `AGENTS.md` §7 — so unlike the DLQ alarm, these reach someone.
|
||||
|
||||
## Booking
|
||||
|
||||
@@ -371,14 +441,25 @@ Plausible or Fathom, cookieless, no consent banner.
|
||||
|
||||
- [x] **Server-side validation independent of the client** — `backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
|
||||
- [x] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
|
||||
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
|
||||
- [ ] **Throttle configured** — an **aggregate** API Gateway route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. `docs/09-cutover-runbook.md` Part 6.3
|
||||
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
|
||||
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months, and it asserts the **mechanism**, not only the period. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — and **writing the attribute is not the mechanism**: TTL must also be enabled on the table, which is a table setting the code cannot see. **`AGENTS.md` §7 holds that status and its stamp; this line does not restate it** — it restated it once, went stale within the day, and had to be pulled back (§12 R19). **The test record is what closes this item, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour. Tracked as §9 Q60
|
||||
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
|
||||
- [x] **PITR enabled** — `ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
|
||||
- [ ] KMS customer-managed key. **Not on the table: encryption at rest is with the AWS-owned key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`. **Not claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
|
||||
- [ ] **Table access matches what `/legal/privacy/` says about it.** ⚠️ **IT DOES NOT, AS AT 2026-09-01.** The page says *"nobody else has access to the table… no external administrator"*; the account's `admins` group carries `AdministratorAccess` and has **two** members, and `simulate-principal-policy` returns **allowed** for `dynamodb:GetItem`/`Query`/`Scan` for both. Evidence and commands: `docs/reference/intake-table-access-verification.md`. §9 **Q62**, and it blocks that page going public
|
||||
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
||||
- [ ] DLQ and CloudWatch alarm configured. The handler writes to DynamoDB **before** sending mail, so a replay cannot lose a submission
|
||||
- [ ] **CloudWatch alarms on Lambda `Errors` and API Gateway `5xx`** — replacing the DLQ item, which is struck: a DLQ on a **synchronously** invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB **before** sending mail, so the protection this item was pointing at is in the code rather than in a queue
|
||||
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
|
||||
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
|
||||
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
|
||||
> **The three remaining items below are commands, and the commands are in
|
||||
> `docs/09-cutover-runbook.md`** — Parts 5, 6 and 3 respectively, each with its
|
||||
> verification and the output to expect. Two things that spec found by reading the
|
||||
> running system rather than the specs, and both would have lost every
|
||||
> submission: the API route needs its **own** Lambda invoke permission, because
|
||||
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the
|
||||
> handler's item shape had to change, because the table's partition key is
|
||||
> `submissionId` and a key schema cannot be altered after creation (§Storage).
|
||||
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it. **And two other distribution changes are prerequisites of the site working at all**, neither of which is intake: a viewer-request function for `trailingSlash: 'always'`, without which 22 of 23 pages return S3's `AccessDenied`, and the 404 mapping `docs/04` requires
|
||||
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
|
||||
|
||||
+253
-48
@@ -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 1–3 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**
|
||||
|
||||
+3
-1
@@ -363,7 +363,9 @@ one the review can settle without new market data.
|
||||
| 6 h | $3,500 | $4,000 | **$500** |
|
||||
| 7 h | $4,000 | $4,500 | **$500** |
|
||||
|
||||
*(Session-cap trigger; the trigger itself is `AGENTS.md` §9 **Q59**, open.)* The
|
||||
*(Session-cap trigger; the trigger itself is `AGENTS.md` **Q59**, ruled and
|
||||
closed 2026-08-31 — this line said "open" for a day after line 144 of this same
|
||||
file recorded the ruling.)* The
|
||||
cause is the relationship between two of D14's own numbers rather than anything
|
||||
about the trigger: **the half-to-full step is $2,000 and three hours of overtime
|
||||
is $1,500.** Any trigger leaves a gap; the envelope trigger closes it only from
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
# 09 — Cutover runbook: the exact command sequence
|
||||
|
||||
Authority: `AGENTS.md` §3 D11 (build everything, one clean cutover) and §7 for
|
||||
every operational fact. `docs/06-deployment.md` owns the cutover **checklist** —
|
||||
what must be true. This file owns the **commands** — how to make it true. The
|
||||
checklist cites this file; this file does not restate the checklist.
|
||||
|
||||
**Pouya runs everything here.** Parts 1–7 need administrative credentials that no
|
||||
agent on this project holds, and Part 8 is a deploy that must not run as
|
||||
`user/pouya` (§10). Every command is followed by the command that verifies it and
|
||||
the output to expect. Where a verification can come back two ways, both are named
|
||||
and the sequence branches — a branch driven by a measurement rather than by a
|
||||
guess is the point.
|
||||
|
||||
> **Read `docs/06`'s cutover checklist alongside this.** The parts below discharge
|
||||
> its Technical group. Its Content and compliance group — the `claims-auditor`
|
||||
> pass and Pouya's own page-by-page read — is not commands and is not here.
|
||||
|
||||
---
|
||||
|
||||
## Why the order is this order, and what is broken while it runs
|
||||
|
||||
The bucket currently holds the **old** single-file site: `index.html` (2,206,032
|
||||
bytes) plus two logo PNGs `[verified 2026-09-01 — list-objects-v2]`. So the live
|
||||
site today is one page, and one page is all that works: measured on the live
|
||||
distribution the same day, `/` returns **200** while `/about/` and
|
||||
`/definitely-not-a-page/` both return **403 with an 111-byte `application/xml`
|
||||
body** — S3's `AccessDenied`, served raw to the reader.
|
||||
|
||||
That is why the infrastructure goes first and the site goes last:
|
||||
|
||||
1. **Parts 1–4 (distribution) change nothing a visitor can see.** The router
|
||||
rewrites `/` to `/index.html`, which is what the default root object already
|
||||
did, and the paths it newly handles do not exist on the old site either way.
|
||||
2. **Parts 5–7 (intake) are inert until `/contact/` exists.**
|
||||
3. **Part 8 is the cutover.** The moment `index.html` syncs, the new site is
|
||||
live — and by then the router, the 404 mapping and `/api/*` are already in
|
||||
place, so there is no window in which 22 of 23 pages are broken.
|
||||
|
||||
Reversing this — site first, infrastructure second — would put every page except
|
||||
`/` behind a 403 for as long as the CloudFront deployment took.
|
||||
|
||||
---
|
||||
|
||||
## Part 0 — Preflight
|
||||
|
||||
### 0.1 The substitution block
|
||||
|
||||
⚠️ **THIS IS THE ONLY PLACE IN THIS FILE THAT NAMES A RESOURCE, AND THAT IS A
|
||||
DELIBERATE, BOUNDED EXCEPTION TO THE §7 RULE.** `AGENTS.md` §7 is the single
|
||||
source of truth for operational facts and specs cite it rather than copying it —
|
||||
but a runbook that cannot be pasted into a terminal is not a runbook. So the
|
||||
values appear exactly once, and **0.2 is the mechanism that keeps the copy
|
||||
honest**: it proves every one of them resolves to a real resource before anything
|
||||
is changed. A duplicated fact needs a mechanism, not a promise.
|
||||
|
||||
Confirm each against §7 before pasting. `./scripts/aws-discover.sh` regenerates
|
||||
them from AWS if §7 itself needs re-verifying.
|
||||
|
||||
```bash
|
||||
cd ~/Dev/Websites/adr-sml
|
||||
|
||||
export AWS_REGION=ca-central-1
|
||||
export AWS_DEFAULT_REGION=ca-central-1
|
||||
export ACCT=327082975128
|
||||
export FN=adr-intake-handler
|
||||
export TABLE=adr-intake-submissions
|
||||
export API_ID=4tl0m5igkj
|
||||
export API_DOMAIN=4tl0m5igkj.execute-api.ca-central-1.amazonaws.com
|
||||
export DIST_ID=E1OK7G98KNKUTA
|
||||
export BUCKET=adr-smlcompany-site
|
||||
export SITE=https://adr.smlcompany.ca
|
||||
export NOTIFY_TO=info@smlcompany.ca
|
||||
export MAIL_FROM=intake@smlcompany.ca
|
||||
```
|
||||
|
||||
`NOTIFY_TO` is where an inquiry lands and `MAIL_FROM` is the From on both
|
||||
messages. Both are choices rather than records: `info@` is the address `/contact/`
|
||||
publishes, so a reply threads where the inquirer expects; `intake@` distinguishes
|
||||
form mail from correspondence. Both must be at `smlcompany.ca`, which is the
|
||||
verified sending identity — 0.2 checks that.
|
||||
|
||||
### 0.2 Prove every one of them exists
|
||||
|
||||
```bash
|
||||
aws sts get-caller-identity
|
||||
for v in AWS_REGION ACCT FN TABLE API_ID API_DOMAIN DIST_ID BUCKET SITE NOTIFY_TO MAIL_FROM; do
|
||||
eval "val=\$$v"
|
||||
[ -n "$val" ] || { echo "EMPTY: $v"; break; }
|
||||
printf '%-12s %s\n' "$v" "$val"
|
||||
done
|
||||
aws lambda get-function-configuration --function-name "$FN" --query 'FunctionName' --output text
|
||||
aws dynamodb describe-table --table-name "$TABLE" --query 'Table.TableStatus' --output text
|
||||
aws apigatewayv2 get-api --api-id "$API_ID" --query 'Name' --output text
|
||||
aws cloudfront get-distribution --id "$DIST_ID" --query 'Distribution.Status' --output text
|
||||
aws s3api head-bucket --bucket "$BUCKET" && echo "bucket ok"
|
||||
aws sesv2 get-email-identity --email-identity "${MAIL_FROM#*@}" \
|
||||
--query 'VerifiedForSendingStatus' --output text
|
||||
```
|
||||
|
||||
**Expect:** an identity with administrative rights; eleven non-empty values;
|
||||
`adr-intake-handler`; `ACTIVE`; `adr-intake-api`; `Deployed`; `bucket ok`; `True`.
|
||||
|
||||
A `None`, an empty value, or any non-zero exit stops the run here. That is the
|
||||
whole purpose of 0.2 — a stale identifier fails loudly at the top instead of
|
||||
halfway through Part 6.
|
||||
|
||||
### 0.3 State this run starts from
|
||||
|
||||
Recorded so a later reader can tell what this sequence changed from what it found.
|
||||
All `[verified 2026-09-01]`, read-only:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Lambda | `nodejs24.x`, arm64, handler `index.handler`, timeout 10 s, memory 128 MB, **no environment variables**, no DLQ, code 1,527 bytes (the hand-built inline function), last modified 2026-05-26 |
|
||||
| Lambda role | `adr-intake-lambda-role` — `AWSLambdaBasicExecutionRole` plus an inline policy granting `dynamodb:PutItem` on the table and `ses:SendEmail`/`ses:SendRawEmail`. **Sufficient for the new handler; no change needed** |
|
||||
| Lambda invoke permission | one statement, `apigateway.amazonaws.com`, **`SourceArn` scoped to `…/4tl0m5igkj/*/*/submissions`** — the old route's path only |
|
||||
| HTTP API | one route, `POST /submissions` → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, the correct one for this handler). Stage `$default`, auto-deploy on, **no throttling**, no access log. CORS allows `POST` from the site origin |
|
||||
| DynamoDB | `ACTIVE`, on-demand, **partition key `submissionId` (S), no sort key**, 4 existing items, TTL **ENABLED** on attribute `ttl`, PITR **ENABLED** (35-day window), encryption at rest with the **AWS-owned** key (no CMK) |
|
||||
| CloudFront | one S3 origin with OAC `E13GAFUL6UQP6R`; default behaviour `Managed-CachingOptimized` + `Managed-SecurityHeadersPolicy`, methods HEAD/GET; **no cache behaviours**, **no custom error responses**, **no function associations** |
|
||||
| S3 | versioning `Enabled`; all four public-access blocks `true`; direct object GET on the S3 hostname returns 403, so OAC is in force |
|
||||
| SES | domain verified for sending, DKIM `SUCCESS` and signing enabled, no custom MAIL FROM |
|
||||
| SNS `ses-alerts` | email subscription to `info@smlcompany.ca` is **CONFIRMED** — it has a real subscription ARN, not `PendingConfirmation`. §7 recorded it as pending for six days after it stopped being pending; §12 R9 closes on this |
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Make S3 answer 404 instead of 403
|
||||
|
||||
**Why this is first.** Part 3 maps **404** to `/404.html` and deliberately does
|
||||
**not** map 403. With OAC and no `s3:ListBucket`, S3 answers a request for a
|
||||
missing key with `403 AccessDenied`, so a 404-only mapping would never fire and
|
||||
every bad URL would keep returning raw XML. Granting the CloudFront service
|
||||
principal `s3:ListBucket` changes that answer to `404 NoSuchKey`.
|
||||
|
||||
Mapping 403 as well would have been one command shorter and is the wrong trade:
|
||||
custom error responses are distribution-wide and cannot be scoped to one
|
||||
behaviour, so it would also convert a broken bucket policy or a broken OAC — a
|
||||
real outage on every URL at once — into a tidy "page not found", and it would
|
||||
turn the intake handler's `Origin` refusal into a 404 page. Keeping 403 meaning
|
||||
403 is worth one statement.
|
||||
|
||||
```bash
|
||||
aws s3api get-bucket-policy --bucket "$BUCKET" --query Policy --output text > /tmp/bucket-policy.before.json
|
||||
python3 -m json.tool /tmp/bucket-policy.before.json
|
||||
```
|
||||
|
||||
**Expect:** one statement, `AllowCloudFrontServicePrincipal`, `s3:GetObject` on
|
||||
`arn:aws:s3:::$BUCKET/*`, conditioned on the distribution ARN. Keep that file —
|
||||
Part 9 restores from it.
|
||||
|
||||
```bash
|
||||
python3 - "$BUCKET" "$ACCT" "$DIST_ID" <<'PY' > /tmp/bucket-policy.after.json
|
||||
import json, sys
|
||||
bucket, acct, dist = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
p = json.load(open('/tmp/bucket-policy.before.json'))
|
||||
arn = f'arn:aws:cloudfront::{acct}:distribution/{dist}'
|
||||
sid = 'AllowCloudFrontListForHonest404s'
|
||||
p['Statement'] = [s for s in p['Statement'] if s.get('Sid') != sid]
|
||||
p['Statement'].append({
|
||||
'Sid': sid,
|
||||
'Effect': 'Allow',
|
||||
'Principal': {'Service': 'cloudfront.amazonaws.com'},
|
||||
'Action': 's3:ListBucket',
|
||||
'Resource': f'arn:aws:s3:::{bucket}',
|
||||
'Condition': {'StringEquals': {'AWS:SourceArn': arn}},
|
||||
})
|
||||
json.dump(p, sys.stdout, indent=2)
|
||||
PY
|
||||
python3 -m json.tool /tmp/bucket-policy.after.json
|
||||
aws s3api put-bucket-policy --bucket "$BUCKET" --policy "file:///tmp/bucket-policy.after.json"
|
||||
```
|
||||
|
||||
**Verify — three things, and the third is the one worth doing:**
|
||||
|
||||
```bash
|
||||
aws s3api get-bucket-policy --bucket "$BUCKET" --query Policy --output text | python3 -m json.tool
|
||||
aws s3api get-public-access-block --bucket "$BUCKET" --query 'PublicAccessBlockConfiguration'
|
||||
curl -s -o /dev/null -w 'direct S3 object: %{http_code}\n' \
|
||||
"https://${BUCKET}.s3.${AWS_REGION}.amazonaws.com/index.html"
|
||||
aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
--query 'DistributionConfig.DefaultCacheBehavior.{Cache:CachePolicyId,OriginRequest:OriginRequestPolicyId}'
|
||||
aws cloudfront get-cache-policy --id 658327ea-f89d-4fab-a63d-7e88639e58f6 \
|
||||
--query 'CachePolicy.CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin.QueryStringsConfig'
|
||||
```
|
||||
|
||||
**Expect:** two statements; all four public-access blocks still `true`; direct S3
|
||||
`403`; `Cache` = `658327ea-…` (`Managed-CachingOptimized`) with
|
||||
**`OriginRequest: null`**; and `QueryStringsConfig` = **`{"QueryStringBehavior":
|
||||
"none"}"`**.
|
||||
|
||||
⚠️ **THOSE LAST TWO ARE THE CHECK, AND THE OBVIOUS ONE IS WORTHLESS.** The
|
||||
tempting verification is `curl "${SITE}/?list-type=2&max-keys=5"` and confirming
|
||||
it returns HTML rather than an XML `ListBucketResult` — **it cannot return
|
||||
anything else, so it is evidence of nothing.** The distribution has
|
||||
`DefaultRootObject: index.html` and, after Part 2, the router rewrites `/` to
|
||||
`/index.html`; the origin request is therefore a `GetObject` on a key, never a
|
||||
request for the bucket root, so `list-type=2` could not be read as a list
|
||||
operation whether or not query strings reached S3. An operator running it sees
|
||||
HTML and ticks a control that never ran, which is the Q22 shape exactly. Found by
|
||||
`adversarial-reviewer`, 2026-09-01.
|
||||
|
||||
**What actually makes the grant safe, and the two commands above assert both
|
||||
halves:** no viewer path resolves to the bucket root, and the default behaviour
|
||||
forwards **no query strings** to the origin — with no origin request policy
|
||||
attached to override that. If either assertion fails, revert this part (Part 9.1)
|
||||
and map 403 instead.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — The trailing-slash router function
|
||||
|
||||
Source and test are in the repo: `infra/cloudfront/router.js` and
|
||||
`router.test.mjs`. Read the header comment before publishing it — the reason it
|
||||
must not be associated with `/api/*` is in there, and it is the difference
|
||||
between a working form and a POST silently converted to a GET.
|
||||
|
||||
```bash
|
||||
node infra/cloudfront/router.test.mjs
|
||||
```
|
||||
|
||||
**Expect:** exit **0**. The case count is deliberately not quoted here — it has
|
||||
already gone 15 → 22 → 30 in one day, and an `Expect` line that never matches
|
||||
teaches the operator to stop reading `Expect` lines. The script asserts its own
|
||||
count internally (`case count != CASES.length`), so exit 0 is the whole check.
|
||||
|
||||
```bash
|
||||
aws cloudfront create-function \
|
||||
--name adr-sml-router \
|
||||
--function-config '{"Comment":"trailing-slash + index.html for the Astro directory build; docs/09","Runtime":"cloudfront-js-2.0"}' \
|
||||
--function-code "fileb://infra/cloudfront/router.js" \
|
||||
--query '{Name:FunctionSummary.FunctionMetadata.FunctionARN,Stage:FunctionSummary.FunctionMetadata.Stage,Status:FunctionSummary.Status}'
|
||||
```
|
||||
|
||||
**Expect:** an ARN, `Stage: DEVELOPMENT`, `Status: UNPUBLISHED`.
|
||||
|
||||
**Test it in the real runtime before publishing.** The local test proves the
|
||||
branches; this proves the runtime accepts the code, which the local test cannot.
|
||||
|
||||
```bash
|
||||
ETAG=$(aws cloudfront describe-function --name adr-sml-router --query ETag --output text)
|
||||
for URI in / /about/ /about /robots.txt /_astro/x.css /api/intake \
|
||||
//evil.example.com/x '/\evil.example.com/x' //about/ //robots.txt; do
|
||||
printf '{"version":"1.0","request":{"method":"GET","uri":"%s","querystring":{},"headers":{},"cookies":{}}}' "$URI" \
|
||||
> /tmp/cf-event.json
|
||||
printf '%-16s ' "$URI"
|
||||
aws cloudfront test-function --name adr-sml-router --if-match "$ETAG" --stage DEVELOPMENT \
|
||||
--event-object fileb:///tmp/cf-event.json \
|
||||
--query 'TestResult.{Out:FunctionOutput,Err:FunctionErrorMessage}' --output text
|
||||
done
|
||||
```
|
||||
|
||||
`fileb://` rather than a base64 argument: it hands the CLI raw bytes and sidesteps
|
||||
the question of whether the local `base64` wraps its output, which would make the
|
||||
argument invalid in a way the error message would not explain. `test-function`
|
||||
does not change the function, so one `ETAG` read serves the whole loop.
|
||||
|
||||
Then the query-string path, which none of the URIs above exercises:
|
||||
|
||||
```bash
|
||||
cat > /tmp/cf-event-qs.json <<'JSON'
|
||||
{"version":"1.0","request":{"method":"GET","uri":"/fees","headers":{},"cookies":{},
|
||||
"querystring":{"utm":{"value":"a%20b"},"q":{"value":"x|y"},"bad":{"value":"z\"<>#&k=v"}}}}
|
||||
JSON
|
||||
aws cloudfront test-function --name adr-sml-router --if-match "$ETAG" --stage DEVELOPMENT \
|
||||
--event-object fileb:///tmp/cf-event-qs.json \
|
||||
--query 'TestResult.{Out:FunctionOutput,Err:FunctionErrorMessage}' --output text
|
||||
```
|
||||
|
||||
**Expect,** in order for the ten URIs: `uri /index.html`; `uri
|
||||
/about/index.html`; a **301 to `/about/`**; `uri /robots.txt` unchanged; `uri
|
||||
/_astro/x.css` unchanged; `/api/intake` a **301 to `/api/intake/`**; and then
|
||||
**four 301s that all stay on this origin with a single leading slash** —
|
||||
`/evil.example.com/x/`, `/evil.example.com/x/`, `/about/`, `/robots.txt`.
|
||||
No `FunctionErrorMessage` on any of the ten.
|
||||
|
||||
From the query-string call: a **301 to
|
||||
`/fees/?utm=a%20b&q=x|y&bad=z&k=v`**. Three things are being checked there and
|
||||
each has been wrong once: `%20` survives (the values arrive percent-encoded, so
|
||||
re-encoding would produce `%2520`); `|` survives (it is not in WHATWG's query
|
||||
percent-encode set, so browsers send it raw, and one revision of the function
|
||||
stripped it and silently corrupted campaign links); and `"`, `<`, `>` and `#` are
|
||||
gone (`#` is the one that changes the *structure* of the header — left in, `&k=v`
|
||||
lands in a fragment and the parameter is lost).
|
||||
|
||||
⚠️ **THE FIRST VERSION OF THIS LOOP PASSED `"querystring":{}` ON EVERY CASE AND
|
||||
TRIED NEITHER REDIRECT PATH.** So in the only environment that runs the real
|
||||
`cloudfront-js-2.0` runtime — the environment this step exists to reach — none of
|
||||
the query-string handling and none of the normalisation was executed, while the
|
||||
prose called it "the authoritative check". Found by `adversarial-reviewer`
|
||||
round 2.
|
||||
|
||||
⚠️ **That last row is the reason this function must not be associated with the
|
||||
`/api/*` behaviour, and it is in the list so you see it rather than read about
|
||||
it.** A 301 turns a POST into a GET, so an intake submission routed through this
|
||||
function would arrive at the handler as a GET with no body — and the visitor would
|
||||
be told nothing was wrong. Part 3 associates the function with the **default**
|
||||
behaviour only; `configure.mjs` sets `FunctionAssociations: { Quantity: 0 }` on
|
||||
the `/api/*` behaviour explicitly rather than by omission.
|
||||
|
||||
```bash
|
||||
aws cloudfront publish-function --name adr-sml-router --if-match "$ETAG"
|
||||
export ROUTER_ARN=$(aws cloudfront describe-function --name adr-sml-router --stage LIVE \
|
||||
--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
|
||||
echo "ROUTER_ARN=$ROUTER_ARN"
|
||||
```
|
||||
|
||||
**Expect:** a non-empty ARN. It is empty if `publish-function` failed — read the
|
||||
status, not the absence of an error.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Apply the three distribution changes
|
||||
|
||||
One script, `infra/cloudfront/configure.mjs`, because the alternative is
|
||||
hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag.
|
||||
It is **dry-run by default**, **idempotent**, and it resolves both managed policy
|
||||
ids **by name from the account** rather than carrying them as literals.
|
||||
|
||||
```bash
|
||||
node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" \
|
||||
--function-arn "$ROUTER_ARN"
|
||||
```
|
||||
|
||||
**Expect** — this is the dry run, and the output on a distribution in the state
|
||||
Part 0.3 records is exactly:
|
||||
|
||||
```
|
||||
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
||||
resolved Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
|
||||
|
||||
4 change(s) to distribution E1OK7G98KNKUTA (ETag …):
|
||||
+ DefaultCacheBehavior.FunctionAssociations viewer-request -> arn:…:function/adr-sml-router
|
||||
+ CustomErrorResponses += 404 -> /404.html with status 404
|
||||
+ Origins += intake-api -> …execute-api… (https-only, TLSv1.2)
|
||||
+ CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed
|
||||
|
||||
DRY RUN — nothing was sent. Re-run with --apply to write it.
|
||||
```
|
||||
|
||||
Fewer than four changes means part of this is already done — read which lines are
|
||||
prefixed `·` (already present) and carry on. More than four, or a different set,
|
||||
means the distribution is not in the state 0.3 recorded: stop and re-read it.
|
||||
|
||||
```bash
|
||||
node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" \
|
||||
--function-arn "$ROUTER_ARN" --apply
|
||||
aws cloudfront wait distribution-deployed --id "$DIST_ID"
|
||||
echo "deployed: $?"
|
||||
```
|
||||
|
||||
**Verify the config landed:**
|
||||
|
||||
```bash
|
||||
aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
--query 'DistributionConfig.{Fn:DefaultCacheBehavior.FunctionAssociations.Items[].EventType,Err:CustomErrorResponses.Items[].{Code:ErrorCode,Page:ResponsePagePath,Status:ResponseCode},Beh:CacheBehaviors.Items[].{P:PathPattern,O:TargetOriginId,Methods:AllowedMethods.Items},Origins:Origins.Items[].Id}'
|
||||
```
|
||||
|
||||
**Expect:** `Fn: ["viewer-request"]`; one error response `404 → /404.html → 404`;
|
||||
one behaviour `/api/*` → `intake-api` with POST in its method list; two origins.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Verify the distribution before the site exists
|
||||
|
||||
Run this now, against the **old** bucket contents. It is the check that the
|
||||
plumbing works independently of the deploy.
|
||||
|
||||
```bash
|
||||
for p in / /index.html /about/ /nope-not-a-page/; do
|
||||
printf '%-22s ' "$p"
|
||||
curl -s -o /dev/null -w 'status=%{http_code} ct=%{content_type}\n' "${SITE}${p}"
|
||||
done
|
||||
printf 'no-slash redirect: '; curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' "${SITE}/about"
|
||||
```
|
||||
|
||||
**Expect:** `/` and `/index.html` → **200 text/html** (the old page still
|
||||
serves — the router did not break it); `/about/` and `/nope-not-a-page/` → **404**
|
||||
rather than the 403 they returned before, because Part 1 changed S3's answer and
|
||||
Part 3 mapped it; `/about` → **301 → `https://adr.smlcompany.ca/about/`**.
|
||||
|
||||
The body of those 404s is not the 404 page yet — `404.html` is not in the bucket
|
||||
until Part 8. **The status is what Part 4 proves; the page is what Part 8.4
|
||||
proves.** Do not tick `docs/06`'s "404 returns a 404 status" item here; tick it
|
||||
after 8.4, when both halves are true at once.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Deploy the handler
|
||||
|
||||
### 5.1 Package
|
||||
|
||||
```bash
|
||||
rm -f /tmp/intake.zip
|
||||
(cd backend/intake && zip -q -X /tmp/intake.zip handler.mjs fields.mjs)
|
||||
unzip -l /tmp/intake.zip
|
||||
```
|
||||
|
||||
**Expect:** exactly two entries, `handler.mjs` and `fields.mjs`, **≈ 25.7 KB
|
||||
uncompressed and ≈ 10.8 KB zipped** `[measured 2026-09-01]`. Both at the zip root —
|
||||
`handler.mjs` imports `./fields.mjs`, so a nested directory breaks the import at
|
||||
cold start.
|
||||
|
||||
### 5.2 Configuration first, code second
|
||||
|
||||
The handler calls `requireEnv()` at **module scope** and throws at cold start on
|
||||
any of six missing variables — deliberately, so a misconfigured function cannot
|
||||
accept a submission it will not store. Set the configuration before the code, so
|
||||
there is never a moment when the new code runs against no environment.
|
||||
|
||||
```bash
|
||||
ENV_JSON=$(node scripts/intake-env.mjs --table "$TABLE" --notify "$NOTIFY_TO" --from "$MAIL_FROM")
|
||||
echo "$ENV_JSON" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Expect** six variables. ⚠️ **`RESPONSE_TIME` and `NO_RETAINER_NOTICE` are read
|
||||
out of `src/data/site.ts` and must never be typed here.** Both are published
|
||||
commitments — the two-business-day response (§4, Q27) and the no-retainer notice
|
||||
`docs/01` §`/contact/` requires, **including its fourth clause about not itself
|
||||
creating a conflict check**, which a hand-typed copy inside the handler had
|
||||
already dropped once. The notice also contains an en dash in "mediator–party",
|
||||
which is exactly the character a retype loses. `scripts/intake-env.mjs` asserts
|
||||
both and exits non-zero rather than emitting a softened commitment.
|
||||
|
||||
```bash
|
||||
aws lambda update-function-configuration --function-name "$FN" \
|
||||
--handler handler.handler --timeout 15 --memory-size 512 --environment "$ENV_JSON"
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
aws lambda get-function-configuration --function-name "$FN" \
|
||||
--query '{Handler:Handler,Timeout:Timeout,Memory:MemorySize,Vars:sort(keys(Environment.Variables)),State:State,Update:LastUpdateStatus}'
|
||||
```
|
||||
|
||||
**Expect:** `Handler: handler.handler` — the file is `handler.mjs` and the export
|
||||
is `handler`, where the hand-built function was `index.handler`; `Timeout: 15`;
|
||||
`Memory: 512`; the six names sorted; `State: Active`; `Update: Successful`.
|
||||
|
||||
Timeout 10 → 15 s and memory 128 → 512 MB are changes, not restatements. The
|
||||
handler does a DynamoDB write and two SES sends per invocation, and 128 MB is
|
||||
where an AWS SDK cold start is slowest — more memory shortens the billed duration
|
||||
enough that it is usually the cheaper setting, not the dearer one.
|
||||
|
||||
### 5.3 Code
|
||||
|
||||
```bash
|
||||
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
aws lambda get-function-configuration --function-name "$FN" \
|
||||
--query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}'
|
||||
```
|
||||
|
||||
**Expect:** `CodeSize` **≈ 10,800** (up from 1,527), `Update: Successful`.
|
||||
⚠️ **`CodeSize` is the ZIP, not the source.** This line said "around 23,000",
|
||||
which was 5.1's uncompressed figure applied to a different quantity — an
|
||||
operator seeing `10819` against an expectation of 23,000 would reasonably
|
||||
conclude the wrong artefact went up.
|
||||
|
||||
### 5.4 Prove it loads, without writing anything
|
||||
|
||||
```bash
|
||||
cat > /tmp/probe-no-origin.json <<'JSON'
|
||||
{"version":"2.0","routeKey":"POST /api/intake","rawPath":"/api/intake",
|
||||
"headers":{"content-type":"application/x-www-form-urlencoded"},
|
||||
"requestContext":{"http":{"method":"POST","path":"/api/intake","sourceIp":"127.0.0.1"}},
|
||||
"body":"probe=1","isBase64Encoded":false}
|
||||
JSON
|
||||
aws lambda invoke --function-name "$FN" --cli-binary-format raw-in-base64-out \
|
||||
--payload file:///tmp/probe-no-origin.json /tmp/probe-out.json
|
||||
cat /tmp/probe-out.json; echo
|
||||
```
|
||||
|
||||
**Expect:** the invoke prints `"StatusCode": 200` with **no `FunctionError`**, and
|
||||
`/tmp/probe-out.json` is
|
||||
|
||||
```json
|
||||
{"statusCode":403,"headers":{"Cache-Control":"no-store"},"body":""}
|
||||
```
|
||||
|
||||
That 403 is the handler's `Origin` check refusing a request with neither `Origin`
|
||||
nor `Referer`. It happens **before** any DynamoDB write and before any email,
|
||||
which is what makes this probe safe to run against production.
|
||||
|
||||
**Two other outcomes, and each has one cause:**
|
||||
|
||||
- `"FunctionError": "Unhandled"` with `Runtime.ImportModuleError` in the body —
|
||||
the `nodejs24.x` runtime does not provide the AWS SDK v3 clients this handler
|
||||
imports. Run 5.5, then repeat 5.4.
|
||||
- A body naming `intake handler: <NAME> is not set` — that variable did not
|
||||
reach the function. Re-run 5.2 and read the `Vars` list.
|
||||
|
||||
### 5.5 Bundled variant — only if 5.4 said `Runtime.ImportModuleError`
|
||||
|
||||
Versions are resolved from the registry at run time rather than pinned in this
|
||||
file: `CLAUDE.md`'s rule is that a version is checked against the registry and
|
||||
never recalled, and a literal here would be stale the week after it was written.
|
||||
|
||||
```bash
|
||||
rm -rf /tmp/intake-bundle && mkdir -p /tmp/intake-bundle
|
||||
cp backend/intake/handler.mjs backend/intake/fields.mjs /tmp/intake-bundle/
|
||||
( cd /tmp/intake-bundle \
|
||||
&& npm init -y > /dev/null \
|
||||
&& npm install --omit=dev --no-audit --no-fund \
|
||||
"@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \
|
||||
"@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" )
|
||||
rm -f /tmp/intake.zip
|
||||
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip handler.mjs fields.mjs node_modules package.json )
|
||||
unzip -l /tmp/intake.zip | tail -1
|
||||
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
```
|
||||
|
||||
**Expect** a zip in the low single-digit megabytes, well under the 50 MB direct-
|
||||
upload limit. **If this path is taken, say so in the `AGENTS.md` Change Log and
|
||||
add both packages to §7** — they become pins this project maintains, and `R11`
|
||||
covers them from that moment.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Route the API at `/api/intake`
|
||||
|
||||
### 6.1 The invoke permission, before the route
|
||||
|
||||
⚠️ **THE EXISTING PERMISSION DOES NOT COVER THE NEW ROUTE, AND THIS IS THE STEP
|
||||
WHOSE OMISSION IS HARDEST TO DIAGNOSE.** Part 0.3 records the one statement on the
|
||||
function: `SourceArn` `…/4tl0m5igkj/*/*/submissions`. Add a route at
|
||||
`/api/intake` without adding a permission for it and API Gateway is refused
|
||||
permission to invoke — the caller gets a **500**, the Lambda logs nothing at all
|
||||
because it was never entered, and the only trace is an API Gateway metric.
|
||||
|
||||
```bash
|
||||
aws lambda add-permission --function-name "$FN" \
|
||||
--statement-id apigw-post-api-intake \
|
||||
--action lambda:InvokeFunction \
|
||||
--principal apigateway.amazonaws.com \
|
||||
--source-arn "arn:aws:execute-api:${AWS_REGION}:${ACCT}:${API_ID}/*/POST/api/intake"
|
||||
aws lambda get-policy --function-name "$FN" --query Policy --output text | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Expect:** two statements — the old one scoped to `…/submissions` and the new one
|
||||
to `…/POST/api/intake`.
|
||||
|
||||
### 6.2 The route
|
||||
|
||||
```bash
|
||||
export INTEG_ID=$(aws apigatewayv2 get-integrations --api-id "$API_ID" \
|
||||
--query "Items[?IntegrationUri=='arn:aws:lambda:${AWS_REGION}:${ACCT}:function:${FN}'].IntegrationId | [0]" \
|
||||
--output text)
|
||||
echo "INTEG_ID=$INTEG_ID"
|
||||
[ -n "$INTEG_ID" ] && [ "$INTEG_ID" != "None" ] || echo "STOP — no integration points at $FN"
|
||||
aws apigatewayv2 create-route --api-id "$API_ID" \
|
||||
--route-key 'POST /api/intake' --target "integrations/${INTEG_ID}" \
|
||||
--query '{RouteId:RouteId,RouteKey:RouteKey,Target:Target}'
|
||||
aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].{Key:RouteKey,Target:Target}'
|
||||
```
|
||||
|
||||
**Expect:** `INTEG_ID` non-empty and not `None`; then two routes,
|
||||
`POST /submissions` and `POST /api/intake`, both on the same integration. The
|
||||
stage is `$default` with auto-deploy on, so the route is live immediately — there
|
||||
is no deployment to create.
|
||||
|
||||
### 6.3 Throttling
|
||||
|
||||
⚠️ **THIS IS NOT THE PER-IP LIMIT `docs/05` ASKS FOR, AND IT CANNOT BE.** That
|
||||
spec says "Rate limit by source IP at API Gateway: 5 requests / 5 minutes".
|
||||
**API Gateway throttling is aggregate — per route and per stage — not per source
|
||||
IP.** Per-IP rate limiting needs AWS WAF with a rate-based rule on the
|
||||
distribution, which is a paid service and therefore Pouya's decision, recorded on
|
||||
`docs/06`'s checklist as explicitly **not** a launch blocker. What is set here is
|
||||
a real control over total volume; describing it as per-IP would be the Q22 shape
|
||||
again — a documented control that does not exist.
|
||||
|
||||
```bash
|
||||
aws apigatewayv2 update-stage --api-id "$API_ID" --stage-name '$default' \
|
||||
--route-settings '{"POST /api/intake":{"ThrottlingRateLimit":1,"ThrottlingBurstLimit":5,"DetailedMetricsEnabled":true}}'
|
||||
aws apigatewayv2 get-stage --api-id "$API_ID" --stage-name '$default' \
|
||||
--query 'RouteSettings'
|
||||
```
|
||||
|
||||
**Expect:** the `POST /api/intake` entry with rate 1/s, burst 5, detailed metrics
|
||||
on. One per second sustained with a burst of five is far above any human filling
|
||||
this form and far below anything worth paying for.
|
||||
|
||||
### 6.4 Retire the old route
|
||||
|
||||
Nothing posts to `/submissions` any more — the form posts to `/api/intake` and
|
||||
the handler that answered the old contract is gone. An unused public route on the
|
||||
same function is surface with no purpose.
|
||||
|
||||
```bash
|
||||
export OLD_ROUTE=$(aws apigatewayv2 get-routes --api-id "$API_ID" \
|
||||
--query "Items[?RouteKey=='POST /submissions'].RouteId | [0]" --output text)
|
||||
echo "OLD_ROUTE=$OLD_ROUTE"
|
||||
aws apigatewayv2 delete-route --api-id "$API_ID" --route-id "$OLD_ROUTE"
|
||||
aws lambda remove-permission --function-name "$FN" \
|
||||
--statement-id 70aeb597-e4d8-5141-8fb1-6b6190f7b2ab
|
||||
aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'
|
||||
aws lambda get-policy --function-name "$FN" --query Policy --output text | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Expect:** one route, `POST /api/intake`; one permission statement, scoped to it.
|
||||
Confirm the statement id in the `remove-permission` call against the policy you
|
||||
printed in 6.1 before running it — it is an id, not a name, and it will differ if
|
||||
the permission was ever rebuilt.
|
||||
|
||||
> **The `execute-api` hostname stays reachable**, so `/api/intake` can be reached
|
||||
> without passing through CloudFront. That is not a new weakness and not a
|
||||
> loose end: `DisableExecuteApiEndpoint` cannot be turned on, because the
|
||||
> distribution's own origin **is** that hostname. The `Origin` check is a CSRF
|
||||
> control rather than authentication either way, and 6.3's throttle applies at
|
||||
> the stage, so it covers both paths.
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — End to end, with a real submission
|
||||
|
||||
### 7.1 The route answers
|
||||
|
||||
```bash
|
||||
curl -si -X POST "${SITE}/api/intake" \
|
||||
-H 'Origin: https://adr.smlcompany.ca' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' | head -12
|
||||
```
|
||||
|
||||
**Expect:** `HTTP/2 303` and `location: https://adr.smlcompany.ca/contact/could-not-send/`.
|
||||
The handler accepted the request, validated it, found an empty submission and
|
||||
redirected to the failure page — **before** any write and any email. This is the
|
||||
same probe `scripts/deploy-local.sh` runs at the end of every deploy.
|
||||
|
||||
⚠️ **404 IS AMBIGUOUS AND THE FIRST DRAFT OF THIS LINE WAS NOT.** It means
|
||||
**either** the `/api/*` behaviour is missing (Part 3) **or** the `POST
|
||||
/api/intake` route is missing or misspelled (Part 6.2) — two different repairs.
|
||||
And because Part 3's custom error response is **distribution-wide**, a 404 from
|
||||
API Gateway is served as the styled `/404.html` body, so the one string that would
|
||||
have named the cause — API Gateway's `{"message":"Not Found"}` — is replaced before
|
||||
you see it. **Check the route first; it is one command:**
|
||||
`aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`.
|
||||
|
||||
**403** means the `Origin` header did not arrive — check that the behaviour uses
|
||||
`Managed-AllViewerExceptHostHeader`, because a policy that drops `Origin` turns
|
||||
every real submission into a 403. **500** means Part 6.1 was skipped.
|
||||
|
||||
### 7.2 A real submission, from the real form
|
||||
|
||||
Do this in a browser at `${SITE}/contact/` **after Part 8**, because the form
|
||||
does not exist until the site is deployed. Fill it as an inquirer would, with
|
||||
`Your name: Cutover test <date>` so the record is identifiable, and a real
|
||||
address you can read.
|
||||
|
||||
**Expect:** the browser lands on `/contact/received/`, and two emails arrive — the
|
||||
notification at `$NOTIFY_TO`, replyable to the address you entered, and the
|
||||
confirmation at that address. Read the confirmation and check three things: the
|
||||
response-time sentence, the **four**-clause no-retainer notice, and that the
|
||||
field summary uses readable labels ("Your name", "Subject matter") rather than
|
||||
field names.
|
||||
|
||||
Then find the record. The notification email prints `submissionId <uuid>` —
|
||||
that value **is** the partition key, so it can be used directly:
|
||||
|
||||
```bash
|
||||
export SUB_ID='<the uuid from the notification email>'
|
||||
aws dynamodb get-item --table-name "$TABLE" \
|
||||
--key "{\"submissionId\":{\"S\":\"${SUB_ID}\"}}" \
|
||||
--query 'Item.{id:submissionId.S,at:submittedAt.S,ttl:ttl.N,name:name.S,area:practiceArea.S,sourceIp:sourceIp.S}'
|
||||
curl -s https://checkip.amazonaws.com
|
||||
```
|
||||
|
||||
⚠️ **READ `sourceIp` AGAINST YOUR OWN ADDRESS — THIS IS THE ONLY PLACE THE PROXY
|
||||
CHAIN GETS MEASURED, AND IT HAS ALREADY BEEN REASONED ABOUT WRONGLY TWICE.** The
|
||||
handler stores `requestContext.http.sourceIp`, which is the TCP peer. Behind the
|
||||
`/api/*` behaviour that peer is a **CloudFront edge**, so the expectation is an
|
||||
AWS address, not yours. Three outcomes and each has a different consequence:
|
||||
|
||||
| what `sourceIp` holds | what it means |
|
||||
|---|---|
|
||||
| an **AWS** address (not the `checkip` value) | As designed. The field records the CDN, so it **cannot serve abuse investigation**, and `/legal/privacy/`'s *"your IP address"* is inaccurate — fold it into the Q62 edit on the same page rather than leaving two wrong sentences there |
|
||||
| **your** address, matching `checkip` | Better than expected, and worth knowing before anyone relies on it. Do not conclude it is trustworthy: verify it is not simply echoing a header by resubmitting with `-H 'X-Forwarded-For: 8.8.8.8'` and confirming `8.8.8.8` is **not** what lands |
|
||||
| `8.8.8.8` after that resubmission | **Stop.** The field is client-controlled and a record can be made to name an uninvolved third party. Revert to storing nothing rather than storing that |
|
||||
|
||||
⚠️ **AN EARLIER REVISION OF THE HANDLER READ `x-forwarded-for` HERE, AND THAT WAS
|
||||
THE THIRD OUTCOME.** CloudFront **appends** the viewer address to a
|
||||
client-supplied XFF rather than replacing it, so the leftmost entry is whatever
|
||||
the client sent. The fix, if a usable value is wanted, is a **custom** origin
|
||||
request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
|
||||
CloudFront generates and overwrites — not the managed
|
||||
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
|
||||
at API Gateway. That is an infrastructure change and it is deliberately not in
|
||||
this runbook: measure first.
|
||||
|
||||
**Expect** the item, with `ttl` a 10-digit epoch-seconds value. Check it is 24
|
||||
months out — read it, do not assume it:
|
||||
|
||||
```bash
|
||||
python3 - <<PY
|
||||
import datetime, os
|
||||
ttl = int("${SUB_ID}" and os.popen(
|
||||
'aws dynamodb get-item --table-name ${TABLE} '
|
||||
'--key \'{"submissionId":{"S":"${SUB_ID}"}}\' --query Item.ttl.N --output text'
|
||||
).read().strip())
|
||||
d = datetime.datetime.fromtimestamp(ttl, datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
months = (d.year - now.year) * 12 + (d.month - now.month)
|
||||
print(f'ttl {ttl} -> {d.isoformat()} ({months} months from now)')
|
||||
print('PASS' if months == 24 else 'FAIL — /legal/privacy/ promises 24 months')
|
||||
PY
|
||||
```
|
||||
|
||||
**Expect:** `24 months from now` and `PASS`. `/legal/privacy/` states the period
|
||||
publicly, so a number that is not 24 is a false disclosure rather than a config
|
||||
error.
|
||||
|
||||
⚠️ **DELETE THE TEST RECORD when you are done with it** — it is a real row in a
|
||||
table whose contents are governed by a published privacy policy.
|
||||
|
||||
```bash
|
||||
aws dynamodb delete-item --table-name "$TABLE" --key "{\"submissionId\":{\"S\":\"${SUB_ID}\"}}"
|
||||
```
|
||||
|
||||
### 7.3 The four pre-existing items
|
||||
|
||||
The table held **4 items** before any of this `[verified 2026-09-01 —
|
||||
describe-table ItemCount]`. They were written by the hand-built handler, whose
|
||||
code is in `docs/reference/AWS-Hosting-Guide.md` Part 8.3 and **writes no `ttl`
|
||||
attribute** — so DynamoDB will never expire them and they are retained
|
||||
indefinitely, against a policy that says otherwise.
|
||||
|
||||
**This is Pouya's call and not a command in this runbook**, because it depends on
|
||||
something no agent can determine: whether those four are the guide's own smoke
|
||||
test (`Test User`) or real inquiries that arrived through the old site. Read them
|
||||
in the console, then either delete the test rows or write a `ttl` onto the real
|
||||
ones. Tracked on `docs/06`'s checklist.
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — The first production deploy
|
||||
|
||||
⚠️ **DO NOT RUN 8.3 UNTIL POUYA HAS READ EVERY PAGE.** `docs/06`'s Content and
|
||||
compliance group — the `claims-auditor` pass and his own page-by-page read — is
|
||||
the gate on this part, and it is not a technical check.
|
||||
|
||||
### 8.1 Archive what is there
|
||||
|
||||
S3 versioning is `Enabled`, so pass 3's `--delete` is recoverable — but
|
||||
`docs/06`'s post-cutover list says to archive the old build rather than rely on
|
||||
that, and a local copy costs one command.
|
||||
|
||||
```bash
|
||||
mkdir -p _archive/pre-cutover-$(date +%F)
|
||||
aws s3 sync "s3://${BUCKET}" "_archive/pre-cutover-$(date +%F)/" --no-progress
|
||||
ls -la "_archive/pre-cutover-$(date +%F)/"
|
||||
find "_archive/pre-cutover-$(date +%F)" -type f | wc -l
|
||||
```
|
||||
|
||||
**Expect:** 3 files — `index.html` at 2,206,032 bytes and two PNGs under
|
||||
`assets/`. Do not commit them; `docs/06` wants them kept, not versioned.
|
||||
|
||||
### 8.2 Dry run — the three passes, in order, writing nothing
|
||||
|
||||
```bash
|
||||
npm run check && npm run build && npm run check:claims && npm run og:proof && npm run check:intake
|
||||
echo "gates exit=$?"
|
||||
npm run lighthouse; echo "lighthouse exit=$?"
|
||||
```
|
||||
|
||||
**Expect:** every one exit 0. `lighthouse` is local-only and cannot run in CI; it
|
||||
is a keyboard gate and this is the keyboard.
|
||||
|
||||
```bash
|
||||
aws s3 sync ./dist "s3://${BUCKET}" --dryrun \
|
||||
--exclude "*" --include "_astro/*" --include "fonts/*" \
|
||||
--cache-control "public, max-age=31536000, immutable" --no-progress | tail -5
|
||||
aws s3 sync ./dist "s3://${BUCKET}" --dryrun \
|
||||
--exclude "*" --include "*.avif" --include "*.webp" --include "*.jpg" \
|
||||
--include "*.png" --include "*.svg" \
|
||||
--cache-control "public, max-age=604800" --no-progress | tail -5
|
||||
aws s3 sync ./dist "s3://${BUCKET}" --dryrun \
|
||||
--exclude "_astro/*" --exclude "fonts/*" \
|
||||
--cache-control "public, max-age=0, must-revalidate" --delete --no-progress | tail -8
|
||||
```
|
||||
|
||||
**Expect,** on the bucket state Part 0.3 records — measured 2026-09-01, so these
|
||||
are counts rather than shapes: **51** `(dryrun) upload:` lines in pass 1, **56**
|
||||
in pass 2, and **52** lines in pass 3 of which **two are
|
||||
`(dryrun) delete:`** — `assets/sml-logo-full.png` and `assets/sml-logo-mark.png`,
|
||||
the old site's two logos. `404.html` appears in pass 3. `index.html` is not
|
||||
deleted; it is overwritten.
|
||||
|
||||
⚠️ **PASS 2's DRY RUN IS A SUPERSET OF WHAT PASS 2 ACTUALLY UPLOADS, AND THE REAL
|
||||
RUN WILL PRINT FEWER LINES.** 34 of those 56 are files under `_astro/`, which
|
||||
pass 1 uploads first. Each `--dryrun` above is compared against the bucket **as it
|
||||
is now**, so it cannot see the uploads the preceding pass would have made; in the
|
||||
real sequential run `aws s3 sync` skips them as already in sync. That skipping is
|
||||
exactly what preserves pass 1's `immutable` header on those files rather than
|
||||
overwriting it with pass 2's week-long one — `docs/06` §Cache policy says the same
|
||||
thing about pass 3, and this is why the pass order is load-bearing. **Do not
|
||||
reorder the passes to make this output tidier, and do not read the smaller real
|
||||
counts as a failed upload.**
|
||||
|
||||
⚠️ **Check the largest file in `./dist` while you are here.** The deploy user has
|
||||
no `s3:AbortMultipartUpload`, which is safe only while nothing approaches
|
||||
`aws s3 sync`'s 8 MB multipart threshold (`docs/06`).
|
||||
|
||||
```bash
|
||||
find ./dist -type f -exec stat -f '%z %N' {} + | sort -rn | head -3
|
||||
```
|
||||
|
||||
**Expect** the largest well under 8,388,608 bytes — it was **171,876** on
|
||||
2026-09-01. GNU `find -printf` is not available here; this is the BSD/macOS form,
|
||||
and writing it as `find -printf … || find -exec …` would not have fallen back,
|
||||
because the exit status of that pipeline belongs to `head`.
|
||||
|
||||
### 8.3 The deploy — on Pouya's word only
|
||||
|
||||
⚠️ **NOT AS `user/pouya`.** The script refuses that identity and §10 is why: the
|
||||
account is shared with unrelated production systems. Use the `adr-sml-deploy`
|
||||
key, which §7 records as provisioned.
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… \
|
||||
AWS_REGION="$AWS_REGION" S3_BUCKET="$BUCKET" CLOUDFRONT_DISTRIBUTION_ID="$DIST_ID" \
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
The script runs `npm run check`, the build, `check:claims`, the three passes, the
|
||||
`/*` invalidation, and then the intake route check. **Read its last line.** It
|
||||
warns rather than fails on a bad intake route, because by then the site is
|
||||
already published and failing the job would not un-publish it.
|
||||
|
||||
```bash
|
||||
git tag "v$(date +%Y).1" && git push --tags
|
||||
```
|
||||
|
||||
### 8.4 Verify the site, not the deploy
|
||||
|
||||
```bash
|
||||
for p in / /about/ /mediation/ /arbitration/ /med-arb/ /practice/ /practice/construction/ \
|
||||
/process/ /for-parties/ /fees/ /insights/ /contact/ /bio/ /legal/privacy/ \
|
||||
/legal/terms/ /robots.txt /sitemap-index.xml /pouya-lajevardi-bio.pdf; do
|
||||
printf '%-32s ' "$p"
|
||||
curl -s -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' "${SITE}${p}"
|
||||
done
|
||||
printf '%-32s ' "404 body"; curl -s "${SITE}/nope-not-a-page/" -o /tmp/404.html -w 'status=%{http_code}\n'
|
||||
grep -c 'That page is not here' /tmp/404.html
|
||||
printf '%-32s ' "cache header on HTML"; curl -sI "${SITE}/about/" | grep -i '^cache-control'
|
||||
printf '%-32s ' "cache header on asset"
|
||||
curl -s "${SITE}/about/" | grep -o '/_astro/[^"]*\.css' | head -1 \
|
||||
| xargs -I{} curl -sI "${SITE}{}" | grep -i '^cache-control'
|
||||
```
|
||||
|
||||
**Expect:** every page 200 with real bytes; the 404 path **404** *and* the phrase
|
||||
`That page is not here` found once; `max-age=0, must-revalidate` on HTML;
|
||||
`max-age=31536000, immutable` on the hashed CSS. Only now is `docs/06`'s "404
|
||||
returns a 404 status" item true on both halves.
|
||||
|
||||
---
|
||||
|
||||
## Part 9 — Rollback, per part
|
||||
|
||||
Each of these is independent. None of them needs the others undone first.
|
||||
|
||||
**9.1 Part 1** — `aws s3api put-bucket-policy --bucket "$BUCKET" --policy "file:///tmp/bucket-policy.before.json"`.
|
||||
Missing keys go back to 403 and the 404 mapping stops firing; nothing else changes.
|
||||
|
||||
**9.2 Parts 2–3** — re-run `configure.mjs` is *not* a rollback; it is idempotent
|
||||
forward-only. To undo, `get-distribution-config`, remove the
|
||||
`FunctionAssociations` entry / the `404` custom error response / the `/api/*`
|
||||
behaviour and the `intake-api` origin, and `update-distribution --if-match`. Then
|
||||
`aws cloudfront delete-function --name adr-sml-router --if-match <etag>`, which
|
||||
fails while the function is still associated — that ordering is a feature.
|
||||
|
||||
**9.3 Part 5** — the previous code is not recoverable from Lambda: the hand-built
|
||||
function was edited in the console and its 1,527 bytes exist only in
|
||||
`docs/reference/AWS-Hosting-Guide.md` Part 8.3. That is the rollback source, and
|
||||
it answers a different contract (JSON in, `{"ok":true}` out) that the current
|
||||
`/contact/` form does not speak. **Forward is the only real fix here** — which is
|
||||
worth knowing before 5.3, not after.
|
||||
|
||||
**9.4 Part 6** — `delete-route` the new route, `remove-permission` the new
|
||||
statement, and re-add the `/submissions` route and its permission if anything
|
||||
still needs it. Nothing does.
|
||||
|
||||
**9.5 Part 8** — S3 versioning is `Enabled`. Restore the previous version of
|
||||
`index.html`, `aws s3 rm` what the new build added, invalidate `/*`. Or
|
||||
`git revert` and re-deploy, which is cleaner and is what the tag in 8.3 is for.
|
||||
|
||||
---
|
||||
|
||||
## Part 10 — Q60: prove TTL deletes, not just that it is enabled
|
||||
|
||||
`AGENTS.md` §7 records `TimeToLiveStatus: ENABLED` on attribute `ttl`
|
||||
`[re-verified 2026-09-01]`, and **that proves the setting and not the behaviour**.
|
||||
`/legal/privacy/` tells the public that a record is *"deleted automatically by the
|
||||
database rather than by someone remembering to do it"* — an assertion about the
|
||||
mechanism. Q60 closes when a record has been watched to disappear, and not
|
||||
before. §12 R19 keeps it surfacing until then.
|
||||
|
||||
### 10.1 Write one record with a near-future `ttl`
|
||||
|
||||
Not through the form: the handler writes a 24-month `ttl` and waiting two years
|
||||
is not a test. Write it directly, with a shape that cannot be mistaken for an
|
||||
inquiry.
|
||||
|
||||
```bash
|
||||
export TTL_ID="q60-ttl-probe-$(date +%Y%m%dT%H%M%SZ)"
|
||||
export TTL_AT=$(python3 -c 'import time; print(int(time.time()) + 300)')
|
||||
python3 -c "import datetime,os; print('ttl', os.environ['TTL_AT'], '->', datetime.datetime.fromtimestamp(int(os.environ['TTL_AT']), datetime.timezone.utc).isoformat())"
|
||||
aws dynamodb put-item --table-name "$TABLE" --item "$(cat <<JSON
|
||||
{"submissionId":{"S":"${TTL_ID}"},
|
||||
"submittedAt":{"S":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"},
|
||||
"ttl":{"N":"${TTL_AT}"},
|
||||
"note":{"S":"AGENTS.md Q60 — TTL behaviour probe, no personal information, safe to delete"}}
|
||||
JSON
|
||||
)"
|
||||
aws dynamodb get-item --table-name "$TABLE" --key "{\"submissionId\":{\"S\":\"${TTL_ID}\"}}" \
|
||||
--query 'Item.{id:submissionId.S,ttl:ttl.N,note:note.S}'
|
||||
echo "$TTL_ID" > ~/q60-ttl-probe-id.txt
|
||||
echo "$TTL_AT" >> ~/q60-ttl-probe-id.txt
|
||||
```
|
||||
|
||||
**Expect:** the item, with `ttl` five minutes in the future. The id is written to
|
||||
a file because the check below happens on a different day and this is the only
|
||||
thing that connects the two.
|
||||
|
||||
### 10.2 The check, and it is not five minutes later
|
||||
|
||||
⚠️ **DYNAMODB'S TTL SWEEPER IS NOT PROMPT AND AWS DOES NOT PROMISE THAT IT IS.**
|
||||
Deletion typically happens within a couple of days of the timestamp passing, and
|
||||
**up to 48 hours or more is normal and not a fault.** A check run at `ttl + 5
|
||||
minutes` that finds the item present has measured nothing — it is the same
|
||||
mistake as reading a value before the transition finishes. So:
|
||||
|
||||
- **Earliest useful check: 48 hours after `TTL_AT`.**
|
||||
- **Do not conclude a failure before 7 days.**
|
||||
|
||||
```bash
|
||||
export TTL_ID=$(sed -n 1p ~/q60-ttl-probe-id.txt)
|
||||
export TTL_AT=$(sed -n 2p ~/q60-ttl-probe-id.txt)
|
||||
python3 -c "import time,os; d=time.time()-int(os.environ['TTL_AT']); print(f'{d/3600:.1f} hours since the ttl passed')"
|
||||
aws dynamodb get-item --table-name "$TABLE" \
|
||||
--key "{\"submissionId\":{\"S\":\"${TTL_ID}\"}}" --output json
|
||||
echo "get-item exit=$?"
|
||||
```
|
||||
|
||||
**Read both the output and the exit status.** `get-item` on a missing key exits
|
||||
**0** with an **empty response body** — it is not an error, and a script that only
|
||||
checked the exit status would report the record present and absent identically.
|
||||
|
||||
| result | meaning |
|
||||
|---|---|
|
||||
| `{"Item": {…}}`, under 48 h since `TTL_AT` | inconclusive. Wait. |
|
||||
| `{}` (empty), any time after `TTL_AT` | **Q60 closes.** The mechanism works. |
|
||||
| `{"Item": {…}}`, more than 7 days after | **Q60 fails.** TTL is enabled and not deleting. `/legal/privacy/` is then making a claim the database does not honour, and the page has to change or the mechanism does. |
|
||||
|
||||
### 10.3 Record it
|
||||
|
||||
On the `{}` result, and only then:
|
||||
|
||||
- Stamp §7's `Intake table` row: the behaviour is **observed**, with the date and
|
||||
the elapsed time, alongside the `ENABLED` reading it already carries.
|
||||
- Close **Q60** in §9, quoting the two timestamps.
|
||||
- Retire **R19** from §12 — its whole purpose was to keep this surfacing.
|
||||
- Tick the TTL item on `docs/06`'s checklist. It has two halves and this is the
|
||||
second: `ENABLED` proved the setting, this proves the behaviour.
|
||||
- Delete the probe row if it somehow survives, and delete
|
||||
`~/q60-ttl-probe-id.txt`.
|
||||
|
||||
If it fails, that is a **published-disclosure defect**, not a backlog item:
|
||||
`src/pages/legal/privacy.astro` carries the matching `TODO(pouya)` and the page
|
||||
asserts the mechanism in as many words.
|
||||
@@ -113,7 +113,10 @@ Administer the Arbitration, Request for the appointment of an arbitrator,
|
||||
Application for Urgent Interim Measures, Application to Challenge an Arbitrator,
|
||||
Notice of Appeal.
|
||||
|
||||
**ADRIC Med-Arb Rules.** A discussion draft was presented to the membership at
|
||||
**ADRIC Med-Arb Rules.** Developed by a **Task Force** — *"a Task Force was
|
||||
formed with a dedicated working group of med-arb professionals. The Task Force
|
||||
completed an initial draft of the Rules, which were then referred to the Rules
|
||||
Committee"* — and a discussion draft was presented to the membership at
|
||||
ADRIC's Annual Conference in **November 2019**. Two sentences are directly
|
||||
useful to `/med-arb/`, both verbatim:
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Who can read `adr-intake-submissions` — verification extract
|
||||
|
||||
**Why this file exists.** `/legal/privacy/` makes a statement to the public about
|
||||
who can see the contents of the intake table. `AGENTS.md` §4 admits no factual
|
||||
claim that cannot be traced, and `CLAUDE.md`'s R14 rule is that anything a spec
|
||||
claims about must be reachable from the repository — *"if the artefact lives only
|
||||
in a console, no reviewer can compare the claim against it and the claim is
|
||||
unverifiable by construction."* Until this file existed, that sentence was the
|
||||
one claim on the site whose subject was entirely outside the repo.
|
||||
|
||||
Raised by `claims-auditor` in the D20 cutover audit, 2026-09-01, finding 8.
|
||||
|
||||
**Provenance.** Every figure below was read from AWS on **2026-09-01** with the
|
||||
commands listed at the end, run read-only as `arn:aws:iam::327082975128:user/pouya`.
|
||||
No command in this file creates or changes anything. Re-run them rather than
|
||||
trusting this file; it is dated for that reason.
|
||||
|
||||
---
|
||||
|
||||
## The claim being checked
|
||||
|
||||
`src/pages/legal/privacy.astro`, §Who can see it:
|
||||
|
||||
> The table is reachable by the function that writes to it and by one
|
||||
> administrative account, which is mine — nobody else has access to the table.
|
||||
> There is no team, no assistant and no external administrator.
|
||||
|
||||
## The finding: it is false
|
||||
|
||||
The account has an IAM group **`admins`** carrying the AWS managed policy
|
||||
**`AdministratorAccess`**, and it has **two members: `pouya` and `lars`.**
|
||||
|
||||
`iam simulate-principal-policy` for `dynamodb:GetItem`, `dynamodb:Query` and
|
||||
`dynamodb:Scan` against
|
||||
`arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions`, across
|
||||
all five IAM users in the account:
|
||||
|
||||
| principal | GetItem | Query | Scan |
|
||||
|---|---|---|---|
|
||||
| `user/pouya` | **allowed** | **allowed** | **allowed** |
|
||||
| `user/lars` | **allowed** | **allowed** | **allowed** |
|
||||
| `user/adr-sml-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny | implicitDeny |
|
||||
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||
|
||||
`lars` holds exactly the access `pouya` holds, by the same route: membership of
|
||||
`admins`. The user's own attachments are only `IAMUserChangePassword`, so the
|
||||
group is the whole of it.
|
||||
|
||||
So the published sentence is wrong on both of its halves — a second account has
|
||||
access, and it belongs to a second administrator of a shared account.
|
||||
|
||||
## The rest of the surface, recorded so the check is complete rather than partial
|
||||
|
||||
- **5 IAM users**: `adr-sml-deploy`, `gitea-deploy-meshkinilaw`, `lars`,
|
||||
`meshkini-backend-deploy`, `pouya`. The three deploy users are all
|
||||
`implicitDeny` above. `adr-sml-deploy`'s scope is S3 + CloudFront and touches
|
||||
no table (`docs/reference/deploy-credential-verification.md`).
|
||||
- **1 IAM group**: `admins` — `AdministratorAccess` and `Billing`, two members.
|
||||
- **33 IAM roles**, 26 of them not service-linked. Two carry
|
||||
`AdministratorAccess`:
|
||||
`cdk-hnb659fds-cfn-exec-role-327082975128-ca-central-1` and
|
||||
`…-us-east-1`. These are **AWS CDK bootstrap CloudFormation execution roles**,
|
||||
assumable by CloudFormation for stack deployment. They are a real path to the
|
||||
table for anyone who can deploy a CDK stack in this account — which is the two
|
||||
administrators above — rather than a third party.
|
||||
- **`adr-intake-lambda-role`** is the writing principal: `dynamodb:PutItem` on
|
||||
this table, `ses:SendEmail`/`SendRawEmail`, plus
|
||||
`AWSLambdaBasicExecutionRole`. **`PutItem` only — it cannot read the table**,
|
||||
which is worth stating because it is a stronger fact than the page currently
|
||||
claims and it is the part of the sentence that is true.
|
||||
- **No resource-based policy on the table.** DynamoDB supports one; this table
|
||||
has none, so access is governed entirely by identity policies.
|
||||
- The account is **not single-project** (`AGENTS.md` §10). `lars` and the two
|
||||
`meshkini*`/`gitea*` users are evidence of that on the IAM surface, not just
|
||||
in the S3 bucket listing §10 describes.
|
||||
|
||||
## What has to happen before `/legal/privacy/` goes public
|
||||
|
||||
Tracked as `AGENTS.md` §9 **Q62**. It is one of two things and both are Pouya's:
|
||||
|
||||
1. **Remove the access** — take `lars` out of `admins`, or replace that
|
||||
membership with a policy that denies DynamoDB on this table — and then this
|
||||
sentence becomes true. Note the likely collision: `AGENTS.md` Q23 records the
|
||||
Gitea instance as *jointly administered* and blocked on *"its second
|
||||
administrator"*, so this account is probably not the only thing that access
|
||||
is for.
|
||||
2. **Correct the sentence** to what is true. It is a privacy policy, so the
|
||||
honest version is short and specific — the number of people with
|
||||
administrative access, and that the function that writes cannot read.
|
||||
|
||||
**Do not resolve it by softening.** "Access is limited to authorised
|
||||
administrators" is the shape §4 exists to bar: defensible, uninformative, and it
|
||||
would replace a false specific with a true vacancy on the one page where a reader
|
||||
is entitled to the specific.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
Run as `user/pouya`, `ca-central-1`, all read-only. Exit status read on each; no
|
||||
stderr suppressed anywhere.
|
||||
|
||||
```bash
|
||||
aws iam list-users --query 'Users[].UserName'
|
||||
aws iam list-groups --query 'Groups[].GroupName'
|
||||
aws iam get-group --group-name admins --query 'Users[].UserName'
|
||||
aws iam list-attached-group-policies --group-name admins --query 'AttachedPolicies[].PolicyName'
|
||||
aws iam list-group-policies --group-name admins --query 'PolicyNames'
|
||||
aws iam list-groups-for-user --user-name lars --query 'Groups[].GroupName'
|
||||
aws iam list-attached-user-policies --user-name lars --query 'AttachedPolicies[].PolicyName'
|
||||
aws iam list-user-policies --user-name lars --query 'PolicyNames'
|
||||
|
||||
TARN="arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions"
|
||||
for U in pouya lars adr-sml-deploy gitea-deploy-meshkinilaw meshkini-backend-deploy; do
|
||||
aws iam simulate-principal-policy \
|
||||
--policy-source-arn "arn:aws:iam::327082975128:user/${U}" \
|
||||
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
|
||||
--resource-arns "$TARN" \
|
||||
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
|
||||
done
|
||||
|
||||
# Roles: 33 total, 26 non-service-linked; screened for broad policies.
|
||||
for R in $(aws iam list-roles --query 'Roles[].RoleName' --output text \
|
||||
| tr '\t' '\n' | grep -v '^AWSServiceRole'); do
|
||||
aws iam list-attached-role-policies --role-name "$R" \
|
||||
--query 'AttachedPolicies[].PolicyName' --output text
|
||||
done
|
||||
|
||||
aws iam get-role-policy --role-name adr-intake-lambda-role \
|
||||
--policy-name adr-intake-lambda-inline --query PolicyDocument
|
||||
aws iam list-attached-role-policies --role-name adr-intake-lambda-role
|
||||
```
|
||||
|
||||
⚠️ **`simulate-principal-policy` was the tool that produced a false negative on
|
||||
this project once already** — `AGENTS.md` Q22, where eight checks returned empty
|
||||
because `2>/dev/null` was hiding an `InvalidInput` error caused by a zsh
|
||||
parameter-expansion bug (`$ACCT:user/` parses `:u` as a history modifier). The
|
||||
loop above brace-quotes `${U}` for that reason, prints one line per principal so
|
||||
a silently-skipped iteration is visible as a missing row, and suppresses nothing.
|
||||
**Five rows, or the run did not happen.**
|
||||
@@ -106,7 +106,15 @@ the dispute."*
|
||||
|
||||
**Consequence:** the neutral in the LAT's pre-hearing step is a **Member /
|
||||
adjudicator of the Tribunal**. It is directed by the Tribunal, attendance is
|
||||
mandatory, and the Member is disqualified from the subsequent hearing panel. A
|
||||
mandatory, and the Member does not sit on the subsequent hearing panel **except
|
||||
with the consent of the parties** (Rule 14.3, quoted verbatim above). ⚠️ **This
|
||||
line read "the Member is disqualified from the subsequent hearing panel" until
|
||||
2026-09-01** — an absolute, thirty lines below the quotation that qualifies it,
|
||||
in this repository's own voice rather than the Tribunal's. `/practice/insurance/`
|
||||
took the absolute from here and published it. Corrected in both places on the
|
||||
same day; the page was corrected first and this file is where the defect would
|
||||
otherwise have re-seeded, which is `CLAUDE.md`'s point about commentary around a
|
||||
quotation being this repository speaking. A
|
||||
privately retained neutral is not appointed to it and cannot be.
|
||||
|
||||
## Finding 2 — the LAT Rules never use the words "mediation", "mediator" or "arbitration"
|
||||
|
||||
@@ -80,4 +80,24 @@ export default [
|
||||
files: ['scripts/**/*.{js,mjs}'],
|
||||
rules: { 'no-console': 'off' },
|
||||
},
|
||||
|
||||
/* `infra/cloudfront/` IS NOT A NODE MODULE AND NOT A BROWSER SCRIPT. A
|
||||
CloudFront Function's entry point is a bare `function handler(event)` that
|
||||
the runtime calls **by name** — it has no `export` (the runtime rejects
|
||||
module syntax) and nothing in the file references it, so
|
||||
`no-unused-vars` fires on the one declaration that is the whole point of
|
||||
the file. `argsIgnorePattern` cannot reach a function declaration, so the
|
||||
rule is scoped off here rather than silenced with a comment at the
|
||||
declaration, which would read as though the name were incidental.
|
||||
The test beside it is a CLI tool and prints, exactly as `scripts/` does.
|
||||
|
||||
⚠️ LIKE THE BLOCK ABOVE, THIS MUST STAY LAST. Flat config applies matching
|
||||
blocks in order and the last one wins. */
|
||||
{
|
||||
files: ['infra/cloudfront/**/*.{js,mjs}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Applies the three distribution changes the site needs, as one reviewable
|
||||
* transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it.
|
||||
*
|
||||
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
|
||||
* request. Without it 22 of 23 pages return S3's AccessDenied XML.
|
||||
* 2. CustomErrorResponses: 404 -> /404.html with response code 404.
|
||||
* `docs/04` requires a genuine 404 status; `docs/06` calls a 200 here
|
||||
* "the single most common misconfiguration in this stack".
|
||||
* 3. A `/api/*` cache behaviour on a new origin pointing at the HTTP API, so
|
||||
* the intake form's same-origin POST reaches the handler.
|
||||
*
|
||||
* ⚠️ DRY RUN BY DEFAULT. It prints what it would change and exits 0 without
|
||||
* calling `update-distribution`. `--apply` is the only thing that writes, and it
|
||||
* sends the `IfMatch` ETag it read, so a concurrent console edit fails the call
|
||||
* rather than being overwritten.
|
||||
*
|
||||
* ⚠️ IDEMPOTENT ON PURPOSE. Every change is checked for before it is made, so a
|
||||
* re-run after a partial failure completes the rest instead of adding a second
|
||||
* `/api/*` behaviour. Re-running a runbook step is the normal case, not the
|
||||
* exception.
|
||||
*
|
||||
* ⚠️ NO MANAGED POLICY ID IS WRITTEN IN THIS FILE. They are resolved by name
|
||||
* from the account at run time — `CLAUDE.md`'s rule that a pin is verified
|
||||
* against the registry rather than recalled applies to an AWS identifier just
|
||||
* as much as to an npm version, and a wrong cache-policy id here would ship a
|
||||
* cached POST endpoint.
|
||||
*
|
||||
* usage:
|
||||
* node infra/cloudfront/configure.mjs --dist <id> --api-domain <host> [--function-arn <arn>]
|
||||
* node infra/cloudfront/configure.mjs ... --apply
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : args[i + 1];
|
||||
};
|
||||
const APPLY = args.includes('--apply');
|
||||
|
||||
const DIST = flag('dist');
|
||||
const API_DOMAIN = flag('api-domain');
|
||||
const FUNCTION_ARN = flag('function-arn');
|
||||
|
||||
if (!DIST || !API_DOMAIN) {
|
||||
console.error(
|
||||
'usage: node infra/cloudfront/configure.mjs --dist <distribution-id> ' +
|
||||
'--api-domain <api-id>.execute-api.<region>.amazonaws.com ' +
|
||||
'[--function-arn <router-function-arn>] [--apply]',
|
||||
);
|
||||
console.error('Values come from AGENTS.md §7.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (/^https?:/.test(API_DOMAIN) || API_DOMAIN.includes('/')) {
|
||||
console.error(
|
||||
`--api-domain must be a bare hostname, not a URL: got ${API_DOMAIN}`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* stderr is NEVER suppressed and the exit status is always read — the AWS CLI
|
||||
reports an expired session, a missing permission and a typo'd id all on
|
||||
stderr with a non-zero status, and swallowing that is how "it failed" becomes
|
||||
"it found nothing" (CLAUDE.md, from AGENTS.md Q22). */
|
||||
const aws = (argv) => {
|
||||
const out = execFileSync('aws', argv, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return out.trim() === '' ? null : JSON.parse(out);
|
||||
};
|
||||
|
||||
const ORIGIN_ID = 'intake-api';
|
||||
const PATH_PATTERN = '/api/*';
|
||||
const ERROR_PAGE = '/404.html';
|
||||
|
||||
function managedId(kind, name) {
|
||||
const listCmd = {
|
||||
cache: ['list-cache-policies', 'CachePolicyList', 'CachePolicy'],
|
||||
origreq: [
|
||||
'list-origin-request-policies',
|
||||
'OriginRequestPolicyList',
|
||||
'OriginRequestPolicy',
|
||||
],
|
||||
}[kind];
|
||||
const res = aws([
|
||||
'cloudfront',
|
||||
listCmd[0],
|
||||
'--type',
|
||||
'managed',
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
const items = res?.[listCmd[1]]?.Items ?? [];
|
||||
const hit = items.find(
|
||||
(i) => i[listCmd[2]][`${listCmd[2]}Config`].Name === name,
|
||||
);
|
||||
if (!hit) {
|
||||
throw new Error(
|
||||
`no managed ${kind} policy named ${name} — ${items.length} listed. ` +
|
||||
'Do not substitute an id from memory.',
|
||||
);
|
||||
}
|
||||
return hit[listCmd[2]].Id;
|
||||
}
|
||||
|
||||
const cachingDisabled = managedId('cache', 'Managed-CachingDisabled');
|
||||
/* AllViewerExceptHostHeader, and the exception is the whole reason: API Gateway
|
||||
routes on the Host header, so forwarding the viewer's `adr.smlcompany.ca`
|
||||
makes every request a 403 from the API. It forwards everything else, which is
|
||||
what carries `Origin` and `Referer` — the handler's CSRF control reads both,
|
||||
so a policy that dropped them would turn every real submission into a 403. */
|
||||
const allViewerExceptHost = managedId(
|
||||
'origreq',
|
||||
'Managed-AllViewerExceptHostHeader',
|
||||
);
|
||||
console.log(`resolved Managed-CachingDisabled = ${cachingDisabled}`);
|
||||
console.log(
|
||||
`resolved Managed-AllViewerExceptHostHeader = ${allViewerExceptHost}`,
|
||||
);
|
||||
|
||||
const current = aws([
|
||||
'cloudfront',
|
||||
'get-distribution-config',
|
||||
'--id',
|
||||
DIST,
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
const etag = current.ETag;
|
||||
const cfg = current.DistributionConfig;
|
||||
if (!etag || !cfg) throw new Error('could not read the distribution config');
|
||||
|
||||
const changes = [];
|
||||
|
||||
/* ---- 1. viewer-request function on the default behaviour ---------------- */
|
||||
if (FUNCTION_ARN) {
|
||||
const fa = cfg.DefaultCacheBehavior.FunctionAssociations ?? { Quantity: 0 };
|
||||
const existing = (fa.Items ?? []).filter(
|
||||
(i) => i.EventType === 'viewer-request',
|
||||
);
|
||||
if (existing.length === 1 && existing[0].FunctionARN === FUNCTION_ARN) {
|
||||
console.log(
|
||||
'· default behaviour already runs this function on viewer-request',
|
||||
);
|
||||
} else {
|
||||
const items = (fa.Items ?? []).filter(
|
||||
(i) => i.EventType !== 'viewer-request',
|
||||
);
|
||||
items.push({ EventType: 'viewer-request', FunctionARN: FUNCTION_ARN });
|
||||
cfg.DefaultCacheBehavior.FunctionAssociations = {
|
||||
Quantity: items.length,
|
||||
Items: items,
|
||||
};
|
||||
changes.push(
|
||||
`DefaultCacheBehavior.FunctionAssociations viewer-request -> ${FUNCTION_ARN}` +
|
||||
(existing.length ? ` (replacing ${existing[0].FunctionARN})` : ''),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('· no --function-arn given, leaving FunctionAssociations alone');
|
||||
}
|
||||
|
||||
/* ---- 2. custom error response ------------------------------------------- */
|
||||
/* ⚠️ ONLY 404 IS MAPPED, NOT 403, AND THAT IS DELIBERATE. Mapping 403 as well
|
||||
would swallow two different real failures: a broken bucket policy or OAC
|
||||
would render as "page not found" on every URL at once, and the intake
|
||||
handler's Origin refusal (a 403 from the API origin) would come back as a 404
|
||||
page. Custom error responses are distribution-wide — they cannot be scoped to
|
||||
one behaviour — so the fix for missing keys is on the S3 side instead:
|
||||
granting the OAC principal `s3:ListBucket` makes S3 answer 404 NoSuchKey
|
||||
rather than 403 AccessDenied. Runbook Part 1 does that first, and its
|
||||
verification step is what proves this mapping is reached. */
|
||||
const cer = cfg.CustomErrorResponses ?? { Quantity: 0, Items: [] };
|
||||
const cerItems = cer.Items ?? [];
|
||||
const has404 = cerItems.some(
|
||||
(i) =>
|
||||
i.ErrorCode === 404 &&
|
||||
i.ResponsePagePath === ERROR_PAGE &&
|
||||
String(i.ResponseCode) === '404',
|
||||
);
|
||||
if (has404) {
|
||||
console.log('· 404 -> /404.html (404) already configured');
|
||||
} else {
|
||||
/* Report a REPLACEMENT as a replacement. This branch filters out any existing
|
||||
404 mapping, so on a distribution that maps 404 to a different page the
|
||||
operator would otherwise be told a mapping was "added" while one was
|
||||
silently changed — and Part 3 tells them to carry on when the change count is
|
||||
lower than expected. The function-association branch above already names
|
||||
what it replaces; this one did not. */
|
||||
const replaced = cerItems.find((i) => i.ErrorCode === 404);
|
||||
const items = cerItems.filter((i) => i.ErrorCode !== 404);
|
||||
items.push({
|
||||
ErrorCode: 404,
|
||||
ResponsePagePath: ERROR_PAGE,
|
||||
ResponseCode: '404',
|
||||
/* Short, not zero. A 404 is cheap to re-fetch and this is the value that
|
||||
decides how long a genuinely-missing URL keeps 404ing after the page it
|
||||
should have been is deployed. */
|
||||
ErrorCachingMinTTL: 10,
|
||||
});
|
||||
cfg.CustomErrorResponses = { Quantity: items.length, Items: items };
|
||||
changes.push(
|
||||
replaced
|
||||
? `CustomErrorResponses 404 -> ${ERROR_PAGE} with status 404 (REPLACING ` +
|
||||
`${replaced.ResponsePagePath} with status ${replaced.ResponseCode})`
|
||||
: `CustomErrorResponses += 404 -> ${ERROR_PAGE} with status 404`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 3. the /api/* origin and behaviour --------------------------------- */
|
||||
const origins = cfg.Origins.Items ?? [];
|
||||
if (origins.some((o) => o.Id === ORIGIN_ID)) {
|
||||
console.log(`· origin ${ORIGIN_ID} already present`);
|
||||
} else {
|
||||
origins.push({
|
||||
Id: ORIGIN_ID,
|
||||
DomainName: API_DOMAIN,
|
||||
OriginPath: '',
|
||||
CustomHeaders: { Quantity: 0 },
|
||||
CustomOriginConfig: {
|
||||
HTTPPort: 80,
|
||||
HTTPSPort: 443,
|
||||
/* https-only to the origin. The API is public over TLS and there is no
|
||||
reason for a leg of this in plaintext. */
|
||||
OriginProtocolPolicy: 'https-only',
|
||||
OriginSslProtocols: { Quantity: 1, Items: ['TLSv1.2'] },
|
||||
OriginReadTimeout: 30,
|
||||
OriginKeepaliveTimeout: 5,
|
||||
},
|
||||
ConnectionAttempts: 3,
|
||||
ConnectionTimeout: 10,
|
||||
OriginShield: { Enabled: false },
|
||||
});
|
||||
cfg.Origins = { Quantity: origins.length, Items: origins };
|
||||
changes.push(
|
||||
`Origins += ${ORIGIN_ID} -> ${API_DOMAIN} (https-only, TLSv1.2)`,
|
||||
);
|
||||
}
|
||||
|
||||
const behaviours = cfg.CacheBehaviors?.Items ?? [];
|
||||
if (behaviours.some((b) => b.PathPattern === PATH_PATTERN)) {
|
||||
console.log(`· cache behaviour ${PATH_PATTERN} already present`);
|
||||
} else {
|
||||
behaviours.push({
|
||||
PathPattern: PATH_PATTERN,
|
||||
TargetOriginId: ORIGIN_ID,
|
||||
ViewerProtocolPolicy: 'https-only',
|
||||
/* POST is the one that matters; the rest are here because CloudFront only
|
||||
offers the three fixed method sets and this is the set containing POST. */
|
||||
AllowedMethods: {
|
||||
Quantity: 7,
|
||||
Items: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
|
||||
CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] },
|
||||
},
|
||||
CachePolicyId: cachingDisabled,
|
||||
OriginRequestPolicyId: allViewerExceptHost,
|
||||
Compress: false,
|
||||
SmoothStreaming: false,
|
||||
FieldLevelEncryptionId: '',
|
||||
/* NO FUNCTION ASSOCIATION, AND THE OMISSION IS LOAD-BEARING. `router.js`
|
||||
would 301 `/api/intake` to `/api/intake/`, and a 301 turns a POST into a
|
||||
GET — the submission body would be dropped with a 200 at the end of it.
|
||||
`infra/cloudfront/router.test.mjs` carries that case as documentation. */
|
||||
FunctionAssociations: { Quantity: 0 },
|
||||
LambdaFunctionAssociations: { Quantity: 0 },
|
||||
TrustedKeyGroups: { Enabled: false, Quantity: 0 },
|
||||
});
|
||||
cfg.CacheBehaviors = { Quantity: behaviours.length, Items: behaviours };
|
||||
changes.push(
|
||||
`CacheBehaviors += ${PATH_PATTERN} -> ${ORIGIN_ID}, CachingDisabled, AllViewerExceptHostHeader, POST allowed`,
|
||||
);
|
||||
}
|
||||
|
||||
/* CloudFront matches cache behaviours in order and the FIRST match wins, so a
|
||||
`/api/*` behaviour placed after a hypothetical `/*` one would never be
|
||||
reached. There is no `/*` behaviour today — the default behaviour is the
|
||||
catch-all and is not part of this list — but assert it rather than assume it. */
|
||||
const catchAll = (cfg.CacheBehaviors?.Items ?? []).findIndex(
|
||||
(b) => b.PathPattern === '*' || b.PathPattern === '/*',
|
||||
);
|
||||
const apiIndex = (cfg.CacheBehaviors?.Items ?? []).findIndex(
|
||||
(b) => b.PathPattern === PATH_PATTERN,
|
||||
);
|
||||
if (catchAll !== -1 && catchAll < apiIndex) {
|
||||
throw new Error(
|
||||
`a catch-all behaviour at index ${catchAll} precedes ${PATH_PATTERN} at ${apiIndex} — ` +
|
||||
'the API behaviour would never match. Reorder before applying.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (changes.length === 0) {
|
||||
console.log(
|
||||
'NOTHING TO CHANGE — the distribution already carries all three.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(
|
||||
`${changes.length} change(s) to distribution ${DIST} (ETag ${etag}):`,
|
||||
);
|
||||
for (const c of changes) console.log(` + ${c}`);
|
||||
console.log('');
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('DRY RUN — nothing was sent. Re-run with --apply to write it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const res = aws([
|
||||
'cloudfront',
|
||||
'update-distribution',
|
||||
'--id',
|
||||
DIST,
|
||||
'--if-match',
|
||||
etag,
|
||||
'--distribution-config',
|
||||
JSON.stringify(cfg),
|
||||
'--output',
|
||||
'json',
|
||||
]);
|
||||
console.log(
|
||||
`APPLIED. Status=${res.Distribution.Status} ETag=${res.ETag}\n` +
|
||||
'CloudFront takes a few minutes to deploy. Wait for Deployed, then run the ' +
|
||||
"runbook's verification block:\n" +
|
||||
` aws cloudfront wait distribution-deployed --id ${DIST}`,
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* CloudFront Function, VIEWER REQUEST, on the default cache behaviour only.
|
||||
*
|
||||
* ⚠️ THE SITE DOES NOT WORK WITHOUT THIS. `astro.config.mjs` sets
|
||||
* `trailingSlash: 'always'` and `build.format: 'directory'`, so every route is
|
||||
* `<dir>/index.html`. CloudFront forwards the viewer path to the S3 REST origin
|
||||
* unchanged, S3 has no key `about/`, and the request fails. Measured on the live
|
||||
* distribution 2026-09-01, before this function existed: `/about/` and
|
||||
* `/definitely-not-a-page/` both returned **403 with an 111-byte
|
||||
* `application/xml` body** — S3's AccessDenied, served raw to the reader. Only
|
||||
* `/` worked, via the distribution's default root object. That is 22 of the 23
|
||||
* pages.
|
||||
*
|
||||
* ⚠️ DO NOT ASSOCIATE IT WITH THE `/api/*` BEHAVIOUR. The intake path
|
||||
* `/api/intake` has no extension and no trailing slash, so the redirect branch
|
||||
* below would answer a form POST with a 301 — and a 301 turns a POST into a GET,
|
||||
* which would lose the submission body silently. The association is per
|
||||
* behaviour and `/api/*` gets none.
|
||||
*
|
||||
* Two rules, and the second is a `docs/04` requirement rather than a nicety:
|
||||
*
|
||||
* /about/ -> rewrite to /about/index.html (the origin has that key)
|
||||
* /about -> 301 to /about/ (one canonical URL per page)
|
||||
*
|
||||
* Anything with a file extension in its last segment is left alone —
|
||||
* `robots.txt`, `sitemap-0.xml`, `/_astro/*`, `/fonts/*`, `/og/*.jpg`,
|
||||
* `favicon.ico`, `pouya-lajevardi-bio.pdf`, and `404.html` itself.
|
||||
*
|
||||
* Written to the `cloudfront-js-2.0` runtime and deliberately conservative: no
|
||||
* arrow functions, no `String.prototype.endsWith`, no template literals. The
|
||||
* runtime supports more than this; a viewer-request function runs on every
|
||||
* request to the site and is the wrong place to be clever.
|
||||
*/
|
||||
/* The header-injection surface, and nothing else: C0 controls, DEL, space, and
|
||||
WHATWG's query percent-encode set (`"`, `#`, `<`, `>`). `#` is in because it
|
||||
changes the STRUCTURE of the Location — left in, `?a=x#&b=y` drops `&b=y` into
|
||||
a fragment. `| ^ ` { }` are NOT in, and must not be added: browsers send them
|
||||
raw and `|` is routine in tracking values. Strip rather than encode — these
|
||||
values arrive percent-encoded, so encoding again makes `%20` into `%2520`. */
|
||||
function safe(part) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return String(part).replace(/[\u0000-\u0020\u007f"<>#]/g, '');
|
||||
}
|
||||
|
||||
function handler(event) {
|
||||
var request = event.request;
|
||||
|
||||
/* ⚠️ NORMALISE, THEN REDIRECT IF ANYTHING CHANGED. Leading `//` and `\` are
|
||||
collapsed because CloudFront forwards duplicate slashes verbatim (it resolves
|
||||
dot-segments; it does not collapse `//`) and `Location: //host/x` is a
|
||||
network-path reference that REPLACES THE AUTHORITY — RFC 3986 s4.2. `\` does
|
||||
the same, because the URL Standard maps it to `/` in special schemes.
|
||||
Redirect rather than rewrite, or `//about/` serves the About page at a second
|
||||
URL with a 200. Only the leading run: an interior `//` is a key that does not
|
||||
exist. */
|
||||
var uri = request.uri.replace(/\\/g, '/').replace(/^\/+/, '/');
|
||||
var normalised = uri !== request.uri;
|
||||
var lastSlash = uri.lastIndexOf('/');
|
||||
var lastSegment = uri.substring(lastSlash + 1);
|
||||
|
||||
// A file, not a route.
|
||||
if (lastSegment.indexOf('.') !== -1) {
|
||||
if (normalised) return moved(uri, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
// A directory-style route: hand the origin the key it actually holds.
|
||||
if (lastSegment === '') {
|
||||
if (normalised) return moved(uri, request);
|
||||
request.uri = uri + 'index.html';
|
||||
return request;
|
||||
}
|
||||
|
||||
/* Extensionless and no trailing slash. Redirect rather than rewrite, so the
|
||||
page has ONE address: serving it at both would put two indexable URLs on the
|
||||
same content, which `docs/04` treats as its primary concern. */
|
||||
return moved(uri + '/', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 301 to a path on this origin, carrying the query string. `location` is always
|
||||
* built from an already-normalised path, which is what keeps it same-origin.
|
||||
*/
|
||||
function moved(path, request) {
|
||||
var qs = '';
|
||||
var names = Object.keys(request.querystring);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var name = names[i];
|
||||
var value = request.querystring[name];
|
||||
if (value.multiValue) {
|
||||
for (var j = 0; j < value.multiValue.length; j++) {
|
||||
qs +=
|
||||
(qs === '' ? '' : '&') +
|
||||
safe(name) +
|
||||
'=' +
|
||||
safe(value.multiValue[j].value);
|
||||
}
|
||||
} else {
|
||||
/* Always `name=value`, so `?ref` and `?ref=` normalise to one form rather
|
||||
than the function guessing which the viewer meant. */
|
||||
qs += (qs === '' ? '' : '&') + safe(name) + '=' + safe(value.value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
statusCode: 301,
|
||||
statusDescription: 'Moved Permanently',
|
||||
headers: {
|
||||
location: { value: path + (qs === '' ? '' : '?' + qs) },
|
||||
'cache-control': { value: 'public, max-age=0, must-revalidate' },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unit test for the viewer-request router. `node infra/cloudfront/router.test.mjs`.
|
||||
*
|
||||
* The function file cannot use module syntax — CloudFront's runtime has no
|
||||
* `export` — so it is read and evaluated rather than imported. `aws cloudfront
|
||||
* test-function` is the authoritative check because it runs the real runtime;
|
||||
* this one runs in a second, catches the branch mistakes, and costs nothing.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, 'router.js'), 'utf8');
|
||||
const handler = new Function(`${src}; return handler;`)();
|
||||
|
||||
const req = (uri, querystring = {}) => ({ request: { uri, querystring } });
|
||||
|
||||
const CASES = [
|
||||
// [uri, querystring, expected] — expected is {uri} for a rewrite/passthrough
|
||||
// or {status, location} for a redirect.
|
||||
['/', {}, { uri: '/index.html' }],
|
||||
['/about/', {}, { uri: '/about/index.html' }],
|
||||
['/practice/construction/', {}, { uri: '/practice/construction/index.html' }],
|
||||
['/contact/received/', {}, { uri: '/contact/received/index.html' }],
|
||||
['/about', {}, { status: 301, location: '/about/' }],
|
||||
['/practice/energy', {}, { status: 301, location: '/practice/energy/' }],
|
||||
// Files are untouched — every one of these is a real object in dist/.
|
||||
['/robots.txt', {}, { uri: '/robots.txt' }],
|
||||
['/sitemap-index.xml', {}, { uri: '/sitemap-index.xml' }],
|
||||
['/404.html', {}, { uri: '/404.html' }],
|
||||
['/favicon.ico', {}, { uri: '/favicon.ico' }],
|
||||
['/pouya-lajevardi-bio.pdf', {}, { uri: '/pouya-lajevardi-bio.pdf' }],
|
||||
['/_astro/schema.Cm5su60K.css', {}, { uri: '/_astro/schema.Cm5su60K.css' }],
|
||||
['/og/mediation.jpg', {}, { uri: '/og/mediation.jpg' }],
|
||||
// The query string survives the redirect, normalised to `name=value`.
|
||||
[
|
||||
'/fees',
|
||||
{ utm_source: { value: 'linkedin' }, ref: { value: '' } },
|
||||
{ status: 301, location: '/fees/?utm_source=linkedin&ref=' },
|
||||
],
|
||||
/* ⚠️ THE OPEN-REDIRECT CASES. CloudFront forwards duplicate leading slashes
|
||||
verbatim (it collapses dot-segments but not `//`), so without normalisation
|
||||
`//evil.example.com/x` produced `Location: //evil.example.com/x/` — a
|
||||
network-path reference that sends the viewer to another host from this
|
||||
domain's own URL. The backslash form defeats a `startsWith('//')` guard,
|
||||
because the URL Standard maps `\` to `/` in special schemes. Both must stay
|
||||
same-origin, and both must keep a SINGLE leading slash. */
|
||||
[
|
||||
'//evil.example.com/x',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
[
|
||||
'///evil.example.com/x',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
[
|
||||
'/\\evil.example.com/x',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
/* ⚠️ A NORMALISED PATH IS REDIRECTED, NOT REWRITTEN — this asserted a 200 for
|
||||
one revision, which closed the redirect and opened an unbounded family of
|
||||
duplicate URLs for every page on the site. */
|
||||
[
|
||||
'//evil.example.com/x/',
|
||||
{},
|
||||
{ status: 301, location: '/evil.example.com/x/' },
|
||||
],
|
||||
['//about/', {}, { status: 301, location: '/about/' }],
|
||||
['///about/', {}, { status: 301, location: '/about/' }],
|
||||
['/\\about/', {}, { status: 301, location: '/about/' }],
|
||||
/* A file is normalised too. This branch returned `request` untouched for one
|
||||
revision, so `//robots.txt` reached S3 with the doubled slash and 404'd. */
|
||||
['//robots.txt', {}, { status: 301, location: '/robots.txt' }],
|
||||
['/\\robots.txt', {}, { status: 301, location: '/robots.txt' }],
|
||||
/* An interior `//` is left alone on purpose: it is a key that does not exist,
|
||||
so it resolves to the 404 page. Only the leading run is a security question. */
|
||||
['/a//b/', {}, { uri: '/a//b/index.html' }],
|
||||
/* Header-injection surface: CR, LF, space and the delimiters browsers disagree
|
||||
about are stripped rather than re-encoded — an already-encoded value must not
|
||||
be encoded twice. `%20` therefore passes through untouched. */
|
||||
[
|
||||
'/fees',
|
||||
{ q: { value: 'a b"><x' }, utm: { value: 'a%20b' } },
|
||||
{ status: 301, location: '/fees/?q=abx&utm=a%20b' },
|
||||
],
|
||||
[
|
||||
'/fees',
|
||||
{ evil: { value: 'x\r\nSet-Cookie: a=b' } },
|
||||
{ status: 301, location: '/fees/?evil=xSet-Cookie:a=b' },
|
||||
],
|
||||
/* `#` changes the STRUCTURE of the Location — without stripping it, `&b=y`
|
||||
lands in a fragment and the parameter is silently lost. */
|
||||
[
|
||||
'/fees',
|
||||
{ a: { value: 'x#&b=y' } },
|
||||
{ status: 301, location: '/fees/?a=x&b=y' },
|
||||
],
|
||||
/* ⚠️ AND THESE MUST SURVIVE. `| ^ ` { }` are not in WHATWG's query
|
||||
percent-encode set, so a browser sends them raw — and `|` is routine in
|
||||
ad-platform tracking values. One revision of `safe()` stripped all of them,
|
||||
silently corrupting exactly the campaign links the 301 exists to preserve. */
|
||||
[
|
||||
'/fees',
|
||||
{ utm_content: { value: 'banner|top' }, k: { value: 'a{b}c^d`e' } },
|
||||
{ status: 301, location: '/fees/?utm_content=banner|top&k=a{b}c^d`e' },
|
||||
],
|
||||
// multiValue, which no case exercised before.
|
||||
[
|
||||
'/fees',
|
||||
{ tag: { value: 'a', multiValue: [{ value: 'a' }, { value: 'b' }] } },
|
||||
{ status: 301, location: '/fees/?tag=a&tag=b' },
|
||||
],
|
||||
/* /api/intake must NEVER be redirected — a 301 turns a POST into a GET and
|
||||
the submission body is gone. This function is not associated with the
|
||||
/api/* behaviour, so this case documents WHY the association matters: if it
|
||||
ever were associated, this is the damage. */
|
||||
['/api/intake', {}, { status: 301, location: '/api/intake/' }],
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
for (const [uri, qs, expected] of CASES) {
|
||||
const out = handler(req(uri, qs));
|
||||
let actual;
|
||||
if (out.statusCode) {
|
||||
actual = { status: out.statusCode, location: out.headers.location.value };
|
||||
} else {
|
||||
actual = { uri: out.uri };
|
||||
}
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) pass += 1;
|
||||
else
|
||||
failures.push(
|
||||
`${uri} -> ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pass + failures.length !== CASES.length) {
|
||||
throw new Error(`case count ${pass + failures.length} != ${CASES.length}`);
|
||||
}
|
||||
console.log(`router: ${pass} of ${CASES.length} cases pass`);
|
||||
for (const f of failures) console.error(' FAIL ' + f);
|
||||
if (failures.length > 0) process.exit(1);
|
||||
@@ -257,6 +257,31 @@ const PATTERNS = [
|
||||
'reached a public page once.',
|
||||
re: /anyone (may|can) be appointed an arbitrator|nothing in law gates|no (licence|license|designation) is (required|needed) to (be appointed|arbitrate|act as an arbitrator)/gi,
|
||||
},
|
||||
{
|
||||
id: 'sole-administrator-q62',
|
||||
rule: 'Q62 — /legal/privacy/ claims sole administrative access to the intake table, and that is FALSE.',
|
||||
incident:
|
||||
'The page shipped "nobody else has access to the table. There is no team, ' +
|
||||
'no assistant and no external administrator." The AWS account has an ' +
|
||||
'`admins` IAM group carrying AdministratorAccess with TWO members, and ' +
|
||||
'`simulate-principal-policy` returns allowed for dynamodb:GetItem, Query ' +
|
||||
'and Scan on the table for both ' +
|
||||
'[verified 2026-09-01 — docs/reference/intake-table-access-verification.md]. ' +
|
||||
'THE REACHED-DIST CONDITION IS WHY THIS PATTERN EXISTS AT ALL: the ' +
|
||||
'sentence was in dist/legal/privacy/index.html, `build`, `check` and this ' +
|
||||
'script all exited 0 over it, and the only thing between it and a live ' +
|
||||
'privacy policy was a TODO(pouya) in a JSX comment — which Astro strips, ' +
|
||||
'so `grep -rn "TODO(pouya)" dist/` returned nothing. The gate was human ' +
|
||||
'memory. Found by `adversarial-reviewer`, 2026-09-01. ' +
|
||||
'DELETE THIS PATTERN when Q62 is ruled: either the access is removed and ' +
|
||||
'the sentence becomes true, or the sentence is rewritten. It is a tripwire ' +
|
||||
'on one specific published falsehood, not a rule about a class.',
|
||||
/* `\s+`, NOT LITERAL SPACES: `compressHTML` collapses whitespace between
|
||||
tags and leaves it alone inside a text node, so the published bytes read
|
||||
"nobody else has access to\n the table". A literal-space version
|
||||
runs, prints `ok`, and exits 0 with the breach in `dist/`. */
|
||||
re: /nobody\s+else\s+has\s+access\s+to\s+the\s+table|there\s+is\s+no\s+team,\s+no\s+assistant\s+and\s+no\s+external\s+administrator/gi,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -341,6 +366,11 @@ const FIXTURES = {
|
||||
'C.Med-Arbitrators',
|
||||
'C.Med-Arbitration',
|
||||
],
|
||||
'sole-administrator-q62': [
|
||||
/* The two published clauses, verbatim from dist/legal/privacy/. */
|
||||
'nobody else has access to the table',
|
||||
'There is no team, no assistant and no external administrator.',
|
||||
],
|
||||
'struck-universal-q39': [
|
||||
'Anyone may be appointed an arbitrator in Ontario',
|
||||
'nothing in law gates the role',
|
||||
@@ -348,6 +378,16 @@ const FIXTURES = {
|
||||
},
|
||||
/* Every one of these is real published or spec-approved copy on this site. */
|
||||
mustNotMatch: [
|
||||
/* NEGATIVE FIXTURES FOR `sole-administrator-q62` — the approved copy nearest
|
||||
to the two clauses it catches. The pattern is deliberately anchored on the
|
||||
two published sentences rather than on the ideas in them, because the
|
||||
replacement wording is not yet decided and a looser pattern would fire on
|
||||
whatever Q62's ruling produces. These four are what "nearest" means: the
|
||||
same page's true sentences about the same subject. */
|
||||
'The table is reachable by the function that writes to it.',
|
||||
'Two accounts hold administrative access to the AWS account, and the function that writes to the table cannot read it.',
|
||||
'Nobody else has access to my mailbox.',
|
||||
'There is no team. Every inquiry is read by me.',
|
||||
'I act as a neutral. I do not act for a party in a matter I take, and each party should have their own legal advice.',
|
||||
'I run a process, I do not run a case for anybody in it.',
|
||||
'I will not run a process whose shape nobody agreed to in advance.',
|
||||
|
||||
+21
-6
@@ -69,7 +69,7 @@ case "$caller" in
|
||||
echo >&2
|
||||
echo "REFUSING: that is the broadly-permissioned personal user." >&2
|
||||
echo "AGENTS.md §10 — never use user/pouya to deploy. Use the scoped" >&2
|
||||
echo "deploy user (Q22: not yet created)." >&2
|
||||
echo "deploy user, adr-sml-deploy — PROVISIONED, AGENTS.md §7." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -157,7 +157,7 @@ location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' \
|
||||
"https://adr.smlcompany.ca/api/intake" 2>/dev/null || true)
|
||||
"https://adr.smlcompany.ca/api/intake" || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
|
||||
@@ -169,10 +169,25 @@ else
|
||||
echo >&2
|
||||
echo "WARNING: POST /api/intake returned $code (expected 303 to" >&2
|
||||
echo "/contact/could-not-send/); redirect was '${location:-none}'." >&2
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can mean the" >&2
|
||||
echo "same thing — CloudFront rejecting a method the default behaviour does" >&2
|
||||
echo "not allow — or the handler refusing the Origin. Either way the form is" >&2
|
||||
echo "not verified working. See docs/06-deployment.md's cutover checklist." >&2
|
||||
# 404 IS AMBIGUOUS BETWEEN THREE CAUSES and the distribution's custom error
|
||||
# response hides the one string that would separate them: API Gateway's
|
||||
# {"message":"Not Found"} is replaced by /404.html, because custom error
|
||||
# responses are distribution-wide. So name the causes and the one command that
|
||||
# tells them apart. Corrected 2026-09-01 by `adversarial-reviewer`; the earlier
|
||||
# text named only the CloudFront behaviour.
|
||||
echo "404 means one of three things, and \`aws apigatewayv2 get-routes" >&2
|
||||
echo "--api-id <id> --query 'Items[].RouteKey'\` separates them in one call:" >&2
|
||||
echo " - the CloudFront /api/* behaviour is missing (docs/09 Part 3);" >&2
|
||||
echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2
|
||||
echo " - the route exists and the distribution's 404 mapping is showing you" >&2
|
||||
echo " /404.html instead of the API's own body." >&2
|
||||
echo "403 means CloudFront rejected the method, or the handler refused the" >&2
|
||||
echo "Origin — check the behaviour uses Managed-AllViewerExceptHostHeader," >&2
|
||||
echo "because a policy that drops Origin turns every real submission into a" >&2
|
||||
echo "403. 500 means the Lambda invoke permission for this route is missing" >&2
|
||||
echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2
|
||||
echo "Either way the form is not verified working. See docs/09-cutover-" >&2
|
||||
echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2
|
||||
fi
|
||||
|
||||
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Prints the intake Lambda's six environment variables as the JSON that
|
||||
* `aws lambda update-function-configuration --environment` takes.
|
||||
*
|
||||
* ⚠️ THIS EXISTS SO THAT TWO PUBLISHED COMMITMENTS ARE NEVER RETYPED INTO A
|
||||
* SHELL COMMAND. `RESPONSE_TIME` and `NO_RETAINER_NOTICE` are read from
|
||||
* `src/data/site.ts` — the same constants `/contact/` renders — because a
|
||||
* hand-typed copy of the notice inside the handler had already dropped a clause
|
||||
* once (`docs/05`, and the handler's own comment on the constant). A deploy
|
||||
* procedure that asks an operator to paste a sentence is the same defect one
|
||||
* step further out, and the notice contains an EN DASH in "mediator–party",
|
||||
* which is exactly the character a retype loses.
|
||||
*
|
||||
* Resource names come from `AGENTS.md` §7 and are passed in, not defaulted from
|
||||
* a second copy here — except the two that are pure site facts.
|
||||
*
|
||||
* usage: node scripts/intake-env.mjs --table <name> --notify <addr> --from <addr>
|
||||
* node scripts/intake-env.mjs ... --shell # export lines instead
|
||||
*/
|
||||
import { CONTACT, NO_RETAINER_NOTICE, SITE } from '../src/data/site.ts';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : args[i + 1];
|
||||
};
|
||||
|
||||
const table = flag('table');
|
||||
const notify = flag('notify');
|
||||
const from = flag('from');
|
||||
const missing = [
|
||||
['--table', table],
|
||||
['--notify', notify],
|
||||
['--from', from],
|
||||
]
|
||||
.filter(([, v]) => !v)
|
||||
.map(([k]) => k);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`missing: ${missing.join(' ')}`);
|
||||
console.error(
|
||||
'usage: node scripts/intake-env.mjs --table <dynamodb-table> ' +
|
||||
'--notify <address> --from <ses-verified-address> [--shell]',
|
||||
);
|
||||
console.error('Resource names are in AGENTS.md §7.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* The site origin is not a deploy-time choice: the handler compares the request
|
||||
Origin against it and redirects to pages ON it, so it must be the canonical
|
||||
origin `astro.config.mjs` builds against. */
|
||||
const origin = SITE.url.replace(/\/$/, '');
|
||||
|
||||
const vars = {
|
||||
INTAKE_TABLE: table,
|
||||
SITE_ORIGIN: origin,
|
||||
NOTIFY_TO: notify,
|
||||
MAIL_FROM: from,
|
||||
RESPONSE_TIME: CONTACT.responseTime,
|
||||
NO_RETAINER_NOTICE,
|
||||
};
|
||||
|
||||
/* Guards, not decoration. Each one is a failure this project has already had or
|
||||
has written down as the next one. */
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
throw new Error(
|
||||
`${k} resolved empty — the handler throws at cold start on that`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!/^https:\/\//.test(origin)) {
|
||||
throw new Error(`SITE_ORIGIN must be an https origin, got ${origin}`);
|
||||
}
|
||||
/* The clause a hand-copy dropped. `docs/01` §/contact/ requires it, so its
|
||||
absence is a published-disclosure defect rather than a typo. */
|
||||
if (!NO_RETAINER_NOTICE.includes('create a conflict check')) {
|
||||
throw new Error(
|
||||
'NO_RETAINER_NOTICE is missing its fourth clause about not itself creating ' +
|
||||
'a conflict check — docs/01 §/contact/ requires it. Do not deploy this.',
|
||||
);
|
||||
}
|
||||
if (!/–/.test(NO_RETAINER_NOTICE)) {
|
||||
throw new Error(
|
||||
'NO_RETAINER_NOTICE no longer contains the en dash in "mediator–party". ' +
|
||||
'Either the constant changed deliberately, or something re-typed it.',
|
||||
);
|
||||
}
|
||||
if (!/\btwo business days\b/.test(CONTACT.responseTime)) {
|
||||
throw new Error(
|
||||
`RESPONSE_TIME is "${CONTACT.responseTime}" — AGENTS.md §4/Q27 is a ` +
|
||||
'two-business-day commitment. If the commitment changed, /contact/, the ' +
|
||||
'bio and this all move together.',
|
||||
);
|
||||
}
|
||||
|
||||
if (args.includes('--shell')) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
console.log(`export ${k}=${JSON.stringify(v)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify({ Variables: vars }));
|
||||
}
|
||||
@@ -89,6 +89,16 @@ async function collectPages(dir = DIST) {
|
||||
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||||
out.push(rel ? `/${rel}/` : '/');
|
||||
}
|
||||
/* ⚠️ `index.html` ALONE MISSED THE 404 PAGE, so the budget was measured on
|
||||
22 pages of 23 while the header above claims it enumerates the site.
|
||||
`404.astro` is emitted as `dist/404.html`, outside `build.format:
|
||||
'directory'`. The path pushed here is a URL this script SERVES, so it is
|
||||
`/404.html` — the form CloudFront's custom error response fetches — and
|
||||
`resolveFile()` resolves it on the `extname` branch. `og-proof.mjs` needs
|
||||
the `OG_CARDS` key `/404/` for the same file; the two differ on purpose. */
|
||||
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||||
out.push(`/${entry.name}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ async function pages(dir = DIST) {
|
||||
const rel = relative(DIST, dir).split(sep).filter(Boolean).join('/');
|
||||
out.push({ path: rel ? `/${rel}/` : '/', file: full });
|
||||
}
|
||||
/* ⚠️ `index.html` ALONE MISSED A WHOLE PAGE. `build.format: 'directory'`
|
||||
puts every route at `<dir>/index.html` — except the ones Astro emits
|
||||
outside the convention, and `404.astro` becomes `dist/404.html`. So this
|
||||
script enumerated 22 pages of 23, and the symptom was backwards: it
|
||||
reported the 404 page's card as ORPHANED ("generated, but no built page
|
||||
references it") rather than reporting the page as unchecked. `path` here
|
||||
is an `OG_CARDS` key, which is `Astro.url.pathname` — `/404/`, not
|
||||
`/404.html`. `scripts/lighthouse.mjs` had the same blind spot and needs
|
||||
the URL form instead; see the note there. */
|
||||
else if (dir === DIST && entry.name.endsWith('.html')) {
|
||||
out.push({ path: `/${entry.name.replace(/\.html$/, '')}/`, file: full });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ export const OG_CARDS: Record<string, { eyebrow: string; headline: string }> = {
|
||||
eyebrow: 'Bio',
|
||||
headline: 'Pouya Lajevardi',
|
||||
},
|
||||
/* `/404/` builds to `dist/404.html` and is `noindex`, but a shared 404 link is
|
||||
exactly the kind of URL that gets pasted into a chat window — so it gets a
|
||||
card on the same one-rule-no-exceptions basis as `/bio/`. */
|
||||
'/404/': {
|
||||
eyebrow: 'Not found',
|
||||
headline: 'That page is not here.',
|
||||
},
|
||||
'/legal/privacy/': {
|
||||
eyebrow: 'Privacy',
|
||||
headline: 'What the intake form collects, and for how long.',
|
||||
|
||||
@@ -259,14 +259,33 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
text: 'The Enhancing Digital Security and Trust Act, 2024 conditions each of its artificial-intelligence obligations on regulations prescribing who they apply to and when. Two regulations have been made under it — one on cyber security, one on digital technology affecting people under 18 — and neither is the AI one.',
|
||||
},
|
||||
{
|
||||
lead: 'And no federal or Ontario statute requires data to be stored in Canada.',
|
||||
text: "This is the one worth stating plainly, because data-residency clauses are often drafted against the opposite assumption. The federal Privacy Commissioner's own guidance says PIPEDA does not prohibit an organisation in Canada from transferring personal information to another jurisdiction for processing; what the Act requires instead is accountability — the organisation stays responsible for information it has transferred to a third party. Ontario's health privacy statute imposes no storage-location rule either.",
|
||||
/* THE LEAD WAS "And no federal or Ontario statute requires data to
|
||||
be stored in Canada" — a universal over the FOUR instruments the
|
||||
extract actually checked (PIPEDA, Ontario FIPPA, PHIPA and
|
||||
O. Reg. 329/04), which is the shape §4 Forbidden's struck Q39
|
||||
universal bars in both directions: this repository does not
|
||||
conclude a proposition of law. Named instruments only. */
|
||||
/* ⚠️ NAMED, AND THE FIRST CORRECTION ONLY NARROWED THE CLASS.
|
||||
"Ontario's public-sector privacy statutes" is a class of two —
|
||||
FIPPA and MFIPPA — and the extract records a residency finding for
|
||||
FIPPA and none for MFIPPA, so the narrowed lead was still a
|
||||
universal over an unchecked instrument. Same shape, smaller.
|
||||
The lead names the three ACTS the extract searched. O. Reg. 329/04
|
||||
is searched too and is deliberately not named: it is a regulation
|
||||
under PHIPA, so naming the Act covers it without putting a
|
||||
regulation number on a marketing page. */
|
||||
lead: "And neither PIPEDA, nor Ontario's Freedom of Information and Protection of Privacy Act, nor its Personal Health Information Protection Act requires data to be stored in Canada.",
|
||||
text: 'This is the one worth stating plainly, because data-residency clauses are often drafted against the opposite assumption. The federal Privacy Commissioner\'s own guidance says PIPEDA does not prohibit an organisation in Canada from transferring personal information to another jurisdiction for processing; what the Act requires instead is accountability — the organisation stays responsible for information it has transferred to a third party. Neither Ontario statute contains a storage-location rule either — FIPPA has no data-localisation provision, and PHIPA\'s "Disclosure outside Ontario" section is a disclosure permission rather than a rule about where records sit.',
|
||||
},
|
||||
{
|
||||
text: 'Which matters in a dispute because the parties are often arguing about a clause neither of them can point to a source for. Establishing what the obligation actually is, rather than what both sides assumed it was, frequently narrows the disagreement to something a mediation can close in a day.',
|
||||
},
|
||||
],
|
||||
note: "Described as the state of the instruments, not applied to anyone's file, and the residency point is the Privacy Commissioner's own words rather than a conclusion of mine. All of it is sourced in docs/reference/canada-privacy-technology.md and all of it can change — a bill at second reading in August 2026 is not a bill at second reading forever. What any of it means for a particular contract is a question for each party's own counsel.",
|
||||
/* THE NOTE CLAIMED THE WHOLE RESIDENCY POINT WAS THE COMMISSIONER'S
|
||||
WORDS. It is his words for PIPEDA and a reading of the Ontario
|
||||
statutes for the rest — so the note disclaimed a conclusion the page
|
||||
does in fact draw, which is worse than drawing it openly. */
|
||||
note: "Described as the state of the instruments, not applied to anyone's file. On residency the PIPEDA half is the federal Privacy Commissioner's own words; the Ontario half is what FIPPA and PHIPA say, and all three are named rather than described as a class. All of it is sourced in docs/reference/canada-privacy-technology.md and all of it can change — a bill at second reading in August 2026 is not a bill at second reading forever. What any of it means for a particular contract is a question for each party's own counsel.",
|
||||
ground: 'inverse',
|
||||
},
|
||||
{
|
||||
@@ -302,8 +321,14 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
'connection regime in Ontario.',
|
||||
h1: 'Grid disputes are engineering disputes with a regulator attached.',
|
||||
lede:
|
||||
'Ontario has spent the last year rewriting how large loads and new ' +
|
||||
'generation get connected. That produces commercial disputes between ' +
|
||||
/* "and new generation" was here and is struck: the extract establishes a
|
||||
change for large loads (Electricity Act s. 28.1) and for what the Board
|
||||
may weigh on a leave-to-construct application, and its one quotation on
|
||||
generation runs the other way — the normal System Impact Assessment
|
||||
"applies to the connection of all generation facilities, renewable or
|
||||
non-renewable, equally". A class asserted from one instance. */
|
||||
'Ontario has spent the last year rewriting how large loads ' +
|
||||
'get connected. That produces commercial disputes between ' +
|
||||
'proponents, distributors, transmitters and municipalities long before ' +
|
||||
'anything reaches a regulator.',
|
||||
disputeTypesLede:
|
||||
@@ -463,11 +488,21 @@ export const PRACTICE_PAGES: Record<PracticeSlug, PracticePage> = {
|
||||
},
|
||||
{
|
||||
lead: "Its case conference is the Tribunal's own, and I am not appointed to it.",
|
||||
text: "The Tribunal's settlement step is a case conference led by one of its adjudicators, who is then disqualified from hearing the matter. It is sometimes called a pre-hearing, which is the Tribunal's own label for it. A privately retained neutral does not conduct it and cannot be appointed to it, and nothing on this page should be read as offering that.",
|
||||
/* "who is then disqualified from hearing the matter" was here and
|
||||
overstated the rule. LAT Rule 14.3 disqualifies the Member
|
||||
"except with the consent of the parties" — an absolute where the
|
||||
rule is qualified. */
|
||||
text: "The Tribunal's settlement step is a case conference led by one of its adjudicators, who does not then sit on the hearing panel unless the parties consent. It is sometimes called a pre-hearing, which is the Tribunal's own label for it. A privately retained neutral does not conduct it and cannot be appointed to it, and nothing on this page should be read as offering that.",
|
||||
},
|
||||
{
|
||||
lead: 'What I offer is private mediation.',
|
||||
text: 'Retained by the parties, on their own terms, under an agreement to mediate they sign. The Tribunal\'s own materials point parties toward mediation: under the heading "Consider other ways to resolve your dispute", the accident-benefits page says that before you apply, you may want to consider negotiation or mediation services.',
|
||||
/* "The Tribunal's own materials point parties toward mediation" was
|
||||
here: a plural class, and a direction, resting on one permissive
|
||||
sentence on one page that pairs mediation with negotiation and
|
||||
ranks neither. The extract's own adversarial check named both
|
||||
over-reads; this is the same gloss one notch weaker, and it
|
||||
survived the correction to the quotation beside it. */
|
||||
text: 'Retained by the parties, on their own terms, under an agreement to mediate they sign. The Tribunal\'s accident-benefits page names mediation as something to consider before applying: under the heading "Consider other ways to resolve your dispute", it says that before you apply, you may want to consider negotiation or mediation services.',
|
||||
},
|
||||
],
|
||||
note: 'That quotation is about mediation before an application is filed, and it is quoted no wider than it goes. Sourced in docs/reference/lat-case-conference.md, which carries the full passage and a correction to an earlier reading of it.',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
/**
|
||||
* The 404 page. `docs/04-seo-spec.md`: "Real, styled, with search-intent links
|
||||
* out. CloudFront must return it with a genuine 404 status."
|
||||
*
|
||||
* ⚠️ THE PAGE IS HALF OF THE FIX AND THE DISTRIBUTION IS THE OTHER HALF. Astro
|
||||
* emits this as `dist/404.html`; nothing in the build can make CloudFront serve
|
||||
* it. Until the custom error response exists, a missing URL returns S3's
|
||||
* `AccessDenied` XML — measured 2026-09-01, not assumed: `/about/` and
|
||||
* `/definitely-not-a-page/` both answered **403, `application/xml`, 111 bytes**
|
||||
* on the live distribution. `docs/06` carries the two commands and the reason
|
||||
* the mapping is on 404 rather than 403.
|
||||
*
|
||||
* `noindex`, because a 404 that invites indexing is a 404 that gets indexed.
|
||||
* `robots` is `noindex,follow` so the links out are still crawled, which is the
|
||||
* whole point of a page with links out.
|
||||
*
|
||||
* NO CLAIM ABOUT PRACTICE OR CREDENTIALS APPEARS IN THE VISIBLE COPY, and the
|
||||
* omission is the design rather than an oversight. An error page has no reader
|
||||
* who came for a credential, so a sentence it adds is a sentence `AGENTS.md` §4
|
||||
* has to carry for no return. It names pages and nothing else.
|
||||
*
|
||||
* ⚠️ THE JSON-LD IS A DIFFERENT MATTER AND THIS COMMENT USED TO DENY IT. The
|
||||
* `pageGraph()` above is the shared graph and it DOES emit the §4 Person node —
|
||||
* `jobTitle`, the `description`, and `hasCredential` for Q.Med and Q.Arb. Every
|
||||
* one of those is registered, so it is not a §4 breach; the false statement was
|
||||
* this comment, which invited the next editor to treat the 404 page as outside
|
||||
* the register's blast radius. It is not: this is machine-readable credential
|
||||
* assertion served on every unmatched URL, to exactly the reader `robots.txt`
|
||||
* names — "an assistant that counsel is using to shortlist a neutral". Found by
|
||||
* `adversarial-reviewer`, 2026-09-01.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Button from '../components/Button.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import { getImage } from 'astro:assets';
|
||||
import ogDefault from '../assets/og-portrait.jpg';
|
||||
import { pageGraph } from '../data/schema';
|
||||
import { CONTACT } from '../data/site';
|
||||
|
||||
const ldImage = await getImage({
|
||||
src: ogDefault,
|
||||
format: 'jpeg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
});
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
/* The routes worth offering, in the order a lost reader is most likely to want
|
||||
them. Not the full sitemap — the footer on this page already carries that. */
|
||||
const routes = [
|
||||
{ href: '/mediation/', label: 'Mediation' },
|
||||
{ href: '/arbitration/', label: 'Arbitration' },
|
||||
{ href: '/med-arb/', label: 'Med-Arb' },
|
||||
{ href: '/practice/', label: 'Practice areas' },
|
||||
{ href: '/fees/', label: 'Fees' },
|
||||
{ href: '/about/', label: 'About' },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Page Not Found · Dispute Resolution · Pouya Lajevardi"
|
||||
description="That page is not here. Mediation, arbitration and med-arb each have a page, the practice areas are listed, and an inquiry can be sent from contact."
|
||||
jsonLd={graph}
|
||||
noindex
|
||||
>
|
||||
<section class="section hero">
|
||||
<div class="wrap">
|
||||
<Eyebrow dot>Not found</Eyebrow>
|
||||
<h1 class="display hero-h">That page is not here.</h1>
|
||||
<div class="prose">
|
||||
<p class="statement">
|
||||
The address may have changed, or it may never have existed.
|
||||
</p>
|
||||
<p>
|
||||
If you were looking for something specific, email <a
|
||||
href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a
|
||||
> and say what it was.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="routes" aria-label="Main pages">
|
||||
<ul role="list">
|
||||
{
|
||||
routes.map((route) => (
|
||||
<li>
|
||||
<a href={route.href}>{route.label}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="cta">
|
||||
<Button href="/">Start at the beginning</Button>
|
||||
<Button href="/contact/" variant="ghost">Send an inquiry →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: var(--space-9) var(--space-11);
|
||||
}
|
||||
.hero-h {
|
||||
margin-block: var(--space-4) var(--space-5);
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.statement {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.routes {
|
||||
margin-block-start: var(--space-7);
|
||||
padding-block-start: var(--space-5);
|
||||
border-block-start: 1px solid var(--rule);
|
||||
}
|
||||
.routes ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-6);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.routes a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* BOTH AXES. `min-block-size` alone left "Fees" at 37 x 44 px — measured, and
|
||||
under `docs/02`'s 44 x 44 floor on the inline axis while the comment beside
|
||||
it claimed compliance. WCAG 2.5.8's 24 x 24 AA minimum was still met via the
|
||||
`--space-6` gap; this is the project's own stricter floor.
|
||||
`padding-inline` as well as the minimum, so a short label is a wide target
|
||||
rather than a narrow one centred in a wide box. */
|
||||
min-block-size: 44px;
|
||||
min-inline-size: 44px;
|
||||
padding-inline: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.routes a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
</style>
|
||||
@@ -245,6 +245,23 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
|
||||
not the section that answers the same question is the sweep failure
|
||||
`CLAUDE.md` describes. Found by `adversarial-reviewer` round 2. */
|
||||
}
|
||||
{
|
||||
/* TODO(pouya): the sentence below is FALSE as at 2026-09-01 and this
|
||||
page must not go public until you rule — §9 Q62. The AWS account has
|
||||
an `admins` IAM group carrying `AdministratorAccess` with TWO
|
||||
members, you and one other person, and
|
||||
`simulate-principal-policy` returns **allowed** for
|
||||
`dynamodb:GetItem`/`Query`/`Scan` on this table for both.
|
||||
Evidence and commands:
|
||||
`docs/reference/intake-table-access-verification.md`.
|
||||
THE QUESTION: do you remove that access — which may be the same
|
||||
co-administrator Q23's Gitea instance depends on, so it is not free
|
||||
— or does this paragraph state the true number? Nothing here may be
|
||||
softened into "authorised administrators": on this page a reader is
|
||||
entitled to the specific, and a true vacancy is worse than a false
|
||||
specific only in that it cannot be caught.
|
||||
Raised by `claims-auditor`, D20 cutover audit, finding 8. */
|
||||
}
|
||||
<p>
|
||||
I can. The table is reachable by the function that writes to it and by
|
||||
one administrative account, which is mine — nobody else has access to
|
||||
|
||||
@@ -213,8 +213,9 @@ const graph = medArbGraph({
|
||||
<p>
|
||||
The ADR Institute of Canada publishes <strong
|
||||
>ADRIC Med-Arb Rules</strong
|
||||
>, developed by a task force and presented to the membership at
|
||||
ADRIC's 2019 annual conference, and designed, in ADRIC's words, to <q
|
||||
>, developed by a task force and presented to the membership as a
|
||||
discussion draft at ADRIC's 2019 annual conference, and designed, in
|
||||
ADRIC's words, to <q
|
||||
>work in tandem with ADRIC's existing Mediation Rules and
|
||||
Arbitration Rules</q
|
||||
>. They were drafted for domestic commercial disputes, and ADRIC notes
|
||||
|
||||
+24
-9
@@ -121,18 +121,33 @@ html {
|
||||
/* The header is sticky from 66rem up, and `scroll-padding-top` has to clear it
|
||||
or "Skip to content" drops the reader behind it — the one control that exists
|
||||
specifically for keyboard users, landing them on content they cannot see.
|
||||
`--header-h` is defined in tokens.css beside the value it has to match, and it
|
||||
is a FLOOR — above the default text size the masthead reflows and is taller, and
|
||||
`SiteHeader` gates stickiness so the offset is generous rather than short.
|
||||
⚠️ TWO CASES DEFEAT THAT GATE and leave this offset short — one a reader
|
||||
setting, one the webfont swap window. Under Chrome's minimum font size the
|
||||
shortfall is an OPEN WCAG 2.2 SC 2.4.11 (AA) failure, not an accepted one:
|
||||
`AGENTS.md` Q61. **CSS CAN detect that setting** — the font-metric units
|
||||
(`ch`, `ex`, `cap`, `lh`) respond to it where `rem`/`em` do not — so a fix is
|
||||
possible here and is awaiting a ruling. Measurements: `docs/02` §Reflow. */
|
||||
`--header-h` is a FLOOR at the default text size, so the `max()` ramp is what
|
||||
carries the cases where the masthead reflows taller (`AGENTS.md` Q61).
|
||||
|
||||
⚠️ THREE THINGS HERE ARE LOAD-BEARING AND EACH BREAKS SILENTLY.
|
||||
|
||||
1. `1lh` and not `1rem`/`1em`. Chrome's minimum-font-size setting enlarges
|
||||
text while `rem` keeps resolving at 16px; the font-metric units read the
|
||||
USED size and track it. `rem` here measures 97px against a 270.56px
|
||||
header — no error, no warning, focus behind the header.
|
||||
2. The plain declaration comes FIRST and is not redundant. An engine without
|
||||
`lh` discards the whole `max()` as invalid, and the property then falls
|
||||
back to `--space-4` = 16px, which is worse than no fix at all.
|
||||
3. `1lh` on `<html>` is immune to the `font-display: swap` window ONLY
|
||||
because `<html>` keeps the UA font family — `--font-sans` is set on
|
||||
`body`. Moving the family up to `html` makes this offset depend on whether
|
||||
a webfont has arrived. Do not.
|
||||
|
||||
One case is still short: fallback metrics with a seventh nav item, gated by a
|
||||
build failure in `SiteHeader` (`AGENTS.md` R20). `docs/02` §Reflow has the
|
||||
measurements. */
|
||||
@media (min-width: 66rem) {
|
||||
html {
|
||||
scroll-padding-top: calc(var(--header-h) + var(--space-4));
|
||||
scroll-padding-top: max(
|
||||
calc(var(--header-h) + var(--space-4)),
|
||||
calc(10lh - 83px)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -140,11 +140,18 @@
|
||||
/* ⚠️ A FLOOR, NOT A CONSTANT: the sticky header's height AT THE DEFAULT TEXT
|
||||
SIZE. Above the default the masthead reflows and is deliberately taller, which
|
||||
is why the nav no longer runs off-screen. `global.css` drives
|
||||
`scroll-padding-top` off it and `SiteHeader` gates stickiness so the offset is
|
||||
generous rather than short — **but not in every case**: `docs/02` §Reflow lists
|
||||
the two cases where it is still short, one of them an OPEN SC 2.4.11 (AA)
|
||||
failure (`AGENTS.md` Q61). If SiteHeader's padding or nav sizing changes,
|
||||
re-measure and change this with it — one fact living in two files.
|
||||
`scroll-padding-top` off it as the LOWER BOUND of a `max()` ramp — the ramp,
|
||||
not this token, is what covers the settings where the masthead reflows taller
|
||||
(`AGENTS.md` Q61, fixed 2026-09-01). One case remains short and is gated by a
|
||||
build failure rather than by this value: `AGENTS.md` R20. If SiteHeader's
|
||||
padding or nav sizing changes, re-measure and change this with it — one fact
|
||||
living in two files.
|
||||
⚠️ **AND THERE IS A THIRD FILE, WHICH IS THE ONE A READER MISSES.**
|
||||
`global.css`'s ramp is `max(calc(var(--header-h) + var(--space-4)),
|
||||
calc(10lh - 83px))`, and that `83px` is fitted so the second term equals the
|
||||
first at the default text size — 97 px both ways, measured. **Change this
|
||||
token and the 83 px moves with it**, or the ramp stops being a no-op at
|
||||
normal settings and starts changing the shipped rendering.
|
||||
[measured 2026-08-26, re-measured 2026-09-01 — headless Chrome, six nav items
|
||||
and a seventh injected. 81.00px at all EIGHT widths from 1056 to 1920px
|
||||
(1056/1100/1216/1240/1280/1440/1600/1920): 32 padding + 48 reserved brand
|
||||
|
||||
Reference in New Issue
Block a user