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
+22
View File
@@ -115,3 +115,25 @@ export const FIELDS = [
* stops filling the field. `check:intake` asserts it is absent from `FIELDS`.
*/
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
* and does not restate them.
*
* ⚠️ THIS IS NOT DEPLOYED. Written at build step 8; nothing on this project
* deploys before cutover (D11). AGENTS.md §7 records that a hand-built
* `adr-intake-handler` already exists in the console, created before this repo,
* and this file REPLACES it rather than describing it. docs/06's cutover
* checklist carries the deployment steps and the CloudFront `/api/*` behaviour
* the form depends on. Until both are done the form on /contact/ posts into
* nothing, which is why that page also publishes the email address.
* ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and
* `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built
* `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT
* DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on
* this file can say: an edit made in that belief ships to a form real inquirers
* are using. Changes here reach production on the next `docs/09` Part 5 run.
*
* ⚠️ 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 ─────────────────────────────
*
@@ -30,6 +33,13 @@
*
* ── 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.**
* 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:
@@ -42,10 +52,11 @@
*
* 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
* it is omitted, said out loud, and the load is carried by the honeypot, the
* Origin check, the aggregate API Gateway route throttle and the validation
* below. (Aggregate, not per-IP — see above; the earlier wording here said
* "rate limit" and let the reader supply the stronger meaning.)
* it is omitted, said out loud, and the load is carried by the TWO honeypots,
* the Origin check, the aggregate API Gateway route throttle and the validation
* below — plus, since 2026-09-04, a score that LABELS and never rejects.
* (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 ──────────────────────────────
*
@@ -65,10 +76,20 @@
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
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
`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
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.
*
* The right value is CloudFront's own `CloudFront-Viewer-Address`, which
* CloudFront generates and overwrites — but reaching it needs a CUSTOM origin
* request policy on the /api/* behaviour (the managed
* AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API
* Gateway, which is why AllViewerExceptHostHeader was chosen). That is an
* infrastructure change, and `docs/09` Part 7.2 measures what this field
* actually contains at cutover rather than reasoning about the proxy chain —
* with a decision table for each outcome. Do not "fix" this from the header
* again without that measurement.
* CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request
* policy on the /api/* behaviour the managed AllViewerAndCloudFrontHeaders
* forwards Host, which 403s every request at API Gateway.
*
* ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5,
* Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL
* DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet
* 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) {
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
* `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);
}
@@ -403,6 +472,50 @@ export async function handler(event) {
.map((f) => `${f.label}: ${clean[f.name]}`)
.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.
* `Promise.allSettled`, not `Promise.all`: the record is already stored, so a
@@ -419,14 +532,19 @@ export async function handler(event) {
ReplyToAddresses: [clean.email],
Content: {
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: {
Text: {
// The bare id, because it is the partition key: this line is
// what gets pasted into the console to find the record, so it
// must be the key and not a rendering of it.
Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`,
},
// The body carries the bare submissionId, because it is the
// partition key: that line gets pasted into the console to find
// the record, so it must be the key and not a rendering of it.
Text: { Data: notificationBody },
},
},
},
+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`,
);