feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
Five items of Pouya's production run, 2026-09-01.
Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.
Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.
Four launch blockers found by reading the running system:
- handler.mjs wrote pk/sk; the live table's key is submissionId with no
sort key, so every submission would have failed validation silently
- the Lambda invoke permission is scoped to the old route path
- 22 of 23 pages 403 without the router function
- there was no 404 page; src/pages/404.astro adds it
Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.
Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca1c2524e1
commit
bd282aa47d
+30
-7
@@ -49,15 +49,15 @@ decision, not an aesthetic one.
|
||||
/legal/terms/ Terms of use
|
||||
```
|
||||
|
||||
Nineteen fixed URLs plus one per article — **and three more added at build steps
|
||||
8 and 9, all three `noindex` and all three excluded from the sitemap in
|
||||
`astro.config.mjs`.** They are utilities rather than pages in the IA above, which
|
||||
is why they are listed here rather than in it:
|
||||
Nineteen fixed URLs plus one per article — **and four more, all `noindex` and all
|
||||
excluded from the sitemap in `astro.config.mjs`.** They are utilities rather than
|
||||
pages in the IA above, which is why they are listed here rather than in it:
|
||||
|
||||
```
|
||||
/contact/received/ The intake form's success landing page
|
||||
/contact/could-not-send/ Its failure landing page
|
||||
/bio/ Source of the one-page PDF (R16)
|
||||
/404/ Emitted as dist/404.html — see below
|
||||
```
|
||||
|
||||
The two `/contact/` pages exist because the site ships **zero JavaScript**, so
|
||||
@@ -66,9 +66,32 @@ the form is a plain POST and the handler answers `303 See Other` to a real URL
|
||||
with an appointment proposal is a *rendering of a reviewed page* rather than a
|
||||
document outside this project's review apparatus.
|
||||
|
||||
**So: 22 built pages plus one per published article.** `npm run lighthouse`
|
||||
enumerates them from `dist/` rather than from this list, which is why this count
|
||||
being stale could never make the gate miss a page.
|
||||
⚠️ **`/404/` IS THE ONE ROUTE THAT BREAKS THIS DOCUMENT'S OWN URL RULES, AND IT
|
||||
HAS TO.** The rules above are lower-case, hyphenated, trailing slash, no file
|
||||
extensions. Astro emits `src/pages/404.astro` as **`dist/404.html`** — a file at
|
||||
the root, outside `build.format: 'directory'` — because that is the object name a
|
||||
CDN custom error response can point at. `SEO.astro` still sees the path as
|
||||
`/404/`, which is why its `OG_CARDS` key is `/404/` while the URL a tool fetches
|
||||
is `/404.html`. Added 2026-09-01; `docs/04` had asked for the page since before
|
||||
build step 1 and it did not exist.
|
||||
|
||||
**So: 23 built pages plus one per published article.**
|
||||
|
||||
⚠️ **AND THIS SENTENCE USED TO END WITH A REASSURANCE THAT WAS DISPROVEN THE DAY
|
||||
THE 404 PAGE WAS ADDED.** It read: *"`npm run lighthouse` enumerates them from
|
||||
`dist/` rather than from this list, which is why this count being stale could never
|
||||
make the gate miss a page."* Both `scripts/lighthouse.mjs` and
|
||||
`scripts/og-proof.mjs` enumerated **`index.html` under `dist/`**, not every page —
|
||||
so both missed `/404/`, and `og:proof` reported it backwards, as an orphaned card
|
||||
rather than an unchecked page. The count being stale was not the failure mode; the
|
||||
**definition of "a page"** was. Both now take any `.html` at the root as well, and
|
||||
`check:claims` always did, which is why the new page's copy was inside the claim
|
||||
register from its first build.
|
||||
|
||||
**The rule that replaces the reassurance:** a route that does not live at
|
||||
`<dir>/index.html` is invisible to anything that looks for `index.html`. If a
|
||||
future page is emitted outside the directory convention, grep the three scripts
|
||||
for `index.html` before trusting any of them.
|
||||
|
||||
### URL rules
|
||||
|
||||
|
||||
+87
-17
@@ -495,9 +495,13 @@ Not a polish pass. A build requirement.
|
||||
⚠️ **It is not a claim about anything outside that grid, and "no document
|
||||
overflow" is not "no accessibility defect".** Three known cases sit outside it
|
||||
deliberately. The **fallback-metrics case** below needs the webfonts blocked and
|
||||
a seventh nav item. The **focus-obscuring case** below is an **open WCAG 2.2
|
||||
SC 2.4.11 (AA) failure** — `AGENTS.md` Q61 — and document overflow cannot see it
|
||||
at all, because nothing overflows: the header simply covers what has focus. And a
|
||||
a seventh nav item. The **focus-obscuring case** below was a **WCAG 2.2
|
||||
SC 2.4.11 (AA) failure**, now **fixed and closed** — `AGENTS.md` Q61 — and it is
|
||||
the sharpest illustration of the warning above: **document overflow could not see
|
||||
it at all**, because nothing overflowed. The header simply covered what had
|
||||
focus, on 290 of 1,455 stops, while every zero in the table above stayed a zero.
|
||||
A residual at `minimumFontSize=16` and `=20` remains, is pre-existing, and is
|
||||
likewise invisible to this grid. And a
|
||||
**footer nav label** still overruns its own column by 24 px at 640 px under
|
||||
minimum font size with 7.7 px of clearance, again with zero document overflow.
|
||||
None of the three is contradicted by the zeros above, and none of them is
|
||||
@@ -1034,9 +1038,10 @@ Not a polish pass. A build requirement.
|
||||
97 → 199.4 px inside a header at 0 → 228.6 px, `position: sticky`,
|
||||
`inset-block-start: 0px`, background `rgb(250, 247, 242)` — opaque cream.
|
||||
|
||||
**THE RECOMMENDED FIX IS MEASURED AND DELIBERATELY NOT IMPLEMENTED — Q61.** Two
|
||||
candidates exist, and the second is better on the exact ground that deferred the
|
||||
first.
|
||||
✅ **FIXED AND CLOSED — Q61 RULED *fix now*, 2026-09-01. Candidate B SHIPPED.**
|
||||
Two candidates existed, and the second was better on the exact ground that had
|
||||
deferred the first. The measurements below are the pre-fix state; the acceptance
|
||||
sweep that closed it is at the end of this block.
|
||||
|
||||
*Candidate A, round 1:* a third gate term `calc((15px - 1ch) * 100000)`,
|
||||
un-sticking the header under the setting while leaving `inset-block-start: 0px`
|
||||
@@ -1076,20 +1081,85 @@ Not a polish pass. A build requirement.
|
||||
font file changes nothing, and the fallback-metrics caveat that deferred
|
||||
candidate A does not apply to B at all.
|
||||
|
||||
**Why B is still not implemented, and none of these is a measurement gap.**
|
||||
`10lh - 83px` is two fitted constants — the difference from A is that a `max()`
|
||||
ramp degrades by pixels if they are off, where A's `* 100000` step flips
|
||||
**What was said against B before it shipped, and what the sweep did with each.**
|
||||
`10lh - 83px` is two fitted constants — the difference from A being that a
|
||||
`max()` ramp degrades by pixels if they are off, where A's `* 100000` step flips
|
||||
stickiness the wrong way. **The first declaration is load-bearing:** an engine
|
||||
without `lh` support drops the whole `max()` as invalid and `scroll-padding-top`
|
||||
falls back to `var(--space-4)` = 16 px, which is *worse* than today. It needs the
|
||||
full 1,408-cell sweep plus the fallback-metrics and seven-item cases. The focus
|
||||
probe behind all of this is **Chrome-only**. And **D19 caps the review at two
|
||||
rounds**, so a header change made now would ship without review — the precise
|
||||
failure mode the cap exists to prevent. **The findings are the premise and the
|
||||
cost; the fix is Pouya's ruling.**
|
||||
falls back to `var(--space-4)` = 16 px, which is *worse* than no fix — so it is
|
||||
written first, as a plain declaration, and must stay there. The focus probe is
|
||||
**Chrome-only**, which is still true and is a limit on the evidence rather than
|
||||
on the fix. The two remaining objections were discharged by measurement:
|
||||
|
||||
**`docs/06` carries this as an OPEN item again**, restored from the `[x]` this
|
||||
session had written, and the decision is `AGENTS.md` **Q61**.
|
||||
**THE ACCEPTANCE SWEEP — 777 cells over 37 settings, plus the focus walk.**
|
||||
|
||||
Per cell: navigate, kill transitions *before* any root-size change, set the
|
||||
root, `location.hash = 'main'`, then read how much of `#main` sits behind the
|
||||
header. The cell count is asserted, so a silently-truncated grid cannot pass.
|
||||
|
||||
| setting | before | after |
|
||||
|---|---|---|
|
||||
| root style 9..32 (504 cells) | 0 | 0 |
|
||||
| `defaultFontSize` 9 / 12 / 16 / 20 / 24 (105) | 0 | 0 |
|
||||
| `minimumFontSize=9`, `=12` (42) | 0 | 0 |
|
||||
| `minimumFontSize=16` (21) | 6 cells, max 52 px | **6 cells, max 52 px** |
|
||||
| `minimumFontSize=20` (21) | 12 cells, max 64 px | **6 cells, max 14 px** |
|
||||
| `minimumFontSize=24` (21) | 15 cells, max 75 px | **0** |
|
||||
| `minimumFontSize=32` (21) | 15 cells, max 174 px | **0** |
|
||||
| fonts blocked (21) | 0 | 0 |
|
||||
| fonts blocked + `minFont=32` (21) | 15 cells, max 174 px | **0** |
|
||||
| **total** | **63 of 777** | **12 of 777** |
|
||||
|
||||
**The focus walk, which is the criterion itself: 290 entirely-hidden stops of
|
||||
1,455 → 0.** Same grid (6 pages × 5 widths ≥ 1056 px, 30 cells, 344
|
||||
header-internal stops skipped), hit-tested with `elementFromPoint`. **The
|
||||
pre-fix tree was rebuilt in a git worktree and put through the identical probe:
|
||||
it still reports 290** — footer 254, `#main` 36, geometry candidates 333 against
|
||||
the fixed build's 43. That control is what makes the 0 a result rather than a
|
||||
hope: both modes reading 0 with identical step counts is the shape `CLAUDE.md`
|
||||
warns *ends* a check instead of starting one.
|
||||
|
||||
**Default settings unchanged: 0 differences over 352 page-widths × 17 fields =
|
||||
5,984 comparisons**, full-page geometry fingerprint included, positive control
|
||||
detecting exactly 1 injected difference.
|
||||
|
||||
⚠️ **AND THE FIRST ATTEMPT AT THAT COMPARISON REPORTED 4,224 DIFFERENCES.** It
|
||||
keyed rows on `page`/`width`; this harness names them `url`/`w`, so every row
|
||||
collapsed onto one map entry and was compared against an arbitrary single row.
|
||||
It was caught only because the result was *uniformly bad*. **The fix is not the
|
||||
corrected field names — it is that the comparison now asserts the key fields
|
||||
exist and are unique before comparing anything.**
|
||||
|
||||
⚠️ **ONE COLUMN OF THE SWEEP WAS MEASURING NOTHING, AND TWO METRICS
|
||||
DISAGREEING IS WHAT SURFACED IT.** A summary column reported
|
||||
`min(scroll-padding-top − headerH)` of **−71.89 px** on root-style rows whose
|
||||
`covered` read **0**. Cause: `insetBlockStart` is **−900 px** in those rows —
|
||||
the header is `position: sticky` and **not pinned**, because that is exactly how
|
||||
the saturating `clamp()` gate un-sticks it. Comparing the offset to the header
|
||||
height is meaningless where the header is not pinned. `covered` — real geometry
|
||||
after a real hash navigation — is the valid metric. **A `position` of `sticky`
|
||||
is not the same fact as "the header is pinned", and any future probe that
|
||||
compares an offset against a header height has to read
|
||||
`inset-block-start` too.**
|
||||
|
||||
**THE 12 REMAINING CELLS ARE PRE-EXISTING, REDUCED, AND DELIBERATELY NOT
|
||||
FIXED.** `minimumFontSize=16` leaves 52 px of `#main` behind the header on
|
||||
`/about/` and `/contact/` at 1280, 1440 and 1920 px; `=20` leaves 14 px on the
|
||||
same six. Every one is unchanged or better than before the fix and none is new.
|
||||
**Why the ramp cannot see them, which is the part worth keeping:** the setting
|
||||
*floors* computed sizes, so at `minimumFontSize=16` the root is genuinely 16 and
|
||||
`1lh` on `<html>` is 18 px — identical to the default — while the header grows to
|
||||
**149.38 px** because the **sub-16 px** type (the 11 px tagline, the 14 px
|
||||
eyebrow and nav) is floored up. **The ramp reads a quantity the setting did not
|
||||
move.** Closing it needs a different measurement — `1lh` taken from an element
|
||||
that carries the small type, or a floor on the header — which is a header change
|
||||
with site-wide reach. Pouya's instruction on this step was *"if the sweep
|
||||
surfaces anything beyond this one declaration's blast radius, stop and report —
|
||||
do not widen"*, so it is reported: `docs/06` carries it as its own item. It is
|
||||
**not** an SC 2.4.11 failure, which is about a component *entirely* hidden.
|
||||
|
||||
**`docs/06`'s item is ticked and carries the figures.** The decision was
|
||||
`AGENTS.md` **Q61**, now closed.
|
||||
|
||||
⚠️ **AND ONE MORE LATENT CASE, AT THE DEFAULT TEXT SIZE — NO READER SETTING
|
||||
INVOLVED: FALLBACK FONT METRICS PLUS THE SEVENTH NAV ITEM.** With every `.woff2`
|
||||
|
||||
+92
-11
@@ -70,7 +70,9 @@ dynamic origin or client-side script, and the site has neither by design.
|
||||
A control that exists on paper and not in fact is worse than a stated gap — that
|
||||
is what `AGENTS.md` Q22 and the Lighthouse row both cost. So it is omitted and
|
||||
said out loud, and the load is carried by the honeypot, the `Origin` check, the
|
||||
API Gateway rate limit and server-side validation.
|
||||
**aggregate** API Gateway route throttle and server-side validation. (Aggregate,
|
||||
not per-IP — see §Validation. "Rate limit" was the wording here and let the reader
|
||||
supply the stronger meaning.)
|
||||
|
||||
**2. CORS is not what protects the form, and the `Origin` check is.** A form POST
|
||||
is a top-level navigation: it is exempt from CORS preflight, so an
|
||||
@@ -156,8 +158,26 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
||||
- Required fields present; email well-formed; lengths within bounds
|
||||
- Reject any field over its cap rather than truncating silently
|
||||
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
|
||||
- **Timestamp check** — reject submissions completed in under 3 seconds
|
||||
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
|
||||
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
|
||||
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
|
||||
while this line stayed an unqualified imperative** — the handler's header,
|
||||
§Three deviations above, and the definition of done below. §Three deviations
|
||||
has the reasoning: `/contact/` is a CDN-cached static file, so a build-time
|
||||
timestamp is the same value for every visitor and `now − served` is always
|
||||
large. **This is the unstruck-imperative shape `CLAUDE.md` names** — and it
|
||||
survived in the same list whose sibling bullet was struck correctly, which is
|
||||
the sweep failure exactly. Found by `adversarial-reviewer` round 2
|
||||
- ~~**Rate limit** by source IP at API Gateway: 5 requests / 5 minutes~~
|
||||
⚠️ **STRUCK 2026-09-01: API GATEWAY CANNOT RATE-LIMIT BY SOURCE IP, SO THIS
|
||||
ASKED FOR A CONTROL THAT CANNOT BE BUILT WHERE IT SAYS TO BUILD IT.** HTTP API
|
||||
throttling is **aggregate** — a rate and a burst, per route and per stage,
|
||||
across all callers. Per-IP limiting needs **AWS WAF** with a rate-based rule on
|
||||
the distribution, which is a paid service and therefore a decision rather than
|
||||
a step. What ships instead is the aggregate throttle
|
||||
(`docs/09-cutover-runbook.md` Part 6.3), and it must never be described as
|
||||
per-IP. This is deviation 1's own argument turned on this spec: *"a control that
|
||||
exists on paper and not in fact is worse than a stated gap"* — the throttle is
|
||||
real and bounds total volume; the per-IP claim was neither
|
||||
- No CAPTCHA. It is a third-party script on a page collecting legal information,
|
||||
and the two controls above stop the traffic that matters
|
||||
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
|
||||
@@ -171,18 +191,44 @@ handing over sensitive material, and where it comes to rest is a fair question
|
||||
for them to ask. Confirm the existing table's region and migrate if it is
|
||||
elsewhere — §7 has the table name and region.
|
||||
|
||||
⚠️ **THE KEY SCHEMA IS THE TABLE'S, NOT THIS SPEC'S — CORRECTED 2026-09-01, AND
|
||||
THE UNCORRECTED VERSION WOULD HAVE LOST EVERY SUBMISSION.** This table specified
|
||||
`pk: INTAKE#<uuid>` and `sk: <timestamp>`, and `handler.mjs` was written to it.
|
||||
The table `AGENTS.md` §7 names has a single partition key **`submissionId` (S)`
|
||||
and no sort key** `[verified 2026-09-01 — aws dynamodb describe-table]`. A
|
||||
`PutItem` missing the key attribute fails the whole write with
|
||||
`ValidationException`, the handler catches it and answers the failure page — so
|
||||
the form would have looked broken to every inquirer while the record went
|
||||
nowhere, from the moment `/api/*` was wired. **A DynamoDB key schema cannot be
|
||||
altered after creation**, so the handler was changed to the table rather than the
|
||||
reverse; the alternative, a new table matching the old shape, was declined
|
||||
because it would re-open the §7-verified TTL and PITR state on a fresh resource
|
||||
at cutover to buy a sort key nothing queries. Verify with `describe-table`, not
|
||||
against this row.
|
||||
|
||||
| Attribute | |
|
||||
|---|---|
|
||||
| `pk` | `INTAKE#<uuid>` |
|
||||
| `sk` | `<ISO-8601 timestamp>` |
|
||||
| `submissionId` | `<uuid>` — **the partition key.** Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console |
|
||||
| `submittedAt` | `<ISO-8601 timestamp>` — an ordinary attribute, not a sort key |
|
||||
| fields | as above |
|
||||
| `sourceIp`, `userAgent` | abuse investigation only |
|
||||
| `consentAt` | `<ISO-8601 timestamp>` — when the consent box was submitted |
|
||||
| `ttl` | epoch seconds — **the input to automatic deletion; see §Retention for why writing it is not the mechanism** |
|
||||
|
||||
**Encryption at rest** with a customer-managed KMS key. **Point-in-time recovery
|
||||
on.** Table access limited to the Lambda role and one named administrative
|
||||
principal.
|
||||
|
||||
⚠️ **TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT.**
|
||||
PITR is **on** `[verified 2026-09-01 — describe-continuous-backups,
|
||||
PointInTimeRecoveryStatus: ENABLED, 35-day window]`. Encryption at rest is on
|
||||
with the **AWS-owned key, not a customer-managed KMS key** `[verified
|
||||
2026-09-01 — describe-table returns no SSEDescription]`. That gap is
|
||||
deliberately not a cutover blocker: `/legal/privacy/` says "encrypted at rest",
|
||||
which is unconditionally true of every DynamoDB table, and it does not claim a
|
||||
customer-managed key — so nothing published depends on it. It stays on
|
||||
`docs/06`'s checklist as the improvement it is.
|
||||
|
||||
### Retention
|
||||
|
||||
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
|
||||
@@ -286,8 +332,32 @@ no visibility.
|
||||
— silently, months later.
|
||||
|
||||
Failure handling: SES failure must never lose the submission. Write to DynamoDB
|
||||
first, then send. A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||
DLQ depth ≥ 1.
|
||||
first, then send. ~~A dead-letter queue on the Lambda, and a CloudWatch alarm on
|
||||
DLQ depth ≥ 1.~~
|
||||
|
||||
⚠️ **THE DLQ IS STRUCK, 2026-09-01, AND IT WOULD HAVE BEEN A CONTROL THAT
|
||||
RECEIVED NOTHING.** Lambda's `DeadLetterConfig` is used **only for asynchronous
|
||||
invocations** (and event-source failures). API Gateway invokes this function
|
||||
**synchronously** and the error is returned to the caller, so a DLQ configured on
|
||||
`adr-intake-handler` would sit at depth 0 for ever and an alarm on it would be a
|
||||
green light that means nothing — the third instance of this project's most
|
||||
expensive shape, after `AGENTS.md` Q22 and the Lighthouse row.
|
||||
|
||||
What actually protects a submission is already built and is not a queue: the
|
||||
handler **writes to DynamoDB before sending mail**, so a mail failure cannot lose
|
||||
a record, and a write failure returns the visitor to `/contact/could-not-send/`
|
||||
rather than telling them an inquiry was received. What is missing is **detection**,
|
||||
and the replacement is two CloudWatch alarms rather than one:
|
||||
|
||||
- **Lambda `Errors` ≥ 1** on `adr-intake-handler` — this is what a DLQ alarm was
|
||||
reaching for and it fires on a synchronous failure, which a DLQ cannot see.
|
||||
- **API Gateway `5xx` ≥ 1** on the `POST /api/intake` route — it catches the one
|
||||
failure the Lambda cannot report, a permission or integration fault where the
|
||||
function is never entered at all (`docs/09-cutover-runbook.md` Part 6.1 is the
|
||||
step whose omission causes exactly that).
|
||||
|
||||
Both notify the `ses-alerts` topic, whose email subscription is **confirmed** as
|
||||
of `AGENTS.md` §7 — so unlike the DLQ alarm, these reach someone.
|
||||
|
||||
## Booking
|
||||
|
||||
@@ -371,14 +441,25 @@ Plausible or Fathom, cookieless, no consent banner.
|
||||
|
||||
- [x] **Server-side validation independent of the client** — `backend/intake/fields.mjs`, cross-checked by `npm run check:intake`
|
||||
- [x] **Honeypot live.** ⚠️ **The timing check is NOT implemented** — see deviation 1 above; it is unimplementable on a CDN-cached static page and would be a control that does nothing
|
||||
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
|
||||
- [ ] **Throttle configured** — an **aggregate** API Gateway route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. `docs/09-cutover-runbook.md` Part 6.3
|
||||
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
|
||||
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months, and it asserts the **mechanism**, not only the period. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — and **writing the attribute is not the mechanism**: TTL must also be enabled on the table, which is a table setting the code cannot see. **`AGENTS.md` §7 holds that status and its stamp; this line does not restate it** — it restated it once, went stale within the day, and had to be pulled back (§12 R19). **The test record is what closes this item, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour. Tracked as §9 Q60
|
||||
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
|
||||
- [x] **PITR enabled** — `ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
|
||||
- [ ] KMS customer-managed key. **Not on the table: encryption at rest is with the AWS-owned key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`. **Not claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
|
||||
- [ ] **Table access matches what `/legal/privacy/` says about it.** ⚠️ **IT DOES NOT, AS AT 2026-09-01.** The page says *"nobody else has access to the table… no external administrator"*; the account's `admins` group carries `AdministratorAccess` and has **two** members, and `simulate-principal-policy` returns **allowed** for `dynamodb:GetItem`/`Query`/`Scan` for both. Evidence and commands: `docs/reference/intake-table-access-verification.md`. §9 **Q62**, and it blocks that page going public
|
||||
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
||||
- [ ] DLQ and CloudWatch alarm configured. The handler writes to DynamoDB **before** sending mail, so a replay cannot lose a submission
|
||||
- [ ] **CloudWatch alarms on Lambda `Errors` and API Gateway `5xx`** — replacing the DLQ item, which is struck: a DLQ on a **synchronously** invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB **before** sending mail, so the protection this item was pointing at is in the code rather than in a queue
|
||||
- [x] **Form usable by keyboard only.** Errors are announced by the browser's own validation, which with no script is the only thing that can announce them inline — `role="alert"` needs a live region and something to write into it
|
||||
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
|
||||
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
|
||||
> **The three remaining items below are commands, and the commands are in
|
||||
> `docs/09-cutover-runbook.md`** — Parts 5, 6 and 3 respectively, each with its
|
||||
> verification and the output to expect. Two things that spec found by reading the
|
||||
> running system rather than the specs, and both would have lost every
|
||||
> submission: the API route needs its **own** Lambda invoke permission, because
|
||||
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the
|
||||
> handler's item shape had to change, because the table's partition key is
|
||||
> `submissionId` and a key schema cannot be altered after creation (§Storage).
|
||||
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it. **And two other distribution changes are prerequisites of the site working at all**, neither of which is intake: a viewer-request function for `trailingSlash: 'always'`, without which 22 of 23 pages return S3's `AccessDenied`, and the 404 mapping `docs/04` requires
|
||||
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
|
||||
|
||||
+253
-48
@@ -39,6 +39,30 @@ One thing blocks the workflow, and it is not a fact to look up:
|
||||
- **Actions are not enabled and no runner is registered** (Q23). The Gitea
|
||||
instance is jointly administered, so both need its second administrator.
|
||||
|
||||
> ✅ **AND THAT IS NOW A DECISION RATHER THAN A BLOCKER — Pouya's ruling,
|
||||
> 2026-09-01: THE FIRST PRODUCTION DEPLOY GOES OUT VIA `scripts/deploy-local.sh`,
|
||||
> AND THE GITEA RUNNER IS DEFERRED UNTIL AFTER LAUNCH.**
|
||||
>
|
||||
> His reason, and it is the reason the deferral is safe: enabling Actions and
|
||||
> registering `act_runner` depends on the instance's **second administrator**, so
|
||||
> the date it happens is not his to set — and **nothing about the deploy depends
|
||||
> on it.** The script and the workflow are one artefact in two places; they run
|
||||
> the same guard, the same three passes with the same cache headers, the same
|
||||
> invalidation and the same intake route check. The pipeline changes only **how a
|
||||
> deploy is triggered**. So a launch that waits for a runner is a launch waiting
|
||||
> on someone else's calendar for no gain.
|
||||
>
|
||||
> **What the deferral actually costs, stated rather than waved past:** every
|
||||
> deploy until then is a person at a keyboard with a long-lived access key in
|
||||
> their environment, and nothing gates a merge to `main` (there is no
|
||||
> `pull_request` trigger). Both were already true; the ruling does not add a
|
||||
> risk, it declines to remove one yet. **`AGENTS.md` R17's rotation date does not
|
||||
> move** — the key is live either way.
|
||||
>
|
||||
> It is **not** a cutover-checklist item and must not become one: a checklist
|
||||
> fires once, at cutover, and this is deliberately scheduled for after it. The
|
||||
> `docs/09-cutover-runbook.md` Part 8 sequence is the deploy path that ships.
|
||||
|
||||
> ✅ **`adr-sml-deploy` EXISTS — created 2026-08-26, Q22 closed 2026-08-28.**
|
||||
> **DO NOT CREATE IT.** This bullet said *"`adr-sml-deploy` does not exist —
|
||||
> `aws iam get-user` returns `NoSuchEntity`… Create it from Create the user
|
||||
@@ -364,6 +388,27 @@ Then invalidate `/*`.
|
||||
|
||||
## Cutover checklist — D11 is a single shot, so run all of it
|
||||
|
||||
> **The commands are in `docs/09-cutover-runbook.md`.** This list is what must be
|
||||
> true; that file is how, in order, with each command's verification and the
|
||||
> output to expect. It also records the order dependency this list cannot show:
|
||||
> **the distribution work goes first and the site sync goes last**, because
|
||||
> reversing them puts 22 of 23 pages behind a 403 for as long as a CloudFront
|
||||
> deployment takes.
|
||||
|
||||
> 🛑 **THREE THINGS BLOCK THIS ENTIRE LIST AS AT 2026-09-01, AND TWO OF THEM WERE
|
||||
> FOUND BY READING THE RUNNING SYSTEM RATHER THAN THE SPECS.**
|
||||
>
|
||||
> 1. **`/legal/privacy/` states something false about who can read the intake
|
||||
> table** — §9 **Q62**, evidence in
|
||||
> `docs/reference/intake-table-access-verification.md`. A privacy policy is
|
||||
> the wrong page to be wrong on, and this one is wrong about third parties'
|
||||
> dispute information.
|
||||
> 2. **Q60 is still open** — TTL is `ENABLED` and no record has been watched to
|
||||
> disappear, and the same page asserts the mechanism. `docs/09` Part 10 is the
|
||||
> test and its answer arrives days after it starts, so **start it early**.
|
||||
> 3. **`claims-auditor`'s D20 pass returned six copy findings on 2026-09-01;
|
||||
> five are corrected and one is a ruling** — the `/med-arb/` gloss, below.
|
||||
|
||||
**Cutover prep — deferred maintenance, done BEFORE the checklist below**
|
||||
|
||||
This group exists because deferring a thing and forgetting it look identical
|
||||
@@ -406,6 +451,33 @@ the decision is re-readable rather than re-litigated.
|
||||
defects worth catching late are the ones that only exist once the pages sit
|
||||
next to each other. Give it the whole built site and the reading order a
|
||||
visitor takes.
|
||||
|
||||
✅ **IT HAS RUN ONCE, 2026-09-01, OVER ALL 23 PAGES — AND IT RETURNED
|
||||
FAIL WITH EIGHT FINDINGS, WHICH IS THE ARGUMENT FOR D20 RATHER THAN
|
||||
AGAINST IT.** Five are corrected (`/practice/energy/`'s lede asserted a
|
||||
change to how *generation* connects, which the extract's own quotation
|
||||
denies; `/practice/insurance/` stated an absolute adjudicator
|
||||
disqualification where LAT Rule 14.3 says *"except with the consent of the
|
||||
parties"*, and glossed one permissive sentence as *"the Tribunal's own
|
||||
materials point parties toward mediation"*; `/practice/technology/` asserted
|
||||
*"no federal or Ontario statute"* — a universal over the **four**
|
||||
instruments its extract checked — and its own note then disclaimed that
|
||||
conclusion as the Commissioner's words; `/med-arb/` said the ADRIC Med-Arb
|
||||
Rules were presented to the membership where the source says a **discussion
|
||||
draft** was). Three remain and each has its own item below or above:
|
||||
the `/med-arb/` gloss (a ruling), Q60, and Q62.
|
||||
|
||||
**Two things about that result are worth carrying forward.** The pass found
|
||||
no defect in any claim about Pouya, his credentials or his designations —
|
||||
every one traced. What it found was **five over-reaches in glosses on
|
||||
sourced legal material**, which is the failure mode a per-step claims run on
|
||||
a single page is least able to see and the one `check:claims` cannot grep
|
||||
for at all. **And it re-derived a stale instruction inside its own brief:**
|
||||
`.claude/agents/claims-auditor.md` still tells it Q.Arb is *"commenced
|
||||
August 2026"*, which §4 struck on 2026-08-29 — applied literally it would
|
||||
have flagged correct copy and demanded the struck form. It read §4 instead.
|
||||
That is the fifth stale claim found in that file and it is not the agent's
|
||||
to fix
|
||||
- [ ] **Pouya has read every page against `AGENTS.md` §4.** The human pass. It is
|
||||
the other half of D20 and it is not delegable — his reading is what the
|
||||
per-step audit was traded for.
|
||||
@@ -467,7 +539,27 @@ the decision is re-readable rather than re-litigated.
|
||||
Chrome — it is not in CI and cannot be (see above). Read the exit status.
|
||||
Every category ≥ 95 on every page; the accessibility figure is measured
|
||||
with `prefers-reduced-motion` forced and that qualification is reported
|
||||
with it (`AGENTS.md` §7)
|
||||
with it (`AGENTS.md` §7).
|
||||
✅ **Run 2026-09-01 on the 23-page build: exit 0, worst of 23 is
|
||||
99 / 100 / 100 / 100.** Re-run at cutover — this is dated, not discharged.
|
||||
⚠️ **The script was enumerating 22 of 23 and its header claimed it
|
||||
enumerated the site.** It collected `index.html` only, and `404.astro` is
|
||||
emitted as `dist/404.html`, outside `build.format: 'directory'`. So the
|
||||
budget would have been asserted over every page except the new one, and
|
||||
silently. `og:proof` had the identical blind spot and reported it
|
||||
**backwards** — as the 404 card being orphaned ("generated, but no built
|
||||
page references it") rather than the page being unmeasured. Both fixed; the
|
||||
two use different path forms for the same file on purpose, and each says why
|
||||
beside the code. **`check:claims` was never affected** — it takes any
|
||||
`.html`, so the new page's copy was inside the claim register from its first
|
||||
build. One instrument of three saw the page, which is the argument for
|
||||
having three.
|
||||
⚠️ **One reported number sits at its budget: LCP on `/` is 2.03 s against
|
||||
`docs/04`'s < 2.0 s.** The script reports LCP and CLS without asserting
|
||||
them, deliberately — simulated throttling on a loopback server is not the
|
||||
Slow 4G field measurement `docs/04` budgets against — so this is not a
|
||||
failing gate and must not be reported as a passing one either. CLS is
|
||||
0.000 on all 23
|
||||
- [ ] Every page renders fully with JavaScript disabled
|
||||
- [ ] `curl` of each URL returns real content, not a shell
|
||||
- [ ] All internal links resolve; no orphan pages
|
||||
@@ -480,12 +572,44 @@ the decision is re-readable rather than re-litigated.
|
||||
is invisible from inside the repo, and because card text cannot be reached
|
||||
by `check:claims`. Run `npm run og:proof -- --sheet` and **look at the
|
||||
contact sheet** as well — the check compares strings, not rendering
|
||||
- [ ] 404 returns a 404 status
|
||||
- [ ] **404 returns a 404 status AND serves the 404 page. Both halves, and until
|
||||
2026-09-01 neither existed.** There was **no 404 page in the site at all**
|
||||
— no `src/pages/404.astro`, 22 pages, and `docs/04` had asked for one
|
||||
("Real, styled, with search-intent links out") since before build step 1 —
|
||||
**and** no custom error response on the distribution, so a missing URL
|
||||
returned S3's `AccessDenied` XML. Measured, not inferred: `/about/` and
|
||||
`/definitely-not-a-page/` both answered **403, `application/xml`, 111
|
||||
bytes** on the live distribution. The page now exists (`/404/`, `noindex`,
|
||||
out of the sitemap, with its own OG card because the registry has one rule
|
||||
and no exceptions) and `docs/09` Parts 1–3 configure the distribution. **Tick
|
||||
this after `docs/09` Part 8.4**, which checks the status *and* greps the body
|
||||
for the page's own `<h1>` — a 404 status serving an empty body passes the
|
||||
status half and fails the point
|
||||
- [ ] Security headers present (`securityheaders.com` A or better)
|
||||
- [ ] **SES identities verified for sending** — confirmed 2026-08-26, re-check at cutover: `aws sesv2 get-email-identity --email-identity smlcompany.ca` and confirm `VerifiedForSendingStatus: true`
|
||||
- [ ] **SES bounce/complaint alarms actually notify someone** — `AGENTS.md` §7 records the `ses-alerts` email subscription as **pending confirmation**, and an unconfirmed SNS subscription drops every message. Confirm it, then `aws sns list-subscriptions-by-topic` and check the ARN is not `PendingConfirmation`. *(SES production access itself is granted — Q19 closed.)*
|
||||
- [x] **SES identities verified for sending** — `VerifiedForSendingStatus: true`, `DkimAttributes.Status: SUCCESS`, signing enabled, and no custom MAIL FROM (so DMARC rests on DKIM alignment, which is what §7 records) `[re-verified 2026-09-01 — sesv2 get-email-identity]`
|
||||
- [x] ✅ **SES bounce/complaint alarms DO notify someone — R9 DISCHARGED, 2026-09-01.** `aws sns list-subscriptions-by-topic` on `ses-alerts` returns the email subscription to `info@smlcompany.ca` with a **real subscription ARN**, not `PendingConfirmation`. §7 recorded it as pending, and §12 R9 said *"this is the first thing to check if `/contact/` ships"* — it had been confirmed at some point before this reading and the record had not moved, which is the same staleness in the safe direction. *(SES production access itself is granted — Q19 closed.)*
|
||||
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
|
||||
IT DOES — build step 8 shipped the page and not the pipe.**
|
||||
⚠️ **THE COMMANDS ARE `docs/09-cutover-runbook.md` PARTS 5 AND 6, AND
|
||||
WRITING THEM FOUND TWO MORE THINGS, EACH OF WHICH WOULD HAVE LOST EVERY
|
||||
SUBMISSION.** Both were found by reading the running system rather than the
|
||||
specs, which is the only way either could have been found.
|
||||
**(a) The handler wrote the wrong key.** It built its item with
|
||||
`pk: INTAKE#<uuid>` / `sk: <timestamp>` from `docs/05`'s table, and the
|
||||
live table's key schema is **`submissionId` (S), no sort key** — a
|
||||
`PutItem` missing the key attribute fails the whole write with
|
||||
`ValidationException`, which the handler catches and answers with the
|
||||
failure page. **The form would have looked broken to every inquirer while
|
||||
the record went nowhere**, from the moment `/api/*` was wired. A DynamoDB
|
||||
key schema cannot be altered after creation, so the handler moved to the
|
||||
table; `docs/05` §Storage records the alternative that was declined.
|
||||
**(b) The Lambda's invoke permission is `SourceArn`-scoped to the OLD
|
||||
route.** The one statement on the function conditions
|
||||
`apigateway.amazonaws.com` on `…/4tl0m5igkj/*/*/submissions`. Add a route at
|
||||
`/api/intake` without adding a permission and API Gateway is refused: the
|
||||
caller gets a **500**, and **the Lambda logs nothing at all** because it is
|
||||
never entered — the hardest possible thing to diagnose from CloudWatch,
|
||||
which is where anyone would look first.
|
||||
1. **A CloudFront behaviour routing `/api/*`** to the HTTP API origin
|
||||
`AGENTS.md` §7 records. The form posts to the same-origin path
|
||||
`/api/intake` — deliberately, so the CSP can be `form-action 'self'`,
|
||||
@@ -512,21 +636,36 @@ the decision is re-readable rather than re-litigated.
|
||||
not itself creating a conflict check**, which a hand-typed copy in the
|
||||
handler had dropped. `RESPONSE_TIME` must read identically on `/contact/`,
|
||||
in the confirmation email, and in the bio.
|
||||
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
|
||||
(`docs/05`). Not expressible in handler code, and it is carrying load
|
||||
the unimplementable 3-second timing check was specified to carry — see
|
||||
`docs/05` §Three deviations.
|
||||
3. **An AGGREGATE API Gateway route throttle** — `docs/09` Part 6.3. Not
|
||||
expressible in handler code, and it carries load the unimplementable
|
||||
3-second timing check was specified to carry (`docs/05` §Three
|
||||
deviations). ⚠️ **THIS ITEM SAID "5 requests / 5 minutes per source IP"
|
||||
AND THAT IS NOT BUILDABLE AT API GATEWAY** — throttling there is per
|
||||
route and per stage across all callers. It contradicted this same file's
|
||||
own correction further down the list, and it is a to-do list an operator
|
||||
works through, so it would have cost an hour in the API Gateway console
|
||||
looking for a control that is not there. Per-IP needs AWS WAF, which is
|
||||
a cost decision and has its own item.
|
||||
|
||||
✅ **BOTH DEPLOY PATHS NOW WARN IF ITEM 1 IS MISSING**, so this does not
|
||||
rest on someone reading this list. `scripts/deploy-local.sh` and
|
||||
`.gitea/workflows/deploy.yml` each end with an **Intake route check**: a
|
||||
`POST` to `/api/intake` with no `Origin`. **404 means not routed. 403 means
|
||||
routed and refused**, which is the handler's `Origin` check doing its job
|
||||
and is therefore a pass — and is why the probe is safe against production:
|
||||
it is rejected before any DynamoDB write or any email. It warns rather than
|
||||
failing, because by that point the site is already deployed and failing the
|
||||
job would not un-deploy it. This replaced an `INTAKE_ENDPOINT` guard that
|
||||
blocked deploys on a variable nothing read.
|
||||
`.gitea/workflows/deploy.yml` each end with an **Intake route check**.
|
||||
⚠️ **AND THIS PARAGRAPH DESCRIBED A PROBE THE SCRIPTS NO LONGER RUN.** It
|
||||
said the probe was *"a `POST` to `/api/intake` with **no `Origin`**"* where
|
||||
*"404 means not routed, 403 means routed and refused… therefore a pass"*.
|
||||
Both scripts send **`Origin: https://adr.smlcompany.ca`** and assert **303
|
||||
to `/contact/could-not-send/`**; 403 is a **warning**, not a pass, because
|
||||
it cannot be told apart from CloudFront rejecting a disallowed method. A
|
||||
stale description of a live control, in the spec that governs it — found by
|
||||
`adversarial-reviewer`, 2026-09-01.
|
||||
**What the probe actually is:** the handler validates, finds an empty
|
||||
submission, and redirects — which happens **before any DynamoDB write and
|
||||
before any email**, and is what makes it safe against production. It warns
|
||||
rather than fails, because by that point the site is deployed and failing
|
||||
the job would not un-deploy it. **404 is ambiguous between three causes**
|
||||
and `docs/09` Part 7.1 lists them with the one command that separates them.
|
||||
This replaced an `INTAKE_ENDPOINT` guard that blocked deploys on a variable
|
||||
nothing read.
|
||||
- [ ] **DYNAMODB TTL ENABLED ON THE TABLE, AND VERIFIED BY A TEST RECORD. THIS
|
||||
ONE BACKS A PUBLISHED PROMISE AND MUST NOT BE TICKED FROM THE HANDLER
|
||||
CODE.** `/legal/privacy/` states that a record is *"deleted automatically
|
||||
@@ -542,6 +681,21 @@ the decision is re-readable rather than re-litigated.
|
||||
confirm it actually disappears. Both halves, or the item stays open. §9
|
||||
Q60 and §12 R19 track it; `src/pages/legal/privacy.astro` carries the
|
||||
matching `TODO(pouya)`.
|
||||
✅ **THE SETTING IS RE-CONFIRMED AND THE PROCEDURE NOW EXISTS:**
|
||||
`TimeToLiveStatus: ENABLED` on attribute `ttl` `[re-verified 2026-09-01 —
|
||||
describe-time-to-live]`, and `docs/09-cutover-runbook.md` **Part 10** is the
|
||||
behaviour test — write a row with a near-future `ttl`, record its id to a
|
||||
file, and check on a **different day**.
|
||||
⚠️ **START IT EARLY. DynamoDB's TTL sweeper is not prompt and AWS does not
|
||||
promise that it is** — deletion typically lands within a couple of days of
|
||||
the timestamp passing, so the runbook sets the earliest useful check at
|
||||
**48 hours** and refuses to call a failure before **7 days**. A check run
|
||||
five minutes after the `ttl` that finds the row present has measured
|
||||
nothing. This is the one blocking item on this list whose answer cannot be
|
||||
hurried, so it should be started before anything else here, not last.
|
||||
⚠️ **And `get-item` on a missing key exits 0 with an empty body** — read the
|
||||
output, not the exit status; a script checking only the status reports
|
||||
present and absent identically.
|
||||
- [ ] **`npm run check:intake`** — the form's field table and the Lambda's agree
|
||||
on names, requiredness, caps and option sets. They are two independent
|
||||
tables on purpose (`docs/05`); this is what stops them drifting
|
||||
@@ -613,35 +767,86 @@ the decision is re-readable rather than re-litigated.
|
||||
control proving `break-word` would not have worked, and the candidates
|
||||
declined as broader than the cause
|
||||
|
||||
- [ ] 🛑 **THE MINIMUM-FONT-SIZE STICKY RESIDUAL — REOPENED THE SAME DAY IT WAS
|
||||
TICKED, AND IT IS A WCAG 2.2 SC 2.4.11 (AA) FAILURE RATHER THAN THE
|
||||
SKIP-LINK INCONVENIENCE IT WAS ACCEPTED AS. NEEDS A FRESH RULING.**
|
||||
It was ruled accepted on 2026-09-01 on the basis *"you proved no CSS
|
||||
mechanism can see minimum-font-size; the only fix is JS and zero-JS is a
|
||||
founding decision"*, and this checklist item was ticked accordingly. Both
|
||||
supporting facts are wrong, and `adversarial-reviewer` found it the same day.
|
||||
**(1) The premise.** The proof covered `rem`/`em` and queries written in them.
|
||||
The font-metric units **`ch`, `ex`, `cap`, `lh`, `rlh`** read the *used* font
|
||||
size and double under the setting — in property values, in `@media` **and** in
|
||||
`@container`. A pure-CSS detector exists, so "the only fix is JS" does not
|
||||
hold. **(2) The cost.** Not a short skip-link landing but **ordinary keyboard
|
||||
focus landing entirely behind an opaque sticky header**: a Shift+Tab walk over
|
||||
6 pages × 5 widths ≥ 1056 px gives **290 entirely-hidden focus stops of 1,455**
|
||||
under the setting — **254 in the footer and 36 inside `#main`** — against **0**
|
||||
at the default size. Hit-tested with `elementFromPoint`, not inferred from
|
||||
geometry: geometry alone said 43 at the default size and all 43 were the skip
|
||||
link, which is stacked *above* the header on purpose. **(3) And the header fix
|
||||
created it:** a build of `fce89d4~1` measures **0** under the same setting,
|
||||
because `flex-wrap: nowrap` kept the masthead one row and short. So the trade
|
||||
was **830 px of horizontal overflow (SC 1.4.10) for 290 obscured focus stops
|
||||
(SC 2.4.11)** — both Level AA, and the asymmetry that was the whole argument
|
||||
does not exist. **A candidate fix is measured but deliberately not
|
||||
implemented:** a third gate term in the shipping idiom, `calc((15px - 1ch) *
|
||||
100000)`, un-sticks the header under the setting and leaves
|
||||
`inset-block-start: 0px` at the default — but 15 px is fitted to Geist's `ch`
|
||||
and needs checking under fallback metrics and Chrome's other font presets.
|
||||
**This item is ticked only by a ruling from Pouya, not by a re-measurement.**
|
||||
The decision is `AGENTS.md` **Q61**; `docs/02` §Reflow carries the measurements
|
||||
- [x] ✅ **THE MINIMUM-FONT-SIZE SC 2.4.11 (AA) FAILURE IS FIXED, 2026-09-01 —
|
||||
Q61 RULED *fix now* AND CLOSED.** Two declarations on `html` inside the
|
||||
existing `@media (min-width: 66rem)` block: the plain
|
||||
`calc(var(--header-h) + var(--space-4))` first, then
|
||||
`max(calc(var(--header-h) + var(--space-4)), calc(10lh - 83px))`.
|
||||
**`1lh` and not `rem`, because the font-metric units read the USED font
|
||||
size** — that is the mechanism the earlier ruling's premise denied existed.
|
||||
**Measured, with the pre-fix build rebuilt in a worktree and put through the
|
||||
identical probe:**
|
||||
the Shift+Tab walk over 6 pages × 5 widths ≥ 1056 px goes from **290
|
||||
entirely-hidden focus stops of 1,455 to 0** — same grid, same 1,455 steps,
|
||||
same 344 header-internal stops skipped — while the pre-fix control still
|
||||
reports **290** (footer 254, `#main` 36), so the probe is proven able to see
|
||||
the failure it now reports gone.
|
||||
**The default-settings rendering is unchanged: 0 differences across 352
|
||||
page-widths × 17 fields = 5,984 comparisons**, full-page geometry
|
||||
fingerprint included, with a positive control detecting exactly 1 injected
|
||||
difference. `scroll-padding-top` computes to **97 px** at the default and
|
||||
**287 px** under the setting against a 270.56 px header; `1lh` on `<html>`
|
||||
is **18 px / 37 px with every `.woff2` blocked**, identical, because
|
||||
`<html>` keeps the UA family — the property that the `ch` candidate could
|
||||
not claim.
|
||||
**A wider grid than the ruling asked for, and it found a residual: 777
|
||||
cells over 37 settings went from 63 failing to 12, and nothing got worse in
|
||||
any cell.** The remainder is the next item, it is **pre-existing**, and it
|
||||
was deliberately not fixed — Pouya's instruction was to stop and report
|
||||
anything beyond the one declaration's blast radius
|
||||
- [ ] ⚠️ **MINIMUM FONT SIZE AT 16 AND 20 STILL LEAVES `#main` BEHIND THE HEADER —
|
||||
PRE-EXISTING, REDUCED BY Q61'S FIX, AND OUTSIDE ITS MECHANISM.** At
|
||||
`minimumFontSize=16`, **52 px** of `#main` sits behind the sticky header
|
||||
after "Skip to content" on `/about/` and `/contact/` at 1280, 1440 and
|
||||
1920 px — 6 cells; at `minimumFontSize=20`, **14 px** on the same 6. Before
|
||||
Q61's fix the same grid gave 6 cells at 52 px, **12 at 64 px**, 15 at 75 px
|
||||
and 15 at 174 px, so every remaining cell is unchanged or better and none
|
||||
is new.
|
||||
**Why the `lh` ramp cannot see it, and this is the interesting part:** the
|
||||
setting *floors* computed sizes, so at `minimumFontSize=16` the root is
|
||||
genuinely 16 and `1lh` on `<html>` is 18 px — identical to the default —
|
||||
while the header grows to **149.38 px** because the sub-16 px type (the
|
||||
11 px tagline, the 14 px eyebrow and nav) is floored up. The ramp reads a
|
||||
quantity the setting did not move. **A different measurement would be
|
||||
needed** — `1lh` taken from an element that actually carries the small type,
|
||||
or a floor on the header — and that is a header change with site-wide reach,
|
||||
which is why it is filed rather than taken inside a capped review loop.
|
||||
Not a conformance failure of the same shape as Q61: a partial obscuring is
|
||||
not SC 2.4.11, which is about a component **entirely** hidden. `docs/02`
|
||||
§Reflow carries the 777-cell grid
|
||||
- [ ] ⚠️ **THE `/med-arb/` GLOSS NEEDS A RULING — `claims-auditor`, D20 pass,
|
||||
finding 1.** §Why this practice says *"Med-arb is not a third service
|
||||
bolted onto mediation and arbitration; it is the two of them run under one
|
||||
agreement"*, and one section above the page publishes ADRIC's own words:
|
||||
med-arb is *"not merely the merging of separate mediation and arbitration
|
||||
processes, but a unique process designed to meet the needs of particular
|
||||
disputants"*. **The gloss asserts what its own cited authority denies in
|
||||
terms, on the same page.** Remove one of the two — either the *"not merely
|
||||
the merging"* clause from the quotation, or the *"it is the two of them run
|
||||
under one agreement"* sentence. ⚠️ **Do not resolve it by adding a
|
||||
competence claim:** ADRIC answers its own competence sentence with the
|
||||
Chartered Med-Arb designation and the Med-Arb Foundational Course, neither
|
||||
of which is held, and C.Med-Arb is struck from this site entirely (§4). The
|
||||
2026-08-30 fix took the competence proposition out of the **heading** and
|
||||
left it in the **body**, which is why this is a second pass at the same
|
||||
paragraph
|
||||
- [ ] ⚠️ **FOUR PRE-EXISTING DYNAMODB ITEMS HAVE NO `ttl` AND WILL NEVER EXPIRE.**
|
||||
The table held 4 items before any of this work `[verified 2026-09-01 —
|
||||
describe-table ItemCount]`, written by the hand-built handler, whose code
|
||||
is in `docs/reference/AWS-Hosting-Guide.md` Part 8.3 and **writes no `ttl`
|
||||
attribute at all**. So they are retained indefinitely while
|
||||
`/legal/privacy/` says a record is deleted automatically after 24 months.
|
||||
**Pouya's call and not scriptable**, because it turns on something no agent
|
||||
can determine: whether they are the guide's own smoke test (`Test User`) or
|
||||
real inquiries that arrived through the old site. Read them, then delete the
|
||||
test rows or write a `ttl` onto the real ones. `docs/09` Part 7.3
|
||||
- [ ] ⚠️ **PER-IP RATE LIMITING NEEDS AWS WAF AND IS A COST DECISION — NOT A
|
||||
LAUNCH BLOCKER.** `docs/05` asked for "5 requests / 5 minutes per source
|
||||
IP at API Gateway"; **API Gateway throttling is aggregate, not per-IP**, so
|
||||
that spec line is struck and an aggregate route throttle ships instead
|
||||
(`docs/09` Part 6.3). A rate-based WAF rule on the distribution is what
|
||||
would do per-IP. Decide it on price, not on the spec's old wording — and do
|
||||
not let anything describe the throttle that ships as per-IP
|
||||
|
||||
- [ ] ⚠️ **A FOOTER NAV LABEL OVERRUNS ITS COLUMN BY 24 px AT 640 px UNDER
|
||||
MINIMUM FONT SIZE, WITH 7.7 px OF CLEARANCE TO THE NEXT COLUMN.** No document
|
||||
@@ -704,10 +909,10 @@ the decision is re-readable rather than re-litigated.
|
||||
checks passed while both defects shipped. **Owner: `AGENTS.md` R20**
|
||||
|
||||
**Infrastructure**
|
||||
- [ ] S3 versioning enabled
|
||||
- [ ] Bucket not publicly readable; OAC in force
|
||||
- [x] **S3 versioning enabled** — `Status: Enabled` `[re-verified 2026-09-01 — get-bucket-versioning]`. This is what makes pass 3's `--delete` recoverable at cutover
|
||||
- [x] **Bucket not publicly readable; OAC in force** — all four public-access blocks `true`, and a direct `GET` on the S3 hostname returns **403** `[re-verified 2026-09-01 — get-public-access-block, curl]`. ⚠️ **Re-check this after `docs/09` Part 1**, which adds an `s3:ListBucket` statement for the CloudFront service principal so that missing keys return 404 rather than 403. Part 1 asserts the two facts that actually make the grant safe: **no viewer path resolves to the bucket root** (the default root object, then the router, always turn `/` into a key), and the default behaviour **forwards no query strings** to the origin with **no origin request policy** overriding that. ⚠️ **This line used to prescribe `curl "$SITE/?list-type=2"` as "the check that matters"** — and Part 1 was rewritten in the same change set to explain that that check **cannot fail** and is therefore evidence of nothing. Two documents, one fix, and the discarded check survived in the one an operator ticks. Found by `adversarial-reviewer` round 2
|
||||
- [ ] ACM certificate valid; Namecheap validation CNAME still present
|
||||
- [ ] CloudWatch alarms: Lambda errors, DLQ depth, 5xx rate
|
||||
- [ ] **CloudWatch alarms: Lambda `Errors`, API Gateway `5xx`.** ⚠️ **"DLQ depth" was on this line and is struck: a dead-letter queue on this function would never receive anything.** `DeadLetterConfig` applies to **asynchronous** invocations, and API Gateway invokes synchronously — so the alarm would have sat permanently green while telling everyone it was watching, which is this project's most expensive recurring shape (`AGENTS.md` Q22, the Lighthouse row, and now this). `docs/05` §Notification carries the replacement and why there are two alarms rather than one: Lambda `Errors` catches a synchronous failure, and API Gateway `5xx` catches the case where the function is never entered at all
|
||||
- [ ] Billing budget/alarm still active — `aws budgets describe-budgets --account-id "$(aws sts get-caller-identity --query Account --output text)"`. `docs/reference/AWS-Hosting-Guide.md` set up an **AWS Budget**, which `cloudwatch describe-alarms` will never return. Whether one was actually created is not recorded anywhere: confirm, do not assume
|
||||
|
||||
**Post-cutover, same day**
|
||||
|
||||
+3
-1
@@ -363,7 +363,9 @@ one the review can settle without new market data.
|
||||
| 6 h | $3,500 | $4,000 | **$500** |
|
||||
| 7 h | $4,000 | $4,500 | **$500** |
|
||||
|
||||
*(Session-cap trigger; the trigger itself is `AGENTS.md` §9 **Q59**, open.)* The
|
||||
*(Session-cap trigger; the trigger itself is `AGENTS.md` **Q59**, ruled and
|
||||
closed 2026-08-31 — this line said "open" for a day after line 144 of this same
|
||||
file recorded the ruling.)* The
|
||||
cause is the relationship between two of D14's own numbers rather than anything
|
||||
about the trigger: **the half-to-full step is $2,000 and three hours of overtime
|
||||
is $1,500.** Any trigger leaves a gap; the envelope trigger closes it only from
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
# 09 — Cutover runbook: the exact command sequence
|
||||
|
||||
Authority: `AGENTS.md` §3 D11 (build everything, one clean cutover) and §7 for
|
||||
every operational fact. `docs/06-deployment.md` owns the cutover **checklist** —
|
||||
what must be true. This file owns the **commands** — how to make it true. The
|
||||
checklist cites this file; this file does not restate the checklist.
|
||||
|
||||
**Pouya runs everything here.** Parts 1–7 need administrative credentials that no
|
||||
agent on this project holds, and Part 8 is a deploy that must not run as
|
||||
`user/pouya` (§10). Every command is followed by the command that verifies it and
|
||||
the output to expect. Where a verification can come back two ways, both are named
|
||||
and the sequence branches — a branch driven by a measurement rather than by a
|
||||
guess is the point.
|
||||
|
||||
> **Read `docs/06`'s cutover checklist alongside this.** The parts below discharge
|
||||
> its Technical group. Its Content and compliance group — the `claims-auditor`
|
||||
> pass and Pouya's own page-by-page read — is not commands and is not here.
|
||||
|
||||
---
|
||||
|
||||
## Why the order is this order, and what is broken while it runs
|
||||
|
||||
The bucket currently holds the **old** single-file site: `index.html` (2,206,032
|
||||
bytes) plus two logo PNGs `[verified 2026-09-01 — list-objects-v2]`. So the live
|
||||
site today is one page, and one page is all that works: measured on the live
|
||||
distribution the same day, `/` returns **200** while `/about/` and
|
||||
`/definitely-not-a-page/` both return **403 with an 111-byte `application/xml`
|
||||
body** — S3's `AccessDenied`, served raw to the reader.
|
||||
|
||||
That is why the infrastructure goes first and the site goes last:
|
||||
|
||||
1. **Parts 1–4 (distribution) change nothing a visitor can see.** The router
|
||||
rewrites `/` to `/index.html`, which is what the default root object already
|
||||
did, and the paths it newly handles do not exist on the old site either way.
|
||||
2. **Parts 5–7 (intake) are inert until `/contact/` exists.**
|
||||
3. **Part 8 is the cutover.** The moment `index.html` syncs, the new site is
|
||||
live — and by then the router, the 404 mapping and `/api/*` are already in
|
||||
place, so there is no window in which 22 of 23 pages are broken.
|
||||
|
||||
Reversing this — site first, infrastructure second — would put every page except
|
||||
`/` behind a 403 for as long as the CloudFront deployment took.
|
||||
|
||||
---
|
||||
|
||||
## Part 0 — Preflight
|
||||
|
||||
### 0.1 The substitution block
|
||||
|
||||
⚠️ **THIS IS THE ONLY PLACE IN THIS FILE THAT NAMES A RESOURCE, AND THAT IS A
|
||||
DELIBERATE, BOUNDED EXCEPTION TO THE §7 RULE.** `AGENTS.md` §7 is the single
|
||||
source of truth for operational facts and specs cite it rather than copying it —
|
||||
but a runbook that cannot be pasted into a terminal is not a runbook. So the
|
||||
values appear exactly once, and **0.2 is the mechanism that keeps the copy
|
||||
honest**: it proves every one of them resolves to a real resource before anything
|
||||
is changed. A duplicated fact needs a mechanism, not a promise.
|
||||
|
||||
Confirm each against §7 before pasting. `./scripts/aws-discover.sh` regenerates
|
||||
them from AWS if §7 itself needs re-verifying.
|
||||
|
||||
```bash
|
||||
cd ~/Dev/Websites/adr-sml
|
||||
|
||||
export AWS_REGION=ca-central-1
|
||||
export AWS_DEFAULT_REGION=ca-central-1
|
||||
export ACCT=327082975128
|
||||
export FN=adr-intake-handler
|
||||
export TABLE=adr-intake-submissions
|
||||
export API_ID=4tl0m5igkj
|
||||
export API_DOMAIN=4tl0m5igkj.execute-api.ca-central-1.amazonaws.com
|
||||
export DIST_ID=E1OK7G98KNKUTA
|
||||
export BUCKET=adr-smlcompany-site
|
||||
export SITE=https://adr.smlcompany.ca
|
||||
export NOTIFY_TO=info@smlcompany.ca
|
||||
export MAIL_FROM=intake@smlcompany.ca
|
||||
```
|
||||
|
||||
`NOTIFY_TO` is where an inquiry lands and `MAIL_FROM` is the From on both
|
||||
messages. Both are choices rather than records: `info@` is the address `/contact/`
|
||||
publishes, so a reply threads where the inquirer expects; `intake@` distinguishes
|
||||
form mail from correspondence. Both must be at `smlcompany.ca`, which is the
|
||||
verified sending identity — 0.2 checks that.
|
||||
|
||||
### 0.2 Prove every one of them exists
|
||||
|
||||
```bash
|
||||
aws sts get-caller-identity
|
||||
for v in AWS_REGION ACCT FN TABLE API_ID API_DOMAIN DIST_ID BUCKET SITE NOTIFY_TO MAIL_FROM; do
|
||||
eval "val=\$$v"
|
||||
[ -n "$val" ] || { echo "EMPTY: $v"; break; }
|
||||
printf '%-12s %s\n' "$v" "$val"
|
||||
done
|
||||
aws lambda get-function-configuration --function-name "$FN" --query 'FunctionName' --output text
|
||||
aws dynamodb describe-table --table-name "$TABLE" --query 'Table.TableStatus' --output text
|
||||
aws apigatewayv2 get-api --api-id "$API_ID" --query 'Name' --output text
|
||||
aws cloudfront get-distribution --id "$DIST_ID" --query 'Distribution.Status' --output text
|
||||
aws s3api head-bucket --bucket "$BUCKET" && echo "bucket ok"
|
||||
aws sesv2 get-email-identity --email-identity "${MAIL_FROM#*@}" \
|
||||
--query 'VerifiedForSendingStatus' --output text
|
||||
```
|
||||
|
||||
**Expect:** an identity with administrative rights; eleven non-empty values;
|
||||
`adr-intake-handler`; `ACTIVE`; `adr-intake-api`; `Deployed`; `bucket ok`; `True`.
|
||||
|
||||
A `None`, an empty value, or any non-zero exit stops the run here. That is the
|
||||
whole purpose of 0.2 — a stale identifier fails loudly at the top instead of
|
||||
halfway through Part 6.
|
||||
|
||||
### 0.3 State this run starts from
|
||||
|
||||
Recorded so a later reader can tell what this sequence changed from what it found.
|
||||
All `[verified 2026-09-01]`, read-only:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Lambda | `nodejs24.x`, arm64, handler `index.handler`, timeout 10 s, memory 128 MB, **no environment variables**, no DLQ, code 1,527 bytes (the hand-built inline function), last modified 2026-05-26 |
|
||||
| Lambda role | `adr-intake-lambda-role` — `AWSLambdaBasicExecutionRole` plus an inline policy granting `dynamodb:PutItem` on the table and `ses:SendEmail`/`ses:SendRawEmail`. **Sufficient for the new handler; no change needed** |
|
||||
| Lambda invoke permission | one statement, `apigateway.amazonaws.com`, **`SourceArn` scoped to `…/4tl0m5igkj/*/*/submissions`** — the old route's path only |
|
||||
| HTTP API | one route, `POST /submissions` → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, the correct one for this handler). Stage `$default`, auto-deploy on, **no throttling**, no access log. CORS allows `POST` from the site origin |
|
||||
| DynamoDB | `ACTIVE`, on-demand, **partition key `submissionId` (S), no sort key**, 4 existing items, TTL **ENABLED** on attribute `ttl`, PITR **ENABLED** (35-day window), encryption at rest with the **AWS-owned** key (no CMK) |
|
||||
| CloudFront | one S3 origin with OAC `E13GAFUL6UQP6R`; default behaviour `Managed-CachingOptimized` + `Managed-SecurityHeadersPolicy`, methods HEAD/GET; **no cache behaviours**, **no custom error responses**, **no function associations** |
|
||||
| S3 | versioning `Enabled`; all four public-access blocks `true`; direct object GET on the S3 hostname returns 403, so OAC is in force |
|
||||
| SES | domain verified for sending, DKIM `SUCCESS` and signing enabled, no custom MAIL FROM |
|
||||
| SNS `ses-alerts` | email subscription to `info@smlcompany.ca` is **CONFIRMED** — it has a real subscription ARN, not `PendingConfirmation`. §7 recorded it as pending for six days after it stopped being pending; §12 R9 closes on this |
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Make S3 answer 404 instead of 403
|
||||
|
||||
**Why this is first.** Part 3 maps **404** to `/404.html` and deliberately does
|
||||
**not** map 403. With OAC and no `s3:ListBucket`, S3 answers a request for a
|
||||
missing key with `403 AccessDenied`, so a 404-only mapping would never fire and
|
||||
every bad URL would keep returning raw XML. Granting the CloudFront service
|
||||
principal `s3:ListBucket` changes that answer to `404 NoSuchKey`.
|
||||
|
||||
Mapping 403 as well would have been one command shorter and is the wrong trade:
|
||||
custom error responses are distribution-wide and cannot be scoped to one
|
||||
behaviour, so it would also convert a broken bucket policy or a broken OAC — a
|
||||
real outage on every URL at once — into a tidy "page not found", and it would
|
||||
turn the intake handler's `Origin` refusal into a 404 page. Keeping 403 meaning
|
||||
403 is worth one statement.
|
||||
|
||||
```bash
|
||||
aws s3api get-bucket-policy --bucket "$BUCKET" --query Policy --output text > /tmp/bucket-policy.before.json
|
||||
python3 -m json.tool /tmp/bucket-policy.before.json
|
||||
```
|
||||
|
||||
**Expect:** one statement, `AllowCloudFrontServicePrincipal`, `s3:GetObject` on
|
||||
`arn:aws:s3:::$BUCKET/*`, conditioned on the distribution ARN. Keep that file —
|
||||
Part 9 restores from it.
|
||||
|
||||
```bash
|
||||
python3 - "$BUCKET" "$ACCT" "$DIST_ID" <<'PY' > /tmp/bucket-policy.after.json
|
||||
import json, sys
|
||||
bucket, acct, dist = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
p = json.load(open('/tmp/bucket-policy.before.json'))
|
||||
arn = f'arn:aws:cloudfront::{acct}:distribution/{dist}'
|
||||
sid = 'AllowCloudFrontListForHonest404s'
|
||||
p['Statement'] = [s for s in p['Statement'] if s.get('Sid') != sid]
|
||||
p['Statement'].append({
|
||||
'Sid': sid,
|
||||
'Effect': 'Allow',
|
||||
'Principal': {'Service': 'cloudfront.amazonaws.com'},
|
||||
'Action': 's3:ListBucket',
|
||||
'Resource': f'arn:aws:s3:::{bucket}',
|
||||
'Condition': {'StringEquals': {'AWS:SourceArn': arn}},
|
||||
})
|
||||
json.dump(p, sys.stdout, indent=2)
|
||||
PY
|
||||
python3 -m json.tool /tmp/bucket-policy.after.json
|
||||
aws s3api put-bucket-policy --bucket "$BUCKET" --policy "file:///tmp/bucket-policy.after.json"
|
||||
```
|
||||
|
||||
**Verify — three things, and the third is the one worth doing:**
|
||||
|
||||
```bash
|
||||
aws s3api get-bucket-policy --bucket "$BUCKET" --query Policy --output text | python3 -m json.tool
|
||||
aws s3api get-public-access-block --bucket "$BUCKET" --query 'PublicAccessBlockConfiguration'
|
||||
curl -s -o /dev/null -w 'direct S3 object: %{http_code}\n' \
|
||||
"https://${BUCKET}.s3.${AWS_REGION}.amazonaws.com/index.html"
|
||||
aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
--query 'DistributionConfig.DefaultCacheBehavior.{Cache:CachePolicyId,OriginRequest:OriginRequestPolicyId}'
|
||||
aws cloudfront get-cache-policy --id 658327ea-f89d-4fab-a63d-7e88639e58f6 \
|
||||
--query 'CachePolicy.CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin.QueryStringsConfig'
|
||||
```
|
||||
|
||||
**Expect:** two statements; all four public-access blocks still `true`; direct S3
|
||||
`403`; `Cache` = `658327ea-…` (`Managed-CachingOptimized`) with
|
||||
**`OriginRequest: null`**; and `QueryStringsConfig` = **`{"QueryStringBehavior":
|
||||
"none"}"`**.
|
||||
|
||||
⚠️ **THOSE LAST TWO ARE THE CHECK, AND THE OBVIOUS ONE IS WORTHLESS.** The
|
||||
tempting verification is `curl "${SITE}/?list-type=2&max-keys=5"` and confirming
|
||||
it returns HTML rather than an XML `ListBucketResult` — **it cannot return
|
||||
anything else, so it is evidence of nothing.** The distribution has
|
||||
`DefaultRootObject: index.html` and, after Part 2, the router rewrites `/` to
|
||||
`/index.html`; the origin request is therefore a `GetObject` on a key, never a
|
||||
request for the bucket root, so `list-type=2` could not be read as a list
|
||||
operation whether or not query strings reached S3. An operator running it sees
|
||||
HTML and ticks a control that never ran, which is the Q22 shape exactly. Found by
|
||||
`adversarial-reviewer`, 2026-09-01.
|
||||
|
||||
**What actually makes the grant safe, and the two commands above assert both
|
||||
halves:** no viewer path resolves to the bucket root, and the default behaviour
|
||||
forwards **no query strings** to the origin — with no origin request policy
|
||||
attached to override that. If either assertion fails, revert this part (Part 9.1)
|
||||
and map 403 instead.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — The trailing-slash router function
|
||||
|
||||
Source and test are in the repo: `infra/cloudfront/router.js` and
|
||||
`router.test.mjs`. Read the header comment before publishing it — the reason it
|
||||
must not be associated with `/api/*` is in there, and it is the difference
|
||||
between a working form and a POST silently converted to a GET.
|
||||
|
||||
```bash
|
||||
node infra/cloudfront/router.test.mjs
|
||||
```
|
||||
|
||||
**Expect:** exit **0**. The case count is deliberately not quoted here — it has
|
||||
already gone 15 → 22 → 30 in one day, and an `Expect` line that never matches
|
||||
teaches the operator to stop reading `Expect` lines. The script asserts its own
|
||||
count internally (`case count != CASES.length`), so exit 0 is the whole check.
|
||||
|
||||
```bash
|
||||
aws cloudfront create-function \
|
||||
--name adr-sml-router \
|
||||
--function-config '{"Comment":"trailing-slash + index.html for the Astro directory build; docs/09","Runtime":"cloudfront-js-2.0"}' \
|
||||
--function-code "fileb://infra/cloudfront/router.js" \
|
||||
--query '{Name:FunctionSummary.FunctionMetadata.FunctionARN,Stage:FunctionSummary.FunctionMetadata.Stage,Status:FunctionSummary.Status}'
|
||||
```
|
||||
|
||||
**Expect:** an ARN, `Stage: DEVELOPMENT`, `Status: UNPUBLISHED`.
|
||||
|
||||
**Test it in the real runtime before publishing.** The local test proves the
|
||||
branches; this proves the runtime accepts the code, which the local test cannot.
|
||||
|
||||
```bash
|
||||
ETAG=$(aws cloudfront describe-function --name adr-sml-router --query ETag --output text)
|
||||
for URI in / /about/ /about /robots.txt /_astro/x.css /api/intake \
|
||||
//evil.example.com/x '/\evil.example.com/x' //about/ //robots.txt; do
|
||||
printf '{"version":"1.0","request":{"method":"GET","uri":"%s","querystring":{},"headers":{},"cookies":{}}}' "$URI" \
|
||||
> /tmp/cf-event.json
|
||||
printf '%-16s ' "$URI"
|
||||
aws cloudfront test-function --name adr-sml-router --if-match "$ETAG" --stage DEVELOPMENT \
|
||||
--event-object fileb:///tmp/cf-event.json \
|
||||
--query 'TestResult.{Out:FunctionOutput,Err:FunctionErrorMessage}' --output text
|
||||
done
|
||||
```
|
||||
|
||||
`fileb://` rather than a base64 argument: it hands the CLI raw bytes and sidesteps
|
||||
the question of whether the local `base64` wraps its output, which would make the
|
||||
argument invalid in a way the error message would not explain. `test-function`
|
||||
does not change the function, so one `ETAG` read serves the whole loop.
|
||||
|
||||
Then the query-string path, which none of the URIs above exercises:
|
||||
|
||||
```bash
|
||||
cat > /tmp/cf-event-qs.json <<'JSON'
|
||||
{"version":"1.0","request":{"method":"GET","uri":"/fees","headers":{},"cookies":{},
|
||||
"querystring":{"utm":{"value":"a%20b"},"q":{"value":"x|y"},"bad":{"value":"z\"<>#&k=v"}}}}
|
||||
JSON
|
||||
aws cloudfront test-function --name adr-sml-router --if-match "$ETAG" --stage DEVELOPMENT \
|
||||
--event-object fileb:///tmp/cf-event-qs.json \
|
||||
--query 'TestResult.{Out:FunctionOutput,Err:FunctionErrorMessage}' --output text
|
||||
```
|
||||
|
||||
**Expect,** in order for the ten URIs: `uri /index.html`; `uri
|
||||
/about/index.html`; a **301 to `/about/`**; `uri /robots.txt` unchanged; `uri
|
||||
/_astro/x.css` unchanged; `/api/intake` a **301 to `/api/intake/`**; and then
|
||||
**four 301s that all stay on this origin with a single leading slash** —
|
||||
`/evil.example.com/x/`, `/evil.example.com/x/`, `/about/`, `/robots.txt`.
|
||||
No `FunctionErrorMessage` on any of the ten.
|
||||
|
||||
From the query-string call: a **301 to
|
||||
`/fees/?utm=a%20b&q=x|y&bad=z&k=v`**. Three things are being checked there and
|
||||
each has been wrong once: `%20` survives (the values arrive percent-encoded, so
|
||||
re-encoding would produce `%2520`); `|` survives (it is not in WHATWG's query
|
||||
percent-encode set, so browsers send it raw, and one revision of the function
|
||||
stripped it and silently corrupted campaign links); and `"`, `<`, `>` and `#` are
|
||||
gone (`#` is the one that changes the *structure* of the header — left in, `&k=v`
|
||||
lands in a fragment and the parameter is lost).
|
||||
|
||||
⚠️ **THE FIRST VERSION OF THIS LOOP PASSED `"querystring":{}` ON EVERY CASE AND
|
||||
TRIED NEITHER REDIRECT PATH.** So in the only environment that runs the real
|
||||
`cloudfront-js-2.0` runtime — the environment this step exists to reach — none of
|
||||
the query-string handling and none of the normalisation was executed, while the
|
||||
prose called it "the authoritative check". Found by `adversarial-reviewer`
|
||||
round 2.
|
||||
|
||||
⚠️ **That last row is the reason this function must not be associated with the
|
||||
`/api/*` behaviour, and it is in the list so you see it rather than read about
|
||||
it.** A 301 turns a POST into a GET, so an intake submission routed through this
|
||||
function would arrive at the handler as a GET with no body — and the visitor would
|
||||
be told nothing was wrong. Part 3 associates the function with the **default**
|
||||
behaviour only; `configure.mjs` sets `FunctionAssociations: { Quantity: 0 }` on
|
||||
the `/api/*` behaviour explicitly rather than by omission.
|
||||
|
||||
```bash
|
||||
aws cloudfront publish-function --name adr-sml-router --if-match "$ETAG"
|
||||
export ROUTER_ARN=$(aws cloudfront describe-function --name adr-sml-router --stage LIVE \
|
||||
--query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
|
||||
echo "ROUTER_ARN=$ROUTER_ARN"
|
||||
```
|
||||
|
||||
**Expect:** a non-empty ARN. It is empty if `publish-function` failed — read the
|
||||
status, not the absence of an error.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Apply the three distribution changes
|
||||
|
||||
One script, `infra/cloudfront/configure.mjs`, because the alternative is
|
||||
hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag.
|
||||
It is **dry-run by default**, **idempotent**, and it resolves both managed policy
|
||||
ids **by name from the account** rather than carrying them as literals.
|
||||
|
||||
```bash
|
||||
node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" \
|
||||
--function-arn "$ROUTER_ARN"
|
||||
```
|
||||
|
||||
**Expect** — this is the dry run, and the output on a distribution in the state
|
||||
Part 0.3 records is exactly:
|
||||
|
||||
```
|
||||
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
||||
resolved Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
|
||||
|
||||
4 change(s) to distribution E1OK7G98KNKUTA (ETag …):
|
||||
+ DefaultCacheBehavior.FunctionAssociations viewer-request -> arn:…:function/adr-sml-router
|
||||
+ CustomErrorResponses += 404 -> /404.html with status 404
|
||||
+ Origins += intake-api -> …execute-api… (https-only, TLSv1.2)
|
||||
+ CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed
|
||||
|
||||
DRY RUN — nothing was sent. Re-run with --apply to write it.
|
||||
```
|
||||
|
||||
Fewer than four changes means part of this is already done — read which lines are
|
||||
prefixed `·` (already present) and carry on. More than four, or a different set,
|
||||
means the distribution is not in the state 0.3 recorded: stop and re-read it.
|
||||
|
||||
```bash
|
||||
node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" \
|
||||
--function-arn "$ROUTER_ARN" --apply
|
||||
aws cloudfront wait distribution-deployed --id "$DIST_ID"
|
||||
echo "deployed: $?"
|
||||
```
|
||||
|
||||
**Verify the config landed:**
|
||||
|
||||
```bash
|
||||
aws cloudfront get-distribution-config --id "$DIST_ID" \
|
||||
--query 'DistributionConfig.{Fn:DefaultCacheBehavior.FunctionAssociations.Items[].EventType,Err:CustomErrorResponses.Items[].{Code:ErrorCode,Page:ResponsePagePath,Status:ResponseCode},Beh:CacheBehaviors.Items[].{P:PathPattern,O:TargetOriginId,Methods:AllowedMethods.Items},Origins:Origins.Items[].Id}'
|
||||
```
|
||||
|
||||
**Expect:** `Fn: ["viewer-request"]`; one error response `404 → /404.html → 404`;
|
||||
one behaviour `/api/*` → `intake-api` with POST in its method list; two origins.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Verify the distribution before the site exists
|
||||
|
||||
Run this now, against the **old** bucket contents. It is the check that the
|
||||
plumbing works independently of the deploy.
|
||||
|
||||
```bash
|
||||
for p in / /index.html /about/ /nope-not-a-page/; do
|
||||
printf '%-22s ' "$p"
|
||||
curl -s -o /dev/null -w 'status=%{http_code} ct=%{content_type}\n' "${SITE}${p}"
|
||||
done
|
||||
printf 'no-slash redirect: '; curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' "${SITE}/about"
|
||||
```
|
||||
|
||||
**Expect:** `/` and `/index.html` → **200 text/html** (the old page still
|
||||
serves — the router did not break it); `/about/` and `/nope-not-a-page/` → **404**
|
||||
rather than the 403 they returned before, because Part 1 changed S3's answer and
|
||||
Part 3 mapped it; `/about` → **301 → `https://adr.smlcompany.ca/about/`**.
|
||||
|
||||
The body of those 404s is not the 404 page yet — `404.html` is not in the bucket
|
||||
until Part 8. **The status is what Part 4 proves; the page is what Part 8.4
|
||||
proves.** Do not tick `docs/06`'s "404 returns a 404 status" item here; tick it
|
||||
after 8.4, when both halves are true at once.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Deploy the handler
|
||||
|
||||
### 5.1 Package
|
||||
|
||||
```bash
|
||||
rm -f /tmp/intake.zip
|
||||
(cd backend/intake && zip -q -X /tmp/intake.zip handler.mjs fields.mjs)
|
||||
unzip -l /tmp/intake.zip
|
||||
```
|
||||
|
||||
**Expect:** exactly two entries, `handler.mjs` and `fields.mjs`, **≈ 25.7 KB
|
||||
uncompressed and ≈ 10.8 KB zipped** `[measured 2026-09-01]`. Both at the zip root —
|
||||
`handler.mjs` imports `./fields.mjs`, so a nested directory breaks the import at
|
||||
cold start.
|
||||
|
||||
### 5.2 Configuration first, code second
|
||||
|
||||
The handler calls `requireEnv()` at **module scope** and throws at cold start on
|
||||
any of six missing variables — deliberately, so a misconfigured function cannot
|
||||
accept a submission it will not store. Set the configuration before the code, so
|
||||
there is never a moment when the new code runs against no environment.
|
||||
|
||||
```bash
|
||||
ENV_JSON=$(node scripts/intake-env.mjs --table "$TABLE" --notify "$NOTIFY_TO" --from "$MAIL_FROM")
|
||||
echo "$ENV_JSON" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Expect** six variables. ⚠️ **`RESPONSE_TIME` and `NO_RETAINER_NOTICE` are read
|
||||
out of `src/data/site.ts` and must never be typed here.** Both are published
|
||||
commitments — the two-business-day response (§4, Q27) and the no-retainer notice
|
||||
`docs/01` §`/contact/` requires, **including its fourth clause about not itself
|
||||
creating a conflict check**, which a hand-typed copy inside the handler had
|
||||
already dropped once. The notice also contains an en dash in "mediator–party",
|
||||
which is exactly the character a retype loses. `scripts/intake-env.mjs` asserts
|
||||
both and exits non-zero rather than emitting a softened commitment.
|
||||
|
||||
```bash
|
||||
aws lambda update-function-configuration --function-name "$FN" \
|
||||
--handler handler.handler --timeout 15 --memory-size 512 --environment "$ENV_JSON"
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
aws lambda get-function-configuration --function-name "$FN" \
|
||||
--query '{Handler:Handler,Timeout:Timeout,Memory:MemorySize,Vars:sort(keys(Environment.Variables)),State:State,Update:LastUpdateStatus}'
|
||||
```
|
||||
|
||||
**Expect:** `Handler: handler.handler` — the file is `handler.mjs` and the export
|
||||
is `handler`, where the hand-built function was `index.handler`; `Timeout: 15`;
|
||||
`Memory: 512`; the six names sorted; `State: Active`; `Update: Successful`.
|
||||
|
||||
Timeout 10 → 15 s and memory 128 → 512 MB are changes, not restatements. The
|
||||
handler does a DynamoDB write and two SES sends per invocation, and 128 MB is
|
||||
where an AWS SDK cold start is slowest — more memory shortens the billed duration
|
||||
enough that it is usually the cheaper setting, not the dearer one.
|
||||
|
||||
### 5.3 Code
|
||||
|
||||
```bash
|
||||
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
aws lambda get-function-configuration --function-name "$FN" \
|
||||
--query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}'
|
||||
```
|
||||
|
||||
**Expect:** `CodeSize` **≈ 10,800** (up from 1,527), `Update: Successful`.
|
||||
⚠️ **`CodeSize` is the ZIP, not the source.** This line said "around 23,000",
|
||||
which was 5.1's uncompressed figure applied to a different quantity — an
|
||||
operator seeing `10819` against an expectation of 23,000 would reasonably
|
||||
conclude the wrong artefact went up.
|
||||
|
||||
### 5.4 Prove it loads, without writing anything
|
||||
|
||||
```bash
|
||||
cat > /tmp/probe-no-origin.json <<'JSON'
|
||||
{"version":"2.0","routeKey":"POST /api/intake","rawPath":"/api/intake",
|
||||
"headers":{"content-type":"application/x-www-form-urlencoded"},
|
||||
"requestContext":{"http":{"method":"POST","path":"/api/intake","sourceIp":"127.0.0.1"}},
|
||||
"body":"probe=1","isBase64Encoded":false}
|
||||
JSON
|
||||
aws lambda invoke --function-name "$FN" --cli-binary-format raw-in-base64-out \
|
||||
--payload file:///tmp/probe-no-origin.json /tmp/probe-out.json
|
||||
cat /tmp/probe-out.json; echo
|
||||
```
|
||||
|
||||
**Expect:** the invoke prints `"StatusCode": 200` with **no `FunctionError`**, and
|
||||
`/tmp/probe-out.json` is
|
||||
|
||||
```json
|
||||
{"statusCode":403,"headers":{"Cache-Control":"no-store"},"body":""}
|
||||
```
|
||||
|
||||
That 403 is the handler's `Origin` check refusing a request with neither `Origin`
|
||||
nor `Referer`. It happens **before** any DynamoDB write and before any email,
|
||||
which is what makes this probe safe to run against production.
|
||||
|
||||
**Two other outcomes, and each has one cause:**
|
||||
|
||||
- `"FunctionError": "Unhandled"` with `Runtime.ImportModuleError` in the body —
|
||||
the `nodejs24.x` runtime does not provide the AWS SDK v3 clients this handler
|
||||
imports. Run 5.5, then repeat 5.4.
|
||||
- A body naming `intake handler: <NAME> is not set` — that variable did not
|
||||
reach the function. Re-run 5.2 and read the `Vars` list.
|
||||
|
||||
### 5.5 Bundled variant — only if 5.4 said `Runtime.ImportModuleError`
|
||||
|
||||
Versions are resolved from the registry at run time rather than pinned in this
|
||||
file: `CLAUDE.md`'s rule is that a version is checked against the registry and
|
||||
never recalled, and a literal here would be stale the week after it was written.
|
||||
|
||||
```bash
|
||||
rm -rf /tmp/intake-bundle && mkdir -p /tmp/intake-bundle
|
||||
cp backend/intake/handler.mjs backend/intake/fields.mjs /tmp/intake-bundle/
|
||||
( cd /tmp/intake-bundle \
|
||||
&& npm init -y > /dev/null \
|
||||
&& npm install --omit=dev --no-audit --no-fund \
|
||||
"@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \
|
||||
"@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" )
|
||||
rm -f /tmp/intake.zip
|
||||
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip handler.mjs fields.mjs node_modules package.json )
|
||||
unzip -l /tmp/intake.zip | tail -1
|
||||
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
|
||||
aws lambda wait function-updated --function-name "$FN"
|
||||
```
|
||||
|
||||
**Expect** a zip in the low single-digit megabytes, well under the 50 MB direct-
|
||||
upload limit. **If this path is taken, say so in the `AGENTS.md` Change Log and
|
||||
add both packages to §7** — they become pins this project maintains, and `R11`
|
||||
covers them from that moment.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Route the API at `/api/intake`
|
||||
|
||||
### 6.1 The invoke permission, before the route
|
||||
|
||||
⚠️ **THE EXISTING PERMISSION DOES NOT COVER THE NEW ROUTE, AND THIS IS THE STEP
|
||||
WHOSE OMISSION IS HARDEST TO DIAGNOSE.** Part 0.3 records the one statement on the
|
||||
function: `SourceArn` `…/4tl0m5igkj/*/*/submissions`. Add a route at
|
||||
`/api/intake` without adding a permission for it and API Gateway is refused
|
||||
permission to invoke — the caller gets a **500**, the Lambda logs nothing at all
|
||||
because it was never entered, and the only trace is an API Gateway metric.
|
||||
|
||||
```bash
|
||||
aws lambda add-permission --function-name "$FN" \
|
||||
--statement-id apigw-post-api-intake \
|
||||
--action lambda:InvokeFunction \
|
||||
--principal apigateway.amazonaws.com \
|
||||
--source-arn "arn:aws:execute-api:${AWS_REGION}:${ACCT}:${API_ID}/*/POST/api/intake"
|
||||
aws lambda get-policy --function-name "$FN" --query Policy --output text | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Expect:** two statements — the old one scoped to `…/submissions` and the new one
|
||||
to `…/POST/api/intake`.
|
||||
|
||||
### 6.2 The route
|
||||
|
||||
```bash
|
||||
export INTEG_ID=$(aws apigatewayv2 get-integrations --api-id "$API_ID" \
|
||||
--query "Items[?IntegrationUri=='arn:aws:lambda:${AWS_REGION}:${ACCT}:function:${FN}'].IntegrationId | [0]" \
|
||||
--output text)
|
||||
echo "INTEG_ID=$INTEG_ID"
|
||||
[ -n "$INTEG_ID" ] && [ "$INTEG_ID" != "None" ] || echo "STOP — no integration points at $FN"
|
||||
aws apigatewayv2 create-route --api-id "$API_ID" \
|
||||
--route-key 'POST /api/intake' --target "integrations/${INTEG_ID}" \
|
||||
--query '{RouteId:RouteId,RouteKey:RouteKey,Target:Target}'
|
||||
aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].{Key:RouteKey,Target:Target}'
|
||||
```
|
||||
|
||||
**Expect:** `INTEG_ID` non-empty and not `None`; then two routes,
|
||||
`POST /submissions` and `POST /api/intake`, both on the same integration. The
|
||||
stage is `$default` with auto-deploy on, so the route is live immediately — there
|
||||
is no deployment to create.
|
||||
|
||||
### 6.3 Throttling
|
||||
|
||||
⚠️ **THIS IS NOT THE PER-IP LIMIT `docs/05` ASKS FOR, AND IT CANNOT BE.** That
|
||||
spec says "Rate limit by source IP at API Gateway: 5 requests / 5 minutes".
|
||||
**API Gateway throttling is aggregate — per route and per stage — not per source
|
||||
IP.** Per-IP rate limiting needs AWS WAF with a rate-based rule on the
|
||||
distribution, which is a paid service and therefore Pouya's decision, recorded on
|
||||
`docs/06`'s checklist as explicitly **not** a launch blocker. What is set here is
|
||||
a real control over total volume; describing it as per-IP would be the Q22 shape
|
||||
again — a documented control that does not exist.
|
||||
|
||||
```bash
|
||||
aws apigatewayv2 update-stage --api-id "$API_ID" --stage-name '$default' \
|
||||
--route-settings '{"POST /api/intake":{"ThrottlingRateLimit":1,"ThrottlingBurstLimit":5,"DetailedMetricsEnabled":true}}'
|
||||
aws apigatewayv2 get-stage --api-id "$API_ID" --stage-name '$default' \
|
||||
--query 'RouteSettings'
|
||||
```
|
||||
|
||||
**Expect:** the `POST /api/intake` entry with rate 1/s, burst 5, detailed metrics
|
||||
on. One per second sustained with a burst of five is far above any human filling
|
||||
this form and far below anything worth paying for.
|
||||
|
||||
### 6.4 Retire the old route
|
||||
|
||||
Nothing posts to `/submissions` any more — the form posts to `/api/intake` and
|
||||
the handler that answered the old contract is gone. An unused public route on the
|
||||
same function is surface with no purpose.
|
||||
|
||||
```bash
|
||||
export OLD_ROUTE=$(aws apigatewayv2 get-routes --api-id "$API_ID" \
|
||||
--query "Items[?RouteKey=='POST /submissions'].RouteId | [0]" --output text)
|
||||
echo "OLD_ROUTE=$OLD_ROUTE"
|
||||
aws apigatewayv2 delete-route --api-id "$API_ID" --route-id "$OLD_ROUTE"
|
||||
aws lambda remove-permission --function-name "$FN" \
|
||||
--statement-id 70aeb597-e4d8-5141-8fb1-6b6190f7b2ab
|
||||
aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'
|
||||
aws lambda get-policy --function-name "$FN" --query Policy --output text | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Expect:** one route, `POST /api/intake`; one permission statement, scoped to it.
|
||||
Confirm the statement id in the `remove-permission` call against the policy you
|
||||
printed in 6.1 before running it — it is an id, not a name, and it will differ if
|
||||
the permission was ever rebuilt.
|
||||
|
||||
> **The `execute-api` hostname stays reachable**, so `/api/intake` can be reached
|
||||
> without passing through CloudFront. That is not a new weakness and not a
|
||||
> loose end: `DisableExecuteApiEndpoint` cannot be turned on, because the
|
||||
> distribution's own origin **is** that hostname. The `Origin` check is a CSRF
|
||||
> control rather than authentication either way, and 6.3's throttle applies at
|
||||
> the stage, so it covers both paths.
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — End to end, with a real submission
|
||||
|
||||
### 7.1 The route answers
|
||||
|
||||
```bash
|
||||
curl -si -X POST "${SITE}/api/intake" \
|
||||
-H 'Origin: https://adr.smlcompany.ca' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' | head -12
|
||||
```
|
||||
|
||||
**Expect:** `HTTP/2 303` and `location: https://adr.smlcompany.ca/contact/could-not-send/`.
|
||||
The handler accepted the request, validated it, found an empty submission and
|
||||
redirected to the failure page — **before** any write and any email. This is the
|
||||
same probe `scripts/deploy-local.sh` runs at the end of every deploy.
|
||||
|
||||
⚠️ **404 IS AMBIGUOUS AND THE FIRST DRAFT OF THIS LINE WAS NOT.** It means
|
||||
**either** the `/api/*` behaviour is missing (Part 3) **or** the `POST
|
||||
/api/intake` route is missing or misspelled (Part 6.2) — two different repairs.
|
||||
And because Part 3's custom error response is **distribution-wide**, a 404 from
|
||||
API Gateway is served as the styled `/404.html` body, so the one string that would
|
||||
have named the cause — API Gateway's `{"message":"Not Found"}` — is replaced before
|
||||
you see it. **Check the route first; it is one command:**
|
||||
`aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`.
|
||||
|
||||
**403** means the `Origin` header did not arrive — check that the behaviour uses
|
||||
`Managed-AllViewerExceptHostHeader`, because a policy that drops `Origin` turns
|
||||
every real submission into a 403. **500** means Part 6.1 was skipped.
|
||||
|
||||
### 7.2 A real submission, from the real form
|
||||
|
||||
Do this in a browser at `${SITE}/contact/` **after Part 8**, because the form
|
||||
does not exist until the site is deployed. Fill it as an inquirer would, with
|
||||
`Your name: Cutover test <date>` so the record is identifiable, and a real
|
||||
address you can read.
|
||||
|
||||
**Expect:** the browser lands on `/contact/received/`, and two emails arrive — the
|
||||
notification at `$NOTIFY_TO`, replyable to the address you entered, and the
|
||||
confirmation at that address. Read the confirmation and check three things: the
|
||||
response-time sentence, the **four**-clause no-retainer notice, and that the
|
||||
field summary uses readable labels ("Your name", "Subject matter") rather than
|
||||
field names.
|
||||
|
||||
Then find the record. The notification email prints `submissionId <uuid>` —
|
||||
that value **is** the partition key, so it can be used directly:
|
||||
|
||||
```bash
|
||||
export SUB_ID='<the uuid from the notification email>'
|
||||
aws dynamodb get-item --table-name "$TABLE" \
|
||||
--key "{\"submissionId\":{\"S\":\"${SUB_ID}\"}}" \
|
||||
--query 'Item.{id:submissionId.S,at:submittedAt.S,ttl:ttl.N,name:name.S,area:practiceArea.S,sourceIp:sourceIp.S}'
|
||||
curl -s https://checkip.amazonaws.com
|
||||
```
|
||||
|
||||
⚠️ **READ `sourceIp` AGAINST YOUR OWN ADDRESS — THIS IS THE ONLY PLACE THE PROXY
|
||||
CHAIN GETS MEASURED, AND IT HAS ALREADY BEEN REASONED ABOUT WRONGLY TWICE.** The
|
||||
handler stores `requestContext.http.sourceIp`, which is the TCP peer. Behind the
|
||||
`/api/*` behaviour that peer is a **CloudFront edge**, so the expectation is an
|
||||
AWS address, not yours. Three outcomes and each has a different consequence:
|
||||
|
||||
| what `sourceIp` holds | what it means |
|
||||
|---|---|
|
||||
| an **AWS** address (not the `checkip` value) | As designed. The field records the CDN, so it **cannot serve abuse investigation**, and `/legal/privacy/`'s *"your IP address"* is inaccurate — fold it into the Q62 edit on the same page rather than leaving two wrong sentences there |
|
||||
| **your** address, matching `checkip` | Better than expected, and worth knowing before anyone relies on it. Do not conclude it is trustworthy: verify it is not simply echoing a header by resubmitting with `-H 'X-Forwarded-For: 8.8.8.8'` and confirming `8.8.8.8` is **not** what lands |
|
||||
| `8.8.8.8` after that resubmission | **Stop.** The field is client-controlled and a record can be made to name an uninvolved third party. Revert to storing nothing rather than storing that |
|
||||
|
||||
⚠️ **AN EARLIER REVISION OF THE HANDLER READ `x-forwarded-for` HERE, AND THAT WAS
|
||||
THE THIRD OUTCOME.** CloudFront **appends** the viewer address to a
|
||||
client-supplied XFF rather than replacing it, so the leftmost entry is whatever
|
||||
the client sent. The fix, if a usable value is wanted, is a **custom** origin
|
||||
request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
|
||||
CloudFront generates and overwrites — not the managed
|
||||
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
|
||||
at API Gateway. That is an infrastructure change and it is deliberately not in
|
||||
this runbook: measure first.
|
||||
|
||||
**Expect** the item, with `ttl` a 10-digit epoch-seconds value. Check it is 24
|
||||
months out — read it, do not assume it:
|
||||
|
||||
```bash
|
||||
python3 - <<PY
|
||||
import datetime, os
|
||||
ttl = int("${SUB_ID}" and os.popen(
|
||||
'aws dynamodb get-item --table-name ${TABLE} '
|
||||
'--key \'{"submissionId":{"S":"${SUB_ID}"}}\' --query Item.ttl.N --output text'
|
||||
).read().strip())
|
||||
d = datetime.datetime.fromtimestamp(ttl, datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
months = (d.year - now.year) * 12 + (d.month - now.month)
|
||||
print(f'ttl {ttl} -> {d.isoformat()} ({months} months from now)')
|
||||
print('PASS' if months == 24 else 'FAIL — /legal/privacy/ promises 24 months')
|
||||
PY
|
||||
```
|
||||
|
||||
**Expect:** `24 months from now` and `PASS`. `/legal/privacy/` states the period
|
||||
publicly, so a number that is not 24 is a false disclosure rather than a config
|
||||
error.
|
||||
|
||||
⚠️ **DELETE THE TEST RECORD when you are done with it** — it is a real row in a
|
||||
table whose contents are governed by a published privacy policy.
|
||||
|
||||
```bash
|
||||
aws dynamodb delete-item --table-name "$TABLE" --key "{\"submissionId\":{\"S\":\"${SUB_ID}\"}}"
|
||||
```
|
||||
|
||||
### 7.3 The four pre-existing items
|
||||
|
||||
The table held **4 items** before any of this `[verified 2026-09-01 —
|
||||
describe-table ItemCount]`. They were written by the hand-built handler, whose
|
||||
code is in `docs/reference/AWS-Hosting-Guide.md` Part 8.3 and **writes no `ttl`
|
||||
attribute** — so DynamoDB will never expire them and they are retained
|
||||
indefinitely, against a policy that says otherwise.
|
||||
|
||||
**This is Pouya's call and not a command in this runbook**, because it depends on
|
||||
something no agent can determine: whether those four are the guide's own smoke
|
||||
test (`Test User`) or real inquiries that arrived through the old site. Read them
|
||||
in the console, then either delete the test rows or write a `ttl` onto the real
|
||||
ones. Tracked on `docs/06`'s checklist.
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — The first production deploy
|
||||
|
||||
⚠️ **DO NOT RUN 8.3 UNTIL POUYA HAS READ EVERY PAGE.** `docs/06`'s Content and
|
||||
compliance group — the `claims-auditor` pass and his own page-by-page read — is
|
||||
the gate on this part, and it is not a technical check.
|
||||
|
||||
### 8.1 Archive what is there
|
||||
|
||||
S3 versioning is `Enabled`, so pass 3's `--delete` is recoverable — but
|
||||
`docs/06`'s post-cutover list says to archive the old build rather than rely on
|
||||
that, and a local copy costs one command.
|
||||
|
||||
```bash
|
||||
mkdir -p _archive/pre-cutover-$(date +%F)
|
||||
aws s3 sync "s3://${BUCKET}" "_archive/pre-cutover-$(date +%F)/" --no-progress
|
||||
ls -la "_archive/pre-cutover-$(date +%F)/"
|
||||
find "_archive/pre-cutover-$(date +%F)" -type f | wc -l
|
||||
```
|
||||
|
||||
**Expect:** 3 files — `index.html` at 2,206,032 bytes and two PNGs under
|
||||
`assets/`. Do not commit them; `docs/06` wants them kept, not versioned.
|
||||
|
||||
### 8.2 Dry run — the three passes, in order, writing nothing
|
||||
|
||||
```bash
|
||||
npm run check && npm run build && npm run check:claims && npm run og:proof && npm run check:intake
|
||||
echo "gates exit=$?"
|
||||
npm run lighthouse; echo "lighthouse exit=$?"
|
||||
```
|
||||
|
||||
**Expect:** every one exit 0. `lighthouse` is local-only and cannot run in CI; it
|
||||
is a keyboard gate and this is the keyboard.
|
||||
|
||||
```bash
|
||||
aws s3 sync ./dist "s3://${BUCKET}" --dryrun \
|
||||
--exclude "*" --include "_astro/*" --include "fonts/*" \
|
||||
--cache-control "public, max-age=31536000, immutable" --no-progress | tail -5
|
||||
aws s3 sync ./dist "s3://${BUCKET}" --dryrun \
|
||||
--exclude "*" --include "*.avif" --include "*.webp" --include "*.jpg" \
|
||||
--include "*.png" --include "*.svg" \
|
||||
--cache-control "public, max-age=604800" --no-progress | tail -5
|
||||
aws s3 sync ./dist "s3://${BUCKET}" --dryrun \
|
||||
--exclude "_astro/*" --exclude "fonts/*" \
|
||||
--cache-control "public, max-age=0, must-revalidate" --delete --no-progress | tail -8
|
||||
```
|
||||
|
||||
**Expect,** on the bucket state Part 0.3 records — measured 2026-09-01, so these
|
||||
are counts rather than shapes: **51** `(dryrun) upload:` lines in pass 1, **56**
|
||||
in pass 2, and **52** lines in pass 3 of which **two are
|
||||
`(dryrun) delete:`** — `assets/sml-logo-full.png` and `assets/sml-logo-mark.png`,
|
||||
the old site's two logos. `404.html` appears in pass 3. `index.html` is not
|
||||
deleted; it is overwritten.
|
||||
|
||||
⚠️ **PASS 2's DRY RUN IS A SUPERSET OF WHAT PASS 2 ACTUALLY UPLOADS, AND THE REAL
|
||||
RUN WILL PRINT FEWER LINES.** 34 of those 56 are files under `_astro/`, which
|
||||
pass 1 uploads first. Each `--dryrun` above is compared against the bucket **as it
|
||||
is now**, so it cannot see the uploads the preceding pass would have made; in the
|
||||
real sequential run `aws s3 sync` skips them as already in sync. That skipping is
|
||||
exactly what preserves pass 1's `immutable` header on those files rather than
|
||||
overwriting it with pass 2's week-long one — `docs/06` §Cache policy says the same
|
||||
thing about pass 3, and this is why the pass order is load-bearing. **Do not
|
||||
reorder the passes to make this output tidier, and do not read the smaller real
|
||||
counts as a failed upload.**
|
||||
|
||||
⚠️ **Check the largest file in `./dist` while you are here.** The deploy user has
|
||||
no `s3:AbortMultipartUpload`, which is safe only while nothing approaches
|
||||
`aws s3 sync`'s 8 MB multipart threshold (`docs/06`).
|
||||
|
||||
```bash
|
||||
find ./dist -type f -exec stat -f '%z %N' {} + | sort -rn | head -3
|
||||
```
|
||||
|
||||
**Expect** the largest well under 8,388,608 bytes — it was **171,876** on
|
||||
2026-09-01. GNU `find -printf` is not available here; this is the BSD/macOS form,
|
||||
and writing it as `find -printf … || find -exec …` would not have fallen back,
|
||||
because the exit status of that pipeline belongs to `head`.
|
||||
|
||||
### 8.3 The deploy — on Pouya's word only
|
||||
|
||||
⚠️ **NOT AS `user/pouya`.** The script refuses that identity and §10 is why: the
|
||||
account is shared with unrelated production systems. Use the `adr-sml-deploy`
|
||||
key, which §7 records as provisioned.
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… \
|
||||
AWS_REGION="$AWS_REGION" S3_BUCKET="$BUCKET" CLOUDFRONT_DISTRIBUTION_ID="$DIST_ID" \
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
The script runs `npm run check`, the build, `check:claims`, the three passes, the
|
||||
`/*` invalidation, and then the intake route check. **Read its last line.** It
|
||||
warns rather than fails on a bad intake route, because by then the site is
|
||||
already published and failing the job would not un-publish it.
|
||||
|
||||
```bash
|
||||
git tag "v$(date +%Y).1" && git push --tags
|
||||
```
|
||||
|
||||
### 8.4 Verify the site, not the deploy
|
||||
|
||||
```bash
|
||||
for p in / /about/ /mediation/ /arbitration/ /med-arb/ /practice/ /practice/construction/ \
|
||||
/process/ /for-parties/ /fees/ /insights/ /contact/ /bio/ /legal/privacy/ \
|
||||
/legal/terms/ /robots.txt /sitemap-index.xml /pouya-lajevardi-bio.pdf; do
|
||||
printf '%-32s ' "$p"
|
||||
curl -s -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' "${SITE}${p}"
|
||||
done
|
||||
printf '%-32s ' "404 body"; curl -s "${SITE}/nope-not-a-page/" -o /tmp/404.html -w 'status=%{http_code}\n'
|
||||
grep -c 'That page is not here' /tmp/404.html
|
||||
printf '%-32s ' "cache header on HTML"; curl -sI "${SITE}/about/" | grep -i '^cache-control'
|
||||
printf '%-32s ' "cache header on asset"
|
||||
curl -s "${SITE}/about/" | grep -o '/_astro/[^"]*\.css' | head -1 \
|
||||
| xargs -I{} curl -sI "${SITE}{}" | grep -i '^cache-control'
|
||||
```
|
||||
|
||||
**Expect:** every page 200 with real bytes; the 404 path **404** *and* the phrase
|
||||
`That page is not here` found once; `max-age=0, must-revalidate` on HTML;
|
||||
`max-age=31536000, immutable` on the hashed CSS. Only now is `docs/06`'s "404
|
||||
returns a 404 status" item true on both halves.
|
||||
|
||||
---
|
||||
|
||||
## Part 9 — Rollback, per part
|
||||
|
||||
Each of these is independent. None of them needs the others undone first.
|
||||
|
||||
**9.1 Part 1** — `aws s3api put-bucket-policy --bucket "$BUCKET" --policy "file:///tmp/bucket-policy.before.json"`.
|
||||
Missing keys go back to 403 and the 404 mapping stops firing; nothing else changes.
|
||||
|
||||
**9.2 Parts 2–3** — re-run `configure.mjs` is *not* a rollback; it is idempotent
|
||||
forward-only. To undo, `get-distribution-config`, remove the
|
||||
`FunctionAssociations` entry / the `404` custom error response / the `/api/*`
|
||||
behaviour and the `intake-api` origin, and `update-distribution --if-match`. Then
|
||||
`aws cloudfront delete-function --name adr-sml-router --if-match <etag>`, which
|
||||
fails while the function is still associated — that ordering is a feature.
|
||||
|
||||
**9.3 Part 5** — the previous code is not recoverable from Lambda: the hand-built
|
||||
function was edited in the console and its 1,527 bytes exist only in
|
||||
`docs/reference/AWS-Hosting-Guide.md` Part 8.3. That is the rollback source, and
|
||||
it answers a different contract (JSON in, `{"ok":true}` out) that the current
|
||||
`/contact/` form does not speak. **Forward is the only real fix here** — which is
|
||||
worth knowing before 5.3, not after.
|
||||
|
||||
**9.4 Part 6** — `delete-route` the new route, `remove-permission` the new
|
||||
statement, and re-add the `/submissions` route and its permission if anything
|
||||
still needs it. Nothing does.
|
||||
|
||||
**9.5 Part 8** — S3 versioning is `Enabled`. Restore the previous version of
|
||||
`index.html`, `aws s3 rm` what the new build added, invalidate `/*`. Or
|
||||
`git revert` and re-deploy, which is cleaner and is what the tag in 8.3 is for.
|
||||
|
||||
---
|
||||
|
||||
## Part 10 — Q60: prove TTL deletes, not just that it is enabled
|
||||
|
||||
`AGENTS.md` §7 records `TimeToLiveStatus: ENABLED` on attribute `ttl`
|
||||
`[re-verified 2026-09-01]`, and **that proves the setting and not the behaviour**.
|
||||
`/legal/privacy/` tells the public that a record is *"deleted automatically by the
|
||||
database rather than by someone remembering to do it"* — an assertion about the
|
||||
mechanism. Q60 closes when a record has been watched to disappear, and not
|
||||
before. §12 R19 keeps it surfacing until then.
|
||||
|
||||
### 10.1 Write one record with a near-future `ttl`
|
||||
|
||||
Not through the form: the handler writes a 24-month `ttl` and waiting two years
|
||||
is not a test. Write it directly, with a shape that cannot be mistaken for an
|
||||
inquiry.
|
||||
|
||||
```bash
|
||||
export TTL_ID="q60-ttl-probe-$(date +%Y%m%dT%H%M%SZ)"
|
||||
export TTL_AT=$(python3 -c 'import time; print(int(time.time()) + 300)')
|
||||
python3 -c "import datetime,os; print('ttl', os.environ['TTL_AT'], '->', datetime.datetime.fromtimestamp(int(os.environ['TTL_AT']), datetime.timezone.utc).isoformat())"
|
||||
aws dynamodb put-item --table-name "$TABLE" --item "$(cat <<JSON
|
||||
{"submissionId":{"S":"${TTL_ID}"},
|
||||
"submittedAt":{"S":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"},
|
||||
"ttl":{"N":"${TTL_AT}"},
|
||||
"note":{"S":"AGENTS.md Q60 — TTL behaviour probe, no personal information, safe to delete"}}
|
||||
JSON
|
||||
)"
|
||||
aws dynamodb get-item --table-name "$TABLE" --key "{\"submissionId\":{\"S\":\"${TTL_ID}\"}}" \
|
||||
--query 'Item.{id:submissionId.S,ttl:ttl.N,note:note.S}'
|
||||
echo "$TTL_ID" > ~/q60-ttl-probe-id.txt
|
||||
echo "$TTL_AT" >> ~/q60-ttl-probe-id.txt
|
||||
```
|
||||
|
||||
**Expect:** the item, with `ttl` five minutes in the future. The id is written to
|
||||
a file because the check below happens on a different day and this is the only
|
||||
thing that connects the two.
|
||||
|
||||
### 10.2 The check, and it is not five minutes later
|
||||
|
||||
⚠️ **DYNAMODB'S TTL SWEEPER IS NOT PROMPT AND AWS DOES NOT PROMISE THAT IT IS.**
|
||||
Deletion typically happens within a couple of days of the timestamp passing, and
|
||||
**up to 48 hours or more is normal and not a fault.** A check run at `ttl + 5
|
||||
minutes` that finds the item present has measured nothing — it is the same
|
||||
mistake as reading a value before the transition finishes. So:
|
||||
|
||||
- **Earliest useful check: 48 hours after `TTL_AT`.**
|
||||
- **Do not conclude a failure before 7 days.**
|
||||
|
||||
```bash
|
||||
export TTL_ID=$(sed -n 1p ~/q60-ttl-probe-id.txt)
|
||||
export TTL_AT=$(sed -n 2p ~/q60-ttl-probe-id.txt)
|
||||
python3 -c "import time,os; d=time.time()-int(os.environ['TTL_AT']); print(f'{d/3600:.1f} hours since the ttl passed')"
|
||||
aws dynamodb get-item --table-name "$TABLE" \
|
||||
--key "{\"submissionId\":{\"S\":\"${TTL_ID}\"}}" --output json
|
||||
echo "get-item exit=$?"
|
||||
```
|
||||
|
||||
**Read both the output and the exit status.** `get-item` on a missing key exits
|
||||
**0** with an **empty response body** — it is not an error, and a script that only
|
||||
checked the exit status would report the record present and absent identically.
|
||||
|
||||
| result | meaning |
|
||||
|---|---|
|
||||
| `{"Item": {…}}`, under 48 h since `TTL_AT` | inconclusive. Wait. |
|
||||
| `{}` (empty), any time after `TTL_AT` | **Q60 closes.** The mechanism works. |
|
||||
| `{"Item": {…}}`, more than 7 days after | **Q60 fails.** TTL is enabled and not deleting. `/legal/privacy/` is then making a claim the database does not honour, and the page has to change or the mechanism does. |
|
||||
|
||||
### 10.3 Record it
|
||||
|
||||
On the `{}` result, and only then:
|
||||
|
||||
- Stamp §7's `Intake table` row: the behaviour is **observed**, with the date and
|
||||
the elapsed time, alongside the `ENABLED` reading it already carries.
|
||||
- Close **Q60** in §9, quoting the two timestamps.
|
||||
- Retire **R19** from §12 — its whole purpose was to keep this surfacing.
|
||||
- Tick the TTL item on `docs/06`'s checklist. It has two halves and this is the
|
||||
second: `ENABLED` proved the setting, this proves the behaviour.
|
||||
- Delete the probe row if it somehow survives, and delete
|
||||
`~/q60-ttl-probe-id.txt`.
|
||||
|
||||
If it fails, that is a **published-disclosure defect**, not a backlog item:
|
||||
`src/pages/legal/privacy.astro` carries the matching `TODO(pouya)` and the page
|
||||
asserts the mechanism in as many words.
|
||||
@@ -113,7 +113,10 @@ Administer the Arbitration, Request for the appointment of an arbitrator,
|
||||
Application for Urgent Interim Measures, Application to Challenge an Arbitrator,
|
||||
Notice of Appeal.
|
||||
|
||||
**ADRIC Med-Arb Rules.** A discussion draft was presented to the membership at
|
||||
**ADRIC Med-Arb Rules.** Developed by a **Task Force** — *"a Task Force was
|
||||
formed with a dedicated working group of med-arb professionals. The Task Force
|
||||
completed an initial draft of the Rules, which were then referred to the Rules
|
||||
Committee"* — and a discussion draft was presented to the membership at
|
||||
ADRIC's Annual Conference in **November 2019**. Two sentences are directly
|
||||
useful to `/med-arb/`, both verbatim:
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Who can read `adr-intake-submissions` — verification extract
|
||||
|
||||
**Why this file exists.** `/legal/privacy/` makes a statement to the public about
|
||||
who can see the contents of the intake table. `AGENTS.md` §4 admits no factual
|
||||
claim that cannot be traced, and `CLAUDE.md`'s R14 rule is that anything a spec
|
||||
claims about must be reachable from the repository — *"if the artefact lives only
|
||||
in a console, no reviewer can compare the claim against it and the claim is
|
||||
unverifiable by construction."* Until this file existed, that sentence was the
|
||||
one claim on the site whose subject was entirely outside the repo.
|
||||
|
||||
Raised by `claims-auditor` in the D20 cutover audit, 2026-09-01, finding 8.
|
||||
|
||||
**Provenance.** Every figure below was read from AWS on **2026-09-01** with the
|
||||
commands listed at the end, run read-only as `arn:aws:iam::327082975128:user/pouya`.
|
||||
No command in this file creates or changes anything. Re-run them rather than
|
||||
trusting this file; it is dated for that reason.
|
||||
|
||||
---
|
||||
|
||||
## The claim being checked
|
||||
|
||||
`src/pages/legal/privacy.astro`, §Who can see it:
|
||||
|
||||
> The table is reachable by the function that writes to it and by one
|
||||
> administrative account, which is mine — nobody else has access to the table.
|
||||
> There is no team, no assistant and no external administrator.
|
||||
|
||||
## The finding: it is false
|
||||
|
||||
The account has an IAM group **`admins`** carrying the AWS managed policy
|
||||
**`AdministratorAccess`**, and it has **two members: `pouya` and `lars`.**
|
||||
|
||||
`iam simulate-principal-policy` for `dynamodb:GetItem`, `dynamodb:Query` and
|
||||
`dynamodb:Scan` against
|
||||
`arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions`, across
|
||||
all five IAM users in the account:
|
||||
|
||||
| principal | GetItem | Query | Scan |
|
||||
|---|---|---|---|
|
||||
| `user/pouya` | **allowed** | **allowed** | **allowed** |
|
||||
| `user/lars` | **allowed** | **allowed** | **allowed** |
|
||||
| `user/adr-sml-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny | implicitDeny |
|
||||
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny | implicitDeny |
|
||||
|
||||
`lars` holds exactly the access `pouya` holds, by the same route: membership of
|
||||
`admins`. The user's own attachments are only `IAMUserChangePassword`, so the
|
||||
group is the whole of it.
|
||||
|
||||
So the published sentence is wrong on both of its halves — a second account has
|
||||
access, and it belongs to a second administrator of a shared account.
|
||||
|
||||
## The rest of the surface, recorded so the check is complete rather than partial
|
||||
|
||||
- **5 IAM users**: `adr-sml-deploy`, `gitea-deploy-meshkinilaw`, `lars`,
|
||||
`meshkini-backend-deploy`, `pouya`. The three deploy users are all
|
||||
`implicitDeny` above. `adr-sml-deploy`'s scope is S3 + CloudFront and touches
|
||||
no table (`docs/reference/deploy-credential-verification.md`).
|
||||
- **1 IAM group**: `admins` — `AdministratorAccess` and `Billing`, two members.
|
||||
- **33 IAM roles**, 26 of them not service-linked. Two carry
|
||||
`AdministratorAccess`:
|
||||
`cdk-hnb659fds-cfn-exec-role-327082975128-ca-central-1` and
|
||||
`…-us-east-1`. These are **AWS CDK bootstrap CloudFormation execution roles**,
|
||||
assumable by CloudFormation for stack deployment. They are a real path to the
|
||||
table for anyone who can deploy a CDK stack in this account — which is the two
|
||||
administrators above — rather than a third party.
|
||||
- **`adr-intake-lambda-role`** is the writing principal: `dynamodb:PutItem` on
|
||||
this table, `ses:SendEmail`/`SendRawEmail`, plus
|
||||
`AWSLambdaBasicExecutionRole`. **`PutItem` only — it cannot read the table**,
|
||||
which is worth stating because it is a stronger fact than the page currently
|
||||
claims and it is the part of the sentence that is true.
|
||||
- **No resource-based policy on the table.** DynamoDB supports one; this table
|
||||
has none, so access is governed entirely by identity policies.
|
||||
- The account is **not single-project** (`AGENTS.md` §10). `lars` and the two
|
||||
`meshkini*`/`gitea*` users are evidence of that on the IAM surface, not just
|
||||
in the S3 bucket listing §10 describes.
|
||||
|
||||
## What has to happen before `/legal/privacy/` goes public
|
||||
|
||||
Tracked as `AGENTS.md` §9 **Q62**. It is one of two things and both are Pouya's:
|
||||
|
||||
1. **Remove the access** — take `lars` out of `admins`, or replace that
|
||||
membership with a policy that denies DynamoDB on this table — and then this
|
||||
sentence becomes true. Note the likely collision: `AGENTS.md` Q23 records the
|
||||
Gitea instance as *jointly administered* and blocked on *"its second
|
||||
administrator"*, so this account is probably not the only thing that access
|
||||
is for.
|
||||
2. **Correct the sentence** to what is true. It is a privacy policy, so the
|
||||
honest version is short and specific — the number of people with
|
||||
administrative access, and that the function that writes cannot read.
|
||||
|
||||
**Do not resolve it by softening.** "Access is limited to authorised
|
||||
administrators" is the shape §4 exists to bar: defensible, uninformative, and it
|
||||
would replace a false specific with a true vacancy on the one page where a reader
|
||||
is entitled to the specific.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
Run as `user/pouya`, `ca-central-1`, all read-only. Exit status read on each; no
|
||||
stderr suppressed anywhere.
|
||||
|
||||
```bash
|
||||
aws iam list-users --query 'Users[].UserName'
|
||||
aws iam list-groups --query 'Groups[].GroupName'
|
||||
aws iam get-group --group-name admins --query 'Users[].UserName'
|
||||
aws iam list-attached-group-policies --group-name admins --query 'AttachedPolicies[].PolicyName'
|
||||
aws iam list-group-policies --group-name admins --query 'PolicyNames'
|
||||
aws iam list-groups-for-user --user-name lars --query 'Groups[].GroupName'
|
||||
aws iam list-attached-user-policies --user-name lars --query 'AttachedPolicies[].PolicyName'
|
||||
aws iam list-user-policies --user-name lars --query 'PolicyNames'
|
||||
|
||||
TARN="arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions"
|
||||
for U in pouya lars adr-sml-deploy gitea-deploy-meshkinilaw meshkini-backend-deploy; do
|
||||
aws iam simulate-principal-policy \
|
||||
--policy-source-arn "arn:aws:iam::327082975128:user/${U}" \
|
||||
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
|
||||
--resource-arns "$TARN" \
|
||||
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
|
||||
done
|
||||
|
||||
# Roles: 33 total, 26 non-service-linked; screened for broad policies.
|
||||
for R in $(aws iam list-roles --query 'Roles[].RoleName' --output text \
|
||||
| tr '\t' '\n' | grep -v '^AWSServiceRole'); do
|
||||
aws iam list-attached-role-policies --role-name "$R" \
|
||||
--query 'AttachedPolicies[].PolicyName' --output text
|
||||
done
|
||||
|
||||
aws iam get-role-policy --role-name adr-intake-lambda-role \
|
||||
--policy-name adr-intake-lambda-inline --query PolicyDocument
|
||||
aws iam list-attached-role-policies --role-name adr-intake-lambda-role
|
||||
```
|
||||
|
||||
⚠️ **`simulate-principal-policy` was the tool that produced a false negative on
|
||||
this project once already** — `AGENTS.md` Q22, where eight checks returned empty
|
||||
because `2>/dev/null` was hiding an `InvalidInput` error caused by a zsh
|
||||
parameter-expansion bug (`$ACCT:user/` parses `:u` as a history modifier). The
|
||||
loop above brace-quotes `${U}` for that reason, prints one line per principal so
|
||||
a silently-skipped iteration is visible as a missing row, and suppresses nothing.
|
||||
**Five rows, or the run did not happen.**
|
||||
@@ -106,7 +106,15 @@ the dispute."*
|
||||
|
||||
**Consequence:** the neutral in the LAT's pre-hearing step is a **Member /
|
||||
adjudicator of the Tribunal**. It is directed by the Tribunal, attendance is
|
||||
mandatory, and the Member is disqualified from the subsequent hearing panel. A
|
||||
mandatory, and the Member does not sit on the subsequent hearing panel **except
|
||||
with the consent of the parties** (Rule 14.3, quoted verbatim above). ⚠️ **This
|
||||
line read "the Member is disqualified from the subsequent hearing panel" until
|
||||
2026-09-01** — an absolute, thirty lines below the quotation that qualifies it,
|
||||
in this repository's own voice rather than the Tribunal's. `/practice/insurance/`
|
||||
took the absolute from here and published it. Corrected in both places on the
|
||||
same day; the page was corrected first and this file is where the defect would
|
||||
otherwise have re-seeded, which is `CLAUDE.md`'s point about commentary around a
|
||||
quotation being this repository speaking. A
|
||||
privately retained neutral is not appointed to it and cannot be.
|
||||
|
||||
## Finding 2 — the LAT Rules never use the words "mediation", "mediator" or "arbitration"
|
||||
|
||||
Reference in New Issue
Block a user