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
+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.
*
* 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
* `X-Robots-Tag: noindex`, so the bio PDF is not indexed as a duplicate of
* `/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
* calling `update-distribution`. `--apply` is the only thing that writes, and it
@@ -34,6 +40,7 @@
* node infra/cloudfront/configure.mjs ... --apply
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const args = process.argv.slice(2);
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('');
/* 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
@@ -583,14 +887,17 @@ if (skipped.length) {
console.log(`${skipped.length} thing(s) SKIPPED, not changed:`);
for (const k of skipped) console.log(` ! ${k}`);
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('');
}
if (changes.length === 0) {
console.log(
skipped.length
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all four.'
: 'NOTHING TO CHANGE — the distribution already carries 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 five.',
);
process.exit(0);
}