Q19 is closed — SES production access granted in ca-central-1, confirmed in writing. Nothing now blocks /contact/. The structural change is the important one. Specs in docs/ carried their own copies of resource IDs, regions, DNS records and service state. AGENTS.md §7 is now the single source of truth for operational facts and docs/ cite it rather than restating it, with the rule recorded in CLAUDE.md under Conventions. The reason is the previous commit's DKIM inversion, generalised: the same fact lived in §7 and docs/05, a correction reached one of them, and the stale copy told an operator to delete the records that authenticate outbound mail. A duplicated fact is one that will eventually be wrong in one place, and the copy that goes stale is the one nobody re-reads. Verified by grep over docs/*.md — no operational identifier remains. Also in this change: - §7 records the SES monitoring: SNS topic ses-alerts, alarms SES-BounceRate-High (>= 0.03) and SES-ComplaintRate-High (>= 0.001), and the deliberate choice of email feedback forwarding over an SNS feedback topic at this volume. The ses-alerts email subscription is stamped PENDING CONFIRMATION — the alarms currently notify nobody, now tracked as R9 and on the cutover checklist. - docs/05 records why those alarms are a real control: SES suspends above roughly a 5% bounce rate, and under 100 messages a month five bounces crosses it. - Q29: the deploy guard now covers AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (emptiness only, never echoed) and INTAKE_ENDPOINT, promoted to job-level env. An empty intake endpoint ships a live form posting to nothing, which is worse than a failed build. Executed under sh -e across four input states; fails closed, leaks nothing. - docs/06: account ID removed from the backup-bucket callout, pointing at §10 instead, as README already does. - astro.config.mjs: prefetch removed entirely. Any setting ships Astro's prefetch script to every page against the zero-JS convention. Recorded as a decision; revisit against real Lighthouse numbers. AGENTS.md entry (r) records the full reasoning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
249 lines
12 KiB
Markdown
249 lines
12 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.
|
||
|
||
## 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, expected response time, 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.
|
||
|
||
**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
|
||
|
||
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
|
||
|
||
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'
|
||
```
|
||
|
||
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
|
||
|
||
- [ ] Server-side validation independent of the client
|
||
- [ ] Honeypot and timing checks live; rate limit configured
|
||
- [ ] CORS restricted to the production origin
|
||
- [ ] TTL set and verified by test record
|
||
- [ ] KMS encryption and PITR enabled
|
||
- [ ] Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
|
||
- [ ] DLQ and CloudWatch alarm configured
|
||
- [ ] Form usable by keyboard only; errors announced with `role="alert"`
|
||
- [ ] Form degrades to a `mailto:` fallback with JavaScript disabled
|
||
- [ ] Privacy policy matches the implementation line for line
|