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
22 KiB
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 | |
| 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.responseTimeShortinsrc/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-alertsemail 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-srchas acquired a dependency. The site now ships inlinestyle="…"attributes that are load-bearing rather than decorative:InfinityMark.astrosets its ownblock-sizethat 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 understyle-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 tighteningstyle-src, and read the components first.script-srcis unaffected, and has got easier: the site ships zero JavaScript, soscript-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
- Server-side validation independent of the client —
backend/intake/fields.mjs, cross-checked bynpm run check:intake - 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
- The form's own protection is the
Origincheck, 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 thettlattribute — epoch seconds, 24 months, confirmed against this spec[verified 2026-08-31]— butdescribe-time-to-livereturnedDISABLEDwhen 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 withAttributeName=ttl; §7 records the state and deliberately does not yet stampENABLED. The test record is what closes this, not the status:ENABLEDproves the setting, a record written with a near-futurettland 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
- 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 - Works with JavaScript disabled — replacing the
mailto:degradation item; see deviation 3 - 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 fromANALYTICS.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_TIMEandNO_RETAINER_NOTICE. It throws at cold start on any missing one, deliberately. ⚠️ This item said five while the handler required six.NO_RETAINER_NOTICEbecame arequireEnvand 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 byadversarial-reviewer, 2026-08-31. Two of the six must be verbatim fromsrc/data/site.ts, because both are published commitments:RESPONSE_TIMEfromCONTACT.responseTime, andNO_RETAINER_NOTICEfrom the constant of the same name — whose fourth clause ("does not itself create a conflict check", required bydocs/01§/contact/) a hand-typed copy in the handler had dropped