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
+13
-7
@@ -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
|
||||
in `docs/07-fees.md`; still do not invent numbers.**
|
||||
|
||||
Hourly rate; half-day and full-day mediation; 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
|
||||
Hourly rate; half-day and full-day mediation; **med-arb, billed by phase and
|
||||
carrying no figure of its own** (added 2026-09-03, `docs/07` §Med-arb, INTERIM
|
||||
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.
|
||||
|
||||
### `/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`
|
||||
between them mean an article publishes only when Pouya sets both flags, and
|
||||
`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
|
||||
is written (`backend/intake/`) and undeployed, and the CloudFront `/api/*`
|
||||
behaviour it posts to does not exist yet. Both are cutover items, and `docs/05`
|
||||
§Build step 8 records three deliberate deviations from that spec
|
||||
8. ✅ `/contact/` — **the page is built and the pipe behind it is LIVE as of
|
||||
2026-09-02.** The handler is deployed and the CloudFront `/api/*` behaviour is
|
||||
in place; `AGENTS.md` §7 holds the state and this list does not restate it.
|
||||
`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
|
||||
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)
|
||||
|
||||
+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
|
||||
|
||||
+116
-9
@@ -508,9 +508,15 @@ Then invalidate `/*`.
|
||||
> §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
|
||||
> unblocked; the end-to-end confirmation is still owed.
|
||||
> 3. ⚠️ **THE D20 CLAIMS PASS RETURNED FAIL WITH 20 CONFIRMED FINDINGS; 15 ARE
|
||||
> NOW FIXED, 2 REFUTED, 3 OUTSTANDING — updated 2026-09-03, and the three
|
||||
> numbers partition the twenty.** Fixed under Pouya's rule *"the gloss may say
|
||||
> 3. ⚠️ **THE D20 CLAIMS PASS RETURNED FAIL WITH 20 CONFIRMED FINDINGS; 17 ARE
|
||||
> NOW FIXED, 2 REFUTED, 1 OWED — updated 2026-09-04, and the three numbers
|
||||
> 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
|
||||
> 1–9, 14–18 and 20 — the whole gloss class, plus `/bio/`'s role verb.
|
||||
> ⚠️ **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.
|
||||
> **OUTSTANDING — findings 10, 11 and 13, and each is outstanding for a
|
||||
> different reason:**
|
||||
> **(10) NEEDS A RULING.** `/fees/`'s *"Every figure is on this page"* against
|
||||
> §4's **Med-Arb** offering, which `docs/07-fees.md` prices nowhere. Either a
|
||||
> med-arb fee term or a scoped promise; it cannot be closed by narrowing.
|
||||
> **(10) ✅ RULED AND CLOSED 2026-09-03 — PRICED, NOT NARROWED.** Med-arb is
|
||||
> billed **by phase**: the mediation phase at the published mediation rates,
|
||||
> 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*
|
||||
> 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
|
||||
> 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
|
||||
> **undertaking**, and §4's gate for that class is one line: Pouya must have
|
||||
> 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
|
||||
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
|
||||
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
|
||||
@@ -1020,7 +1065,29 @@ the decision is re-readable rather than re-litigated.
|
||||
- [ ] 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 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.**
|
||||
⚠️ **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
|
||||
@@ -1141,8 +1208,48 @@ the decision is re-readable rather than re-litigated.
|
||||
byte-reproducible** — Chrome stamps a `/CreationDate`, so two runs of
|
||||
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
|
||||
- [ ] 🛑 **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
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
⚠️ **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
|
||||
recorded here so the 12-month review has something to test against, not to
|
||||
re-open a settled decision.
|
||||
|
||||
+269
-26
@@ -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
|
||||
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-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
|
||||
+ CustomErrorResponses += 404 -> /404.html with status 404
|
||||
+ Origins += intake-api -> …execute-api… (https-only, TLSv1.2)
|
||||
+ CacheBehaviors += /api/* -> intake-api, CachingDisabled, AllViewerExceptHostHeader, POST allowed
|
||||
+ 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)
|
||||
+ 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.
|
||||
```
|
||||
@@ -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
|
||||
distribution serving 23 pages.
|
||||
|
||||
Fewer than six changes means part of this is already done — read which lines are
|
||||
prefixed `·` (already present) and carry on. **On the live distribution as at
|
||||
2026-09-03, changes 1–3 are applied and you should see exactly the last two.**
|
||||
More than six, or a different set, means the distribution is not in the state 0.3
|
||||
recorded: stop and re-read it.
|
||||
Fewer than eight changes means part of this is already done — read which lines
|
||||
are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
|
||||
for *already present* **and** for *would CREATE / would SET / would ADD*, so the
|
||||
prefix alone does not say whether a line is done or still pending. **On the live distribution as at
|
||||
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 1–4 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
|
||||
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
|
||||
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
|
||||
same fact:**
|
||||
|
||||
@@ -442,16 +595,40 @@ after 8.4, when both halves are true at once.
|
||||
|
||||
### 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
|
||||
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
|
||||
```
|
||||
|
||||
**Expect:** exactly two entries, `handler.mjs` and `fields.mjs`, **≈ 25.7 KB
|
||||
uncompressed and ≈ 10.8 KB zipped** `[measured 2026-09-01]`. Both at the zip root —
|
||||
`handler.mjs` imports `./fields.mjs`, so a nested directory breaks the import at
|
||||
cold start.
|
||||
⚠️ **THE LIST IS SUBSTITUTED DIRECTLY, NOT HELD IN A VARIABLE, AND THAT IS NOT
|
||||
STYLE.** A first version read `MODULES=$(ls …)` then `zip … $MODULES`. **In zsh
|
||||
that packages ONE file whose name is all three joined by newlines** — zsh does
|
||||
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
|
||||
|
||||
@@ -500,11 +677,22 @@ aws lambda get-function-configuration --function-name "$FN" \
|
||||
--query '{CodeSize:CodeSize,Runtime:Runtime,Update:LastUpdateStatus,Modified:LastModified}'
|
||||
```
|
||||
|
||||
**Expect:** `CodeSize` **≈ 10,800** (up from 1,527), `Update: Successful`.
|
||||
⚠️ **`CodeSize` is the ZIP, not the source.** This line said "around 23,000",
|
||||
which was 5.1's uncompressed figure applied to a different quantity — an
|
||||
operator seeing `10819` against an expectation of 23,000 would reasonably
|
||||
conclude the wrong artefact went up.
|
||||
**Expect** `Update: Successful`, and a `CodeSize` that says **which path you
|
||||
took** — it is the ZIP, not the source:
|
||||
|
||||
| path | expected `CodeSize` |
|
||||
|---|---|
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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 \
|
||||
&& npm init -y > /dev/null \
|
||||
&& npm install --omit=dev --no-audit --no-fund \
|
||||
"@aws-sdk/client-dynamodb@$(npm view @aws-sdk/client-dynamodb version)" \
|
||||
"@aws-sdk/client-sesv2@$(npm view @aws-sdk/client-sesv2 version)" )
|
||||
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
|
||||
aws lambda update-function-code --function-name "$FN" --zip-file fileb:///tmp/intake.zip
|
||||
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:**
|
||||
`aws apigatewayv2 get-routes --api-id "$API_ID" --query 'Items[].RouteKey'`.
|
||||
|
||||
**403** means the `Origin` header did not arrive — check that the behaviour uses
|
||||
`Managed-AllViewerExceptHostHeader`, because a policy that drops `Origin` turns
|
||||
every real submission into a 403. **500** means Part 6.1 was skipped.
|
||||
**403** means the `Origin` header did not arrive, and **as of 2026-09-04 there
|
||||
are two policies it could be** — read which one the behaviour carries before
|
||||
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
|
||||
|
||||
@@ -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
|
||||
CloudFront generates and overwrites — not the managed
|
||||
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
|
||||
at API Gateway. That is an infrastructure change and it is deliberately not in
|
||||
this runbook: measure first.
|
||||
at API Gateway. ⚠️ **THAT CHANGE IS NOW IN THIS RUNBOOK — Part 3, change 8, written
|
||||
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
|
||||
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.
|
||||
|
||||
**9.2 Parts 2–3** — 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/*`
|
||||
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
|
||||
fails while the function is still associated — that ordering is a feature.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user