Pouya's rulings and the page are one commit, not two, because they are not separable: Q37 changed the credential label the page renders, Q39 scoped the arbitration copy, and Q35 gave Med-Arb the row that lets the footer link stand. Splitting them would produce a commit where the register says one thing and the page says another. RULINGS Q39 — ANSWERED, and my assertion was FALSE as a universal. Pouya checked rather than defended. Family arbitration in Ontario is gated: prescribed training, 14h on screening for domestic violence and power imbalances, 30h of Ontario family law for arbitrators outside the bar, 10h ongoing per two years. claims-auditor produced that counter-example from inside the repo on 2026-08-26 and it was right. The source is now IN the repo per R14 — docs/reference/ontario-family-arbitration-training.md, retrieved with its provenance — and §4 states which half it establishes: the family gate directly, the commercial half only by absence. So "commercial arbitration in Ontario requires no licence and no designation" is recorded as Pouya's stated position, unstamped. What disposes of the question is the scope exclusion: he does not do family arbitration, so it is NOT OFFERED and the gate never bears on the practice. The false universal survived in three more live places, found by grep, not recall: §4's NOT-NEGOTIABLE boundary bullet (the boundary paragraph breaching its own boundary), §9's Q33 closure row, and a comment in SiteHeader.astro. Q35 — ANSWERED, all four items, and the answer supplied a gate that was missing. Med-Arb gets an Offerings row and stays in the footer sitemap. The six subject-matter labels pass test 1. §4 gains "Subject-matter areas — the publication gate": competence to accept an appointment, AND a page that frames it as positioning without claiming history. Nothing in the nav or footer is unrowed any more. Q37 — ANSWERED. "Law and engineering" becomes "Legal training and engineering practice". A degree is not a practice; the parallel was doing the implying. Q38/R13 — the committed SVG does not close it; the walk-back went too far. It renders faithfully BECAUSE it is the raster. R13 stays open. R1 — surfaced and acknowledged; the interim licensure framing is now carried by a shipped page rather than a spec, which raises the stakes. BUILD STEP 2 Seven of docs/01's eight home sections, six new components, zero JavaScript. Section 7 (Latest insights) ships at step 7 with the collection it lists — recorded in docs/01 and in the page, not just here. Four credential slots, not three: §4's paired-disclosure condition requires the Q.Arb stage on any page that offers arbitration. No booking link (R6). The masthead tagline is suppressed on / (it duplicated the hero eyebrow). The step-1 proof sheet is deleted, and five live references to it were found by grep. WHAT THE REVIEWS FOUND — 24 findings across the two passes claims-auditor FAILED it on ten, every one implication or scope rather than fabrication, which is where D13 says the risk lives. The four that mattered: "I mediate and arbitrate" asserted a track record §4 does not hold for arbitration; the JSON-LD asserted arbitration twice and stated the stage nowhere (a crawler-only claim is still a claim); "at one published rate with preparation time included" misdescribed money against docs/07's two day rates and capped prep allowance; and "Law and engineering are not blended here" was Q37's struck parallel relocated into prose one day after Pouya struck it. It also found the Canadian Tax Foundation missing from §9 Q8 — a stale second copy of a fact that would have bitten at step 3. adversarial-reviewer found fourteen, three blocking. The first was class="section-head" on <SectionHeading> never matching — the parent-cannot- style-a-child defect for the FOURTH time, written into a diff where I had just added fresh warnings about it to two other components. Measured: 0px margin, 0px gap, headings over the card edges, with astro check and eslint both clean. I had looked at a screenshot of that section and passed over it. Fixed with a page-owned wrapper (48px, measured) and the prop is deleted from six components so it is now a build error. Also: the credential row was never "two up on a phone" and its comment said it was; PROCESS was hardcoded in the page against the reason written in site.ts; 83px of residual overflow at a 200% default font size, now 3px. Seven more I found myself first, including <Picture widths> declaring the untouched 1600px master as the <img src> fallback (254,626 B for a 476px slot, and the build log said "before: 349kB" either way), and a prop named `as` silently turning off type-checking for a whole component. VERIFICATION — run, not asserted. Full figures in AGENTS.md entry (w). npx tsc 6.0.3 · check 0/0/0 · lint clean · build clean · audit 0 1 <script> and it is JSON-LD; non-JSON-LD scripts 0; no JS bundle; identical page with script execution disabled (444 nodes, 6,578 chars) Phase 5 minifier check: no `animation` shorthand beside animation-timeline overflow 0 at 14 widths, AND every mark measured at 1.5654-1.5657 vs 1.5657 one h1, no heading skips, focus order == DOM order across 44 focusables 31 painted contrast pairs at 3 widths, 0 failures 72/72 hit-test points across 8 cards resolve to the card's link print 0 hidden, reduced-motion 0 hidden Lighthouse NOT RUN — tool unavailable until step 7 (R11) HTML validator NOT RUN, and 4 of 12 srcsets carry a duplicate 1x descriptor Opened for Pouya: Q40 (one OG image for nineteen pages), Q41 (does Q37 reach prose; may the comparative be restored; what LAT pre-hearing mediation means), Q42 (the four "also offered" processes have no row), Q43 (the process timings are published commitments with no row). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
266 lines
13 KiB
Markdown
266 lines
13 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, 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
|
||
|
||
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'
|
||
```
|
||
|
||
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
|