Answers four questions and starts build step 1. Q22 — the scoped deploy user does not exist: aws iam get-user returns NoSuchEntity. Recorded in §7 as NOT PROVISIONED and swept so that no file describes it as existing. §10 records that user/pouya, the broadly- permissioned personal user that has been authenticating to this account, must never be used in CI; scripts/deploy-local.sh refuses to run as it. Q23 — the Gitea instance reports 1.27.2, well above the vars-context floor, so the first-step guard is belt-and-braces rather than load-bearing. What remains is not a fact but a dependency: the instance is jointly administered, so enabling Actions and registering a runner both need a second admin. Hence npm run deploy (scripts/deploy-local.sh), which performs exactly what the workflow performs — same guard, same three passes, same headers, same invalidation. Documented as the current path, not as a workaround. §10 gains the risk that follows: the deploy secret will live on jointly administered infrastructure, where an instance admin can reach repo secrets. That does not change the plan, but it makes the scoped IAM policy the actual control between a shared Gitea instance and an AWS account holding another business's client-database backups. Never widen it. Q27 — response time is two business days, in site.ts with a derived short form so the confirmation email cannot drift from the page. Q28 — OBA sections confirmed, stamped "for now"; membership renews yearly, tracked as R10. Build step 1: dependencies installed and package-lock.json created, closing the npm ci blocker. ESLint flat config and Prettier config added; npm run lint, check and build all pass. Prettier deliberately excludes *.md and tokens.css — reasons recorded in .prettierignore. npm audit reports 7 high-severity advisories, all requiring an Astro major upgrade. Not applied; escalated in AGENTS.md entry (s) as a decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
12 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.
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 — 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.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
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