feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam
Build and deploy / build-and-deploy (push) Failing after 4s

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
This commit is contained in:
Pouya Lajevardi
2026-09-04 10:06:37 -04:00
co-authored by Claude Opus 5
parent 02739adac9
commit 3c3ba5dc6e
21 changed files with 2443 additions and 141 deletions
+6 -1
View File
@@ -215,7 +215,12 @@ jobs:
echo "'Items[].RouteKey' — the --api-id is required; without it the" echo "'Items[].RouteKey' — the --api-id is required; without it the"
echo "CLI exits 252 on ParamValidation." echo "CLI exits 252 on ParamValidation."
echo "403: method rejected, or the handler refused the Origin —" echo "403: method rejected, or the handler refused the Origin —"
echo "check Managed-AllViewerExceptHostHeader is on the behaviour." echo "read which origin request policy /api/* carries. Since"
echo "2026-09-04 it may be the custom whitelist"
echo "adr-sml-api-viewer-address rather than the managed"
echo "AllViewerExceptHostHeader; a policy that does not forward"
echo "Origin 403s every real submission. Rollback id:"
echo "b689b0a8-53d0-40ab-baf2-68738e2966ac."
echo "500: the invoke permission for this route is missing (6.1)." echo "500: the invoke permission for this route is missing (6.1)."
echo "See docs/09-cutover-runbook.md Part 7.1." echo "See docs/09-cutover-runbook.md Part 7.1."
fi fi
+475 -10
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -115,3 +115,25 @@ export const FIELDS = [
* stops filling the field. `check:intake` asserts it is absent from `FIELDS`. * stops filling the field. `check:intake` asserts it is absent from `FIELDS`.
*/ */
export const HONEYPOT = 'company_website'; export const HONEYPOT = 'company_website';
/**
* The SECOND honeypot — a decoy checkbox that must arrive ABSENT. Also not in
* `FIELDS`, for the same reason, and `check:intake` asserts that too.
*
* ⚠️ **DIFFERENT TRAP, NOT A SECOND COPY.** `HONEYPOT` catches a bot that fills
* every text input; this catches one that sets every control it enumerates.
*
* ⚠️ **IT IS PROBABLY INERT AGAINST THE 2026-09-04 PAIR, AND THE COMMENT HERE
* SAID THE OPPOSITE FOR ONE ROUND.** They left `HONEYPOT` empty, so they skip
* hidden fields — and a bot that skips a hidden text input skips a hidden
* checkbox. `src/data/intake.ts` carries the full argument; this is defence in
* depth against a different class, not a counter to the observed one.
*
* ⚠️ **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 mere
* presence.** See
* `src/data/intake.ts` for the full reasoning; the two files state it separately
* because they are separately deployed and `check:intake` is what keeps the
* NAMES in step, not the comments.
*/
export const DECOY_CHECKBOX = 'updates_optin';
+147 -29
View File
@@ -3,13 +3,16 @@
* table and SES state: AGENTS.md §7 — this file reads them from the environment * table and SES state: AGENTS.md §7 — this file reads them from the environment
* and does not restate them. * and does not restate them.
* *
* ⚠️ THIS IS NOT DEPLOYED. Written at build step 8; nothing on this project * ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and
* deploys before cutover (D11). AGENTS.md §7 records that a hand-built * `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built
* `adr-intake-handler` already exists in the console, created before this repo, * `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT
* and this file REPLACES it rather than describing it. docs/06's cutover * DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on
* checklist carries the deployment steps and the CloudFront `/api/*` behaviour * this file can say: an edit made in that belief ships to a form real inquirers
* the form depends on. Until both are done the form on /contact/ posts into * are using. Changes here reach production on the next `docs/09` Part 5 run.
* nothing, which is why that page also publishes the email address. *
* ⚠️ AND A BARE `POST /api/intake` RETURNS 403 BY DESIGN — the Origin check
* below. `docs/09` §7.1 is the only valid route probe; a 403 without that header
* is not evidence about the route. It has been misread as one twice.
* *
* ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ───────────────────────────── * ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ─────────────────────────────
* *
@@ -30,6 +33,13 @@
* *
* ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ────────────────────────────── * ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ──────────────────────────────
* *
* ⚠️ **RE-ASKED 2026-09-04 AND STILL NOT IMPLEMENTABLE HERE.** Pouya ruled
* *"raise the timing floor"* after the first real spam. There is no floor to
* raise — the check has never existed — and the reason below is unchanged by
* the spam arriving: it is a property of a CDN-cached static page, not of how
* hard anyone has tried. What CAN carry a per-visitor clock is named in
* `docs/05` §Observed abuse and it is outside "handler + form only". §9 Q66.
*
* **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.** * **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.**
* docs/05 asks to "reject submissions completed in under 3 seconds". It cannot * docs/05 asks to "reject submissions completed in under 3 seconds". It cannot
* be done here and implementing it would produce a control that does nothing: * be done here and implementing it would produce a control that does nothing:
@@ -42,10 +52,11 @@
* *
* That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both * That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both
* records of what a control that exists on paper and not in fact costs here. So * records of what a control that exists on paper and not in fact costs here. So
* it is omitted, said out loud, and the load is carried by the honeypot, the * it is omitted, said out loud, and the load is carried by the TWO honeypots,
* Origin check, the aggregate API Gateway route throttle and the validation * the Origin check, the aggregate API Gateway route throttle and the validation
* below. (Aggregate, not per-IP — see above; the earlier wording here said * below — plus, since 2026-09-04, a score that LABELS and never rejects.
* "rate limit" and let the reader supply the stronger meaning.) * (Aggregate, not per-IP — see above; the earlier wording here said "rate
* limit" and let the reader supply the stronger meaning.)
* *
* ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ────────────────────────────── * ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ──────────────────────────────
* *
@@ -65,10 +76,20 @@
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
/* The field table and the honeypot name live in their own module so that /* The field table and BOTH honeypot names live in their own module so that
`npm run check:intake` can import them without this file's module-scope `npm run check:intake` can import them without this file's module-scope
`requireEnv()` calls running. See fields.mjs for why there are two tables. */ `requireEnv()` calls running. See fields.mjs for why there are two tables. */
import { FIELDS, HONEYPOT } from './fields.mjs'; import { DECOY_CHECKBOX, FIELDS, HONEYPOT } from './fields.mjs';
/* Scoring lives in its own module so it can be unit-tested — this file throws at
import without a configured environment, so it cannot be. `node
backend/intake/spam-score.test.mjs`. ⚠️ IT IS A THIRD FILE IN THE ZIP:
`docs/09` Part 5.1 packages it explicitly, and a cold start would fail with
ERR_MODULE_NOT_FOUND if it were left out. */
import {
isPossibleSpam,
scoreSubmission,
SPAM_THRESHOLD,
} from './spam-score.mjs';
/* Region comes from the Lambda runtime, which sets AWS_REGION to the function's /* Region comes from the Lambda runtime, which sets AWS_REGION to the function's
own region — the one §7 records. Not hardcoded: a second copy of a fact §7 own region — the one §7 records. Not hardcoded: a second copy of a fact §7
@@ -219,14 +240,18 @@ function parseBody(event) {
* presented as an identification is worse than an honest useless one. * presented as an identification is worse than an honest useless one.
* *
* The right value is CloudFront's own `CloudFront-Viewer-Address`, which * The right value is CloudFront's own `CloudFront-Viewer-Address`, which
* CloudFront generates and overwrites — but reaching it needs a CUSTOM origin * CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request
* request policy on the /api/* behaviour (the managed * policy on the /api/* behaviour the managed AllViewerAndCloudFrontHeaders
* AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API * forwards Host, which 403s every request at API Gateway.
* Gateway, which is why AllViewerExceptHostHeader was chosen). That is an *
* infrastructure change, and `docs/09` Part 7.2 measures what this field * ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5,
* actually contains at cutover rather than reasoning about the proxy chain — * Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL
* with a decision table for each outcome. Do not "fix" this from the header * DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet
* again without that measurement. * acted on*. What the record holds is published field by field on
* /legal/privacy/, so storing a different address is a DISCLOSURE change
* governed by `docs/09` §7.2's decision table — an infrastructure change
* forwards a header; only a privacy-policy change may store one. Do not "fix"
* this from any header without that measurement and that edit.
*/ */
function viewerIp(event) { function viewerIp(event) {
return event.requestContext?.http?.sourceIp ?? 'unknown'; return event.requestContext?.http?.sourceIp ?? 'unknown';
@@ -293,7 +318,51 @@ export async function handler(event) {
* human cannot reach this field — it is `display: none`, `tabindex="-1"` and * human cannot reach this field — it is `display: none`, `tabindex="-1"` and
* `aria-hidden` — so a non-empty value is not a mistake anyone made. * `aria-hidden` — so a non-empty value is not a mistake anyone made.
*/ */
if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].trim() !== '') { /* ⚠️ COERCED, NOT TYPE-CHECKED. `parseBody` accepts JSON, so a value can
arrive as `true` or `1` rather than a string — and `typeof === 'string'`
let exactly that through both traps for one round. `String(v).trim()`
catches every non-empty shape and still treats absence as a pass. */
if (body[HONEYPOT] !== undefined && String(body[HONEYPOT]).trim() !== '') {
/* LOGGED, BECAUSE THIS IS ONE OF ONLY TWO PATHS THAT DISCARD A SUBMISSION
AND ANSWER WITH THE SUCCESS PAGE. Unlogged, a honeypot that starts firing
on real visitors — a stylesheet that 404s, an autofiller, a template edit
that unhides the wrapper — is indistinguishable from quiet weeks, and the
only signal is inquiries that were never mentioned again. The FIELD NAME
only: the value is whatever a bot chose and nothing about the submission
is kept, which is what makes this safe to log at all. */
console.warn('intake: discarded by honeypot', { field: HONEYPOT });
return redirect(SUCCESS);
}
/**
* THE SECOND HONEYPOT, AND IT TRAPS A DIFFERENT BEHAVIOUR. A checkbox no
* person can see; an unchecked box sends nothing at all, so a VALUE arrives
* only because something ticked it. The value itself is not compared —
* `=1`, `=yes` and `=on` are all a tick — only that there is one.
*
* Same silent SUCCESS as above, and for the same reason.
*
* ⚠️ ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE. Both directions matter and
* they fail differently:
*
* - Requiring the field to ARRIVE would turn every dropped-field path — an
* extension, a proxy, a template edit — into a lost inquiry reported as
* sent.
* - Trapping on mere PRESENCE (`!== undefined`) would catch a form
* serialiser that emits `updates_optin=` for a hidden checkbox without
* reading its checked state. That is rare and it is not impossible, and
* the cost of being wrong is a real legal inquiry discarded in silence.
*
* So the test is the same shape as the honeypot above — a non-empty value —
* while the BEHAVIOUR it catches is the opposite one. That is the distinction
* that matters: filling text fields versus ticking boxes, not `undefined`
* versus `''`.
*/
if (
body[DECOY_CHECKBOX] !== undefined &&
String(body[DECOY_CHECKBOX]).trim() !== ''
) {
console.warn('intake: discarded by honeypot', { field: DECOY_CHECKBOX });
return redirect(SUCCESS); return redirect(SUCCESS);
} }
@@ -403,6 +472,50 @@ export async function handler(event) {
.map((f) => `${f.label}: ${clean[f.name]}`) .map((f) => `${f.label}: ${clean[f.name]}`)
.join('\n'); .join('\n');
/**
* SCORING, AND IT LABELS RATHER THAN REJECTS — Pouya, 2026-09-04.
*
* ⚠️ THIS RUNS AFTER THE RECORD IS STORED, WHICH IS NOT AN ACCIDENT OF
* ORDERING. Nothing below can decline a submission: by the time it runs, the
* write has already succeeded and the only remaining question is what the
* OPERATOR's subject line says. There is deliberately no branch here that can
* reach `redirect(FAILURE)`.
*
* ⚠️ AND IT TOUCHES THE NOTIFICATION ONLY. The confirmation below is
* unchanged. A real inquirer wrongly scored must never be told that a machine
* thought they were a bot.
*/
/* ⚠️ WRAPPED, AND THE GUARD IS THE RULING RATHER THAN CAUTION. An exception
here would escape `handler`, API Gateway would answer 500, and the inquirer
would see a failure for a submission ALREADY WRITTEN to the table — a path
that costs an inquiry, decided by a labelling function. Pouya's constraint
is that nothing but a honeypot may cost one, so the scorer is allowed to
fail and the submission is not. Unlabelled is the safe default. */
let spam = { score: 0, signals: [] };
try {
spam = scoreSubmission(clean);
} catch (error) {
console.error('intake: spam scoring failed; sending unlabelled', {
id,
error,
});
}
const flagged = isPossibleSpam(spam);
const notificationBody = [
`Received ${now.toISOString()}`,
`submissionId ${id}`,
...(flagged
? [
'',
`Possible spam. Score ${spam.score} of threshold ${SPAM_THRESHOLD}. ` +
`Signals: ${spam.signals.join('; ')}.`,
]
: []),
'',
summaryLines,
'',
].join('\n');
/** /**
* TWO EMAILS — D18, and the second one is why the form beats a mailto: link. * TWO EMAILS — D18, and the second one is why the form beats a mailto: link.
* `Promise.allSettled`, not `Promise.all`: the record is already stored, so a * `Promise.allSettled`, not `Promise.all`: the record is already stored, so a
@@ -419,14 +532,19 @@ export async function handler(event) {
ReplyToAddresses: [clean.email], ReplyToAddresses: [clean.email],
Content: { Content: {
Simple: { Simple: {
Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` }, /* The prefix is what Pouya filters on in Gmail, so it is the
FIRST thing in the subject and it is a fixed string. Do not make
it conditional on anything else, and do not vary its wording. */
Subject: {
Data:
`${flagged ? '[Possible spam] ' : ''}` +
`Intake — ${clean.name} (${clean.practiceArea})`,
},
Body: { Body: {
Text: { // The body carries the bare submissionId, because it is the
// The bare id, because it is the partition key: this line is // partition key: that line gets pasted into the console to find
// what gets pasted into the console to find the record, so it // the record, so it must be the key and not a rendering of it.
// must be the key and not a rendering of it. Text: { Data: notificationBody },
Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`,
},
}, },
}, },
}, },
+196
View File
@@ -0,0 +1,196 @@
/**
* Spam SCORING for the intake handler. Pouya's ruling, 2026-09-04.
*
* ⚠️ **THIS MODULE NEVER REJECTS ANYTHING, AND THAT IS THE WHOLE DESIGN.** It
* returns a score and a list of signal names. The handler stores the record and
* sends both emails either way; above the threshold it prefixes the OPERATOR
* notification's subject with `[Possible spam] ` and adds one line naming the
* signals. Pouya filters in Gmail. His words: *"Nothing is dropped; a false
* positive costs him one glance."*
*
* That asymmetry is why the thresholds below can be tuned aggressively. The cost
* of a false positive is a subject-line prefix; the cost of a false negative is
* one unlabelled email. Neither loses an inquiry — which a filter that rejected
* would, and a legal inquiry lost silently is the one outcome this form must not
* produce.
*
* ⚠️ **NOTHING HERE IS STORED.** The score and the signals do not enter the
* DynamoDB item. `/legal/privacy/` publishes what the record holds, field by
* field, and adding an attribute would make that list wrong — a disclosure
* defect, not a schema change. The label lives only in the operator
* notification. ⚠️ **THAT MAILBOX IS DELEGATED, NOT PERSONAL** — §9 Q63 and
* `/legal/privacy/` §Who can see it both say so, and an earlier draft of this
* comment said the label "lives in an email that only Pouya reads", which is the
* exclusivity Q63 struck. It reaches whoever reads `info@smlcompany.ca`. If a
* stored score is ever wanted, the page changes first.
*
* ⚠️ **AND THE INQUIRER NEVER SEES ANY OF THIS.** The confirmation email is
* untouched. A person wrongly scored must not be told a machine thought they
* were a bot.
*
* WHY SCORING RATHER THAN MORE REJECTION. The two submissions of 2026-09-04
* (`docs/05` §Observed abuse) passed the honeypot. Every rule that would have
* caught them — a foreign phone, a link in the summary, a disposable-looking
* address — is a rule some real inquirer also trips: this practice takes
* cross-border commercial work, so a `+44` number is a client, not a bot. A
* rejecting rule set built from those signals would eventually discard a real
* dispute and report success while doing it.
*/
/**
* ⚠️ **NOT MEASURED FROM A CORPUS — THERE IS NO CORPUS.** No genuine inquiry has
* arrived through this form yet, so there is nothing to measure a normal summary
* length against, and a number presented as measured when it is not is the
* defect `AGENTS.md` keeps paying for.
*
* It is DERIVED, and the derivation is the form's own instruction: the `summary`
* field's hint reads *"A few sentences is enough."* This floor sits **below** what
* that invites, so it fires on a summary that does not attempt the question
* rather than on one that answers it briefly. `[assumed 2026-09-04]`
*
* ⚠️ **TUNE IT DOWN WHEN IN DOUBT, NEVER UP.** An unlabelled spam costs nothing
* that matters; a labelled real inquiry spends the reader's trust in the label.
* *"Shareholder dispute, two directors, Ontario CBCA company."* is 57 characters
* and is exactly what the hint asks for — a floor above that scores the form's
* own instruction as a spam signal.
*
* **Pouya can replace this with a measurement whenever he likes** — the two spam
* records of 2026-09-04 are still in the table, and their `summary` lengths are
* the first real data this number could rest on. §9 Q65 records that.
*/
export const SHORT_SUMMARY_CHARS = 100;
/**
* Above this, the notification is labelled. Weights below are 1 for a signal a
* real inquirer plausibly trips and 2 for one they rarely do, so the threshold
* of 2 means: **one strong signal, or two weak ones.**
*
* Worked, because a threshold nobody has worked through is a guess with a number
* on it:
* - Ontario counsel, local number, three-line summary → 0, clean
* - Cross-border counsel, `+44` number, three-line summary → 1, clean
* - Cross-border counsel, `+44` number, one-line summary → 2, LABELLED
* - A four-part real name at gmail.com → 1, clean
* - Anyone pasting a link to a public tender document → 2, LABELLED
* - foreign number + a short scraped summary carrying a link → 4, LABELLED
*
* The third and fourth rows are the accepted false positives. Both are real
* shapes, both cost one glance, and both were preferred to missing the fifth.
*
* ⚠️ **THE LAST ROW IS A SHAPE, NOT A MEASUREMENT OF THE TWO 2026-09-04
* SUBMISSIONS. THOSE RECORDS WERE NEVER READ.** What the attested signature
* guarantees is a non-NANP phone — **one weak signal** — and whether either is
* labelled turns on facts only the two rows in the table hold. §9 Q65 records
* that they are still there and are the only real data any of these numbers
* could rest on.
*/
export const SPAM_THRESHOLD = 2;
/** `https://…` or `www.…` only. A bare `acme.com` is NOT matched: an inquirer
* writing "the dispute concerns acme.com's supply contract" is describing a
* party, and matching that would label ordinary commercial prose. */
const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)\S/i;
/**
* NANP: an explicit `+<cc>` settles it; otherwise ten digits, or eleven
* beginning with 1, after the tail is dropped.
*
* ⚠️ **A DIGIT COUNT ALONE CANNOT DO THIS.** `416-555-0123 ext 22` is twelve
* digits, `416-555-0123 or 416-555-0124` is twenty, and both are ordinary
* Toronto numbers that a bare count calls foreign — a signal saying the opposite
* of the truth. The tail is dropped at the first extension marker or
* second-number separator, and the marker list is deliberately generous.
*
* **The country code is read FIRST because it is the only unambiguous thing in
* the field.** `+44 …` and `+7 …` are settled without counting anything, which
* is what a pure shape test cannot do: `+7 912 345 6789` is grouped 3-3-4
* exactly like a NANP number, so matching the shape would call it Canadian.
* Only when there is no explicit country code does the digit count run, and then
* the tail is dropped at the first extension marker or second-number separator.
*/
function looksNorthAmerican(phone) {
const trimmed = phone.trim();
/* An explicit international prefix is decisive in both directions. */
const cc = trimmed.match(/^\+\s*(\d{1,3})/);
if (cc) return cc[1] === '1';
/* Longest alternative FIRST: regex alternation is leftmost-first, so `ext`
placed before `extension` matches the first three letters and then relies on
backtracking. Ordering it correctly is cheaper than depending on that.
`\bx\b` would NOT match the `x` in `x22` — the digit after it is a word
character, so there is no boundary — which is how `(416) 555-0123 x22` scored
foreign for one round. The marker is matched by what FOLLOWS it. */
const digits = trimmed
.split(/\s*(?:extension|extn|ext|x)[.:-]?\s*\d|[#,;]|\bor\b/i)[0]
.replace(/\D/g, '');
return digits.length === 10 || (digits.length === 11 && digits[0] === '1');
}
/**
* The Gmail dot trick: one mailbox, unlimited distinct-looking addresses,
* because Gmail ignores dots in the local part.
*
* ⚠️ **A DOT IS NOT THE SIGNAL, AND TREATING IT AS ONE WOULD LABEL MOST REAL
* GMAIL USERS.** `first.last@gmail.com` is the single most ordinary form a Gmail
* address takes. What distinguishes the trick is dot DENSITY: **three or more
* dots**, and nothing else.
*
* ⚠️ **AND THE WEIGHT IS 1, NOT 2, WHICH MATTERS MORE THAN THE BOUNDARY DOES.**
* `mary.jane.o.brien@gmail.com` and `maria.de.la.cruz@gmail.com` are three-dot
* REAL names — compound surnames and middle initials are ordinary, not rare —
* and weight 2 is defined here as what a real inquirer rarely trips. At weight 1
* nothing can be labelled on the shape of its owner's name alone; a genuine
* dot-trick address reaches the threshold as soon as it trips anything else,
* which spam reliably does. **Do not raise it back.**
*/
function looksLikeGmailDotTrick(email) {
const at = email.lastIndexOf('@');
if (at < 1) return false;
const local = email.slice(0, at);
const domain = email.slice(at + 1).toLowerCase();
if (domain !== 'gmail.com' && domain !== 'googlemail.com') return false;
const dots = local.split('.').length - 1;
return dots >= 3;
}
/**
* @param {Record<string, string>} fields the handler's `clean` map — validated,
* plain-texted values, keyed by field name. Absent fields are simply absent.
* @returns {{score: number, signals: string[]}} `signals` are written for a
* human reading one line of an email, not for a machine.
*/
export function scoreSubmission(fields) {
const signals = [];
let score = 0;
const add = (weight, label) => {
score += weight;
signals.push(label);
};
const summary = fields.summary ?? '';
const phone = fields.phone ?? '';
const email = fields.email ?? '';
/* Only when a summary exists. An absent one is a validation failure the
handler has already turned into the failure page, so scoring an empty
string here would be scoring a submission that never got this far. */
if (summary !== '' && summary.length < SHORT_SUMMARY_CHARS) {
add(1, `summary under ${SHORT_SUMMARY_CHARS} characters`);
}
/* `phone` is OPTIONAL. Not giving one is not a signal — most inquirers will
not — so this fires only on a number that is present and not North
American. Treating absence as suspicious would label the quiet majority. */
if (phone !== '' && !looksNorthAmerican(phone)) {
add(1, 'phone is not a Canadian or US number');
}
if (URL_IN_TEXT.test(summary)) {
add(2, 'link in the dispute summary');
}
if (looksLikeGmailDotTrick(email)) {
add(1, 'Gmail address using the dot trick');
}
return { score, signals };
}
/** True when the operator notification should carry the label. */
export const isPossibleSpam = ({ score }) => score >= SPAM_THRESHOLD;
+336
View File
@@ -0,0 +1,336 @@
/**
* Unit test for the intake spam scorer. `node backend/intake/spam-score.test.mjs`.
*
* Same shape and same reasoning as `infra/cloudfront/router.test.mjs`: the real
* check is a real submission, this one runs in a second and catches the branch
* mistakes that a regex change makes silently.
*
* ⚠️ **EVERY SIGNAL SHIPS WITH A NEGATIVE FIXTURE**, which is the discipline
* `CLAUDE.md` imposes on `check:claims` and applies here for the same reason:
* this scorer's failure mode is not missing spam, it is labelling a real
* inquiry. The pairs below are the nearest legitimate submission to each trap —
* `j.k.smith@gmail.com` beside the dot trick, an extension-carrying Toronto
* number beside a Russian one, ordinary commercial prose naming a company
* beside a pasted link.
*
* ⚠️ **EACH CASE ASSERTS THE SIGNAL NAMES, NOT ONLY THE SCORE.** Asserting the
* total alone lets two rules swap weights, or one rule fire in place of
* another, with every case still passing — the suite would then be checking
* arithmetic rather than behaviour. `expected` is the exact signal set.
*/
import {
scoreSubmission,
isPossibleSpam,
SPAM_THRESHOLD,
SHORT_SUMMARY_CHARS,
} from './spam-score.mjs';
const SHORT = `summary under ${SHORT_SUMMARY_CHARS} characters`;
const PHONE = 'phone is not a Canadian or US number';
const LINK = 'link in the dispute summary';
const GMAIL = 'Gmail address using the dot trick';
const MID =
'A construction lien dispute over a delayed fit-out. Counsel are engaged ' +
'on both sides and we want a mediator.';
const LONG =
'The parties are in dispute over a delayed fit-out on a Toronto office ' +
'tower. The subcontract was terminated in June and the holdback has not ' +
'been released. Counsel are engaged on both sides and we are looking for a ' +
'mediator with construction experience.';
/* [label, fields, expected signals] — score and labelled are DERIVED from the
weights below, so a weight change fails every affected case by name rather
than silently re-balancing the totals. */
const WEIGHTS = { [SHORT]: 1, [PHONE]: 1, [LINK]: 2, [GMAIL]: 1 };
const CASES = [
// ---- clean submissions, which is the half that matters most -------------
[
'ordinary Ontario inquiry',
{ summary: LONG, phone: '416-555-0123', email: 'a.counsel@firm.ca' },
[],
],
['no phone given at all', { summary: LONG, email: 'counsel@firm.ca' }, []],
[
'+1 with punctuation',
{ summary: LONG, phone: '+1 (647) 555-0188', email: 'c@firm.ca' },
[],
],
[
'ten digits, no punctuation',
{ summary: LONG, phone: '6475550188', email: 'c@firm.ca' },
[],
],
[
'Toronto number with an extension',
{ summary: LONG, phone: '416-555-0123 ext 22', email: 'c@firm.ca' },
[],
],
[
'extension written x22',
{ summary: LONG, phone: '(416) 555-0123 x22', email: 'c@firm.ca' },
[],
],
[
'extension written Ext:',
{ summary: LONG, phone: '416-555-0123 Ext: 4501', email: 'c@firm.ca' },
[],
],
[
'extension spelled out',
{ summary: LONG, phone: '416-555-0123 extension 22', email: 'c@firm.ca' },
[],
],
[
'extension hyphenated',
{ summary: LONG, phone: '416-555-0123 ext-22', email: 'c@firm.ca' },
[],
],
[
'two numbers in one field',
{
summary: LONG,
phone: '416-555-0123 or 416-555-0124',
email: 'c@firm.ca',
},
[],
],
[
'ordinary gmail, one dot',
{ summary: LONG, phone: '416-555-0123', email: 'first.last@gmail.com' },
[],
],
[
'gmail, single initial',
{ summary: LONG, phone: '416-555-0123', email: 'j.smith@gmail.com' },
[],
],
[
'gmail, TWO initials and a surname',
{ summary: LONG, email: 'j.k.smith@gmail.com' },
[],
],
/* ⚠️ NON-GMAIL, THREE DOTS — this pins the DOMAIN GUARD, which nothing did.
Deleting `if (domain !== 'gmail.com' && …) return false` left all 30 cases
passing: the nearest legitimate submission to a three-dot trap is a
three-dot address at a firm domain, and it was the one fixture missing. */
[
'law-firm address, three dots',
{ summary: LONG, email: 'j.p.van.dam@blakes.com' },
[],
],
[
'four-part real name at gmail',
{
summary: LONG,
phone: '416-555-0123',
email: 'mary.jane.o.brien@gmail.com',
},
[GMAIL],
],
[
'company named in prose, no link',
{ summary: `${LONG} The respondent is acme.com Ltd.`, email: 'c@firm.ca' },
[],
],
[
'googlemail, one dot',
{ summary: LONG, email: 'first.last@googlemail.com' },
[],
],
// ---- one weak signal: still clean ---------------------------------------
[
'cross-border counsel, UK number',
{ summary: LONG, phone: '+44 20 7946 0958', email: 'c@firm.co.uk' },
[PHONE],
],
[
'the concise summary the hint invites',
{
summary: 'Shareholder dispute, two directors, Ontario CBCA company.',
phone: '416-555-0123',
email: 'c@firm.ca',
},
[SHORT],
],
// ---- boundaries ----------------------------------------------------------
/* PINS THE FLOOR'S VALUE, which the two boundary cases below cannot: they
derive their lengths from `SHORT_SUMMARY_CHARS`, so they move with it and
a floor raised back to 140 passed them silently. This one is a literal
109-character summary of the kind the form's hint invites, and it fails the
moment the floor rises above it. */
[
'a realistic 109-character summary',
{ summary: MID, email: 'c@firm.ca' },
[],
],
[
'summary exactly at the floor',
{ summary: 'x'.repeat(SHORT_SUMMARY_CHARS), email: 'c@firm.ca' },
[],
],
[
'summary one under the floor',
{ summary: 'x'.repeat(SHORT_SUMMARY_CHARS - 1), email: 'c@firm.ca' },
[SHORT],
],
[
'eleven digits not starting 1',
{ summary: LONG, phone: '+7 912 345 6789', email: 'c@firm.ca' },
[PHONE],
],
/* ⚠️ TEN DIGITS IN TOTAL, AND FOREIGN — Iceland writes +354 followed by seven.
This is the ONE case that pins the country-code branch: without it the
digit count reads 10 and calls this a NANP number. Every other foreign
fixture here has 11+ digits, so the count agrees by accident and the
branch could be deleted with the whole suite still green. */
[
'ten-digit international number',
{ summary: LONG, phone: '+354 555 1234', email: 'c@firm.is' },
[PHONE],
],
[
'gmail, exactly two dots',
{ summary: LONG, email: 'a.b.smith@gmail.com' },
[],
],
[
'gmail, exactly three dots',
{ summary: LONG, email: 'a.b.c.smith@gmail.com' },
[GMAIL],
],
// ---- two weak signals: labelled ------------------------------------------
[
'foreign number and terse summary',
{
summary: 'Need a mediator.',
phone: '+7 912 345 6789',
email: 'c@firm.ru',
},
[SHORT, PHONE],
],
// ---- one strong signal: labelled -----------------------------------------
[
'link in the summary',
{ summary: `${LONG} See https://example.com/tender`, email: 'c@firm.ca' },
[LINK],
],
[
'www link in the summary',
{ summary: `${LONG} See www.example.com/tender`, email: 'c@firm.ca' },
[LINK],
],
[
'dot trick, four dots',
{ summary: LONG, email: 'j.o.h.nsmith@gmail.com' },
[GMAIL],
],
/* ⚠️ WEIGHT 1, SO IT DOES NOT LABEL ALONE. That is the whole point of the
weight change, and this is the case that fails if it goes back to 2. */
[
'dot trick alone does not label',
{ summary: LONG, email: 'r.a.n.d.om@gmail.com' },
[GMAIL],
],
// ---- the shape the 2026-09-04 pair is described as ------------------------
// NOT a measurement of those records: their `summary` values were never read.
[
'scraped text, foreign number, link',
{
summary: 'Buy now at https://spam.example/offer',
phone: '+7 912 345 6789',
email: 'r.a.n.d.om@gmail.com',
},
[SHORT, PHONE, LINK, GMAIL],
],
/* The module's own worked example of a legitimate concise summary, beside a
Toronto direct line. It scored 2 and shipped `[Possible spam]` while the
extension strip was incomplete. */
[
'concise summary + Toronto extension',
{
summary: 'Shareholder dispute, two directors, Ontario CBCA company.',
phone: '416-555-0123 ext: 4501',
email: 'c@firm.ca',
},
[SHORT],
],
// The attested signature ALONE — a non-NANP phone and nothing else known —
// is one weak signal and is NOT labelled. Kept as a case so the limit of what
// the observed evidence supports is asserted rather than described.
[
'attested signature alone',
{ summary: LONG, phone: '+7 912 345 6789', email: 'random@gmail.com' },
[PHONE],
],
// ---- absent fields must not throw or score -------------------------------
['empty object', {}, []],
[
'summary absent, phone local',
{ phone: '416-555-0123', email: 'c@firm.ca' },
[],
],
['email absent', { summary: LONG }, []],
['malformed email, no @', { summary: LONG, email: 'not-an-address' }, []],
['gmail with no local part', { summary: LONG, email: '@gmail.com' }, []],
];
let pass = 0;
const failures = [];
const seen = new Set();
for (const [label, fields, expected] of CASES) {
const result = scoreSubmission(fields);
expected.forEach((sig) => seen.add(sig));
const wantScore = expected.reduce((n, sig) => n + WEIGHTS[sig], 0);
const wantLabelled = wantScore >= SPAM_THRESHOLD;
const gotSignals = [...result.signals].sort();
const wantSignals = [...expected].sort();
const ok =
result.score === wantScore &&
isPossibleSpam(result) === wantLabelled &&
JSON.stringify(gotSignals) === JSON.stringify(wantSignals);
if (ok) {
pass += 1;
} else {
failures.push(
` ${label}\n` +
` expected score ${wantScore}, labelled ${wantLabelled}, signals ${JSON.stringify(wantSignals)}\n` +
` got score ${result.score}, labelled ${isPossibleSpam(result)}, signals ${JSON.stringify(gotSignals)}`,
);
}
}
/* COVERAGE, ASSERTED RATHER THAN ASSUMED. A rule with no positive case is a rule
nobody has run, and it would still show a green suite. */
for (const sig of Object.keys(WEIGHTS)) {
if (!seen.has(sig)) {
failures.push(` no case exercises the "${sig}" signal — it is untested.`);
}
}
/* The threshold is part of the contract the cases above were written against.
Changing it without re-deriving them would leave every expectation a
statement about a threshold that no longer exists. */
if (SPAM_THRESHOLD !== 2) {
failures.push(
` SPAM_THRESHOLD is ${SPAM_THRESHOLD}, not 2 — the weights and expectations ` +
'above were written against 2. Re-derive them before changing it.',
);
}
if (failures.length > 0) {
console.error(`spam-score: ${failures.length} FAILED of ${CASES.length}`);
console.error(failures.join('\n'));
process.exit(1);
}
console.log(
`spam-score: ${pass} of ${CASES.length} cases pass; all ${Object.keys(WEIGHTS).length} signals exercised`,
);
+13 -7
View File
@@ -627,9 +627,11 @@ commitments, the first matter that slips makes the page false."*
**Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card **Unblocked — `AGENTS.md` Q4/Q14 answered (D14). Build from the confirmed card
in `docs/07-fees.md`; still do not invent numbers.** in `docs/07-fees.md`; still do not invent numbers.**
Hourly rate; half-day and full-day mediation; preparation time policy; Hourly rate; half-day and full-day mediation; **med-arb, billed by phase and
cancellation terms; administrative fee; HST treatment; who pays and how costs carrying no figure of its own** (added 2026-09-03, `docs/07` §Med-arb, INTERIM
are shared between parties; payment terms. A real page with real numbers, or a against R5 — the page shipped it and this outline did not name it); preparation
time policy; cancellation terms; administrative fee; HST treatment; who pays and
how costs are shared between parties; payment terms. A real page with real numbers, or a
`TODO(pouya)` — nothing in between. `TODO(pouya)` — nothing in between.
### `/for-parties/` ### `/for-parties/`
@@ -715,10 +717,14 @@ Dependency-ordered, so nothing is blocked mid-stream:
**The section is not live and cannot be**: D9 and `src/content.config.ts` **The section is not live and cannot be**: D9 and `src/content.config.ts`
between them mean an article publishes only when Pouya sets both flags, and between them mean an article publishes only when Pouya sets both flags, and
`SiteHeader` keeps Insights out of the nav until two are live `SiteHeader` keeps Insights out of the nav until two are live
8.`/contact/`**the page is built; the pipe behind it is not.** The handler 8.`/contact/` — **the page is built and the pipe behind it is LIVE as of
is written (`backend/intake/`) and undeployed, and the CloudFront `/api/*` 2026-09-02.** The handler is deployed and the CloudFront `/api/*` behaviour is
behaviour it posts to does not exist yet. Both are cutover items, and `docs/05` in place; `AGENTS.md` §7 holds the state and this list does not restate it.
§Build step 8 records three deliberate deviations from that spec `docs/05` §Build step 8 records three deliberate deviations from that spec, and
§Observed abuse records the first real spam and what was added for it.
⚠️ **This item read *"the pipe behind it is not"* until 2026-09-04** — written
under D11, true then, and left asserting an undeployed backend for two days
after cutover
9.`/fees/`**built 2026-08-31 on Q59's ruling**, which settled where the 9.`/fees/`**built 2026-08-31 on Q59's ruling**, which settled where the
overtime hour starts (the session cap) and supplied the reservation point that overtime hour starts (the session cap) and supplied the reservation point that
answers the rate card's arithmetic anomaly. The PDF bio shipped with it (R16) answers the rate card's arithmetic anomaly. The PDF bio shipped with it (R16)
+134 -16
View File
@@ -22,14 +22,19 @@ The shape is right. This is a hardening and rework pass, not a replacement.
**What is in the repository:** `/contact/` with the intake form, two **What is in the repository:** `/contact/` with the intake form, two
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` + POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
`backend/intake/fields.mjs` — the handler that **replaces** the hand-built `backend/intake/fields.mjs` + `backend/intake/spam-score.mjs` the handler that
`adr-intake-handler` §7 records. **replaced** the hand-built `adr-intake-handler` §7 records.
**What is NOT done, and the form does not work until it is.** Nothing on this 🟢 **IT IS ALL LIVE AS OF 2026-09-02, AND THIS PARAGRAPH SAID THE OPPOSITE UNTIL
project deploys before cutover (D11), so: the handler is not deployed, and the 2026-09-04.** It read *"the handler is not deployed, and the CloudFront `/api/*`
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on behaviour the form posts to does not exist"* — true when written under D11, false
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in
as the form for exactly this reason. 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 form is a plain HTML POST, and it answers 303
@@ -158,6 +163,24 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
- Required fields present; email well-formed; lengths within bounds - Required fields present; email well-formed; lengths within bounds
- Reject any field over its cap rather than truncating silently - Reject any field over its cap rather than truncating silently
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty - **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~~ - ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
⚠️ **STRUCK, and it was recorded as unimplementable in three other places ⚠️ **STRUCK, and it was recorded as unimplementable in three other places
while this line stayed an unqualified imperative** — the handler's header, while this line stayed an unqualified imperative** — the handler's header,
@@ -178,11 +201,86 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
per-IP. This is deviation 1's own argument turned on this spec: *"a control that 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 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 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, - No CAPTCHA. It is a third-party script on a page collecting legal information.
and the two controls above stop the traffic that matters ⚠️ **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 - CORS restricted to `https://adr.smlcompany.ca` — no wildcard
- Strip HTML from every field before storage and before it enters an email body - 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 ## Storage
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
@@ -448,8 +546,26 @@ Plausible or Fathom, cookieless, no consent banner.
## Definition of done ## Definition of done
- [x] **Server-side validation independent of the client**`backend/intake/fields.mjs`, cross-checked by `npm run check:intake` - [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 - [x] **The first honeypot is live** — the hidden text input that must arrive
- [ ] **Throttle configured** — an **aggregate** API Gateway 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 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
- [x]**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
- [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 - [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.** `/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 - [ ] **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
- [x] **PITR enabled**`ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]` - [x] **PITR enabled**`ENABLED`, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`
@@ -460,14 +576,16 @@ Plausible or Fathom, cookieless, no consent banner.
- [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] **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] **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` - [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`
> **The three remaining items below are commands, and the commands are in > ✅ **THE THREE ITEMS BELOW WERE COMMANDS AND ALL THREE HAVE RUN — cutover,
> `docs/09-cutover-runbook.md`** — Parts 5, 6 and 3 respectively, each with its > 2026-09-02**, verified against the live account 2026-09-04. They are ticked
> verification and the output to expect. Two things that spec found by reading the > 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 > running system rather than the specs, and both would have lost every
> submission: the API route needs its **own** Lambda invoke permission, because > submission: the API route needs its **own** Lambda invoke permission, because
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the > 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 > 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). > `submissionId` and a key schema cannot be altered after creation (§Storage).
- [ ] **CloudFront `/api/*` behaviour created**, 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 - [x]**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**, 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 - [x]**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
+116 -9
View File
@@ -508,9 +508,15 @@ Then invalidate `/*`.
> §7.1 stops before any write and any email by design, and that is `docs/09` > §7.1 stops before any write and any email by design, and that is `docs/09`
> §7.2, the real-submission test Pouya has in progress. The disclosures are > §7.2, the real-submission test Pouya has in progress. The disclosures are
> unblocked; the end-to-end confirmation is still owed. > unblocked; the end-to-end confirmation is still owed.
> 3. ⚠️ **THE D20 CLAIMS PASS RETURNED FAIL WITH 20 CONFIRMED FINDINGS; 15 ARE > 3. ⚠️ **THE D20 CLAIMS PASS RETURNED FAIL WITH 20 CONFIRMED FINDINGS; 17 ARE
> NOW FIXED, 2 REFUTED, 3 OUTSTANDING — updated 2026-09-03, and the three > NOW FIXED, 2 REFUTED, 1 OWED — updated 2026-09-04, and the three numbers
> numbers partition the twenty.** Fixed under Pouya's rule *"the gloss may say > partition the twenty.** **Findings 10 and 13 were both RULED by Pouya on
> 2026-09-03 and are closed** (see below); **the one remaining is 11**, which
> is ruled and waiting on Q60's observation window rather than on a copy
> change. ⚠️ **THIS ITEM STAYS UNTICKED, AND NOT BECAUSE A CLAIM IS WRONG.**
> What is outstanding is a *confirmation that a record was seen to vanish*,
> not a sentence anyone disputes — tick it when Q60 closes. The previous
> tally follows. Fixed under Pouya's rule *"the gloss may say
> no more than the extract says; no new claims, no new sources"*: findings > no more than the extract says; no new claims, no new sources"*: findings
> 19, 1418 and 20 — the whole gloss class, plus `/bio/`'s role verb. > 19, 1418 and 20 — the whole gloss class, plus `/bio/`'s role verb.
> ⚠️ **15 FINDINGS, 14 DISTINCT EDITS: findings 4 and 15 quote the same > ⚠️ **15 FINDINGS, 14 DISTINCT EDITS: findings 4 and 15 quote the same
@@ -518,14 +524,42 @@ Then invalidate `/*`.
> findings 12 and 19, the two backend disclosures, with item 2 above. > findings 12 and 19, the two backend disclosures, with item 2 above.
> **OUTSTANDING — findings 10, 11 and 13, and each is outstanding for a > **OUTSTANDING — findings 10, 11 and 13, and each is outstanding for a
> different reason:** > different reason:**
> **(10) NEEDS A RULING.** `/fees/`'s *"Every figure is on this page"* against > **(10) ✅ RULED AND CLOSED 2026-09-03 — PRICED, NOT NARROWED.** Med-arb is
> §4's **Med-Arb** offering, which `docs/07-fees.md` prices nowhere. Either a > billed **by phase**: the mediation phase at the published mediation rates,
> med-arb fee term or a scoped promise; it cannot be closed by narrowing. > the arbitration phase (if it is reached) at the published arbitration
> rates; additional-party and cancellation terms apply to each phase as they
> apply to that process on its own; **there is no separate med-arb fee.**
> Pouya took the more expensive of the two fixes — the promise is unchanged
> and is now true, rather than being trimmed to fit. `FEES.medArb` is the
> single source, `/fees/` §4 renders it, `docs/07` §Med-arb carries the rule
> **marked INTERIM, set 2026-09-03, reviewed at §12 R5**. ⚠️ **It carries NO
> figure of its own and must not be given one** — a fourth price for a
> process priced twice would disagree with one of them. ⚠️ **AND BECAUSE IT
> IS DERIVED, MOVING A RATE AT R5 MOVES IT SILENTLY**, with no diff on the
> med-arb rule; R5 carries that. Verified by reading the built page.
> *(The original wording of this item follows.)* `/fees/`'s *"Every figure is
> on this page"* against §4's **Med-Arb** offering, which `docs/07-fees.md`
> priced nowhere. Either a med-arb fee term or a scoped promise; it cannot be
> closed by narrowing.
> **(11) IS RULED, AND THE CONFIRMATION IS OWED.** The retention *mechanism* > **(11) IS RULED, AND THE CONFIRMATION IS OWED.** The retention *mechanism*
> sentence on `/legal/privacy/` is unchanged and still ships, deliberately — > sentence on `/legal/privacy/` is unchanged and still ships, deliberately —
> that is blocker 1 above and §9 Q60, reading from 2026-09-04. It is listed so > that is blocker 1 above and §9 Q60, reading from 2026-09-04. It is listed so
> the twenty account for themselves, not because it is unresolved. > the twenty account for themselves, not because it is unresolved.
> **(13) NEEDS HIM TO HAVE SAID IT.** `/legal/privacy/`'s *"if a conflicts > **(13) ✅ RULED AND CLOSED 2026-09-03 — HE SAID IT, AND THE PAGE SAID MORE
> THAN HE SAID.** Pouya attested that he runs a conflicts check on every
> inquiry before engaging. §4 gains **conduct undertaking (g)**, `[attested
> 2026-09-03]`, and `CONDUCT_UNDERTAKINGS` now holds **seven** strings, not
> six. ⚠️ **THE ATTESTATION DOES NOT COVER THE SENTENCE THAT RAISED THE
> FINDING.** Finding 13 quoted a promise to **disclose the outcome** —
> *"I will tell you what its outcome was"* — which is a different commitment
> from running the check, and his instruction was that the page *"may say no
> more than that attestation"*. So the clause is **struck**; the page now
> reads *"it does not undo a conflicts check that has already been run"*, and
> the undertaking itself ships through `<Undertaking>` in §Information about
> other people, replacing a hand-typed near-equivalent. ⚠️ **IT DOES NOT
> REVERSE Q57**, which refused an undertaking about what happens when a check
> turns something up; that one is still refused. *(The original wording of
> this item follows.)* **NEEDS HIM TO HAVE SAID IT.** `/legal/privacy/`'s *"if a conflicts
> check has already been run I will tell you what its outcome was"* is an > check has already been run I will tell you what its outcome was"* is an
> **undertaking**, and §4's gate for that class is one line: Pouya must have > **undertaking**, and §4's gate for that class is one line: Pouya must have
> made it **in terms**. It is not in `CONDUCT_UNDERTAKINGS`. > made it **in terms**. It is not in `CONDUCT_UNDERTAKINGS`.
@@ -753,6 +787,17 @@ the decision is re-readable rather than re-litigated.
not a wording problem: it is the privacy policy of a live site describing a not a wording problem: it is the privacy policy of a live site describing a
mechanism that cannot run, which is the defect class `AGENTS.md` Q22 named. mechanism that cannot run, which is the defect class `AGENTS.md` Q22 named.
⚠️ **THE SECOND CLASS WAS REFUTED — 2026-09-03, AND AGAIN BY DIRECT
MEASUREMENT 2026-09-04.** The backend **is** deployed; the 403 that founded
those two findings was a bare POST with no `Origin` header, which the
handler rejects by design. `docs/09` §7.1 run correctly returns **303**, and
on 2026-09-04 the function's own configuration and its deployed artefact
were read: `handler.handler`, six environment variables, both source files
byte-identical to commit `02739ad`. The paragraph above is preserved as what the pass
found; **only findings 10, 11 and 13 outlived it, and 10 and 13 are now
ruled** — see item 3 of the callout near the top of this file, which is the
current tally and this is not.
⚠️ **AND THE PASS RAN AFTER THE SITE PUBLISHED, WHICH IS THE ONE THING D20 ⚠️ **AND THE PASS RAN AFTER THE SITE PUBLISHED, WHICH IS THE ONE THING D20
RESTED ON AND NO LONGER HAS.** D20's reasoning is explicit that deferring RESTED ON AND NO LONGER HAS.** D20's reasoning is explicit that deferring
the claims pass is safe because *"nothing has shipped and there is no public the claims pass is safe because *"nothing has shipped and there is no public
@@ -1020,7 +1065,29 @@ the decision is re-readable rather than re-litigated.
- [ ] Security headers present (`securityheaders.com` A or better) - [ ] Security headers present (`securityheaders.com` A or better)
- [x] **SES identities verified for sending** — `VerifiedForSendingStatus: true`, `DkimAttributes.Status: SUCCESS`, signing enabled, and no custom MAIL FROM (so DMARC rests on DKIM alignment, which is what §7 records) `[re-verified 2026-09-01 — sesv2 get-email-identity]` - [x] **SES identities verified for sending** — `VerifiedForSendingStatus: true`, `DkimAttributes.Status: SUCCESS`, signing enabled, and no custom MAIL FROM (so DMARC rests on DKIM alignment, which is what §7 records) `[re-verified 2026-09-01 — sesv2 get-email-identity]`
- [x] ✅ **SES bounce/complaint alarms DO notify someone — R9 DISCHARGED, 2026-09-01.** `aws sns list-subscriptions-by-topic` on `ses-alerts` returns the email subscription to `info@smlcompany.ca` with a **real subscription ARN**, not `PendingConfirmation`. §7 recorded it as pending, and §12 R9 said *"this is the first thing to check if `/contact/` ships"* — it had been confirmed at some point before this reading and the record had not moved, which is the same staleness in the safe direction. *(SES production access itself is granted — Q19 closed.)* - [x] ✅ **SES bounce/complaint alarms DO notify someone — R9 DISCHARGED, 2026-09-01.** `aws sns list-subscriptions-by-topic` on `ses-alerts` returns the email subscription to `info@smlcompany.ca` with a **real subscription ARN**, not `PendingConfirmation`. §7 recorded it as pending, and §12 R9 said *"this is the first thing to check if `/contact/` ships"* — it had been confirmed at some point before this reading and the record had not moved, which is the same staleness in the safe direction. *(SES production access itself is granted — Q19 closed.)*
- [ ] **THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE - [ ] 🛑 **THE END-TO-END SUBMISSION TEST IS STILL OWED — `docs/09` §7.2.**
The route answers (§7.1 returns **303**), which is a different fact:
**§7.1 stops before any DynamoDB write and before any SES send, by
design.** What is unproven is that a real submission stores a record and
that **both** emails arrive — the notification and the inquirer's
confirmation, D18's whole point. ⚠️ **THIS ITEM DID NOT EXIST FOR ONE
ROUND.** Ticking "the intake form works" below removed the only unticked
line covering §7.2, so the one genuinely outstanding intake verification
lived inside an item marked done. Pouya has this in progress; §7.2 also
says to read `sourceIp` against `checkip` and to delete the test record
- [x] ✅ **THE INTAKE FORM WORKS — all three happened at cutover, 2026-09-02**,
and every one was re-verified against the live account on 2026-09-04:
`handler.handler` with six variables, one route `POST /api/intake`, and the
`/api/*` behaviour on the distribution. `docs/09` §7.1 returns **303**.
⚠️ **THIS ITEM READ "THE INTAKE FORM DOES NOT WORK YET" UNTIL 2026-09-04**,
unticked, near the top of the list an operator follows — the same staleness
as §7's two intake rows and from the same cause: the list was written under
D11 and never re-read after Part 5 ran. **What is still owed is §7.2**, the
real-submission test that proves both emails arrive; §7.1 stops before any
write and any send by design. **The original text follows, because the two
things it records are what made this hard and they are still true of the
code.**
**THE INTAKE FORM DOES NOT WORK YET, AND THREE THINGS HAVE TO HAPPEN BEFORE
IT DOES — build step 8 shipped the page and not the pipe.** IT DOES — build step 8 shipped the page and not the pipe.**
⚠️ **THE COMMANDS ARE `docs/09-cutover-runbook.md` PARTS 5 AND 6, AND ⚠️ **THE COMMANDS ARE `docs/09-cutover-runbook.md` PARTS 5 AND 6, AND
WRITING THEM FOUND TWO MORE THINGS, EACH OF WHICH WOULD HAVE LOST EVERY WRITING THEM FOUND TWO MORE THINGS, EACH OF WHICH WOULD HAVE LOST EVERY
@@ -1141,8 +1208,48 @@ the decision is re-readable rather than re-litigated.
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
identical content differ in digest and every re-render is a binary diff. identical content differ in digest and every re-render is a binary diff.
Re-commit it when something actually changed, and say what in the message Re-commit it when something actually changed, and say what in the message
- [ ] 🛑 **THE SPAM MITIGATIONS ARE HALF-SHIPPED BY A DEPLOY, AND THE HALF THAT
MATTERS IS NOT — 2026-09-04.** `scripts/deploy-local.sh` does an S3 sync
and a CloudFront invalidation and **nothing else**: it contains no Lambda
step `[verified 2026-09-04 — read]`. So `npm run deploy` ships the second
honeypot, because that is markup in `dist/contact/index.html`, and ships
**neither the check that reads it nor the spam scoring**, because both are
in `backend/intake/`. **The handler needs `docs/09` Part 5** — 5.1, 5.2,
5.3, then **5.4, and 5.5 if 5.4 fires**, which it did at cutover.
⚠️ **`spam-score.mjs` IS A THIRD FILE IN THE ZIP, AND SINCE 2026-09-04 BOTH
5.1 AND 5.5 DERIVE THE LIST FROM THE DIRECTORY RATHER THAN NAMING IT** —
they were hand-typed in both, with nothing checking they agreed, until the
review found it. A zip missing a module fails at cold start with
`Runtime.ImportModuleError` and every submission then 500s. Run
`node backend/intake/spam-score.test.mjs` (**39 of 39**) before packaging.
**There is no ordering hazard either way**: a form ahead of the handler
renders a field nothing checks, and a handler ahead of the form checks a
field nothing renders. Both are inert, so the only cost of doing one and
not the other is that the mitigation is not yet in force
- [ ] **`CloudFront-Viewer-Address` forwarded on `/api/*`** — ⚠️ **WRITTEN
2026-09-04, NOT YET APPLIED. Same `configure.mjs --apply` run as the item
below; not a deploy.** `infra/cloudfront/configure.mjs` §5 creates a custom
origin request policy `adr-sml-api-viewer-address` and points the `/api/*`
behaviour at it. Pouya's ruling of 2026-09-04, after the first real spam:
forward it **so per-IP measures become possible later — measured, not yet
acted on**. 🛑 **THIS IS THE ONLY CHANGE IN `configure.mjs` THAT REPLACES
RATHER THAN ADDS, AND IT REPLACES THE POLICY ON THE PATH THE INTAKE FORM
POSTS TO.** AWS has no behaviour meaning *"all viewer headers except Host,
plus a CloudFront header"* — `allExcept` can only subtract, and
`allViewerAndWhitelistCloudFront` forwards `Host` and 403s at API Gateway
(derived from the API's own enum, 2026-09-04). A **whitelist** is forced,
so the five listed headers are load-bearing: the handler's four `headerOf`
reads plus the new one. **A missing header does not error — every
submission would validate short and land on `/contact/could-not-send/`,
which reads as the inquirer's own browser misbehaving.** So `docs/09`
Part 3's `303` probe and its one-field rollback are **mandatory** after
this, not advisory. ⚠️ **AND THE HANDLER STILL STORES THE EDGE ADDRESS.**
Forwarding is infrastructure; **storing** the viewer address is a
`/legal/privacy/` change governed by `docs/09` §7.2's decision table, and
it is deliberately not made here
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`** — ⚠️ **WRITTEN 2026-09-03, NOT YET - [ ] **`X-Robots-Tag: noindex` on `*.pdf`** — ⚠️ **WRITTEN 2026-09-03, NOT YET
APPLIED. It needs a `configure.mjs --apply` run, not a deploy.** APPLIED. It needs a `configure.mjs --apply` run, not a deploy** — the same
run as the item above; one `--apply` does both.
`infra/cloudfront/configure.mjs` §4 creates a response-headers policy `infra/cloudfront/configure.mjs` §4 creates a response-headers policy
`adr-sml-pdf-noindex` and a `*.pdf` cache behaviour carrying it. ⚠️ **S3 `adr-sml-pdf-noindex` and a `*.pdf` cache behaviour carrying it. ⚠️ **S3
OBJECT METADATA CANNOT DO THIS, which is the natural first reach and was OBJECT METADATA CANNOT DO THIS, which is the natural first reach and was
+49
View File
@@ -249,6 +249,46 @@ paragraph this one used to point at.
**No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not **No tribunal-secretary rate.** Removed by Pouya. Do not reinstate it, and do not
offer tribunal-secretary work on the site. offer tribunal-secretary work on the site.
### Med-arb — billed by phase
⚠️ **INTERIM. Set by Pouya 2026-09-03; reviewed at the twelve-month fee review,
`AGENTS.md` §12 R5.** It is stamped interim because it is the only rule on this
page set after the card was published rather than with it, and because it prices
an offering by reference to two other rows — if either moves at R5, this moves
with them and nobody will be reminded by a figure changing.
**The rule, and it carries no figure of its own:**
- Med-arb is billed **by phase**. The mediation phase is charged at the
**mediation** rates above. If the matter proceeds to arbitration, that phase is
charged at the **arbitration** rates above.
- **There is no separate med-arb fee.**
- The additional-party and cancellation terms apply to each phase **as they
apply to that process on its own**.
**Why this rule exists at all, because a fee page does not usually need one.**
`/fees/` opens *"Every figure is on this page"*, and `AGENTS.md` §4 Offerings
carries a **Med-Arb** row that this document priced nowhere. The promise was
therefore wider than the card — the D20 cutover claims pass, finding 10. Pouya
closed it by **pricing the offering rather than narrowing the promise**, which is
the more expensive of the two fixes and the one that leaves the page saying the
stronger thing.
⚠️ **DO NOT GIVE MED-ARB A RATE ROW.** A med-arb figure would be a fourth price
for a process that is already priced twice, and the first thing it would do is
disagree with one of them. The rule is expressed as a pointer to the two cards
above **on purpose**; that is what keeps the count of published figures the same
as the count of published rates.
⚠️ **"AS THEY APPLY TO THAT PROCESS ON ITS OWN" IS NOT "TO BOTH PHASES".** The
additional-party fee is a **mediation** row; the arbitration card has no
equivalent. The wording above invents nothing. *"The additional-party term
applies throughout"* would invent an additional-party charge in the arbitral
phase, which no ruling has set.
`FEES.medArb` in `src/data/site.ts` holds the three sentences and `/fees/`
renders them, so the rule is not retyped into the template.
### Other services — hourly ### Other services — hourly
Early neutral evaluation, dispute-system design, and pre-dispute technical Early neutral evaluation, dispute-system design, and pre-dispute technical
@@ -313,6 +353,15 @@ for a reader with no counsel to catch it.)*
## Recorded dissent — for the 12-month review (R5) ## Recorded dissent — for the 12-month review (R5)
⚠️ **SECOND ITEM FOR R5, ADDED 2026-09-04 — MED-ARB, AND IT IS NOT A DISSENT.**
It is here because **R5 names this section as where its items live**, and the
med-arb rule was stamped INTERIM against R5 in §Med-arb above and written into no
list the review actually reads. **The rule is derived** — each phase at the rates
for that process, no figure of its own — so **moving any mediation or arbitration
number at R5 moves the med-arb price with it, silently, with no diff on the
med-arb rule.** Nothing else on this page has that property. Check it against
whatever the review does to the two cards above.
Claude recommended a two-tier card; Pouya set a single rate. The reasoning is Claude recommended a two-tier card; Pouya set a single rate. The reasoning is
recorded here so the 12-month review has something to test against, not to recorded here so the 12-month review has something to test against, not to
re-open a settled decision. re-open a settled decision.
+269 -26
View File
@@ -309,7 +309,7 @@ status, not the absence of an error.
--- ---
## Part 3 — Apply the four distribution changes ## Part 3 — Apply the five distribution changes
One script, `infra/cloudfront/configure.mjs`, because the alternative is One script, `infra/cloudfront/configure.mjs`, because the alternative is
hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag. hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag.
@@ -328,13 +328,15 @@ Part 0.3 records is exactly:
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
resolved Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac resolved Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
6 change(s) to distribution E1OK7G98KNKUTA (ETag …): 8 change(s) to distribution E1OK7G98KNKUTA (ETag …):
+ DefaultCacheBehavior.FunctionAssociations viewer-request -> arn:…:function/adr-sml-router + DefaultCacheBehavior.FunctionAssociations viewer-request -> arn:…:function/adr-sml-router
+ CustomErrorResponses += 404 -> /404.html with status 404 + CustomErrorResponses += 404 -> /404.html with status 404
+ Origins += intake-api -> …execute-api… (https-only, TLSv1.2) + Origins += intake-api -> …execute-api… (https-only, TLSv1.2)
+ CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed + CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed
+ create response-headers policy adr-sml-pdf-noindex (SecurityHeadersConfig cloned from … + X-Robots-Tag: noindex) + create response-headers policy adr-sml-pdf-noindex (SecurityHeadersConfig cloned from … + X-Robots-Tag: noindex)
+ CacheBehaviors += *.pdf -> <s3-origin>, default cache policy, adr-sml-pdf-noindex (policy id created in the same --apply pass) + CacheBehaviors += *.pdf -> <s3-origin>, default cache policy, adr-sml-pdf-noindex (policy id created in the same --apply pass)
+ create origin request policy adr-sml-api-viewer-address (whitelist: CloudFront-Viewer-Address, Content-Type, Origin, Referer, User-Agent; cookies all; query strings all)
+ /api/* OriginRequestPolicyId b689b0a8-… -> adr-sml-api-viewer-address
DRY RUN — nothing was sent. Re-run with --apply to write it. DRY RUN — nothing was sent. Re-run with --apply to write it.
``` ```
@@ -348,11 +350,150 @@ behaviour in one call — do not run it twice.** The dry run reports both change
either way; one that listed only the policy would hide the half that touches a either way; one that listed only the policy would hide the half that touches a
distribution serving 23 pages. distribution serving 23 pages.
Fewer than six changes means part of this is already done — read which lines are Fewer than eight changes means part of this is already done — read which lines
prefixed `·` (already present) and carry on. **On the live distribution as at are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
2026-09-03, changes 13 are applied and you should see exactly the last two.** for *already present* **and** for *would CREATE / would SET / would ADD*, so the
More than six, or a different set, means the distribution is not in the state 0.3 prefix alone does not say whether a line is done or still pending. **On the live distribution as at
recorded: stop and re-read it. 2026-09-04 the dry run returns exactly four `+` lines — the two for section 4
and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
`E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set,
means the distribution is not in the state 0.3 recorded: stop and re-read it.
⚠️ **RUN IT WITHOUT `--function-arn` ONLY IF THE ROUTER IS ALREADY ATTACHED.**
Omitting the flag prints `· no --function-arn given, leaving FunctionAssociations
alone` and skips change 1 — which is right on a re-run and wrong on a first one,
and the two look identical in a count.
🛑 **SECTION 5 IS THE ONLY ONE THAT REPLACES SOMETHING, AND WHAT IT REPLACES IS
ON THE INTAKE FORM'S PATH.** Sections 14 add. Section 5 swaps the origin request
policy on `/api/*` from `Managed-AllViewerExceptHostHeader` to a **whitelist** of
five headers, because AWS has no behaviour meaning "all viewer headers except
Host, plus a CloudFront header" — `allExcept` can only subtract, and
`allViewerAndWhitelistCloudFront` drags `Host` along and 403s at API Gateway.
Whitelisting is therefore forced, and the cost is that **a header missing from
that list is a header the handler never sees.** The list is the handler's four
`headerOf` reads plus `CloudFront-Viewer-Address`. The check prints the names,
so they can be compared to the whitelist rather than counted:
```bash
grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \
| sed "s/.*'\(.*\)'/\1/" | sort
```
**Expect** exactly `content-type`, `origin`, `referer`, `user-agent`.
⚠️ **`grep -n "headerOf(event"` WAS PRESCRIBED HERE AND RETURNS FIVE** — it
matches `function headerOf(event, name)`, the definition itself — so an operator
comparing it against a documented "four" concludes the handler grew a read.
**The failure mode is not an error.** Every submission would validate short and
redirect to `/contact/could-not-send/` — a real inquirer would read it as their
own browser misbehaving, and nothing would appear in a log as a fault. So the
block below is **not optional after an `--apply` that includes change 8**, and there are
**three** of them. The first is Part 7.1's probe with its output read differently
**not "unchanged", which this said for one round**: §7.1 pipes into `head -12`
and reads the status by eye, while these read curl's own exit status and count
the `location` separately.
**Run all three, in this order, and each answers a different question:**
| # | probe | what only it can tell you |
|---|---|---|
| 1 | `Origin` + body | `Origin` is still forwarded — a **403** means it is not |
| 2 | `Referer`, no `Origin` | the Firefox fallback still works — nothing else tests it |
| 3 | honeypot value | the **body parsed** — probes 1 and 2 return the same 303 whether it did or not |
**PROBE 1 — is `Origin` still forwarded?**
```bash
curl -si -X POST "$SITE/api/intake" \
-H 'Origin: https://adr.smlcompany.ca' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'probe=1' -o /tmp/api.h
echo "curl_exit=$?" # curl's OWN status, on its own line
head -1 /tmp/api.h
grep -ic '^location: .*could-not-send' /tmp/api.h
```
**Expect** `curl_exit=0`, `HTTP/2 303`, and `1`. A **403** here means the
`Origin` header is no longer reaching the handler — i.e. the whitelist dropped
it — and the form is broken for everyone.
**PROBE 2 — the `Referer` fallback, which nothing else tests.** The handler
accepts `Referer` when `Origin` is absent (Firefox omits `Origin` on some
same-origin form navigations), so a whitelist that forwarded `Origin` and dropped
`Referer` passes probe 1 and fails for exactly those users:
```bash
curl -si -X POST "$SITE/api/intake" \
-H 'Referer: https://adr.smlcompany.ca/contact/' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'company_website=probe' -o /tmp/api3.h
echo "curl_exit=$?"
head -1 /tmp/api3.h
grep -ic '^location: .*contact/received' /tmp/api3.h
```
**Expect** `curl_exit=0`, `303` and `1` `[verified against production 2026-09-04
— it returns 303 today, on the managed policy]`. A **403** means `Referer` is not
being forwarded.
🛑 **PROBE 3, AND NEITHER OF THE FIRST TWO CAN REPLACE IT: THEY CANNOT FAIL IN THE
INTERESTING DIRECTION.** `303 →
could-not-send` is what the handler returns **both** when it parsed the body and
found an empty submission **and** when `parseBody` threw because
`Content-Type` never arrived. Two opposite outcomes, one status, one location —
so a dropped `Content-Type` reads as a pass. This probe separates them, and
**writes nothing and sends nothing**:
```bash
curl -si -X POST "$SITE/api/intake" \
-H 'Origin: https://adr.smlcompany.ca' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'company_website=probe' -o /tmp/api2.h
echo "curl_exit=$?"
head -1 /tmp/api2.h
grep -ic '^location: .*contact/received' /tmp/api2.h
```
**Expect** `curl_exit=0`, `HTTP/2 303`, and `1` — location
`/contact/received/`, **not** `could-not-send`. That is the honeypot branch: it
is reached **only if the body parsed**, and it returns before validation, before
any DynamoDB write and before any SES send, so it leaves no record and sends no
email. `could-not-send` here means the body did not parse — `Content-Type` is
missing from the whitelist. **Roll back.**
⚠️ **IT DEPENDS ON THE HONEYPOT'S NAME** (`company_website`, `fields.mjs`). If
that is ever renamed, this probe degrades to the `could-not-send` branch — which
reads as a failure and starts an investigation, not as a pass. That direction is
the safe one; keep it that way if you change the probe.
**ROLLBACK, and it is one field.** Do not debug a broken intake form in place:
```bash
# ⚠️ THIS RETURNS THE ID THE BEHAVIOUR HAS NOW — which, if change 8 applied, is
# the whitelist you are rolling back FROM, not the value to restore. The value to
# restore is the managed id on the line below. Run this to confirm which state
# you are in, then PUT the managed id back with
# update-distribution --if-match. ⚠️ NOT by re-running configure.mjs: section 5
# converges FORWARD and cannot tell a deliberate revert from a first run — the
# two are byte-identical in the config — so --apply would re-attach the
# whitelist and put the form back in the state you are rolling back from.
aws cloudfront get-distribution-config --id "$DIST_ID" \
--query 'DistributionConfig.CacheBehaviors.Items[?PathPattern==`/api/*`].OriginRequestPolicyId'
# Managed-AllViewerExceptHostHeader = b689b0a8-53d0-40ab-baf2-68738e2966ac
```
Set that behaviour's `OriginRequestPolicyId` back to
`b689b0a8-53d0-40ab-baf2-68738e2966ac` and `update-distribution` with the current
ETag. `configure.mjs` prints the same id on the line it changes, prefixed `↩`, at
the moment it changes it.
⚠️ **THE HANDLER STILL STORES THE EDGE ADDRESS AFTER THIS.** Forwarding the
header does not change what is recorded, and it must not be made to as a
follow-up edit: what the record holds is published field by field on
`/legal/privacy/`, so storing `CloudFront-Viewer-Address` is a **disclosure**
change governed by §7.2's decision table, not a code tidy. Pouya's ruling of
2026-09-04 is *measured, not yet acted on*.
⚠️ **AND `adr-sml-pdf-noindex` IS RECONCILED ON EVERY RUN, NOT ONLY CREATED.** A ⚠️ **AND `adr-sml-pdf-noindex` IS RECONCILED ON EVERY RUN, NOT ONLY CREATED.** A
response-headers policy **replaces** rather than merges, so the PDF policy has to response-headers policy **replaces** rather than merges, so the PDF policy has to
@@ -382,6 +523,18 @@ aws cloudfront get-distribution-config --id "$DIST_ID" \
would turn the form's POST into a GET and drop the body), and `*.pdf` → the S3 would turn the form's POST into a GET and drop the body), and `*.pdf` → the S3
origin **with an `RHP` id and `Fn2: ["viewer-request"]`**; two origins. origin **with an `RHP` id and `Fn2: ["viewer-request"]`**; two origins.
⚠️ **THAT QUERY DOES NOT PROJECT `OriginRequestPolicyId`, SO IT CANNOT SEE
CHANGE 8.** Read it separately rather than concluding anything from its absence:
```bash
aws cloudfront get-distribution-config --id "$DIST_ID" \
--query 'DistributionConfig.CacheBehaviors.Items[].{P:PathPattern,ORP:OriginRequestPolicyId}'
```
**Expect** `/api/*` carrying the **`adr-sml-api-viewer-address`** id — *not*
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, which is the managed policy it replaced
and is what a rollback restores.
**Then verify the header actually arrives, because the config landing is not the **Then verify the header actually arrives, because the config landing is not the
same fact:** same fact:**
@@ -442,16 +595,40 @@ after 8.4, when both halves are true at once.
### 5.1 Package ### 5.1 Package
🛑 **THREE FILES SINCE 2026-09-04, AND THE ZIP FOLLOWS NO IMPORT.**
`handler.mjs` imports both `./fields.mjs` and `./spam-score.mjs`; a zip missing
either fails at cold start with `Runtime.ImportModuleError` and every submission
then 500s. **The list is now derived from the directory**`ls *.mjs` minus the
tests — in this step and in 5.5, so a new module is packaged without editing
anything. It was typed out in both until 2026-09-04, and this banner still said
so, fifteen lines above the paragraph that says otherwise.
```bash ```bash
rm -f /tmp/intake.zip rm -f /tmp/intake.zip
(cd backend/intake && zip -q -X /tmp/intake.zip handler.mjs fields.mjs) (cd backend/intake \
&& echo "packaging: $(ls *.mjs | grep -v '\.test\.' | tr '\n' ' ')" \
&& zip -q -X /tmp/intake.zip $(ls *.mjs | grep -v '\.test\.'))
unzip -l /tmp/intake.zip unzip -l /tmp/intake.zip
``` ```
**Expect:** exactly two entries, `handler.mjs` and `fields.mjs`, **≈ 25.7 KB ⚠️ **THE LIST IS SUBSTITUTED DIRECTLY, NOT HELD IN A VARIABLE, AND THAT IS NOT
uncompressed and ≈ 10.8 KB zipped** `[measured 2026-09-01]`. Both at the zip root — STYLE.** A first version read `MODULES=$(ls …)` then `zip … $MODULES`. **In zsh
`handler.mjs` imports `./fields.mjs`, so a nested directory breaks the import at that packages ONE file whose name is all three joined by newlines** — zsh does
cold start. not word-split parameter expansions, only command substitutions — so it fails on
the shell this project is actually operated from while working in bash.
`CLAUDE.md` names this trap; it was reintroduced here and caught by running the
block in both shells rather than by reading it.
**Expect:** exactly three entries — `handler.mjs`, `fields.mjs`,
`spam-score.mjs`**42,604 bytes uncompressed and 18,462 zipped**
`[measured 2026-09-04]`. All three at the zip root: the imports are `./`-relative,
so a nested directory breaks them at cold start. *(This read "two entries, ≈ 25.7
KB / ≈ 10.8 KB" `[measured 2026-09-01]`, before the scorer existed.)*
⚠️ **`spam-score.test.mjs` IS NOT IN THE ZIP AND MUST NOT BE.** Run it at a
keyboard — `node backend/intake/spam-score.test.mjs`, **39 of 39** — before
packaging. It is the only check on the scorer, whose failure mode is labelling
real inquiries rather than throwing.
### 5.2 Configuration first, code second ### 5.2 Configuration first, code second
@@ -500,11 +677,22 @@ aws lambda get-function-configuration --function-name "$FN" \
--query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}' --query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}'
``` ```
**Expect:** `CodeSize` **≈ 10,800** (up from 1,527), `Update: Successful`. **Expect** `Update: Successful`, and a `CodeSize` that says **which path you
⚠️ **`CodeSize` is the ZIP, not the source.** This line said "around 23,000", took** — it is the ZIP, not the source:
which was 5.1's uncompressed figure applied to a different quantity — an
operator seeing `10819` against an expectation of 23,000 would reasonably | path | expected `CodeSize` |
conclude the wrong artefact went up. |---|---|
| 5.1's plain three-file zip | **≈ 18,462** |
| 5.5's bundled variant | **low single-digit MB** — it was **3,307,021** on 2026-09-02 `[measured 2026-09-04 — get-function-configuration]` |
🛑 **5.5 IS THE PATH THAT WAS ACTUALLY TAKEN AT CUTOVER.** The live function
carries the bundled zip, so **a redeploy that runs 5.1 and stops would replace it
with an unbundled one and reintroduce the `Runtime.ImportModuleError` 5.5 exists
to fix.** Run 5.4 after 5.3, every time, and follow it to 5.5 if it fires.
*(This line said "≈ 10,800", and before that "around 23,000" — 5.1's uncompressed
figure applied to a different quantity. Both were written against the unbundled
path, which is not the one in production.)*
### 5.4 Prove it loads, without writing anything ### 5.4 Prove it loads, without writing anything
@@ -545,16 +733,36 @@ Versions are resolved from the registry at run time rather than pinned in this
file: `CLAUDE.md`'s rule is that a version is checked against the registry and file: `CLAUDE.md`'s rule is that a version is checked against the registry and
never recalled, and a literal here would be stale the week after it was written. never recalled, and a literal here would be stale the week after it was written.
⚠️ **THIS PATH WAS TAKEN — 2026-09-02, and the live function is the bundled
zip** `[measured 2026-09-04 — the deployed artefact was downloaded via
`get-function` `Code.Location` and read]`. Its two source entries were
byte-identical to commit `02739ad`, and the two packages inside it are
**`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**.
`AGENTS.md` §7 now records them, which this step required in terms and which did
not happen at the time.
**THE `cp` AND `zip` LINES BELOW DERIVE THE FILE LIST THE SAME WAY 5.1 DOES.**
They were a second hand-typed copy until 2026-09-04, not derived from 5.1's and
with nothing checking that the two agreed — so a module added to one and not the
other would ship from whichever path the operator happened to take. Both now read
the directory.
```bash ```bash
rm -rf /tmp/intake-bundle && mkdir -p /tmp/intake-bundle rm -rf /tmp/intake-bundle && mkdir -p /tmp/intake-bundle
cp backend/intake/handler.mjs backend/intake/fields.mjs /tmp/intake-bundle/ echo "bundling: $(cd backend/intake && ls *.mjs | grep -v '\.test\.' | tr '\n' ' ')"
(cd backend/intake && cp $(ls *.mjs | grep -v '\.test\.') /tmp/intake-bundle/)
# ⚠️ ASSERT THE COPY LANDED. A glob that matches nothing makes `cp` fail, `zip`
# succeed on an empty set, and `update-function-code` upload a bundle with no
# handler — a silent failure that only shows up as 5xx on the live form.
test -f /tmp/intake-bundle/handler.mjs || { echo "FATAL: handler.mjs not copied"; exit 1; }
echo "copied: $(ls /tmp/intake-bundle/*.mjs | wc -l | tr -d ' ') module(s)"
( cd /tmp/intake-bundle \ ( cd /tmp/intake-bundle \
&& npm init -y > /dev/null \ && npm init -y > /dev/null \
&& npm install --omit=dev --no-audit --no-fund \ && npm install --omit=dev --no-audit --no-fund \
"@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \ "@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \
"@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" ) "@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" )
rm -f /tmp/intake.zip rm -f /tmp/intake.zip
( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip handler.mjs fields.mjs node_modules package.json ) ( cd /tmp/intake-bundle && zip -qr -X /tmp/intake.zip $(ls *.mjs) node_modules package.json )
unzip -l /tmp/intake.zip | tail -1 unzip -l /tmp/intake.zip | tail -1
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
aws lambda wait function-updated --function-name "$FN" aws lambda wait function-updated --function-name "$FN"
@@ -687,9 +895,20 @@ have named the cause — API Gateway's `{"message":"Not Found"}` — is replaced
you see it. **Check the route first; it is one command:** you see it. **Check the route first; it is one command:**
`aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`. `aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`.
**403** means the `Origin` header did not arrive — check that the behaviour uses **403** means the `Origin` header did not arrive, and **as of 2026-09-04 there
`Managed-AllViewerExceptHostHeader`, because a policy that drops `Origin` turns are two policies it could be** — read which one the behaviour carries before
every real submission into a 403. **500** means Part 6.1 was skipped. repairing:
- **`adr-sml-api-viewer-address`** (Part 3, change 8) — a **whitelist**. If
`Origin` is missing from its Headers list, or the list drifted, every real
submission 403s. Roll back by PUTting the managed id below with
`update-distribution --if-match`**not** by re-running `configure.mjs`,
which converges forward and would re-attach the whitelist.
- **`Managed-AllViewerExceptHostHeader`** (`b689b0a8-53d0-40ab-baf2-68738e2966ac`)
— what it replaced, and what a rollback restores.
Either way, a policy that drops `Origin` turns every real submission into a 403.
**500** means Part 6.1 was skipped.
### 7.2 A real submission, from the real form ### 7.2 A real submission, from the real form
@@ -735,8 +954,14 @@ the client sent. The fix, if a usable value is wanted, is a **custom** origin
request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
CloudFront generates and overwrites — not the managed CloudFront generates and overwrites — not the managed
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request `AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
at API Gateway. That is an infrastructure change and it is deliberately not in at API Gateway. ⚠️ **THAT CHANGE IS NOW IN THIS RUNBOOK — Part 3, change 8, written
this runbook: measure first. 2026-09-04 on Pouya's ruling and NOT YET APPLIED.** This paragraph said it was
*"deliberately not in this runbook: measure first"*, which was true until the
ruling and false afterwards. **Forwarding the header does not change what is
stored:** `viewerIp()` still records `requestContext.http.sourceIp`, and the
decision table above is still the procedure for changing that, because what the
record holds is published field by field on `/legal/privacy/`. Measure first
still governs the STORING, not the forwarding.
**Expect** the item, with `ttl` a 10-digit epoch-seconds value. Check it is 24 **Expect** the item, with `ttl` a 10-digit epoch-seconds value. Check it is 24
months out — read it, do not assume it: months out — read it, do not assume it:
@@ -913,9 +1138,27 @@ Each of these is independent. None of them needs the others undone first.
Missing keys go back to 403 and the 404 mapping stops firing; nothing else changes. Missing keys go back to 403 and the 404 mapping stops firing; nothing else changes.
**9.2 Parts 23** — re-run `configure.mjs` is *not* a rollback; it is idempotent **9.2 Parts 23** — re-run `configure.mjs` is *not* a rollback; it is idempotent
forward-only. To undo, `get-distribution-config`, remove the forward-only, **and that now matters most for change 8**: section 5 re-attaches
the `/api/*` whitelist on the next `--apply`, because a deliberately reverted
behaviour and a never-configured one are byte-identical in the config and no
detector can separate them. To undo, `get-distribution-config`, remove the
`FunctionAssociations` entry / the `404` custom error response / the `/api/*` `FunctionAssociations` entry / the `404` custom error response / the `/api/*`
behaviour and the `intake-api` origin, and `update-distribution --if-match`. Then behaviour and the `intake-api` origin, and `update-distribution --if-match`.
**Sections 4 and 5 were added after this paragraph and undo the same way:**
put `/api/*`'s `OriginRequestPolicyId` back to
`b689b0a8-53d0-40ab-baf2-68738e2966ac` (the managed policy) and/or remove the
`*.pdf` behaviour, with `update-distribution --if-match`. The two custom policies
`adr-sml-api-viewer-address` and `adr-sml-pdf-noindex` can then be deleted with
`delete-origin-request-policy` / `delete-response-headers-policy`, each of which
**fails while still attached** — the same ordering feature as the function below.
⚠️ **Deleting the policies does not prevent re-attachment either** — the next
`--apply` simply creates them again by name and attaches them. Nothing in this
script can be made to remember a deliberate revert, because a reverted behaviour
and a never-configured one are byte-identical in the config. **The rollback holds
only until someone runs `configure.mjs --apply` again**; that is a property of a
forward-converging script, and the fix if it ever matters is a flag, not a
deletion. Then
`aws cloudfront delete-function --name adr-sml-router --if-match <etag>`, which `aws cloudfront delete-function --name adr-sml-router --if-match <etag>`, which
fails while the function is still associated — that ordering is a feature. fails while the function is still associated — that ordering is a feature.
+13
View File
@@ -100,4 +100,17 @@ export default [
'no-console': 'off', 'no-console': 'off',
}, },
}, },
/* THE BACKEND TEST FILE ONLY — NOT `backend/intake/**`. `handler.mjs` runs in
Lambda, where `console.log` is a line in CloudWatch that nobody reads and
`console.warn`/`console.error` are the two that signal, so the rule stays on
for it deliberately. The test beside it is a CLI tool and prints its verdict,
exactly as `scripts/` and the router test do.
⚠️ LAST, LIKE THE TWO ABOVE. Flat config applies matching blocks in order
and the last one wins. */
{
files: ['backend/**/*.test.mjs'],
rules: { 'no-console': 'off' },
},
]; ];
+311 -4
View File
@@ -1,5 +1,5 @@
/** /**
* Applies the four distribution changes the site needs, as one reviewable * Applies the five distribution changes the site needs, as one reviewable
* transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it. * transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it.
* *
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer * 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
@@ -12,6 +12,12 @@
* 4. A `*.pdf` cache behaviour carrying a response-headers policy that adds * 4. A `*.pdf` cache behaviour carrying a response-headers policy that adds
* `X-Robots-Tag: noindex`, so the bio PDF is not indexed as a duplicate of * `X-Robots-Tag: noindex`, so the bio PDF is not indexed as a duplicate of
* `/bio/`. `docs/06`'s checklist item carries the reasoning. * `/bio/`. `docs/06`'s checklist item carries the reasoning.
* 5. A custom origin request policy on `/api/*` forwarding
* `CloudFront-Viewer-Address` — the only address CloudFront generates and
* overwrites, so the only one that could ever support a per-IP measure.
* ⚠️ THE ONLY ITEM HERE THAT REPLACES RATHER THAN ADDS, and it replaces
* the policy on the path the intake form posts to. `docs/09` Part 3's
* verification block runs after it, not optionally.
* *
* ⚠️ DRY RUN BY DEFAULT. It prints what it would change and exits 0 without * ⚠️ DRY RUN BY DEFAULT. It prints what it would change and exits 0 without
* calling `update-distribution`. `--apply` is the only thing that writes, and it * calling `update-distribution`. `--apply` is the only thing that writes, and it
@@ -34,6 +40,7 @@
* node infra/cloudfront/configure.mjs ... --apply * node infra/cloudfront/configure.mjs ... --apply
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const flag = (name) => { const flag = (name) => {
@@ -574,6 +581,303 @@ if (!defaultRhpId) {
} }
} }
/* ---- 5. CloudFront-Viewer-Address on /api/* -----------------------------
⚠️ THIS IS THE ONE CHANGE IN THIS FILE THAT CAN BREAK A LIVE FORM, AND THE
VERIFY-AND-ROLLBACK BLOCK IN `docs/09` PART 3 IS NOT OPTIONAL AFTER IT.
Everything else here ADDS. This one REPLACES the origin request policy on the
behaviour that carries real legal inquiries: get the header set wrong and
every submission redirects to /contact/could-not-send/, which looks like a
browser problem and is not.
WHY A WHITELIST, WHICH IS NOT THE OBVIOUS CHOICE. The wanted forwarding is
"every viewer header except Host, plus CloudFront-Viewer-Address", and NO
ORIGIN REQUEST POLICY EXPRESSES IT. ⚠️ THAT IS A CLAIM ABOUT ORIGIN REQUEST
POLICIES, NOT ABOUT AWS, AND IT SAID "AWS has no behaviour that expresses it"
FOR ONE ROUND — the shape `CLAUDE.md` names: "no mechanism can X" is a claim
about every mechanism, including the ones you did not enumerate. **The one
not enumerated: a viewer-request CloudFront Function on /api/* that copies
`event.viewer.ip` into a custom header, leaving the managed policy in place.**
That removes this section's entire failure class — nothing can be dropped
because nothing is re-listed — at the cost of a second function on a path
whose "no function association" comment is load-bearing for a different
reason (a 301 would turn the POST into a GET; a header-only function would
not). It is not built because Pouya's ruling names an origin request policy;
it is written down so the choice is visible rather than implied.
Derived from the API's own enum, not recalled:
allViewer - viewer headers only, Host included
allExcept - viewer headers minus a list; the list is
an EXCLUSION, so nothing can be added
allViewerAndWhitelistCloudFront - viewer headers PLUS CloudFront headers,
and "viewer headers" includes Host, which
403s at API Gateway. `docs/09` warns
against exactly this one
whitelist - only the listed headers, and CloudFront
headers may be listed
CloudFront-generated headers exist in none of the "allViewer*" sets except
the one that also drags Host along. So `whitelist` is the only shape left,
and the cost of it is that the list below is now load-bearing: a header
omitted here is a header the handler never sees.
⚠️ THE LIST IS THE HANDLER'S OWN READS, AND NOTHING ELSE. `handler.mjs` reads
exactly four headers — content-type, origin, referer, user-agent. Adding a
fifth read there without adding it here is silent: the value simply arrives
undefined. The check PRINTS THE NAMES, so it can be compared to the list
above rather than counted:
grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \\
| sed "s/.*'\\(.*\\)'/\\1/" | sort
⚠️ `grep -n "headerOf(event"` WAS PRESCRIBED HERE AND IN TWO DOCUMENTS AND IT
RETURNS FIVE, NOT FOUR — it matches `function headerOf(event, name)`, its own
definition. An operator comparing 5 against a documented 4 concludes the
handler grew a read it did not grow. A count is the wrong instrument when the
names are what the whitelist has to match.
⚠️ COOKIES AND QUERY STRINGS STAY `all`, MATCHING THE MANAGED POLICY THIS
REPLACES. The site sets no cookies and the endpoint reads no query string, so
`none` would be tidier and is deliberately not used: the only reviewable
delta should be the header set. A second change hidden inside this one is how
a rollback stops being a rollback.
WHAT IT BUYS, AND IT IS NOT USED YET. `requestContext.http.sourceIp` behind
this behaviour is a CloudFront edge, so the stored value identifies AWS
rather than the sender, and `x-forwarded-for` is client-forgeable — see
`viewerIp()`. `CloudFront-Viewer-Address` is generated and overwritten by
CloudFront, so it is the one trustworthy value. Pouya's ruling of 2026-09-04:
forward it so per-IP measures become possible later, MEASURED AND NOT YET
ACTED ON. The handler is unchanged and still stores the edge address.
⚠️ SO DO NOT "FIX" `viewerIp()` TO READ THIS HEADER AS A FOLLOW-UP. What the
record holds is published on `/legal/privacy/`, field by field; changing the
stored value changes a disclosure, and `docs/09` §7.2's decision table is the
procedure for that. Forwarding a header is infrastructure. Storing it is a
privacy-policy edit. */
const ORP_NAME = 'adr-sml-api-viewer-address';
/* Sorted, because the drift check below compares this list to what CloudFront
returns and an ordering difference would read as a drift. */
const ORP_HEADERS = [
'CloudFront-Viewer-Address',
'Content-Type',
'Origin',
'Referer',
'User-Agent',
];
/**
* ⚠️ THE WHITELIST IS CHECKED AGAINST THE HANDLER'S SOURCE, NOT AGAINST A
* COMMENT. `ORP_HEADERS` is a second copy of a fact `backend/intake/handler.mjs`
* owns, and this repository's rule is that a duplicated fact needs a mechanism —
* `npm run check:intake` exists for exactly this shape. Until 2026-09-04 the
* only thing keeping the two in step was a comment plus a grep an operator was
* asked to run by eye, on the one change that can break a live intake form.
*
* A header the handler reads and this list omits is silently `undefined` at run
* time. So: read the handler, extract every `headerOf(event, '<name>')`, and
* refuse to proceed if any of them is missing here. **Missing FILE is a skip,
* not a throw** — `configure.mjs` must stay runnable from a checkout that does
* not carry `backend/`, and sections 1-3 have already staged their work.
*/
function handlerHeaderReads() {
const path = new URL('../../backend/intake/handler.mjs', import.meta.url)
.pathname;
let src;
try {
src = readFileSync(path, 'utf8');
} catch {
return null;
}
return [
...new Set(
[...src.matchAll(/headerOf\(event,\s*'([a-z-]+)'/g)].map((m) => m[1]),
),
].sort();
}
function findApiOriginRequestPolicy() {
const res = aws([
'cloudfront',
'list-origin-request-policies',
'--type',
'custom',
'--output',
'json',
]);
const items = res?.OriginRequestPolicyList?.Items ?? [];
return (
items.find(
(i) => i.OriginRequestPolicy.OriginRequestPolicyConfig.Name === ORP_NAME,
)?.OriginRequestPolicy ?? null
);
}
/* Read from `cfg`, not from `behaviours`: section 3 may have just staged this
behaviour in the same run, and it must be reachable either way. */
const apiBehaviour = (cfg.CacheBehaviors?.Items ?? []).find(
(b) => b.PathPattern === PATH_PATTERN,
);
if (!apiBehaviour) {
/* Unreachable in practice — section 3 either found it or pushed it — so if it
fires, something above changed. Skip rather than throw, for the reason
section 4 gives: sections 1-3 have already staged their mutations. */
skipped.push(
`${PATH_PATTERN} / ${ORP_NAME} — no ${PATH_PATTERN} cache behaviour to attach it to`,
);
} else {
const reads = handlerHeaderReads();
if (reads === null) {
skipped.push(
`${PATH_PATTERN} / ${ORP_NAME} — backend/intake/handler.mjs is not in this checkout, so the whitelist could not be checked against the handler's own reads`,
);
} else {
const lower = ORP_HEADERS.map((h) => h.toLowerCase());
const missing = reads.filter((h) => !lower.includes(h));
if (missing.length) {
throw new Error(
`${ORP_NAME} would NOT forward ${missing.length} header(s) the handler reads: ` +
`${missing.join(', ')}.\n` +
` handler reads : ${reads.join(', ')}\n` +
` whitelist : ${lower.join(', ')}\n` +
`Every submission would validate short and land on /contact/could-not-send/, ` +
`which reads to the inquirer as their own browser. Add the header to ORP_HEADERS ` +
`and re-run. This is checked here rather than by eye because the grep that was ` +
`prescribed for it returned five lines for four reads.`,
);
}
console.log(
`· whitelist covers all ${reads.length} headers the handler reads (${reads.join(', ')})`,
);
}
const existingOrp = findApiOriginRequestPolicy();
let orpId = existingOrp?.Id ?? null;
const wantedOrp = {
HeadersConfig: {
HeaderBehavior: 'whitelist',
Headers: { Quantity: ORP_HEADERS.length, Items: ORP_HEADERS },
},
CookiesConfig: { CookieBehavior: 'all' },
QueryStringsConfig: { QueryStringBehavior: 'all' },
};
if (existingOrp) {
const have = existingOrp.OriginRequestPolicyConfig;
const norm = (o) => JSON.stringify(o ?? null);
/* Compare the header ITEMS as a sorted set rather than the whole
HeadersConfig object: CloudFront echoes `Quantity` back and a list that
differs only in order is the same forwarding rule. A drift report that
fires on ordering is a drift report nobody reads twice. */
const haveHeaders = [...(have.HeadersConfig?.Headers?.Items ?? [])].sort();
const drift = [];
if (have.HeadersConfig?.HeaderBehavior !== 'whitelist')
drift.push([
'HeaderBehavior',
have.HeadersConfig?.HeaderBehavior,
'whitelist',
]);
if (norm(haveHeaders) !== norm([...ORP_HEADERS].sort()))
drift.push(['Headers', norm(haveHeaders), norm(ORP_HEADERS)]);
for (const k of ['CookiesConfig', 'QueryStringsConfig']) {
if (norm(have[k]) !== norm(wantedOrp[k]))
drift.push([k, norm(have[k]), norm(wantedOrp[k])]);
}
if (drift.length) {
/* Both sides, same rule as section 4: naming the field does not say which
direction to repair in, and here the two directions are "the handler
reads a header nobody forwards" and "CloudFront forwards a header
nobody reads". Only one of those loses inquiries. */
throw new Error(
`${ORP_NAME} has DRIFTED from what this script expects on ` +
`${drift.length} field(s). ${PATH_PATTERN} is the intake form's path, ` +
`so read which way before repairing:\n` +
drift
.map(([k, a, b]) => ` ${k}\n live : ${a}\n wanted : ${b}`)
.join('\n') +
`\nReconcile with update-origin-request-policy (it needs the policy's ` +
`own ETag), then re-run.` +
`\n\nNOTE: in an --apply run this throws AFTER section 4 may already have ` +
`created ${PDF_POLICY_NAME}, and BEFORE update-distribution is called — ` +
`so a policy can exist that no behaviour references. That is harmless ` +
`and self-healing: the next run finds it by name, matches it, and ` +
`attaches it. Do not delete it by hand.`,
);
}
console.log(`· origin request policy ${ORP_NAME} exists and matches`);
} else if (!APPLY) {
console.log(`· would CREATE origin request policy ${ORP_NAME}`);
changes.push(
`create origin request policy ${ORP_NAME} (whitelist: ${ORP_HEADERS.join(', ')}; cookies all; query strings all)`,
);
} else {
const created = aws([
'cloudfront',
'create-origin-request-policy',
'--origin-request-policy-config',
JSON.stringify({
Name: ORP_NAME,
Comment:
'Forwards CloudFront-Viewer-Address plus the four headers the intake handler reads. Replaces Managed-AllViewerExceptHostHeader on /api/*. See infra/cloudfront/configure.mjs section 5.',
...wantedOrp,
}),
'--output',
'json',
]);
orpId = created?.OriginRequestPolicy?.Id;
if (!orpId) {
throw new Error(
`create-origin-request-policy returned no Id for ${ORP_NAME}`,
);
}
changes.push(`created origin request policy ${ORP_NAME} (${orpId})`);
}
if (orpId && apiBehaviour.OriginRequestPolicyId === orpId) {
console.log(`· ${PATH_PATTERN} already uses ${ORP_NAME}`);
} else if (!APPLY) {
console.log(
`· would SET ${PATH_PATTERN} OriginRequestPolicyId -> ${ORP_NAME}` +
` (from ${apiBehaviour.OriginRequestPolicyId})`,
);
changes.push(
`${PATH_PATTERN} OriginRequestPolicyId ${apiBehaviour.OriginRequestPolicyId} -> ${ORP_NAME}`,
);
} else {
const from = apiBehaviour.OriginRequestPolicyId;
apiBehaviour.OriginRequestPolicyId = orpId;
changes.push(
`${PATH_PATTERN} OriginRequestPolicyId ${from} -> ${orpId} (${ORP_NAME})`,
);
/* Printed at the moment of the change, not only in the runbook, because the
operator who needs it most is the one who did not read Part 3 first.
⚠️ IT NAMED THE OLD ID AS `Managed-AllViewerExceptHostHeader` WITHOUT
CHECKING, and printed an empty string when the behaviour carried no
policy at all — an "id" an operator would paste into a rollback. It now
says only what it read, and says so when it read nothing.
⚠️ AND IT SAID "and re-apply", WHICH NAMES THIS SCRIPT. Re-running with
--apply RE-ATTACHES the whitelist: section 5 converges forward and does
not know a revert from a first run (they are byte-identical in the
config). The rollback is a direct `update-distribution`, and the runbook
says so in the sentence under its code block; this line no longer
contradicts it. */
console.log(
from
? ` ↩ ROLLBACK for ${PATH_PATTERN}: PUT OriginRequestPolicyId back to ${from}` +
`${from === allViewerExceptHost ? ' (Managed-AllViewerExceptHostHeader)' : ''}` +
' with update-distribution --if-match. Do NOT re-run this script to' +
' roll back — it would re-attach the whitelist.'
: ` ↩ ROLLBACK for ${PATH_PATTERN}: the behaviour carried NO origin request` +
' policy before this change. Remove the field with' +
' update-distribution --if-match; do NOT re-run this script.',
);
}
}
console.log(''); console.log('');
/* Skips print under their own heading and are NOT counted as changes — see the /* Skips print under their own heading and are NOT counted as changes — see the
comment on `skipped`. A skip means section 4 did nothing and the PDF is comment on `skipped`. A skip means section 4 did nothing and the PDF is
@@ -583,14 +887,17 @@ if (skipped.length) {
console.log(`${skipped.length} thing(s) SKIPPED, not changed:`); console.log(`${skipped.length} thing(s) SKIPPED, not changed:`);
for (const k of skipped) console.log(` ! ${k}`); for (const k of skipped) console.log(` ! ${k}`);
console.log(' Sections 1-3 are unaffected. Investigate before relying on'); console.log(' Sections 1-3 are unaffected. Investigate before relying on');
console.log(` ${PDF_PATTERN} carrying X-Robots-Tag.`); console.log(
` ${PDF_PATTERN} carrying X-Robots-Tag, or on ${PATH_PATTERN} forwarding`,
);
console.log(' CloudFront-Viewer-Address — the skip above says which.');
console.log(''); console.log('');
} }
if (changes.length === 0) { if (changes.length === 0) {
console.log( console.log(
skipped.length skipped.length
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all four.' ? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.'
: 'NOTHING TO CHANGE — the distribution already carries all four.', : 'NOTHING TO CHANGE — the distribution already carries all five.',
); );
process.exit(0); process.exit(0);
} }
+66 -2
View File
@@ -23,10 +23,15 @@
* Both files are read directly — Node strips the types out of the `.ts` — so * Both files are read directly — Node strips the types out of the `.ts` — so
* this script holds no third copy of the list. * this script holds no third copy of the list.
*/ */
import { INTAKE_FIELDS, HONEYPOT_FIELD } from '../src/data/intake.ts'; import {
INTAKE_FIELDS,
HONEYPOT_FIELD,
DECOY_CHECKBOX_FIELD,
} from '../src/data/intake.ts';
import { import {
FIELDS as SERVER_FIELDS, FIELDS as SERVER_FIELDS,
HONEYPOT, HONEYPOT,
DECOY_CHECKBOX,
} from '../backend/intake/fields.mjs'; } from '../backend/intake/fields.mjs';
/** /**
@@ -81,6 +86,63 @@ if (serverNames.includes(HONEYPOT_FIELD)) {
); );
} }
/* THE SECOND HONEYPOT GETS THE SAME THREE CHECKS, and it needs a fourth.
Added 2026-09-04 with the decoy checkbox. Every failure mode below is silent
in production: a mismatched name disables the trap, a name inside `FIELDS`
turns it into ordinary validation, and two traps sharing one name is one
trap with a comment claiming there are two. */
if (DECOY_CHECKBOX !== DECOY_CHECKBOX_FIELD) {
problems.push(
`decoy checkbox name differs: form "${DECOY_CHECKBOX_FIELD}", handler ` +
`"${DECOY_CHECKBOX}". The form renders one name and the handler checks ` +
'another, so the trap is disabled and nothing fails.',
);
}
if (serverNames.includes(DECOY_CHECKBOX_FIELD)) {
problems.push(
`the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the handler's FIELDS ` +
'table; it must be checked separately, or ticking it would fail ' +
'validation instead of sending the bot to the success page.',
);
}
if (clientNames.includes(DECOY_CHECKBOX_FIELD)) {
problems.push(
`the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the form's ` +
'INTAKE_FIELDS table; it would render as a real, visible field.',
);
}
/* `consent` is submitted by the form and read by the handler, and it is in
NEITHER field table — so the two checks above cannot see a collision with it.
A honeypot named `consent` would discard every valid submission behind the
success page, which is the worst failure this file can fail to catch. */
for (const [what, name] of [
['honeypot', HONEYPOT_FIELD],
['decoy checkbox', DECOY_CHECKBOX_FIELD],
]) {
if (name === 'consent') {
problems.push(
`the ${what} is named "consent", which the form submits and the handler ` +
'requires — every valid submission would be discarded behind the ' +
'success page.',
);
}
}
if (DECOY_CHECKBOX_FIELD === HONEYPOT_FIELD) {
problems.push(
'the two honeypots share the name ' +
`"${HONEYPOT_FIELD}" — that is one trap, not two, and the second ` +
'mechanism (a checkbox that must arrive absent) would not exist.',
);
}
/* And the first honeypot must not appear on the form's own table either — the
mirror of the check above it, which existed only for the handler's side. */
if (clientNames.includes(HONEYPOT_FIELD)) {
problems.push(
`the honeypot "${HONEYPOT_FIELD}" is in the form's INTAKE_FIELDS table; ` +
'it would render as a real, visible field.',
);
}
for (const clientField of INTAKE_FIELDS) { for (const clientField of INTAKE_FIELDS) {
const serverField = server.find((f) => f.name === clientField.name); const serverField = server.find((f) => f.name === clientField.name);
if (!serverField) continue; if (!serverField) continue;
@@ -128,7 +190,9 @@ for (const clientField of INTAKE_FIELDS) {
console.log( console.log(
`check:intake — ${clientNames.length} form fields, ${serverNames.length} ` + `check:intake — ${clientNames.length} form fields, ${serverNames.length} ` +
'handler fields, compared on name, label, requiredness, cap and option set.', 'handler fields, compared on name, label, requiredness, cap and option ' +
`set; 2 honeypots ("${HONEYPOT_FIELD}", "${DECOY_CHECKBOX_FIELD}") ` +
'compared on name and checked out of both tables.',
); );
if (problems.length > 0) { if (problems.length > 0) {
console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`); console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`);
+11 -3
View File
@@ -181,10 +181,18 @@ else
echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2 echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2
echo " - the route exists and the distribution's 404 mapping is showing you" >&2 echo " - the route exists and the distribution's 404 mapping is showing you" >&2
echo " /404.html instead of the API's own body." >&2 echo " /404.html instead of the API's own body." >&2
# THE ORIGIN REQUEST POLICY ON /api/* IS NO LONGER A CONSTANT. Since
# 2026-09-04 the behaviour may carry the custom `adr-sml-api-viewer-address`
# whitelist (docs/09 Part 3, change 8) instead of the managed policy, so this
# text no longer names one and tells the operator to read it. Naming the old
# one would send them to "restore" what was deliberately replaced.
echo "403 means CloudFront rejected the method, or the handler refused the" >&2 echo "403 means CloudFront rejected the method, or the handler refused the" >&2
echo "Origin — check the behaviour uses Managed-AllViewerExceptHostHeader," >&2 echo "Origin. Read which origin request policy /api/* carries — since" >&2
echo "because a policy that drops Origin turns every real submission into a" >&2 echo "2026-09-04 it may be the custom whitelist adr-sml-api-viewer-address" >&2
echo "403. 500 means the Lambda invoke permission for this route is missing" >&2 echo "rather than Managed-AllViewerExceptHostHeader — because a policy that" >&2
echo "drops or fails to forward Origin turns every real submission into a" >&2
echo "403. Rollback id: b689b0a8-53d0-40ab-baf2-68738e2966ac." >&2
echo "500 means the Lambda invoke permission for this route is missing" >&2
echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2 echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2
echo "Either way the form is not verified working. See docs/09-cutover-" >&2 echo "Either way the form is not verified working. See docs/09-cutover-" >&2
echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2 echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2
+1 -1
View File
@@ -31,7 +31,7 @@ The second is what the neutral will actually do. That is a different question, a
## What I undertake ## What I undertake
{/* ⚠️ RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — §4's third class says {/* ⚠️ RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — §4's third class says
so in terms: "The six strings live in `CONDUCT_UNDERTAKINGS` in so in terms: "The strings live in `CONDUCT_UNDERTAKINGS` in
`src/data/site.ts` and the pages render them, so the diff that would soften `src/data/site.ts` and the pages render them, so the diff that would soften
one is visible on one constant rather than distributed through three one is visible on one constant rather than distributed through three
templates." They were hand-typed here in the first draft, which put a fourth templates." They were hand-typed here in the first draft, which put a fourth
+65 -8
View File
@@ -10,8 +10,15 @@
* *
* What stops the two drifting is a check rather than a shared import: * What stops the two drifting is a check rather than a shared import:
* **`npm run check:intake`** asserts that the two tables agree on every field * **`npm run check:intake`** asserts that the two tables agree on every field
* name, on which are required, and on every length cap and fails the build * name, on which are required, on every length cap, and — since 2026-09-04 — on
* script if they do not. Independent validation, mechanically cross-checked. If * both honeypot names.
*
* ⚠️ **IT IS A KEYBOARD GATE, NOT A DEPLOY GATE, AND THIS COMMENT SAID IT "fails
* the build script".** It does not: `npm run build` is `astro build`, and
* `scripts/deploy-local.sh` runs `check`, `build` and `check:claims` and not this
* one. Run it yourself. A control described as running where it does not is
* `AGENTS.md` Q22, and this change set makes this check the only thing keeping
* the second honeypot's two names in step. Independent validation, mechanically cross-checked. If
* you add a field here, add it there, and the check will tell you if you didn't. * you add a field here, add it there, and the check will tell you if you didn't.
* *
* WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a * WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a
@@ -200,6 +207,50 @@ export const CONSENT_TEXT =
*/ */
export const HONEYPOT_FIELD = 'company_website'; export const HONEYPOT_FIELD = 'company_website';
/**
* THE SECOND HONEYPOT, AND IT IS A DIFFERENT TRAP RATHER THAN A SECOND COPY OF
* THE FIRST. Pouya's ruling, 2026-09-04, after two automated submissions walked
* through `HONEYPOT_FIELD` (`docs/05` §Observed abuse).
*
* ⚠️ **THE MECHANISM IS INVERTED, WHICH IS THE POINT.** `HONEYPOT_FIELD` is a
* text input that must arrive EMPTY — it catches a bot that fills every input it
* finds. The pair of 2026-09-04 did not fill it, so a second field of the same
* kind would catch them exactly as well as the first did: not at all.
*
* This is a CHECKBOX, and what it catches is a bot that sets every control it
* enumerates rather than one that fills every text field.
*
* ⚠️ **WHAT IT IS AIMED AT, AND WHAT THE EVIDENCE ACTUALLY SUPPORTS — READ THIS
* BEFORE RELYING ON IT.** An earlier version of this comment said the decoy
* targets *"a behaviour anything reaching validation must have"*, because the
* consent box is required and unchecked by default, so a submission that
* validated must have sent `consent=on`. **That argument does not survive its own
* premise.** The 2026-09-04 pair did NOT fill the text honeypot, so they are
* selective about hidden fields — and a bot selective enough to skip a hidden
* text input is selective enough to skip a hidden checkbox. Sending `consent=on`
* shows only that it knows one field name, not that it ticks everything it finds.
*
* **So this trap is very likely INERT against the traffic it was built from**,
* and it is defence in depth against a different and common class: the bot that
* enumerates controls and sets all of them. That is worth having and it is not
* what the observation proved. `docs/05` §Observed abuse states the same limit;
* the two must not drift, because the tempting sentence is the confident one.
*
* ⚠️ **ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE.** A browser sends nothing
* at all for an unchecked box, so every way this field can fail to arrive — a
* stripping extension, a proxy, a future template that drops it — reads as a
* HUMAN; and a serialiser that emits `updates_optin=` without reading the
* checked state reads as one too, because the handler tests for a NON-EMPTY
* value rather than for presence. The failure mode of a trap is a lost legal
* inquiry that looks like a successful one, and this trap fires only on
* something that deliberately ticked a box no person can see.
*
* The name is a plausible marketing opt-in, which is what a bot expects to find
* and a real form here does not have. Hidden the same way as the first — the
* hiding is standard, the mechanism is not.
*/
export const DECOY_CHECKBOX_FIELD = 'updates_optin';
/** /**
* WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY * WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY
* HOSTNAME. This is a design decision with four consequences, taken at step 8 * HOSTNAME. This is a design decision with four consequences, taken at step 8
@@ -209,6 +260,13 @@ export const HONEYPOT_FIELD = 'company_website';
* Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*` * Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*`
* to that origin: * to that origin:
* *
* ⚠️ **AND IT IS ALL LIVE SINCE 2026-09-02** — see the closing paragraph of this
* block. The same stale sentence was corrected in `handler.mjs`, `docs/01` and
* `docs/05` before it was corrected here; this note was added, in the same pass,
* ABOVE a paragraph that still said the opposite twenty lines below it. **A note
* asserting a correction is not the correction**, and the two sat contradicting
* each other until `adversarial-reviewer` round 2.
*
* 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies * 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies
* `form-action 'self' <api-endpoint>`; with a same-origin post the second * `form-action 'self' <api-endpoint>`; with a same-origin post the second
* term is unnecessary, so the policy is strictly tighter. * term is unnecessary, so the policy is strictly tighter.
@@ -229,11 +287,10 @@ export const HONEYPOT_FIELD = 'company_website';
* a POST 404s. Under the alternative, clicking Submit on a laptop would * a POST 404s. Under the alternative, clicking Submit on a laptop would
* write a real DynamoDB record and send two real emails. * write a real DynamoDB record and send two real emails.
* *
* ⚠️ **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT * ⚠️ **THE COST, WHICH WAS REAL AND IS NOW PAID: THE FORM DID NOT WORK UNTIL
* WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.** * THAT CLOUDFRONT BEHAVIOUR EXISTED AND THE HANDLER WAS DEPLOYED.** Both ran at
* Neither has been done — nothing on this project deploys before cutover (D11), * cutover on 2026-09-02 — `AGENTS.md` §7 holds the state and this comment does
* and both are checklist items in `docs/06`. Until then the page is complete and * not restate it. `/contact/` still publishes the email address beside the form,
* the pipe behind it is not, which is why `/contact/` also publishes the email * which is now a courtesy rather than a fallback.
* address rather than treating the form as the only way in.
*/ */
export const INTAKE_ACTION = '/api/intake'; export const INTAKE_ACTION = '/api/intake';
+60 -2
View File
@@ -303,7 +303,12 @@ export const NEUTRAL_ROLE_LINE =
'party should have their own legal advice.'; 'party should have their own legal advice.';
/** /**
* THE SIX CONDUCT UNDERTAKINGS — Q54, ANSWERED BY POUYA 2026-08-29. * THE CONDUCT UNDERTAKINGS — Q54, ANSWERED BY POUYA 2026-08-29, plus (g).
*
* ⚠️ **(a)(f) ARE Q54's SIX. (g) IS NOT** — it was attested 2026-09-03 to
* close D20 finding 13 and carries its own stamp on the object below. The
* heading no longer states a count: this comment said "THE SIX" while the
* object held seven for exactly as long as it took to notice.
* *
* A THIRD CLASS OF CLAIM, and the class is his: not a credential (a fact about * A THIRD CLASS OF CLAIM, and the class is his: not a credential (a fact about
* him, §4 Verified) and not an offering (a process the practice conducts, §4 * him, §4 Verified) and not an offering (a process the practice conducts, §4
@@ -365,7 +370,26 @@ export const CONDUCT_UNDERTAKINGS = {
arbitrationAwardDate: arbitrationAwardDate:
'The date the award is due is fixed in the first procedural order rather ' + 'The date the award is due is fixed in the first procedural order rather ' +
'than left open.', 'than left open.',
} as const; // [verified 2026-08-29 — Pouya, Q54] /**
* (g) `/legal/privacy/` — the conflicts check. **ATTESTED 2026-09-03 by
* Pouya, closing D20 finding 13.** It is NOT one of the Q54 six: its own
* date, its own ruling, and it is stamped separately below.
*
* The page was already stating a conflicts undertaking in prose, and §4's
* gate for this class is one line — he must have made it IN TERMS. He now
* has, so the sentence is rendered from here rather than typed there.
*
* ⚠️ **IT IS HIS WORDING, NOT A RENDERING OF IT, AND THAT IS THE WHOLE GATE.**
* The attestation is *"runs a conflicts check on every inquiry before
* engaging"*. This string shipped for one round as *"before I accept an
* appointment"* — the site's own vocabulary, defensible, and **a paraphrase of
* a commitment the page publishes as his**. §4's gate for this class is that
* he made it IN TERMS, and a substitution recorded in a code comment is not
* that. Do not smooth it back. If "engaging" turns out to be the wrong verb,
* the fix is a second attestation, never an edit here.
*/
conflictsCheck: 'I run a conflicts check on every inquiry before engaging.',
} as const; // (a)(f) [verified 2026-08-29 — Pouya, Q54]; (g) [attested 2026-09-03 — Pouya]
/** /**
* THE HELD-DESIGNATIONS SENTENCE, RENDERED AND NEVER RETYPED. * THE HELD-DESIGNATIONS SENTENCE, RENDERED AND NEVER RETYPED.
@@ -552,6 +576,40 @@ export const FEES = {
* it, and do not price it. * it, and do not price it.
*/ */
hourly: 500, // [verified 2026-08-26] hourly: 500, // [verified 2026-08-26]
/**
* MED-ARB IS BILLED BY PHASE, AND THAT IS WHY THERE IS NO NUMBER IN HERE.
*
* Pouya's ruling, 2026-09-03, closing D20 finding 10. `/fees/` opens *"Every
* figure is on this page"* while §4 Offerings carries a **Med-Arb** row that
* `docs/07-fees.md` priced nowhere — so the promise was wider than the card.
* The ruling closes it by pricing the offering out of the two rate sets that
* are already published rather than by narrowing the promise: each phase is
* charged at the rates for that process, so no third set of figures exists
* and the sentence becomes true as written.
*
* ⚠️ **THERE IS NO `amount` HERE ON PURPOSE. Do not add one.** A med-arb
* figure would be a fourth price for a process priced twice already, and the
* first thing it would do is disagree with one of them.
*
* ⚠️ **`termsApply` SAYS "as they apply to that process on its own", NOT
* "to both phases".** The additional-party fee is a MEDIATION row; the
* arbitration card has no equivalent. Saying the terms apply to each phase as
* they apply to that process invents nothing; saying they apply throughout
* would invent an additional-party charge in the arbitral phase.
*
* INTERIM, set 2026-09-03, reviewed at the §12 R5 twelve-month fee review.
* `docs/07` §Med-arb — billed by phase carries the rule and the same stamp.
*/
medArb: {
rule:
'Med-arb is billed by phase. The mediation phase is charged at the ' +
'mediation rates above. If the matter proceeds to arbitration, that ' +
'phase is charged at the arbitration rates above.',
noSeparateFee: 'There is no separate med-arb fee.',
termsApply:
'The additional-party and cancellation terms apply to each phase as ' +
'they apply to that process on its own.',
}, // [verified 2026-09-03 — Pouya, interim; R5]
cancellation: [ cancellation: [
{ window: 'More than 30 days before', fee: 'No fee. Disbursements only.' }, { window: 'More than 30 days before', fee: 'No fee. Disbursements only.' },
{ window: '15 to 30 days before', fee: '50% of the booked fee.' }, { window: '15 to 30 days before', fee: '50% of the booked fee.' },
+64 -7
View File
@@ -38,15 +38,21 @@
*/ */
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import Button from '../components/Button.astro'; import Button from '../components/Button.astro';
import Undertaking from '../components/Undertaking.astro';
import ContactBand from '../components/ContactBand.astro'; import ContactBand from '../components/ContactBand.astro';
import Eyebrow from '../components/Eyebrow.astro'; import Eyebrow from '../components/Eyebrow.astro';
import SectionHeading from '../components/SectionHeading.astro'; import SectionHeading from '../components/SectionHeading.astro';
import { getImage } from 'astro:assets'; import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg'; import ogDefault from '../assets/og-portrait.jpg';
import { pageGraph } from '../data/schema'; import { pageGraph } from '../data/schema';
import { CONTACT, NO_RETAINER_NOTICE } from '../data/site'; import {
CONDUCT_UNDERTAKINGS,
CONTACT,
NO_RETAINER_NOTICE,
} from '../data/site';
import { import {
CONSENT_TEXT, CONSENT_TEXT,
DECOY_CHECKBOX_FIELD,
HONEYPOT_FIELD, HONEYPOT_FIELD,
INTAKE_ACTION, INTAKE_ACTION,
INTAKE_FIELDS, INTAKE_FIELDS,
@@ -117,11 +123,20 @@ const hintId = (name: string) => `${name}-hint`;
</div> </div>
<div class="prose"> <div class="prose">
<p class="statement">{NO_RETAINER_NOTICE}</p> <p class="statement">{NO_RETAINER_NOTICE}</p>
<p>I ask for the other parties and their counsel for one reason.</p>
{
/* RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — undertaking (g).
⚠️ THIS PAGE HAND-TYPED THE SAME PROPOSITION AS *"I cannot accept an
appointment before conflicts are checked"* UNTIL 2026-09-04, and it
survived the change set that struck the identical sentence from
`/legal/privacy/` — one file swept, its sibling missed, which is the
shape R8 exists for. §4 row (g) lists BOTH surfaces. */
}
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
<p> <p>
I ask for the other parties and their counsel because I cannot accept That check needs names, and the call above is where it happens. Please
an appointment before conflicts are checked, and that check needs keep the summary short and leave privileged or confidential detail out
names. Please keep the summary short and leave privileged or of it — the call is for that.
confidential detail out of it — the call is for that.
</p> </p>
<p> <p>
What is collected, where it is stored, how long it is kept, and how to What is collected, where it is stored, how long it is kept, and how to
@@ -248,7 +263,13 @@ const hintId = (name: string) => `${name}-hint`;
the form: a browser that helpfully fills a plausible-looking field the form: a browser that helpfully fills a plausible-looking field
would make a human look like a bot. */ would make a human look like a bot. */
} }
<div class="honeypot" aria-hidden="true"> {
/* `hidden` ADDED 2026-09-04, for the reason spelled out on the decoy
below: a class-only rule leaves this field on screen wherever author
styles do not apply, and a visitor who fills it loses their inquiry
behind a success page. */
}
<div class="honeypot" hidden aria-hidden="true">
<label for={HONEYPOT_FIELD}>Company website</label> <label for={HONEYPOT_FIELD}>Company website</label>
<input <input
type="text" type="text"
@@ -287,6 +308,41 @@ const hintId = (name: string) => `${name}-hint`;
</p> </p>
</div> </div>
{
/* THE SECOND HONEYPOT — a decoy CHECKBOX. The mechanism, and the
limits of what the observed spam supports, are in `src/data/intake.ts`
and are not restated here. Four properties of the MARKUP, each of
which is what stops this field costing a real inquiry:
· its own CLASS NAME, not `.honeypot` — one selector must not
match both traps. They share a declaration block below, which is
presentation; what matters is that `.honeypot` does not select
this one;
· placed after the consent block, not beside the other honeypot;
· `hidden` as well as the CSS rule, so it stays hidden where
author styles do not apply;
· a label that tells a human not to tick it. With `hidden` in
place a human essentially cannot see it, so this is the last
line rather than the first — and it costs almost nothing,
because the PLAUSIBLE NAME is what a bot matches on and the name
is unchanged.
⚠️ NO `required`, AND NO `checked`. An unchecked box sends nothing,
so absence is the pass — and the handler tests for a NON-EMPTY value,
so an empty one passes too. */
}
<div class="optin-decoy" hidden aria-hidden="true">
<label for={DECOY_CHECKBOX_FIELD}>Leave this box unticked.</label>
<input
type="checkbox"
id={DECOY_CHECKBOX_FIELD}
name={DECOY_CHECKBOX_FIELD}
value="on"
tabindex="-1"
autocomplete="off"
/>
</div>
{ {
/* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`. /* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`.
`.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw `.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw
@@ -505,7 +561,8 @@ const hintId = (name: string) => `${name}-hint`;
the input are belt and braces for the case where a future stylesheet the input are belt and braces for the case where a future stylesheet
un-hides it. Do not swap this for `visibility` or an off-screen position: un-hides it. Do not swap this for `visibility` or an off-screen position:
an off-screen input is still focusable and still announced. */ an off-screen input is still focusable and still announced. */
.honeypot { .honeypot,
.optin-decoy {
display: none; display: none;
} }
+42 -4
View File
@@ -36,6 +36,17 @@
* sentence ships **adjacent to the overtime row**, not in a footnote. Same * sentence ships **adjacent to the overtime row**, not in a footnote. Same
* structural rule as `PROCESS_FRAMING` beside the five timings under Q43. * structural rule as `PROCESS_FRAMING` beside the five timings under Q43.
* *
* ✅ **MED-ARB IS PRICED HERE AS OF 2026-09-03, AND IT CARRIES NO FIGURE.**
* Pouya's ruling closing D20 finding 10: it is billed **by phase**, each phase
* at the rates already on this page. The finding was that the hero promises
* *"Every figure is on this page"* while §4 Offerings carries a Med-Arb row
* that `docs/07` priced nowhere — a promise wider than the card. It is closed by
* pricing the offering, not by narrowing the promise, so the hero sentence is
* unchanged and is now true as written. **Do not give the section a rate row:**
* a med-arb figure would be a fourth price for a process priced twice, and the
* first thing it would do is disagree with one of them. `FEES.medArb` holds the
* three sentences; `docs/07` §Med-arb holds the rule. INTERIM, reviewed at R5.
*
* ⚠️ **NO TRIBUNAL-SECRETARY RATE AND NO SETTLEMENT COUNSEL.** Both are struck * ⚠️ **NO TRIBUNAL-SECRETARY RATE AND NO SETTLEMENT COUNSEL.** Both are struck
* rows in §4 Offerings — the first removed by Pouya from D14, the second by him * rows in §4 Offerings — the first removed by Pouya from D14, the second by him
* as his own error in `docs/01`. **A rate on a fee page is an offer**, which is * as his own error in `docs/01`. **A rate on a fee page is an offer**, which is
@@ -240,8 +251,35 @@ const ARBITRATION_ROWS = [
</div> </div>
</section> </section>
{/* ---- 4. Other services ---------------------------------------------- */} {/* ---- 4. Med-arb ------------------------------------------------------ */}
<section class="section section-alt reveal"> <section class="section section-alt reveal">
<div class="wrap">
<div class="section-head">
<SectionHeading
eyebrow="Med-arb"
level={2}
lede="One appointment, two processes. Each phase is charged at the rates for that process."
>
<span slot="heading">Billed by phase.</span>
</SectionHeading>
</div>
{
/* NO `<dl class="rates">` HERE, AND THE ABSENCE IS THE POINT — see the
header. Every other section on this page pairs an item with a figure;
this one has no figure of its own, and giving it a row would mean
inventing one. The three sentences come from `FEES.medArb` so the rule
lives beside the numbers it points at rather than in this template. */
}
<ul class="notes" role="list">
<li>{FEES.medArb.rule}</li>
<li>{FEES.medArb.noSeparateFee}</li>
<li>{FEES.medArb.termsApply}</li>
</ul>
</div>
</section>
{/* ---- 5. Other services ---------------------------------------------- */}
<section class="section reveal">
<div class="wrap"> <div class="wrap">
<div class="section-head"> <div class="section-head">
<SectionHeading <SectionHeading
@@ -304,8 +342,8 @@ const ARBITRATION_ROWS = [
</div> </div>
</section> </section>
{/* ---- 5. Cancellation ------------------------------------------------ */} {/* ---- 6. Cancellation ------------------------------------------------ */}
<section class="section reveal"> <section class="section section-alt reveal">
<div class="wrap"> <div class="wrap">
<div class="section-head"> <div class="section-head">
<SectionHeading <SectionHeading
@@ -334,7 +372,7 @@ const ARBITRATION_ROWS = [
</div> </div>
</section> </section>
{/* ---- 6. Terms -------------------------------------------------------- */} {/* ---- 7. Terms -------------------------------------------------------- */}
<section class="section section-inverse reveal"> <section class="section section-inverse reveal">
<div class="wrap"> <div class="wrap">
<div class="section-head"> <div class="section-head">
+47 -12
View File
@@ -59,10 +59,16 @@
*/ */
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import Eyebrow from '../../components/Eyebrow.astro'; import Eyebrow from '../../components/Eyebrow.astro';
import Undertaking from '../../components/Undertaking.astro';
import { getImage } from 'astro:assets'; import { getImage } from 'astro:assets';
import ogDefault from '../../assets/og-portrait.jpg'; import ogDefault from '../../assets/og-portrait.jpg';
import { pageGraph } from '../../data/schema'; import { pageGraph } from '../../data/schema';
import { ANALYTICS, CONTACT, SITE } from '../../data/site'; import {
ANALYTICS,
CONDUCT_UNDERTAKINGS,
CONTACT,
SITE,
} from '../../data/site';
import { INTAKE_FIELDS } from '../../data/intake'; import { INTAKE_FIELDS } from '../../data/intake';
const ldImage = await getImage({ const ldImage = await getImage({
@@ -85,7 +91,7 @@ const RETENTION_MONTHS = 24;
/** Bump this on ANY substantive edit. A privacy policy with a stale date is a /** Bump this on ANY substantive edit. A privacy policy with a stale date is a
* policy a reader cannot tell they are reading an old version of. */ * policy a reader cannot tell they are reading an old version of. */
const LAST_UPDATED = '3 September 2026'; const LAST_UPDATED = '4 September 2026';
/* Rendered from the form's own field list, so the two cannot drift. `consent` /* Rendered from the form's own field list, so the two cannot drift. `consent`
and the honeypot are absent from `INTAKE_FIELDS` deliberately and are and the honeypot are absent from `INTAKE_FIELDS` deliberately and are
@@ -160,15 +166,34 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
<p> <p>
The form asks for the other parties to the dispute and their counsel. The form asks for the other parties to the dispute and their counsel.
That is information about people who have not filled in the form and That is information about people who have not filled in the form and
may not know it was sent. It is asked for one reason: I cannot accept may not know it was sent. It is asked for one reason, and the reason
an appointment before conflicts are checked, and the check needs is a commitment rather than an observation.
names.
</p> </p>
{
/* `<Undertaking>` AND `CONDUCT_UNDERTAKINGS`, NEVER TYPED PROSE —
§4's third class, whose characteristic failure mode is that a
promise gets quietly smaller and nothing fails. Undertaking (g),
attested 2026-09-03.
⚠️ IT IS THE COMPONENT FOR THE REASON THE COMPONENT EXISTS: one
treatment on every page, so a reader can tell a promise from a
description. This shipped for one pass as an ordinary paragraph in
`&ldquo;`/`&rdquo;` — the only such entities in `src/`, and a
commitment set as body prose reads as another sentence about
process.
It REPLACED the hand-typed "I cannot accept an appointment before
conflicts are checked", which stated the same proposition as a
constraint; keeping both would have set the undertaking beside its
own paraphrase — the (e)/(f) treatment. */
}
<Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking>
<p> <p>
Please give names and nothing more about them. The form asks you not The check needs names. Please give names and nothing more about them.
to include privileged or confidential detail anywhere in it, and the The form asks you not to include privileged or confidential detail
summary field says so directly. There is deliberately no field for anywhere in it, and the summary field says so directly. There is
amounts in dispute and no way to attach a document. deliberately no field for amounts in dispute and no way to attach a
document.
</p> </p>
<h2>Why it is collected, and on what basis</h2> <h2>Why it is collected, and on what basis</h2>
@@ -340,11 +365,21 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
delete it before the {RETENTION_MONTHS} months are up. delete it before the {RETENTION_MONTHS} months are up.
{' '}{CONTACT.responseTime} {' '}{CONTACT.responseTime}
</p> </p>
{
/* ⚠️ THE CLAUSE THAT WAS HERE PROMISED TO DISCLOSE THE OUTCOME OF A
CONFLICTS CHECK — *"I will tell you what its outcome was rather than
pretending the inquiry did not happen"* — and that is an UNDERTAKING,
which §4 may publish only where Pouya has made it in terms. He had
not. D20 finding 13, and it is closed by his attestation of
2026-09-03, which covers RUNNING the check and says nothing about
reporting it. The sentence now states what deletion does not undo and
stops there. Do not restore the promise without a second attestation:
it is a different commitment from the one he made. */
}
<p> <p>
Deletion removes the record. It does not retract the emails already Deletion removes the record. It does not retract the emails already
sent, and if a conflicts check has already been run I will tell you sent, and it does not undo a conflicts check that has already been
what its outcome was rather than pretending the inquiry did not run.
happen.
</p> </p>
<h2>What an inquiry is not</h2> <h2>What an inquiry is not</h2>