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
466 lines
29 KiB
Markdown
466 lines
29 KiB
Markdown
# 05 — Intake, booking, and data handling
|
||
|
||
Authority: `AGENTS.md` §3 D10 — rebuilt intake form plus calendar booking.
|
||
Existing infrastructure is authoritative in `AGENTS.md` §7. How it was built is
|
||
recorded in `docs/reference/AWS-Hosting-Guide.md` Parts 8–10 — a historical
|
||
record with a do-not-execute banner, superseded by §7 wherever they disagree.
|
||
**Read that guide before changing anything**; the resources already exist and
|
||
were built by hand in the console.
|
||
|
||
---
|
||
|
||
## What exists today
|
||
|
||
API Gateway (HTTP API) → Lambda → DynamoDB, with SES for notification email and
|
||
a verified sender on `smlcompany.ca`. `[verified 2026-08-26 — AGENTS.md §7]`
|
||
|
||
The shape is right. This is a hardening and rework pass, not a replacement.
|
||
|
||
---
|
||
|
||
## Build step 8, as actually built — 2026-08-31
|
||
|
||
**What is in the repository:** `/contact/` with the intake form, two
|
||
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
|
||
`backend/intake/fields.mjs` — the handler that **replaces** the hand-built
|
||
`adr-intake-handler` §7 records.
|
||
|
||
**What is NOT done, and the form does not work until it is.** Nothing on this
|
||
project deploys before cutover (D11), so: the handler is not deployed, and the
|
||
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on
|
||
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well
|
||
as the form for exactly this reason.
|
||
|
||
### The form is a plain HTML POST, and it answers 303
|
||
|
||
The site ships **zero** JavaScript (§7 — none, not "minimal"), so the form is a
|
||
`<form method="post">` and the handler replies **303 See Other** to a page on the
|
||
site. That buys three things with no script anywhere: it works with JavaScript
|
||
disabled, which is the failure this whole project exists to fix; the visitor never
|
||
sees a raw JSON body rendered as a page; and a refresh cannot resubmit, because
|
||
the browser lands on a GET.
|
||
|
||
Two pages exist for the two outcomes — `/contact/received/` and
|
||
`/contact/could-not-send/`. Both are `noindex` and both are excluded from the
|
||
sitemap in `astro.config.mjs`. **The failure page names no field**, because the
|
||
handler deliberately does not return the error list (an enumeration of the
|
||
validation rules is a gift to whoever is probing them) and because a static page
|
||
cannot read `?error=` without script.
|
||
|
||
### It posts to `/api/intake`, not to the execute-api hostname
|
||
|
||
Same-origin, with a CloudFront behaviour routing `/api/*` to the HTTP API origin
|
||
§7 records. Four consequences, and the fourth is the one that matters day to day:
|
||
`form-action 'self'` alone satisfies the CSP below; there is no cross-origin POST
|
||
to reason about; the endpoint id stays out of the HTML and out of the repo; and
|
||
**submitting the form from `astro dev` does nothing**, because there is no
|
||
`/api/` route locally. Under the alternative, clicking Submit on a laptop would
|
||
write a real DynamoDB record and send two real emails.
|
||
|
||
### ⚠️ Three deviations from this spec, each deliberate
|
||
|
||
**1. The 3-second timestamp check is NOT implemented.** It cannot be, and
|
||
implementing it would produce a control that does nothing. The check needs to know
|
||
when the form was *served to that visitor*; `/contact/` is a static file cached at
|
||
the CloudFront edge, so a build-time timestamp is the same value for every visitor
|
||
and is hours or days old. `now − served` is therefore always large, and the check
|
||
passes for a bot exactly as it passes for a human. A per-visitor token needs a
|
||
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
|
||
**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
|
||
`Access-Control-Allow-Origin` setting cannot stop another site posting a form
|
||
here. The handler compares `Origin` (falling back to `Referer`, which Firefox
|
||
sends where it omits `Origin`) against the site origin and refuses anything else.
|
||
The CORS restriction in this spec is still right — it governs *scripted* calls to
|
||
the endpoint — but it is a different control and was being relied on for this one.
|
||
|
||
**3. There is no `mailto:` fallback, because there is nothing to fall back FROM.**
|
||
This spec's definition of done asks that the form "degrades to a `mailto:`
|
||
fallback with JavaScript disabled". The form never used script, so it does not
|
||
degrade. The email address is published on `/contact/` regardless, and the failure
|
||
page routes to it.
|
||
|
||
### Two field tables, cross-checked
|
||
|
||
`src/data/intake.ts` builds the form. `backend/intake/fields.mjs` is what the
|
||
handler validates against. **The duplication is architectural**, because this
|
||
spec's own rule is that the Lambda re-validates everything: a server validating
|
||
against a list the client shipped it is asking the caller what the rules are. And
|
||
the Lambda is a separately deployed zip that cannot import from `src/`.
|
||
|
||
**`npm run check:intake` is what keeps them honest** — it imports both and asserts
|
||
they agree on every field name, on which are required, on every length cap, and on
|
||
every closed option set. Probed with three deliberate mismatches (a changed cap, a
|
||
dropped field, a changed option); each was caught, exit 1.
|
||
|
||
### Analytics: decided, not installed
|
||
|
||
D15 chose Plausible. **§7 records that no script is on any page**, and
|
||
`ANALYTICS.installed` in `src/data/site.ts` is `false`. `/legal/privacy/` renders
|
||
its analytics paragraph from that flag, so today the policy says the site sets no
|
||
cookies and runs no analytics — which is the fact. **Flipping the flag is a change
|
||
to a published disclosure**, not a config edit: the policy changes on the same
|
||
build and its last-updated date moves with it.
|
||
|
||
## What this data actually is
|
||
|
||
The form collects, in a live legal dispute: the inquirer's identity and contact
|
||
details, the names of opposing parties and their counsel, the nature of the
|
||
dispute, and often the amounts at issue.
|
||
|
||
That is **personal information about identifiable third parties who have not
|
||
consented and do not know the submission happened.** It is more sensitive than a
|
||
typical contact form by a wide margin, and it is potentially conflict-relevant.
|
||
Design accordingly. Nothing in this section is optional.
|
||
|
||
---
|
||
|
||
## Form fields
|
||
|
||
| Field | Type | Required | Notes |
|
||
|---|---|---|---|
|
||
| Name | text | yes | |
|
||
| Email | email | yes | Validated server-side, not only in the browser |
|
||
| Phone | tel | no | |
|
||
| Role | select | yes | Counsel · In-house · Party · Institution · Other |
|
||
| Firm / organisation | text | no | |
|
||
| Process sought | select | yes | Mediation · Arbitration · Med-Arb · ENE · Not sure |
|
||
| Practice area | select | yes | The six areas plus Other |
|
||
| Other parties | text | no | Surfaced for conflicts screening |
|
||
| Opposing counsel | text | no | Same |
|
||
| Matter summary | textarea | yes | 2000 char cap. Hint: *no privileged or confidential detail* |
|
||
| Timing | select | no | Urgent · 30 days · 90 days · Exploring |
|
||
| Preferred contact | radio | no | Email · Phone |
|
||
| Consent | checkbox | **yes** | Explicit, unchecked by default, links to `/legal/privacy/` |
|
||
|
||
**Do not collect** dollar amounts, document uploads, or anything the inquirer
|
||
might reasonably treat as privileged. The intake call is for that.
|
||
|
||
### Consent text
|
||
|
||
> I consent to Pouya Lajevardi storing and using the information in this form to
|
||
> respond to my inquiry and to run a conflicts check. I understand that
|
||
> submitting this form does not create a retainer, does not appoint a neutral,
|
||
> and does not itself establish a mediator–party relationship.
|
||
|
||
## Validation and abuse control
|
||
|
||
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~~
|
||
⚠️ **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
|
||
- Strip HTML from every field before storage and before it enters an email body
|
||
|
||
## Storage
|
||
|
||
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
|
||
worth stating in the privacy policy**: parties describing a live dispute are
|
||
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 | |
|
||
|---|---|
|
||
| `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
|
||
mechanism that runs whether anyone remembers or not.
|
||
|
||
⚠️ **WRITING THE ATTRIBUTE IS NOT THE MECHANISM.** The handler supplies `ttl`;
|
||
TTL must also be **enabled on the table**, and **`AGENTS.md` §7 records whether
|
||
it is — this section deliberately does not.** So the paragraph above is a
|
||
statement about the design and not about the running system until the cutover
|
||
item below is ticked on **both** halves: `ENABLED` by command, and a test record
|
||
observed to disappear.
|
||
|
||
Rationale: long enough to serve conflicts screening across a normal matter
|
||
lifecycle; short enough to be defensible under PIPEDA's requirement to retain
|
||
personal information only as long as necessary. Whatever number ships must match
|
||
`/legal/privacy/` exactly.
|
||
|
||
## Notification
|
||
|
||
SES on submission:
|
||
|
||
- **To Pouya:** the full submission, plainly formatted, replyable to the inquirer.
|
||
- **To the inquirer:** confirmation of receipt, the response-time commitment, a
|
||
repeat of the no-retainer language, and a link to the privacy policy. This
|
||
email is the reason the form beats a `mailto:` link.
|
||
|
||
**The response time is a public commitment — two business days** (§4,
|
||
Q27). Render it from `SITE.responseTime` / `SITE.responseTimeShort` in
|
||
`src/data/site.ts`; never retype it. It must read identically here, on
|
||
`/contact/`, and in any bio.
|
||
|
||
**SES production access is granted** (Q19, 2026-08-26) — mail reaches unverified
|
||
recipients, so the inquirer confirmation works. See §7 for the account state.
|
||
|
||
### Bounce and complaint monitoring
|
||
|
||
Configured 2026-08-26; the resource names, thresholds and current state are in
|
||
`AGENTS.md` §7. What matters here is why it is a real control rather than a
|
||
formality:
|
||
|
||
**At this volume a single bad address is a threshold event.** SES suspends
|
||
sending above roughly a 5% bounce rate. Under 100 messages a month, five bounces
|
||
crosses it — and an intake form is exactly where mistyped addresses arrive. The
|
||
alarms sit well below that line so there is room to react.
|
||
|
||
**Bounces and complaints are handled by SES email feedback forwarding**, which is
|
||
on by default, **not** by an SNS feedback topic. That is deliberate: at this
|
||
volume there is nothing to consume a programmatic feed, and an unused SNS topic
|
||
is one more thing to keep correct. Revisit when code needs to *act* on a
|
||
bounce — suppression lists, retry logic, marking a record undeliverable.
|
||
|
||
> **The alarms currently notify nobody.** §7 records the `ses-alerts` email
|
||
> subscription as **pending confirmation**. An unconfirmed SNS subscription
|
||
> drops every message, so until the confirmation link is clicked the alarms
|
||
> fire into nothing. This is the first thing to check if `/contact/` ships.
|
||
|
||
**Email authentication — in place as of 2026-08-26 (Q20).**
|
||
|
||
SPF and DMARC are both live and independently verified (Q20); mail is on Google
|
||
Workspace with Google DKIM configured, and the SES domain identity is verified
|
||
for sending. **The record values, the MX, and the region are in `AGENTS.md` §7 —
|
||
not restated here.** An earlier version of this spec asserted that neither SPF
|
||
nor DMARC existed; that was true when written and is no longer, which is the
|
||
whole argument for citing §7 rather than copying it.
|
||
|
||
**What is already in place.** `AGENTS.md` §7 is the record — resource IDs, DNS
|
||
records, DKIM token sets, and their verification state all live there and are not
|
||
restated here. Read §7 before touching DNS.
|
||
|
||
Two points from §7 that this spec depends on, cited rather than copied:
|
||
|
||
- **Only one of the two SES DKIM token sets resolves.** §7 names both sets and
|
||
marks which is which. The resolving set is what DMARC alignment rests on;
|
||
deleting it breaks intake mail authentication silently. The other set is
|
||
NXDOMAIN and inert. **Do not act on any DKIM list that is not §7's.**
|
||
- **SES has no custom MAIL FROM**, so SPF is unaligned and SES satisfies DMARC
|
||
through DKIM alone.
|
||
|
||
Notes that mattered when these were added, kept because they matter again on
|
||
any future edit: a domain may publish **only one** `v=spf1` record, so both senders go in one
|
||
string. Namecheap TXT values take **no surrounding quotes** — quoting them stores
|
||
the quotes literally and breaks the record.
|
||
|
||
**Correction to an earlier version of this spec.** SPF is not what authenticates
|
||
SES here. Without a custom MAIL FROM domain, SES uses an envelope sender at
|
||
`amazonses.com`, so its SPF pass is not *aligned* with `smlcompany.ca` and does
|
||
not satisfy DMARC. **SES satisfies DMARC through DKIM alignment** — that is what
|
||
the **three resolving** DKIM CNAMEs above are doing, and it already works. (Six
|
||
are present in the zone; only the `f5pu` / `jdue` / `kznn` set answers.) The SPF record's real job
|
||
is authenticating **Google Workspace** mail, which currently has no SPF at all.
|
||
`include:amazonses.com` is harmless and becomes useful if a custom MAIL FROM
|
||
domain is configured later.
|
||
|
||
Start DMARC at `p=none` — it collects reports without affecting delivery. Move to
|
||
`quarantine` only after reports come back clean. Reports arrive as XML
|
||
attachments, so filter them in Gmail, or drop `rua=` entirely and accept having
|
||
no visibility.
|
||
|
||
**Do not delete the ACM validation CNAMEs.** They are how the certificate for
|
||
`adr.smlcompany.ca` auto-renews. Removing them breaks HTTPS at the next renewal
|
||
— 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.~~
|
||
|
||
⚠️ **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
|
||
|
||
**PARKED — R6, and `/contact/` ships without it.** Pouya parked the booking tool
|
||
on 2026-08-26; build step 8 shipped the form and no embed. The "reserved slot"
|
||
`docs/01` asks for is `CONTACT.bookingUrl` being `null`: nothing renders, and a
|
||
URL there brings the block back without a rebuild of the page.
|
||
|
||
**Nothing on `/contact/` mentions booking**, deliberately — a page that says
|
||
"book a call" with no way to book one is worse than a page that says to email.
|
||
D10 committed to booking because it removes the back-and-forth that loses
|
||
appointments, so the form alone is a partial answer and R6 stays live.
|
||
|
||
An embedded scheduler for the 30–45 minute confidential intake call
|
||
(**Q5** — tool not yet chosen).
|
||
|
||
- Prefer a provider with Canadian or EU data residency and no advertising
|
||
business. Cal.com self-hosted is the strongest privacy posture; Cal.com cloud
|
||
or Calendly are acceptable.
|
||
- **Lazy-load behind a click.** No third-party iframe on first paint, and no
|
||
third-party script on any other page.
|
||
- Provide a plain link fallback that works with JavaScript disabled.
|
||
- The booking page must carry the same no-retainer language.
|
||
- Disclose the provider by name in `/legal/privacy/`.
|
||
|
||
## Security headers
|
||
|
||
> **Note added 2026-08-26 — `style-src` has acquired a dependency.** The site
|
||
> now ships inline `style="…"` attributes that are load-bearing rather than
|
||
> decorative: `InfinityMark.astro` sets its own `block-size` that way, and the
|
||
> step-1 proof sheet rendered computed swatches with it (that page was deleted
|
||
> at build step 2; the mechanism is what matters here). They are fine under
|
||
> `style-src 'self' 'unsafe-inline'` as specified below. They would **not**
|
||
> survive a move to hashed or nonce'd styles — the infinity mark would collapse.
|
||
> Price that before tightening `style-src`, and read the components first.
|
||
> `script-src` is unaffected, and has got easier: the site ships **zero**
|
||
> JavaScript, so `script-src 'self'` needs no hash and no nonce (`AGENTS.md`
|
||
> §7). That is why the reveal moved from an inline observer to CSS.
|
||
|
||
Set at CloudFront via a response-headers policy:
|
||
|
||
```
|
||
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
|
||
X-Content-Type-Options: nosniff
|
||
Referrer-Policy: strict-origin-when-cross-origin
|
||
Permissions-Policy: geolocation=(), microphone=(), camera=(), interest-cohort=()
|
||
Content-Security-Policy: default-src 'self'; img-src 'self' data:;
|
||
style-src 'self' 'unsafe-inline'; script-src 'self';
|
||
frame-src <booking-provider>; form-action 'self' <api-endpoint>;
|
||
base-uri 'self'; frame-ancestors 'none'
|
||
```
|
||
|
||
⚠️ **`form-action` IS NOW `'self'` ALONE, and that is tighter than the line
|
||
above.** Build step 8 posts the intake form to the same-origin path `/api/intake`
|
||
rather than to the execute-api hostname, so no third-party origin needs to appear
|
||
in the policy. Drop `<api-endpoint>` from `form-action` when the policy is
|
||
written. `frame-src <booking-provider>` is also unnecessary while R6 keeps the
|
||
embed parked — add it with the embed, not before.
|
||
|
||
Tighten CSP once the booking provider is chosen. `unsafe-inline` on styles is
|
||
tolerable for critical CSS; `unsafe-inline` on scripts is not — use a hash or
|
||
nonce for the reveal script.
|
||
|
||
## Privacy policy must state
|
||
|
||
Written to match what is actually built, not what is typical:
|
||
|
||
What is collected · why · lawful basis (consent) · where it is stored (DynamoDB,
|
||
region, encrypted at rest) · **the retention period and that deletion is
|
||
automatic** · who can access it · third parties involved (AWS, SES, the booking
|
||
provider, analytics if any) · how to request access or deletion and the address
|
||
to use · that submitting the form creates no retainer and no mediator–party
|
||
relationship · cookie and analytics disclosure · last-updated date.
|
||
|
||
If analytics ship, prefer a cookieless privacy-preserving tool (Plausible,
|
||
Fathom). GA4 on a page collecting legal-dispute information is a poor fit for a
|
||
practice whose privacy posture is part of its offer — D15 settles this:
|
||
Plausible or Fathom, cookieless, no consent banner.
|
||
|
||
## Definition of done
|
||
|
||
- [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
|
||
- [ ] **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
|
||
- [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
|
||
- [ ] **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`
|
||
> **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
|