Build and deploy / build-and-deploy (push) Failing after 4s
D19 caps the loop at two rounds, and this is what the second round is for. BLOCKING. Round 1 made NO_RETAINER_NOTICE a requireEnv and added it to no document, while the fix's own comment claimed docs/06 named it. The deployment list said five variables for a handler that needs six, so an operator following the cutover checklist would have deployed a function that throws at cold start on every invocation — 5xx from API Gateway, every inquiry lost from the moment /api/* was wired, loud in CloudWatch and silent to Pouya. docs/05 and docs/06 now name all six, and the comment that asserted the documentation existed is corrected rather than deleted. The intake route check added in round 1 could not fail: curl -w already prints 000 on a failed transfer, so `|| echo 000` double-appended and the failure arm was unreachable, and the pass arm accepted anything that was not literally 404 — including the 403 CloudFront returns when the /api/* behaviour is missing, which is the one distinction the check exists to draw. It now sends the correct Origin and asserts a positive: 303 to /contact/could-not-send/, which the handler returns before any DynamoDB write or email. Probed on refused/501/403/303; the old version passed the first three. Fixed in both deploy paths. Removing priceRange left three statements saying it was present or pending, one of them the stated reason /fees/ emits no Offer node. Deleting overtimeStartsAfterSessionHours left AGENTS.md §9 naming it and left Q59 recorded as open. The Google-as-processor fix was applied to the privacy policy's "Where it is stored" and not to "Who can see it", which still read "Nobody else has access". And the variable removal was justified with a path-scoped git grep — which also cannot see untracked files. The unscoped sweep found docs/06's variable table, the OIDC example, and .env.example still carrying them; .env.example also restates the execute-api hostname, falsifying a live claim in intake.ts that has been corrected. That file is not edited here: this environment denies read access to it, and nothing may edit a file it cannot read. It is in the batched list. Also: og:image:alt was the page title rather than the card's headline on 20 pages; og-card.ts documented the wrong path and invocation for the contact sheet; deploy-local.sh still said Q22's deploy credential "does NOT yet exist"; and the round-1 fix comments were trimmed per D19, though the ratio held at 0.44. Round 2 also confirmed the round-1 fixes by measurement: all 56 .btn instances across 22 pages, the consent checkbox's computed accessible name, the radio labels hit-tested at 44px, and og:proof exercised against synthetic article pages in a sandbox. Verified: check/build/check:claims/og:proof/check:intake/lint/bio:pdf all exit 0 on a clean build; 22 pages; Lighthouse 99-100 / 100 / 100 / 100, CLS 0.000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
378 lines
21 KiB
Markdown
378 lines
21 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
|
||
API Gateway rate limit and server-side validation.
|
||
|
||
**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
|
||
- **Rate limit** by source IP at API Gateway: 5 requests / 5 minutes
|
||
- 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.
|
||
|
||
| Attribute | |
|
||
|---|---|
|
||
| `pk` | `INTAKE#<uuid>` |
|
||
| `sk` | `<ISO-8601 timestamp>` |
|
||
| fields | as above |
|
||
| `sourceIp`, `userAgent` | abuse investigation only |
|
||
| `ttl` | epoch seconds — **automatic deletion** |
|
||
|
||
**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.
|
||
|
||
### Retention
|
||
|
||
**24 months, enforced by DynamoDB TTL.** Not a policy someone remembers — a
|
||
mechanism that runs whether anyone remembers or not.
|
||
|
||
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.
|
||
|
||
## 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
|
||
- [ ] **Rate limit configured** — API Gateway throttling, 5 requests / 5 minutes per source IP. Not expressible in handler code; not done
|
||
- [x] **The form's own protection is the `Origin` check, not CORS** — see deviation 2. CORS on the endpoint still to be restricted for scripted calls
|
||
- [ ] **TTL set and verified by test record.** ⚠️ **THIS ONE BACKS A PUBLISHED PROMISE.** `/legal/privacy/` states that records are deleted automatically after 24 months. The handler writes the `ttl` attribute; TTL must also be **enabled on the table**, and §7 does not record that it is. Until this is verified the page promises a mechanism that may not run
|
||
- [ ] KMS customer-managed key and PITR enabled. **Neither is claimed on `/legal/privacy/`** — the page says "encrypted at rest", which is true of every DynamoDB table unconditionally, and does not mention either of these because §7 does not verify them
|
||
- [ ] 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
|
||
- [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
|
||
- [ ] **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
|