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
+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
|
||||
|
||||
Reference in New Issue
Block a user