Files
adr-sml/docs/05-backend-spec.md
T
Pouya LajevardiandClaude Opus 5 3c3ba5dc6e
Build and deploy / build-and-deploy (push) Failing after 4s
feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam
Pouya's rulings of 2026-09-03 (the last two D20 findings) and 2026-09-04 (the
spam observation and four mitigations), in one change set.

D20 finding 10 — med-arb is billed BY PHASE, each phase at the rates already
published, so /fees/'s "Every figure is on this page" is true as written rather
than narrowed. FEES.medArb is the single source; docs/07 §Med-arb carries the
rule INTERIM against R5, and R5 now carries it back, because a derived price
moves silently when a rate moves.

D20 finding 13 — conduct undertaking (g), attested 2026-09-03, published as his
wording verbatim on /legal/privacy/ and /contact/. The clause that raised the
finding promised to DISCLOSE a conflicts check's outcome, which the attestation
does not cover; it is struck. D20 now partitions 17 fixed / 2 refuted / 1 owed.

Spam, 2026-09-04 — recorded in docs/05 §Observed abuse with the date and
signature. A second honeypot (a decoy checkbox, own class, `hidden`, a label
that tells a human not to tick it) and scoring that LABELS and never rejects:
nothing is dropped, nothing new is stored, and only the operator notification
changes. Q65 opens the WAF cost call.

The timing floor could not be built: there is no timing check and never has
been. docs/05 carries it struck, and every mechanism that would give a real
per-visitor clock breaks zero-JS, handler-and-form-only, or D1. Q66.

