Compare commits
5
Commits
9f2d2eeb04
...
bd282aa47d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd282aa47d | ||
|
|
ca1c2524e1 | ||
|
|
fce89d46eb | ||
|
|
0f7595b602 | ||
|
|
64bce105f8 |
@@ -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/
|
||||
|
||||
@@ -273,6 +273,15 @@ argument and the comparison never ran. Prefer `git grep`, quote or array-expand
|
||||
anything you pass as flags, and re-check any result whose shape is "uniformly
|
||||
bad".
|
||||
|
||||
**zsh does not word-split parameter expansions; a loop over `$VAR` runs ONCE —
|
||||
use command substitution or arrays, and assert the iteration count.** *Pouya's
|
||||
convention, 2026-09-01.* `for p in $PAGES` iterates one item, not twenty-two,
|
||||
and `node probe.mjs 320,360 $P` measures one page — both of which then report
|
||||
"max=0, nonzero=0" and read as a clean sweep. `$(cat file)` and `${=VAR}` do
|
||||
split; `"${(@f)VAR}"` splits on newlines. **The fix is not remembering which:
|
||||
assert the count before reading the result** — a probe that says how many rows it
|
||||
measured cannot silently measure one.
|
||||
|
||||
**And re-check "uniformly GOOD" too — that is the dangerous half.** *Added
|
||||
2026-08-30; sharpened on Pouya's instruction 2026-08-31, as "the sharpest
|
||||
instrument finding yet".* The same `set -- $pair` loop recurred while confirming
|
||||
@@ -387,6 +396,50 @@ So before acting on a number: say what it is a number *of*; confirm the command
|
||||
actually ran and read its exit status; and check it against a second method that
|
||||
cannot fail the same way — the bytes on disk, a screenshot, a hit test.
|
||||
|
||||
**Two simulations of 200% text are not equivalent: media-query `rem` resolves
|
||||
against the browser DEFAULT font size, not the root element — measure under both
|
||||
methods before declaring a reflow result.** *Pouya's convention, 2026-09-01.*
|
||||
Raising the default moves the breakpoints along with the type, so the desktop
|
||||
layout is never reached and nothing overflows; setting `documentElement.style
|
||||
.fontSize` doubles the type and leaves the breakpoints where they were, which is
|
||||
the layout the desktop nav was measured in. One of those reported **0** while the
|
||||
other reported **944 px** on the same 22 pages, and the prose generalised the
|
||||
zero.
|
||||
|
||||
⚠️ **AND THERE ARE THREE MECHANISMS, NOT TWO — the third defeats the `rem`/`em`
|
||||
FAMILY, which is not the same as defeating CSS.** Measured 2026-09-01: Chrome's
|
||||
**"Minimum font size"** *floors* computed font sizes instead of scaling them, so
|
||||
text enlarges while `rem` keeps resolving at 16 px. A media query in `rem` does
|
||||
not see it, and neither does a container query — `@container` `rem`/`em` DO track
|
||||
the root element (that is the one real difference from `@media`, and it is
|
||||
measured), but under a minimum-font-size setting they still resolve at 16 px while
|
||||
`getComputedStyle` reports 32 px.
|
||||
|
||||
⚠️ **BUT "NO CSS CONDITIONAL CAN SEE IT" IS FALSE, AND ASSERTING IT COST A
|
||||
CONFORMANCE FAILURE.** *Corrected 2026-09-01, same day, by `adversarial-reviewer`.*
|
||||
The **font-metric** units read the *used* font size and therefore double:
|
||||
**`ch`, `ex`, `cap`, `lh`, `rlh`** all respond — in property values, in `@media`
|
||||
**and** in `@container` (`ch` 10.608 → 21.216 px; `@media (min-width: 100ch)`
|
||||
flips). Only `rem`, `em`, `ic` and `px` are blind. The false generalisation was
|
||||
written into `docs/02`, `docs/06`, `global.css` and `tokens.css`, and it was then
|
||||
used as the premise for accepting a **WCAG 2.2 SC 2.4.11 (AA)** failure as
|
||||
unfixable — *"the only fix is JS"*. **The lesson is the shape, not the units: "no
|
||||
mechanism can X" is a claim about every mechanism, including the ones you did not
|
||||
enumerate.** Test the family you did not think of before writing "none", and
|
||||
prefer "every construct I measured is blind, and here is the list" — which is
|
||||
falsifiable and was what the measurement actually supported.
|
||||
|
||||
For *reflow* the conclusion is unchanged: **used-value layout — wrapping — is
|
||||
still the right mechanism**, because it needs no threshold and no fitted constant.
|
||||
That path
|
||||
was the worst of the three: on one grid of 22 pages × 16 widths, **219 of 352
|
||||
page-widths overflowed** against root-style's **175**, and it was the only one
|
||||
failing at 320 px and 1024 px. **Always state the grid with the count** — two
|
||||
sweeps in that session quoted totals of 220, 330 and 352 for the same claim, and
|
||||
side by side they read as contradictions rather than as different width lists.
|
||||
Measure all three mechanisms; treat a clean result from one as evidence about
|
||||
that one.
|
||||
|
||||
**And a grep that matches is not a finding until you read what it matched.**
|
||||
A case-insensitive sweep for `LSO` hit `I aLSO practise`; a superlative sweep for
|
||||
`leading` hit `the pLEADINGs`. Both on the same page on the same day. Print the
|
||||
|
||||
+69
-19
@@ -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';
|
||||
@@ -75,8 +81,9 @@ const SITE_ORIGIN = requireEnv('SITE_ORIGIN');
|
||||
const NOTIFY_TO = requireEnv('NOTIFY_TO');
|
||||
const MAIL_FROM = requireEnv('MAIL_FROM');
|
||||
|
||||
/** 24 months, docs/05 §Retention — enforced by DynamoDB TTL, "not a policy
|
||||
* someone remembers". It must match /legal/privacy/ exactly. */
|
||||
/** 24 months, docs/05 §Retention. It must match /legal/privacy/ exactly.
|
||||
* ⚠️ Writing this attribute is NOT the mechanism — TTL must be enabled on the
|
||||
* table, and AGENTS.md §7 records whether it is. */
|
||||
const RETENTION_MONTHS = 24;
|
||||
|
||||
/** The public commitment, §4 and Q27. It must read identically here, on
|
||||
@@ -198,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
|
||||
@@ -305,8 +339,10 @@ export async function handler(event) {
|
||||
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
const ttl =
|
||||
Math.floor(
|
||||
/* NO `|| 0` FALLBACK (removed 2026-08-31): DynamoDB will not expire an item
|
||||
whose TTL is more than five years past, so `ttl: 0` means RETAINED FOREVER
|
||||
while /legal/privacy/ promises deletion. Let a bad value fail the write. */
|
||||
const ttl = Math.floor(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth() + RETENTION_MONTHS,
|
||||
@@ -315,25 +351,36 @@ export async function handler(event) {
|
||||
now.getUTCMinutes(),
|
||||
now.getUTCSeconds(),
|
||||
) / 1000,
|
||||
) || 0;
|
||||
);
|
||||
|
||||
/**
|
||||
* 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),
|
||||
},
|
||||
@@ -375,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
|
||||
|
||||
|
||||
+935
-23
@@ -98,7 +98,9 @@ Preload only the two faces used above the fold — Instrument Serif regular and
|
||||
Geist 400.
|
||||
|
||||
**Scale.** Fluid, `clamp()`, `1.25` ratio at the small end widening to `1.333` at
|
||||
the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
|
||||
the display end. Tokens `--text-2xs` through `--text-6xl` in `tokens.css`, plus
|
||||
`--text-eyebrow` at 14 px — the same value as `--text-sm` and deliberately not an
|
||||
alias of it; see the eyebrow rule below.
|
||||
|
||||
**Rules.**
|
||||
|
||||
@@ -107,8 +109,102 @@ the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
|
||||
- Display line-height `0.95`–`1.05`; letter-spacing `-0.02em`.
|
||||
- Body line-height `1.6`. Measure capped at `68ch` — the old site ran full-bleed
|
||||
paragraphs at 1400 px, which is unreadable.
|
||||
- Eyebrows: mono, 11–12 px, `0.18em` tracking, uppercase, always paired with a
|
||||
real heading. An eyebrow is not a heading and never carries the `<h*>`.
|
||||
- Eyebrows: mono, **14 px** (`--text-eyebrow`), `0.18em` tracking, uppercase,
|
||||
weight 500, always paired with a real heading. Pouya raised it **12 → 13 → 14 px
|
||||
on 2026-08-31**, in two passes, because uppercase mono at this tracking reads
|
||||
smaller than it measures and 13 px was still getting lost against the display
|
||||
type. There is **one definition**, `.eyebrow` in `global.css`. An eyebrow is not
|
||||
a heading and never carries the `<h*>` — but a real heading may carry the class,
|
||||
and **fourteen do, in two groups**: the footer's four column headings
|
||||
(`SiteFooter.astro`) and `/bio/`'s ten block headings.
|
||||
⚠️ **"ONE DEFINITION" HAS ONE CARVE-OUT AND IT IS NAMED RATHER THAN IMPLIED:**
|
||||
`/contact/`'s `<label>`/`<legend>` set and its direct-contact `<dt>`s repeat the
|
||||
treatment instead of taking the class, because they need `--text-secondary`
|
||||
(11.75 : 1) rather than `.eyebrow`'s `--text-meta` (5.47 : 1) — a form label is
|
||||
operative text. **Everything else about them matches, `font-weight` included**;
|
||||
without that they rendered at 400 beneath a `p.eyebrow` of the same size and
|
||||
colour, which is the divergence a carve-out is meant to bound rather than hide.
|
||||
Do not widen this to any other element.
|
||||
**14 px is the same value as `--text-sm`, and `--text-eyebrow` is deliberately
|
||||
not an alias of it.** The two move for different reasons; aliasing would mean a
|
||||
future change to body-meta type silently moved every eyebrow on the site.
|
||||
(This bullet said "13 px is not a rung on the scale — it sits between
|
||||
`--text-xs` and `--text-sm`" for one revision. At 14 px it is that rung.)
|
||||
- **THE 11 px FLOOR HAS EXACTLY ONE CONSUMER, AND THAT IS THE CLAIM THAT IS
|
||||
TRUE.** Pouya's ruling, 2026-08-31: *"form labels are operative text, and the
|
||||
site should have exactly one small-text floor."* `--text-2xs` (11 px) now has
|
||||
**one consumer in `src/`** — the header tagline (`git grep 'var(--text-2xs)' --
|
||||
src` returns one hit, `SiteHeader.astro`; 21 rendered instances, every one
|
||||
`span.eyebrow.brand-tagline`) — and nothing else may use it without a
|
||||
measurement recorded beside it.
|
||||
⚠️ **THIS BULLET SAID "THE SMALL-TEXT FLOOR IS 14 px, AND THERE IS EXACTLY ONE
|
||||
EXCEPTION" FOR ONE REVISION, AND THAT WAS FALSE** — falsified by this document
|
||||
thirty lines below, which concedes a 12 px family. The ruling asked for the
|
||||
record that the tagline is the only sub-14 px text on the site; **it is not, so
|
||||
that is not what is recorded here.** What is true is the sentence above: one
|
||||
consumer of the 11 px token. `--text-xs` (12 px) is a separate rung and is
|
||||
enumerated below.
|
||||
**The exception, and why it is deliberate rather than residual:** the tagline is
|
||||
ornamental and layout-constrained. ⚠️ **THE COST WAS RE-MEASURED ON 2026-09-01
|
||||
AND IT IS A DIFFERENT COST NOW — the masthead may wrap, so what used to overflow
|
||||
invisibly is visible instead.** At `--text-eyebrow` (14 px) the header stands at
|
||||
**144.98 px** rather than 81 px — at 1216 px with six nav items, and at **every
|
||||
width from 1216 px up** with a seventh (measured at 1216 / 1240 / 1280 / 1360 /
|
||||
1440 / 1600 / 1760 / 1920) — because the row wraps. *(This listed four widths
|
||||
ending at 1440 for one revision; that list was carried over from the superseded
|
||||
CTA-past-the-edge measurement, which really did stop at 1440.)* **Document overflow
|
||||
is 0 and the CTA sits exactly on `.header-inner`'s content edge in every one of
|
||||
those cases**, where before the fix they were 20 px of overflow at 1216 px, 4 px
|
||||
at 1280 px, and the CTA 67.8 / 43.8 / 51.8 / 51.8 px past the content edge. So
|
||||
the two costs this bullet used to cite are **gone**, replaced by one that is
|
||||
larger and easier to see: 64 px of header height on every page. *(13 px took the
|
||||
header to 83.4 px; that figure was taken before the masthead could wrap.)*
|
||||
Insights is that seventh item. The measurements are in `SiteHeader.astro`.
|
||||
**Three blocks moved to `--text-eyebrow` on 2026-08-31 and their carve-outs are
|
||||
superseded.** (a) `/contact/`'s `<label>`/`<legend>` set — *raise, do not
|
||||
ratify*, superseding the "accepted, not ratified" note this bullet carried for
|
||||
one revision. (b) `/contact/`'s direct-contact `<dl>` terms, which are label
|
||||
text on the same page and would otherwise have been left a step behind the
|
||||
labels beside them. (c) `/bio/`'s ten block headings and its strap, which were
|
||||
**copies of five of `.eyebrow`'s six declarations, at 11 px** — the same escape
|
||||
the footer's column headings turned out to be — and now carry the class. Not
|
||||
byte-for-byte: the `font-size` differed, and **the missing sixth was
|
||||
`font-weight`**, which is precisely the one the print block now has to freeze.
|
||||
⚠️ **(c)'s carve-out was justified by a reason that did not apply.** This bullet
|
||||
said the `/bio/` sheet was held at 11 px because `npm run bio:pdf` holds it to
|
||||
one page. Measured 2026-08-31: the `@media print` block sets both elements to
|
||||
**7 pt**, so the screen size never reached the PDF and the one-page constraint
|
||||
was never what kept them small. **Print does now freeze `font-weight: 400`**,
|
||||
because taking `.eyebrow`'s 500 changed the printed sheet — the PDF grows from
|
||||
**89,496 to 91,151 bytes, +1,655** — and the ruling that moved them was about
|
||||
size. *(This read "63,743 bytes" for one revision. That is the `cmp -l`
|
||||
differing-byte count, not the size delta: the content stream is Flate-compressed,
|
||||
so a one-property change scrambles most of the file. Two figures, and the wrong
|
||||
one answered a question nobody asked.)* ⚠️ **THAT FREEZE IS NOW A RULED
|
||||
CONSTRAINT, 2026-09-01, NOT A DECISION AWAITING POUYA: the circulated PDF's
|
||||
typography changes only when its CONTENT is deliberately revised, never as a
|
||||
side effect of a screen refactor.** So the print block keeps 400 whatever the
|
||||
screen does, and `.eyebrow`'s 500 stops at the `@media print` boundary. **The
|
||||
reason it is a rule and not a preference** is that the PDF is the one artefact
|
||||
this project's apparatus sees only when someone re-renders it (`AGENTS.md`
|
||||
R16): a refactor that silently re-typesets it changes a document already in a
|
||||
reader's hands, and the diff that would have shown it is a 1,655-byte
|
||||
compressed blob nobody reads. A screen change that reaches print is therefore
|
||||
a defect by construction, not a judgement call — see `bio.astro`.
|
||||
**`--text-xs` (12 px) IS A SEPARATE RUNG AND IT WAS NOT IN SCOPE ON 2026-08-31.
|
||||
Ten declaration sites, all of them:** `Pill`, `Breadcrumbs`, `CredentialRow`,
|
||||
`DefinitionGrid`, `ArticleCard`, `ProcessStep` (two), `insights/[...slug].astro`,
|
||||
and **`/bio/`'s two — `.fine` and `.sheet-contact p`**. The first eight are mono
|
||||
and uppercase or tracked at `0.06em`, a third of the eyebrow's tracking, which is
|
||||
what makes them a different treatment. **`/bio/`'s two are neither uppercase nor
|
||||
tracked**, so they are not in that family at all — they are simply 12 px text,
|
||||
and an earlier draft of this bullet defined the exception in a way that excluded
|
||||
them and therefore missed them.
|
||||
**Rendered count, and say what it is a number of:** at 1280 px / root 16 across
|
||||
all 22 pages, **130 elements carrying their own text compute to 12 px** (149 if
|
||||
elements that merely inherit the size are counted). `ArticleCard` and the article
|
||||
meta contribute **zero** today — no article is published, so neither renders.
|
||||
Every instance measures ≥ 5.01 : 1.
|
||||
- Italic display (`.it`) is the one flourish the design allows. One italic phrase
|
||||
per headline, at most.
|
||||
- Never skip a heading level. `<h1>` once per page.
|
||||
@@ -227,6 +323,12 @@ Not a polish pass. A build requirement.
|
||||
32 px, a real accessibility setting and not page zoom — `/` measured **234 px**
|
||||
of overflow at 390. Brought down in three measured steps:
|
||||
|
||||
⚠️ **THE THREE TABLES BELOW ARE A HISTORY OF HOW THE NUMBER CAME DOWN, NOT THE
|
||||
CURRENT STATE.** Every residual in their final rows — the 3 px, the 23 px and the
|
||||
63 px — went to **0 on 2026-08-31**, on all 22 pages at 320 / 360 / 390 / 414 /
|
||||
640 / 1024 px. The current state is §Reflow below, which also carries what is
|
||||
still NOT zero at 1280 px and 1920 px. Read the rows as dated steps.
|
||||
|
||||
| Fix | 390 px | 320 px |
|
||||
|---|---|---|
|
||||
| as first built | 234 px | 304 px |
|
||||
@@ -256,10 +358,13 @@ Not a polish pass. A build requirement.
|
||||
| `/med-arb/` | **0 px** | 23 px | 63 px |
|
||||
|
||||
At the **default** root size all three are **0 px at every width measured**.
|
||||
The element-level sweep at 320/root-32, predicate `right > clientWidth`,
|
||||
names **three** `PracticeCard` chips on `/mediation/` — Construction,
|
||||
Technology, Shareholder — plus the header and footer brand elements, which
|
||||
appear on all five pages. *(This read "six" for one pass. Six is the count of
|
||||
⚠️ **THE ELEMENT-LEVEL SWEEP BELOW IS ALSO HISTORICAL.** It read: *"the
|
||||
element-level sweep at 320/root-32, predicate `right > clientWidth`, names three
|
||||
`PracticeCard` chips on `/mediation/` — Construction, Technology, Shareholder —
|
||||
plus the header and footer brand elements, which appear on all five pages."*
|
||||
Re-run with that exact predicate on 2026-08-31: **0 elements**, on all five pages
|
||||
at 320 / 360 / 390 with root 32. The chips were fixed at their cause
|
||||
(`PracticeCard`'s padding) and the brand elements at theirs; see §Reflow. *(This read "six" for one pass. Six is the count of
|
||||
`article.card` with `scrollWidth > clientWidth`, a different predicate on a
|
||||
different element; `adversarial-reviewer` re-ran it and no predicate yields
|
||||
six chips. A number in this table has to be re-runnable, which is the whole
|
||||
@@ -275,27 +380,834 @@ Not a polish pass. A build requirement.
|
||||
single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content
|
||||
box at 320 px; `overflow-wrap: anywhere` is the only remedy that reduces
|
||||
min-content size. `/about/` now measures equal to or better than `/` at all
|
||||
three widths, and its 320/360 residual is the same header decision.
|
||||
three widths. *(Its 320/360 residual was recorded here as "the same header
|
||||
decision" until 2026-08-31, when that attribution turned out to be wrong and the
|
||||
residual went to 0 — see below.)*
|
||||
|
||||
Command, so the numbers are re-runnable rather than quoted: headless Chrome
|
||||
over the built `dist`, `document.documentElement.style.fontSize = '32px'`, then
|
||||
`documentElement.scrollWidth - documentElement.clientWidth`, plus an
|
||||
enumeration of every element wider than `clientWidth` to name the offender.
|
||||
enumeration of every element that either **is wider than `clientWidth`** or
|
||||
**whose own content overflows its own box** (`scrollWidth - clientWidth`), to
|
||||
name the offender. ⚠️ **Both halves of that predicate are load-bearing and the
|
||||
second was added on 2026-09-01, after the first half alone reported "no offender"
|
||||
at three of the four widths that were failing** — the footer email spilled text
|
||||
out of a box that was itself inside the viewport. A sweep that enumerates only
|
||||
over-wide *elements* will name no cause for a whole class of real overflow.
|
||||
|
||||
Two things worth keeping. **`overflow-wrap: break-word` permits a break at
|
||||
layout time but does not reduce min-content size** — `anywhere` does, and that
|
||||
distinction was the whole of one of those fixes. And 1280 px stays over, from the header's
|
||||
deliberate `flex-wrap: nowrap` above 66 rem plus `white-space: nowrap` on the
|
||||
brand name — **602 px on `/` and 944 px on every other page**, because
|
||||
`SiteHeader` gates the tagline on `!isHome`, so the masthead is wider
|
||||
everywhere except home. *(This read a flat "602 px" until 2026-08-28. That
|
||||
figure was measured on `/` and generalised; `/about/` was already 944 when it
|
||||
was added to this table, and step 4's three pages are 944 too. Found by
|
||||
`adversarial-reviewer`.)* The 320 px residual is the same header
|
||||
plus the display headline's 104 px floor. Undoing either re-opens the measured
|
||||
step-1 header decision, so they stand. All of this is beyond what this floor
|
||||
requires — page zoom is clean — so it is a robustness margin rather than a
|
||||
failure. Revisit if a real reader hits it.
|
||||
⚠️ **AND THE RECIPE ABOVE HAS A UNIFORM-PASS TRAP THAT IS NOT VISIBLE IN IT.**
|
||||
Run `document.documentElement.style.fontSize = '32px'` with
|
||||
`prefers-reduced-motion: reduce` in force — launch flag *or* CDP emulation — and
|
||||
it reports **0 of 352 with the enlargement never applied**: brand width, header
|
||||
height and document height all byte-identical to root 16. The reduced-motion
|
||||
reset emits `*,::before,::after{transition-duration:.01ms!important}` over the
|
||||
initial `transition-property: all`, so the root font-size change becomes a
|
||||
`CSSTransition` and a synchronous read returns its START value.
|
||||
`document.getAnimations()` shows `CSSTransition: font-size, running, t=0`; after
|
||||
one `requestAnimationFrame` it is 32 px. **Kill motion with an injected
|
||||
`transition:none;animation:none` sheet rather than by emulating reduced motion,
|
||||
and assert the applied root size per cell** — that assertion is the only thing
|
||||
that distinguishes this from a clean sweep. Found by `adversarial-reviewer`,
|
||||
round 2, running this section's own published recipe.
|
||||
|
||||
✅ **THE LARGE-TEXT NAV OVERFLOW IS FIXED, 2026-09-01.** Pouya reopened the
|
||||
step-1 header decision for the purpose — *"its record attributed the residual to
|
||||
the wrong cause and characterized a measured 944 px functional failure as a
|
||||
robustness margin"* — and ruled *fix, do not accept*. What follows is the
|
||||
current state. The narrow case was ruled a defect on 2026-08-31 (*"the
|
||||
152/112/82 px overflows at 320/360/390 with 32 px root text violate WCAG 1.4.10
|
||||
and are not accepted"*) and fixed then.
|
||||
|
||||
**THREE MECHANISMS ENLARGE TEXT, AND THEY ARE NOT EQUIVALENT — this is the
|
||||
measurement the whole record turned on.** Every figure in this section before
|
||||
2026-09-01 came from `documentElement.style.fontSize = '32px'`. A later sweep
|
||||
used Chrome's `Page.setFontSizes({standard: 32})`, reported 0 everywhere, and
|
||||
the prose generalised that zero. All three are re-measured below.
|
||||
|
||||
| mechanism | `rem` in a media query | `rem`/`em` in a PROPERTY | `getComputedStyle` root | sees the enlargement? |
|
||||
|---|---|---|---|---|
|
||||
| `documentElement.style.fontSize='32px'` (root style) | **16 px → `66rem` = 1056 px** | 32 px | 32 px | media query **no**, property **yes** |
|
||||
| `Page.setFontSizes({standard:32})` (the reader's *default font size*) | 32 px → `66rem` = **2112 px** | 32 px | 32 px | **yes, both** — the breakpoint moves with the type |
|
||||
| `--blink-settings=minimumFontSize=32` (the reader's *minimum font size*) | 16 px → `66rem` = **1056 px** | **16 px** | **32 px** | **NO `rem`/`em` CONSTRUCT SEES IT** — the columns of this table are all `rem`/`em`, and that is the limit of what the row measures. ⚠️ **The FONT-METRIC units DO see it** (`ch`, `ex`, `cap`, `lh`, `rlh`); see the table below |
|
||||
|
||||
*(Row 1's media-query cell read "32 px → 2112 px" for one revision, which
|
||||
contradicted its own verdict column, the paragraph below it, and measurement —
|
||||
and it deleted the one number that explains why the failure is reachable at all.
|
||||
If `66rem` really resolved to 2112 px under root scaling, the 66 rem desktop
|
||||
masthead would never be reached at 1280 px and the 944 px overflow could not
|
||||
happen. Found by `adversarial-reviewer`.)*
|
||||
|
||||
Two consequences, both measured 2026-09-01 and both load-bearing:
|
||||
|
||||
1. **In a media query `rem` resolves against the browser's DEFAULT font size,
|
||||
not the root element's.** So raising the *default* moves the breakpoints
|
||||
along with the type and the desktop masthead is never reached; setting the
|
||||
*root* doubles the type and leaves the breakpoints where they were, which is
|
||||
the layout the desktop nav was measured in. **A container query is the one
|
||||
construct that differs** — `@container` `rem`/`em` DO resolve against the
|
||||
root element, measured — which is why the sticky gate below is a property and
|
||||
not a query.
|
||||
2. ⚠️ **UNDER A MINIMUM-FONT-SIZE SETTING, `getComputedStyle(el).fontSize` AND
|
||||
THE `em` UNIT DISAGREE.** Blink floors the *reported computed* font size to
|
||||
32 px while `em` and `rem` keep resolving at 16 px — so `scroll-padding-top:
|
||||
6.0625em` computes to **97 px, not 194 px**, a `66em`-wide box measures
|
||||
**1056 px, not 2112 px**, and `min(0px, calc(100vw - 66em))` is **0px**.
|
||||
⚠️ **AND THE SENTENCE THAT USED TO SIT HERE WAS FALSE AND LOAD-BEARING: it
|
||||
said "media queries, container queries and length units are all blind to it"
|
||||
and that "only used-value layout — wrapping — responds".** It is the
|
||||
`rem`/`em` FAMILY that is blind, not CSS. The **font-metric** units read the
|
||||
*used* font size and therefore double, and they do so in all three
|
||||
constructs — measured 2026-09-01 on `/fees/` @1280, headless Chrome, one
|
||||
probe element per cell:
|
||||
|
||||
| construct | `rem`, `em`, `ic`, `px` | `ch`, `ex`, `cap`, `lh`, `rlh` |
|
||||
|---|---|---|
|
||||
| property value | blind (16 px → 16 px) | **responds** — `ch` 10.608 → 21.216 px, `ex` 8.48 → 16.96, `cap` 11.36 → 22.72, `lh` 25.59 → 51.19 |
|
||||
| `@media (min-width: N<unit>)` | blind — threshold never flips | **responds** — `100ch`, `110ex`, `80cap`, `45lh` all flip |
|
||||
| `@container (min-width: N<unit>)` | blind | **responds** — `45ex`, `32cap` flip |
|
||||
|
||||
So wrapping is still the right mechanism for *reflow* — it needs no
|
||||
threshold and no fitted constant — but **"no CSS can detect this setting" is
|
||||
not true, and the residual below is therefore not provably unfixable.**
|
||||
Found by `adversarial-reviewer` and reproduced independently.
|
||||
|
||||
**Before and after, 22 pages × 16 widths (320 → 1920 px) = 352 page-widths per
|
||||
setting, document overflow `documentElement.scrollWidth −
|
||||
documentElement.clientWidth`. Both columns measured on the SAME grid**, from a
|
||||
build of the previous commit and a build of the working tree, by the same probe:
|
||||
|
||||
| text setting | before | after |
|
||||
|---|---|---|
|
||||
| default (root 16) | 0 of 352 | **0 of 352** |
|
||||
| root style 32 px | **175 of 352** | **0 of 352** |
|
||||
| default font size 32 | 0 of 352 | **0 of 352** |
|
||||
| minimum font size 32 | **219 of 352** | **0 of 352** — the header fix left 88, all of them the footer; the footer fix below closed them, 2026-09-01 |
|
||||
|
||||
✅ **SO: ZERO DOCUMENT OVERFLOW UNDER ALL FOUR METHODS AT EVERY WIDTH
|
||||
MEASURED — AND THE SCOPE OF THAT SENTENCE IS EXACTLY THE GRID ABOVE.** 22 built
|
||||
pages × those 16 widths × four enlargement methods = **1,408 page-widths**,
|
||||
document overflow by `documentElement.scrollWidth − documentElement.clientWidth`,
|
||||
webfonts loaded, six nav items, `prefers-reduced-motion` neutralised by an
|
||||
injected sheet rather than emulated. Plus **762** further points for the sticky
|
||||
gate (roots 9–15, roots 16–32, the band seam, both thresholds, all five Chrome
|
||||
presets), 0 failing.
|
||||
|
||||
⚠️ **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 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
|
||||
measured by them — which is the reason each is recorded in its own right rather
|
||||
than summarised into the table. Widths
|
||||
between the sampled ones are not measured either — the grid is 16 columns, not a
|
||||
continuum, and the two thresholds and the band seam were swept precisely because
|
||||
a 16-column grid can step over a 36 px band.
|
||||
|
||||
**The wide-width figures that were the defect, root style 32 px, and what they
|
||||
are now.** Every one is 0 of 22 pages after the fix, and the nav items and the
|
||||
CTA are on-screen at every width:
|
||||
|
||||
| width | before | after |
|
||||
|---|---|---|
|
||||
| 1056 | 649 px, 22 of 22 | **0** |
|
||||
| 1100 | 606 px, 22 of 22 | **0** |
|
||||
| 1216 | 928 px / 491 px on `/`, 22 of 22 | **0** |
|
||||
| 1240 | 904 px / 468 px on `/` | **0** |
|
||||
| 1280 | **944 px** / 508 px on `/` | **0** |
|
||||
| 1440 | 784 px / 351 px on `/` | **0** |
|
||||
| 1600 | 624 px / 193 px on `/` | **0** |
|
||||
| 1920 | 304 px, 21 of 22 | **0** |
|
||||
|
||||
*(Only 1280 and 1920 were in the earlier record, so it never showed that the
|
||||
failure ran the whole width of the sticky range. The same run under minimum font
|
||||
size was 414 / 370 / 854 / 830 / 830 / 750 / 670 / 510 px, of which the record
|
||||
carried only the 830.)*
|
||||
|
||||
⚠️ **AND IT WAS NOT A MARGIN — IT WAS UNREACHABLE NAVIGATION.** The furthest
|
||||
element was `div.header-cta` at **2224 px** against a 1280 px viewport; the nav
|
||||
clipped mid-word after "Arbitration", and **Practice, Fees, Contact and the
|
||||
header CTA sat entirely off-screen** behind a horizontal scrollbar — the route
|
||||
to the inquiry form among them. WCAG 1.4.4 with loss of functionality. The
|
||||
paragraph struck here called it *"a robustness margin rather than a failure.
|
||||
Revisit if a real reader hits it."*
|
||||
|
||||
**THE FIX IS TWO DECLARATIONS, AND THE SECOND ONE IS THERE BECAUSE THE FIRST
|
||||
ONE HAS A CONSEQUENCE.**
|
||||
|
||||
**(1) The masthead may wrap.** `flex-wrap: nowrap` is gone from `.header-inner`
|
||||
and from `.nav-list` above 66 rem, along with the dead `flex: none` beside it.
|
||||
|
||||
⚠️ **WHICH OF THE TWO WAS THE CAUSE IS NOW MEASURED, AND THIS PARAGRAPH FIRST
|
||||
GOT IT WRONG.** It said *"that pair, not any one declaration, was the cause"*.
|
||||
Restoring each declaration alone on the fixed build, root 32, `/about/` —
|
||||
document overflow / number of nav links and CTA off-screen:
|
||||
|
||||
| restored above 66 rem | 1056 | 1216 | 1280 | 1440 | 1920 |
|
||||
|---|---|---|---|---|---|
|
||||
| neither (shipped) | 0/0 | 0/0 | 0/0 | 0/0 | 0/0 |
|
||||
| `.nav-list { flex-wrap: nowrap }` only | 0/0 | 0/0 | 0/0 | 0/0 | 0/0 |
|
||||
| `.header-inner { flex-wrap: nowrap }` only | 0/0 | 273/1 | 209/1 | 49/1 | 0/0 |
|
||||
| both (= the state before) | 649/3 | 928/5 | 944/5 | 784/4 | 304/1 |
|
||||
|
||||
**`.header-inner`'s `nowrap` was necessary and sufficient.** And the second row
|
||||
is the one that matters for honesty: **`.nav-list`'s removal is inert** — byte-
|
||||
identical to shipped at every width, under a 200 % root size *and* under minimum
|
||||
font size, **with six nav items and with seven**. It is kept removed for two
|
||||
reasons that are not "it fixed the overflow": the `nowrap` prohibition in
|
||||
`SiteHeader.astro` would otherwise contradict a `nowrap` still sitting in the
|
||||
file, and the override only ever re-stated `nowrap` over a base rule that already
|
||||
wraps. Found by `adversarial-reviewer`; the mechanism sentence about min-content
|
||||
was true and was not the binding constraint. *(`flex` is a flex-ITEM property and `.nav`
|
||||
computes `display: block`, measured at 1056 / 1280 / 1920 px — so `flex: none`
|
||||
set `flex-shrink: 0` on something that is not a flex item and had **no effect**.
|
||||
Removing it leaves `.nav`'s and `.nav-list`'s geometry byte-identical at all
|
||||
three widths; the only trace was the computed `flex-shrink`, 0 → 1. Its comment
|
||||
claimed it was what stopped the nav being squeezed, which it never was.)*
|
||||
**It cost nothing at any normal size: 0 geometry
|
||||
differences across 22 pages × 16 widths (352 page-widths)**, header exactly **81.00 px** at every
|
||||
width from 1056 px up, brand on one line, nav on one line, CTA exactly on
|
||||
`.header-inner`'s content edge (gap 0.00 px) — **and the same with a seventh nav
|
||||
item injected**, at 1024 / 1047 / 1056 / 1071 / 1100 / 1150 / 1200 / 1216 / 1240
|
||||
/ 1280 / 1440 / 1920 px, where all seven share one line from 1056 px up. So the
|
||||
`nowrap` was not load-bearing at any width above the breakpoint; the 66 rem
|
||||
breakpoint is what keeps the row intact, and it already accounts for seven items.
|
||||
|
||||
**(2) The masthead is sticky only while it is one row**, and this half took two
|
||||
attempts — the first one shipped a regression, which is recorded because the
|
||||
reason it failed is the useful part.
|
||||
|
||||
Wrapping makes the header **taller** at enlarged text: **244.59–350.86 px** at a
|
||||
200 % root size against 81 px normally. A sticky box that size covers `#main`
|
||||
after "Skip to content", so stickiness has to go wherever the header can exceed
|
||||
`scroll-padding-top`. The gate is a **property**, not a query, because in a
|
||||
property `rem` tracks the root element.
|
||||
|
||||
⚠️ **ROUND 1 WROTE `min(0px, calc(100vw - 66rem))` AND IT WAS WRONG IN TWO WAYS
|
||||
THAT ONLY AN INTERMEDIATE ROOT SIZE EXPOSES.** Both were found by
|
||||
`adversarial-reviewer` and independently reproduced: **20 of 300**
|
||||
(page × viewport × root) points left `#main` behind the header, 10–69 px, across
|
||||
roots 18–30 — while root 16 and root 32, the only two sizes the first sweep
|
||||
measured, were both clean. **WCAG 1.4.4 is a requirement up TO 200 %, not AT
|
||||
200 %**, and a two-point sweep cannot see a diagonal band.
|
||||
|
||||
1. **It RAMPED where it needed to STEP.** One pixel inside the threshold the term
|
||||
lifts the header by one pixel — but the header has already gained a whole row,
|
||||
so it still covered `#main`. Wrapping is a step function; the response has to
|
||||
be one too. Hence the `* 100000` saturation, bounded by `-100vh`.
|
||||
2. **It used the wrong threshold in the wider band.** The tagline appears at
|
||||
76 rem, and with it the one-row masthead fits only from **1207 px = 75.4 rem**
|
||||
— the step-1 binary search already recorded that number. Gating the wide band
|
||||
on 66 rem therefore fired far too late. **`/` measured clean throughout, and
|
||||
that is what identified the driver:** `/` is the one page that suppresses the
|
||||
masthead tagline.
|
||||
|
||||
⚠️ **AND ROUND 2 FOUND THAT THE GATE WAS ONE-SIDED — it could only fire when the
|
||||
root was LARGER than the default.** Chrome's "Font size" control has **five**
|
||||
presets (Very small 9 px, Small 12, Medium 16, Large 20, Very large 24) and the
|
||||
sweeps had used 16 and 32 — 32 is not one of them. At **9 px the masthead wraps
|
||||
for the opposite reason**: `--width-content` is `80rem`, so the content column
|
||||
shrinks to 720 px while the header's `min-inline-size: 44px` / `min-block-size:
|
||||
44px` floors do not, and the row wraps at **every** viewport width. The header
|
||||
stood at **120 px against a 54.56 px `scroll-padding-top`** — **65 px of `#main`
|
||||
behind it on the 21 pages that render the tagline**, at 1056 through 1920 px.
|
||||
`/` measured 12 px, and that 12 px is **pre-existing**: the previous build is 67 px
|
||||
against the same 54.56 px offset on all 22 pages. So the wrap fix widened a
|
||||
pre-existing 12 px defect to 65 px, and the second term closes both.
|
||||
|
||||
**The shipped form is two bands, each gating on the width ITS layout requires,
|
||||
and two terms, each catching one direction:**
|
||||
|
||||
```css
|
||||
@media (min-width: 66rem) { .site-header {
|
||||
position: sticky;
|
||||
inset-block-start: clamp(-100vh,
|
||||
min(calc((100vw - 66rem) * 100000), calc((1rem - 16px) * 100000)), 0px); } }
|
||||
@media (min-width: 76rem) { .site-header {
|
||||
inset-block-start: clamp(-100vh,
|
||||
min(calc((100vw - 76rem) * 100000), calc((1rem - 16px) * 100000)), 0px); } }
|
||||
```
|
||||
|
||||
`1rem - 16px` is negative exactly when the root is below the CSS initial font
|
||||
size — the 16 px the whole rem scale is built on — and it also catches the
|
||||
root-style equivalent, where the previous form left the header sticky at 69 px
|
||||
against a 60.63 px offset at root 10. **The cost, measured and accepted:** a
|
||||
reader whose default text size is "Small" (12 px) loses the sticky header, where
|
||||
it currently works — but only just: the header is 73 px against a 72.75 px offset,
|
||||
a **0.25 px** margin. Trading a 0.25 px margin for a provable gate is the right
|
||||
way round.
|
||||
|
||||
Both thresholds are the existing layout breakpoints, and both sit just above the
|
||||
measured one-row fit width (1047 px ≈ 65.4 rem without the tagline, 1207 px ≈
|
||||
75.4 rem with it), so the gate fires **at or before** the wrap rather than after
|
||||
it. **At every normal size both evaluate to exactly `0px`** — a matched
|
||||
`min-width: 66rem` guarantees `100vw ≥ 1056 px` and a matched `min-width: 76rem`
|
||||
guarantees `100vw ≥ 1216 px`, because `100vw` counts a classic scrollbar and the
|
||||
query width does not, so the term can only be more positive.
|
||||
|
||||
**Measured after: 0.00 px of `#main` covered — 0 of 300 grid points across roots
|
||||
16–32 × viewports 1056–1920 on three pages, 0 of 176 rows at the default size and
|
||||
at a 200 % root size over all 22 pages, 0 of 144 cases sitting directly on the
|
||||
two thresholds, and 0 of 198 at the SEAM where the two bands meet** (viewports
|
||||
1180–1260 × roots 16–32). ⚠️ **`covered: 0` is also what a non-sticky header
|
||||
gives, so the seam was checked the other way round as well:** at the default size
|
||||
across 1180 / 1200 / 1210 / 1215 / 1216 / 1217 / 1220 / 1240 / 1260 / 1440 /
|
||||
1920 px the computed `inset-block-start` is `0px` and the header is still pinned
|
||||
at top 0.00 when scrolled — stickiness is preserved, not quietly lost. ⚠️ **Saturation leaves a transition band, and it is
|
||||
arithmetic rather than measured:** a finite factor means the response is only
|
||||
a near-step, so the band is about (header − `scroll-padding-top`) / factor ≈
|
||||
**0.0007 px** of viewport width. CDP takes integer viewport widths, so that band
|
||||
was not driven directly — it is bounded by the arithmetic, not by a probe.
|
||||
|
||||
*(Lightning CSS folds the factor into the units and emits
|
||||
`clamp(-100vh,10000000vw - 6600000rem,0px)`. Verified equivalent by measurement,
|
||||
not by reading: the computed `inset-block-start` is `0px` at every sticky width at
|
||||
the default size, and the header pins at top 0.00.)*
|
||||
|
||||
⚠️ **"AN OFFSET TOO SHORT CANNOT ARISE" WAS WRITTEN HERE AND IN TWO SHIPPED
|
||||
COMMENTS, AND IT IS FALSE. THE EXCEPTIONS ARE ENUMERATED RATHER THAN WAVED AT**,
|
||||
because a comment that says "cannot" is the kind that stops the next reader
|
||||
looking. The gate handles **root-relative** enlargement in both directions.
|
||||
What it cannot see, measured, header height against computed
|
||||
`scroll-padding-top`, header `top` = `0px` in every row:
|
||||
|
||||
| condition | header | offset | short by | status |
|
||||
|---|---|---|---|---|
|
||||
| `minimumFontSize=32` | 164.58–270.56 px | 97 px | **68–174 px** | 🛑 **OPEN — needs a fresh ruling.** The 2026-09-01 acceptance rested on two facts that are both false; and the real cost is **290 entirely-obscured focus stops, SC 2.4.11 AA**, not a short skip-link landing. **A pure-CSS detector DOES exist.** See below |
|
||||
| fallback font metrics + 7th nav item, **default** text size, 1056–1091 px | 141 px | 97 px | **44 px** | latent on `showInsights`. **HARD GATE: `AGENTS.md` R20 — the seventh item does not ship until this is fixed** |
|
||||
| `defaultFontSize=9` | 120 px | 54.56 px | ~~65 px~~ | **fixed** by the `1rem - 16px` term |
|
||||
| root-style 10 px | 69 px | 60.63 px | ~~8 px~~ | **fixed** by the same term |
|
||||
|
||||
**`--header-h` IS A FLOOR, NOT A CONSTANT**, and that is the sentence that
|
||||
had to change. It is the header's height at the **default** text size; above the
|
||||
default the masthead is deliberately taller, and declaration (2) is what makes
|
||||
that safe — where the gate can see the header exceed the token it is not sticky,
|
||||
so a `scroll-padding-top` that is too *generous* is harmless. ⚠️ **It does NOT
|
||||
make a short offset impossible: the two cases the gate cannot see are tabulated
|
||||
above.** The token previously asserted "one constant 81 px across
|
||||
every width where it is sticky", and that sentence is what made a 944 px
|
||||
overflow read as settled.
|
||||
|
||||
**What the narrow-width fix DID establish, and it is worth keeping separate.**
|
||||
The narrow residual had been charged to that same `flex-wrap: nowrap` — and for
|
||||
the narrow case that attribution was **wrong**. The cause there was
|
||||
`white-space: nowrap` on `.brand-name`, a different declaration in the same
|
||||
component, and removing it **changes nothing at any real size**: the brand name
|
||||
is one line at all 16 widths at root 16, the sticky header still measures exactly
|
||||
81 px at every width from 66 rem up, and the CTA still lands exactly on
|
||||
`.header-inner`'s content edge. **A residual defended by the wrong cause is
|
||||
defended forever**, because the reason given is one nobody wants to re-open —
|
||||
which is exactly what happened for four days.
|
||||
|
||||
**ELEVEN FIXES ACROSS THREE PASSES, IN TWELVE ROWS — no `overflow-x` was added
|
||||
anywhere.** Eight are narrow-width (2026-08-31), two are the large-text nav
|
||||
(2026-09-01), and the eleventh is the footer email (2026-09-01, the pass that
|
||||
closed the last recorded overflow). ⚠️ **The twelfth row is `.nav-list`, and it
|
||||
is a row without being a fix:** it is here because its removal was *measured*,
|
||||
and it is not counted because the measurement was that it changes nothing. Say
|
||||
so rather than leaving the arithmetic to a reader who counts rows — the heading
|
||||
read "TEN FIXES" over eleven rows until 2026-09-01 for exactly this reason. All
|
||||
but one of the eleven are cause-specific; the exception is marked as the
|
||||
backstop it is:
|
||||
|
||||
| Element | Was | 320 px | Cause |
|
||||
|---|---|---|---|
|
||||
| `SiteHeader .header-inner` | `flex-wrap: nowrap` removed above 66 rem | **944 px** at 1280 px, root 32 | **Necessary and sufficient, measured.** A `nowrap` flex line cannot break, so the brand / nav / CTA row could not reflow at all |
|
||||
| `SiteHeader .nav-list` | `flex-wrap: nowrap` and a dead `flex: none` removed above 66 rem | **0 px — inert** | Kept removed so the `nowrap` prohibition is not contradicted by a `nowrap` in the same file. Byte-identical to shipped at every width and every text setting, six items and seven |
|
||||
| `SiteHeader .site-header` | `inset-block-start: 0` → a saturating `clamp()` gate, in **two** bands (66 rem, and 76 rem where the tagline shows) | *(consequence of the row above)* | Wrapping makes the header 244.59–350.86 px at root 32; sticky at that height covered `#main` by up to 157 px after the skip link. **Round 1's single-band `min()` ramped instead of stepping and left 20 of 300 grid points covered by 10–69 px** |
|
||||
| `SiteHeader .brand-name` | `white-space: nowrap` removed | **63 px, all 22 pages** | Two words glued into one unbreakable box |
|
||||
| `/bio/ .rates-list` | `overflow-wrap: anywhere` | **152 px** | `https://adr.smlcompany.ca/fees/` has no break opportunity, and the single grid track it sized stretched all five rows to 424 px |
|
||||
| `/ .hero-h` | `overflow-wrap: anywhere` | 36 px | "contract," at 104 px held `.hero-copy` open |
|
||||
| `/ .feature-body` | `overflow-wrap: anywhere` | 26 px | "party-appointed" at 202 px |
|
||||
| `CredentialRow .credential-label` | `overflow-wrap: anywhere` | 38 px | "designation" needs 143 px in an 88 px track |
|
||||
| `SiteFooter .footer-contact a[href^='mailto:']` | `overflow-wrap: anywhere` | **14 px** | `info@smlcompany.ca` has no break opportunity and demands 310 px. Minimum-font-size only; also 38 / 30 / 19 px at 1024 / 1056 / 1100 px, by a different mechanism — see the footer block above |
|
||||
| `SiteFooter .footer-brand` | `flex-wrap: wrap` | 12 px | Flex item at `min-width: auto` cannot shrink below "Lajevardi" |
|
||||
| `PracticeCard .card` | `padding` clamped | *(the cause under `Pill`)* | The space scale is rem-based, so `--space-6` is 64 px a side at root 32 — 128 px of padding in a ~224 px box |
|
||||
| `Pill` | `overflow-wrap: anywhere` | 33 px → **1 px** | ⚠️ **SYMPTOM, NOT CAUSE.** A one-word pill cannot wrap at a space that is not there |
|
||||
|
||||
`flex-wrap: wrap` on the footer brand rather than `anywhere`, because it reflows
|
||||
without hyphenating a person's name.
|
||||
|
||||
⚠️ **`Pill`'s `anywhere` IS A BACKSTOP AND THE TRADE IS RECORDED RATHER THAN
|
||||
HIDDEN.** It removes the min-content floor of a shrink-to-fit `inline-flex` box,
|
||||
so the pill collapses to whatever its parent gives it. The real cause was
|
||||
`PracticeCard`'s padding: with it clamped, "Construction" at root 32 / 320 px
|
||||
went from **94 × 220 px broken into six two-character lines** to **158 × 85 px on
|
||||
two lines**, and root 16 is unchanged at 121 × 27 px on one line. **`anywhere` is
|
||||
still load-bearing** — removing it leaves 1 px of document overflow on `/`,
|
||||
`/mediation/` and `/practice/`, and renders the pill 240 px wide inside a 224 px
|
||||
card, escaping its own rounded border. Two lines is the better of those. The
|
||||
clamp does not change any normal size: 10vw holds 32 px from 320 px up.
|
||||
|
||||
**TEN instrument findings, and each one hid a real defect. Items 4–6 are from the
|
||||
2026-09-01 header pass and all three produced a CLEAN-LOOKING result; 7–10 are
|
||||
from the footer pass the same day. Each is recorded once — the footer block above
|
||||
points here rather than restating them.** ⚠️ **Finding 2 RECURRED in that footer
|
||||
pass**, in a probe written by the person who had written finding 2 down: it is
|
||||
described where it did its damage, beside the footer fix, and not duplicated as
|
||||
an eleventh item.
|
||||
|
||||
|
||||
4. ⚠️ **A UNIFORM PASS FROM A TAUTOLOGY.** The skip-link probe reported
|
||||
`0.0 px of #main covered` on **946 of 946** page-widths, before and after,
|
||||
under every mechanism. `global.css` sets `scroll-behavior: smooth`, which
|
||||
makes `scrollIntoView()` **asynchronous**, so a synchronous read afterwards
|
||||
measures `scrollY = 0`; and at `scrollY = 0` the target sits exactly at the
|
||||
header's bottom edge, so `covered` is 0 **by construction**. Pass
|
||||
`behavior: 'instant'` and assert that `scrollY` equals its predicted value —
|
||||
once fixed the same probe reported 10–26 px covered on 22 of 22 pages, a
|
||||
pre-existing defect the tautology had hidden.
|
||||
5. ⚠️ **A RUNNING TRANSITION MAKES A PROPERTY READ RETURN THE OLD VALUE.**
|
||||
Forcing reduced motion — by CDP emulation *or* by
|
||||
`--force-prefers-reduced-motion` — made `documentElement.style.fontSize =
|
||||
'32px'` read back as **16 px**, while the inline `style` attribute plainly
|
||||
said `font-size: 32px`. This codebase's reduced-motion block sets
|
||||
`transition-duration: 0.01ms !important` on `*`, which starts a transition on
|
||||
**every** property change, and a synchronous `getComputedStyle` then returns
|
||||
the transition's *start* value. The instrument now injects
|
||||
`transition:none;animation:none` instead. Same family as *a running
|
||||
transition outranks an `!important` author declaration*.
|
||||
6. **A DOM-INJECTED ELEMENT RECEIVES NO SCOPED STYLES.** Astro scopes component
|
||||
CSS with a `data-astro-cid-*` attribute, so the hand-built
|
||||
`<a class="nav-link">` used to test a seventh nav item got **none** of
|
||||
`.nav-link`'s rules — no `min-inline-size`, no `min-block-size`, no
|
||||
`font-size` — and measured narrower and shorter than a real item, which is
|
||||
the direction that makes a width test pass. **Clone a real node** and assert
|
||||
its computed styles match a sibling's.
|
||||
|
||||
7. ⚠️ **A CAUSE READ OFF A TRUNCATED LIST.** The header harness capped its
|
||||
offender array at `slice(0, 20)` while reporting `offenderCount` beside it,
|
||||
and **44 of the 88 failing rows exceeded the cap** — so the enumeration that
|
||||
was supposed to name the footer's cause was silently missing 34 of the 54
|
||||
boxes at 320 px. The truncation was visible only to a reader who compared the
|
||||
two numbers. Same family as the `tail -3` rule in `CLAUDE.md`, through a
|
||||
different pipe.
|
||||
8. ⚠️ **`grep -c` COUNTS LINES, NOT OCCURRENCES, AND MINIFIED CSS IS ONE LINE.**
|
||||
`grep -c 'overflow-wrap:anywhere' dist/_astro/*.css` returned `1` for a file
|
||||
that holds **three** such declarations. It was being used to confirm the new
|
||||
rule had reached `dist/`, which it could not do. Extract and print the bytes
|
||||
instead — the rule that actually settled it was reading the emitted selector,
|
||||
`.footer-contact[data-astro-cid-nns7i3if] a[data-astro-cid-nns7i3if][href^=mailto\:]`.
|
||||
|
||||
9. ⚠️ **`getClientRects().length` IS 1 FOR A `display: flex` ELEMENT HOWEVER MANY
|
||||
LINES OF TEXT IT HOLDS** — it is one block box, not a fragmented inline. The
|
||||
wrap probe therefore reported `lines=1` for the footer email while its own
|
||||
`height` said **102.38 px = 2 × 51.19**, i.e. two lines. Line counts come from
|
||||
`Range` rects over the text, which do fragment per line. **Two metrics of the
|
||||
same quantity disagreeing is the cheapest defect detector in this section** —
|
||||
it is what caught this one.
|
||||
10. ⚠️ **`Page.captureScreenshot`'s `clip` IS IN PAGE COORDINATES, NOT VIEWPORT
|
||||
COORDINATES.** Given a `getBoundingClientRect()` taken after a scroll, it
|
||||
captured a **blank cream plate** — of a footer that is ink. The screenshot
|
||||
existed to be the independent second method for a break the numbers could not
|
||||
judge, so a silently wrong one is worse than none. Add `window.scrollX/Y`, or
|
||||
pass `captureBeyondViewport: true` and page coordinates.
|
||||
|
||||
The original three:
|
||||
|
||||
1. **`overflow-wrap: break-word` permits a break at layout time but does not
|
||||
reduce min-content size** — `anywhere` does. Five of the fixes are this
|
||||
distinction. `global.css:166` sets `break-word` on **`p` AND `h1`–`h6`** —
|
||||
*(this line said "`h1`–`h6` and nothing else, so body copy inherits no
|
||||
wrapping at all", which was wrong in a way that mattered: `.feature-body`
|
||||
**is** a `<p>` and already had `break-word`, so the premise given for its fix
|
||||
was false while the fix itself was right)*. Nothing else gets one, so a
|
||||
`<span>`, `<li>`, `<dd>` or `<a>` inherits no wrapping.
|
||||
2. **`getBoundingClientRect()` reports BORDER BOXES, so an element sweep cannot
|
||||
see text spilling outside its own box.** `.credential-label`'s words ran 86 px
|
||||
past an 88 px cell while every element's rect stayed inside the viewport —
|
||||
the document was 38 px over and the offender was invisible to the predicate
|
||||
this table's own command uses. Read `scrollWidth - clientWidth` per element as
|
||||
well as per document.
|
||||
3. **Under `Emulation.setDeviceMetricsOverride({mobile: true})` the LAYOUT
|
||||
viewport expands to fit the content**, so `innerWidth` becomes 472 at a
|
||||
requested 320 and `scrollWidth - innerWidth` reads **0** while the reader is
|
||||
still scrolling sideways. The honest formula is
|
||||
`documentElement.scrollWidth - documentElement.clientWidth`, which is correct
|
||||
in both modes.
|
||||
|
||||
✅ **THE FOOTER RESIDUAL IS FIXED, 2026-09-01 — ONE DECLARATION, AND THE
|
||||
EARLIER RECORD MISDIAGNOSED HALF OF IT.** Under Chrome's minimum font size the
|
||||
document overflowed at four widths on all 22 pages — **14 px at 320, 38 px at
|
||||
1024, 30 px at 1056, 19 px at 1100** — and 0 from 1200 px up. One cause at all
|
||||
four: `info@smlcompany.ca` has no break opportunity, so its **min-content width
|
||||
is 310 px**.
|
||||
|
||||
| Element | Was | Fix | Effect |
|
||||
|---|---|---|---|
|
||||
| `SiteFooter .footer-contact a[href^='mailto:']` | `overflow-wrap: normal` | `overflow-wrap: anywhere` | **88 of 352 → 0 of 352** |
|
||||
|
||||
⚠️ **TWO MECHANISMS, ONE CAUSE — and this is the part the earlier record got
|
||||
wrong.** It said the 1024–1100 px half "looks like one `overflow-wrap: anywhere`
|
||||
on the footer contact links" and that "the 320 px half is a column-sizing
|
||||
question and is not the same fix". **It is the same fix.** The column sizing at
|
||||
320 px is *driven by* that same 310 px:
|
||||
|
||||
- **At 320 px** `.footer-grid`'s implicit track is `auto`, whose minimum is
|
||||
min-content, so the track — and every `.footer-col` in it — measures **310 px
|
||||
inside a 272 px content area**. The boxes themselves cross the viewport edge:
|
||||
**54 elements** did, right edge 334 px against a 320 px client width.
|
||||
- **At 1024 / 1056 / 1100 px** the contact column is `minmax(0, 1fr)` — a **0**
|
||||
minimum, so the track does not grow — and the box stays **224 / 232 / 243 px**
|
||||
while the text spills **86 / 78 / 67 px outside it**. **No element's border box
|
||||
crosses the viewport edge at all** at those widths.
|
||||
|
||||
Reducing one min-content width closes both halves.
|
||||
|
||||
⚠️ **AND IT CLOSED A THIRD CASE THAT THE FOUR-METHOD TABLE REPORTED AS CLEAN —
|
||||
"MEASURE THE ELEMENTS, NOT ONLY THE PAGE", WHICH IS ALREADY A RULE IN THIS
|
||||
SECTION.** With the fix disabled as a control, over 420 points (3 pages × 10
|
||||
widths × 6 minimum-font-size presets and 8 root sizes), **69 failed: 12 by
|
||||
document overflow and 57 by the link overrunning its OWN box while the document
|
||||
measured 0.** Those 57 sit at **root style 26 / 28 / 30 / 32 px** and at
|
||||
**`minimumFontSize` 24 and 32** — and root style 32 px is a row the table above
|
||||
reports as **0 of 352**, truthfully, because it is a document measurement.
|
||||
|
||||
**Why it never reached the viewport edge, measured rather than surmised:**
|
||||
`.wrap` carries a **96 px** right gutter at that size, and the spill is smaller
|
||||
than the gutter. At root 32 on `/about/` the link's box ends at 928 px and its
|
||||
text at **1007.3 px** against a **1024 px** client width — 79 px of spill sitting
|
||||
inside 96 px of padding. The spill shrinks as the viewport widens (79 / 71 / 60 /
|
||||
35 / 15 / 0 px at 1024 / 1056 / 1100 / 1200 / 1280 / 1440), which is why the case
|
||||
disappears at 1440 px rather than at a breakpoint.
|
||||
|
||||
**After the fix: 0 of 420, and 0 clipping at every preset and every root size.**
|
||||
The control is what makes that number mean anything — a sweep returning zero on
|
||||
every cell is the shape this section warns about twice, so it was re-run with the
|
||||
declaration forced back to `normal` and it failed 69.
|
||||
|
||||
⚠️ **AND THE ELEMENT SWEEP THAT LOOKED FOR THE CAUSE REPORTED "NONE" AT THREE
|
||||
OF THE FOUR WIDTHS** — instrument finding 2 in this section, committed again by
|
||||
the person who wrote it down. `getBoundingClientRect()` reports **border boxes**,
|
||||
so a `right > clientWidth` predicate cannot see text spilling out of a box that
|
||||
is itself inside the viewport, which is precisely the 1024–1100 px mechanism.
|
||||
The document figure was right and the offender list was empty, and those two
|
||||
facts together are the signature of this bug. **Read `scrollWidth − clientWidth`
|
||||
per element as well as per document.**
|
||||
|
||||
⚠️ **And the harness's offender list was truncated at 20 entries on 44 of the 88
|
||||
failing rows**, so it could not have named the cause either — instrument finding
|
||||
**7** below.
|
||||
|
||||
**What the fix costs, measured rather than asserted.** At the default text size,
|
||||
**nothing**: the address is one line, **44.00 px** tall — exactly the touch-target
|
||||
floor — at all 16 widths, and the normal-settings identity check is 0 differences
|
||||
on 352 page-widths. Under minimum font size it wraps to two lines and the break
|
||||
is mid-token — `info@smlcompany` / `.ca` at 320 px, `info@smlcomp` / `any.ca` at
|
||||
1024 px, `info@smlcompa` / `ny.ca` at 1100 px — read out of the DOM one character
|
||||
at a time and confirmed on a screenshot, not inferred from a width. The `href`
|
||||
is untouched (`mailto:info@smlcompany.ca`), every painted glyph is inside the
|
||||
viewport in **176 of 176** rows, and the link never clips its own box. A
|
||||
mid-token break in an address is cosmetic; a document that scrolls sideways is a
|
||||
WCAG 1.4.10 failure.
|
||||
|
||||
**`anywhere`, NOT `break-word` — and that was established with a NEGATIVE
|
||||
CONTROL rather than from the rule.** `break-word` permits a break at layout time
|
||||
without reducing min-content size, so it leaves the defect untouched: injected in
|
||||
place of `anywhere` it failed **88 of 176** rows, the same rows as the unfixed
|
||||
baseline. Both the baseline and the control had to fail for the trial to mean
|
||||
anything — **a trial in which every candidate passes is a broken trial**, and
|
||||
this section already carries five instrument findings of exactly that shape.
|
||||
|
||||
**On the link, not on the column, and not in the markup.** Three candidates all
|
||||
reach 0 of 176: `anywhere` on the email link, on `.footer-contact`, and on
|
||||
`.footer-col a`. The address is the only string in the footer that produces
|
||||
**document** overflow, so the rule sits on the address and the two broader
|
||||
selectors were declined as broader than the cause.
|
||||
|
||||
⚠️ **THE REASON FIRST GIVEN FOR THAT SCOPING WAS THE WRONG TEST, AND IT IS WORTH
|
||||
MORE THAN THE SCOPING.** It said *"the address is the only string in the footer
|
||||
with no break opportunity — the location line, the response sentence and the
|
||||
sixteen nav labels all have spaces"*. **A space is not the test. Min-content is
|
||||
set by the longest WORD**, so a label with spaces still overruns its track when
|
||||
one of its words does not fit. `adversarial-reviewer` found one that does, and it
|
||||
reproduced at a width the review had not sampled:
|
||||
|
||||
| width | element | own-box overrun | clearance to the next column | document |
|
||||
|---|---|---|---|---|
|
||||
| 640 px | `.footer-col a` "Construction & Infrastructure" | **24 px** out of a 176 px box | **7.7 px** | 0 |
|
||||
| 640 px | `.footer-col a` "Shareholder & Family Business" | 2 px | 7.7 px | 0 |
|
||||
| 700 px | `.footer-col a` "Construction & Infrastructure" | 4 px out of a 196 px box | 27.7 px | 0 |
|
||||
|
||||
`minimumFontSize=32`, all 22 pages, 12 widths from 320 to 1024 px = 264 rows;
|
||||
clean at every other width sampled, including 768 and 834 px. **"Infrastructure"
|
||||
is 14 characters and does not fit a 176 px track at 32 px** — the space in the
|
||||
label is irrelevant to that.
|
||||
|
||||
⚠️ **AND THE 12-WIDTH GRID STEPPED OVER TWO MORE**: round 2 found the same label
|
||||
at **17 px @660 and 11 px @680**, widths this grid does not sample. That is this
|
||||
section's own caveat holding — *the grid is 16 columns, not a continuum* — and it
|
||||
is the second time in one session that a between-columns width mattered.
|
||||
|
||||
**A FOURTH CASE OF THE SAME FAMILY, and it should be ruled on together with the
|
||||
footer label rather than separately.** `minimumFontSize=32`,
|
||||
`/practice/construction/` @320 px: `nav.crumbs > ol` overruns its own 272 px box
|
||||
by **7 px**, furthest text edge 302.9 px against a 320 px client width,
|
||||
`overflow-x: visible`, no clipping, **document overflow 0**. Clean at 360 / 390 /
|
||||
414 px and on the other five practice pages. **No success criterion fails**, so
|
||||
like the footer label it is recorded rather than fixed — but if
|
||||
`.footer-col a { overflow-wrap: anywhere }` is ruled in, the same call covers
|
||||
`.crumbs` and the ruling should be taken once for both. Raised by
|
||||
`adversarial-reviewer`, round 2.
|
||||
|
||||
**Not fixed here, and the reason is a scope judgement rather than a measurement.**
|
||||
It produces **no document overflow at any width**, so it fails no success
|
||||
criterion; `.footer-col a { overflow-wrap: anywhere }` is measured to close it
|
||||
and to be inert at normal settings. What it would change is how six practice-area
|
||||
names break on a marketing surface under enlargement, which is a copy-adjacent
|
||||
call. ⚠️ **The maintenance risk is the real finding: 7.7 px of clearance is one
|
||||
label rename away from two columns colliding**, and nothing in the build measures
|
||||
it. Pouya's to rule on; `docs/06` carries it. `word-break: break-all` also
|
||||
reaches 0 and was declined for the same reason: it breaks where a normal
|
||||
opportunity exists. **And NOT a `<wbr>` after the `@`**, which would give a
|
||||
prettier break: it splits a §4-registered fact across an element boundary, so
|
||||
`info@smlcompany.ca` would no longer be greppable in `dist/` — the surface
|
||||
`npm run check:claims` reads — and its longest unbreakable run (`smlcompany.ca`,
|
||||
~224 px) does not fit the 224 px column at 1024 px anyway, so it would need
|
||||
`anywhere` as a backstop regardless.
|
||||
|
||||
**Not header-side, and that survives the fix:** no header element extends the
|
||||
document at any width under any of the four mechanisms, and the nav items and CTA
|
||||
are on-screen in **1408 of 1408** measurements. *(This said "946 of 946" for one
|
||||
revision. 946 is a different sweep — the four runs made with the tautological
|
||||
skip probe in instrument finding 4 — and reusing it for the final build was the
|
||||
two-similar-totals mistake this section keeps warning about.)* *(The Practice
|
||||
dropdown panel's contents overflow their own box by 84 px under minimum font
|
||||
size and 40 px at the default, on all 22 pages — unchanged by this fix, and it
|
||||
never extends the document because the panel sits inside a **closed**
|
||||
`<details>`. Re-checked after the fix rather than carried forward.)*
|
||||
|
||||
🛑 **THE MINIMUM-FONT-SIZE STICKY RESIDUAL IS BACK OPEN, AND IT IS NOW A
|
||||
CONFORMANCE FAILURE RATHER THAN A CONVENIENCE LOSS. IT NEEDS A FRESH RULING —
|
||||
THE ONE OF 2026-09-01 WAS TAKEN ON TWO FACTS THAT ARE BOTH WRONG.** Pouya ruled
|
||||
it accepted 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."*
|
||||
`adversarial-reviewer` attacked both halves of that and both attacks reproduced.
|
||||
|
||||
**(1) THE PREMISE IS FALSE.** The proof was of `rem`/`em` and of queries written
|
||||
in them. The font-metric units `ch`, `ex`, `cap`, `lh` and `rlh` all read the
|
||||
*used* font size and double under the setting — in property values, in `@media`
|
||||
and in `@container`. The table at the top of this section carries the
|
||||
measurement. **A pure-CSS detector for this mechanism exists**, so the residual
|
||||
is not unfixable and "the only fix is JS" does not hold.
|
||||
|
||||
**(2) THE COST WAS UNDERSTATED, AND BY A CATEGORY RATHER THAN A NUMBER.** The
|
||||
record described it as the skip link landing short — 68–174 px of `#main` behind
|
||||
the header — and argued *"one of the two is a WCAG 1.4.10 failure while the other
|
||||
degrades the convenience of a skip link that still works."* What is actually
|
||||
happening is that **ordinary keyboard focus lands entirely behind an opaque
|
||||
sticky header**, which is **WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum),
|
||||
Level AA** — the same conformance level as the 1.4.10 failure it was traded
|
||||
against. **So the asymmetry that was the whole argument does not exist: both
|
||||
sides are AA failures.**
|
||||
|
||||
**Measured, and the instrument is checked in three ways.** Shift+Tab walk — the
|
||||
ordinary way a keyboard user returns to a link they passed — 6 pages × 5 widths
|
||||
≥ 1056 px, 70 steps per cell, elements inside the header excluded:
|
||||
|
||||
| build | mode | focus stops | entirely hidden | zones |
|
||||
|---|---|---|---|---|
|
||||
| working tree | default | 1,455 | **0** | — |
|
||||
| working tree | `minimumFontSize=32` | 1,455 | **290** | 254 footer, **36 `#main`** |
|
||||
| `fce89d4~1` (pre-header-fix) | `minimumFontSize=32` | 1,455 | **0** | — |
|
||||
| `fce89d4~1` (pre-header-fix) | default | 1,455 | **0** | — |
|
||||
|
||||
⚠️ **THE THIRD ROW IS THE ONE THAT MATTERS: THE HEADER FIX CREATED THIS.** Before
|
||||
it, `flex-wrap: nowrap` kept the masthead one row under this setting, so it was
|
||||
short enough that focus landed clear; the overflow went sideways instead. After
|
||||
it the masthead wraps to 164.58–270.56 px and covers the 97 px landing. This is
|
||||
a **regression introduced by the 2026-09-01 header fix**, not a condition it
|
||||
inherited — and the ruling to accept it was taken without that comparison
|
||||
existing.
|
||||
|
||||
**Instrument checks, because a focus-obscuring count is easy to fake in both
|
||||
directions.** *(a)* The default-mode run returns **0**, so the predicate is not
|
||||
tautological. *(b)* ⚠️ **Geometry alone was WRONG and said 43.** All 43 were the
|
||||
**skip link**, which is stacked deliberately *above* the header and is not
|
||||
covered by it at all — so `elementFromPoint` at the focused box's centre is
|
||||
authoritative and geometry only nominates candidates. Under the setting there
|
||||
are 333 geometric candidates and **290** survive the hit test. *(c)* Sampled
|
||||
hits agree 6 of 6, e.g. `/` @1056: focused `a` "Technology, AI & Data" at
|
||||
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.
|
||||
|
||||
✅ **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`
|
||||
at the default. **Deferred:** 15 px is fitted to Geist's `ch`, so it needs
|
||||
checking under fallback metrics and Chrome's other font presets, and the
|
||||
reviewer's own injection of it was flaky.
|
||||
|
||||
*Candidate B, round 2 — recommended:* `scroll-padding-top`, the property that
|
||||
already exists for this job, rather than un-sticking anything. Two declarations
|
||||
in the existing `@media (min-width: 66rem)` block:
|
||||
|
||||
```css
|
||||
html {
|
||||
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)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Verified independently — every value read from `getComputedStyle`, not
|
||||
reasoned:**
|
||||
|
||||
| condition | `1lh` on `<html>` | computed offset | header | verdict |
|
||||
|---|---|---|---|---|
|
||||
| default, fonts loaded | 18 px | **97 px — byte-identical to shipped** | 81 px | clears |
|
||||
| `minimumFontSize=32`, fonts loaded | 37 px | **287 px** | 270.56 px | clears |
|
||||
| default, **every `.woff2` blocked** | **18 px** | **97 px** | 81 px | clears |
|
||||
| `minimumFontSize=32`, **`.woff2` blocked** | **37 px** | **287 px** | 270.56 px | clears |
|
||||
| `minimumFontSize=32`, family Georgia | 36 px | 277 px | 270.56 px | clears |
|
||||
| `minimumFontSize=32`, family Verdana | 39 px | 307 px | 270.56 px | clears |
|
||||
|
||||
⚠️ **Rows 3 and 4 are why B beats A: `1lh` on `<html>` is immune to the webfont
|
||||
fallback.** `<html>` keeps the UA default family — `--font-sans` is set on
|
||||
`body`, and `<html>`'s computed `font-family` measures `Times` — so blocking every
|
||||
font file changes nothing, and the fallback-metrics caveat that deferred
|
||||
candidate A does not apply to B at all.
|
||||
|
||||
**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 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:
|
||||
|
||||
**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`
|
||||
blocked — what a reader on the Slow 4G profile `docs/04` budgets against sees
|
||||
first, since the fonts are `font-display: swap` — and a seventh nav item cloned
|
||||
from a real node, the header measures **141 px at every width from 1056 to
|
||||
1091 px**, and 81 px from 1092 px up. With the webfonts loaded it is 81 px at all
|
||||
of them. **Two consequences, and the record first carried only the smaller one:**
|
||||
|
||||
1. **A 60 px collapse** when Geist swaps in — a CLS contribution on all 22 pages,
|
||||
against the CLS < 0.05 budget. **Larger, not new:** the previous build gives
|
||||
86.59–86.97 px in the same band, so a ~5.6 px shift already existed.
|
||||
2. ⚠️ **AND 44 px OF `#main` BEHIND THE STICKY HEADER** after the skip link,
|
||||
because 141 px exceeds the 97 px `scroll-padding-top`. **This one IS new** —
|
||||
the previous build's 86.97 px stayed under 97 px, so it covered **0**. Framing
|
||||
this case as "a CLS contribution" understated it, and a reader ruling on a
|
||||
layout shift would weigh it differently from one ruling on the skip link.
|
||||
|
||||
*(The band was recorded as "1056 / 1064 / 1072 / 1084 … 81 px from 1092 up",
|
||||
which reads as though 1086–1090 were checked and clear; they are all 141 px. It
|
||||
is a contiguous **36 px** band, 1056–1091. Both errors found by
|
||||
`adversarial-reviewer`.)*
|
||||
|
||||
**It is latent, not shipped:** with six items there is no wrap at any width, and
|
||||
`showInsights` turns the seventh on only at two published articles. **Not fixed
|
||||
here, and deliberately:** the two candidates are raising the desktop breakpoint
|
||||
past 1091 px, which changes the layout at normal settings in that band, or giving
|
||||
Geist a metric-matched `size-adjust` fallback, which is a font-stack change well
|
||||
outside "the header and its consumers".
|
||||
|
||||
⚠️ **AND IT IS NOW GATED RATHER THAN MERELY PARKED — Pouya's ruling,
|
||||
2026-09-01: NO SEVENTH NAV ITEM SHIPS UNTIL THIS IS FIXED.** Fixing it is a
|
||||
**prerequisite of publishing the second Insights article**, because publishing
|
||||
article #2 is what adds the item — `SiteHeader` computes `showInsights` from the
|
||||
collection, so nothing else stands between that editorial decision and both
|
||||
consequences above. The gate is recorded in three places on purpose: `AGENTS.md`
|
||||
R20, `docs/06`'s `/insights/` state item, and the comment on `showInsights` in
|
||||
`SiteHeader.astro` — the last of those being the only one a person editing an
|
||||
article's front matter is likely to be looking at. The fix itself is *"to be
|
||||
designed then, not now"*.
|
||||
|
||||
**What is still latent, measured and not a defect today:** `.hero-h` has **16
|
||||
scoped rules, one per page, and only 2 carry `overflow-wrap: anywhere`** (`/` and
|
||||
`/about/`). The other 14 measure 0 because their headlines' longest words are
|
||||
shorter. The single-source fix would be to change `global.css`'s `h1`–`h6` rule
|
||||
from `break-word` to `anywhere` and delete both overrides; that is a design-system
|
||||
change with a site-wide blast radius on intrinsic sizing and it has not been
|
||||
measured, so it is recorded here rather than done.
|
||||
|
||||
- **Measure the elements, not only the page.** A document-level overflow check
|
||||
passes while a flex child absorbs the deficit by being crushed — that is how
|
||||
|
||||
@@ -537,6 +537,15 @@ Not both halves of the rule.**
|
||||
long-term designation goal* row, which carries the reasoning and the
|
||||
deliberate deviation from the strategy brief. `check:claims`
|
||||
`c-med-arb-struck` sweeps `dist/`
|
||||
- [ ] **A class statement about what Ontario law does or does not gate behind an
|
||||
arbitral designation** — decided by §4 Forbidden's *"Anyone may be appointed
|
||||
an arbitrator in Ontario" / "nothing in law gates the role"* row, which
|
||||
carries the committed source and the reason the scoped replacement is
|
||||
**attributed to Pouya rather than stamped**. It bars the claim in **both**
|
||||
directions; this repository does not conclude a proposition of law.
|
||||
`check:claims` `struck-universal-q39` sweeps `dist/`. *(Added 2026-08-31,
|
||||
when Pouya ruled the §4 row into existence: this line could not exist before
|
||||
it, because an item here cites a row rather than restating a bar.)*
|
||||
- [ ] **A firm, a team, or offices that do not exist** — §4 Forbidden rows the
|
||||
specific false artefacts (*"Since 2009"*, *"sixteen years"*, the London
|
||||
and New York offices, the company number, and the fictitious founder);
|
||||
|
||||
+101
-13
@@ -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,23 +191,56 @@ 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 |
|
||||
| `ttl` | epoch seconds — **automatic deletion** |
|
||||
| `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
|
||||
mechanism that runs whether anyone remembers or not.
|
||||
|
||||
⚠️ **WRITING THE ATTRIBUTE IS NOT THE MECHANISM.** The handler supplies `ttl`;
|
||||
TTL must also be **enabled on the table**, and **`AGENTS.md` §7 records whether
|
||||
it is — this section deliberately does not.** So the paragraph above is a
|
||||
statement about the design and not about the running system until the cutover
|
||||
item below is ticked on **both** halves: `ENABLED` by command, and a test record
|
||||
observed to disappear.
|
||||
|
||||
Rationale: long enough to serve conflicts screening across a normal matter
|
||||
lifecycle; short enough to be defensible under PIPEDA's requirement to retain
|
||||
personal information only as long as necessary. Whatever number ships must match
|
||||
@@ -279,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
|
||||
|
||||
@@ -364,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. The handler writes the `ttl` attribute; TTL must also be **enabled on the table**, and §7 does not record that it is. Until this is verified the page promises a mechanism that may not run
|
||||
- [ ] 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
|
||||
- [ ] **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
|
||||
- [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
|
||||
|
||||
+402
-25
@@ -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,53 @@ 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
|
||||
three weeks later. Each item carries the date it was deferred and the reason, so
|
||||
the decision is re-readable rather than re-litigated.
|
||||
|
||||
- [ ] ⚠️ **TWO DEPENDENCY MAJORS ARE ONE BEHIND, DEFERRED 2026-09-01 BY RULING.**
|
||||
`@astrojs/mdx` **^7.0.8 → 8.0.0** and `typescript` **^6.0.3 → 7.0.2**.
|
||||
Pouya's reasoning: *"npm audit is clean and majors mid-walkthrough add churn
|
||||
without user value."* `npm audit` reports **0 vulnerabilities**
|
||||
`[verified 2026-09-01]`, so this is a churn decision and not a security one —
|
||||
and it would become a security decision the moment that stops being true.
|
||||
**Take both majors here, before cutover, and read the changelogs rather than
|
||||
the version numbers**: an MDX major can change how `docs/03`'s copy renders,
|
||||
and a TypeScript major can change what `astro check` accepts, which is a
|
||||
gate on both deploy paths. **`AGENTS.md` R11 keeps firing at every phase
|
||||
boundary as designed** — this item is the deferral, not a replacement for the
|
||||
reminder, and R11 is what will raise the next pin that drifts.
|
||||
**The sweep behind this item, `npm view <pkg> version` across all 19 pins
|
||||
`[verified 2026-09-01]`:** those two are the only majors behind. Four are a
|
||||
minor or patch behind and are already satisfied by their own carets, so they
|
||||
need no edit — `astro` 7.2.9 → **7.2.10**, `@astrojs/sitemap` 3.7.3 → 3.7.4,
|
||||
`globals` 17.11.0 → 17.12.0, `typescript-eslint` 8.68.0 → 8.69.0. The
|
||||
remaining 13 are current. Re-run the sweep here rather than trusting this
|
||||
list: it is dated for that reason
|
||||
|
||||
**Content and compliance**
|
||||
|
||||
> ⚠️ **THE FIRST TWO ITEMS ARE THE PROJECT'S ONLY FULL CLAIMS PASS — D20, Pouya,
|
||||
@@ -380,6 +451,33 @@ Then invalidate `/*`.
|
||||
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.
|
||||
@@ -425,6 +523,15 @@ Then invalidate `/*`.
|
||||
- [ ] `/fees/` carries the rates confirmed in D14 and `docs/07-fees.md`, or the page does not ship. **And the reservation sentence sits adjacent to the overtime row** — Q59, ruled 2026-08-31: a reader who takes the $500 and skips *"a full day reserves the day; half-day overtime is subject to availability"* has read a different offer, and the arithmetic then makes the full-day rate look strictly worse
|
||||
- [ ] Privacy policy matches the backend as actually built. **Three of its statements are derived and cannot drift** — the collected-data list from `INTAKE_FIELDS`, the retention period from the handler's own figure, the analytics paragraph from `ANALYTICS.installed`. **If analytics are installed, that flag flips and the policy's last-updated date moves on the same build**; a policy naming a processor that processes nothing is a false disclosure
|
||||
- [ ] **`/insights/` — check which state it is in.** While no article is published the page emits `noindex` (derived from the collection) and `SiteHeader` keeps Insights out of the primary nav until **two** are live. Both clear themselves on publication. D9 means the flip is Pouya's: `src/content.config.ts` refuses `draft: false` without `reviewedByPouya: true`
|
||||
- [ ] ⚠️ **AND PUBLISHING THE SECOND ARTICLE IS GATED — `AGENTS.md` R20, ruled
|
||||
2026-09-01.** The flip that makes `/insights/` indexable is the same flip that
|
||||
adds the **seventh nav item**, and with seven items under fallback font
|
||||
metrics the masthead measures 141 px across 1056–1091 px: a 60 px layout shift
|
||||
on every page and 44 px of `#main` behind the sticky header after the skip
|
||||
link, both at the **default** text size. **No seventh nav item ships until
|
||||
that is fixed**, so the fix is a prerequisite of article #2 — not a follow-up
|
||||
to it. The measurements and the two candidate fixes are on the
|
||||
seventh-nav-item item under **Technical** below
|
||||
- [ ] **The five drafted articles are `draft: true` and therefore invisible to `check:claims`**, which reads `dist/`. They were checked once, on 2026-08-31, by temporarily publishing all five and running it — clean on 27 pages. **Re-run it that way before any of them publishes**, because a draft that changes in between is unchecked
|
||||
|
||||
**Technical**
|
||||
@@ -432,7 +539,27 @@ Then invalidate `/*`.
|
||||
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
|
||||
@@ -445,12 +572,44 @@ Then invalidate `/*`.
|
||||
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'`,
|
||||
@@ -477,32 +636,66 @@ Then invalidate `/*`.
|
||||
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
|
||||
by the database rather than by someone remembering to do it"* after 24
|
||||
months. The handler writes the `ttl` attribute; **TTL must also be enabled
|
||||
on the table**, and §7 does not record that it is. Until this is verified,
|
||||
that page promises a mechanism that may not run — which on a privacy policy
|
||||
is a false statement to the public. `aws dynamodb describe-time-to-live
|
||||
--table-name <the table §7 records>` and confirm
|
||||
`TimeToLiveStatus: ENABLED`, then write a record with a near-future `ttl`
|
||||
and confirm it disappears.
|
||||
on the table** — ⚠️ **§7 records whether it is, and it has not always
|
||||
been**, which is why this item cannot be ticked from the handler code: the
|
||||
handler only writes the attribute. On a privacy policy an unbacked
|
||||
mechanism is a false statement to the public. `aws dynamodb
|
||||
describe-time-to-live --table-name <the table §7 records>` and confirm
|
||||
`TimeToLiveStatus: ENABLED` — then, because **`ENABLED` proves the setting
|
||||
and not the behaviour**, write a record with a near-future `ttl` and
|
||||
confirm it actually disappears. Both halves, or the item stays open. §9
|
||||
Q60 and §12 R19 track it; `src/pages/legal/privacy.astro` carries the
|
||||
matching `TODO(pouya)`.
|
||||
✅ **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
|
||||
@@ -530,12 +723,196 @@ Then invalidate `/*`.
|
||||
- [ ] Favicon set complete
|
||||
- [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox
|
||||
- [ ] Tested at 320 px and at 200% zoom
|
||||
- [x] ✅ **THE 200%-TEXT NAV OVERFLOW IS FIXED, 2026-09-01 — THIS ITEM IS
|
||||
DISCHARGED.** Pouya reopened the step-1 header decision and ruled *fix, do
|
||||
not accept*. `flex-wrap: nowrap` is gone from `.header-inner` (the necessary
|
||||
and sufficient one, measured) and from `.nav-list`, and the masthead is
|
||||
sticky only while it is one row — a saturating `clamp()` gate in two bands,
|
||||
66 rem and 76 rem. Measured across 22 pages × 16 widths, before and after on
|
||||
the same grid: **root-style 32 px went from 175 of 352 page-widths over to
|
||||
0**; minimum font size from 219 to 88, all of the remainder being the footer;
|
||||
default font size stays 0. Nav items and CTA on-screen in **1408 of 1408**
|
||||
measurements. The shipped rendering at the default size is
|
||||
**geometry-identical — 0 differences on 352 page-widths**, header 81.00 px,
|
||||
CTA on the content edge, with six nav items and with a seventh.
|
||||
⚠️ **AND IT WAS VERIFIED ACROSS THE INTERMEDIATE ROOT SIZES, WHICH IS WHAT
|
||||
ROUND 1 DID NOT DO.** WCAG 1.4.4 is a requirement up TO 200 %, not AT 200 %:
|
||||
round 1 sampled root 16 and root 32 only, both clean, and shipped a gate that
|
||||
left `#main` behind the header at **20 of 300** grid points across roots
|
||||
18–30. **And round 2 found the gate was one-sided**: Chrome's "Font size" has
|
||||
five presets (9 / 12 / 16 / 20 / 24 px) and only 16 had been swept, so at
|
||||
**9 px the masthead wrapped for the opposite reason** — the `80rem` content
|
||||
cap shrinks while the header's px minimums do not — leaving **65 px of `#main`
|
||||
behind the header** on 21 of 22 pages at every width. A second gate term
|
||||
(`1rem - 16px`) closes it, and closes a **pre-existing 12 px** shortfall on
|
||||
all 22 pages at that setting as well. Now **0 failing across 762 points**:
|
||||
roots 9–15 (70), roots 16–32 (300), the band seam (198), both thresholds
|
||||
(144) and all five default-font-size presets (50). Both findings by
|
||||
`adversarial-reviewer`; `docs/02` §Reflow carries all of it
|
||||
- [x] ✅ **THE FOOTER REFLOW RESIDUAL IS FIXED, 2026-09-01.** Document overflow of
|
||||
**14 px at 320, 38 px at 1024, 30 px at 1056 and 19 px at 1100 CSS px** on all
|
||||
22 pages under Chrome's minimum-font-size setting, 0 from 1200 px up. One
|
||||
cause at all four: `info@smlcompany.ca` has no break opportunity, so its
|
||||
**min-content width is 310 px**. One declaration closes it —
|
||||
`.footer-contact a[href^='mailto:'] { overflow-wrap: anywhere }` — measured
|
||||
**88 of 352 → 0 of 352**, the other three enlargement methods unchanged at 0
|
||||
of 352, and the normal-settings identity check at **0 differences on 352
|
||||
page-widths across 8 metrics**. It also closed **57 cases of the link
|
||||
overrunning its own box** at root style 26–32 px that the document-level
|
||||
table reports as clean. ⚠️ **The earlier note here said the 320 px half "is a
|
||||
column-sizing question and is not the same fix" — that was wrong**; the
|
||||
column sizing at 320 px *is* that same 310 px min-content, arriving through
|
||||
`.footer-grid`'s `auto` track instead of through text spilling out of a
|
||||
`minmax(0, 1fr)` one. `docs/02` §Reflow carries both mechanisms, the negative
|
||||
control proving `break-word` would not have worked, and the candidates
|
||||
declined as broader than the cause
|
||||
|
||||
- [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
|
||||
overflow at any width, so it fails no success criterion and is cosmetic
|
||||
today — filed because **7.7 px is one label rename away from two columns
|
||||
colliding**, and nothing in the build measures it. `.footer-col a`
|
||||
"Construction & Infrastructure" overruns its 176 px box by **24 px** at
|
||||
640 px and by 4 px at 700 px (clearance 27.7 px); "Shareholder & Family
|
||||
Business" by 2 px at 640 px, and — at widths that 12-column grid steps over —
|
||||
**17 px at 660 px and 11 px at 680 px**. Clean at every other width sampled,
|
||||
including 768 and 834 px; `minimumFontSize=32`, 22 pages × 12 widths = 264
|
||||
rows. **A fourth case of the same family: `nav.crumbs > ol` on
|
||||
`/practice/construction/` @320 px overruns its own 272 px box by 7 px**, also
|
||||
with zero document overflow. **Rule on both together** — if
|
||||
`.footer-col a { overflow-wrap: anywhere }` is ruled in, the same call covers
|
||||
`.crumbs`; if it is declined, decline both and the record is complete.
|
||||
**`.footer-col a { overflow-wrap: anywhere }` is measured to close it and to
|
||||
be inert at normal settings**, and is not applied because it changes how six
|
||||
practice-area names break on a marketing surface under enlargement — a
|
||||
copy-adjacent call, not a technical one. ⚠️ **And it falsified the reason
|
||||
given for scoping the email fix to the address** (*"every other footer string
|
||||
has spaces"*): min-content is set by the longest **word**, not by whether a
|
||||
space exists. Corrected in `docs/02` §Reflow. Raised by
|
||||
`adversarial-reviewer`, 2026-09-01
|
||||
|
||||
- [ ] ⚠️ **THE SEVENTH NAV ITEM PLUS FALLBACK FONT METRICS — A LAYOUT SHIFT *AND*
|
||||
A SKIP-LINK FAILURE, AT THE DEFAULT TEXT SIZE, ARMING ITSELF WHEN THE SECOND
|
||||
INSIGHTS ARTICLE PUBLISHES.** No reader setting is involved: with every
|
||||
`.woff2` blocked — what a reader on the Slow 4G profile `docs/04` budgets
|
||||
against sees first, since the fonts are `font-display: swap` — and a seventh
|
||||
nav item present, the header measures **141 px at every width from 1056 to
|
||||
1091 px** (a contiguous **36 px** band) and 81 px from 1092 px up; with the
|
||||
webfonts loaded it is 81 px throughout. Two consequences: **(i)** a **60 px
|
||||
collapse** when Geist swaps in, on all 22 pages, against the CLS < 0.05
|
||||
budget — *larger, not new*, since the previous build shifts ~5.6 px in the
|
||||
same band; and **(ii) 44 px of `#main` behind the sticky header** after "Skip
|
||||
to content", because 141 px exceeds the 97 px `scroll-padding-top` — **this
|
||||
half IS new**, the previous build's 86.97 px stayed under 97 px and covered 0.
|
||||
**Latent, not shipped:** six items never wrap, and `showInsights` turns the
|
||||
seventh on at two published articles. Two candidate fixes, both Pouya's call
|
||||
because both are outside "the header and its consumers": raise the desktop
|
||||
breakpoint past 1091 px, which changes the normal-settings layout in that
|
||||
band, or give Geist a metric-matched `size-adjust` fallback. Raised by
|
||||
`adversarial-reviewer`, 2026-09-01; `docs/02` §Reflow carries the measurements.
|
||||
|
||||
⚠️ **THIS IS NOT A CUTOVER BLOCKER AND IT IS NOT TICKABLE HERE — IT IS A HARD
|
||||
PRE-PUBLICATION GATE. Pouya's ruling, 2026-09-01: no seventh nav item ships
|
||||
until the fallback-metrics defect is fixed**, which makes fixing it a
|
||||
**prerequisite of publishing the second Insights article**, since that is what
|
||||
adds the item. It is left unchecked on purpose: a checklist that fires once, at
|
||||
cutover, cannot discharge a defect that arms itself later on an editorial
|
||||
decision. ⚠️ **AND THE GATE IS A BUILD FAILURE, NOT THIS CHECKLIST AND NOT A
|
||||
COMMENT.** `SiteHeader.astro` throws when `published.length >= 2`, with the
|
||||
measurements and the instruction in the message, so it fires on both deploy
|
||||
paths on the machine of whoever publishes. **Proven rather than assumed:** two
|
||||
articles were temporarily flipped to `draft: false` and `npm run build` exited
|
||||
**1** naming R20; the files were restored and the restoration verified by
|
||||
`git diff --exit-code` and an unchanged `dist` digest. It was first written as
|
||||
three prose cross-references and **did not gate** — the build and all five
|
||||
checks passed while both defects shipped. **Owner: `AGENTS.md` R20**
|
||||
|
||||
**Infrastructure**
|
||||
- [ ] S3 versioning enabled
|
||||
- [ ] Bucket not publicly readable; OAC in force
|
||||
- [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));
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ const { slots } = Astro.props;
|
||||
measured-sounding comment that was false is this project's own named
|
||||
failure mode.
|
||||
|
||||
`minmax(0, 1fr)` cannot overflow at any width or any root font size,
|
||||
which also retires the `min(11rem, 100%)` guard this line briefly
|
||||
`minmax(0, 1fr)` means the TRACK cannot overflow at any width or any root
|
||||
font size, which retires the `min(11rem, 100%)` guard this line briefly
|
||||
carried — that guard was fixing the overflow symptom of a floor that
|
||||
should not have been there. */
|
||||
should not have been there. ⚠️ **THE TRACK IS NOT THE CONTENT:** a label's
|
||||
own words can overflow the track, and at a 200% default font size they
|
||||
did. That is why `.credential-label` below carries `overflow-wrap`. */
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6) var(--space-5);
|
||||
margin: 0;
|
||||
@@ -102,7 +104,10 @@ const { slots } = Astro.props;
|
||||
deliberately long (AGENTS.md Q37) — it wraps to two lines at every width
|
||||
and must not be prevented from doing so. Do not add `white-space: nowrap`
|
||||
here, and do not shorten the label to make the row tidier: the asymmetry
|
||||
is the honest part. */
|
||||
is the honest part. ⚠️ `overflow-wrap: anywhere` is LOAD-BEARING here, not
|
||||
cosmetic — `anywhere`, not `break-word`: see `docs/02` §Reflow, instrument
|
||||
finding 1. */
|
||||
text-wrap: pretty;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,12 @@ void _props;
|
||||
200% default font size (root 32px) overflowed the document by 111px at
|
||||
320px — WCAG 1.4.10 Reflow. Measured 2026-08-28; no pill on the site is
|
||||
that long today, which is exactly why this is easy to delete and must not
|
||||
be. `normal` costs nothing at default size. */
|
||||
be. `normal` costs nothing at default size.
|
||||
⚠️ `normal` ALONE IS NOT ENOUGH — a one-word pill cannot wrap at a space
|
||||
that is not there. `anywhere`, not `break-word`, and this is the BACKSTOP
|
||||
rather than the cause: `docs/02` §Reflow, instrument finding 1 and the
|
||||
`Pill` row. */
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,7 +48,13 @@ const H = `h${level}` as 'h2' | 'h3';
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
/* CLAMPED, not a flat --space-6, for the reason `.feature` on `/` carries:
|
||||
the space scale is rem-based, so `2rem` is 64 px a side at a 200% default
|
||||
font size — 128 px of padding inside a ~224 px content box, which left
|
||||
~96 px for the whole card column and was the real cause of a `Pill`
|
||||
overflowing. The 10vw term holds it at 32 px on every viewport from 320 px
|
||||
up and lets it collapse only when the rem is doubled. */
|
||||
padding: clamp(var(--space-4), 10vw, var(--space-6));
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-block-start: 2px solid var(--rule);
|
||||
|
||||
@@ -71,7 +71,7 @@ const aboutLinks = [
|
||||
<div class="footer-grid">
|
||||
<nav class="footer-nav" aria-label="Footer">
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">Practice areas</h2>
|
||||
<h2 class="eyebrow footer-heading">Practice areas</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
PRACTICE_AREAS.map((area) => (
|
||||
@@ -85,7 +85,7 @@ const aboutLinks = [
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">Process</h2>
|
||||
<h2 class="eyebrow footer-heading">Process</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
processLinks.map((link) => (
|
||||
@@ -98,7 +98,7 @@ const aboutLinks = [
|
||||
</div>
|
||||
|
||||
<div class="footer-col">
|
||||
<h2 class="footer-heading">About</h2>
|
||||
<h2 class="eyebrow footer-heading">About</h2>
|
||||
<ul role="list">
|
||||
{
|
||||
aboutLinks.map((link) => (
|
||||
@@ -112,7 +112,7 @@ const aboutLinks = [
|
||||
</nav>
|
||||
|
||||
<div class="footer-col footer-contact">
|
||||
<h2 class="footer-heading">Contact</h2>
|
||||
<h2 class="eyebrow footer-heading">Contact</h2>
|
||||
<ul role="list">
|
||||
<li><a href={`mailto:${CONTACT.email}`}>{CONTACT.email}</a></li>
|
||||
<li><span class="footer-meta">{CONTACT.phoneFallback}</span></li>
|
||||
@@ -171,8 +171,14 @@ const aboutLinks = [
|
||||
border-block-end: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* `flex-wrap: wrap` is the reflow fix, chosen OVER `overflow-wrap: anywhere` on
|
||||
the name: the name is a flex item at `min-width: auto` and cannot shrink
|
||||
below "Lajevardi", and wrapping the flex line breaks no word where
|
||||
`anywhere` would have hyphenated a person's name. No effect at any normal
|
||||
size. `docs/02` §Reflow carries the measurement. */
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-block-size: 44px;
|
||||
@@ -207,12 +213,10 @@ const aboutLinks = [
|
||||
gap: var(--space-7) var(--space-6);
|
||||
}
|
||||
|
||||
/* Type comes from the global `.eyebrow` class on the element; only what differs
|
||||
is here. The colour is load-bearing, not decorative: gold-l on ink is
|
||||
11.09:1 and `.eyebrow`'s own `--text-meta` on ink is 3.07:1, which fails. */
|
||||
.footer-heading {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-inverse-2);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
@@ -240,6 +244,12 @@ const aboutLinks = [
|
||||
color: var(--text-inverse-2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* `anywhere`, NOT `break-word` — only `anywhere` reduces min-content size, which
|
||||
is the defect: the address has no break opportunity. `docs/02` §Reflow. */
|
||||
.footer-contact a[href^='mailto:'] {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Not a link, so no target floor — but it shares a column with links and
|
||||
should sit on the same rhythm. */
|
||||
.footer-meta {
|
||||
|
||||
+100
-24
@@ -23,7 +23,24 @@ import InfinityMark from './InfinityMark.astro';
|
||||
import Button from './Button.astro';
|
||||
|
||||
const published = await getCollection('insights', ({ data }) => !data.draft);
|
||||
/* ⚠️ GATED BY A BUILD FAILURE, NOT BY THIS COMMENT — `AGENTS.md` R20 and Q61.
|
||||
The seventh nav item arms two measured header defects under fallback font
|
||||
metrics. Prose cross-references did not gate it: every check passed while it
|
||||
fired. Delete the throw as part of the fix, not before it. */
|
||||
const showInsights = published.length >= 2;
|
||||
if (showInsights) {
|
||||
throw new Error(
|
||||
'AGENTS.md R20 — the seventh nav item is gated and this build would ship it.\n' +
|
||||
`A second Insights article is published (${published.length} live), which puts ` +
|
||||
'Insights in the primary nav. With seven items under FALLBACK font metrics — ' +
|
||||
'the font-display: swap window, at the default text size, no reader setting ' +
|
||||
'involved — the masthead measures 141px across 1056-1091px instead of 81px: a ' +
|
||||
'60px shift on every page against the CLS < 0.05 budget, and 44px of #main ' +
|
||||
'behind the sticky header after "Skip to content".\n' +
|
||||
'Fix that first (docs/02 §Reflow lists the two candidate fixes), then delete ' +
|
||||
'this guard. Do not work around it by unpublishing the article.',
|
||||
);
|
||||
}
|
||||
|
||||
const items = PRIMARY_NAV.filter(
|
||||
(item) => item.href !== '/insights/' || showInsights,
|
||||
@@ -158,10 +175,15 @@ const isHome = path === '/';
|
||||
Pouya's name read as a held capability, and §4 then recorded Q.Arb as
|
||||
merely commenced. Q33 answered that the same day, and the premise was
|
||||
wrong twice over — §4 now records Q.Arb as HELD, and even before it did:
|
||||
ADR designations are voluntary credentials, not licences, and COMMERCIAL
|
||||
arbitral appointment in Ontario is not gated behind a designation — so the
|
||||
constraint was always positional, never legal, and Pouya accepts sole,
|
||||
party-appointed and co-arbitration work today. See §4 Offerings.
|
||||
ADR designations are voluntary credentials rather than licences, and on
|
||||
**Pouya's stated position, which §4 Offerings records attributed to him and
|
||||
deliberately unstamped**, commercial arbitral appointment in Ontario is not
|
||||
gated behind a designation — so the constraint was always positional rather
|
||||
than legal, and he accepts sole, party-appointed and co-arbitration work
|
||||
today. ⚠️ **STATED AS HIS POSITION, NOT AS FACT, AND THAT IS REQUIRED:** §4
|
||||
Forbidden bars the class claim about arbitral gating **in both directions**,
|
||||
and this repository does not conclude a proposition of law. See §4 Offerings
|
||||
and the `struck-universal-q39` row.
|
||||
|
||||
SCOPED 2026-08-27 (Q39). This comment said "Anyone may be appointed an
|
||||
arbitrator in Ontario", which Pouya checked and found FALSE as a universal:
|
||||
@@ -176,9 +198,11 @@ const isHome = path === '/';
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
/* 48px, not 44. Still clears the touch floor, and it reserves the height the
|
||||
two-line brand takes at >=76rem so the sticky header is one constant 81px
|
||||
across every width where it is sticky — which is what --header-h and
|
||||
scroll-padding-top are keyed to. One number instead of two bands. */
|
||||
two-line brand takes at >=76rem so the sticky header is 81px at every width
|
||||
where it is sticky — AT THE DEFAULT TEXT SIZE, which is what `--header-h`
|
||||
and `scroll-padding-top` are keyed to. One number instead of two bands.
|
||||
Above the default the masthead wraps and is taller on purpose, and the
|
||||
sticky gate at the 66rem block is what keeps that safe. */
|
||||
min-block-size: 48px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
@@ -190,22 +214,35 @@ const isHome = path === '/';
|
||||
gap: var(--space-05);
|
||||
}
|
||||
/* The tagline appears only where there is room for it — see the 76rem block.
|
||||
Measured: at 11px with 0.18em tracking the string is ~285px wide, and
|
||||
restoring it under the name pushed the one-row header past its content box
|
||||
by 18px at 1024 with six items and 84px with seven. The brand name carries
|
||||
Measured: at 11px with 0.18em tracking the string is 283.1px wide. (Two
|
||||
figures for 1024px were struck 2026-08-31: below 66rem `.nav` takes
|
||||
`flex-basis: 100%` and `.header-cta` is `display: none`, so there is no
|
||||
one-row header there and no CTA box to be past — neither number could be
|
||||
re-derived. See the rule at the 66rem block below.) The brand name carries
|
||||
the identity on its own; the tagline is a flourish, and `/` opens with the
|
||||
same words as the hero eyebrow (docs/01). */
|
||||
.brand-tagline {
|
||||
display: none;
|
||||
font-size: var(--text-2xs); /* 11px — the eyebrow floor in docs/02 */
|
||||
/* HELD AT 11px, BELOW THE `.eyebrow` THIS ELEMENT CARRIES. At 14px the header
|
||||
WRAPS and stands at 144.98px instead of 81.00px — at 1216 with six nav items,
|
||||
and at EVERY width from 1216 up with a seventh. Nothing overflows and the CTA
|
||||
stays on the content edge, so the cost is now 64px of header height on every
|
||||
page: larger than the pre-wrap cost it replaces, and visible rather than
|
||||
invisible. `docs/02` §Reflow carries the superseded figures. Insights is that
|
||||
seventh item; `showInsights` turns it on at two published articles. */
|
||||
font-size: var(--text-2xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* ⚠️ NEVER `white-space: nowrap` HERE. It was, until 2026-08-31, and because the
|
||||
masthead is on all 22 pages that one declaration was the site's binding
|
||||
reflow defect at a 200% default font size. The name is two words and takes
|
||||
two lines when it has to; at every normal size it never wraps. WCAG 1.4.4 /
|
||||
1.4.10; `docs/02` §Reflow carries the measurement. */
|
||||
.brand-name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
.brand:hover .brand-name {
|
||||
@@ -364,14 +401,30 @@ const isHome = path === '/';
|
||||
|
||||
Sticky only from here up, too. Below this the nav takes a second row and
|
||||
the header stands at 137px, which is more of a small viewport than a
|
||||
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there. */
|
||||
sticky header is worth. Deviation from docs/02 "Sticky"; recorded there.
|
||||
That same reasoning is what the sticky gate below extends to text size: a
|
||||
tall header is not worth sticking whether the height comes from a narrow
|
||||
viewport or from large type. */
|
||||
@media (min-width: 66rem) {
|
||||
.site-header {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
.header-inner {
|
||||
flex-wrap: nowrap;
|
||||
/* ⚠️ STICKY ONLY WHILE THE MASTHEAD IS ONE ROW. A media query cannot say so:
|
||||
its `rem` resolves against the browser's DEFAULT font size, a property's
|
||||
against the root element. TWO terms, both load-bearing —
|
||||
`100vw - 66rem` catches a root ABOVE the default (the row wraps and the
|
||||
header stands 244–351px); `1rem - 16px` catches a root BELOW it, where the
|
||||
80rem content cap shrinks faster than the header's px minimums and the row
|
||||
wraps at EVERY viewport width — 65px of `#main` sat behind the header at
|
||||
Chrome's "Very small" (9px) with only the first term. `* 100000` saturates
|
||||
because wrapping is a step and a ramp left 10–69px covered across roots
|
||||
18–30; `-100vh` bounds the result. Both terms are >= 0 at the default size,
|
||||
so this is exactly `0px`. `docs/02` §Reflow carries the measurements and
|
||||
the cases where the offset can still be short. */
|
||||
inset-block-start: clamp(
|
||||
-100vh,
|
||||
min(calc((100vw - 66rem) * 100000), calc((1rem - 16px) * 100000)),
|
||||
0px
|
||||
);
|
||||
}
|
||||
.brand {
|
||||
margin-inline-end: var(--space-5);
|
||||
@@ -379,14 +432,23 @@ const isHome = path === '/';
|
||||
.nav {
|
||||
flex-basis: auto;
|
||||
}
|
||||
/* nowrap, and flex:none so the nav is never squeezed below its content
|
||||
width. Measured before this: at 960-1250px the seven-item nav broke to
|
||||
two rows and the header stood at 141px instead of 81px. */
|
||||
/* ⚠️ NOTHING HERE MAY SAY `flex-wrap: nowrap`, ON `.header-inner` OR ON
|
||||
`.nav-list`. `.header-inner`'s was the binding one, measured necessary AND
|
||||
sufficient: a `nowrap` line cannot break, so at a 200% text size the row ran
|
||||
944px past a 1280px viewport with Practice, Fees, Contact and the CTA
|
||||
off-screen. `.nav-list`'s is INERT — identical at every width and text
|
||||
setting, six items and seven — and stays removed only so this prohibition is
|
||||
not contradicted by a `nowrap` in the same file. Wrapping is the only
|
||||
mechanism that reflows under all THREE ways a reader enlarges text, because
|
||||
it is driven by used sizes rather than by a query.
|
||||
⚠️ "It never wraps above this breakpoint" holds only with the WEBFONTS
|
||||
LOADED: under fallback metrics with a seventh nav item the header is 141px
|
||||
across 1056–1091, which is both a 60px swap-in shift and 44px of `#main`
|
||||
behind the sticky header. Latent — six items never wrap. `docs/02` §Reflow. */
|
||||
.nav-list {
|
||||
flex-wrap: nowrap;
|
||||
flex: none;
|
||||
/* 16px from 66rem, widening to 24px at 76rem where there is room for it.
|
||||
Measured with seven items at every width from 1024px up. */
|
||||
/* 16px from 66rem, widening to 24px at 80rem — see that block; the 76rem
|
||||
block deliberately does NOT widen it. Measured with seven items at every
|
||||
width from 1024px up. */
|
||||
column-gap: var(--space-4);
|
||||
}
|
||||
.header-cta {
|
||||
@@ -440,6 +502,20 @@ const isHome = path === '/';
|
||||
.brand-tagline {
|
||||
display: block;
|
||||
}
|
||||
/* THE STICKY GATE MOVES WITH THE TAGLINE, and this is the same threshold the
|
||||
binary search above produced: with the tagline the one-row masthead fits
|
||||
from 1207px = 75.4rem, so 66rem is no longer the width it needs. Gating the
|
||||
wider band on 66rem left the header sticky and wrapped from root 18 up —
|
||||
46px of `#main` behind it at 1216/1280, 51px at 1440, 69px at 1920 — while
|
||||
`/` measured clean throughout, because `/` is the one page that suppresses
|
||||
the tagline. Each band gates on the width ITS layout requires. */
|
||||
.site-header {
|
||||
inset-block-start: clamp(
|
||||
-100vh,
|
||||
min(calc((100vw - 76rem) * 100000), calc((1rem - 16px) * 100000)),
|
||||
0px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -77,7 +77,7 @@ const { preloadSerifItalic = false, ...seo } = Astro.props;
|
||||
a fallback first, and a serif-to-fallback swap inside a 96px headline
|
||||
moves the whole last line.
|
||||
|
||||
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 12px, uppercase,
|
||||
GEIST MONO IS DELIBERATELY NOT. It sets the eyebrow — 13px, uppercase,
|
||||
0.18em tracking — and the credential labels. A swap there costs one short
|
||||
line of reflow at a size where the fallback is metrically close, and
|
||||
preloading it would put 95,688 B of font on the critical path instead of
|
||||
|
||||
@@ -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>
|
||||
@@ -906,7 +906,8 @@ const CREDENTIAL_GROUPS = [
|
||||
not a heading and never carries the <h*>." `Eyebrow.astro` restates it.
|
||||
|
||||
The measurable consequence was worse than the rule breach: at 11px these
|
||||
<h3>s were SMALLER than the 12px eyebrow above them and 5px smaller than
|
||||
<h3>s were SMALLER than the eyebrow above them (12px at the time, 13px
|
||||
since 2026-08-31) and 5px smaller than
|
||||
the 16px list items they head, so "MEMBERSHIPS" was the least legible text
|
||||
on the page an appointing body reads.
|
||||
|
||||
|
||||
+31
-21
@@ -119,12 +119,12 @@ const PROCESSES = [
|
||||
</div>
|
||||
<h1 class="sheet-name">{SITE.name}</h1>
|
||||
<p class="sheet-desigs">{CREDENTIALS.designations.join(' · ')}</p>
|
||||
<p class="sheet-strap">{SITE.tagline}</p>
|
||||
<p class="eyebrow sheet-strap">{SITE.tagline}</p>
|
||||
</header>
|
||||
|
||||
<div class="sheet-grid">
|
||||
<section class="block block-wide">
|
||||
<h2>The practice</h2>
|
||||
<h2 class="eyebrow">The practice</h2>
|
||||
<p>
|
||||
{
|
||||
/* ⚠️ NO LEADING SCOPE. This sentence read "I act as a neutral in
|
||||
@@ -152,26 +152,26 @@ const PROCESSES = [
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Designations</h2>
|
||||
<h2 class="eyebrow">Designations</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.designations.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Education</h2>
|
||||
<h2 class="eyebrow">Education</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.education.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Certifications</h2>
|
||||
<h2 class="eyebrow">Certifications</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.certifications.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Memberships</h2>
|
||||
<h2 class="eyebrow">Memberships</h2>
|
||||
<ul role="list">
|
||||
{CREDENTIALS.memberships.map((d) => <li>{d}</li>)}
|
||||
</ul>
|
||||
<h2>Languages</h2>
|
||||
<h2 class="eyebrow">Languages</h2>
|
||||
<ul role="list">
|
||||
<li>
|
||||
{CREDENTIALS.languages.join(' and ')}, without an interpreter
|
||||
@@ -180,14 +180,14 @@ const PROCESSES = [
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Processes</h2>
|
||||
<h2 class="eyebrow">Processes</h2>
|
||||
<ul role="list">
|
||||
{PROCESSES.map((p) => <li>{p}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2>Subject matter</h2>
|
||||
<h2 class="eyebrow">Subject matter</h2>
|
||||
<ul role="list">
|
||||
{PRACTICE_AREAS.map((area) => <li>{area.name}</li>)}
|
||||
</ul>
|
||||
@@ -197,7 +197,7 @@ const PROCESSES = [
|
||||
</section>
|
||||
|
||||
<section class="block block-wide">
|
||||
<h2>Rates</h2>
|
||||
<h2 class="eyebrow">Rates</h2>
|
||||
<ul role="list" class="rates-list">
|
||||
<li>
|
||||
Half day, up to {halfDay.hours} hours of session — {
|
||||
@@ -228,7 +228,7 @@ const PROCESSES = [
|
||||
</section>
|
||||
|
||||
<section class="block block-wide sheet-contact">
|
||||
<h2>Contact</h2>
|
||||
<h2 class="eyebrow">Contact</h2>
|
||||
<p>
|
||||
{CONTACT.email} · {CONTACT.phoneFallback} · {CONTACT.location}
|
||||
<br />
|
||||
@@ -271,13 +271,13 @@ const PROCESSES = [
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Type comes from the global `.eyebrow` class on the element; only the margin is
|
||||
here. This rule and `.block h2` below were byte-for-byte copies of
|
||||
`.eyebrow`'s declarations at `--text-2xs` — the same escape the footer's
|
||||
column headings were. The print block below sets both to 7pt, so the screen
|
||||
size never reached the PDF. */
|
||||
.sheet-strap {
|
||||
margin-block-start: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
|
||||
.sheet-grid {
|
||||
@@ -290,12 +290,8 @@ const PROCESSES = [
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* See `.sheet-strap` above: type from `.eyebrow`, only the rule under it here. */
|
||||
.block h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
padding-block-end: var(--space-2);
|
||||
border-block-end: 1px solid var(--border);
|
||||
}
|
||||
@@ -320,8 +316,13 @@ const PROCESSES = [
|
||||
.block p + p {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
/* `anywhere`, and the cause is ONE STRING: the last row prints the fee-card URL,
|
||||
which has no break opportunity and sized this single-column grid track. The
|
||||
URL has to stay — printed sheet, the reader cannot click it. `docs/02`
|
||||
§Reflow, instrument finding 1. */
|
||||
.rates-list {
|
||||
max-inline-size: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fine {
|
||||
font-size: var(--text-xs);
|
||||
@@ -367,6 +368,15 @@ const PROCESSES = [
|
||||
.sheet-head {
|
||||
padding-block-end: 8pt;
|
||||
}
|
||||
/* ⚠️ `font-weight` IS FROZEN AT 400 BY RULING, 2026-09-01. The circulated
|
||||
PDF's typography changes only when its CONTENT is deliberately revised,
|
||||
never as a side effect of a screen refactor — so print keeps 400 while
|
||||
screen takes the 500 every other eyebrow has. `docs/02` §Accessibility
|
||||
floor carries the reasoning and the byte figures. */
|
||||
.block h2,
|
||||
.sheet-strap {
|
||||
font-weight: var(--weight-normal);
|
||||
}
|
||||
.block h2 {
|
||||
font-size: 7pt;
|
||||
padding-block-end: 3pt;
|
||||
|
||||
+22
-5
@@ -323,18 +323,22 @@ const hintId = (name: string) => `${name}-hint`;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The direct-contact block. A `<dl>` because each row is genuinely a
|
||||
term and its value, which is also what lets the labels stay legible at
|
||||
small sizes without a heading level. */
|
||||
/* The direct-contact block. A `<dl>` because each row is genuinely a term and
|
||||
its value, which is what gives the labels their semantics without spending a
|
||||
heading level on them. */
|
||||
.direct {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
/* `--text-eyebrow`, not `--text-2xs`: these `<dt>`s are label text on the page
|
||||
that collects inquiries, so they move with the `<label>`s below rather than
|
||||
sitting a step behind them. Pouya's ruling, 2026-08-31. */
|
||||
.direct dt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
font-size: var(--text-eyebrow);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
@@ -343,6 +347,10 @@ const hintId = (name: string) => `${name}-hint`;
|
||||
margin-block-start: var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-snug);
|
||||
/* The email address has no break opportunity and overflowed at a 200% default
|
||||
font size. It must stay selectable and correct, so it breaks rather than
|
||||
being truncated. `docs/02` §Reflow carries the measurement. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The no-retainer sentence, set larger than the paragraphs under it. On an
|
||||
@@ -374,13 +382,22 @@ const hintId = (name: string) => `${name}-hint`;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* NOT the `.eyebrow` class, deliberately: `--text-secondary` (11.75:1) rather
|
||||
than `.eyebrow`'s `--text-meta` (5.47:1), because a form label is operative
|
||||
text. Everything else matches it, `font-weight` included — without that these
|
||||
rendered at 400 under a `p.eyebrow` of the same size and colour.
|
||||
`overflow-wrap` because at a 200% default font size "Firm or organisation"
|
||||
ran 38px outside its own box at 320px: `docs/02` §Reflow, instrument
|
||||
finding 2. */
|
||||
label,
|
||||
legend {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
font-size: var(--text-eyebrow);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Maroon on cream is 12.29:1, so the asterisk is legible — but it is
|
||||
`aria-hidden` and paired with a visually-hidden "(required)", because
|
||||
|
||||
@@ -687,6 +687,9 @@ const latest = (await getCollection('insights', ({ data }) => !data.draft))
|
||||
runs four lines; `text-wrap: balance` (global.css) keeps them even. */
|
||||
font-size: var(--text-6xl);
|
||||
max-inline-size: 22ch;
|
||||
/* `anywhere`, not `break-word` — one word here held the whole hero column
|
||||
open. `docs/02` §Reflow, instrument finding 1. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hero-lede {
|
||||
max-inline-size: var(--width-prose);
|
||||
@@ -875,6 +878,10 @@ const latest = (await getCollection('insights', ({ data }) => !data.draft))
|
||||
flex: 1 1 auto;
|
||||
max-inline-size: 46ch;
|
||||
color: var(--text-secondary);
|
||||
/* `anywhere`, not the `break-word` `global.css` already gives this `<p>` —
|
||||
one token held this card's grid track open. `docs/02` §Reflow, instrument
|
||||
finding 1. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.feature-arrow {
|
||||
font-size: var(--text-xl);
|
||||
|
||||
@@ -27,20 +27,25 @@
|
||||
*
|
||||
* ⚠️ **WHAT THIS PAGE DELIBERATELY DOES NOT CLAIM, AND THE OMISSIONS ARE THE
|
||||
* POINT.** docs/05 specifies a customer-managed KMS key, point-in-time recovery,
|
||||
* and DynamoDB TTL. `AGENTS.md` §7 verifies the table's name and region and
|
||||
* **does not verify any of those three as enabled**. So:
|
||||
* and DynamoDB TTL. `AGENTS.md` §7 is the register for whether each of the three
|
||||
* is enabled, and **this comment does not restate what it says** — it did once,
|
||||
* went stale within the day, and had to be pulled back (§12 R19). So:
|
||||
*
|
||||
* - "Encrypted at rest" IS stated, because DynamoDB encrypts every table at
|
||||
* rest unconditionally — it is true whether or not the customer-managed key
|
||||
* in docs/05 has been configured.
|
||||
* - The customer-managed key and point-in-time recovery are NOT mentioned.
|
||||
* Neither is a fact a reader needs, and neither is verified.
|
||||
* - **Automatic deletion IS stated, and it is the one promise on this page
|
||||
* that depends on a control nobody has verified.** The handler writes the
|
||||
* `ttl` attribute; TTL must also be ENABLED on the table, which §7 does not
|
||||
* record. docs/05's definition of done carries "TTL set and verified by test
|
||||
* record" and `docs/06`'s cutover checklist now names this page as what that
|
||||
* item is protecting. It must be verified before this page is public.
|
||||
* - **Automatic deletion IS stated, and it asserts a MECHANISM rather than only
|
||||
* a period** — the one promise here whose truth lives entirely outside this
|
||||
* repository. The handler writes the `ttl` attribute, and ⚠️ **writing the
|
||||
* attribute is not the mechanism**: TTL must also be enabled on the table,
|
||||
* which §7 records — **and the setting being on still does not prove a
|
||||
* record is ever deleted.** Only a record written with a near-future `ttl`
|
||||
* and watched to vanish proves that. docs/05's definition of done carries
|
||||
* "TTL set and verified by test record" and `docs/06`'s cutover checklist
|
||||
* names this page as what that item protects. **Both halves before this page
|
||||
* is public.** See the TODO(pouya) on the retention section below, and §9 Q60.
|
||||
*
|
||||
* ⚠️ **NO LICENSURE CLAIM AND NO ANSWER TO THE CAPACITY QUESTION.** A privacy
|
||||
* policy is where "legal advice" phrasing arrives by convention. §4 records
|
||||
@@ -205,6 +210,17 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
|
||||
</p>
|
||||
|
||||
<h2>How long it is kept</h2>
|
||||
{
|
||||
/* TODO(pouya): has a test record been written to the intake table with a
|
||||
near-future `ttl` and OBSERVED TO DISAPPEAR? AGENTS.md §9 Q60. The
|
||||
sentence below asserts a MECHANISM, not just a period, and the
|
||||
setting being on does not prove the mechanism runs. The table
|
||||
setting is confirmed — §7 holds that status and this comment does
|
||||
not restate it, because it did restate it once and went stale within
|
||||
the day (§12 R19). Do not answer this from the handler code, which
|
||||
only writes the attribute. This page must not go public until a
|
||||
deletion has actually been seen. */
|
||||
}
|
||||
<p>
|
||||
<strong>{RETENTION_MONTHS} months from the date you send it</strong>,
|
||||
after which the record is deleted automatically by the database rather
|
||||
@@ -229,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
|
||||
|
||||
+28
-3
@@ -121,10 +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. */
|
||||
`--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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,15 +223,17 @@ h4 {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* The class is the type treatment; `Eyebrow.astro` is the label component. A real
|
||||
heading may carry the class — the footer's columns do — but an eyebrow above a
|
||||
heading is never itself an <h*>, which is what the component enforces. */
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-size: var(--text-eyebrow);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
/* An eyebrow is a label, never the page's heading element. */
|
||||
.eyebrow .dot {
|
||||
display: inline-block;
|
||||
inline-size: 6px;
|
||||
|
||||
+36
-11
@@ -64,10 +64,23 @@
|
||||
|
||||
/* Fluid scale, 360px → 1600px viewport. Ratio widens toward the display
|
||||
end (1.25 → 1.333) so headlines scale harder than body copy.
|
||||
--text-2xs is the eyebrow floor docs/02 sets at 11px. Added 2026-08-27:
|
||||
--text-2xs HAS EXACTLY ONE CONSUMER: the header tagline in SiteHeader, held
|
||||
there for a measured layout reason recorded beside it. It is the smallest
|
||||
type on the site and nothing else may use it without a measurement — the
|
||||
three other places that did (the /contact/ labels, the /contact/ dl terms,
|
||||
the /bio/ sheet) all moved to --text-eyebrow on 2026-08-31 because none of
|
||||
them had a reason beyond having been written that way. Added 2026-08-27:
|
||||
SiteHeader wrote `0.6875rem` as a literal, step-1 review finding #7. */
|
||||
--text-2xs: 0.6875rem; /* 11 — eyebrow */
|
||||
--text-2xs: 0.6875rem; /* 11 — label */
|
||||
--text-xs: 0.75rem; /* 12 — legal */
|
||||
/* THE SAME VALUE AS `--text-sm` BELOW, AND DELIBERATELY NOT AN ALIAS OF IT.
|
||||
Uppercase mono at 0.18em reads smaller than it measures, so the eyebrow
|
||||
needs the top of the small range rather than a rung of its own — but it and
|
||||
body-meta type move for different reasons, and aliasing would mean a future
|
||||
change to meta text silently moved every eyebrow on the site. Pouya raised
|
||||
it 12 -> 13 -> 14 px on 2026-08-31; 13 px was still getting lost against the
|
||||
display type. */
|
||||
--text-eyebrow: 0.875rem; /* 14 — eyebrow */
|
||||
--text-sm: 0.875rem; /* 14 — meta */
|
||||
--text-base: 1rem; /* 16 — body */
|
||||
--text-lg: clamp(1.0625rem, 0.99rem + 0.32vw, 1.1875rem); /* 17→19 */
|
||||
@@ -124,15 +137,27 @@
|
||||
|
||||
/* --- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Sticky-header height at >= 66rem, where the header IS sticky. global.css
|
||||
drives `scroll-padding-top` off this, so the skip link does not drop the
|
||||
reader behind the header. If SiteHeader's padding or nav sizing changes,
|
||||
re-measure and change this with it — one fact living in two files.
|
||||
[measured 2026-08-26 — headless Chrome at 1024/1100/1280/1440px, with six
|
||||
nav items and with a seventh injected. 81px at every one: 32 padding + 48
|
||||
reserved brand block + the 1px bottom border, which is easy to forget and
|
||||
is why this is measured rather than added up. The brand reserves 48px so
|
||||
the height does not change when the tagline appears at 76rem] */
|
||||
/* ⚠️ 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 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
|
||||
block + the 1px bottom border, which is easy to forget and is why this is
|
||||
measured rather than added up. The brand reserves 48px so the height does not
|
||||
change when the tagline appears at 76rem] */
|
||||
--header-h: 5.0625rem; /* 81 — measured, not chosen */
|
||||
|
||||
--width-content: 80rem; /* 1280 */
|
||||
|
||||
Reference in New Issue
Block a user