Files
adr-sml/docs/05-backend-spec.md
T
Pouya LajevardiandClaude Opus 5 4735989f0b feat: cut /legal/privacy/ §Who can see it to four plain statements; name SML Company Ltd on the consent; close Q64 moot
Two rulings from Pouya, 2026-09-02.

(1) The section stays generic — "it over-explains technical mechanics that
belong in the evidence file, not in front of an inquirer." Deleted: the
measurement paragraph, the root-credential sentence, the SSO/federated-login
enumeration, the resource-policy clause, the "company that runs a database"
aside, the deploy-credential sentence and the three-copies summary. All of it
stays true and stays measured in AGENTS.md §7 and the evidence file, which now
maps each shipped sentence to what it rests on.

(2) The consent string names the corporation: "I consent to SML Company Ltd
storing and using the information in this form…". docs/05 §Consent text moves
with it, proven byte-identical. Two new §4 rows carry the attestations the copy
rests on.

(3) The §Who can see it approval closes via the page read-through, which is now
blocker 2 in docs/06's callout rather than a checklist line.

Q64 closes MOOT — the paragraph it was about was deleted, so it gates nothing.
The underlying gap is unchanged: §7 records root as held by Pouya, not held only
by Pouya, and nothing about root custody may be published without asking again.

Two sentences were added back under review: the shared-account disclosure, to
§Where it is stored (a storage disclosure, never named in the ruling — without
it no page said the intake sits in a shared account), and one naming SML Company
Ltd in the policy, because a consent naming a company the linked policy never
mentions is an accountability gap.

adversarial-reviewer, two rounds, 14 findings, all resolved, none declined;
nine of round 2's ten were defects in round 1's own repairs. claims-auditor
correctly deferred to cutover per D20.

Gates, exit status read: check 0 · build 0 (23 pages) · check:claims 0
(12 patterns, 33 approved strings) · check:intake 0 · og:proof 0 · lint 0 ·
lighthouse 0, worst of 23 99/100/100/100. Tripwire proven both ways — exit 0 on
the revised page, exit 1 with 5 matches on the bd282aa bytes. Regex untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 12:03:47 -04:00

474 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 810 — 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 SML Company Ltd 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 mediatorparty 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~~ — ⚠️ **AMENDED 2026-09-02: that purpose holds for `userAgent` and `submittedAt`, and NOT for `sourceIp`.** Behind the `/api/*` behaviour `requestContext.http.sourceIp` is a CloudFront edge, so it identifies the network rather than the sender and cannot serve an abuse investigation. `/legal/privacy/` now states the two purposes separately — timestamp and user-agent for abuse, the address as something that simply arrives with the request. **A spec and a page disagreeing about WHY data is held is the disclosure PIPEDA actually turns on**, and this row said one thing while the page said another for a day. `docs/09` Part 7.2 measures the field; if it holds the reader's own address, this row and that paragraph both change. `adversarial-reviewer`, round 2 |
| `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.** ⚠️ **THE THIRD LINE HERE WAS *"table access limited to the Lambda role and
one named administrative principal"*, AND IT WAS THE Q62 FALSEHOOD — struck
2026-09-02.** It is false on both halves: `adr-intake-lambda-role` holds
`PutItem` **only** and cannot read the table at all, and access is not one
principal. **`AGENTS.md` §7's `Intake table — who can read it` row is the answer
and this spec does not restate it** — a duplicated fact is one that goes wrong in
the copy nobody re-reads, which is what happened here: the Q62 sweep ran over
`src/` and never reached a spec, and `check:claims` carries this exact sentence
as a string that reached `dist/`. It survived the sweep this file's own
definition-of-done claims to have completed (`adversarial-reviewer`, round 2).
⚠️ **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 3045 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 mediatorparty
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
- [x] ✅ **Table access matches what `/legal/privacy/` says about it — 2026-09-02.** **The access is unchanged; the page now states it.** Pouya ruled *state the truth* rather than *remove the access* (§9 Q62), so the page states the truth about access rather than a false exclusivity. ⚠️ **WHAT IT STATES CHANGED TWICE MORE THAT DAY AND THIS LINE IS WRITTEN AGAINST THE SHIPPED BYTES, NOT AGAINST THE RULING.** §9 Q63 took the human headcount off (a simulation counts identities and the page was reading them as people), and a second ruling then cut §Who can see it to **four plain statements**. The page now says: *"The record in the table: me, and the small number of people who administer the account it sits in with me"*; that the receiving system *"can only add a record — it cannot read back what is stored"*; where the notification goes and who reads it; and that the confirmation sits with the reader's own provider. **§Where it is stored carries the shared-account disclosure** — *"an Amazon Web Services account that also runs systems unrelated to this practice"*. ⚠️ **`adr-sml-deploy` is `implicitDeny` on all seven read AND write actions — MEASURED, TRUE, AND NO LONGER ON THE PAGE**; it went with the mechanics cut and it is §7's claim now, not the policy's. Do not tick this item against a page that states it. Evidence and commands: `docs/reference/intake-table-access-verification.md`, whose enumeration was **extended on 2026-09-02** — the original screened roles by `list-attached-role-policies` alone, missing that 23 of 26 non-service-linked roles carry inline policies and that the two CDK `lookup` roles can read the table. Four roles can, not two; every one of them is reachable only by those administrators. ⚠️ **Do not restate that as a count of PEOPLE** — this line said *"all four terminate at the same two people"* until 2026-09-02, which is the inference §9 Q63 struck. ⚠️ **THIS LINE SAID "IT DOES NOT" FOR A DAY AFTER THE PAGE WAS CORRECTED, AND IT IS A DEFINITION-OF-DONE LIST SOMEONE FOLLOWS AT CUTOVER** — the Q62 sweep was run over `src/` only, so it could not reach a spec. `adversarial-reviewer`, round 1. The sweep across `docs/` is in the Change Log entry
- [ ] 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