configure.mjs gains section 5 — a custom origin request policy forwarding
CloudFront-Viewer-Address on /api/*. Written, dry-run against the live
distribution, NOT applied. It reads the handler's own header reads and refuses
to run if the whitelist omits one.

And reading the live account to do it found four AGENTS.md §7 rows saying the
intake backend was undeployed, two days after it went live — corrected against
get-function-configuration, get-routes, get-stage, get-policy and the deployed
zip, which was downloaded and read.

Review: adversarial-reviewer only (claims-auditor is D20's cutover pass and has
run). Round 1 five lenses, 56 findings, 7 blocking, 4 refuted by an independent
refuter; round 2 four lenses, 36 findings, 33 of them defects in round 1's own
repairs. Stopped at two per D19.

Gates, exit status read for each: check 0 · build 0 (23 pages) · check:claims 0
· check:intake 0 · og:proof 0 · lint 0 · spam-score.test 39/39 with 6/6 mutations
killed · router.test 30/30 · minifier grep 1 (clean) · lighthouse 0, no category
below 95 · configure.mjs dry run 0, nothing written.

Nothing deployed and nothing applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-04 10:06:37 -04:00

41 KiB
Raw Blame History

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.


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 + backend/intake/spam-score.mjs — the handler that replaced the hand-built adr-intake-handler §7 records.

🟢 IT IS ALL LIVE AS OF 2026-09-02, AND THIS PARAGRAPH SAID THE OPPOSITE UNTIL 2026-09-04. It read "the handler is not deployed, and the CloudFront /api/* behaviour the form posts to does not exist" — true when written under D11, false from the moment docs/09 Parts 3, 5 and 6 ran at cutover, and two days stale in the document an implementer reads before touching the handler. Measured 2026-09-04: the function carries handler.handler with six environment variables and its two source entries are byte-identical to commit 02739ad; the API has exactly one route, POST /api/intake; §7 holds the full state and this spec does not restate it. /contact/ still publishes the email address beside the form, which is now a courtesy rather than a fallback.

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 aggregate API Gateway route throttle and server-side validation. (Aggregate, not per-IP — see §Validation. "Rate limit" was the wording here and let the reader supply the stronger meaning.)

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.

I consent to SML Company Ltd 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
  • Second honeypot — a hidden CHECKBOX that must arrive absent, added 2026-09-04. A different trap, not a second copy: the first catches a bot that fills every text input, this one catches a bot that sets every control it enumerates. ⚠️ IT IS PROBABLY INERT AGAINST THE TRAFFIC THAT PROMPTED IT — see §Observed abuse, which retracts in full the argument this bullet made for one round ("which anything reaching validation must do, because the consent box is required and unchecked by default"). The retraction was written sixty lines below this bullet and did not reach it. Unchecked sends nothing, so absence is the pass — and so is an empty value, because the handler tests for a non-empty one rather than for presence: no dropped-field path and no blind form serialiser can turn it into a lost inquiry. It carries its own wrapper class (not the first honeypot's), the hidden attribute as well as the CSS rule, and a label that tells a human not to tick it — see src/pages/contact.astro, where each of the three is a correction rather than a precaution
  • Spam SCORING that labels and never rejects, added 2026-09-04. See §Observed abuse. It changes the operator notification's subject line and nothing else
  • Timestamp check — reject submissions completed in under 3 seconds ⚠️ STRUCK, and it was recorded as unimplementable in three other places while this line stayed an unqualified imperative — the handler's header, §Three deviations above, and the definition of done below. §Three deviations has the reasoning: /contact/ is a CDN-cached static file, so a build-time timestamp is the same value for every visitor and now served is always large. This is the unstruck-imperative shape CLAUDE.md names — and it survived in the same list whose sibling bullet was struck correctly, which is the sweep failure exactly. Found by adversarial-reviewer round 2
  • Rate limit by source IP at API Gateway: 5 requests / 5 minutes ⚠️ STRUCK 2026-09-01: API GATEWAY CANNOT RATE-LIMIT BY SOURCE IP, SO THIS ASKED FOR A CONTROL THAT CANNOT BE BUILT WHERE IT SAYS TO BUILD IT. HTTP API throttling is aggregate — a rate and a burst, per route and per stage, across all callers. Per-IP limiting needs AWS WAF with a rate-based rule on the distribution, which is a paid service and therefore a decision rather than a step. What ships instead is the aggregate throttle (docs/09-cutover-runbook.md Part 6.3), and it must never be described as per-IP. This is deviation 1's own argument turned on this spec: "a control that exists on paper and not in fact is worse than a stated gap" — the throttle is real and bounds total volume; the per-IP claim was neither
  • No CAPTCHA. It is a third-party script on a page collecting legal information. ⚠️ THIS BULLET USED TO END "and the two controls above stop the traffic that matters", WHICH THE FIRST REAL SPAM FALSIFIED — see §Observed abuse. The reason to keep CAPTCHA out is unchanged and stands on its own; the claim that what ships is sufficient was an untested prediction and has been removed rather than reworded
  • CORS restricted to https://adr.smlcompany.ca — no wildcard
  • Strip HTML from every field before storage and before it enters an email body

Observed abuse

First real-world spam: 2026-09-04. Two automated submissions, 10:51Z and 12:16Z, submissionId prefixes 50cda580… and e3e21122…. Recorded here rather than in the Change Log alone because this section's controls were specified against an imagined attacker and this is the first measured one.

Both passed the honeypot, and neither was stopped by anything else that ships: the aggregate route throttle is 1 request/second with a burst of 5 (AGENTS.md §7), and two submissions ninety minutes apart are nowhere near it.

The signature, as Pouya recorded it:

Names random
Email random Gmail addresses — one using the dot trick
Phone Russian format
Organisation big-brand names
Dispute summary scraped text

⚠️ THE HONEYPOT WAS NOT DEFEATED BY CLEVERNESS — IT WAS NOT ENGAGED. A bot that submits only the fields it recognises never touches a decoy text input.

🛑 AND THAT CUTS BOTH WAYS. THE SECOND HONEYPOT IS PROBABLY INERT AGAINST THIS PAIR, AND THIS SECTION CLAIMED THE OPPOSITE FOR ONE ROUND. It said the checkbox "is aimed at a behaviour the traffic must have", reasoning that the consent box is required so anything that validated must have been ticking checkboxes. Sending consent=on shows only that it knows one field name. A bot selective enough to skip a hidden text input is selective enough to skip a hidden checkbox, and the same evidence that explains the first honeypot's silence predicts the second's. It is defence in depth against a different and common class — the bot that enumerates controls and sets all of them — which is worth adding and is not a counter to what was observed. Nothing in this repository has yet caught a bot with it.

What was added, and the ordering rule Pouya set: "Nothing is dropped; a false positive costs him one glance."

  1. A second honeypot — above.
  2. Scoring that labels. backend/intake/spam-score.mjs, unit-tested at spam-score.test.mjs. Signals and weights: summary under a floor (1), phone present and not North American (1), a link in the summary (2), a Gmail address with dot-trick density (2); threshold 2. Above it the record is still stored, both emails are still sent, and only the operator notification changes — subject prefixed [Possible spam] , plus one line naming the signals. The confirmation to the inquirer is untouched.
  3. Nothing is stored. The score and signals do not enter the DynamoDB item, because §Storage's attribute list is published on /legal/privacy/ and adding one would make that page wrong.

⚠️ THE TIMING FLOOR WAS RULED AND COULD NOT BE BUILT — §9 Q66. Pouya's ruling of 2026-09-04 asked to "raise the timing floor". There is no floor to raise: the timestamp check is struck above and has never existed, for a reason unchanged by the spam arriving — /contact/ is a CDN-cached static file, so no per-visitor "served at" value exists to subtract from. Nothing inside "handler + form only, zero-JS preserved" can produce one, and the three mechanisms that could each break one of his constraints:

mechanism what it costs
Client-side script timing the fill Breaks zero JavaScript (§7, and it is none, not minimal)
A CloudFront Function on viewer-response setting a signed short-lived cookie, read by the handler Outside "handler + form only", and it puts a cookie on a site whose privacy policy turns on there being none — a /legal/privacy/ change and a consent question this repository must not answer for itself
A dynamic origin for /contact/ Reverses D1's output: 'static'

A fourth is worse than doing nothing: shipping a build-time timestamp and calling it a timing check. now served would be hours or days for every caller, so it would pass for a bot exactly as it passes for a human — the control that exists on paper and not in fact, which is what deviation 1 and AGENTS.md Q22 are both records of.

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.

⚠️ THE KEY SCHEMA IS THE TABLE'S, NOT THIS SPEC'S — CORRECTED 2026-09-01, AND THE UNCORRECTED VERSION WOULD HAVE LOST EVERY SUBMISSION. This table specified pk: INTAKE#<uuid> and sk: <timestamp>, and handler.mjs was written to it. The table AGENTS.md §7 names has a single partition key **submissionId (S)and no sort key**[verified 2026-09-01 — aws dynamodb describe-table]. A PutItemmissing the key attribute fails the whole write withValidationException, the handler catches it and answers the failure page — so the form would have looked broken to every inquirer while the record went nowhere, from the moment /api/*was wired. **A DynamoDB key schema cannot be altered after creation**, so the handler was changed to the table rather than the reverse; the alternative, a new table matching the old shape, was declined because it would re-open the §7-verified TTL and PITR state on a fresh resource at cutover to buy a sort key nothing queries. Verify withdescribe-table`, not against this row.

Attribute
submissionId <uuid>the partition key. Fixed by the table; the notification email prints this value verbatim so it can be pasted into the console
submittedAt <ISO-8601 timestamp> — an ordinary attribute, not a sort key
fields as above
sourceIp, userAgent abuse investigation only⚠️ AMENDED 2026-09-02: that purpose holds for userAgent and submittedAt, and NOT for sourceIp. Behind the /api/* behaviour requestContext.http.sourceIp is a CloudFront edge, so it identifies the network rather than the sender and cannot serve an abuse investigation. /legal/privacy/ now states the two purposes separately — timestamp and user-agent for abuse, the address as something that simply arrives with the request. A spec and a page disagreeing about WHY data is held is the disclosure PIPEDA actually turns on, and this row said one thing while the page said another for a day. docs/09 Part 7.2 measures the field; if it holds the reader's own address, this row and that paragraph both change. adversarial-reviewer, round 2
consentAt <ISO-8601 timestamp> — when the consent box was submitted
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. ⚠️ THE THIRD LINE HERE WAS "table access limited to the Lambda role and one named administrative principal", AND IT WAS THE Q62 FALSEHOOD — struck 2026-09-02. It is false on both halves: adr-intake-lambda-role holds PutItem only and cannot read the table at all, and access is not one principal. AGENTS.md §7's Intake table — who can read it row is the answer and this spec does not restate it — a duplicated fact is one that goes wrong in the copy nobody re-reads, which is what happened here: the Q62 sweep ran over src/ and never reached a spec, and check:claims carries this exact sentence as a string that reached dist/. It survived the sweep this file's own definition-of-done claims to have completed (adversarial-reviewer, round 2).

⚠️ TWO OF THOSE THREE ARE THE STATE OF THE RUNNING TABLE AND ONE IS NOT. PITR is on [verified 2026-09-01 — describe-continuous-backups, PointInTimeRecoveryStatus: ENABLED, 35-day window]. Encryption at rest is on with the AWS-owned key, not a customer-managed KMS key [verified 2026-09-01 — describe-table returns no SSEDescription]. That gap is deliberately not a cutover blocker: /legal/privacy/ says "encrypted at rest", which is unconditionally true of every DynamoDB table, and it does not claim a customer-managed key — so nothing published depends on it. It stays on docs/06's checklist as the improvement it is.

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.

⚠️ THE DLQ IS STRUCK, 2026-09-01, AND IT WOULD HAVE BEEN A CONTROL THAT RECEIVED NOTHING. Lambda's DeadLetterConfig is used only for asynchronous invocations (and event-source failures). API Gateway invokes this function synchronously and the error is returned to the caller, so a DLQ configured on adr-intake-handler would sit at depth 0 for ever and an alarm on it would be a green light that means nothing — the third instance of this project's most expensive shape, after AGENTS.md Q22 and the Lighthouse row.

What actually protects a submission is already built and is not a queue: the handler writes to DynamoDB before sending mail, so a mail failure cannot lose a record, and a write failure returns the visitor to /contact/could-not-send/ rather than telling them an inquiry was received. What is missing is detection, and the replacement is two CloudWatch alarms rather than one:

  • Lambda Errors ≥ 1 on adr-intake-handler — this is what a DLQ alarm was reaching for and it fires on a synchronous failure, which a DLQ cannot see.
  • API Gateway 5xx ≥ 1 on the POST /api/intake route — it catches the one failure the Lambda cannot report, a permission or integration fault where the function is never entered at all (docs/09-cutover-runbook.md Part 6.1 is the step whose omission causes exactly that).

Both notify the ses-alerts topic, whose email subscription is confirmed as of AGENTS.md §7 — so unlike the DLQ alarm, these reach someone.

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 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

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 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 clientbackend/intake/fields.mjs, cross-checked by npm run check:intake
  • The first honeypot is live — the hidden text input that must arrive empty. Deployed since cutover. ⚠️ The timing check is NOT implemented — see deviation 1 above and §Observed abuse; it is unimplementable on a CDN-cached static page and would be a control that does nothing. Re-ruled and re-blocked 2026-09-04, §9 Q66
  • 🛑 THE SECOND HONEYPOT AND THE SPAM SCORING ARE WRITTEN AND NOT DEPLOYED — 2026-09-04. Both live in backend/intake/, and a site deploy does not carry backend/: scripts/deploy-local.sh is an S3 sync and an invalidation, nothing more. They need docs/09 Part 5 (and Part 5.5, which is the path in production). ⚠️ THIS LINE READ [x] … "live" FOR ONE ROUND, ON AN UNCOMMITTED WORKING TREE, while §7's own row recorded the running function as last modified 2026-09-02 with source digests matching HEAD — the spec asserting a control that its neighbour proved absent. node backend/intake/spam-score.test.mjs returns 39 of 39 and all four signals are exercised [verified 2026-09-04]; that is a statement about the repository, not about production. ⚠️ The only paths that discard a submission are the two honeypots, and both answer with the success page rather than an error. Validation failures redirect to /contact/could-not-send/, which is a told failure, not a silent one
  • Throttle configured — POST /api/intake at rate 1.0 req/s, burst 5, detailed metrics on [verified 2026-09-04 — get-stage]. ⚠️ IT IS A RouteSettings ENTRY, NOT THE STAGE DEFAULT, and a query projecting DefaultRouteSettings alone returns only DetailedMetricsEnabled and reads as no throttle configured — which is how §7 came to say so. Read RouteSettings before concluding it is absent. An aggregate route throttle, not the per-source-IP limit this spec used to ask for; see §Validation above for why that is not buildable at API Gateway and what it would take. Not expressible in handler code. docs/09-cutover-runbook.md Part 6.3
  • 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. /legal/privacy/ states that records are deleted automatically after 24 months, and it asserts the mechanism, not only the period. The handler writes the ttl attribute — epoch seconds, 24 months, confirmed against this spec [verified 2026-08-31] — and writing the attribute is not the mechanism: TTL must also be enabled on the table, which is a table setting the code cannot see. AGENTS.md §7 holds that status and its stamp; this line does not restate it — it restated it once, went stale within the day, and had to be pulled back (§12 R19). The test record is what closes this item, not the status: ENABLED proves the setting, a record written with a near-future ttl and observed to vanish proves the behaviour. Tracked as §9 Q60
  • PITR enabledENABLED, 35-day window [verified 2026-09-01 — describe-continuous-backups]
  • KMS customer-managed key. Not on the table: encryption at rest is with the AWS-owned key [verified 2026-09-01 — describe-table returns no SSEDescription]. Not claimed on /legal/privacy/ — the page says "encrypted at rest", which is unconditionally true of every DynamoDB table and does not mention a customer-managed key, so nothing published depends on it. An improvement, not a blocker
  • Table access matches what /legal/privacy/ says about it — 2026-09-02. The access is unchanged; the page now states it. Pouya ruled state the truth rather than remove the access (§9 Q62), so the page states the truth about access rather than a false exclusivity. ⚠️ WHAT IT STATES CHANGED TWICE MORE THAT DAY AND THIS LINE IS WRITTEN AGAINST THE SHIPPED BYTES, NOT AGAINST THE RULING. §9 Q63 took the human headcount off (a simulation counts identities and the page was reading them as people), and a second ruling then cut §Who can see it to four plain statements. The page now says: "The record in the table: me, and the small number of people who administer the account it sits in with me"; that the receiving system "can only add a record — it cannot read back what is stored"; where the notification goes and who reads it; and that the confirmation sits with the reader's own provider. §Where it is stored carries the shared-account disclosure"an Amazon Web Services account that also runs systems unrelated to this practice". ⚠️ adr-sml-deploy is implicitDeny on all seven read AND write actions — MEASURED, TRUE, AND NO LONGER ON THE PAGE; it went with the mechanics cut and it is §7's claim now, not the policy's. Do not tick this item against a page that states it. Evidence and commands: docs/reference/intake-table-access-verification.md, whose enumeration was extended on 2026-09-02 — the original screened roles by list-attached-role-policies alone, missing that 23 of 26 non-service-linked roles carry inline policies and that the two CDK lookup roles can read the table. Four roles can, not two; every one of them is reachable only by those administrators. ⚠️ Do not restate that as a count of PEOPLE — this line said "all four terminate at the same two people" until 2026-09-02, which is the inference §9 Q63 struck. ⚠️ THIS LINE SAID "IT DOES NOT" FOR A DAY AFTER THE PAGE WAS CORRECTED, AND IT IS A DEFINITION-OF-DONE LIST SOMEONE FOLLOWS AT CUTOVER — the Q62 sweep was run over src/ only, so it could not reach a spec. adversarial-reviewer, round 1. The sweep across docs/ is in the Change Log entry
  • Both emails send; SPF/DKIM/DMARC aligned; inbox-tested, not spam-tested
  • CloudWatch alarms on Lambda Errors and API Gateway 5xx — replacing the DLQ item, which is struck: a DLQ on a synchronously invoked function never receives anything, so the alarm on its depth would have been permanently green. See §Notification. The handler writes to DynamoDB before sending mail, so the protection this item was pointing at is in the code rather than in a queue
  • 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 from ANALYTICS.installed

THE THREE ITEMS BELOW WERE COMMANDS AND ALL THREE HAVE RUN — cutover, 2026-09-02, verified against the live account 2026-09-04. They are ticked below and the reasoning is kept because it is what made them non-obvious. The commands are in docs/09-cutover-runbook.md — Parts 5, 6 and 3 respectively, each with its verification and the output to expect. Two things that spec found by reading the running system rather than the specs, and both would have lost every submission: the API route needs its own Lambda invoke permission, because the existing one is SourceArn-scoped to the old /submissions path; and the handler's item shape had to change, because the table's partition key is submissionId and a key schema cannot be altered after creation (§Storage).

  • CloudFront /api/* behaviour created [verified 2026-09-04 — get-distribution-config: 1 cache behaviour, 2 origins, 1 function association, 1 custom error response], routing to the HTTP API origin §7 records. The form does not work without it. And two other distribution changes are prerequisites of the site working at all, neither of which is intake: a viewer-request function for trailingSlash: 'always', without which 22 of 23 pages return S3's AccessDenied, and the 404 mapping docs/04 requires
  • Handler deployed 2026-09-02, replacing the hand-built adr-intake-handler [verified 2026-09-04 — get-function-configuration: handler.handler, 15 s, 512 MB, six variables; and the deployed zip downloaded and read]. ⚠️ Ticking it does NOT mean the current working tree is deployed — the running artefact matches HEAD, and backend/ changes reach production only through Part 5. 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