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
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:
co-authored by
Claude Opus 5
parent
02739adac9
commit
3c3ba5dc6e
+147
-29
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user