Two of Pouya's rulings of 2026-08-31, committed as one tree at his instruction
("as-is") because AGENTS.md Change Log entry (ai) covers both and splitting it
would mean rewriting the record rather than moving it. This is a deliberate
departure from one-logical-change-per-commit, recorded here rather than left to
be inferred.
THE EYEBROW. `--text-eyebrow: 0.8125rem` added to tokens.css; `.eyebrow` in
global.css retargeted to it. One edit site, which is what the design system
claimed. 13px is not a rung on the modular ladder — it sits between xs and sm
deliberately, because uppercase mono at 0.18em tracking reads smaller than it
measures. `--text-2xs`'s comment stopped calling itself the eyebrow floor.
The rendered sweep (22 pages × 2 widths, 838 mono elements measured over CDP,
not grepped) found exactly one escape and one deliberate override:
- h2.footer-heading, 176 instances — an ESCAPED EYEBROW. Five declarations
byte-identical to `.eyebrow`, differing only in colour. Consolidated to
`class="eyebrow footer-heading"`; the scoped rule is now colour + margin.
The colour is load-bearing, not decorative: `.eyebrow`'s own `--text-meta`
on ink is 3.07:1 and fails.
- span.eyebrow.brand-tagline, 21 instances — HELD at `--text-2xs`. Measured:
at 13px the header grows 81 → 83.4px while `--header-h` is pinned at 81 and
drives `scroll-padding-top`; and with a seventh nav item the CTA lands past
`.header-inner`'s content edge by 42px at 1216, 18px at 1240, 26px at 1280
and 1440. Document overflow is 0 in all of those, so no page-level check
can see it. Insights is that seventh item.
Everything else mono-uppercase is a genuinely different component and was left:
the 0.06em `--tracking-wide` family, the 14px mixed-case designation strip, the
/bio/ print sheet, /contact/'s form labels.
Measured after: contrast unchanged on all 817 instances (11.09 / 8.11 / 5.47 /
5.01:1, all pass at 13px, which is still normal text and needs 4.5:1). Zero
document overflow and identical header geometry at 15 widths. Two eyebrows gain
a line below 414px — /'s hero, already wrapping at 320px before this, and
/insights/'s empty state at 320px only. Accepted, not re-tuned.
docs/02's type spec moved 11–12px → 13px and now enumerates the three 11px
carve-outs instead of implying there are none. The /type-scale/ proof sheet
(d) asked for no longer exists — deleted at build step 2 — so the spec prose is
the proof sheet now.
THE TTL. backend/intake/handler.mjs CONFIRMED to match `AttributeName=ttl`: it
writes `ttl` as a Number, in epoch seconds, at RETENTION_MONTHS = 24. Nothing
needed changing for the enable command.
Removed `|| 0` from the TTL computation. DynamoDB does not expire an item whose
TTL is more than five years past, so `ttl: 0` meant RETAINED FOREVER while
/legal/privacy/ promises deletion — a fallback whose failure mode was the exact
inverse of the claim it was protecting. Unreachable in practice, which is why
it would never have been noticed. A bad value now fails the write.
§7 records TTL as DISABLED at first verification, so the privacy policy's
automatic-deletion promise was unbacked from the moment it was written. §7 is
the only place that status lives; docs/05 and docs/06 carry the constraint and
cite §7, because round 2 of review caught this change set reproducing the SES
DKIM defect — five copies of a status that is about to be re-stamped.
Added, and these are the gate: `TODO(pouya)` on /legal/privacy/'s retention
section, §9 Q60, §12 R19. The page does not publish a period, it asserts a
MECHANISM — deleted by the database rather than by someone remembering — and
nothing in the toolchain can see that. check:claims is frozen with no pattern
for it and deploy does not read docs/06. The copy was NOT softened: it is about
to be true, and weakening a privacy commitment to make it defensible is the
move the rules forbid. What was missing was the gate, not the caveat.
Reviewed by adversarial-reviewer, two rounds (D19 cap), 15 findings, all
accepted, none declined. claims-auditor did not run — D20. Round 1's findings
were almost entirely in prose written that session, and round 2's blocking
finding plus its sharpest should-fix were both defects in round 1's own fixes.
public/pouya-lajevardi-bio.pdf is deliberately NOT in this commit. It was
regenerated and reverted: /bio/'s eyebrow sits inside `.no-print`, so the sheet
has no eyebrow at all, and `cmp -l` showed exactly 10 differing bytes, all in
/CreationDate and /ModDate.
Gates, every one read as an exit status and none through a pipe: check 0,
build 0 (22 pages), check:claims 0, og:proof 0, check:intake 0, lint 0,
minifier tripwire clean, TODO in dist 0. Lighthouse run twice with identical
output: perf 99 on / and 100 on the other 21, a11y 100, best practices 100,
SEO 100 on every indexable page, CLS 0.000, LCP 1.50–2.03s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
385 lines
22 KiB
Markdown
385 lines
22 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 — **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.** 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.
|
||
|
||
⚠️ **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.
|
||
|
||
## 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, AND THE SETTING WAS MEASURED OFF ON 2026-08-31.** `/legal/privacy/` states that records are deleted automatically after 24 months. The handler writes the `ttl` attribute — epoch seconds, 24 months, confirmed against this spec `[verified 2026-08-31]` — but `describe-time-to-live` returned **`DISABLED`** when Pouya first ran it, so the page promised a mechanism that was **not running**, from the day it was written. Enabling was initiated the same day with `AttributeName=ttl`; §7 records the state and deliberately does not yet stamp `ENABLED`. **The test record is what closes this, not the status:** `ENABLED` proves the setting, a record written with a near-future `ttl` and observed to vanish proves the behaviour
|
||
- [ ] 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
|