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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-02 06:52:20 -04:00
co-authored by Claude Opus 5
parent ca1c2524e1
commit bd282aa47d
30 changed files with 3256 additions and 143 deletions
+30 -7
View File
@@ -49,15 +49,15 @@ decision, not an aesthetic one.
/legal/terms/ Terms of use
```
Nineteen fixed URLs plus one per article — **and three more added at build steps
8 and 9, all three `noindex` and all three excluded from the sitemap in
`astro.config.mjs`.** They are utilities rather than pages in the IA above, which
is why they are listed here rather than in it:
Nineteen fixed URLs plus one per article — **and four more, all `noindex` and all
excluded from the sitemap in `astro.config.mjs`.** They are utilities rather than
pages in the IA above, which is why they are listed here rather than in it:
```
/contact/received/ The intake form's success landing page
/contact/could-not-send/ Its failure landing page
/bio/ Source of the one-page PDF (R16)
/404/ Emitted as dist/404.html — see below
```
The two `/contact/` pages exist because the site ships **zero JavaScript**, so
@@ -66,9 +66,32 @@ the form is a plain POST and the handler answers `303 See Other` to a real URL
with an appointment proposal is a *rendering of a reviewed page* rather than a
document outside this project's review apparatus.
**So: 22 built pages plus one per published article.** `npm run lighthouse`
enumerates them from `dist/` rather than from this list, which is why this count
being stale could never make the gate miss a page.
⚠️ **`/404/` IS THE ONE ROUTE THAT BREAKS THIS DOCUMENT'S OWN URL RULES, AND IT
HAS TO.** The rules above are lower-case, hyphenated, trailing slash, no file
extensions. Astro emits `src/pages/404.astro` as **`dist/404.html`** — a file at
the root, outside `build.format: 'directory'` — because that is the object name a
CDN custom error response can point at. `SEO.astro` still sees the path as
`/404/`, which is why its `OG_CARDS` key is `/404/` while the URL a tool fetches
is `/404.html`. Added 2026-09-01; `docs/04` had asked for the page since before
build step 1 and it did not exist.
**So: 23 built pages plus one per published article.**
⚠️ **AND THIS SENTENCE USED TO END WITH A REASSURANCE THAT WAS DISPROVEN THE DAY
THE 404 PAGE WAS ADDED.** It read: *"`npm run lighthouse` enumerates them from
`dist/` rather than from this list, which is why this count being stale could never
make the gate miss a page."* Both `scripts/lighthouse.mjs` and
`scripts/og-proof.mjs` enumerated **`index.html` under `dist/`**, not every page —
so both missed `/404/`, and `og:proof` reported it backwards, as an orphaned card
rather than an unchecked page. The count being stale was not the failure mode; the
**definition of "a page"** was. Both now take any `.html` at the root as well, and
`check:claims` always did, which is why the new page's copy was inside the claim
register from its first build.
**The rule that replaces the reassurance:** a route that does not live at
`<dir>/index.html` is invisible to anything that looks for `index.html`. If a
future page is emitted outside the directory convention, grep the three scripts
for `index.html` before trusting any of them.
### URL rules
+87 -17
View File
@@ -495,9 +495,13 @@ Not a polish pass. A build requirement.
⚠️ **It is not a claim about anything outside that grid, and "no document
overflow" is not "no accessibility defect".** Three known cases sit outside it
deliberately. The **fallback-metrics case** below needs the webfonts blocked and
a seventh nav item. The **focus-obscuring case** below is an **open WCAG 2.2
SC 2.4.11 (AA) failure**`AGENTS.md` Q61 — and document overflow cannot see it
at all, because nothing overflows: the header simply covers what has focus. And a
a seventh nav item. The **focus-obscuring case** below was a **WCAG 2.2
SC 2.4.11 (AA) failure**, now **fixed and closed**`AGENTS.md` Q61 — and it is
the sharpest illustration of the warning above: **document overflow could not see
it at all**, because nothing overflowed. The header simply covered what had
focus, on 290 of 1,455 stops, while every zero in the table above stayed a zero.
A residual at `minimumFontSize=16` and `=20` remains, is pre-existing, and is
likewise invisible to this grid. And a
**footer nav label** still overruns its own column by 24 px at 640 px under
minimum font size with 7.7 px of clearance, again with zero document overflow.
None of the three is contradicted by the zeros above, and none of them is
@@ -1034,9 +1038,10 @@ Not a polish pass. A build requirement.
97 → 199.4 px inside a header at 0 → 228.6 px, `position: sticky`,
`inset-block-start: 0px`, background `rgb(250, 247, 242)` — opaque cream.
**THE RECOMMENDED FIX IS MEASURED AND DELIBERATELY NOT IMPLEMENTED — Q61.** Two
candidates exist, and the second is better on the exact ground that deferred the
first.
✅ **FIXED AND CLOSED — Q61 RULED *fix now*, 2026-09-01. Candidate B SHIPPED.**
Two candidates existed, and the second was better on the exact ground that had
deferred the first. The measurements below are the pre-fix state; the acceptance
sweep that closed it is at the end of this block.
*Candidate A, round 1:* a third gate term `calc((15px - 1ch) * 100000)`,
un-sticking the header under the setting while leaving `inset-block-start: 0px`
@@ -1076,20 +1081,85 @@ Not a polish pass. A build requirement.
font file changes nothing, and the fallback-metrics caveat that deferred
candidate A does not apply to B at all.
**Why B is still not implemented, and none of these is a measurement gap.**
`10lh - 83px` is two fitted constants — the difference from A is that a `max()`
ramp degrades by pixels if they are off, where A's `* 100000` step flips
**What was said against B before it shipped, and what the sweep did with each.**
`10lh - 83px` is two fitted constants — the difference from A being that a
`max()` ramp degrades by pixels if they are off, where A's `* 100000` step flips
stickiness the wrong way. **The first declaration is load-bearing:** an engine
without `lh` support drops the whole `max()` as invalid and `scroll-padding-top`
falls back to `var(--space-4)` = 16 px, which is *worse* than today. It needs the
full 1,408-cell sweep plus the fallback-metrics and seven-item cases. The focus
probe behind all of this is **Chrome-only**. And **D19 caps the review at two
rounds**, so a header change made now would ship without review — the precise
failure mode the cap exists to prevent. **The findings are the premise and the
cost; the fix is Pouya's ruling.**
falls back to `var(--space-4)` = 16 px, which is *worse* than no fix — so it is
written first, as a plain declaration, and must stay there. The focus probe is
**Chrome-only**, which is still true and is a limit on the evidence rather than
on the fix. The two remaining objections were discharged by measurement:
**`docs/06` carries this as an OPEN item again**, restored from the `[x]` this
session had written, and the decision is `AGENTS.md` **Q61**.
**THE ACCEPTANCE SWEEP — 777 cells over 37 settings, plus the focus walk.**
Per cell: navigate, kill transitions *before* any root-size change, set the
root, `location.hash = 'main'`, then read how much of `#main` sits behind the
header. The cell count is asserted, so a silently-truncated grid cannot pass.
| setting | before | after |
|---|---|---|
| root style 9..32 (504 cells) | 0 | 0 |
| `defaultFontSize` 9 / 12 / 16 / 20 / 24 (105) | 0 | 0 |
| `minimumFontSize=9`, `=12` (42) | 0 | 0 |
| `minimumFontSize=16` (21) | 6 cells, max 52 px | **6 cells, max 52 px** |
| `minimumFontSize=20` (21) | 12 cells, max 64 px | **6 cells, max 14 px** |
| `minimumFontSize=24` (21) | 15 cells, max 75 px | **0** |
| `minimumFontSize=32` (21) | 15 cells, max 174 px | **0** |
| fonts blocked (21) | 0 | 0 |
| fonts blocked + `minFont=32` (21) | 15 cells, max 174 px | **0** |
| **total** | **63 of 777** | **12 of 777** |
**The focus walk, which is the criterion itself: 290 entirely-hidden stops of
1,455 → 0.** Same grid (6 pages × 5 widths ≥ 1056 px, 30 cells, 344
header-internal stops skipped), hit-tested with `elementFromPoint`. **The
pre-fix tree was rebuilt in a git worktree and put through the identical probe:
it still reports 290** — footer 254, `#main` 36, geometry candidates 333 against
the fixed build's 43. That control is what makes the 0 a result rather than a
hope: both modes reading 0 with identical step counts is the shape `CLAUDE.md`
warns *ends* a check instead of starting one.
**Default settings unchanged: 0 differences over 352 page-widths × 17 fields =
5,984 comparisons**, full-page geometry fingerprint included, positive control
detecting exactly 1 injected difference.
⚠️ **AND THE FIRST ATTEMPT AT THAT COMPARISON REPORTED 4,224 DIFFERENCES.** It
keyed rows on `page`/`width`; this harness names them `url`/`w`, so every row
collapsed onto one map entry and was compared against an arbitrary single row.
It was caught only because the result was *uniformly bad*. **The fix is not the
corrected field names — it is that the comparison now asserts the key fields
exist and are unique before comparing anything.**
⚠️ **ONE COLUMN OF THE SWEEP WAS MEASURING NOTHING, AND TWO METRICS
DISAGREEING IS WHAT SURFACED IT.** A summary column reported
`min(scroll-padding-top headerH)` of **71.89 px** on root-style rows whose
`covered` read **0**. Cause: `insetBlockStart` is **900 px** in those rows —
the header is `position: sticky` and **not pinned**, because that is exactly how
the saturating `clamp()` gate un-sticks it. Comparing the offset to the header
height is meaningless where the header is not pinned. `covered` — real geometry
after a real hash navigation — is the valid metric. **A `position` of `sticky`
is not the same fact as "the header is pinned", and any future probe that
compares an offset against a header height has to read
`inset-block-start` too.**
**THE 12 REMAINING CELLS ARE PRE-EXISTING, REDUCED, AND DELIBERATELY NOT
FIXED.** `minimumFontSize=16` leaves 52 px of `#main` behind the header on
`/about/` and `/contact/` at 1280, 1440 and 1920 px; `=20` leaves 14 px on the
same six. Every one is unchanged or better than before the fix and none is new.
**Why the ramp cannot see them, which is the part worth keeping:** the setting
*floors* computed sizes, so at `minimumFontSize=16` the root is genuinely 16 and
`1lh` on `<html>` is 18 px — identical to the default — while the header grows to
**149.38 px** because the **sub-16 px** type (the 11 px tagline, the 14 px
eyebrow and nav) is floored up. **The ramp reads a quantity the setting did not
move.** Closing it needs a different measurement — `1lh` taken from an element
that carries the small type, or a floor on the header — which is a header change
with site-wide reach. Pouya's instruction on this step was *"if the sweep
surfaces anything beyond this one declaration's blast radius, stop and report —
do not widen"*, so it is reported: `docs/06` carries it as its own item. It is
**not** an SC 2.4.11 failure, which is about a component *entirely* hidden.
**`docs/06`'s item is ticked and carries the figures.** The decision was
`AGENTS.md` **Q61**, now closed.
⚠️ **AND ONE MORE LATENT CASE, AT THE DEFAULT TEXT SIZE — NO READER SETTING
INVOLVED: FALLBACK FONT METRICS PLUS THE SEVENTH NAV ITEM.** With every `.woff2`
+92 -11
View File
@@ -70,7 +70,9 @@ dynamic origin or client-side script, and the site has neither by design.
A control that exists on paper and not in fact is worse than a stated gap — that
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
said out loud, and the load is carried by the honeypot, the `Origin` check, the
API Gateway rate limit and server-side validation.
**aggregate** API Gateway route throttle and server-side validation. (Aggregate,
not per-IP — see §Validation. "Rate limit" was the wording here and let the reader
supply the stronger meaning.)
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
is a top-level navigation: it is exempt from CORS preflight, so an
@@ -156,8 +158,26 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
- Required fields present; email well-formed; lengths within bounds
- Reject any field over its cap rather than truncating silently
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
- **Timestamp check** — reject submissions completed in under 3 seconds
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
while this line stayed an unqualified imperative** — the handler's header,
§Three deviations above, and the definition of done below. §Three deviations
has the reasoning: `/contact/` is a CDN-cached static file, so a build-time
timestamp is the same value for every visitor and `now served` is always
large. **This is the unstruck-imperative shape `CLAUDE.md` names** — and it
survived in the same list whose sibling bullet was struck correctly, which is
the sweep failure exactly. Found by `adversarial-reviewer` round 2
- ~~**Rate limit** by source IP at API Gateway: 5 requests / 5 minutes~~
⚠️ **STRUCK 2026-09-01: API GATEWAY CANNOT RATE-LIMIT BY SOURCE IP, SO THIS
ASKED FOR A CONTROL THAT CANNOT BE BUILT WHERE IT SAYS TO BUILD IT.** HTTP API
throttling is **aggregate** — a rate and a burst, per route and per stage,
across all callers. Per-IP limiting needs **AWS WAF** with a rate-based rule on
the distribution, which is a paid service and therefore a decision rather than
a step. What ships instead is the aggregate throttle
(`docs/09-cutover-runbook.md` Part 6.3), and it must never be described as
per-IP. This is deviation 1's own argument turned on this spec: *"a control that
exists on paper and not in fact is worse than a stated gap"* — the throttle is
real and bounds total volume; the per-IP claim was neither
- No CAPTCHA. It is a third-party script on a page collecting legal information,
and the two controls above stop the traffic that matters
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
@@ -171,18 +191,44 @@ handing over sensitive material, and where it comes to rest is a fair question
for them to ask. Confirm the existing table's region and migrate if it is
elsewhere — §7 has the table name and region.
⚠️ **THE KEY SCHEMA IS THE TABLE'S, NOT THIS SPEC'S — CORRECTED 2026-09-01, AND
THE UNCORRECTED VERSION WOULD HAVE LOST EVERY SUBMISSION.** This table specified
`pk: INTAKE#<uuid>` and `sk: <timestamp>`, and `handler.mjs` was written to it.
The table `AGENTS.md` §7 names has a single partition key **`submissionId` (S)`
and no sort key** `[verified 2026-09-01 — aws dynamodb describe-table]`. A
`PutItem` missing the key attribute fails the whole write with
`ValidationException`, the handler catches it and answers the failure page — so
the form would have looked broken to every inquirer while the record went
nowhere, from the moment `/api/*` was wired. **A DynamoDB key schema cannot be
altered after creation**, so the handler was changed to the table rather than the
reverse; the alternative, a new table matching the old shape, was declined
because it would re-open the §7-verified TTL and PITR state on a fresh resource
at cutover to buy a sort key nothing queries. Verify with `describe-table`, not
against this row.
| Attribute | |
|---|---|
| `pk` | `INTAKE#<uuid>` |
| `sk` | `<ISO-8601 timestamp>` |
| `submissionId` | `<uuid>` — **the partition key.** Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console |
| `submittedAt` | `<ISO-8601 timestamp>` — an ordinary attribute, not a sort key |
| fields | as above |
| `sourceIp`, `userAgent` | abuse investigation only |
| `consentAt` | `<ISO-8601 timestamp>` — when the consent box was submitted |
| `ttl` | epoch seconds — **the input to automatic deletion; see §Retention for why writing it is not the mechanism** |
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
on.** Table access limited to the Lambda role and one named administrative
principal.
⚠️ **TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT.**
PITR is **on** `[verified 2026-09-01 — describe-continuous-backups,
PointInTimeRecoveryStatus: ENABLED, 35-day window]`. Encryption at rest is on
with the **AWS-owned key, not a customer-managed KMS key** `[verified
2026-09-01 — describe-table returns no SSEDescription]`. That gap is
deliberately not a cutover blocker: `/legal/privacy/` says "encrypted at rest",
which is unconditionally true of every DynamoDB table, and it does not claim a
customer-managed key — so nothing published depends on it. It stays on
`docs/06`'s checklist as the improvement it is.
### Retention
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
@@ -286,8 +332,32 @@ no visibility.
— silently, months later.
Failure handling: SES failure must never lose the submission. Write to DynamoDB
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
DLQ depth ≥ 1.
first, then send. ~~A dead-letter queue on the Lambda, and a CloudWatch alarm on
DLQ depth ≥ 1.~~
⚠️ **THE DLQ IS STRUCK, 2026-09-01, AND IT WOULD HAVE BEEN A CONTROL THAT
RECEIVED NOTHING.** Lambda's `DeadLetterConfig` is used **only for asynchronous
invocations** (and event-source failures). API Gateway invokes this function
**synchronously** and the error is returned to the caller, so a DLQ configured on
`adr-intake-handler` would sit at depth 0 for ever and an alarm on it would be a
green light that means nothing — the third instance of this project's most
expensive shape, after `AGENTS.md` Q22 and the Lighthouse row.
What actually protects a submission is already built and is not a queue: the
handler **writes to DynamoDB before sending mail**, so a mail failure cannot lose
a record, and a write failure returns the visitor to `/contact/could-not-send/`
rather than telling them an inquiry was received. What is missing is **detection**,
and the replacement is two CloudWatch alarms rather than one:
- **Lambda `Errors` ≥ 1** on `adr-intake-handler` — this is what a DLQ alarm was
reaching for and it fires on a synchronous failure, which a DLQ cannot see.
- **API Gateway `5xx` ≥ 1** on the `POST /api/intake` route — it catches the one
failure the Lambda cannot report, a permission or integration fault where the
function is never entered at all (`docs/09-cutover-runbook.md` Part 6.1 is the
step whose omission causes exactly that).
Both notify the `ses-alerts` topic, whose email subscription is **confirmed** as
of `AGENTS.md` §7 — so unlike the DLQ alarm, these reach someone.
## Booking
@@ -371,14 +441,25 @@ Plausible or Fathom, cookieless, no consent banner.
- [x] **Server-side validation independent of the client** — `backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
- [x] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
- [ ] **Throttle configured** — an **aggregate** API Gateway route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. `docs/09-cutover-runbook.md` Part 6.3
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months, and it asserts the **mechanism**, not only the period. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — and **writing the attribute is not the mechanism**: TTL must also be enabled on the table, which is a table setting the code cannot see. **`AGENTS.md` §7 holds that status and its stamp; this line does not restate it** — it restated it once, went stale within the day, and had to be pulled back (§12 R19). **The test record is what closes this item, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour. Tracked as §9 Q60
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
- [x] **PITR enabled** — `ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
- [ ] KMS customer-managed key. **Not on the table: encryption at rest is with the AWS-owned key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`. **Not claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
- [ ] **Table access matches what `/legal/privacy/` says about it.** ⚠️ **IT DOES NOT, AS AT 2026-09-01.** The page says *"nobody else has access to the table… no external administrator"*; the account's `admins` group carries `AdministratorAccess` and has **two** members, and `simulate-principal-policy` returns **allowed** for `dynamodb:GetItem`/`Query`/`Scan` for both. Evidence and commands: `docs/reference/intake-table-access-verification.md`. §9 **Q62**, and it blocks that page going public
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
- [ ] DLQ and CloudWatch alarm configured. The handler writes to DynamoDB **before** sending mail, so a replay cannot lose a submission
- [ ] **CloudWatch alarms on Lambda `Errors` and API Gateway `5xx`** — replacing the DLQ item, which is struck: a DLQ on a **synchronously** invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB **before** sending mail, so the protection this item was pointing at is in the code rather than in a queue
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
> **The three remaining items below are commands, and the commands are in
> `docs/09-cutover-runbook.md`** — Parts 5, 6 and 3 respectively, each with its
> verification and the output to expect. Two things that spec found by reading the
> running system rather than the specs, and both would have lost every
> submission: the API route needs its **own** Lambda invoke permission, because
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the
> handler's item shape had to change, because the table's partition key is
> `submissionId` and a key schema cannot be altered after creation (§Storage).
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it. **And two other distribution changes are prerequisites of the site working at all**, neither of which is intake: a viewer-request function for `trailingSlash: 'always'`, without which 22 of 23 pages return S3's `AccessDenied`, and the 404 mapping `docs/04` requires
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
+253 -48
View File
@@ -39,6 +39,30 @@ One thing blocks the workflow, and it is not a fact to look up:
- **Actions are not enabled and no runner is registered** (Q23). The Gitea
instance is jointly administered, so both need its second administrator.
> ✅ **AND THAT IS NOW A DECISION RATHER THAN A BLOCKER — Pouya's ruling,
> 2026-09-01: THE FIRST PRODUCTION DEPLOY GOES OUT VIA `scripts/deploy-local.sh`,
> AND THE GITEA RUNNER IS DEFERRED UNTIL AFTER LAUNCH.**
>
> His reason, and it is the reason the deferral is safe: enabling Actions and
> registering `act_runner` depends on the instance's **second administrator**, so
> the date it happens is not his to set — and **nothing about the deploy depends
> on it.** The script and the workflow are one artefact in two places; they run
> the same guard, the same three passes with the same cache headers, the same
> invalidation and the same intake route check. The pipeline changes only **how a
> deploy is triggered**. So a launch that waits for a runner is a launch waiting
> on someone else's calendar for no gain.
>
> **What the deferral actually costs, stated rather than waved past:** every
> deploy until then is a person at a keyboard with a long-lived access key in
> their environment, and nothing gates a merge to `main` (there is no
> `pull_request` trigger). Both were already true; the ruling does not add a
> risk, it declines to remove one yet. **`AGENTS.md` R17's rotation date does not
> move** — the key is live either way.
>
> It is **not** a cutover-checklist item and must not become one: a checklist
> fires once, at cutover, and this is deliberately scheduled for after it. The
> `docs/09-cutover-runbook.md` Part 8 sequence is the deploy path that ships.
> ✅ **`adr-sml-deploy` EXISTS — created 2026-08-26, Q22 closed 2026-08-28.**
> **DO NOT CREATE IT.** This bullet said *"`adr-sml-deploy` does not exist —
> `aws iam get-user` returns `NoSuchEntity`… Create it from Create the user
@@ -364,6 +388,27 @@ Then invalidate `/*`.
## Cutover checklist — D11 is a single shot, so run all of it
> **The commands are in `docs/09-cutover-runbook.md`.** This list is what must be
> true; that file is how, in order, with each command's verification and the
> output to expect. It also records the order dependency this list cannot show:
> **the distribution work goes first and the site sync goes last**, because
> reversing them puts 22 of 23 pages behind a 403 for as long as a CloudFront
> deployment takes.
> 🛑 **THREE THINGS BLOCK THIS ENTIRE LIST AS AT 2026-09-01, AND TWO OF THEM WERE
> FOUND BY READING THE RUNNING SYSTEM RATHER THAN THE SPECS.**
>
> 1. **`/legal/privacy/` states something false about who can read the intake
> table** — §9 **Q62**, evidence in
> `docs/reference/intake-table-access-verification.md`. A privacy policy is
> the wrong page to be wrong on, and this one is wrong about third parties'
> dispute information.
> 2. **Q60 is still open** — TTL is `ENABLED` and no record has been watched to
> disappear, and the same page asserts the mechanism. `docs/09` Part 10 is the
> test and its answer arrives days after it starts, so **start it early**.
> 3. **`claims-auditor`'s D20 pass returned six copy findings on 2026-09-01;
> five are corrected and one is a ruling** — the `/med-arb/` gloss, below.
**Cutover prep — deferred maintenance, done BEFORE the checklist below**
This group exists because deferring a thing and forgetting it look identical
@@ -406,6 +451,33 @@ the decision is re-readable rather than re-litigated.
defects worth catching late are the ones that only exist once the pages sit
next to each other. Give it the whole built site and the reading order a
visitor takes.
✅ **IT HAS RUN ONCE, 2026-09-01, OVER ALL 23 PAGES — AND IT RETURNED
FAIL WITH EIGHT FINDINGS, WHICH IS THE ARGUMENT FOR D20 RATHER THAN
AGAINST IT.** Five are corrected (`/practice/energy/`'s lede asserted a
change to how *generation* connects, which the extract's own quotation
denies; `/practice/insurance/` stated an absolute adjudicator
disqualification where LAT Rule 14.3 says *"except with the consent of the
parties"*, and glossed one permissive sentence as *"the Tribunal's own
materials point parties toward mediation"*; `/practice/technology/` asserted
*"no federal or Ontario statute"* — a universal over the **four**
instruments its extract checked — and its own note then disclaimed that
conclusion as the Commissioner's words; `/med-arb/` said the ADRIC Med-Arb
Rules were presented to the membership where the source says a **discussion
draft** was). Three remain and each has its own item below or above:
the `/med-arb/` gloss (a ruling), Q60, and Q62.
**Two things about that result are worth carrying forward.** The pass found
no defect in any claim about Pouya, his credentials or his designations —
every one traced. What it found was **five over-reaches in glosses on
sourced legal material**, which is the failure mode a per-step claims run on
a single page is least able to see and the one `check:claims` cannot grep
for at all. **And it re-derived a stale instruction inside its own brief:**
`.claude/agents/claims-auditor.md` still tells it Q.Arb is *"commenced
August 2026"*, which §4 struck on 2026-08-29 — applied literally it would
have flagged correct copy and demanded the struck form. It read §4 instead.
That is the fifth stale claim found in that file and it is not the agent's
to fix
- [ ] **Pouya has read every page against `AGENTS.md` §4.** The human pass. It is
the other half of D20 and it is not delegable — his reading is what the
per-step audit was traded for.
@@ -467,7 +539,27 @@ the decision is re-readable rather than re-litigated.
Chrome — it is not in CI and cannot be (see above). Read the exit status.
Every category ≥ 95 on every page; the accessibility figure is measured
with `prefers-reduced-motion` forced and that qualification is reported
with it (`AGENTS.md` §7)
with it (`AGENTS.md` §7).
✅ **Run 2026-09-01 on the 23-page build: exit 0, worst of 23 is
99 / 100 / 100 / 100.** Re-run at cutover — this is dated, not discharged.
⚠️ **The script was enumerating 22 of 23 and its header claimed it
enumerated the site.** It collected `index.html` only, and `404.astro` is
emitted as `dist/404.html`, outside `build.format: 'directory'`. So the
budget would have been asserted over every page except the new one, and
silently. `og:proof` had the identical blind spot and reported it
**backwards** — as the 404 card being orphaned ("generated, but no built
page references it") rather than the page being unmeasured. Both fixed; the
two use different path forms for the same file on purpose, and each says why
beside the code. **`check:claims` was never affected** — it takes any
`.html`, so the new page's copy was inside the claim register from its first
build. One instrument of three saw the page, which is the argument for
having three.
⚠️ **One reported number sits at its budget: LCP on `/` is 2.03 s against
`docs/04`'s < 2.0 s.** The script reports LCP and CLS without asserting
them, deliberately — simulated throttling on a loopback server is not the
Slow 4G field measurement `docs/04` budgets against — so this is not a
failing gate and must not be reported as a passing one either. CLS is
0.000 on all 23
- [ ] Every page renders fully with JavaScript disabled
- [ ] `curl` of each URL returns real content, not a shell
- [ ] All internal links resolve; no orphan pages
@@ -480,12 +572,44 @@ the decision is re-readable rather than re-litigated.
is invisible from inside the repo, and because card text cannot be reached
by `check:claims`. Run `npm run og:proof -- --sheet` and **look at the
contact sheet** as well — the check compares strings, not rendering
- [ ] 404 returns a 404 status
- [ ] **404 returns a 404 status AND serves the 404 page. Both halves, and until
2026-09-01 neither existed.** There was **no 404 page in the site at all**
— no `src/pages/404.astro`, 22 pages, and `docs/04` had asked for one
("Real, styled, with search-intent links out") since before build step 1 —
**and** no custom error response on the distribution, so a missing URL
returned S3's `AccessDenied` XML. Measured, not inferred: `/about/` and
`/definitely-not-a-page/` both answered **403, `application/xml`, 111
bytes** on the live distribution. The page now exists (`/404/`, `noindex`,
out of the sitemap, with its own OG card because the registry has one rule
and no exceptions) and `docs/09` Parts 13 configure the distribution. **Tick
this after `docs/09` Part 8.4**, which checks the status *and* greps the body
for the page's own `<h1>` — a 404 status serving an empty body passes the
status half and fails the point
- [ ] Security headers present (`securityheaders.com` A or better)
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
- [ ] **SES bounce/complaint alarms actually notify someone**`AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
- [x] **SES identities verified for sending** — `VerifiedForSendingStatus: true`, `DkimAttributes.Status: SUCCESS`, signing enabled, and no custom MAIL FROM (so DMARC rests on DKIM alignment, which is what §7 records) `[re-verified 2026-09-01 — sesv2 get-email-identity]`
- [x] ✅ **SES bounce/complaint alarms DO notify someone — R9 DISCHARGED, 2026-09-01.** `aws sns list-subscriptions-by-topic` on `ses-alerts` returns the email subscription to `info@smlcompany.ca` with a **real subscription ARN**, not `PendingConfirmation`. §7 recorded it as pending, and §12 R9 said *"this is the first thing to check if `/contact/` ships"* — it had been confirmed at some point before this reading and the record had not moved, which is the same staleness in the safe direction. *(SES production access itself is granted — Q19 closed.)*
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
IT DOES — build step 8 shipped the page and not the pipe.**
⚠️ **THE COMMANDS ARE `docs/09-cutover-runbook.md` PARTS 5 AND 6, AND
WRITING THEM FOUND TWO MORE THINGS, EACH OF WHICH WOULD HAVE LOST EVERY
SUBMISSION.** Both were found by reading the running system rather than the
specs, which is the only way either could have been found.
**(a) The handler wrote the wrong key.** It built its item with
`pk: INTAKE#<uuid>` / `sk: <timestamp>` from `docs/05`'s table, and the
live table's key schema is **`submissionId` (S), no sort key** — a
`PutItem` missing the key attribute fails the whole write with
`ValidationException`, which the handler catches and answers with the
failure page. **The form would have looked broken to every inquirer while
the record went nowhere**, from the moment `/api/*` was wired. A DynamoDB
key schema cannot be altered after creation, so the handler moved to the
table; `docs/05` §Storage records the alternative that was declined.
**(b) The Lambda's invoke permission is `SourceArn`-scoped to the OLD
route.** The one statement on the function conditions
`apigateway.amazonaws.com` on `…/4tl0m5igkj/*/*/submissions`. Add a route at
`/api/intake` without adding a permission and API Gateway is refused: the
caller gets a **500**, and **the Lambda logs nothing at all** because it is
never entered — the hardest possible thing to diagnose from CloudWatch,
which is where anyone would look first.
1. **A CloudFront behaviour routing `/api/*`** to the HTTP API origin
`AGENTS.md` §7 records. The form posts to the same-origin path
`/api/intake` — deliberately, so the CSP can be `form-action 'self'`,
@@ -512,21 +636,36 @@ the decision is re-readable rather than re-litigated.
not itself creating a conflict check**, which a hand-typed copy in the
handler had dropped. `RESPONSE_TIME` must read identically on `/contact/`,
in the confirmation email, and in the bio.
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
(`docs/05`). Not expressible in handler code, and it is carrying load
the unimplementable 3-second timing check was specified to carry — see
`docs/05` §Three deviations.
3. **An AGGREGATE API Gateway route throttle** — `docs/09` Part 6.3. Not
expressible in handler code, and it carries load the unimplementable
3-second timing check was specified to carry (`docs/05` §Three
deviations). ⚠️ **THIS ITEM SAID "5 requests / 5 minutes per source IP"
AND THAT IS NOT BUILDABLE AT API GATEWAY** — throttling there is per
route and per stage across all callers. It contradicted this same file's
own correction further down the list, and it is a to-do list an operator
works through, so it would have cost an hour in the API Gateway console
looking for a control that is not there. Per-IP needs AWS WAF, which is
a cost decision and has its own item.
✅ **BOTH DEPLOY PATHS NOW WARN IF ITEM 1 IS MISSING**, so this does not
rest on someone reading this list. `scripts/deploy-local.sh` and
`.gitea/workflows/deploy.yml` each end with an **Intake route check**: a
`POST` to `/api/intake` with no `Origin`. **404 means not routed. 403 means
routed and refused**, which is the handler's `Origin` check doing its job
and is therefore a pass — and is why the probe is safe against production:
it is rejected before any DynamoDB write or any email. It warns rather than
failing, because by that point the site is already deployed and failing the
job would not un-deploy it. This replaced an `INTAKE_ENDPOINT` guard that
blocked deploys on a variable nothing read.
`.gitea/workflows/deploy.yml` each end with an **Intake route check**.
⚠️ **AND THIS PARAGRAPH DESCRIBED A PROBE THE SCRIPTS NO LONGER RUN.** It
said the probe was *"a `POST` to `/api/intake` with **no `Origin`**"* where
*"404 means not routed, 403 means routed and refused… therefore a pass"*.
Both scripts send **`Origin: https://adr.smlcompany.ca`** and assert **303
to `/contact/could-not-send/`**; 403 is a **warning**, not a pass, because
it cannot be told apart from CloudFront rejecting a disallowed method. A
stale description of a live control, in the spec that governs it — found by
`adversarial-reviewer`, 2026-09-01.
**What the probe actually is:** the handler validates, finds an empty
submission, and redirects — which happens **before any DynamoDB write and
before any email**, and is what makes it safe against production. It warns
rather than fails, because by that point the site is deployed and failing
the job would not un-deploy it. **404 is ambiguous between three causes**
and `docs/09` Part 7.1 lists them with the one command that separates them.
This replaced an `INTAKE_ENDPOINT` guard that blocked deploys on a variable
nothing read.
- [ ] **DYNAMODB TTL ENABLED ON THE TABLE, AND VERIFIED BY A TEST RECORD. THIS
ONE BACKS A PUBLISHED PROMISE AND MUST NOT BE TICKED FROM THE HANDLER
CODE.** `/legal/privacy/` states that a record is *"deleted automatically
@@ -542,6 +681,21 @@ the decision is re-readable rather than re-litigated.
confirm it actually disappears. Both halves, or the item stays open. §9
Q60 and §12 R19 track it; `src/pages/legal/privacy.astro` carries the
matching `TODO(pouya)`.
✅ **THE SETTING IS RE-CONFIRMED AND THE PROCEDURE NOW EXISTS:**
`TimeToLiveStatus: ENABLED` on attribute `ttl` `[re-verified 2026-09-01 —
describe-time-to-live]`, and `docs/09-cutover-runbook.md` **Part 10** is the
behaviour test — write a row with a near-future `ttl`, record its id to a
file, and check on a **different day**.
⚠️ **START IT EARLY. DynamoDB's TTL sweeper is not prompt and AWS does not
promise that it is** — deletion typically lands within a couple of days of
the timestamp passing, so the runbook sets the earliest useful check at
**48 hours** and refuses to call a failure before **7 days**. A check run
five minutes after the `ttl` that finds the row present has measured
nothing. This is the one blocking item on this list whose answer cannot be
hurried, so it should be started before anything else here, not last.
⚠️ **And `get-item` on a missing key exits 0 with an empty body** — read the
output, not the exit status; a script checking only the status reports
present and absent identically.
- [ ] **`npm run check:intake`** — the form's field table and the Lambda's agree
on names, requiredness, caps and option sets. They are two independent
tables on purpose (`docs/05`); this is what stops them drifting
@@ -613,35 +767,86 @@ the decision is re-readable rather than re-litigated.
control proving `break-word` would not have worked, and the candidates
declined as broader than the cause
- [ ] 🛑 **THE MINIMUM-FONT-SIZE STICKY RESIDUAL — REOPENED THE SAME DAY IT WAS
TICKED, AND IT IS A WCAG 2.2 SC 2.4.11 (AA) FAILURE RATHER THAN THE
SKIP-LINK INCONVENIENCE IT WAS ACCEPTED AS. NEEDS A FRESH RULING.**
It was ruled accepted on 2026-09-01 on the basis *"you proved no CSS
mechanism can see minimum-font-size; the only fix is JS and zero-JS is a
founding decision"*, and this checklist item was ticked accordingly. Both
supporting facts are wrong, and `adversarial-reviewer` found it the same day.
**(1) The premise.** The proof covered `rem`/`em` and queries written in them.
The font-metric units **`ch`, `ex`, `cap`, `lh`, `rlh`** read the *used* font
size and double under the setting — in property values, in `@media` **and** in
`@container`. A pure-CSS detector exists, so "the only fix is JS" does not
hold. **(2) The cost.** Not a short skip-link landing but **ordinary keyboard
focus landing entirely behind an opaque sticky header**: a Shift+Tab walk over
6 pages × 5 widths ≥ 1056 px gives **290 entirely-hidden focus stops of 1,455**
under the setting — **254 in the footer and 36 inside `#main`** — against **0**
at the default size. Hit-tested with `elementFromPoint`, not inferred from
geometry: geometry alone said 43 at the default size and all 43 were the skip
link, which is stacked *above* the header on purpose. **(3) And the header fix
created it:** a build of `fce89d4~1` measures **0** under the same setting,
because `flex-wrap: nowrap` kept the masthead one row and short. So the trade
was **830 px of horizontal overflow (SC 1.4.10) for 290 obscured focus stops
(SC 2.4.11)** — both Level AA, and the asymmetry that was the whole argument
does not exist. **A candidate fix is measured but deliberately not
implemented:** a third gate term in the shipping idiom, `calc((15px - 1ch) *
100000)`, un-sticks the header under the setting and leaves
`inset-block-start: 0px` at the default — but 15 px is fitted to Geist's `ch`
and needs checking under fallback metrics and Chrome's other font presets.
**This item is ticked only by a ruling from Pouya, not by a re-measurement.**
The decision is `AGENTS.md` **Q61**; `docs/02` §Reflow carries the measurements
- [x] **THE MINIMUM-FONT-SIZE SC 2.4.11 (AA) FAILURE IS FIXED, 2026-09-01 —
Q61 RULED *fix now* AND CLOSED.** Two declarations on `html` inside the
existing `@media (min-width: 66rem)` block: the plain
`calc(var(--header-h) + var(--space-4))` first, then
`max(calc(var(--header-h) + var(--space-4)), calc(10lh - 83px))`.
**`1lh` and not `rem`, because the font-metric units read the USED font
size** — that is the mechanism the earlier ruling's premise denied existed.
**Measured, with the pre-fix build rebuilt in a worktree and put through the
identical probe:**
the Shift+Tab walk over 6 pages × 5 widths ≥ 1056 px goes from **290
entirely-hidden focus stops of 1,455 to 0** — same grid, same 1,455 steps,
same 344 header-internal stops skipped — while the pre-fix control still
reports **290** (footer 254, `#main` 36), so the probe is proven able to see
the failure it now reports gone.
**The default-settings rendering is unchanged: 0 differences across 352
page-widths × 17 fields = 5,984 comparisons**, full-page geometry
fingerprint included, with a positive control detecting exactly 1 injected
difference. `scroll-padding-top` computes to **97 px** at the default and
**287 px** under the setting against a 270.56 px header; `1lh` on `<html>`
is **18 px / 37 px with every `.woff2` blocked**, identical, because
`<html>` keeps the UA family — the property that the `ch` candidate could
not claim.
**A wider grid than the ruling asked for, and it found a residual: 777
cells over 37 settings went from 63 failing to 12, and nothing got worse in
any cell.** The remainder is the next item, it is **pre-existing**, and it
was deliberately not fixed — Pouya's instruction was to stop and report
anything beyond the one declaration's blast radius
- [ ] ⚠️ **MINIMUM FONT SIZE AT 16 AND 20 STILL LEAVES `#main` BEHIND THE HEADER —
PRE-EXISTING, REDUCED BY Q61'S FIX, AND OUTSIDE ITS MECHANISM.** At
`minimumFontSize=16`, **52 px** of `#main` sits behind the sticky header
after "Skip to content" on `/about/` and `/contact/` at 1280, 1440 and
1920 px — 6 cells; at `minimumFontSize=20`, **14 px** on the same 6. Before
Q61's fix the same grid gave 6 cells at 52 px, **12 at 64 px**, 15 at 75 px
and 15 at 174 px, so every remaining cell is unchanged or better and none
is new.
**Why the `lh` ramp cannot see it, and this is the interesting part:** the
setting *floors* computed sizes, so at `minimumFontSize=16` the root is
genuinely 16 and `1lh` on `<html>` is 18 px — identical to the default —
while the header grows to **149.38 px** because the sub-16 px type (the
11 px tagline, the 14 px eyebrow and nav) is floored up. The ramp reads a
quantity the setting did not move. **A different measurement would be
needed** — `1lh` taken from an element that actually carries the small type,
or a floor on the header — and that is a header change with site-wide reach,
which is why it is filed rather than taken inside a capped review loop.
Not a conformance failure of the same shape as Q61: a partial obscuring is
not SC 2.4.11, which is about a component **entirely** hidden. `docs/02`
§Reflow carries the 777-cell grid
- [ ] ⚠️ **THE `/med-arb/` GLOSS NEEDS A RULING — `claims-auditor`, D20 pass,
finding 1.** §Why this practice says *"Med-arb is not a third service
bolted onto mediation and arbitration; it is the two of them run under one
agreement"*, and one section above the page publishes ADRIC's own words:
med-arb is *"not merely the merging of separate mediation and arbitration
processes, but a unique process designed to meet the needs of particular
disputants"*. **The gloss asserts what its own cited authority denies in
terms, on the same page.** Remove one of the two — either the *"not merely
the merging"* clause from the quotation, or the *"it is the two of them run
under one agreement"* sentence. ⚠️ **Do not resolve it by adding a
competence claim:** ADRIC answers its own competence sentence with the
Chartered Med-Arb designation and the Med-Arb Foundational Course, neither
of which is held, and C.Med-Arb is struck from this site entirely (§4). The
2026-08-30 fix took the competence proposition out of the **heading** and
left it in the **body**, which is why this is a second pass at the same
paragraph
- [ ] ⚠️ **FOUR PRE-EXISTING DYNAMODB ITEMS HAVE NO `ttl` AND WILL NEVER EXPIRE.**
The table held 4 items before any of this work `[verified 2026-09-01 —
describe-table ItemCount]`, written by the hand-built handler, whose code
is in `docs/reference/AWS-Hosting-Guide.md` Part 8.3 and **writes no `ttl`
attribute at all**. So they are retained indefinitely while
`/legal/privacy/` says a record is deleted automatically after 24 months.
**Pouya's call and not scriptable**, because it turns on something no agent
can determine: whether they are the guide's own smoke test (`Test User`) or
real inquiries that arrived through the old site. Read them, then delete the
test rows or write a `ttl` onto the real ones. `docs/09` Part 7.3
- [ ] ⚠️ **PER-IP RATE LIMITING NEEDS AWS WAF AND IS A COST DECISION — NOT A
LAUNCH BLOCKER.** `docs/05` asked for "5 requests / 5 minutes per source
IP at API Gateway"; **API Gateway throttling is aggregate, not per-IP**, so
that spec line is struck and an aggregate route throttle ships instead
(`docs/09` Part 6.3). A rate-based WAF rule on the distribution is what
would do per-IP. Decide it on price, not on the spec's old wording — and do
not let anything describe the throttle that ships as per-IP
- [ ] ⚠️ **A FOOTER NAV LABEL OVERRUNS ITS COLUMN BY 24 px AT 640 px UNDER
MINIMUM FONT SIZE, WITH 7.7 px OF CLEARANCE TO THE NEXT COLUMN.** No document
@@ -704,10 +909,10 @@ the decision is re-readable rather than re-litigated.
checks passed while both defects shipped. **Owner: `AGENTS.md` R20**
**Infrastructure**
- [ ] S3 versioning enabled
- [ ] Bucket not publicly readable; OAC in force
- [x] **S3 versioning enabled** — `Status: Enabled` `[re-verified 2026-09-01 — get-bucket-versioning]`. This is what makes pass 3's `--delete` recoverable at cutover
- [x] **Bucket not publicly readable; OAC in force** — all four public-access blocks `true`, and a direct `GET` on the S3 hostname returns **403** `[re-verified 2026-09-01 — get-public-access-block, curl]`. ⚠️ **Re-check this after `docs/09` Part 1**, which adds an `s3:ListBucket` statement for the CloudFront service principal so that missing keys return 404 rather than 403. Part 1 asserts the two facts that actually make the grant safe: **no viewer path resolves to the bucket root** (the default root object, then the router, always turn `/` into a key), and the default behaviour **forwards no query strings** to the origin with **no origin request policy** overriding that. ⚠️ **This line used to prescribe `curl "$SITE/?list-type=2"` as "the check that matters"** — and Part 1 was rewritten in the same change set to explain that that check **cannot fail** and is therefore evidence of nothing. Two documents, one fix, and the discarded check survived in the one an operator ticks. Found by `adversarial-reviewer` round 2
- [ ] ACM certificate valid; Namecheap validation CNAME still present
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
- [ ] **CloudWatch alarms: Lambda `Errors`, API Gateway `5xx`.** ⚠️ **"DLQ depth" was on this line and is struck: a dead-letter queue on this function would never receive anything.** `DeadLetterConfig` applies to **asynchronous** invocations, and API Gateway invokes synchronously — so the alarm would have sat permanently green while telling everyone it was watching, which is this project's most expensive recurring shape (`AGENTS.md` Q22, the Lighthouse row, and now this). `docs/05` §Notification carries the replacement and why there are two alarms rather than one: Lambda `Errors` catches a synchronous failure, and API Gateway `5xx` catches the case where the function is never entered at all
- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)"`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
**Post-cutover, same day**
+3 -1
View File
@@ -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
+967
View File
@@ -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 17 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 14 (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 57 (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 "mediatorparty",
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 23** — 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.
+4 -1
View File
@@ -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.**
+9 -1
View File
@@ -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"