Files
adr-sml/docs/05-backend-spec.md
T
Pouya LajevardiandClaude Opus 5 6bf1167624 fix: sweep D3 amendment through the specs; correct inverted DKIM table
The re-audit of the deploy-guard change surfaced defects well outside the
diff, including one that would have broken production mail.

docs/05-backend-spec.md had the two SES DKIM sets exactly inverted, labelling
the three records that resolve as "orphans" and the three NXDOMAIN records as
"Live. Never delete". Entry (j) corrected this in AGENTS.md §7 and the
correction never reached docs/05. Since SES has no custom MAIL FROM, DKIM is
the only thing satisfying DMARC, so acting on that table would have silently
broken intake mail authentication.

Also in this change:

- .gitea/workflows/deploy.yml gains a guard as steps[0] that fails the run,
  naming the variable, if AWS_REGION, S3_BUCKET or CLOUDFRONT_DISTRIBUTION_ID
  is empty — how a Gitea too old for the vars context manifests. Verified
  fail-closed under bash -e, sh -e and bash -euo pipefail.
- AGENTS.md Current Truth: SPF and DMARC recorded as present (Q20), the
  matching §10 High risk row retired, three duplicate Q rows removed.
- docs/reference/AWS-Hosting-Guide.md tracked and given a do-not-execute
  banner; it was an executable procedure for the architecture D1/D3 replace.
- Copy decks: "a working litigator" and "an active litigation practice"
  replaced with the register's own wording; LegalService JSON-LD replaced with
  ProfessionalService; tribunal-secretary offers removed per D14; nine stale
  question blockers swept.
- astro.config.mjs: prefetchAll disabled — it injected JS into every page
  against the zero-JS convention with no decision recorded.
- src/data/site.ts: unregistered response-time commitment nulled (Q27);
  OBA section names downgraded to [assumed] (Q28).
- s3:AbortMultipartUpload reasoning corrected to measure ./dist, not the repo.

Opens Q27, Q28, Q29. AGENTS.md entry (q) records the full resolution,
including the findings declined and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 11:28:42 -04:00

226 lines
11 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.
## 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 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
- **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, `ca-central-1` — **Canadian data residency is a real selling point for
a Canadian legal practice, and the privacy policy will say so.** Confirm the
existing table's region and migrate if it is elsewhere. `AGENTS.md` §7 records
`adr-intake-submissions` in `ca-central-1` `[verified 2026-08-26]`.
| 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.
**Email authentication — in place as of 2026-08-26 (Q20).**
SPF and DMARC were both added by Pouya and independently verified on
2026-08-26. Mail is on Google Workspace (MX `1 smtp.google.com`) with Google
DKIM configured, and the SES domain identity is verified for sending in
`ca-central-1`. **An earlier version of this spec said neither record existed;
that was true when written and is no longer.** The records now live are:
| Host | Type | Value |
|---|---|---|
| `@` | TXT | `v=spf1 include:_spf.google.com include:amazonses.com ~all` |
| `_dmarc` | TXT | `v=DMARC1; p=none; rua=mailto:info@smlcompany.ca; fo=1` |
**What is already in place** (Namecheap DNS and the SES console, both inspected
2026-08-26):
| Record | Status |
|---|---|
| SES DKIM — `f5puwearz…`, `jdue2r22c…`, `kznn3cklv…` `._domainkey` | **LIVE. Never delete.** All three resolve (NOERROR) and back the healthy `ca-central-1` SES identity — `DkimStatus: SUCCESS`. These are the records DMARC alignment rests on `[verified 2026-08-26 — DNS, AGENTS.md §7]` |
| SES DKIM — `3zsnvsjg…`, `jejgp7na3…`, `xpiwyftpo…` `._domainkey` | **BROKEN and inert.** Entered into Namecheap with the full name in the Host field, so the zone doubled the domain; they answer NXDOMAIN at the correct name. They belong to a stray `us-east-1` identity this project does not use. Harmless where they are — leaving them is the low-risk choice (Q21) `[verified 2026-08-26 — DNS, AGENTS.md §7]` |
| `google._domainkey` TXT | Google Workspace DKIM. Never delete |
| Two CNAMEs → `jkddzztszm.acm-validations.aws` | ACM certificate validation. **Never delete** — breaks HTTPS at the next renewal |
| `adr` CNAME → `d26v23dhgsp2ta.cloudfront.net` | The site |
| Custom MAIL FROM | **Not configured.** Optional; would add SPF alignment |
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 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
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 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
- [ ] 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