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
+134
-16
@@ -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
|
||||
POST-redirect-GET landing pages, and `backend/intake/handler.mjs` +
|
||||
`backend/intake/fields.mjs` — the handler that **replaces** the hand-built
|
||||
`adr-intake-handler` §7 records.
|
||||
`backend/intake/fields.mjs` + `backend/intake/spam-score.mjs` — the handler that
|
||||
**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
|
||||
project deploys before cutover (D11), so: the handler is not deployed, and the
|
||||
**CloudFront `/api/*` behaviour the form posts to does not exist**. Both are on
|
||||
`docs/06`'s cutover checklist. `/contact/` publishes the email address as well
|
||||
as the form for exactly this reason.
|
||||
🟢 **IT IS ALL LIVE AS OF 2026-09-02, AND THIS PARAGRAPH SAID THE OPPOSITE UNTIL
|
||||
2026-09-04.** It read *"the handler is not deployed, and the CloudFront `/api/*`
|
||||
behaviour the form posts to does not exist"* — true when written under D11, false
|
||||
from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in
|
||||
the document an implementer reads before touching the handler. **Measured
|
||||
2026-09-04:** the function carries `handler.handler` with six environment
|
||||
variables and its two source entries are byte-identical to commit `02739ad`; the API has
|
||||
exactly one route, `POST /api/intake`; §7 holds the full state and this spec does
|
||||
not restate it. `/contact/` still publishes the email address beside the form,
|
||||
which is now a courtesy rather than a fallback.
|
||||
|
||||
### The form is a plain HTML POST, and it answers 303
|
||||
|
||||
@@ -158,6 +163,24 @@ Client-side validation is a convenience. **The Lambda re-validates everything.**
|
||||
- Required fields present; email well-formed; lengths within bounds
|
||||
- Reject any field over its cap rather than truncating silently
|
||||
- **Honeypot** field, hidden from sighted and screen-reader users, must be empty
|
||||
- **Second honeypot** — a hidden CHECKBOX that must arrive **absent**, added
|
||||
2026-09-04. A different trap, not a second copy: the first catches a bot that
|
||||
fills every text input, this one catches a bot that sets every control it
|
||||
enumerates. ⚠️ **IT IS PROBABLY INERT AGAINST THE TRAFFIC THAT PROMPTED IT —
|
||||
see §Observed abuse, which retracts in full the argument this bullet made for
|
||||
one round** (*"which anything reaching validation must do, because the consent
|
||||
box is required and unchecked by default"*). The retraction was written sixty
|
||||
lines below this bullet and did not reach it. **Unchecked sends nothing, so
|
||||
absence is the pass —
|
||||
and so is an empty value**, because the handler tests for a non-empty one
|
||||
rather than for presence: no dropped-field path and no blind form serialiser
|
||||
can turn it into a lost inquiry. It carries its **own** wrapper class (not the
|
||||
first honeypot's), the `hidden` attribute as well as the CSS rule, and a label
|
||||
that tells a human not to tick it — see `src/pages/contact.astro`, where each
|
||||
of the three is a correction rather than a precaution
|
||||
- **Spam SCORING that labels and never rejects**, added 2026-09-04. See
|
||||
§Observed abuse. It changes the operator notification's subject line and
|
||||
nothing else
|
||||
- ~~**Timestamp check** — reject submissions completed in under 3 seconds~~
|
||||
⚠️ **STRUCK, and it was recorded as unimplementable in three other places
|
||||
while this line stayed an unqualified imperative** — the handler's header,
|
||||
@@ -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
|
||||
exists on paper and not in fact is worse than a stated gap"* — the throttle is
|
||||
real and bounds total volume; the per-IP claim was neither
|
||||
- No CAPTCHA. It is a third-party script on a page collecting legal information,
|
||||
and the two controls above stop the traffic that matters
|
||||
- No CAPTCHA. It is a third-party script on a page collecting legal information.
|
||||
⚠️ **THIS BULLET USED TO END "and the two controls above stop the traffic that
|
||||
matters", WHICH THE FIRST REAL SPAM FALSIFIED** — see §Observed abuse. The
|
||||
reason to keep CAPTCHA out is unchanged and stands on its own; the claim that
|
||||
what ships is sufficient was an untested prediction and has been removed rather
|
||||
than reworded
|
||||
- CORS restricted to `https://adr.smlcompany.ca` — no wildcard
|
||||
- Strip HTML from every field before storage and before it enters an email body
|
||||
|
||||
## Observed abuse
|
||||
|
||||
**First real-world spam: 2026-09-04.** Two automated submissions, **10:51Z** and
|
||||
**12:16Z**, `submissionId` prefixes `50cda580…` and `e3e21122…`. Recorded here
|
||||
rather than in the Change Log alone because this section's controls were
|
||||
specified against an imagined attacker and this is the first measured one.
|
||||
|
||||
**Both passed the honeypot**, and neither was stopped by anything else that
|
||||
ships: the aggregate route throttle is 1 request/second with a burst of 5
|
||||
(`AGENTS.md` §7), and two submissions ninety minutes apart are nowhere near it.
|
||||
|
||||
**The signature, as Pouya recorded it:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Names | random |
|
||||
| Email | random Gmail addresses — **one using the dot trick** |
|
||||
| Phone | Russian format |
|
||||
| Organisation | big-brand names |
|
||||
| Dispute summary | scraped text |
|
||||
|
||||
⚠️ **THE HONEYPOT WAS NOT DEFEATED BY CLEVERNESS — IT WAS NOT ENGAGED.** A bot
|
||||
that submits only the fields it recognises never touches a decoy text input.
|
||||
|
||||
🛑 **AND THAT CUTS BOTH WAYS. THE SECOND HONEYPOT IS PROBABLY INERT AGAINST THIS
|
||||
PAIR, AND THIS SECTION CLAIMED THE OPPOSITE FOR ONE ROUND.** It said the checkbox
|
||||
*"is aimed at a behaviour the traffic must have"*, reasoning that the consent box
|
||||
is required so anything that validated must have been ticking checkboxes.
|
||||
**Sending `consent=on` shows only that it knows one field name.** A bot selective
|
||||
enough to skip a hidden text input is selective enough to skip a hidden checkbox,
|
||||
and the same evidence that explains the first honeypot's silence predicts the
|
||||
second's. It is **defence in depth against a different and common class** — the
|
||||
bot that enumerates controls and sets all of them — which is worth adding and is
|
||||
not a counter to what was observed. Nothing in this repository has yet caught a
|
||||
bot with it.
|
||||
|
||||
**What was added, and the ordering rule Pouya set:** *"Nothing is dropped; a
|
||||
false positive costs him one glance."*
|
||||
|
||||
1. A second honeypot — above.
|
||||
2. **Scoring that labels.** `backend/intake/spam-score.mjs`, unit-tested at
|
||||
`spam-score.test.mjs`. Signals and weights: summary under a floor **(1)**,
|
||||
phone present and not North American **(1)**, a link in the summary **(2)**,
|
||||
a Gmail address with dot-trick density **(2)**; **threshold 2**. Above it the
|
||||
record is still stored, both emails are still sent, and only the operator
|
||||
notification changes — subject prefixed `[Possible spam] `, plus one line
|
||||
naming the signals. **The confirmation to the inquirer is untouched.**
|
||||
3. **Nothing is stored.** The score and signals do not enter the DynamoDB item,
|
||||
because §Storage's attribute list is published on `/legal/privacy/` and adding
|
||||
one would make that page wrong.
|
||||
|
||||
⚠️ **THE TIMING FLOOR WAS RULED AND COULD NOT BE BUILT — §9 Q66.** Pouya's ruling
|
||||
of 2026-09-04 asked to *"raise the timing floor"*. **There is no floor to raise:**
|
||||
the timestamp check is struck above and has never existed, for a reason unchanged
|
||||
by the spam arriving — `/contact/` is a CDN-cached static file, so no per-visitor
|
||||
"served at" value exists to subtract from. Nothing inside *"handler + form only,
|
||||
zero-JS preserved"* can produce one, and the three mechanisms that could each
|
||||
break one of his constraints:
|
||||
|
||||
| mechanism | what it costs |
|
||||
|---|---|
|
||||
| Client-side script timing the fill | **Breaks zero JavaScript** (§7, and it is *none*, not *minimal*) |
|
||||
| A CloudFront Function on viewer-response setting a signed short-lived cookie, read by the handler | Outside *"handler + form only"*, and it puts a **cookie** on a site whose privacy policy turns on there being none — a `/legal/privacy/` change and a consent question this repository must not answer for itself |
|
||||
| A dynamic origin for `/contact/` | Reverses D1's `output: 'static'` |
|
||||
|
||||
**A fourth is worse than doing nothing:** shipping a build-time timestamp and
|
||||
calling it a timing check. `now − served` would be hours or days for every
|
||||
caller, so it would pass for a bot exactly as it passes for a human — the control
|
||||
that exists on paper and not in fact, which is what deviation 1 and `AGENTS.md`
|
||||
Q22 are both records of.
|
||||
|
||||
## Storage
|
||||
|
||||
DynamoDB, in the region `AGENTS.md` §7 records. **Canadian data residency is
|
||||
@@ -448,8 +546,26 @@ Plausible or Fathom, cookieless, no consent banner.
|
||||
## Definition of done
|
||||
|
||||
- [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
|
||||
- [ ] **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
|
||||
- [x] **The first honeypot is live** — the hidden text input that must arrive
|
||||
empty. Deployed since cutover. ⚠️ **The timing check is NOT implemented** —
|
||||
see deviation 1 above and §Observed abuse; it is unimplementable on a
|
||||
CDN-cached static page and would be a control that does nothing.
|
||||
**Re-ruled and re-blocked 2026-09-04, §9 Q66**
|
||||
- [ ] 🛑 **THE SECOND HONEYPOT AND THE SPAM SCORING ARE WRITTEN AND NOT
|
||||
DEPLOYED** — 2026-09-04. Both live in `backend/intake/`, and **a site
|
||||
deploy does not carry `backend/`**: `scripts/deploy-local.sh` is an S3 sync
|
||||
and an invalidation, nothing more. They need `docs/09` Part 5 (and Part 5.5,
|
||||
which is the path in production). ⚠️ **THIS LINE READ `[x]` … "live" FOR ONE
|
||||
ROUND, ON AN UNCOMMITTED WORKING TREE**, while §7's own row recorded the
|
||||
running function as last modified 2026-09-02 with source digests matching
|
||||
`HEAD` — the spec asserting a control that its neighbour proved absent.
|
||||
`node backend/intake/spam-score.test.mjs` returns **39 of 39** and all four
|
||||
signals are exercised `[verified 2026-09-04]`; that is a statement about
|
||||
the repository, not about production. ⚠️ **The only paths that discard a
|
||||
submission are the two honeypots**, and both answer with the success page
|
||||
rather than an error. Validation failures redirect to
|
||||
`/contact/could-not-send/`, which is a told failure, not a silent one
|
||||
- [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
|
||||
- [ ] **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]`
|
||||
@@ -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] **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`
|
||||
> **The three remaining items below are commands, and 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
|
||||
> ✅ **THE THREE ITEMS BELOW WERE COMMANDS AND ALL THREE HAVE RUN — cutover,
|
||||
> 2026-09-02**, verified against the live account 2026-09-04. They are ticked
|
||||
> below and the reasoning is kept because it is what made them non-obvious.
|
||||
> The commands are in `docs/09-cutover-runbook.md` — Parts 5, 6 and 3
|
||||
> respectively, each with its verification and the output to expect. Two things that spec found by reading the
|
||||
> running system rather than the specs, and both would have lost every
|
||||
> submission: the API route needs its **own** Lambda invoke permission, because
|
||||
> the existing one is `SourceArn`-scoped to the old `/submissions` path; and the
|
||||
> handler's item shape had to change, because the table's partition key is
|
||||
> `submissionId` and a key schema cannot be altered after creation (§Storage).
|
||||
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, 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] ✅ **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
|
||||
- [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
|
||||
|
||||
Reference in New Issue
Block a user