From 3c3ba5dc6e82171f3c21ac9f1fd17226f236310d Mon Sep 17 00:00:00 2001 From: Pouya Lajevardi Date: Fri, 4 Sep 2026 10:06:37 -0400 Subject: [PATCH] feat: price med-arb by phase, attest the conflicts undertaking, and answer the first real spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5 --- .gitea/workflows/deploy.yml | 7 +- AGENTS.md | 485 ++++++++++++++++++++- backend/intake/fields.mjs | 22 + backend/intake/handler.mjs | 176 ++++++-- backend/intake/spam-score.mjs | 196 +++++++++ backend/intake/spam-score.test.mjs | 336 ++++++++++++++ docs/01-architecture.md | 20 +- docs/05-backend-spec.md | 150 ++++++- docs/06-deployment.md | 125 +++++- docs/07-fees.md | 49 +++ docs/09-cutover-runbook.md | 295 +++++++++++-- eslint.config.js | 13 + infra/cloudfront/configure.mjs | 315 ++++++++++++- scripts/check-intake.mjs | 68 ++- scripts/deploy-local.sh | 14 +- src/content/insights/when-med-arb-fits.mdx | 2 +- src/data/intake.ts | 73 +++- src/data/site.ts | 62 ++- src/pages/contact.astro | 71 ++- src/pages/fees.astro | 46 +- src/pages/legal/privacy.astro | 59 ++- 21 files changed, 2443 insertions(+), 141 deletions(-) create mode 100644 backend/intake/spam-score.mjs create mode 100644 backend/intake/spam-score.test.mjs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 74db40e..3441e12 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -215,7 +215,12 @@ jobs: echo "'Items[].RouteKey' — the --api-id is required; without it the" echo "CLI exits 252 on ParamValidation." echo "403: method rejected, or the handler refused the Origin —" - echo "check Managed-AllViewerExceptHostHeader is on the behaviour." + echo "read which origin request policy /api/* carries. Since" + echo "2026-09-04 it may be the custom whitelist" + echo "adr-sml-api-viewer-address rather than the managed" + echo "AllViewerExceptHostHeader; a policy that does not forward" + echo "Origin 403s every real submission. Rollback id:" + echo "b689b0a8-53d0-40ab-baf2-68738e2966ac." echo "500: the invoke permission for this route is missing (6.1)." echo "See docs/09-cutover-runbook.md Part 7.1." fi diff --git a/AGENTS.md b/AGENTS.md index a05c06f..9472bf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -589,9 +589,12 @@ two-business-day response time — so the bar is that **he has said them**, not > EDIT — Pouya's instruction, and it governs every row below.** This is the > class's characteristic failure mode and it is silent: nothing in a build fails > when a promise gets a little smaller, and the diff reads like tightening. The -> six strings live in **`CONDUCT_UNDERTAKINGS` in `src/data/site.ts`** and the +> strings live in **`CONDUCT_UNDERTAKINGS` in `src/data/site.ts`** and the > pages render them, so the diff that would soften one is visible on one -> constant rather than distributed through three templates. +> constant rather than distributed through three templates. *(This read "the six +> strings" until 2026-09-03, when (g) was attested. **The count is not restated +> here** — the table below holds it, and a number in prose beside a table that +> holds the same number is the second copy that goes stale. This one did.)* ⚠️ **ON THE STAMP DATE, BECAUSE THE DATE IS THE WHOLE CONTENT OF A STAMP.** The ruling says *"dated 2026-08-27"*. The rows below read **`[verified 2026-08-29 — @@ -616,8 +619,9 @@ do not re-open it against the quoted ruling above.** | **(d) Mediation — caucus confidentiality.** *"What a party tells me in caucus stays in that caucus until they tell me I may use it, and I do not carry a number across the hall that I was not given to carry."* | `/mediation/` §Confidentiality, **`/process/` §Confidentiality, and `/for-parties/` §Questions** — the last both visibly and inside that page's `FAQPage` node (all added 2026-08-30, build step 6). **Three surfaces.** The row recorded two for one pass; `adversarial-reviewer` found the third, and an incomplete "where it ships" on the one class Pouya flagged as failing *silently* is this column doing the opposite of its job | `[verified 2026-08-29 — Pouya, Q54]`. Shipped for one pass at step 4 and removed by `claims-auditor` — correctly: the gate had been applied to `/med-arb/` in the same change set and not one file over. It is here now because it is answered, not because the gate relaxed | | **(e) Arbitration — procedure.** *"I will not run a process whose shape nobody agreed to in advance."* | `/arbitration/` §Rules | `[verified 2026-08-29 — Pouya, Q54]`. Same one-pass history as (d). **It REPLACED a third-person sentence rather than joining it** — the page already ended that paragraph *"What does not is a process whose shape nobody agreed to in advance"*, the observation form of the same proposition, and keeping both would have set the undertaking beside its own paraphrase | | **(f) Arbitration — the award date.** *"The date the award is due is fixed in the first procedural order rather than left open."* | `/arbitration/` §Awards | `[verified 2026-08-29 — Pouya, Q54]`. Same replacement as (e), of *"The date an award is due belongs in the first procedural order"*. **The sentence after it is unchanged and is doing different work:** *"No number is published here"* is §4 Forbidden's bar on a time-to-award statistic, which is not what this undertaking is | +| **(g) Conflicts — the check itself.** *"I run a conflicts check on every inquiry before engaging."* | `/legal/privacy/` §Information about other people **and `/contact/`** — both render `` from the constant. ⚠️ **`/contact/` was missed for one round**: it hand-typed the same proposition as *"I cannot accept an appointment before conflicts are checked"* and survived the change set that struck the identical sentence one file over, which is R8's shape exactly | `[attested 2026-09-03 — Pouya]`. ⚠️ **NOT ONE OF THE Q54 SIX — its own date, its own ruling, and it closes D20 finding 10's sibling, finding 13.** The page was already publishing a conflicts undertaking in prose and this class's gate is one line: he must have made it **in terms**. It was not in `CONDUCT_UNDERTAKINGS`, so it was published under no gate at all. ⚠️ **AND THE SENTENCE THAT PROMPTED IT IS NOT THE SENTENCE THIS ROW AUTHORISES.** Finding 13 quoted *"if a conflicts check has already been run I will tell you what its outcome was"* — a promise to **disclose the outcome**, which the attestation does not cover and which is a different commitment. That clause is **struck** from the page; what ships is the attestation and nothing beyond it. ⚠️ **THE PUBLISHED FORM IS HIS WORDING, VERBATIM, AND IT SHIPPED FOR ONE ROUND AS A PARAPHRASE.** It read *"before I accept an appointment"* — the site's own vocabulary, defensible, and still a rewording of a commitment published as his, recorded only in a code comment. **Two review lenses refuted the framing and neither refutes the fix:** on `/legal/terms/`'s own sequence — conflicts check, then terms agreed, then engagement — *"before I accept an appointment"* is if anything **earlier** than *"before engaging"*, so it was not a softening. It was a *substitution*, and this class's gate is that he said it in terms. Using his words removes the question instead of answering it. If "engaging" is the wrong verb, the fix is a second attestation. ⚠️ **IT DOES NOT REVERSE Q57.** Q57 refused a seventh undertaking about what happens **when a check turns something up** (*"if a conflict is found I decline"*); this one is about **running** the check. Different propositions, and the refused one is still refused | -**What these six buy, stated once so it is not re-argued.** Q54's finding was that +**What Q54's six buy, stated once so it is not re-argued.** *(The table above now holds seven; (g) is a separate attestation and this paragraph is about Q54's set, which is why it is scoped rather than re-counted.)* Q54's finding was that all three process pages answered the fairness question *at the level of process design* — what an agreement has to settle, what a rule set does and does not fix — and that this is the half a reader can get from any competent page on med-arb. @@ -705,7 +709,7 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec | Thing | Value | |---|---| -| 🟢 **THE SITE IS LIVE** | **`https://adr.smlcompany.ca` — cutover executed 2026-09-02 by Pouya, `scripts/deploy-local.sh`, commit `67847d9`.** D11's single shot is spent; the old site is replaced. `[verified 2026-09-02 — 26 routes curled with the iteration count asserted]`: all 22 pages, `robots.txt`, `sitemap-index.xml` and `/pouya-lajevardi-bio.pdf` return **200**; an unknown path returns **404** with the styled Astro page (14,321 B), so the CloudFront function and custom error response are both in force. HTML `max-age=0, must-revalidate`; `_astro` `max-age=31536000, immutable`; PDF **89,496 B**, matching `public/` exactly. **All 22 live pages are byte-identical to a `dist/` rebuilt at `67847d9`** — SHA-256 per page, 22 same / 0 differ. Five `noindex` surfaces correct; sitemap 17 URLs. ⚠️ **THREE THINGS ARE LIVE AND NOT RIGHT, and `docs/06`'s callout carries them:** the intake form POSTs to `/api/intake`, which **403s with an empty body** (only `POST /submissions` exists), so a submitter gets a blank page; `/legal/privacy/` published while **Q60 is open** and its own source comment said it must not; and the **D20 claims pass ran after cutover and returned FAIL with 20 confirmed findings**. ⚠️ **`X-Robots-Tag: noindex` IS ABSENT ON THE PDF** `[verified 2026-09-02 — curl -I]` — `docs/06` carries that as an unticked item and the file is linked from `/about/`, so it is crawlable even though `/bio/` is not | +| 🟢 **THE SITE IS LIVE** | **`https://adr.smlcompany.ca` — cutover executed 2026-09-02 by Pouya, `scripts/deploy-local.sh`, commit `67847d9`.** D11's single shot is spent; the old site is replaced. `[verified 2026-09-02 — 26 routes curled with the iteration count asserted]`: all 22 pages, `robots.txt`, `sitemap-index.xml` and `/pouya-lajevardi-bio.pdf` return **200**; an unknown path returns **404** with the styled Astro page (14,321 B), so the CloudFront function and custom error response are both in force. HTML `max-age=0, must-revalidate`; `_astro` `max-age=31536000, immutable`; PDF **89,496 B**, matching `public/` exactly. **All 22 live pages are byte-identical to a `dist/` rebuilt at `67847d9`** — SHA-256 per page, 22 same / 0 differ. Five `noindex` surfaces correct; sitemap 17 URLs. ⚠️ **TWO THINGS ARE LIVE AND NOT RIGHT, and `docs/06`'s callout carries them.** ⚠️ **A THIRD WAS LISTED HERE AND IS REFUTED — the intake form is NOT broken.** This row said it *"403s with an empty body (only `POST /submissions` exists), so a submitter gets a blank page"*. **Both halves are false:** a bare POST 403s **by design** (the `Origin` check, and `docs/09` §7.1 says so three lines below the probe it prescribes), and the only route that exists is `POST /api/intake` `[verified 2026-09-04 — get-routes]`. Run correctly, §7.1 returns **303**. Kept struck rather than deleted because this row is what an operator reads first. The two that stand: `/legal/privacy/` published while **Q60 is open** and its own source comment said it must not; and the **D20 claims pass ran after cutover and returned FAIL with 20 confirmed findings**. ⚠️ **`X-Robots-Tag: noindex` IS ABSENT ON THE PDF** `[verified 2026-09-02 — curl -I]` — `docs/06` carries that as an unticked item and the file is linked from `/about/`, so it is crawlable even though `/bio/` is not | | Framework | **Astro 7.2.9**, `output: 'static'` `[verified 2026-08-27 — npm view astro version, D1 as amended]`. `@astrojs/mdx` 7.0.8, `@astrojs/sitemap` 3.7.3, `sharp` 0.35.4 — all three at `latest`. Bumped from 7.2.7 at the step 1 → step 2 phase boundary under R11: 7.2.8 published 2026-08-26 and 7.2.9 on 2026-08-27, i.e. **two patches appeared inside 48 hours**, which is the argument for checking at boundaries rather than on failure. `engines` unchanged at `node >=22.12.0`, `npm >=9.6.5` `[verified 2026-08-27 — npm view astro@7.2.9 engines]` | | **TypeScript — HELD at 6.x, and the hold is a peer constraint rather than a preference** | Pinned `^6.0.3`; `npm view typescript version` returns **7.0.2** `[verified 2026-08-31 — R11, re-run at the step 10 → 11 boundary after the run added five dependencies: **18 of 19 pins current**, this the only hold, `npm audit` 0 vulnerabilities. The earlier run that day, at the step 6 → 7 boundary, read 13 of 14]`. **The removal trigger was re-checked rather than recalled:** `npm view @astrojs/check@0.9.10 peerDependencies` still returns `{ typescript: '^5.0.0 \|\| ^6.0.0' }`, so the hold stands. ⚠️ **AND THERE ARE TWO GATES, NOT ONE — corrected 2026-08-31, and this row named only the first for two days.** `npm view typescript-eslint peerDependencies` returns `{ typescript: '>=4.8.4 <6.1.0' }`, which is **tighter**: widening `@astrojs/check` alone would not lift the hold, so the trigger as recorded was unreachable. **A second consequence, live:** the pin is a caret, so if a 6.1.x ever ships, a plain `npm install` moves the tree into a peer-range breach with nothing in the repo saying so. Nothing is breached today — `npm ls typescript` resolves **6.0.3**, which is the highest stable 6.x on the registry. Found by running R11's own check rather than reading the row. **One major behind, which is the exact shape D1 was amended over — so the reason is recorded rather than the hold being silent.** `@astrojs/check@0.9.10` declares `peerDependencies: { typescript: '^5.0.0 \|\| ^6.0.0' }` `[verified 2026-08-29 — npm view @astrojs/check@0.9.10 peerDependencies]`, and `npm run check` is `astro check`, which is the type gate the deploy path runs before it builds. **Removal trigger: `@astrojs/check` AND `typescript-eslint` both widen to include 7** — either alone is not enough. Re-check at every phase boundary under R11 — this is a hold on a dependency's schedule, not on a judgement, so it lifts without a decision | | Lint toolchain | ESLint **10.9.1**, `@eslint/js` 10.0.1, `globals` 17.11.0, `eslint-plugin-astro` **3.1.0**, `eslint-plugin-jsx-a11y` 6.10.2, `typescript-eslint` 8.68.0, `typescript` **6.0.3** — **every pin at the registry's `latest` except `typescript`** `[verified 2026-08-30 — npm view, all 14 pins, R11 at the step 5 → 6 boundary; thirteen current, one held]`. `@eslint/js` and `globals` are declared explicitly; before 2026-08-26 `eslint.config.js` imported them and they resolved only by npm hoisting accident. **Accessibility linting is on** — `flat/jsx-a11y-recommended`, 36 rules `[verified 2026-08-26 — 7 rules fired on a deliberately inaccessible .astro file]`. `eslint-plugin-jsx-a11y@6.10.2` declares a stale `eslint ^3..^9` peer range; a one-line `overrides` entry in `package.json` resolves it. ⚠️ **`eslint-plugin-astro@3.1.0` declares `node ^22.22.3 \|\| ^24.16.0 \|\| >=26.3.0`**, which excludes Pouya's Node 25.6.0 — `npm install` prints EBADENGINE there. Dev-time only, and `nvm use` (Node 22 LTS, per `.nvmrc`) clears it. **`typescript` — the hold, and it was too wide by a whole major until 2026-08-27.** `latest` is **7.0.2** and it is unusable here: `typescript-eslint@8.68.0` peers `typescript >=4.8.4 <6.1.0` and `@astrojs/check@0.9.10` peers `^5.0.0 \|\| ^6.0.0`, so taking 7 breaks **both** the linter and `astro check` `[verified 2026-08-27 — npm view peerDependencies]`. **But this row previously read "held at 5.x", and 6.0.3 is a stable release both peers accept** — so the pin sat a full major behind for no reason the record could name, in a row whose whole job is to name the reason. Now at **6.0.3**, the newest version compatible with both peers; `npx tsc --version` reports 6.0.3 and the full gate is green `[verified 2026-08-27]`. The 6.x listing is mostly `-dev` and `-beta` tags; **6.0.2 and 6.0.3 are the only stable 6.x releases**, which is why `npm view typescript version` (7.0.2) is not the number to pin against here. Re-check at the next phase boundary: the hold ends when `typescript-eslint` widens its peer range past `<6.1.0` | @@ -721,10 +725,11 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec | AWS account | `327082975128` `[verified 2026-08-26 — inventory]` | | Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` | | S3 bucket | **`adr-smlcompany-site`** — versioning **Enabled**, so rollback works `[verified 2026-08-26]` | -| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. ⚠️ **AND THREE THINGS THE SITE NEEDS ARE ABSENT: NO FUNCTION ASSOCIATIONS, NO CUSTOM ERROR RESPONSES, NO CACHE BEHAVIOURS** `[verified 2026-09-01 — get-distribution-config]`. The first is why **22 of the 23 pages do not serve**: `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist. Measured on the live distribution the same day: `/` **200**, `/about/` and `/definitely-not-a-page/` both **403 with an 111-byte `application/xml` body** — S3's `AccessDenied`, served raw. `docs/09-cutover-runbook.md` Parts 1–3 configure all three; `infra/cloudfront/` holds the function and the config script | +| CloudFront | **`E1OK7G98KNKUTA`**, alias `adr.smlcompany.ca`, origin `adr-smlcompany-site.s3.ca-central-1.amazonaws.com` with OAC **`E13GAFUL6UQP6R`**, Deployed `[verified 2026-08-26; config re-read 2026-09-01]`. Default behaviour: `Managed-CachingOptimized`, `Managed-SecurityHeadersPolicy`, methods HEAD/GET, `redirect-to-https`, default root object `index.html`. 🟢 **ALL THREE ARE NOW PRESENT — `docs/09` Parts 1–3 ran at cutover.** `[verified 2026-09-04 — get-distribution-config]`: **1** function association (`adr-sml-router`, viewer-request), **1** custom error response (404 → `/404.html`, status 404), **1** cache behaviour (`/api/*` → `intake-api`), **2** origins. ⚠️ **THIS ROW READ "THREE THINGS THE SITE NEEDS ARE ABSENT … 22 of the 23 pages do not serve" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 3 ran, and asserting that the live site does not serve. **Still to apply, and they are the only two outstanding:** the `*.pdf` behaviour with `adr-sml-pdf-noindex` (section 4) and the `/api/*` origin request policy `adr-sml-api-viewer-address` (section 5). Both need `configure.mjs --apply`, not a deploy. The original text, because the trailing-slash reasoning is what makes the router load-bearing: *"the first is why 22 of the 23 pages do not serve — `astro.config.mjs` sets `trailingSlash: 'always'` with `build.format: 'directory'`, so CloudFront asks S3 for the key `about/`, which does not exist."* `infra/cloudfront/` holds the function and the config script | | ACM certificate | `arn:aws:acm:us-east-1:327082975128:certificate/2b6d5bdf-6790-430c-9b82-c00ab66e6d87` — ISSUED `[verified 2026-08-26]` | -| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com` `[verified 2026-08-26]`. **One route, `POST /submissions`** → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, which is the format `handler.mjs` reads). Stage `$default`, auto-deploy on, **no throttling**, no access log. CORS allows `POST` from the site origin `[verified 2026-09-01 — get-routes, get-api, get-stages, get-integrations]`. `DisableExecuteApiEndpoint` is **false** and must stay false: the CloudFront origin **is** that hostname. The form's route (`POST /api/intake`) does not exist yet — `docs/09` Part 6 | -| Intake Lambda | `adr-intake-handler`, `nodejs24.x`, **arm64**, handler `index.handler`, timeout **10 s**, memory **128 MB**, role `adr-intake-lambda-role`, **no environment variables**, no DLQ, code **1,527 bytes**, last modified 2026-05-26 `[verified 2026-09-01 — get-function-configuration]`. **That is still the HAND-BUILT function, not `backend/intake/handler.mjs`** — nothing has been deployed (D11). Its resource policy has **one** statement, `apigateway.amazonaws.com` conditioned on `SourceArn` `…/4tl0m5igkj/*/*/submissions` — **the old route's path only**, so a new route needs its own permission or API Gateway is refused and answers 500 with nothing in the Lambda log. The execution role is **sufficient as it stands**: `dynamodb:PutItem` on the table (write-only — it cannot read it), `ses:SendEmail`/`SendRawEmail`, plus `AWSLambdaBasicExecutionRole`. Deployment commands: `docs/09-cutover-runbook.md` Part 5 | +| Intake API | `adr-intake-api`, HTTP API `4tl0m5igkj`, endpoint `https://4tl0m5igkj.execute-api.ca-central-1.amazonaws.com`. 🟢 **LIVE. ONE ROUTE, `POST /api/intake`** → integration `0ftgjgv` (`AWS_PROXY`, payload format **2.0**, which is the format `handler.mjs` reads). Stage `$default`, auto-deploy on, no access log. **THROTTLED per route: `POST /api/intake` → rate 1.0 req/s, burst 5, detailed metrics on** — the aggregate throttle `docs/05` §Validation specifies, `docs/09` Part 6.3, and it is a ROUTE setting rather than the stage default `[verified 2026-09-04 — get-routes, get-stage]`. `DisableExecuteApiEndpoint` is **false** and must stay false: the CloudFront origin **is** that hostname. ⚠️ **THIS ROW READ "One route, `POST /submissions`" AND "The form's route (`POST /api/intake`) does not exist yet" UNTIL 2026-09-04, TWO DAYS AFTER CUTOVER** — the old route was retired by `docs/09` Part 6.4 and the row was never re-read. It also said **no throttling**, which was true of the stage default and false of the route, and a query projecting `DefaultRouteSettings` alone reproduces that error exactly: **read `RouteSettings` too before concluding a throttle is absent** | +| Intake Lambda | `adr-intake-handler`, `nodejs24.x`, **arm64**, role `adr-intake-lambda-role`. 🟢 **LIVE — IT IS `backend/intake/handler.mjs`, NOT THE HAND-BUILT FUNCTION.** Handler **`handler.handler`**, timeout **15 s**, memory **512 MB**, **six environment variables** (`INTAKE_TABLE`, `MAIL_FROM`, `NOTIFY_TO`, `NO_RETAINER_NOTICE`, `RESPONSE_TIME`, `SITE_ORIGIN`), no DLQ, code **3,307,021 bytes**, last modified **2026-09-02T18:59:06Z** `[verified 2026-09-04 — get-function-configuration]`. ⚠️ **THAT CODE SIZE MEANS THE BUNDLED VARIANT, `docs/09` §5.5 — the runtime does not supply the SDK v3 clients.** The deployed artefact was downloaded via `get-function` `Code.Location` and read: it holds `handler.mjs`, `fields.mjs`, `node_modules/` and `package.json`, and **both source files are byte-identical to commit `02739ad`** — sha1 `ab94d506…` and `18b44c74…` `[verified 2026-09-04]`. ⚠️ **THIS SAID "byte-identical to `HEAD`" AND `HEAD` IS A MOVING TARGET**: the 2026-09-04 change set edits both files and adds a third, so the sentence would have become false at its own commit while reading as current. **A digest claim about a deployed artefact names the commit it matched, never a ref.** The working tree is NOT what is running until `docs/09` Part 5 runs again. 🛑 **THE PACKAGE FILE LIST FOLLOWS NO IMPORT — it is `ls *.mjs` minus the tests, in both §5.1 and §5.5** (hand-typed in both until 2026-09-04, with nothing checking they agreed). `spam-score.mjs` joined it 2026-09-04 and a zip missing a module fails at cold start. Resource policy: **one** statement, `apigw-post-api-intake`, `apigateway.amazonaws.com` conditioned on `…/4tl0m5igkj/*/POST/api/intake` `[verified 2026-09-04 — get-policy]`. Execution role: `dynamodb:PutItem` on the table (write-only — it cannot read it), `ses:SendEmail`/`SendRawEmail`, plus `AWSLambdaBasicExecutionRole`. ⚠️ **THIS ROW SAID `index.handler`, 10 s, 128 MB, NO ENVIRONMENT VARIABLES, 1,527 BYTES AND "nothing has been deployed (D11)" UNTIL 2026-09-04**, stamped `[verified 2026-09-01]` — true when written, false from the moment Part 5 ran, and it is the row an operator reads before touching production. Deployment commands: `docs/09` Part 5 | +| **Intake Lambda — the two bundled SDK pins** | **`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**, read out of the deployed zip `[verified 2026-09-04]`. ⚠️ **`docs/09` §5.5 REQUIRES THESE TO BE RECORDED HERE THE MOMENT THAT PATH IS TAKEN — *"add both packages to §7… they become pins this project maintains, and R11 covers them from that moment"*. ⚠️ **R11's OWN INSTRUCTION IS SCOPED TO `package.json`** — *"run `npm view version` across every pin in `package.json`"* — and these two are deliberately NOT in it, so §5.5's sentence extends R11 past its written scope. Recorded rather than quietly relied on: **the reminder that actually covers them is this row.** The path was taken on 2026-09-02 and they reached no document until 2026-09-04**, so two production dependencies sat outside dependency-currency review for two days. They are **not** in `package.json` and never will be: §5.5 resolves them from the registry at install time, so a redeploy takes whatever is current — `npm view` returns **3.1126.0** for both `[verified 2026-09-04 — R11]`, i.e. the next handler deploy moves them one patch without anyone choosing to. That is the trade §5.5 makes deliberately; the number here is what is RUNNING, not a pin that constrains it | | Intake table | `adr-intake-submissions` (DynamoDB, ca-central-1) `[verified 2026-08-26]`. ⚠️ **KEY SCHEMA: PARTITION KEY `submissionId` (S), NO SORT KEY** ``[verified 2026-09-01 — `aws dynamodb describe-table`]``. **This contradicted `docs/05`, which specified `pk`/`sk`, and `backend/intake/handler.mjs` was written to the spec** — a `PutItem` missing the key attribute fails the whole write with `ValidationException`, the handler catches it and returns the failure page, so **every submission would have been lost while looking like a browser problem**. A DynamoDB key schema cannot be altered after creation; the handler was changed to the table on 2026-09-01 and `docs/05` §Storage carries the correction and the declined alternative. PITR **`ENABLED`**, 35-day window `[verified 2026-09-01 — describe-continuous-backups]`. Encryption at rest uses the **AWS-owned key — there is no customer-managed KMS key** `[verified 2026-09-01 — describe-table returns no SSEDescription]`, which `/legal/privacy/` does not claim, so nothing published depends on it. **4 items predate this repo**, written by the hand-built handler, which writes **no `ttl`** — so they never expire; `docs/06` carries that as Pouya's call. **TTL IS `ENABLED`, `AttributeName: ttl`** ``[verified 2026-08-31 — Pouya ran `describe-time-to-live` and read `TimeToLiveStatus: ENABLED`]``. The handler side matches: `backend/intake/handler.mjs` writes `ttl` as a Number in **epoch seconds** at **24 months** (`RETENTION_MONTHS = 24`, added to `getUTCMonth()`), which is `docs/05` §Retention and the `ttl` row of its item table `[verified 2026-08-31 — read from the handler, not recalled]`. ⚠️ **IT WAS `DISABLED` AT FIRST VERIFICATION EARLIER THE SAME DAY, AND THAT IS RECORDED RATHER THAN OVERWRITTEN.** Pouya ran `describe-time-to-live` on **2026-08-31** and it returned `DISABLED`; he enabled it on **2026-08-31** and re-read `ENABLED` the same day. `/legal/privacy/` has stated since build step 10 that a record is *"deleted automatically by the database rather than by someone remembering to do it"* after 24 months, so **that promise was unbacked from the day it was written until the day it was enabled** — the handler wrote the attribute and nothing on the table consumed it. This is the Q22 shape on a public privacy commitment rather than on a deploy control: a documented mechanism that did not exist. ⚠️ **`ENABLED` PROVES THE SETTING, NOT THE BEHAVIOUR, AND THE BEHAVIOUR IS STILL UNPROVEN — §9 Q60 STAYS OPEN.** No record has been written with a near-future `ttl` and watched to disappear. `docs/06`'s cutover checklist carries that test as a blocking item, it is not ticked by reading this row or the handler code, and §12 R19 keeps it surfacing until a deletion has actually been observed | | **Intake table — who can read it** | 🛑 **TWO IAM IDENTITIES — AND THAT IS A COUNT OF IDENTITIES, NOT OF PEOPLE. `/legal/privacy/` NO LONGER PUBLISHES A HUMAN NUMBER (Q63, ruled 2026-09-02).** ⚠️ **THE DISTINCTION IS THE ROW'S MOST IMPORTANT CONTENT, because this register supplied the false one.** The enumeration below is exhaustive over identities and every read path terminates at `user/pouya` or `user/lars` — and the page then rendered that as *"Two people can"*. **Pouya's attestation, 2026-09-02: *"two people is an exaggeration… a handful is accurate"*.** A simulation cannot see how many humans reach a credential, so the identity count is a **lower** bound on people and was published as an exact one. The page now says *"The record in the table: me, and the small number of people who administer the account it sits in with me"*, and **no numeric human headcount may ship**. ⚠️ **THAT SENTENCE HAS BEEN RE-QUOTED HERE THREE TIMES IN ONE DAY AND WAS WRONG TWICE — QUOTE IT FROM `dist/`, NEVER FROM A RULING OR FROM THIS ROW'S PREVIOUS VALUE.** ⚠️ **AND AS OF POUYA'S SECOND RULING THAT DAY, THIS ROW IS THE ONLY HOME FOR MOST OF WHAT FOLLOWS.** **Two facts below still ship** and must be kept true on the page as well as here: the Lambda role's **add-only** access (§Who can see it, paragraph 2) and the **shared account** (§Where it is stored). Everything else is this row's alone. *"The page stays generic. It over-explains technical mechanics that belong in the evidence file, not in front of an inquirer."* Deleted from `/legal/privacy/` §Who can see it: the measurement paragraph, the root-credential sentence, the single-sign-on and federated-login enumeration, the resource-policy clause, the *"company that runs a database"* aside, the deploy-credential sentence and the three-copies summary. ⚠️ **THE SHARED-ACCOUNT CLAUSE WAS CUT WITH THEM AND THEN RESTORED — to §Where it is stored, where it belongs.** It is a storage disclosure rather than mechanics, the ruling did not name it, and without it no page told a reader their intake sits in an account that also runs unrelated systems (`adversarial-reviewer`, round 1). **These lists must stay identical — there were four of them and they named four different sets.** **Everything below is unchanged, unretracted and still measured** — and all of it except the two facts named above has stopped appearing anywhere a reader can see it, which **raises** this row's stakes rather than lowering them: a register that nothing public contradicts is a register nobody re-reads. **Nothing here may be restored to the page**; the section comment in `src/pages/legal/privacy.astro` carries that bar. **ROOT: held by Pouya `[verified 2026-09-02 — Pouya, Q63(c)]` — RECORDED HERE, PUBLISHED NOWHERE.** The page stated it for part of 2026-09-02 and the sentence was deleted by the mechanics ruling; §9 **Q64** — *does anyone else hold it* — is closed as **MOOT rather than answered**, so ⚠️ *held by* is still not *held only by*, and **nothing about root custody may be published without asking him again** — not an IAM principal, cannot be simulated, no policy constrains it; `AccountAccessKeysPresent: 0` and `AccountMFAEnabled: 1` `[verified 2026-09-02]`. The identity facts follow, and they are unchanged and still exhaustive. `user/pouya` and `user/lars`, both via group **`admins`** carrying `AdministratorAccess`. **Four roles can also read it** — two `cdk-hnb659fds-cfn-exec-role-*` (all seven actions; trust `cloudformation.amazonaws.com` only) and two `cdk-hnb659fds-lookup-role-*` (the four read actions; trust the account root, and `sts:AssumeRole` is **allowed only for those same two users**). **`adr-intake-lambda-role` holds `PutItem` ONLY** — implicitDeny on `GetItem`/`Query`/`Scan`/`BatchGetItem`/`UpdateItem`/`DeleteItem`. **`adr-sml-deploy` is implicitDeny on all seven.** The CDK/CloudFormation escalation path is implicitDeny for all three deploy users. No SAML, OIDC or Identity Center principal exists (0/0/0) — **and the account is not in an AWS Organization (`AWSOrganizationsNotInUseException`), which is what makes that Identity Center zero conclusive rather than merely local** `[verified 2026-09-02]`. **No resource-based policy on the table: `dynamodb get-resource-policy` returns `PolicyNotFoundException`** `[verified 2026-09-02]` — a command, not an inference; a resource policy is invisible to `describe-table` and grants from the opposite side to every simulation here, so nothing else in the enumeration could have seen one. Root holds **no access keys**, MFA on. ``[verified 2026-09-02 — `get-resource-policy` on the table, `organizations describe-organization`, 5 users x 7 actions, **all 33** roles x 7 actions — 26 non-service-linked and, added 2026-09-02, the 7 service-linked ones every earlier sweep had excluded by `grep -v '^AWSServiceRole'`, all implicitDeny, 4 trust policies, 5 users x 6 CDK-path actions, all in `docs/reference/intake-table-access-verification.md` with the counts asserted per call]``. ⚠️ **THE ORIGINAL 2026-09-01 VERIFICATION WAS NOT ENOUGH FOR THE SENTENCE IT BACKED**: it screened roles with `list-attached-role-policies` alone, so it never saw that **23 of 26 roles carry inline policies** and that the two `lookup` roles can read the table. Four roles can, not two. The conclusion held; the reasoning did not. **THE PAGE GOES FALSE IF THIS CHANGES AND NOTHING IN AWS WILL SAY SO — §12 R21 is the trigger.** *(Less of the page than before: the mechanics cut of 2026-09-02 took four of R21's five claims off `/legal/privacy/`, leaving the administrators sentence and the mailbox. **The trigger did not weaken with them** — this row still asserts everything below, and `docs/06` still instructs an operator to re-run the verification before cutover.)* | | **`info@smlcompany.ca` — who reads it** | **A DELEGATED MAILBOX: Pouya AND administrative staff `[verified 2026-09-02 — Pouya, Q63(b)]`.** D18 sends the intake notification here, so this is the access list for the **second** copy of every submission — including the opposing parties and their counsel, which is the most sensitive thing the form collects. `/legal/privacy/` states it in terms (*"read by me and by administrative staff"*), having previously said *"anyone who can reach that mailbox"* — true either way, and a lower standard than the measured answer given one paragraph earlier for the table. ⚠️ **THIS IS AN ATTESTATION, NOT A MEASUREMENT, and it is the only fact behind a `/legal/privacy/` sentence that is.** Nothing in this repository or in AWS can check it: the mailbox is on Google Workspace (see the mail-hosting row) and this repo holds no Workspace credential. **It goes stale the way the AWS enumeration does and by the same mechanism — nobody is told when a delegation changes — so §12 R21's trigger covers it too.** | @@ -778,6 +783,8 @@ Nothing below can be invented. Each needs an answer from Pouya. | # | Question | Blocks | |---|---|---| +| **Q66** | 🛑 **THE TIMING FLOOR WAS RULED ON 2026-09-04 AND CANNOT BE BUILT WHERE THE RULING PUTS IT — and the ruling's premise is the part to read first.** Pouya's mitigation item 1 was *"raise the timing floor to a value a human cannot beat filling 12 fields but a patient bot might"*, on the basis that the two spam submissions *"passed the honeypot **and timing checks**"*. ⚠️ **THERE IS NO TIMING CHECK. There never has been** — `docs/05` §Validation carries it **struck**, with the reason in three further places, and `backend/intake/handler.mjs` says so in its header. So there is no floor to raise, and the spam did not defeat a control; it walked past a gap that was recorded as a gap. **The reason is unchanged by the spam arriving:** `/contact/` is a CDN-cached static file, so no per-visitor *served-at* value exists to subtract from, and a build-time one is identical for every caller. ⚠️ **AND THE SECOND HALF OF THE INSTRUCTION IS ALSO UNMET: he asked to *"measure a real fill first (Pouya's own test)"* and no measurement was supplied**, so even a buildable floor would have no number. **Every mechanism that WOULD produce a real per-visitor clock breaks one of his own constraints** — client script breaks zero-JS; a CloudFront viewer-response function setting a signed cookie is outside *"handler + form only"* **and puts a cookie on a site whose privacy policy turns on there being none**; a dynamic origin reverses D1. The table is in `docs/05` §Observed abuse. **What is needed:** either (a) accept that there is no timing signal and let the scoring carry it — the recommendation, because scoring is already shipped and costs nothing further; or (b) rule that the cookie mechanism is in scope, which is a privacy-policy change before it is an engineering one. Raised 2026-09-04 | **Nothing, and the other three do not depend on it.** ⚠️ **"Shipped" would be the wrong word for any of them and this column said it:** the second honeypot and the scorer are **written and not deployed** (a site deploy does not carry `backend/`; they need `docs/09` Part 5), and the origin request policy needs `configure.mjs --apply`. What Q66 blocks is only the claim that a timing control exists | +| **Q65** | **AWS WAF ON THE DISTRIBUTION — A COST DECISION, DEFERRED BY POUYA 2026-09-04.** A rate-based WAF rule is the only way to limit `/api/*` **per source IP**: API Gateway throttling is aggregate, which `docs/05` §Validation records as a struck requirement. His ruling: deferred, *"revisit if volume exceeds the labelling approach"*. **The trigger is therefore a volume he has not named**, and this row exists so that it is a decision rather than a drift. ⚠️ **THE GROUNDWORK IS DONE AND IS NOT THE DECISION:** `configure.mjs` section 5 forwards `CloudFront-Viewer-Address`, the one address CloudFront generates and overwrites, so a per-IP measure becomes *possible* — it needs `--apply`, and the handler still stores the edge address, deliberately. **Two things would make this answerable rather than a guess:** the volume that would justify the spend, and the `summary` lengths of the two 2026-09-04 records, which are still in the table and are the only real data the scorer's `SHORT_SUMMARY_CHARS` floor could rest on — it is `[assumed]` today. Raised 2026-09-04 | **Nothing today.** It blocks any per-IP control, and it holds the scoring floor at an assumed value | | ~~**Q64**~~ | ✅ **CLOSED 2026-09-02 — MOOT. THE PARAGRAPH IT WAS ABOUT WAS DELETED, WHICH IS NOT THE SAME AS THE QUESTION BEING ANSWERED.** Pouya's second ruling of 2026-09-02 cut §Who can see it to four plain statements and struck the mechanics, the root sentence among them — *"the page stays generic. It over-explains technical mechanics that belong in the evidence file, not in front of an inquirer."* No page now says anything about root, so nothing turns on who holds it and the question gates nothing. The `TODO(pouya)` went with the paragraph. ⚠️ **THE UNDERLYING GAP IS EXACTLY AS OPEN AS IT WAS AND MUST NOT BE READ AS CLOSED.** §7 records root as *held by Pouya* `[verified 2026-09-02 — Pouya]`, which is not *held only by Pouya*; root is not an IAM principal, cannot be simulated, and `get-account-summary` reports only `AccountAccessKeysPresent: 0` and `AccountMFAEnabled: 1`. **Nothing about root custody may be published without asking him again**, and the question to ask is not *who holds root* — that is answered — but ***whether anyone else does***. §7's row, `docs/reference/intake-table-access-verification.md`'s addendum and the section comment in `src/pages/legal/privacy.astro` all carry that bar. **This is the third distinct way a question has left this list in one day — answered (Q63), struck (Q62), and now moot — and all three look identical in a count.** Original question follows. 🛑 **DOES ANYONE ELSE HOLD THE AWS ROOT PASSWORD OR ITS MFA DEVICE?** `/legal/privacy/` now publishes *"Its root credential — the one path no policy constrains — has no programmatic key, and I hold it"*, which are Pouya's own words from the Q63(c) ruling and are **true whether or not somebody else holds it too**. ⚠️ **The defect is what a reader takes from it.** The sentence sits one paragraph below *"the small number of people who administer it with me"*, so a reader takes *"I hold it"* as **sole** custody — and nothing establishes that. Root is not an IAM principal, cannot be simulated, and `get-account-summary` reports only `AccountAccessKeysPresent: 0` and `AccountMFAEnabled: 1` `[verified 2026-09-02]`; the attestation in §7 says *held by Pouya*, not *held only by Pouya*. **This is Q63's own lesson one paragraph lower and pointing the other way:** Q63 struck a sentence for reading identities as people, and this one invites a reader to read a possessive as an exclusion. §12 **R21** was written treating it as exclusive and has been corrected pending the answer. **If it is sole custody: say so, put it in §7, and R21 arms it. If it is not: the possessive comes out and the sentence keeps only the measured half.** Raised by `adversarial-reviewer`, round 1, 2026-09-02 | *(closed — moot)* | | ~~**Q63**~~ | ✅ **CLOSED 2026-09-02 — ALL THREE LIMBS ANSWERED BY RULING, and the answer to (c) changed the sentence (a) had just approved.** **(a) WORDING APPROVED WITH TWO TRIMS:** the editorial closing sentence (*"I would rather tell you that than give you the tidier answer"*) is struck, and the mailbox clause is rewritten per (b). **(b) `info@smlcompany.ca` IS A DELEGATED MAILBOX — Pouya and administrative staff read it.** The page said *"anyone who can reach that mailbox"*; it now says who. ⚠️ **That answer reached FOUR sentences, not the one the question named** — §Where it is stored twice (*"a notification to me"*, *"my own mail is on Google Workspace"*), §How long it is kept once (*"the notification sits in my mailbox"*) and §Who can see it once — because the mailbox had been written as a personal one throughout the page. **Fifth partial sweep of this page's who-can-see-it set. The section comment names the places by opening phrase and gives NO COUNT** — it carried "eight" for one round after excluding a paragraph its own change set had edited, then "nine", and the set is now ten (`adversarial-reviewer`, rounds 1 and 2, on successive counts). **(c) ROOT IS HELD BY POUYA** `[verified 2026-09-02 — Pouya]`, and the page now states that it has no programmatic key and that he holds it. ⚠️ **THE CONSEQUENCE NOBODY ASKED FOR AND IT IS THE MOST IMPORTANT LINE IN THIS ROW: THE HUMAN HEADCOUNT CAME OFF THE PAGE.** Pouya's attestation: *"two people is an exaggeration… a handful is accurate — the simulation counts identities, not humans, and the two are not the same claim."* **The enumeration was exhaustive and the inference off it was not**: every read path terminates at two IAM identities, which is a **lower bound** on the number of people who can reach them, and `/legal/privacy/` published it as an exact count. It then read *"the account's administrators — me, and the small number of people who administer it with me"*. ⚠️ **THAT IS NOT THE SHIPPED SENTENCE EITHER, AND NOR WAS ITS REPLACEMENT.** Pouya ruled again the same day that the section states who and not how; the measurement paragraph, the root sentence and the summary were deleted outright, and the first sentence was then rewritten **twice more under review** — *"The people who run this practice can"* was struck for asserting an unregistered claim about who runs the practice, and *"administer this practice's systems"* for naming a set neither the measurement nor the attestation supports. **It ships as *"The record in the table: me, and the small number of people who administer the account it sits in with me"*, and §4 has the row it rests on.** Each limb of Q63 still stands; only the text it was applied to moved — **four times in one day, which is why nothing outside `dist/` is a safe source for this quote.** **No numeric human headcount ships**, the identity count stays in §7 and in the reference extract, and the extract's own inference sentence (*"The count of people is two"*) is corrected at source, because a false inference left in the evidence file re-supplies itself to the next reader. ⚠️ **AND THE `33` WAS DELIBERATELY NOT PUBLISHED** — the ruling permits the identity count on the page *"if useful"*; a role total moves when AWS creates a service-linked role by itself, so publishing it would put a second R21-governed number on a legal page that can go stale with no human acting. *"Every user and every role in the account"* carries the exhaustiveness and survives the count moving. **One line to overrule.** Original question follows. 🛑 **TWO THINGS `/legal/privacy/` STILL NEEDED FROM POUYA, ANSWERED IN THE SAME READ-THROUGH.** **(a) APPROVE THE §Who can see it WORDING.** Q62 settled what it must say and he reserved the wording in terms: *"Draft it; Pouya gives final approval on wording during his page read-through."* The draft is shipped in `dist/` and quoted in `docs/reference/intake-table-access-verification.md`. **This is a gate that existed only inside records marked closed until 2026-09-02** — the `TODO(pouya)` had been deleted, Q62 struck, and `docs/06`'s blocker ticked, so when Q60's TTL test passes **nothing mechanical or visual would have stopped unapproved copy publishing.** `adversarial-reviewer`, round 1. **(b) WHO ELSE CAN READ `info@smlcompany.ca`?** Nothing in this repository establishes it — §7 records the mail host (Google Workspace) and the SES identities, **not the mailbox's access list**, and a Workspace super-admin can reach any mailbox in the tenancy. Given that this project's AWS account, its Gitea instance and §10 are all jointly administered, the plausible case is that it is not only him. The copy is now written to assert **no** access list (*"anyone who can reach that mailbox"*), so nothing false is published either way — but a privacy policy that answers the table half with a measured number and the mail half with a shrug is answering the same question on two standards, and the reader is entitled to the specific on both. **(c) WHO HOLDS THE ROOT CREDENTIAL FOR THE AWS ACCOUNT?** The page's headline answer is a **count of people**, and root is the one path no policy constrains and no simulation can reach — it is not an IAM principal and does not appear in `list-users`. Two facts bound it: `AccountAccessKeysPresent: 0`, so there is no programmatic root credential, and MFA is on, so console access needs the root password and its device `[verified 2026-09-02]`. **If those are held by anyone other than the two administrators, "Two people can" is short by one.** The second paragraph is scoped to *"every user and every role"* and to who has been **granted** access, so it is unaffected either way — this reaches the first sentence only. Raised by `adversarial-reviewer`, round 2, which is also where the honest form of the objection came from: the artefact says in terms that this is not established, and a page was resting a count on it. **If he answers: put the fact in §7, make the sentence specific like the table sentence above it, and arm it with §12 R21's trigger.** Raised by `claims-auditor` and `adversarial-reviewer`, D20 cutover pass round 1, 2026-09-02 | *(closed)* | | ~~**Q62**~~ | ✅ **CLOSED 2026-09-02 — RULED *state the truth*, NOT *remove the access*. Pouya:** *"Rewrite the `/legal/privacy/` sentence to say exactly who can access the submissions table… the true number of people and their roles, stated specifically — not 'authorised administrators' or any other vacancy."* **Applied.** The page now opens §Who can see it with *"Two people can"*, states that the AWS account also runs systems unrelated to this practice and has two administrators, and says administrative access carries the ability to read the table. It then published the two facts the false sentence had been crowding out and which are **stronger** than what it claimed: the function that receives the form can only add a record and **cannot read the table back**, and the credential that publishes this website has **no access to the table at all**. ⚠️ **OF THOSE TWO, ONLY THE FIRST STILL SHIPS** — the mechanics ruling of 2026-09-02 took the deploy-credential sentence off the page along with the rest of the method. It is unretracted and still measured; it lives in §7 and in `docs/reference/intake-table-access-verification.md`. ⚠️ **THE FIX TOUCHED THREE PLACES, NOT ONE, AND THE OTHER TWO ARE THE POINT.** A vocabulary sweep — `grep -rniE "no (team\|assistant\|outside\|external) [a-z]*\|nobody else\|no one else" src/` — found the same falsehood in different words **two sections up the same page**: §Where it is stored ended *"and no assistant or outside administrator"*, which the Q62 pattern could not see because it was anchored on the two sentences under the other heading. And the summary paragraph closed *"the honest answer to 'who can see this' is: me, and Google"* — which would have survived the correction directly above it and re-asserted the struck number. All three now change together and the page's own comment says so. **THE TRIPWIRE STAYS PERMANENTLY — his ruling in terms:** *"it bars the false-claim shape from returning, which is exactly what the freeze's breach exception exists for."* Extended from two alternatives to **five** — round 1 of the closing audit found a third published surface unbarred, and found the first extension had widened one alternative to four phrasings where one was published. Every alternative is now a single string that reached `dist/`, so nothing speculative entered a frozen script. **Proven both ways, on real published bytes and not on fixtures alone:** against the pre-correction page rebuilt from `bd282aa` it exits **1** with **5 matches** at `dist/legal/privacy/index.html:54, 67, 67, 68, 72` — 54 is the clause the first form missed and 72 the surface it could not see at all — and against the corrected page it exits **0**. ⚠️ **THE APPROVED-STRING AND FIXTURE COUNTS ARE NO LONGER RESTATED HERE, DELIBERATELY.** They said four/four/33/three, then 36/six, then were stale again within one review round when the Q63 rewrite changed the copy the fixtures quote — three times in two days, on a row whose own point is that the record of what a frozen script bars is its maintenance surface. **`npm run check:claims` prints both numbers on every run; read them there.** **Both numbers are deliberately not repeated here** — they moved again on 2026-09-02 when the mechanics cut retired six fixtures and added four, and a row that had just ruled the printed numbers authoritative was still carrying its own stale pair one sentence later (`adversarial-reviewer`, round 1). The script prints the pattern count, the approved-string count and which of them are live page copy; that is the record. ⚠️ **The counts in this row said four/four/33/three until 2026-09-02**, describing the script as it stood before round 1's fix; on a frozen script the record of what it bars is the maintenance surface, so `adversarial-reviewer` round 2 was right to treat a stale count as a defect. ⚠️ **AND THE FIX AS FIRST WRITTEN INTRODUCED THREE DEFECTS OF ITS OWN, ALL FOUND BY THE D20 PASS ROUND 1 AND ALL NOW CORRECTED — see entry (an).** The replacement asserted *"No third party has access to it"* (an absolute negative that excludes a **disclosed processor**, which is the exact sentence struck from this page on 2026-08-31 as *"the most serious thing found in the step 7–10 review"*); it claimed *"every account and role in this infrastructure… that is checked rather than assumed"* over an artefact that had screened five users and **one** role; and it said *"the one other place a copy exists"* when the handler puts the whole submission into the confirmation it sends the inquirer, so a **third** copy sits with the reader's own provider — which this page already says two sections up. **Q62's own fix recreated Q62's shape twice.** **Two things are NOT resolved and are now Q63 rather than a footnote here** — the wording approval Pouya reserved, and the `info@smlcompany.ca` access list. The earlier form of this row called the mailbox point non-gating *"because the copy is true either way"*, which was **a guess about a fact nobody checked**; the copy is now written so it asserts no access list at all, and the question gates the page through Q63 with a `TODO(pouya)` beside it. Original finding follows. 🛑 **`/legal/privacy/` TELLS THE PUBLIC SOMETHING FALSE ABOUT WHO CAN READ THE INTAKE TABLE, AND IT IS A PRIVACY POLICY.** The page says: *"The table is reachable by the function that writes to it and by one administrative account, which is mine — nobody else has access to the table. There is no team, no assistant and no external administrator."* **The account has an IAM group `admins` carrying `AdministratorAccess` with TWO members**, and `iam simulate-principal-policy` returns **allowed** for `dynamodb:GetItem`, `dynamodb:Query` and `dynamodb:Scan` on the table for both of them — identical access, by the same route, the other user's own attachments being only `IAMUserChangePassword` `[verified 2026-09-01 — the five users, the group, and a five-row simulation, all commands in `docs/reference/intake-table-access-verification.md`]`. So **both halves of the sentence are wrong**: a second account has access, and it belongs to a second administrator of a shared account (§10). The three deploy users are `implicitDeny`; two CDK bootstrap roles carry `AdministratorAccess` but are assumable only by the same two administrators; the writing role holds **`PutItem` only and cannot read the table**, which is a stronger fact than the page currently claims and is the part of the sentence that is true. **THE QUESTION, AND IT IS ONE OF TWO THINGS.** (1) **Remove the access** — take that user out of `admins`, or deny DynamoDB on this table — after which the sentence becomes true as written. ⚠️ Note the likely collision: **Q23 records the Gitea instance as jointly administered and blocked on "its second administrator"**, so that access is probably not only for this account and removing it may cost something elsewhere. (2) **State the true number** — how many people hold administrative access, and that the function which writes cannot read. ⚠️ **DO NOT RESOLVE IT BY SOFTENING.** *"Access is limited to authorised administrators"* is the shape §4 exists to bar: defensible, uninformative, and it would replace a false specific with a true vacancy on the one page where a reader describing a live dispute is entitled to the specific. **Why this was invisible until now:** it is the only claim on the site whose subject lives entirely outside the repository, so R14 applies — *"unverifiable by construction"* — and there was no committed artefact to compare it against. There is now. Raised by `claims-auditor`, D20 cutover pass, finding 8 | *(closed)* | @@ -785,7 +792,7 @@ Nothing below can be invented. Each needs an answer from Pouya. | **Q60** | ⚠️ **HAS A TEST RECORD BEEN OBSERVED TO DISAPPEAR FROM THE INTAKE TABLE?** **Half one closed 2026-08-31: TTL is `ENABLED` with `AttributeName: ttl`, verified by command — §7 holds that status and this row does not restate it.** The question is now the second half alone, and it was never the smaller half. `/legal/privacy/` does not merely publish a retention *period* — it asserts a **mechanism**: *"the record is deleted automatically by the database rather than by someone remembering to do it"*. **`ENABLED` proves the setting; only a record written with a near-future `ttl` and watched to vanish proves the behaviour.** Two things this may NOT be answered from: the handler code, which writes the attribute and nothing more (that side is verified and is not what is being asked); and the table setting, which is what was just confirmed. ⚠️ **AND THE FIRST HALF IS THE REASON TO TRUST THE SECOND LESS, NOT MORE:** `describe-time-to-live` returned **`DISABLED`** when Pouya first ran it on 2026-08-31, so the sentence above was published against a mechanism that was not running, and nothing in the repo, the build or AWS reported it. A setting that was off for as long as nobody looked is not evidence that the behaviour now works. ⚠️ **THE `TODO(pouya)` MARKER IS GONE AS OF 2026-09-03 AND THIS QUESTION IS STILL OPEN — do not read the one as the other.** It sat on the retention section of `src/pages/legal/privacy.astro`; Pouya ruled that day that the page publishes and the deletion is confirmed after launch, so the comment now states that decision and the marker came off with the gate it enforced. **What changed is when the page publishes, not whether the fact is known.** `src/` therefore carries **zero** live `TODO(pouya)` markers while a §9 question is open, which is the one configuration `CLAUDE.md`'s marker convention does not describe — recorded here rather than resolved, because restoring a marker Pouya asked to be removed would be the wrong repair. **The mechanisms that keep this surfacing are now `docs/06`'s cutover checklist, which carries the test as blocking, and §12 R19.** *(The Change Log entry of 2026-09-02 (ap) records `TODO(pouya) in src/: exactly one, Q60's` and stands unedited — it was true when written, and past entries are not rewritten.)* **Why this is a numbered question and not only a checklist line:** `CLAUDE.md` requires a `TODO(pouya)` plus a §9 row when a page needs a fact the repository does not have, and this page needs one — a cutover checklist fires once, at cutover, and §9 is what a person editing this page reads. Raised by `adversarial-reviewer` round 2, 2026-08-31 | **`/legal/privacy/` going public.** Nothing else — no other page states the mechanism, verified by sweeping `dist/` for the retention vocabulary and reading each hit in context | | ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling and `docs/07` carries it in full. ⚠️ **THIS ROW NAMED A CONSTANT THAT NO LONGER EXISTS** — `FEES.mediation.overtimeStartsAfterSessionHours` was deleted the same day as dead data: nothing read it, so reversing it would have changed nothing and failed nothing, which is Q22's shape at constant scope. **Where the ruling actually lives:** the trigger is rendered on `/fees/` from `halfDay.hours` / `fullDay.hours`, and `FEES.mediation.reservation` carries the half that publishes as prose. Found by `adversarial-reviewer` round 2 — §9 is what a later implementer reads to find where a ruling is recorded, so pointing it at a deleted identifier is the same defect one layer up. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped | | ~~Q58~~ | **RULED 2026-08-31 — `hours` IS THE SESSION, AND THE AMBIGUITY WAS IN `docs/07` RATHER THAN IN ANY COPY. Pouya owned it in terms:** *"the ambiguity is mine… My `docs/07` wording said "up to 3.5 h, including 2 h preparation", which is genuinely unclear: 3.5 was meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours in the room. Your arithmetic caught it: if prep sat inside, 3.5 and 7 wouldn't be exactly 2×, because preparation doesn't scale with session length. The intended reading is the market's, and my wording obscured it."* **THE CORRECTED CARD, in his words:** *"Half day — up to 3 hours of session. Fee includes up to 2 hours of preparation. $2,000. Full day — up to 6 hours of session. Fee includes up to 3 hours of preparation. $4,000."* His reason for 3 and 6: *"the market convention — Patey and Zuber both publish "all or part of 3 hours" and "all or part of 6 hours", and those were the comparables the rate was set against. Selling 1.5 hours of room time as a half day would be an outlier nobody would recognise."* ⚠️ **ONE PROVENANCE NOTE, and it is R14's rule rather than a doubt about the ruling:** `docs/07`'s committed extract records Patey and Zuber at **3 h** and **6 h** but **does not carry the phrase "all or part of"** — so `docs/07` cites the hours, not the phrase, and the phrase is not attributed to them anywhere in the repository. The hours corroborate the ruling on their own, and ADR Chambers' roster rate in the same table is the clearest corroboration of the *shape*: *"one half hour of preparation time per party **and** up to three hours of mediation"* — preparation counted separately from a three-hour session. **APPLIED:** `docs/07`'s two card rows and its §All parameters confirmed (which prescribed the flat *"including 2 hours"*, the form `/for-parties/` then shipped); `FEES.mediation.*.hours` 3.5 → 3 and 7 → 6 with the semantics in the constant's doc comment; `/for-parties/` now states the session length interpolated from the constant and the preparation allowance **as a cap**. **The preparation allowance is CAPPED and must be published as capped** — *"including **up to** 2 hours"*, never the flat form and never "preparation included". **`/fees/` is UNBLOCKED for build step 9.** **The question as raised is preserved below.** **DOES `hours` IN THE MEDIATION RATE CARD MEAN THE LENGTH OF THE DAY, OR THE BILLED ENVELOPE INCLUDING PREPARATION?** `docs/07-fees.md` reads *"Half day — **up to 3.5 h, including 2 h preparation**"* and *"Full day — up to 7 h, including 3 h preparation"*. Taken at face value, 3.5 is the whole billed envelope and the **time in the room is 1.5 h** for a half day and **4 h** for a full day. **Against that reading:** 3.5 and 7 are exactly 2×, which they would not be if preparation sat inside them (1.5 vs 4 is not 2×). So either the card's wording is wrong in the one document that is the authority on money, or `FEES.mediation.*.hours` in `src/data/site.ts` does not mean what a page would naturally publish it as. **This was one sentence from shipping.** A draft of `/for-parties/` answered *"What happens on the day?"* with *"A half day is about 3.5 hours"* — the envelope presented as the day, to the reader least able to check it. The sentence was removed rather than resolved by inference; the page now says only that you book a half day or a full day. **What is needed:** one line from Pouya saying which the 3.5 and 7 are. Then `docs/07`'s two rows or `site.ts`'s field gains the correction, and the semantics go in the constant's doc comment (a warning is there now). **`/fees/` at build step 9 publishes this table and cannot be built without the answer.** Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing.** No page stated a duration while the question was open — the one draft sentence that did was removed rather than reconciled, which is why the ruling had nothing to correct in public copy | -| ~~Q57~~ | **CLOSED 2026-08-31 — NO SEVENTH UNDERTAKING, AND THE PAGE IS COMPLETE AS IT STANDS.** Pouya: *"`/process/` stating when conflicts are run and what the check needs is complete. A reader assumes the outcome, and the obvious undertaking ("if a conflict is found I decline") adds nothing a reader doesn't already infer. Your restraint was right — §4's gate held. Record it closed rather than open, so it stops appearing in the live list."* **So this is a closure, not a deferral:** the answer is that the page says nothing further, which was one of the two outcomes the question named. §4 gains no seventh conduct undertaking and `CONDUCT_UNDERTAKINGS` still holds six. **APPLIED:** the `TODO(pouya)` is removed from `src/pages/process.astro` §Conflicts and replaced with the ruling, so a later reader finds the decision where the question was rather than an open marker; the file header's *"see the TODO below"* is corrected to cite this closure. `src/` now carries **zero** live `TODO(pouya)` markers. **The question as raised is preserved below.** **WHAT HAPPENS WHEN A CONFLICTS CHECK TURNS SOMETHING UP?** `/process/` §Conflicts ships saying **when** the check runs (the intake call, before anything is agreed) and **what it needs** (full legal names of the parties, the parent or affiliate actually behind the dispute, counsel on each side). It stops there, and the stop is deliberate: **any sentence naming the outcome is a SEVENTH conduct undertaking**, and §4's gate for that class is one line — *"an undertaking may be published only where Pouya has made it in terms. Not 'would obviously agree to', not 'follows from the process' — said."* *"If a conflict appears I decline the appointment"* is exactly what that gate refuses to let this repository infer, however obvious it looks. **What is needed:** one sentence from Pouya, in his words, or a decision that the page says nothing further. `TODO(pouya)` sits at `src/pages/process.astro` §Conflicts. Raised at build step 6, 2026-08-30 | **Nothing.** The section shipped accurate and unchanged; what closed is whether anything more was owed | +| ~~Q57~~ | **CLOSED 2026-08-31 — NO SEVENTH UNDERTAKING, AND THE PAGE IS COMPLETE AS IT STANDS.** Pouya: *"`/process/` stating when conflicts are run and what the check needs is complete. A reader assumes the outcome, and the obvious undertaking ("if a conflict is found I decline") adds nothing a reader doesn't already infer. Your restraint was right — §4's gate held. Record it closed rather than open, so it stops appearing in the live list."* **So this is a closure, not a deferral:** the answer is that the page says nothing further, which was one of the two outcomes the question named. §4 gains no seventh conduct undertaking **of the kind this question asked about** and `CONDUCT_UNDERTAKINGS` held six. ⚠️ **UPDATED 2026-09-03 — THE CONSTANT NOW HOLDS SEVEN, AND THAT IS NOT A REVERSAL OF THIS RULING.** Undertaking (g) attests that a conflicts check **is run**; what this question refused was a sentence about **what happens when one turns something up**. The refused proposition is still refused, and `/process/` §Conflicts is unchanged. The count is corrected here because §9 is Current Truth and a closed row asserting a stale number is read as a current one. **APPLIED:** the `TODO(pouya)` is removed from `src/pages/process.astro` §Conflicts and replaced with the ruling, so a later reader finds the decision where the question was rather than an open marker; the file header's *"see the TODO below"* is corrected to cite this closure. `src/` now carries **zero** live `TODO(pouya)` markers. **The question as raised is preserved below.** **WHAT HAPPENS WHEN A CONFLICTS CHECK TURNS SOMETHING UP?** `/process/` §Conflicts ships saying **when** the check runs (the intake call, before anything is agreed) and **what it needs** (full legal names of the parties, the parent or affiliate actually behind the dispute, counsel on each side). It stops there, and the stop is deliberate: **any sentence naming the outcome is a SEVENTH conduct undertaking**, and §4's gate for that class is one line — *"an undertaking may be published only where Pouya has made it in terms. Not 'would obviously agree to', not 'follows from the process' — said."* *"If a conflict appears I decline the appointment"* is exactly what that gate refuses to let this repository infer, however obvious it looks. **What is needed:** one sentence from Pouya, in his words, or a decision that the page says nothing further. `TODO(pouya)` sits at `src/pages/process.astro` §Conflicts. Raised at build step 6, 2026-08-30 | **Nothing.** The section shipped accurate and unchanged; what closed is whether anything more was owed | | ~~Q56~~ | **RULED 2026-08-30 — MEDIATION IS *NOT* SCOPED COMMERCIAL.** Pouya: *"Correct the four 'Commercial Mediation' surfaces to 'Mediation'; leave §4's mediation row unscoped."* **And the asymmetry with arbitration is DESIGNED, not an oversight — the reason is now recorded beside both §4 rows so nobody tidies them into a matching pair.** **Arbitration is scoped commercial because of a LEGAL GATE:** Q39 — family arbitration in Ontario requires prescribed training, and Pouya has excluded it by choice. **Mediation has no such gate**; he mediates commercial, construction, insurance/SABS, shareholder and cross-cultural matters, and the practice pages say so. So the site-wide "commercial" framing was **under-describing a wider offering**, which is why it is corrected rather than ratified as a positioning choice. `/practice/insurance/`'s SABS framing needs no reconciliation: it was never outside the offering. **The question as raised is preserved below.** ⚠️ **IS THE MEDIATION OFFERING SCOPED COMMERCIAL, OR NOT? THE SITE SAID BOTH.** §4 Offerings rows arbitration three times, each **scoped commercial**; the mediation row is `**Mediation** — sole mediator`, **unscoped**. Shipped output scopes it anyway: `/mediation/`'s `` is *"Commercial Mediation"*, its `Service` node is `name: "Commercial mediation"`, and `/` and `/about/` both say *"I mediate commercial disputes"*. Meanwhile **`/practice/insurance/` offers mediation in matters that are not commercial on any ordinary reading** — *"Disputes between an insured person and an insurer under the Statutory Accident Benefits Schedule"*, with *"What I offer is private mediation"*. An individual claimant against their own insurer is not a commercial dispute. **Nothing here is a false claim** — scoping a page to commercial mediation asserts *less* than the unscoped §4 row grants, and narrower than the row is always safe. **The problem is that the two halves cannot both be the whole picture**: either the practice takes non-commercial mediations (and the site-wide "commercial" framing under-describes it, including on the page an appointing body reads), or it does not (and `/practice/insurance/` is offering something outside the offering). **What is needed:** either a §4 Offerings row scoping mediation, with `/practice/insurance/`'s SABS framing reconciled to it — or a decision that mediation is deliberately unscoped, in which case the site-wide "commercial" wording is a positioning choice and should be recorded as one rather than read as a limit. **Pre-existing, not created 2026-08-30** — but this change set newly wrote the claim onto `/med-arb/` and it has been removed again pending this answer. Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing — ruled the same day it was raised.** No page was wrong; the register was silent where the site was specific, and the ruling makes the site match the register rather than the other way round | | ~~Q55~~ | **CLOSED 2026-08-30 — NOT RESOLVED, AND THE DIFFERENCE IS THE RULING.** Pouya: *"The date is not published and nothing depends on it. Your split stamp was right: `[verified]` on the status, `[Pouya's stated basis]` on the date, the 2026-08-26 record noted as unreconciled. A register that says 'two inconsistent reports of an unpublished fact, neither relied on' is complete. Do not put it to Pouya."* **So this row is not a question any more and must not be re-raised as one.** §4's Q.Arb row now carries the split stamp explicitly and marks the 2026-08-26 (a) report **unreconciled, permanently**. **Why closing beats answering here:** the register's job is to say what it can vouch for, and it can vouch for exactly this — that he reported a July acquisition, that he had reported an August commencement three days earlier, and that no published claim rests on either. Asking again would buy a third self-report about a fact the site does not state. **The question as raised is preserved below, because the evidence is the record.** ⚠️ **WHEN WAS Q.Arb ACTUALLY OBTAINED? THE REGISTER HOLDS TWO DATES FROM THE SAME SOURCE AND THEY CANNOT BOTH BE TRUE.** (a) **2026-08-26 (a)**, Change Log, `[verified 2026-08-26]`: *"Q.Arb dated. Old → new: `[assumed]`, stage unknown → **commenced August 2026**"*. (b) **2026-08-29**, Pouya's amendment, now the §4 Verified row: *"Obtained July 2026"*. A designation cannot have been obtained in July from a pathway reported as commencing in August, and (a) was reported three days before (b). One self-report is wrong; the register cannot say which, and **inferring it would be exactly the guessed-explanation failure `CLAUDE.md` bars**. **THIS DOES NOT BLOCK, and that is the whole reason it is a question rather than a hold.** The published claim is *held* — **which is faithful to his most recent instruction, and that is a different thing from correct.** §4's premise is that `[verified — Pouya]` establishes *what he reported*, never the fact, and this is the one row where the register holds documented evidence that a self-report about this credential is wrong. The date is deliberately unpublished, so no page turns on the answer. What turns on it is whether §4 can vouch for its own highest-stakes new row. **What is needed:** one line from Pouya giving the correct date, and whether (a) was a misreport, a different milestone, or something else. Then re-stamp the §4 row and correct or annotate (a) — the Change Log is append-only, so (a) gets a pointer, never an edit. Raised by `claims-auditor`, 2026-08-30 | **Nothing — and closed on that basis rather than despite it.** The site states what Pouya most recently instructed, and no page carries the date | | ~~Q1~~ | **ANSWERED 2026-08-26.** Licensure is left out entirely; the site asserts the JD only. See D13 | — | @@ -842,7 +849,7 @@ Nothing below can be invented. Each needs an answer from Pouya. | ~~Q51~~ | **ANSWERED 2026-08-28 — KEEP THE OBA SECTIONS. The distinction is structural, not evidentiary.** Pouya: *"the Law Society is the regulator, so membership IS licensure; the OBA is a voluntary association. Structural distinction, independent of eligibility details."* That last clause is what closes it: the question was raised as unanswerable inside this repo **because OBA eligibility is not established here** — and the ruling makes eligibility irrelevant. A voluntary association admits members it does not license, so listing it carries no licensure implication; a regulator's membership roll *is* the licence. Recorded in §4's memberships row and in `.claude/agents/claims-auditor.md` so it is not re-litigated, and removed from `docs/06`'s cutover checklist. **R1 is unaffected** — same page, same subject, different question, still live | — | | ~~Q52~~ | **CLOSED 2026-08-28 — committed, and RE-RUN rather than transcribed.** Pouya: *"YES — commit the simulate results, the inline policy, and the `NoSuchBucketPolicy` response, access key ID redacted."* `docs/reference/deploy-credential-verification.md` — eighteen read-only calls, each with the command that produced it, generated from captured output with the key ID replaced by `AKIA…REDACTED`. **Every value in §7's row reproduced**, including all nine `simulate-principal-policy` decisions (four `allowed`, five `implicitDeny`). **Two things the re-run added that the original verification did not have.** (1) A **contrast call**: `get-bucket-policy` on the *site* bucket returns exit 0 and a real policy, which is what makes exit **254** / `NoSuchBucketPolicy` on the backup bucket a genuine absence rather than a command that failed to start — the rule `CLAUDE.md` records twice, applied to the evidence that closes a High risk. (2) A correction to §7's own wording on the key's `LastUsed` field shape. The **secret** access key was never requested; the API cannot return it | — | | ~~Q53~~ | **ANSWERED 2026-08-28 — EMIT IT. The withholding is dropped.** Pouya: *"the memberships are already crawlable in `/about/`'s HTML, so withholding the triple reduces no exposure and only makes the graph less complete than the page."* Option (a) of the three the question offered. `src/data/schema.ts` now emits `memberOf` — the four §4 memberships as `Organization` nodes — **on `/about/` only**, which is where the page shows them, so the graph and the visible page assert the same set. **This ends a judgement that was restated three times and wrong twice:** cacheability proved too much (equally true of `hasCredential`, which ships), volatility did not survive the page already carrying the claim, and the widened *"yearly renewing"* premise it rested on was itself a §4 misstatement found on re-audit | — | -| ~~Q54~~ | **ANSWERED 2026-08-29 — ALL SIX PUBLISH, INCLUDING (c), AND THE ANSWER CREATED A THIRD CLASS IN §4.** Pouya: *"Publish all six, including (c). (c) ships as drafted."* His reasoning on the expensive one, recorded on his instruction: *"it is the strongest available answer to the med-arb objection, and cheaper in practice than it sounds — the arbitral phase runs on the evidentiary record, not the caucus, so the case where a neutral genuinely cannot decide without confidential material is uncommon. `/med-arb/` was raising the hardest question about med-arb and answering it only at the level of process design."* **§4 now carries `Conduct undertakings` as a class distinct from both credentials and offerings** — his ruling: *"They are not facts about experience; they are commitments Pouya has now made... and they bind."* The gate for the class is one line: he must have said it in terms. The six strings are `CONDUCT_UNDERTAKINGS` in `src/data/site.ts` and the three pages render them, so **a later softening shows up as a diff on one constant** — his instruction that softening is a change to a published commitment, made mechanical rather than left as a note. (a)(b)(c) ship on `/med-arb/` in a new §What I undertake; (d) on `/mediation/` §Confidentiality; (e) and (f) on `/arbitration/`, each **replacing** the third-person sentence that already stated the same proposition as an observation. ⚠️ **The stamp reads 2026-08-29, not the 2026-08-27 he named** — the drafts did not exist on 2026-08-27 (Q54 was raised 2026-08-28), so that date would predate the text. **RATIFIED 2026-08-30: 2026-08-29 stands.** Pouya: *"You're right that a commitment cannot predate its own text. My 2026-08-27 was the date I ruled, not the date the undertakings existed."* | — | +| ~~Q54~~ | **ANSWERED 2026-08-29 — ALL SIX PUBLISH, INCLUDING (c), AND THE ANSWER CREATED A THIRD CLASS IN §4.** Pouya: *"Publish all six, including (c). (c) ships as drafted."* His reasoning on the expensive one, recorded on his instruction: *"it is the strongest available answer to the med-arb objection, and cheaper in practice than it sounds — the arbitral phase runs on the evidentiary record, not the caucus, so the case where a neutral genuinely cannot decide without confidential material is uncommon. `/med-arb/` was raising the hardest question about med-arb and answering it only at the level of process design."* **§4 now carries `Conduct undertakings` as a class distinct from both credentials and offerings** — his ruling: *"They are not facts about experience; they are commitments Pouya has now made... and they bind."* The gate for the class is one line: he must have said it in terms. The six strings are `CONDUCT_UNDERTAKINGS` in `src/data/site.ts` and the three pages render them, so **a later softening shows up as a diff on one constant** *(the constant holds **seven** since 2026-09-03 — undertaking (g), attested separately and not part of Q54's six; the mechanism is unchanged)* — his instruction that softening is a change to a published commitment, made mechanical rather than left as a note. (a)(b)(c) ship on `/med-arb/` in a new §What I undertake; (d) on `/mediation/` §Confidentiality; (e) and (f) on `/arbitration/`, each **replacing** the third-person sentence that already stated the same proposition as an observation. ⚠️ **The stamp reads 2026-08-29, not the 2026-08-27 he named** — the drafts did not exist on 2026-08-27 (Q54 was raised 2026-08-28), so that date would predate the text. **RATIFIED 2026-08-30: 2026-08-29 stands.** Pouya: *"You're right that a commitment cannot predate its own text. My 2026-08-27 was the date I ruled, not the date the undertakings existed."* | — | | **Q38** | **A true vector master for the infinity mark.** The mark is a shaded ribbon — variable-width band, maroon flowing into champagne, twisting in three dimensions and passing over itself at the crossing. That is gradient-mesh artwork, and there is no honest way to express it as the flat vector paths `docs/02` assumes. An SVG **is** held — `src/assets/brand/sml-logo-source.svg` — and **it renders faithfully**: rasterised at 8333 px it reproduces the master exactly, at the same 1.566:1 `[verified 2026-08-26 — rendered and measured]`. *The characterisation has now moved twice and Pouya has settled it.* This row first called the file **"a raster in a vector wrapper"**; a later draft withdrew that as unfair. **The withdrawal went too far, and Pouya's ruling of 2026-08-27 restores the substance:** *"It renders faithfully because it IS the raster. Your first characterisation was accurate and the walk-back went too far."* Both things are true at once and the row must hold both — the file is a faithful rendering **and** it is faithful because seven base64 PNGs are carried inside it, which is why fidelity was never the question. **The Canva SVG does not close this question.** Pouya: *"Keep it committed, keep the AVIF render path. R13 stays open for a true vector master."* What rules it out is **payload and composition**: 257,278 bytes against **3,063 B** for the AVIF a Retina browser takes in the header — **84×** — plus **seven embedded base64 PNGs** and a 1,225-stop gradient mesh, so inlining it would breach `CLAUDE.md`'s rule against base64-inlining images. *Restated 2026-08-27, because the single number stopped covering the case:* build step 2 added a **232 px** call site (the home page's approach section, rendering at 225.5 px) beside the existing 64 px one, and at that size a DPR-2 device takes **14,555 B** and DPR-3 **22,639 B** — a ratio of ~11×, not 84×. `adversarial-reviewer` caught the three copies of the old figure going stale together, which is the SES-DKIM duplication in a new place. Both ladders are in `docs/reference/brand-assets.md`; **quote the size with the call site, never on its own.** Also worth knowing before someone reads it as a regression: the PNG fallback at 232 px runs **38,094 / 91,618 / 150,215 B**. Nothing that negotiates content types takes it — a DPR-3 device with neither AVIF nor WebP does not exist in practice — and the AVIF above is what real clients fetch. Accepted deliberately rather than capped, because capping it would blunt the mark on the high-DPI desktops that are the only place the 696 px variant is used at all. What is wanted is a master that is faithful **and** light. **Pouya is commissioning a proper vector master.** Until it lands, `InfinityMark.astro` renders an optimised raster and that is a **documented, temporary exception to `docs/02`'s inline-SVG rule** (R13 keeps it from becoming permanent). When the master arrives: replace the component's `<Picture>` with inline SVG, regenerate the favicons from it, and delete the exception from `docs/02` and this row. Raised by Claude Code 2026-08-26; scoped by Pouya the same day | Nothing — the mark renders correctly. This is fidelity and payload, not function | | ~~Q32~~ | **ANSWERED 2026-08-26 — and the answer was that the reconstruction was WRONG.** Pouya rendered the committed path against the master (`SML Company Just Logo Transparent.png`, 3000×3000) and rejected it on three counts. Two reproduce from the path alone: **(a) TANGENT, NOT CROSSING** — all four cubic branches meet the origin at exactly 90°, so the loops are mutually tangent on a vertical line and at stroke-width 28 render as *two kissing circles*, the one thing an infinity mark must not be `[verified 2026-08-26 — tangent vectors computed per segment, independently reproduced]`. Both lobes are strictly confined to their own half-plane (x is monotone on every segment), so the signed crossing number is **0** — they cannot cross anywhere, not merely at the node. Worse at the size actually shipped: the two strokes stay fused into one mass wherever the centreline separation `y²/192` is under 28, i.e. across **61% of the mark's height** — at 2rem that is a 16.8px blob out of a 27.4px-tall mark. No stroke-width, viewBox or scale change can produce a crossing from this centreline; **(b) WRONG PROPORTION** — the master's ink bounding box is **2668 × 1704 = 1.5657:1** `[verified 2026-08-26 — measured]`, and **(c)** the master is a shaded ribbon where the trace was two flat uniform strokes. ***1.23:1 — RECONCILED, and it was a real measurement, not a slip.*** It is the bounding box of the path's **coordinates** rather than of the **curve**. The control points sit at y = ±160 while the curve only reaches ±120 — the standard 3/4 pull-in of a cubic — so a coordinate-hull box is 400 × 320 = 1.2500, and with stroke-width 28 applied on all four sides it is **428 × 348 = 1.2299**. Pouya's figure to four significant figures, from one method applied consistently `[verified 2026-08-26 — derived]`. **It is a trap rather than a slip:** because x is monotone on every segment, the control points give the *correct* width (±200) and a 33% inflated height, so the obvious sanity check — "does the width look right?" — passes. Any tool that reads a bbox from parsed coordinates lands here; `getBBox()` would have returned 400 × 240. **And the direction is inverted from how it read:** measured from the curve, the traced path is 6.4% *wider and flatter* than the master, not squatter. **Consequence worth keeping:** the declared viewBox 440:280 = 1.5714 is within **0.35%** of the master's 1.566, so re-tuning the layout to the true ratio was ~0.18px of work at the shipped 2rem — and **re-tuning to 1.23 would have actively broken it** — at any given width 1.2299 allocates 1.5657/1.2299 = **27% more height** than the mark occupies, so the header would have been rebuilt around a box a quarter too tall. The ratio was never sufficient grounds on its own; **(a) tangency is, and it is exact.** **The traced path is deleted, not kept as a fallback**, on Pouya's instruction: *a wrong mark that renders is worse than a missing one, because it stops looking wrong.* Now closed by Q38 for the vector master | — | | ~~Q32-orig~~ | *(superseded — the original wording of this question is preserved in entry (v))* \| `src/components/InfinityMark.astro` is built from geometry lifted verbatim from the deployed site's own loading-thumbnail SVG — the element it labels `smlMark`, fetched from `https://adr.smlcompany.ca/` on 2026-08-26. Path, both stroke widths (28 / 6) and the 0.7 inner opacity are the source's; the only change is folding a `translate(60 0)` into the coordinates. So it is SML's own artwork rather than a redrawing — but a loading placeholder is not necessarily the canonical file, and D7 says the mark carries over *unchanged*. If a master SVG or AI/EPS exists, supply it and the component gets replaced. Raised by Claude Code, 2026-08-26 | Nothing — the mark renders. This is about fidelity, not a blocker | @@ -927,7 +934,7 @@ never being raised again. | R2 | **Matter counts stay off the site until they are independently credible.** Revisit once there is a number a sophisticated GC would find persuasive on its own | 2026-08-26 | §4 forbids them now. That rule has an expiry date nobody has set | | R3 | **The month 12–18 practice-area review — now two candidates, not one.** *(a)* **Indigenous engagement**, deliberately omitted at launch (§6). *(b)* **Tax-adjacent disputes**, added 2026-08-26 on the Canadian Tax Foundation membership: it is the one credential none of the six areas touch, and valuation, earn-out, indemnity and shareholder disputes that turn on a tax position are ADR territory. Reasoning for both is in `docs/01-architecture.md`. One review, two candidates | 2026-08-26 | The brief rates the Indigenous niche the most valuable single one, and omission there is a timing call rather than a permanent one. The tax case is the same shape and fails for the same reason today: a practice page is a claim of present capability, and a membership is a credential, not a caseload. Both stop being true at some point, and nothing will tell you when | | R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding | -| R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive. ⚠️ **ONE ITEM IS ALREADY WAITING AND IT IS ARITHMETIC RATHER THAN JUDGEMENT — added 2026-08-31:** the half-day-plus-overtime route is cheaper than the full-day rate at **every** session length, by $2,000 at three hours narrowing to $500 from six on, because the half-to-full step is $2,000 and three hours of overtime is $1,500. Written out in `docs/07` §Recorded dissent with the table, which is the section built for this review to test against. The **trigger** for the overtime hour is a separate open question — §9 Q59 | 2026-08-26 | D14 is priced for where the practice is going, not where it is. And the anomaly above was assigned to this reminder twice in one change set and written into neither place the reminder lives, which is the failure §12 exists to prevent | +| R5 | **Fee review at 12 months.** Published rates are sticky; the right moment to move them is deliberate, not reactive. ⚠️ **ONE ITEM IS ALREADY WAITING AND IT IS ARITHMETIC RATHER THAN JUDGEMENT — added 2026-08-31:** the half-day-plus-overtime route is cheaper than the full-day rate at **every** session length, by $2,000 at three hours narrowing to $500 from six on, because the half-to-full step is $2,000 and three hours of overtime is $1,500. Written out in `docs/07` §Recorded dissent with the table, which is the section built for this review to test against. The **trigger** for the overtime hour is a separate open question — §9 Q59. ⚠️ **A SECOND ITEM IS WAITING AS OF 2026-09-03, AND IT IS THE KIND THAT GOES STALE SILENTLY:** med-arb is now priced **by phase** — each phase at the rates for that process — and it therefore carries **no figure of its own**. `docs/07` §Med-arb and `FEES.medArb` both stamp it INTERIM against this review. **It is derived, so nothing about it changes when the rates change**, which is precisely why it needs a reminder: move a mediation or arbitration number at R5 and the med-arb price moves with it, unannounced, with no diff on the med-arb rule | 2026-08-26 | D14 is priced for where the practice is going, not where it is. And the anomaly above was assigned to this reminder twice in one change set and written into neither place the reminder lives, which is the failure §12 exists to prevent | | R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer | | ~~R9~~ | ✅ **CLOSED 2026-09-01. The `ses-alerts` email subscription is CONFIRMED** — `aws sns list-subscriptions-by-topic` returns a real subscription ARN rather than the literal `PendingConfirmation` `[verified 2026-09-01]`, so the two bounce/complaint alarms reach `info@smlcompany.ca`. ⚠️ **It had been confirmed for some unknown part of six days while §7, `docs/05`, `docs/06` and this row all said the alarms fired into nothing.** That is the Q22 staleness in the **safe** direction, and the direction is why it lasted: nothing was broken, so nothing prompted anyone to re-read it. The generalisable half, and it is worth more than the row: **a record whose staleness is harmless is the record that stays stale longest**, because every other kind announces itself by breaking something. Re-read the harmless ones on a schedule or they are never re-read at all. *Previous text follows.* **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for | | R10 | ✅ **FIRED AND SATISFIED 2026-09-02 — THE CUTOVER EVENT AND THE CONFIRMATION, IN THAT ORDER. THE ROW STAYS LIVE.** Asked of Pouya on 2026-09-02 as a one-line question — *are ADRIC, ADRIO, the three OBA sections and the CTF all still current?* — and **answered the same day: all current.** It was asked rather than looked up, which is the entire content of this reminder: **a stamp is not a renewal receipt, and re-reading an old one is not re-confirming.** It was open for part of the day, alongside R18 which fired and was satisfied the same day. **Re-stamped on all three surfaces, because `memberOf` is emitted and the constant feeds both:** §4's memberships row, `CREDENTIALS.memberships` in `src/data/site.ts` and the R10 note in `src/data/schema.ts`. ⚠️ **AND THERE ARE TWO ARRAYS, NOT ONE — re-stamping is not the same act as checking they still agree.** `CREDENTIALS.memberships` feeds `/about/`'s visible list and `/bio/`; **`MEMBERSHIP_ORGS` feeds `/process/` §Confidentiality and the `memberOf` triples**, and `site.ts` records that the two differ on three of four lines. `_MembershipParity` compares their **`['length']` only**, so a substitution passes `npm run check` in silence. An earlier form of this row said one constant fed both surfaces, which would have left `/process/` and the JSON-LD publishing a lapsed membership after a correct-looking edit — the OCNI failure with a green build (`adversarial-reviewer`, round 1). `docs/06`'s item is ticked and **re-armed for the next republish** — the trigger is an event and events recur, which is why this row does not close on being satisfied. Original text follows. ⚠️ **A THIRD SURFACE, 2026-08-30: `/process/` §Confidentiality renders `MEMBERSHIP_ORGS[0]` ("I am a member of the ADR Institute of Canada").** It is rendered from the constant rather than typed, so the sweep this reminder prescribes reaches it — that was `adversarial-reviewer`'s finding and the fix, in that order. **DISCHARGED AS WRITTEN 2026-08-28 — AND RE-ARMED WITH AN EVENT TRIGGER INSTEAD OF A DATE. STILL LIVE.** Pouya re-confirmed all four memberships as current (Q44), which discharges the prohibition this row carried, and `/about/` now publishes the Memberships group. **The row does not close, because he declined renewal-date tracking**, and that was his instruction for what to do about it: *"Without renewal months it cannot fire on a date, so make it fire on an event: re-confirm memberships before any cutover or major republish, and re-stamp §4 when confirmed."* **THE TRIGGER: re-confirm before any cutover, and before any major republish. Then re-stamp §4 the same day.** **His reason, kept verbatim because it is the general principle and not a membership detail:** *"§4 already carries OCNI as lapsed and unpublishable, and that was found roughly a year late. A stamp with no trigger behind it goes stale silently, which is exactly how OCNI got onto a list of things to feature."* **Two things the discharge did NOT license.** (1) **No currency warranty on the page** — list the memberships, promise nothing about their future state; the struck sentence stays struck and nothing replaces it. (2) ~~`memberOf` stays out of the JSON-LD~~ — **SUPERSEDED. Q53, ruled 2026-08-28: EMIT IT.** `/about/`'s Person node now carries the four memberships as `Organization` nodes. Pouya took `adversarial-reviewer`'s argument: they are already crawlable in `/about/`'s HTML, so withholding the triple reduced no exposure and only made the graph less complete than the page. **The consequence for THIS reminder is that it now covers two surfaces** — re-confirming before a cutover means `src/data/schema.ts` as well as the visible list, and they must not be allowed to diverge. **Renewal periods, stated once and not widened again:** the OBA sections and the CTF renew yearly; §4 records **nothing** about ADRIC's or ADRIO's period, and the widened form ("all four renew yearly") reached four files before it was swept. *Previous text described the prohibition and the withheld group; it held for one session and did its job.* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this. The group is on a public page now, which raises the cost of a lapse rather than lowering it — *(This rationale ended by pointing at **Q48** as a possible widening of the row. Q48 closed 2026-08-28 as not site-relevant — ADRIO retention governs whether Pouya keeps a designation, not what the site may say about holding one — so the clause is struck. §12 is read aloud every session; a live reminder pointing at a struck row produces a false surface every time.)*, not just a list | @@ -949,6 +956,464 @@ never being raised again. # Change Log +## 2026-09-04 (at) — D20 finding 10 is PRICED rather than narrowed and finding 13 is ATTESTED, closing the pass at 17/2/1; the first real spam gets a second honeypot and a scorer that labels; the timing floor Pouya ruled on turns out never to have existed; and §7 told an operator the intake backend was undeployed for two days after it went live + +**Pouya's rulings of 2026-09-03 (the two remaining D20 findings) and 2026-09-04 +(the spam observation and four mitigations), applied in one change set.** + +--- + +### 1. Finding 10 — med-arb is priced BY PHASE, and the promise was not narrowed + +His ruling: *"Med-Arb is billed BY PHASE — the mediation phase at the published +mediation rates, and if the matter proceeds to arbitration, that phase at the +published arbitration rates; additional-party and cancellation terms apply as for +each phase… state plainly that no separate med-arb fee exists."* + +`/fees/` opened *"Every figure is on this page"* while §4 Offerings carried a +**Med-Arb** row that `docs/07` priced nowhere. **He took the more expensive of +the two fixes.** The offering is now priced out of the two rate sets already +published, so the sentence is true as written and was not touched. + +- `FEES.medArb` — three sentences, **no `amount`**, and the comment says why one + must never be added: a med-arb figure would be a fourth price for a process + priced twice, and the first thing it would do is disagree with one of them. +- `/fees/` gains **§4 Med-arb**, between arbitration and Also offered. It renders + the three strings and carries **no rate row** — the only section on the page + without one, deliberately. +- `docs/07` §Med-arb — the rule, **INTERIM, set 2026-09-03, reviewed at R5**. +- **§12 R5 gains its second waiting item**, and the reason is that this one goes + stale *silently*: the price is DERIVED, so moving a mediation or arbitration + number at R5 moves med-arb with it, with no diff on the med-arb rule. +- `termsApply` says *"as they apply to that process on its own"* and **not** "to + both phases": the additional-party fee is a **mediation** row and arbitration + has no equivalent, so the stronger wording would have invented a charge. + +⚠️ **THE SECTION ORDER BROKE THE PAGE'S ALTERNATING BACKGROUNDS AND THAT WAS +FIXED BY MEASUREMENT, NOT BY READING.** Inserting a plain `section` after +arbitration's plain `section` put two untinted bands together; the classes on +three sections were re-flipped and the sequence re-read out of the file. + +### 2. Finding 13 — he said it, and the page had been saying more than he said + +His ruling: register the attestation, *"the page sentence may say no more than +that attestation — a check before engaging — verify the wording matches and does +not strengthen it."* **Verified, and it did strengthen it.** + +- §4 gains **conduct undertaking (g)**, `[attested 2026-09-03 — Pouya]`. + `CONDUCT_UNDERTAKINGS` holds **seven**; (g) is not one of Q54's six and carries + its own stamp. +- 🛑 **THE SENTENCE THAT RAISED THE FINDING IS NOT THE SENTENCE THE ATTESTATION + COVERS.** Finding 13 quoted *"if a conflicts check has already been run I will + tell you what its outcome was"* — a promise to **disclose the outcome**. The + attestation covers **running** the check and says nothing about reporting it. + The clause is **struck**; the page now reads *"it does not undo a conflicts + check that has already been run"*. +- It ships on **two** surfaces through `<Undertaking>` — `/legal/privacy/` + §Information about other people **and `/contact/`** — each **replacing** a + hand-typed *"I cannot accept an appointment before conflicts are checked"*. + The (e)/(f) treatment: replace rather than join, so an undertaking never sits + beside its own paraphrase. ⚠️ **`/contact/` was missed for a full round** — the + identical sentence, one file over, surviving the change set that struck it. +- ⚠️ **IT SHIPPED FOR ONE PASS AS ORDINARY PROSE IN `“`/`”`** — the + only such entities in `src/` — and was moved to the component on re-reading the + built page. A commitment set as body prose reads as another sentence about + process, which is the entire reason `Undertaking.astro` exists. +- ⚠️ **WHAT SHIPS IS HIS WORDING, VERBATIM — *"before engaging"*.** It shipped + for one round as *"before I accept an appointment"*: the site's own vocabulary, + defensible, and still a rewording of a commitment the page publishes as his, + recorded only in a code comment. **Two review lenses refuted the framing and + neither refutes the fix** — on `/legal/terms/`'s own sequence (conflicts check, + then terms agreed, then engagement) the paraphrase was if anything *earlier*, + so it was not a softening. It was a **substitution**, and this class's gate is + that he said it in terms. If "engaging" is the wrong verb, the fix is a second + attestation, never an edit to the string. +- **It does not reverse Q57**, which refused an undertaking about what happens + *when* a check turns something up. That one is still refused; §9 Q57 is + corrected in place because it asserted a count that is now wrong. + +**D20 now partitions as 17 fixed / 2 refuted / 1 owed.** The one is finding 11, +which is ruled and waiting on Q60's observation window rather than on any +sentence. `docs/06`'s `claims-auditor` item **stays unticked**, and the reason is +recorded on it: what is outstanding is a confirmation that a record was seen to +vanish, not a claim anyone disputes. + +--- + +### 3. The first real spam, and the three mitigations that could be built + +**Recorded in `docs/05` §Observed abuse**, with the date, the two `submissionId` +prefixes and the signature Pouya gave. Two submissions, **10:51Z and 12:16Z**, +both past the honeypot. The route throttle — 1 req/s, burst 5 — was never going +to see two submissions ninety minutes apart. + +⚠️ **THE HONEYPOT WAS NOT DEFEATED. IT WAS NOT ENGAGED.** A bot that submits only +the fields it recognises never touches a decoy text input, so a second field of +the same kind would have caught them exactly as well as the first did. + +**(2) A second honeypot — a different trap, not a second copy.** A hidden +**checkbox** that must arrive unticked, aimed at a behaviour the traffic must +have: the consent box is required and unchecked by default, so anything reaching +validation is ticking checkboxes. + +⚠️ **THE TEST WAS TIGHTENED FROM `!== undefined` TO A NON-EMPTY VALUE BEFORE IT +SHIPPED**, and the reasoning is the asymmetry rather than the likelihood. A form +serialiser that emits `updates_optin=` for a hidden checkbox without reading its +checked state is rare and is not impossible — and the cost of being wrong is a +real legal inquiry discarded in silence, which is the worst outcome this form +has. The mechanism distinction Pouya asked for survives it: it is *filling text +fields* versus *ticking boxes*, not `undefined` versus `''`. + +**(3) Scoring that labels and never rejects.** `backend/intake/spam-score.mjs`, +**39 of 39** cases in `spam-score.test.mjs`, all four signals exercised. Signals and weights — 1 for what a +real inquirer plausibly trips, 2 for what they rarely do: + +| signal | weight | +|---|---| +| summary under `SHORT_SUMMARY_CHARS` (**100**) | 1 | +| phone present and not a NANP number | 1 | +| a link (`https?://` or `www.`) in the summary | 2 | +| a Gmail address with three or more dots | **1** | + +**Threshold 2** — the one strong signal, or two weak ones. ⚠️ **The Gmail weight +is 1 and the floor is 100 because both started higher and both labelled real +people; §6 has the measurements.** 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 inquirer's confirmation is untouched**; a person wrongly scored must not be +told a machine thought they were a bot. + +⚠️ **A DOT IS NOT THE GMAIL SIGNAL, AND NEITHER IS A NAME SHAPE.** +`first.last@gmail.com` is the most ordinary form a Gmail address takes; so are +`j.k.smith@` and `mary.jane.o.brien@`. The rule is **three or more dots at weight +1**, so it cannot label anyone on the shape of their own name alone. Every one of +those addresses is a negative fixture in the test. + +⚠️ **NOTHING IS STORED.** The score and signals do not enter the DynamoDB item, +because §Storage's attribute list is published field by field on +`/legal/privacy/` and adding one would make that page wrong. + +⚠️ **AND THE SCORER IS WRAPPED IN A `try`.** An exception would escape `handler`, +API Gateway would answer 500, and the inquirer would see a failure for a record +**already written** — a path that costs an inquiry, decided by a labelling +function. Unlabelled is the safe default. + +**`SHORT_SUMMARY_CHARS = 140` is `[assumed]`, and it says so.** There is no corpus +— no genuine inquiry has arrived through this form — so it is derived from the +form's own hint (*"A few sentences is enough"*) and set below three short +sentences. **Pouya holds the only real data**: the two spam records are still in +the table and their `summary` lengths would let this be set from measurement. +§9 Q65 carries that. + +**(4) `CloudFront-Viewer-Address` on `/api/*`.** `configure.mjs` **section 5** — +a custom origin request policy `adr-sml-api-viewer-address`, and the `/api/*` +behaviour re-pointed at it. **Written, dry-run against the live distribution, +NOT applied.** + +🛑 **IT IS A WHITELIST, AND THAT WAS FORCED RATHER THAN CHOSEN.** Derived from +the CloudFront API's own enum, read out of the installed CLI's service model +rather than recalled: `allExcept` can only SUBTRACT, so it cannot add a +CloudFront-generated header; `allViewerAndWhitelistCloudFront` can, and forwards +`Host`, which 403s at API Gateway. `whitelist` is the only shape left. **The cost +is that the five listed headers are now load-bearing** — the handler's four +`headerOf` reads plus the new one — and a missing one does not error: every +submission would validate short and land on `/contact/could-not-send/`, which +reads as the inquirer's own browser misbehaving. + +⚠️ **AND `docs/09` §7.1 CANNOT DETECT THAT, WHICH IS WHY PART 3 NOW CARRIES A +SECOND PROBE.** `303 → could-not-send` is what the handler returns **both** when +it parsed the body and found it empty **and** when `parseBody` threw because +`Content-Type` never arrived. One status, one location, two opposite outcomes — +so a dropped `Content-Type` reads as a pass. The added probe posts +`company_website=probe` and expects **`303 → /contact/received/`**: that is the +honeypot branch, reached only if the body parsed, returning before validation, +before any write and before any send. **Verified against the live handler before +being written into the runbook** — it leaves no record and sends no email. + +**The rollback is one field** and `configure.mjs` prints it, prefixed `↩`, at the +moment it makes the change. + +⚠️ **THE HANDLER STILL STORES THE EDGE ADDRESS.** Forwarding a header is +infrastructure; **storing** one is a `/legal/privacy/` change governed by +`docs/09` §7.2's decision table. Pouya's ruling is *measured, not yet acted on*, +and `viewerIp()`'s docblock now says so where the next reader will be tempted. + +### (1) The timing floor — RULED, AND IT CANNOT BE BUILT WHERE THE RULING PUTS IT. §9 Q66 + +His item 1: *"raise the timing floor to a value a human cannot beat filling 12 +fields but a patient bot might"*, on the stated basis that the two submissions +*"passed the honeypot **and timing checks**"*. + +🛑 **THERE IS NO TIMING CHECK. THERE NEVER HAS BEEN.** `docs/05` §Validation +carries it **struck**, `handler.mjs`'s header says so, and `docs/05`'s definition +of done says so. So there was no floor to raise, and the spam did not defeat a +control — it walked past a gap that was already recorded as a gap. + +**The reason is unchanged by the spam arriving.** `/contact/` is a CDN-cached +static file: there is no per-visitor *served-at* value to subtract from, and a +build-time one is identical for every caller, so the check would pass for a bot +exactly as it passes for a human. **That fourth option is worse than doing +nothing** — the control that exists on paper and not in fact, which is what Q22 +is a record of. + +**Every mechanism that WOULD produce a real per-visitor clock breaks one of his +own constraints**, and the table is in `docs/05` §Observed abuse: client script +breaks zero-JS; a CloudFront viewer-response function setting a signed cookie is +outside *"handler + form only"* **and puts a cookie on a site whose privacy +policy turns on there being none**; a dynamic origin reverses D1. + +⚠️ **AND THE SECOND HALF OF HIS INSTRUCTION IS ALSO UNMET.** He asked to *"measure +a real fill first (Pouya's own test)"* and no measurement was supplied — so even a +buildable floor would have had no number. Two independent blockers, either one +sufficient. **Recommendation on Q66: (a) accept that there is no timing signal +and let the scoring carry it** — it is already shipped and costs nothing further. + +### §9 gains Q65 (WAF, deferred cost call) and Q66 (the timing floor) + +Both on his instruction for the first; the second because a ruling that cannot be +executed must become a question rather than a silence. + +--- + +### 4. What reading the live account found, and it is the part nobody asked for + +The `--apply` work needed the real distribution id, so §7 was read — and then the +account was read against §7. **Three rows were telling an operator that the +intake backend is not deployed, two days after it went live.** + +| §7 row | said | measured 2026-09-04 | +|---|---|---| +| Intake API | *"One route, `POST /submissions`"*; *"The form's route (`POST /api/intake`) does not exist yet"*; **no throttling** | **one route, `POST /api/intake`**; throttled **per route** at rate 1.0, burst 5 | +| Intake Lambda | `index.handler`, 10 s, 128 MB, **no environment variables**, 1,527 bytes; *"nothing has been deployed (D11)"* | **`handler.handler`**, 15 s, 512 MB, **six** variables, **3,307,021 bytes**, modified 2026-09-02T18:59:06Z | +| — | *(no row)* | **`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**, which `docs/09` §5.5 required in §7 **in terms** the moment that path was taken | + +⚠️ **THE `[verified 2026-09-01]` STAMPS WERE HONEST AND THAT IS THE POINT.** Both +rows were true when written and false from the moment `docs/09` Parts 3, 5 and 6 +ran. A stamp records when something was checked, not that it is still true, and +**§7 is the row an operator reads before touching production.** + +**The code size is the finding underneath the finding.** 3.3 MB means the +**bundled** variant, `docs/09` §5.5 — not §5.1's plain zip. The deployed artefact +was downloaded via `get-function` `Code.Location` and read: `handler.mjs`, +`fields.mjs`, `node_modules/`, `package.json`, with **both source files +byte-identical to `HEAD`** (asserted non-empty on both sides before comparing). +So §5.5 is the path in production, and **its file list is hand-typed and follows +no import** — as is §5.1's. `spam-score.mjs` had to be added to **both**, or the +next handler deploy fails at cold start with `ERR_MODULE_NOT_FOUND` and every +submission 500s. Figures re-measured: **38,286 uncompressed / 16,609 zipped.** + +⚠️ **AND ONE ALMOST-FINDING, RECORDED BECAUSE IT WAS NEARLY PUBLISHED.** A first +query projected `DefaultRouteSettings` alone, which holds only +`DetailedMetricsEnabled`, and read as *"the aggregate throttle does not exist"* — +a documented control that was missing. It exists; it is a **`RouteSettings`** +entry. The full stage object was read before anything was written down. *A +command that cannot see the thing is not evidence of its absence*, and this one +would have produced a confident, false, published claim about a live control. + +### The stale-instruction sweep — four sites, and three were outside the diff + +Change 8 replaces `Managed-AllViewerExceptHostHeader` on `/api/*`. **Four places +told an operator that a 403 means checking that the behaviour uses that policy** — +i.e. told them to restore the very thing that was deliberately replaced: +`docs/09` §7.1, `scripts/deploy-local.sh`, `.gitea/workflows/deploy.yml`, and +`handler.mjs`'s `viewerIp()` docblock. All four now name both policies and the +rollback id. Found by sweeping the identifier across the whole repository rather +than reading the diff: + +``` +$ git grep -n 'AllViewerExceptHostHeader' -- . +``` + +Three more documents asserted the undeployed backend and are corrected in place: +`docs/05` §Build step 8, `docs/01` build-order item 8, and `docs/06`'s class-2 +paragraph (which now carries the refutation the top of that callout already had). +`AGENTS.md` §9 Q54 and Q57 both stated a count of six undertakings. + +### Two observations, and neither is a numbered question — D19 + +- **`/med-arb/` has no fee prose block** where `/mediation/` and `/arbitration/` + both do. All three link to `/fees/` from nav, footer and a CTA, so the rule is + reachable and *"every figure"* holds; the asymmetry is Pouya's call and was not + built, because his ruling named `/fees/` as the place to render it. +- **`/fees/` §Terms still carries one unpriced figure** — travel outside the GTA, + *"billed separately, or bundled at a day rate stated in the terms of + appointment"*. It predates this change and was not part of finding 10. + +--- + +### 5. The review — round 1: 56 findings across five lenses, 7 blocking, and the refutation stage earned its place + +`adversarial-reviewer` only. **`claims-auditor` did not run** — D20 puts it at +cutover, once, and it has already run there. Five lenses in parallel over one +diff (backend, infrastructure, pages, register, scope), then **one independent +refuter per blocking finding, instructed to refute**. + +**Four of the seven blocking findings were REFUTED, and refuting them was worth +more than fixing them would have been:** + +- **"The rollback is self-reversing."** The convergence is real — a post-rollback + `--apply` does re-attach the whitelist — but the refuter showed the script + prints the rollback line the finding said it did not, that `docs/09`'s rollback + paragraph names `update-distribution` in the sentence under its own code block, + and that Part 9.2 agrees rather than contradicts. **The residual was one word**: + "re-apply", which names this script. Fixed, in both places, with the reason. +- **"Both probes sit above the `--apply` block."** Refuted: Part 7.1 follows Part + 3 and does send a POST. +- **Undertaking (g) publishes a paraphrase** — refuted twice, and the second + refutation is the interesting one. `/legal/terms/` fixes the sequence *conflicts + check → terms agreed → engagement*, so *"before I accept an appointment"* is if + anything **earlier** than *"before engaging"*: it was not a softening. + **The fix stands anyway**, because the objection that survives is not + *softening* but *substitution* — §4's gate for this class is that he said it in + terms, and a rewording recorded in a code comment is not that. + +**Three were confirmed and all three were about the record rather than the code:** +`docs/05`'s definition of done ticked two controls that are **written and not +deployed** — the refuter proved it by digesting the working tree against `HEAD` — +while leaving unticked three that were done at cutover; and `docs/06`'s checklist +still opened with *"THE INTAKE FORM DOES NOT WORK YET"*. + +**What the should-fix set caught in my own work, and these are the ones worth +recording:** + +| what | why it mattered | +|---|---| +| `singles >= 2` in the Gmail rule fired on **`j.k.smith@gmail.com`** — two initials and a surname — at weight 2, i.e. the threshold alone | A rule that labels a real inquirer on the shape of their own name. The limb is deleted; the rule is dot count and nothing else | +| `416-555-0123 ext 22` scored **foreign** | Twelve digits. An ordinary Toronto direct line, labelled on a signal that says the opposite of the truth | +| The 140-character floor fired on *"Shareholder dispute, two directors, Ontario CBCA company."* | 57 characters, and **exactly what the form's own hint asks for**. Lowered to 100 | +| The test asserted `score` only, never `signals` | Two rules could swap weights with every case still green. It now asserts the signal set, derives score from weights, and **fails if any rule has no positive case** | +| The two honeypots were **adjacent siblings carrying the same class** | One selector defeats both — and the comment beside them claimed the opposite about the shipped artefact | +| Both honeypots were hidden **by author CSS alone** | With styles unavailable, a checkbox labelled *"Send me occasional updates"* sits above the consent box and ticking it discards a real inquiry behind a success page. `hidden` added to both, and the label now tells a human not to tick it | +| The decoy's label offered a mailing list **`/legal/privacy/` denies in terms** | *"There is no newsletter"* | +| `FEES.medArb` was inserted **between the Q42 comment and `hourly`** | *"Do not price settlement counsel"* became documentation of the med-arb block, and `FEES.hourly` lost its comment | +| `/contact/` still hand-typed the conflicts undertaking | One file swept, its sibling missed — R8's shape, in the change set that struck the identical sentence one file over | +| `grep -n "headerOf(event"` returns **5**, not the 4 three documents told the operator to expect | It matches its own function definition. Replaced by a check that prints the header **names**, so they can be compared to the whitelist instead of counted | +| Neither honeypot logged anything | The only two paths that discard a submission and answer with the success page were unobservable. Both now log the field name and nothing else | +| The privacy sentence read *"the reason is a commitment rather than an observation"* | The repository's internal §4 taxonomy, on a public legal page | + +🛑 **AND ONE REPAIR WAS ITSELF DEFECTIVE, CAUGHT BY RUNNING IT RATHER THAN +READING IT.** The fix for the duplicated Lambda file list was the reviewer's own +suggestion — derive it with a glob instead of typing it twice — and the first +version read `MODULES=$(ls …)` then `zip … $MODULES`. **In zsh that packages one +file whose name is all three joined by newlines**, because zsh does not +word-split parameter expansions. It works in bash. `CLAUDE.md` names this trap by +name and it was reintroduced anyway; it was caught by executing the block in both +shells, and the runbook now substitutes directly. **The check that found it is in +the entry because the reading that missed it was mine.** + +**Declined, with the reason:** the scope lens found *"one line of code under +sixteen lines of comment"* (D19). Partly acted on — the decoy's rationale is now +stated once, in `src/data/intake.ts`, and pointed at from three places instead of +restated. The rest is declined: the comments that remain are load-bearing +constraints of the kind `CLAUDE.md` names as staying — why the decoy tests +non-empty, why absence is the pass, why the trap is probably inert. The test is +whether a future reader needs it to avoid breaking something. + +### 6. Round 2: 36 findings, and **33 of them were defects in round 1's own repairs** + +That ratio is the reason D19 mandates a second round and caps it at two. Four +blocking, all three of the code ones introduced by a repair: + +- 🛑 **The mandatory post-`--apply` probes could not run.** The `Referer` probe + and the header-whitelist check were written with **`\\` line continuations** + instead of `\`, so run verbatim they exit on a syntax error after printing + plausible output — the verification block for the one change that can break a + live intake form, unable to verify anything. Fixed and **executed against + production**: the `Referer` fallback returns **303** today. +- 🛑 **§4's register quoted the struck paraphrase while the site shipped the + other string.** The wording fix changed `CONDUCT_UNDERTAKINGS` and not the + register row, so for one round the register authorised one sentence and + `/legal/privacy/` published another. There is now an assertion in the report + that reads both and compares them. +- 🛑 **A note claiming a correction, twenty lines above the sentence it had not + corrected.** `INTAKE_ACTION`'s docblock still ended *"the pipe behind it is + not"*; the repair added a paragraph above saying it had been fixed. **A note + asserting a correction is not the correction.** + +**And the scorer had to be corrected twice more, both times in the same +direction — it was labelling real people:** + +| | | +|---|---| +| Round 1 | `j.k.smith@gmail.com` — two initials and a surname — labelled at weight 2, the threshold alone | +| Round 2 | `dots >= 3` at weight 2 still labelled **`mary.jane.o.brien@gmail.com`** and **`maria.de.la.cruz@gmail.com`** — compound surnames and middle initials are ordinary, not rare | +| The fix | **weight 1, not another boundary.** This module defines weight 2 as what a real inquirer *rarely* trips, and a four-part name is not that. Nothing can now be labelled on the shape of its owner's name alone | +| Also | `416-555-0123 Ext: 4501`, `extension 22`, `ext-22` and a field holding **two** numbers all scored *foreign*. The country code is now read **first** — `+7 912 345 6789` is grouped 3-3-4 exactly like a NANP number, so no shape test could tell them apart | + +**The test suite was the other half of it.** Round 1's rewrite asserted signal +names, and round 2 found the one hole: **deleting the `gmail.com` domain guard +entirely left all 30 cases passing**, because no fixture carried three dots at a +firm domain. It now has one, plus a ten-digit international number that is the +only case pinning the country-code branch. **39 of 39, and six of six mutations +killed** — including two that survived the previous suite. + +⚠️ **AND THE ONE DUPLICATED FACT LEFT IN THE CHANGE SET IS NOW A MECHANISM.** +`ORP_HEADERS` in `configure.mjs` is a second copy of the handler's own header +reads, and the only thing keeping them in step was a comment plus a grep an +operator was asked to run by eye — **on the change that can lose every +submission**. Section 5 now reads `backend/intake/handler.mjs`, extracts every +`headerOf(event, '…')`, and **refuses to run** if the whitelist omits one. +Probed: adding a fifth read makes it exit 1 naming the header. A missing +`backend/` is a skip, not a throw, so the script stays runnable from a checkout +without it. + +**Declined, with the reason.** The scope lens measured `spam-score.mjs` at 3.4:1 +comment-to-code and called it the D19 shape. **Partly acted on** — every +round-by-round narration is cut, which is Change Log material, and the file is +now 3.0:1. The rest stands: this is a heuristic whose failure mode is *silently +labelling a real legal inquiry*, it has been wrong twice in that direction, and +the reasoning that keeps the next reader from raising the Gmail weight back to 2 +is exactly the load-bearing kind `CLAUDE.md` says to keep. + +**Stopped at two rounds, per D19,** and the reason is the measurement above +rather than fatigue: 33 of 36 findings were defects manufactured by the previous +round's repairs, so a third round would both find and manufacture at a rate that +no longer favours the marginal finding. + +### The gates, each exit status read + +| | | +|---|---| +| `npm run check` | **0** — 0 errors, 0 warnings, 0 hints | +| `npm run build` | **0** — 23 pages | +| `npm run check:claims` | **0** — every pattern ran and every pattern still fires on its fixture | +| `npm run check:intake` | **0** — 12/12 fields, **2 honeypots** compared on name and checked out of both tables | +| `npm run og:proof` | **0** | +| `npm run lint` | **0** | +| `spam-score.test.mjs` | **0** — **39 of 39**, all four signals exercised, **6 of 6 mutations killed** | +| `router.test.mjs` | **0** — 30 of 30 | +| minifier grep | **exit 1** — clean | +| `configure.mjs` dry run | **0** against the live distribution, **4 changes, nothing written** | +| `npm run lighthouse` | **0** — 23 pages, **no category below 95**, worst 99/100/100/100, CLS 0.000. The one LCP note is `/`, the headshot Pouya deferred | + +⚠️ **AND ONE INSTRUMENT FAILURE OF MY OWN, RECORDED BECAUSE IT IS A NEW SHAPE OF +AN OLD RULE.** The first Lighthouse run was wrapped as +`npm run lighthouse > file 2>&1; echo "exit=$?"`, and **the harness reported the +task as exit code 0 — that was the `echo`.** Lighthouse had exited **1**: it +enumerated `dist/`, and I rebuilt underneath it mid-run, so it failed on a file +that had ceased to exist. The real status was in the output only because the +`echo` printed it. Same family as the `tail -3` and `PIPESTATUS` traps +`CLAUDE.md` already names — *a wrapper's exit status is not the command's* — and +it is the third way this project has found to read a pass off a command that +failed. + +### What is NOT done, and none of it is carried by a deploy + +1. 🛑 **`configure.mjs --apply` has not run.** The `*.pdf` response-headers + policy **and** the `/api/*` origin request policy are both written and both + unapplied. One `--apply` does both. **Then run Part 3's three probes** — the + third one is the only one that can detect a dropped `Content-Type`. +2. 🛑 **`docs/09` Part 5 has not run.** `npm run deploy` is an S3 sync and an + invalidation and **contains no Lambda step** — so a deploy ships the second + honeypot's markup and neither the check that reads it nor the scoring. + **5.5 is the path in production**, and both 5.1 and 5.5 now derive the file + list from the directory. +3. **`docs/09` §7.2** — the real-submission test. §7.1 answering 303 is a + different fact: it stops before any write and any send. Now its own unticked + item on `docs/06`. +4. **§9 Q60** — the TTL observation window, reading from 2026-09-04. +5. **§9 Q65 (WAF) and Q66 (the timing floor)** — both need Pouya. + ## 2026-09-03 (as) — (ar)'s intake-form finding is REFUTED by measurement, and the correction is appended rather than applied to it; the D20 gloss class is fixed across 9 files; `X-Robots-Tag` turns out to be impossible the way it was asked for **Pouya's rulings of 2026-09-03**, in five parts. This entry is the correction to diff --git a/backend/intake/fields.mjs b/backend/intake/fields.mjs index 939b395..13b90dd 100644 --- a/backend/intake/fields.mjs +++ b/backend/intake/fields.mjs @@ -115,3 +115,25 @@ export const FIELDS = [ * stops filling the field. `check:intake` asserts it is absent from `FIELDS`. */ export const HONEYPOT = 'company_website'; + +/** + * The SECOND honeypot — a decoy checkbox that must arrive ABSENT. Also not in + * `FIELDS`, for the same reason, and `check:intake` asserts that too. + * + * ⚠️ **DIFFERENT TRAP, NOT A SECOND COPY.** `HONEYPOT` catches a bot that fills + * every text input; this catches one that sets every control it enumerates. + * + * ⚠️ **IT IS PROBABLY INERT AGAINST THE 2026-09-04 PAIR, AND THE COMMENT HERE + * SAID THE OPPOSITE FOR ONE ROUND.** They left `HONEYPOT` empty, so they skip + * hidden fields — and a bot that skips a hidden text input skips a hidden + * checkbox. `src/data/intake.ts` carries the full argument; this is defence in + * depth against a different class, not a counter to the observed one. + * + * ⚠️ **UNCHECKED SENDS NOTHING, so absence is the pass — and so is an empty + * value, because the handler tests for a non-empty one rather than for mere + * presence.** See + * `src/data/intake.ts` for the full reasoning; the two files state it separately + * because they are separately deployed and `check:intake` is what keeps the + * NAMES in step, not the comments. + */ +export const DECOY_CHECKBOX = 'updates_optin'; diff --git a/backend/intake/handler.mjs b/backend/intake/handler.mjs index f6c831d..01b5f13 100644 --- a/backend/intake/handler.mjs +++ b/backend/intake/handler.mjs @@ -3,13 +3,16 @@ * table and SES state: AGENTS.md §7 — this file reads them from the environment * and does not restate them. * - * ⚠️ THIS IS NOT DEPLOYED. Written at build step 8; nothing on this project - * deploys before cutover (D11). AGENTS.md §7 records that a hand-built - * `adr-intake-handler` already exists in the console, created before this repo, - * and this file REPLACES it rather than describing it. docs/06's cutover - * checklist carries the deployment steps and the CloudFront `/api/*` behaviour - * the form depends on. Until both are done the form on /contact/ posts into - * nothing, which is why that page also publishes the email address. + * ⚠️ THIS IS LIVE. Deployed at cutover on 2026-09-02 by `docs/09` Part 5, and + * `/api/intake` answers 303 to the Part 7.1 probe. It REPLACED a hand-built + * `adr-intake-handler` that predates this repo. **This banner read "THIS IS NOT + * DEPLOYED" until 2026-09-04**, which is the most dangerous thing a comment on + * this file can say: an edit made in that belief ships to a form real inquirers + * are using. Changes here reach production on the next `docs/09` Part 5 run. + * + * ⚠️ AND A BARE `POST /api/intake` RETURNS 403 BY DESIGN — the Origin check + * below. `docs/09` §7.1 is the only valid route probe; a 403 without that header + * is not evidence about the route. It has been misread as one twice. * * ── THE SHAPE, AND WHY IT IS POST-REDIRECT-GET ───────────────────────────── * @@ -30,6 +33,13 @@ * * ── WHAT THIS DELIBERATELY DOES NOT IMPLEMENT ────────────────────────────── * + * ⚠️ **RE-ASKED 2026-09-04 AND STILL NOT IMPLEMENTABLE HERE.** Pouya ruled + * *"raise the timing floor"* after the first real spam. There is no floor to + * raise — the check has never existed — and the reason below is unchanged by + * the spam arriving: it is a property of a CDN-cached static page, not of how + * hard anyone has tried. What CAN carry a per-visitor clock is named in + * `docs/05` §Observed abuse and it is outside "handler + form only". §9 Q66. + * * **THE 3-SECOND TIMESTAMP CHECK IS NOT IMPLEMENTED, AND THAT IS A DECISION.** * docs/05 asks to "reject submissions completed in under 3 seconds". It cannot * be done here and implementing it would produce a control that does nothing: @@ -42,10 +52,11 @@ * * That is worse than omitting it: AGENTS.md Q22 and the Lighthouse row are both * records of what a control that exists on paper and not in fact costs here. So - * it is omitted, said out loud, and the load is carried by the honeypot, the - * Origin check, the aggregate API Gateway route throttle and the validation - * below. (Aggregate, not per-IP — see above; the earlier wording here said - * "rate limit" and let the reader supply the stronger meaning.) + * it is omitted, said out loud, and the load is carried by the TWO honeypots, + * the Origin check, the aggregate API Gateway route throttle and the validation + * below — plus, since 2026-09-04, a score that LABELS and never rejects. + * (Aggregate, not per-IP — see above; the earlier wording here said "rate + * limit" and let the reader supply the stronger meaning.) * * ── WHAT MUST BE CONFIGURED OUTSIDE THIS FILE ────────────────────────────── * @@ -65,10 +76,20 @@ import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import { randomUUID } from 'node:crypto'; -/* The field table and the honeypot name live in their own module so that +/* The field table and BOTH honeypot names live in their own module so that `npm run check:intake` can import them without this file's module-scope `requireEnv()` calls running. See fields.mjs for why there are two tables. */ -import { FIELDS, HONEYPOT } from './fields.mjs'; +import { DECOY_CHECKBOX, FIELDS, HONEYPOT } from './fields.mjs'; +/* Scoring lives in its own module so it can be unit-tested — this file throws at + import without a configured environment, so it cannot be. `node + backend/intake/spam-score.test.mjs`. ⚠️ IT IS A THIRD FILE IN THE ZIP: + `docs/09` Part 5.1 packages it explicitly, and a cold start would fail with + ERR_MODULE_NOT_FOUND if it were left out. */ +import { + isPossibleSpam, + scoreSubmission, + SPAM_THRESHOLD, +} from './spam-score.mjs'; /* Region comes from the Lambda runtime, which sets AWS_REGION to the function's own region — the one §7 records. Not hardcoded: a second copy of a fact §7 @@ -219,14 +240,18 @@ function parseBody(event) { * presented as an identification is worse than an honest useless one. * * The right value is CloudFront's own `CloudFront-Viewer-Address`, which - * CloudFront generates and overwrites — but reaching it needs a CUSTOM origin - * request policy on the /api/* behaviour (the managed - * AllViewerAndCloudFrontHeaders forwards Host, which 403s every request at API - * Gateway, which is why AllViewerExceptHostHeader was chosen). That is an - * infrastructure change, and `docs/09` Part 7.2 measures what this field - * actually contains at cutover rather than reasoning about the proxy chain — - * with a decision table for each outcome. Do not "fix" this from the header - * again without that measurement. + * CloudFront generates and overwrites. Reaching it needs a CUSTOM origin request + * policy on the /api/* behaviour — the managed AllViewerAndCloudFrontHeaders + * forwards Host, which 403s every request at API Gateway. + * + * ⚠️ THAT POLICY IS NOW WRITTEN — `infra/cloudfront/configure.mjs` section 5, + * Pouya's ruling of 2026-09-04 — SO THE HEADER MAY ARRIVE. THIS FUNCTION STILL + * DOES NOT READ IT, AND THAT IS THE RULING, NOT AN OMISSION: *measured, not yet + * acted on*. What the record holds is published field by field on + * /legal/privacy/, so storing a different address is a DISCLOSURE change + * governed by `docs/09` §7.2's decision table — an infrastructure change + * forwards a header; only a privacy-policy change may store one. Do not "fix" + * this from any header without that measurement and that edit. */ function viewerIp(event) { return event.requestContext?.http?.sourceIp ?? 'unknown'; @@ -293,7 +318,51 @@ export async function handler(event) { * human cannot reach this field — it is `display: none`, `tabindex="-1"` and * `aria-hidden` — so a non-empty value is not a mistake anyone made. */ - if (typeof body[HONEYPOT] === 'string' && body[HONEYPOT].trim() !== '') { + /* ⚠️ COERCED, NOT TYPE-CHECKED. `parseBody` accepts JSON, so a value can + arrive as `true` or `1` rather than a string — and `typeof === 'string'` + let exactly that through both traps for one round. `String(v).trim()` + catches every non-empty shape and still treats absence as a pass. */ + if (body[HONEYPOT] !== undefined && String(body[HONEYPOT]).trim() !== '') { + /* LOGGED, BECAUSE THIS IS ONE OF ONLY TWO PATHS THAT DISCARD A SUBMISSION + AND ANSWER WITH THE SUCCESS PAGE. Unlogged, a honeypot that starts firing + on real visitors — a stylesheet that 404s, an autofiller, a template edit + that unhides the wrapper — is indistinguishable from quiet weeks, and the + only signal is inquiries that were never mentioned again. The FIELD NAME + only: the value is whatever a bot chose and nothing about the submission + is kept, which is what makes this safe to log at all. */ + console.warn('intake: discarded by honeypot', { field: HONEYPOT }); + return redirect(SUCCESS); + } + + /** + * THE SECOND HONEYPOT, AND IT TRAPS A DIFFERENT BEHAVIOUR. A checkbox no + * person can see; an unchecked box sends nothing at all, so a VALUE arrives + * only because something ticked it. The value itself is not compared — + * `=1`, `=yes` and `=on` are all a tick — only that there is one. + * + * Same silent SUCCESS as above, and for the same reason. + * + * ⚠️ ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE. Both directions matter and + * they fail differently: + * + * - Requiring the field to ARRIVE would turn every dropped-field path — an + * extension, a proxy, a template edit — into a lost inquiry reported as + * sent. + * - Trapping on mere PRESENCE (`!== undefined`) would catch a form + * serialiser that emits `updates_optin=` for a hidden checkbox without + * reading its checked state. That is rare and it is not impossible, and + * the cost of being wrong is a real legal inquiry discarded in silence. + * + * So the test is the same shape as the honeypot above — a non-empty value — + * while the BEHAVIOUR it catches is the opposite one. That is the distinction + * that matters: filling text fields versus ticking boxes, not `undefined` + * versus `''`. + */ + if ( + body[DECOY_CHECKBOX] !== undefined && + String(body[DECOY_CHECKBOX]).trim() !== '' + ) { + console.warn('intake: discarded by honeypot', { field: DECOY_CHECKBOX }); return redirect(SUCCESS); } @@ -403,6 +472,50 @@ export async function handler(event) { .map((f) => `${f.label}: ${clean[f.name]}`) .join('\n'); + /** + * SCORING, AND IT LABELS RATHER THAN REJECTS — Pouya, 2026-09-04. + * + * ⚠️ THIS RUNS AFTER THE RECORD IS STORED, WHICH IS NOT AN ACCIDENT OF + * ORDERING. Nothing below can decline a submission: by the time it runs, the + * write has already succeeded and the only remaining question is what the + * OPERATOR's subject line says. There is deliberately no branch here that can + * reach `redirect(FAILURE)`. + * + * ⚠️ AND IT TOUCHES THE NOTIFICATION ONLY. The confirmation below is + * unchanged. A real inquirer wrongly scored must never be told that a machine + * thought they were a bot. + */ + /* ⚠️ WRAPPED, AND THE GUARD IS THE RULING RATHER THAN CAUTION. An exception + here would escape `handler`, API Gateway would answer 500, and the inquirer + would see a failure for a submission ALREADY WRITTEN to the table — a path + that costs an inquiry, decided by a labelling function. Pouya's constraint + is that nothing but a honeypot may cost one, so the scorer is allowed to + fail and the submission is not. Unlabelled is the safe default. */ + let spam = { score: 0, signals: [] }; + try { + spam = scoreSubmission(clean); + } catch (error) { + console.error('intake: spam scoring failed; sending unlabelled', { + id, + error, + }); + } + const flagged = isPossibleSpam(spam); + const notificationBody = [ + `Received ${now.toISOString()}`, + `submissionId ${id}`, + ...(flagged + ? [ + '', + `Possible spam. Score ${spam.score} of threshold ${SPAM_THRESHOLD}. ` + + `Signals: ${spam.signals.join('; ')}.`, + ] + : []), + '', + summaryLines, + '', + ].join('\n'); + /** * TWO EMAILS — D18, and the second one is why the form beats a mailto: link. * `Promise.allSettled`, not `Promise.all`: the record is already stored, so a @@ -419,14 +532,19 @@ export async function handler(event) { ReplyToAddresses: [clean.email], Content: { Simple: { - Subject: { Data: `Intake — ${clean.name} (${clean.practiceArea})` }, + /* The prefix is what Pouya filters on in Gmail, so it is the + FIRST thing in the subject and it is a fixed string. Do not make + it conditional on anything else, and do not vary its wording. */ + Subject: { + Data: + `${flagged ? '[Possible spam] ' : ''}` + + `Intake — ${clean.name} (${clean.practiceArea})`, + }, Body: { - Text: { - // The bare id, because it is the partition key: this line is - // what gets pasted into the console to find the record, so it - // must be the key and not a rendering of it. - Data: `Received ${now.toISOString()}\nsubmissionId ${id}\n\n${summaryLines}\n`, - }, + // The body carries the bare submissionId, because it is the + // partition key: that line gets pasted into the console to find + // the record, so it must be the key and not a rendering of it. + Text: { Data: notificationBody }, }, }, }, diff --git a/backend/intake/spam-score.mjs b/backend/intake/spam-score.mjs new file mode 100644 index 0000000..e78f764 --- /dev/null +++ b/backend/intake/spam-score.mjs @@ -0,0 +1,196 @@ +/** + * Spam SCORING for the intake handler. Pouya's ruling, 2026-09-04. + * + * ⚠️ **THIS MODULE NEVER REJECTS ANYTHING, AND THAT IS THE WHOLE DESIGN.** It + * returns a score and a list of signal names. The handler stores the record and + * sends both emails either way; above the threshold it prefixes the OPERATOR + * notification's subject with `[Possible spam] ` and adds one line naming the + * signals. Pouya filters in Gmail. His words: *"Nothing is dropped; a false + * positive costs him one glance."* + * + * That asymmetry is why the thresholds below can be tuned aggressively. The cost + * of a false positive is a subject-line prefix; the cost of a false negative is + * one unlabelled email. Neither loses an inquiry — which a filter that rejected + * would, and a legal inquiry lost silently is the one outcome this form must not + * produce. + * + * ⚠️ **NOTHING HERE IS STORED.** The score and the signals do not enter the + * DynamoDB item. `/legal/privacy/` publishes what the record holds, field by + * field, and adding an attribute would make that list wrong — a disclosure + * defect, not a schema change. The label lives only in the operator + * notification. ⚠️ **THAT MAILBOX IS DELEGATED, NOT PERSONAL** — §9 Q63 and + * `/legal/privacy/` §Who can see it both say so, and an earlier draft of this + * comment said the label "lives in an email that only Pouya reads", which is the + * exclusivity Q63 struck. It reaches whoever reads `info@smlcompany.ca`. If a + * stored score is ever wanted, the page changes first. + * + * ⚠️ **AND THE INQUIRER NEVER SEES ANY OF THIS.** The confirmation email is + * untouched. A person wrongly scored must not be told a machine thought they + * were a bot. + * + * WHY SCORING RATHER THAN MORE REJECTION. The two submissions of 2026-09-04 + * (`docs/05` §Observed abuse) passed the honeypot. Every rule that would have + * caught them — a foreign phone, a link in the summary, a disposable-looking + * address — is a rule some real inquirer also trips: this practice takes + * cross-border commercial work, so a `+44` number is a client, not a bot. A + * rejecting rule set built from those signals would eventually discard a real + * dispute and report success while doing it. + */ + +/** + * ⚠️ **NOT MEASURED FROM A CORPUS — THERE IS NO CORPUS.** No genuine inquiry has + * arrived through this form yet, so there is nothing to measure a normal summary + * length against, and a number presented as measured when it is not is the + * defect `AGENTS.md` keeps paying for. + * + * It is DERIVED, and the derivation is the form's own instruction: the `summary` + * field's hint reads *"A few sentences is enough."* This floor sits **below** what + * that invites, so it fires on a summary that does not attempt the question + * rather than on one that answers it briefly. `[assumed 2026-09-04]` + * + * ⚠️ **TUNE IT DOWN WHEN IN DOUBT, NEVER UP.** An unlabelled spam costs nothing + * that matters; a labelled real inquiry spends the reader's trust in the label. + * *"Shareholder dispute, two directors, Ontario CBCA company."* is 57 characters + * and is exactly what the hint asks for — a floor above that scores the form's + * own instruction as a spam signal. + * + * **Pouya can replace this with a measurement whenever he likes** — the two spam + * records of 2026-09-04 are still in the table, and their `summary` lengths are + * the first real data this number could rest on. §9 Q65 records that. + */ +export const SHORT_SUMMARY_CHARS = 100; + +/** + * Above this, the notification is labelled. Weights below are 1 for a signal a + * real inquirer plausibly trips and 2 for one they rarely do, so the threshold + * of 2 means: **one strong signal, or two weak ones.** + * + * Worked, because a threshold nobody has worked through is a guess with a number + * on it: + * - Ontario counsel, local number, three-line summary → 0, clean + * - Cross-border counsel, `+44` number, three-line summary → 1, clean + * - Cross-border counsel, `+44` number, one-line summary → 2, LABELLED + * - A four-part real name at gmail.com → 1, clean + * - Anyone pasting a link to a public tender document → 2, LABELLED + * - foreign number + a short scraped summary carrying a link → 4, LABELLED + * + * The third and fourth rows are the accepted false positives. Both are real + * shapes, both cost one glance, and both were preferred to missing the fifth. + * + * ⚠️ **THE LAST ROW IS A SHAPE, NOT A MEASUREMENT OF THE TWO 2026-09-04 + * SUBMISSIONS. THOSE RECORDS WERE NEVER READ.** What the attested signature + * guarantees is a non-NANP phone — **one weak signal** — and whether either is + * labelled turns on facts only the two rows in the table hold. §9 Q65 records + * that they are still there and are the only real data any of these numbers + * could rest on. + */ +export const SPAM_THRESHOLD = 2; + +/** `https://…` or `www.…` only. A bare `acme.com` is NOT matched: an inquirer + * writing "the dispute concerns acme.com's supply contract" is describing a + * party, and matching that would label ordinary commercial prose. */ +const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)\S/i; + +/** + * NANP: an explicit `+<cc>` settles it; otherwise ten digits, or eleven + * beginning with 1, after the tail is dropped. + * + * ⚠️ **A DIGIT COUNT ALONE CANNOT DO THIS.** `416-555-0123 ext 22` is twelve + * digits, `416-555-0123 or 416-555-0124` is twenty, and both are ordinary + * Toronto numbers that a bare count calls foreign — a signal saying the opposite + * of the truth. The tail is dropped at the first extension marker or + * second-number separator, and the marker list is deliberately generous. + * + * **The country code is read FIRST because it is the only unambiguous thing in + * the field.** `+44 …` and `+7 …` are settled without counting anything, which + * is what a pure shape test cannot do: `+7 912 345 6789` is grouped 3-3-4 + * exactly like a NANP number, so matching the shape would call it Canadian. + * Only when there is no explicit country code does the digit count run, and then + * the tail is dropped at the first extension marker or second-number separator. + */ +function looksNorthAmerican(phone) { + const trimmed = phone.trim(); + /* An explicit international prefix is decisive in both directions. */ + const cc = trimmed.match(/^\+\s*(\d{1,3})/); + if (cc) return cc[1] === '1'; + /* Longest alternative FIRST: regex alternation is leftmost-first, so `ext` + placed before `extension` matches the first three letters and then relies on + backtracking. Ordering it correctly is cheaper than depending on that. + `\bx\b` would NOT match the `x` in `x22` — the digit after it is a word + character, so there is no boundary — which is how `(416) 555-0123 x22` scored + foreign for one round. The marker is matched by what FOLLOWS it. */ + const digits = trimmed + .split(/\s*(?:extension|extn|ext|x)[.:-]?\s*\d|[#,;]|\bor\b/i)[0] + .replace(/\D/g, ''); + return digits.length === 10 || (digits.length === 11 && digits[0] === '1'); +} + +/** + * The Gmail dot trick: one mailbox, unlimited distinct-looking addresses, + * because Gmail ignores dots in the local part. + * + * ⚠️ **A DOT IS NOT THE SIGNAL, AND TREATING IT AS ONE WOULD LABEL MOST REAL + * GMAIL USERS.** `first.last@gmail.com` is the single most ordinary form a Gmail + * address takes. What distinguishes the trick is dot DENSITY: **three or more + * dots**, and nothing else. + * + * ⚠️ **AND THE WEIGHT IS 1, NOT 2, WHICH MATTERS MORE THAN THE BOUNDARY DOES.** + * `mary.jane.o.brien@gmail.com` and `maria.de.la.cruz@gmail.com` are three-dot + * REAL names — compound surnames and middle initials are ordinary, not rare — + * and weight 2 is defined here as what a real inquirer rarely trips. At weight 1 + * nothing can be labelled on the shape of its owner's name alone; a genuine + * dot-trick address reaches the threshold as soon as it trips anything else, + * which spam reliably does. **Do not raise it back.** + */ +function looksLikeGmailDotTrick(email) { + const at = email.lastIndexOf('@'); + if (at < 1) return false; + const local = email.slice(0, at); + const domain = email.slice(at + 1).toLowerCase(); + if (domain !== 'gmail.com' && domain !== 'googlemail.com') return false; + const dots = local.split('.').length - 1; + return dots >= 3; +} + +/** + * @param {Record<string, string>} fields the handler's `clean` map — validated, + * plain-texted values, keyed by field name. Absent fields are simply absent. + * @returns {{score: number, signals: string[]}} `signals` are written for a + * human reading one line of an email, not for a machine. + */ +export function scoreSubmission(fields) { + const signals = []; + let score = 0; + const add = (weight, label) => { + score += weight; + signals.push(label); + }; + + const summary = fields.summary ?? ''; + const phone = fields.phone ?? ''; + const email = fields.email ?? ''; + + /* Only when a summary exists. An absent one is a validation failure the + handler has already turned into the failure page, so scoring an empty + string here would be scoring a submission that never got this far. */ + if (summary !== '' && summary.length < SHORT_SUMMARY_CHARS) { + add(1, `summary under ${SHORT_SUMMARY_CHARS} characters`); + } + /* `phone` is OPTIONAL. Not giving one is not a signal — most inquirers will + not — so this fires only on a number that is present and not North + American. Treating absence as suspicious would label the quiet majority. */ + if (phone !== '' && !looksNorthAmerican(phone)) { + add(1, 'phone is not a Canadian or US number'); + } + if (URL_IN_TEXT.test(summary)) { + add(2, 'link in the dispute summary'); + } + if (looksLikeGmailDotTrick(email)) { + add(1, 'Gmail address using the dot trick'); + } + + return { score, signals }; +} + +/** True when the operator notification should carry the label. */ +export const isPossibleSpam = ({ score }) => score >= SPAM_THRESHOLD; diff --git a/backend/intake/spam-score.test.mjs b/backend/intake/spam-score.test.mjs new file mode 100644 index 0000000..77ba813 --- /dev/null +++ b/backend/intake/spam-score.test.mjs @@ -0,0 +1,336 @@ +/** + * Unit test for the intake spam scorer. `node backend/intake/spam-score.test.mjs`. + * + * Same shape and same reasoning as `infra/cloudfront/router.test.mjs`: the real + * check is a real submission, this one runs in a second and catches the branch + * mistakes that a regex change makes silently. + * + * ⚠️ **EVERY SIGNAL SHIPS WITH A NEGATIVE FIXTURE**, which is the discipline + * `CLAUDE.md` imposes on `check:claims` and applies here for the same reason: + * this scorer's failure mode is not missing spam, it is labelling a real + * inquiry. The pairs below are the nearest legitimate submission to each trap — + * `j.k.smith@gmail.com` beside the dot trick, an extension-carrying Toronto + * number beside a Russian one, ordinary commercial prose naming a company + * beside a pasted link. + * + * ⚠️ **EACH CASE ASSERTS THE SIGNAL NAMES, NOT ONLY THE SCORE.** Asserting the + * total alone lets two rules swap weights, or one rule fire in place of + * another, with every case still passing — the suite would then be checking + * arithmetic rather than behaviour. `expected` is the exact signal set. + */ +import { + scoreSubmission, + isPossibleSpam, + SPAM_THRESHOLD, + SHORT_SUMMARY_CHARS, +} from './spam-score.mjs'; + +const SHORT = `summary under ${SHORT_SUMMARY_CHARS} characters`; +const PHONE = 'phone is not a Canadian or US number'; +const LINK = 'link in the dispute summary'; +const GMAIL = 'Gmail address using the dot trick'; + +const MID = + 'A construction lien dispute over a delayed fit-out. Counsel are engaged ' + + 'on both sides and we want a mediator.'; + +const LONG = + 'The parties are in dispute over a delayed fit-out on a Toronto office ' + + 'tower. The subcontract was terminated in June and the holdback has not ' + + 'been released. Counsel are engaged on both sides and we are looking for a ' + + 'mediator with construction experience.'; + +/* [label, fields, expected signals] — score and labelled are DERIVED from the + weights below, so a weight change fails every affected case by name rather + than silently re-balancing the totals. */ +const WEIGHTS = { [SHORT]: 1, [PHONE]: 1, [LINK]: 2, [GMAIL]: 1 }; + +const CASES = [ + // ---- clean submissions, which is the half that matters most ------------- + [ + 'ordinary Ontario inquiry', + { summary: LONG, phone: '416-555-0123', email: 'a.counsel@firm.ca' }, + [], + ], + ['no phone given at all', { summary: LONG, email: 'counsel@firm.ca' }, []], + [ + '+1 with punctuation', + { summary: LONG, phone: '+1 (647) 555-0188', email: 'c@firm.ca' }, + [], + ], + [ + 'ten digits, no punctuation', + { summary: LONG, phone: '6475550188', email: 'c@firm.ca' }, + [], + ], + [ + 'Toronto number with an extension', + { summary: LONG, phone: '416-555-0123 ext 22', email: 'c@firm.ca' }, + [], + ], + [ + 'extension written x22', + { summary: LONG, phone: '(416) 555-0123 x22', email: 'c@firm.ca' }, + [], + ], + [ + 'extension written Ext:', + { summary: LONG, phone: '416-555-0123 Ext: 4501', email: 'c@firm.ca' }, + [], + ], + [ + 'extension spelled out', + { summary: LONG, phone: '416-555-0123 extension 22', email: 'c@firm.ca' }, + [], + ], + [ + 'extension hyphenated', + { summary: LONG, phone: '416-555-0123 ext-22', email: 'c@firm.ca' }, + [], + ], + [ + 'two numbers in one field', + { + summary: LONG, + phone: '416-555-0123 or 416-555-0124', + email: 'c@firm.ca', + }, + [], + ], + [ + 'ordinary gmail, one dot', + { summary: LONG, phone: '416-555-0123', email: 'first.last@gmail.com' }, + [], + ], + [ + 'gmail, single initial', + { summary: LONG, phone: '416-555-0123', email: 'j.smith@gmail.com' }, + [], + ], + [ + 'gmail, TWO initials and a surname', + { summary: LONG, email: 'j.k.smith@gmail.com' }, + [], + ], + /* ⚠️ NON-GMAIL, THREE DOTS — this pins the DOMAIN GUARD, which nothing did. + Deleting `if (domain !== 'gmail.com' && …) return false` left all 30 cases + passing: the nearest legitimate submission to a three-dot trap is a + three-dot address at a firm domain, and it was the one fixture missing. */ + [ + 'law-firm address, three dots', + { summary: LONG, email: 'j.p.van.dam@blakes.com' }, + [], + ], + [ + 'four-part real name at gmail', + { + summary: LONG, + phone: '416-555-0123', + email: 'mary.jane.o.brien@gmail.com', + }, + [GMAIL], + ], + [ + 'company named in prose, no link', + { summary: `${LONG} The respondent is acme.com Ltd.`, email: 'c@firm.ca' }, + [], + ], + [ + 'googlemail, one dot', + { summary: LONG, email: 'first.last@googlemail.com' }, + [], + ], + + // ---- one weak signal: still clean --------------------------------------- + [ + 'cross-border counsel, UK number', + { summary: LONG, phone: '+44 20 7946 0958', email: 'c@firm.co.uk' }, + [PHONE], + ], + [ + 'the concise summary the hint invites', + { + summary: 'Shareholder dispute, two directors, Ontario CBCA company.', + phone: '416-555-0123', + email: 'c@firm.ca', + }, + [SHORT], + ], + + // ---- boundaries ---------------------------------------------------------- + /* PINS THE FLOOR'S VALUE, which the two boundary cases below cannot: they + derive their lengths from `SHORT_SUMMARY_CHARS`, so they move with it and + a floor raised back to 140 passed them silently. This one is a literal + 109-character summary of the kind the form's hint invites, and it fails the + moment the floor rises above it. */ + [ + 'a realistic 109-character summary', + { summary: MID, email: 'c@firm.ca' }, + [], + ], + [ + 'summary exactly at the floor', + { summary: 'x'.repeat(SHORT_SUMMARY_CHARS), email: 'c@firm.ca' }, + [], + ], + [ + 'summary one under the floor', + { summary: 'x'.repeat(SHORT_SUMMARY_CHARS - 1), email: 'c@firm.ca' }, + [SHORT], + ], + [ + 'eleven digits not starting 1', + { summary: LONG, phone: '+7 912 345 6789', email: 'c@firm.ca' }, + [PHONE], + ], + /* ⚠️ TEN DIGITS IN TOTAL, AND FOREIGN — Iceland writes +354 followed by seven. + This is the ONE case that pins the country-code branch: without it the + digit count reads 10 and calls this a NANP number. Every other foreign + fixture here has 11+ digits, so the count agrees by accident and the + branch could be deleted with the whole suite still green. */ + [ + 'ten-digit international number', + { summary: LONG, phone: '+354 555 1234', email: 'c@firm.is' }, + [PHONE], + ], + [ + 'gmail, exactly two dots', + { summary: LONG, email: 'a.b.smith@gmail.com' }, + [], + ], + [ + 'gmail, exactly three dots', + { summary: LONG, email: 'a.b.c.smith@gmail.com' }, + [GMAIL], + ], + + // ---- two weak signals: labelled ------------------------------------------ + [ + 'foreign number and terse summary', + { + summary: 'Need a mediator.', + phone: '+7 912 345 6789', + email: 'c@firm.ru', + }, + [SHORT, PHONE], + ], + + // ---- one strong signal: labelled ----------------------------------------- + [ + 'link in the summary', + { summary: `${LONG} See https://example.com/tender`, email: 'c@firm.ca' }, + [LINK], + ], + [ + 'www link in the summary', + { summary: `${LONG} See www.example.com/tender`, email: 'c@firm.ca' }, + [LINK], + ], + [ + 'dot trick, four dots', + { summary: LONG, email: 'j.o.h.nsmith@gmail.com' }, + [GMAIL], + ], + /* ⚠️ WEIGHT 1, SO IT DOES NOT LABEL ALONE. That is the whole point of the + weight change, and this is the case that fails if it goes back to 2. */ + [ + 'dot trick alone does not label', + { summary: LONG, email: 'r.a.n.d.om@gmail.com' }, + [GMAIL], + ], + + // ---- the shape the 2026-09-04 pair is described as ------------------------ + // NOT a measurement of those records: their `summary` values were never read. + [ + 'scraped text, foreign number, link', + { + summary: 'Buy now at https://spam.example/offer', + phone: '+7 912 345 6789', + email: 'r.a.n.d.om@gmail.com', + }, + [SHORT, PHONE, LINK, GMAIL], + ], + /* The module's own worked example of a legitimate concise summary, beside a + Toronto direct line. It scored 2 and shipped `[Possible spam]` while the + extension strip was incomplete. */ + [ + 'concise summary + Toronto extension', + { + summary: 'Shareholder dispute, two directors, Ontario CBCA company.', + phone: '416-555-0123 ext: 4501', + email: 'c@firm.ca', + }, + [SHORT], + ], + // The attested signature ALONE — a non-NANP phone and nothing else known — + // is one weak signal and is NOT labelled. Kept as a case so the limit of what + // the observed evidence supports is asserted rather than described. + [ + 'attested signature alone', + { summary: LONG, phone: '+7 912 345 6789', email: 'random@gmail.com' }, + [PHONE], + ], + + // ---- absent fields must not throw or score ------------------------------- + ['empty object', {}, []], + [ + 'summary absent, phone local', + { phone: '416-555-0123', email: 'c@firm.ca' }, + [], + ], + ['email absent', { summary: LONG }, []], + ['malformed email, no @', { summary: LONG, email: 'not-an-address' }, []], + ['gmail with no local part', { summary: LONG, email: '@gmail.com' }, []], +]; + +let pass = 0; +const failures = []; +const seen = new Set(); +for (const [label, fields, expected] of CASES) { + const result = scoreSubmission(fields); + expected.forEach((sig) => seen.add(sig)); + const wantScore = expected.reduce((n, sig) => n + WEIGHTS[sig], 0); + const wantLabelled = wantScore >= SPAM_THRESHOLD; + const gotSignals = [...result.signals].sort(); + const wantSignals = [...expected].sort(); + const ok = + result.score === wantScore && + isPossibleSpam(result) === wantLabelled && + JSON.stringify(gotSignals) === JSON.stringify(wantSignals); + if (ok) { + pass += 1; + } else { + failures.push( + ` ${label}\n` + + ` expected score ${wantScore}, labelled ${wantLabelled}, signals ${JSON.stringify(wantSignals)}\n` + + ` got score ${result.score}, labelled ${isPossibleSpam(result)}, signals ${JSON.stringify(gotSignals)}`, + ); + } +} + +/* COVERAGE, ASSERTED RATHER THAN ASSUMED. A rule with no positive case is a rule + nobody has run, and it would still show a green suite. */ +for (const sig of Object.keys(WEIGHTS)) { + if (!seen.has(sig)) { + failures.push(` no case exercises the "${sig}" signal — it is untested.`); + } +} + +/* The threshold is part of the contract the cases above were written against. + Changing it without re-deriving them would leave every expectation a + statement about a threshold that no longer exists. */ +if (SPAM_THRESHOLD !== 2) { + failures.push( + ` SPAM_THRESHOLD is ${SPAM_THRESHOLD}, not 2 — the weights and expectations ` + + 'above were written against 2. Re-derive them before changing it.', + ); +} + +if (failures.length > 0) { + console.error(`spam-score: ${failures.length} FAILED of ${CASES.length}`); + console.error(failures.join('\n')); + process.exit(1); +} +console.log( + `spam-score: ${pass} of ${CASES.length} cases pass; all ${Object.keys(WEIGHTS).length} signals exercised`, +); diff --git a/docs/01-architecture.md b/docs/01-architecture.md index d968b3a..b0a77e7 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -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) diff --git a/docs/05-backend-spec.md b/docs/05-backend-spec.md index 2c59c05..b78aea8 100644 --- a/docs/05-backend-spec.md +++ b/docs/05-backend-spec.md @@ -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 diff --git a/docs/06-deployment.md b/docs/06-deployment.md index 0c48fc5..4ba1170 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -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 diff --git a/docs/07-fees.md b/docs/07-fees.md index be094f2..f45633f 100644 --- a/docs/07-fees.md +++ b/docs/07-fees.md @@ -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. diff --git a/docs/09-cutover-runbook.md b/docs/09-cutover-runbook.md index de18db3..c1f01c4 100644 --- a/docs/09-cutover-runbook.md +++ b/docs/09-cutover-runbook.md @@ -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. diff --git a/eslint.config.js b/eslint.config.js index f0ed95e..b495d9c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -100,4 +100,17 @@ export default [ 'no-console': 'off', }, }, + + /* THE BACKEND TEST FILE ONLY — NOT `backend/intake/**`. `handler.mjs` runs in + Lambda, where `console.log` is a line in CloudWatch that nobody reads and + `console.warn`/`console.error` are the two that signal, so the rule stays on + for it deliberately. The test beside it is a CLI tool and prints its verdict, + exactly as `scripts/` and the router test do. + + ⚠️ LAST, LIKE THE TWO ABOVE. Flat config applies matching blocks in order + and the last one wins. */ + { + files: ['backend/**/*.test.mjs'], + rules: { 'no-console': 'off' }, + }, ]; diff --git a/infra/cloudfront/configure.mjs b/infra/cloudfront/configure.mjs index 5835b3a..3a3865e 100644 --- a/infra/cloudfront/configure.mjs +++ b/infra/cloudfront/configure.mjs @@ -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); } diff --git a/scripts/check-intake.mjs b/scripts/check-intake.mjs index d014b92..2b1cf36 100644 --- a/scripts/check-intake.mjs +++ b/scripts/check-intake.mjs @@ -23,10 +23,15 @@ * Both files are read directly — Node strips the types out of the `.ts` — so * this script holds no third copy of the list. */ -import { INTAKE_FIELDS, HONEYPOT_FIELD } from '../src/data/intake.ts'; +import { + INTAKE_FIELDS, + HONEYPOT_FIELD, + DECOY_CHECKBOX_FIELD, +} from '../src/data/intake.ts'; import { FIELDS as SERVER_FIELDS, HONEYPOT, + DECOY_CHECKBOX, } from '../backend/intake/fields.mjs'; /** @@ -81,6 +86,63 @@ if (serverNames.includes(HONEYPOT_FIELD)) { ); } +/* THE SECOND HONEYPOT GETS THE SAME THREE CHECKS, and it needs a fourth. + Added 2026-09-04 with the decoy checkbox. Every failure mode below is silent + in production: a mismatched name disables the trap, a name inside `FIELDS` + turns it into ordinary validation, and two traps sharing one name is one + trap with a comment claiming there are two. */ +if (DECOY_CHECKBOX !== DECOY_CHECKBOX_FIELD) { + problems.push( + `decoy checkbox name differs: form "${DECOY_CHECKBOX_FIELD}", handler ` + + `"${DECOY_CHECKBOX}". The form renders one name and the handler checks ` + + 'another, so the trap is disabled and nothing fails.', + ); +} +if (serverNames.includes(DECOY_CHECKBOX_FIELD)) { + problems.push( + `the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the handler's FIELDS ` + + 'table; it must be checked separately, or ticking it would fail ' + + 'validation instead of sending the bot to the success page.', + ); +} +if (clientNames.includes(DECOY_CHECKBOX_FIELD)) { + problems.push( + `the decoy checkbox "${DECOY_CHECKBOX_FIELD}" is in the form's ` + + 'INTAKE_FIELDS table; it would render as a real, visible field.', + ); +} +/* `consent` is submitted by the form and read by the handler, and it is in + NEITHER field table — so the two checks above cannot see a collision with it. + A honeypot named `consent` would discard every valid submission behind the + success page, which is the worst failure this file can fail to catch. */ +for (const [what, name] of [ + ['honeypot', HONEYPOT_FIELD], + ['decoy checkbox', DECOY_CHECKBOX_FIELD], +]) { + if (name === 'consent') { + problems.push( + `the ${what} is named "consent", which the form submits and the handler ` + + 'requires — every valid submission would be discarded behind the ' + + 'success page.', + ); + } +} +if (DECOY_CHECKBOX_FIELD === HONEYPOT_FIELD) { + problems.push( + 'the two honeypots share the name ' + + `"${HONEYPOT_FIELD}" — that is one trap, not two, and the second ` + + 'mechanism (a checkbox that must arrive absent) would not exist.', + ); +} +/* And the first honeypot must not appear on the form's own table either — the + mirror of the check above it, which existed only for the handler's side. */ +if (clientNames.includes(HONEYPOT_FIELD)) { + problems.push( + `the honeypot "${HONEYPOT_FIELD}" is in the form's INTAKE_FIELDS table; ` + + 'it would render as a real, visible field.', + ); +} + for (const clientField of INTAKE_FIELDS) { const serverField = server.find((f) => f.name === clientField.name); if (!serverField) continue; @@ -128,7 +190,9 @@ for (const clientField of INTAKE_FIELDS) { console.log( `check:intake — ${clientNames.length} form fields, ${serverNames.length} ` + - 'handler fields, compared on name, label, requiredness, cap and option set.', + 'handler fields, compared on name, label, requiredness, cap and option ' + + `set; 2 honeypots ("${HONEYPOT_FIELD}", "${DECOY_CHECKBOX_FIELD}") ` + + 'compared on name and checked out of both tables.', ); if (problems.length > 0) { console.error(`\nINTAKE TABLE MISMATCH — ${problems.length}:`); diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh index c647a8f..1eb4e0c 100755 --- a/scripts/deploy-local.sh +++ b/scripts/deploy-local.sh @@ -181,10 +181,18 @@ else echo " - the POST /api/intake route is missing or misspelled (Part 6.2);" >&2 echo " - the route exists and the distribution's 404 mapping is showing you" >&2 echo " /404.html instead of the API's own body." >&2 + # THE ORIGIN REQUEST POLICY ON /api/* IS NO LONGER A CONSTANT. Since + # 2026-09-04 the behaviour may carry the custom `adr-sml-api-viewer-address` + # whitelist (docs/09 Part 3, change 8) instead of the managed policy, so this + # text no longer names one and tells the operator to read it. Naming the old + # one would send them to "restore" what was deliberately replaced. echo "403 means CloudFront rejected the method, or the handler refused the" >&2 - echo "Origin — check the behaviour uses Managed-AllViewerExceptHostHeader," >&2 - echo "because a policy that drops Origin turns every real submission into a" >&2 - echo "403. 500 means the Lambda invoke permission for this route is missing" >&2 + echo "Origin. Read which origin request policy /api/* carries — since" >&2 + echo "2026-09-04 it may be the custom whitelist adr-sml-api-viewer-address" >&2 + echo "rather than Managed-AllViewerExceptHostHeader — because a policy that" >&2 + echo "drops or fails to forward Origin turns every real submission into a" >&2 + echo "403. Rollback id: b689b0a8-53d0-40ab-baf2-68738e2966ac." >&2 + echo "500 means the Lambda invoke permission for this route is missing" >&2 echo "(Part 6.1) — the function is never entered, so CloudWatch is silent." >&2 echo "Either way the form is not verified working. See docs/09-cutover-" >&2 echo "runbook.md Part 7.1 and docs/06's cutover checklist." >&2 diff --git a/src/content/insights/when-med-arb-fits.mdx b/src/content/insights/when-med-arb-fits.mdx index aba7f0f..fbafe20 100644 --- a/src/content/insights/when-med-arb-fits.mdx +++ b/src/content/insights/when-med-arb-fits.mdx @@ -31,7 +31,7 @@ The second is what the neutral will actually do. That is a different question, a ## What I undertake {/* ⚠️ RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — §4's third class says - so in terms: "The six strings live in `CONDUCT_UNDERTAKINGS` in + so in terms: "The strings live in `CONDUCT_UNDERTAKINGS` in `src/data/site.ts` and the pages render them, so the diff that would soften one is visible on one constant rather than distributed through three templates." They were hand-typed here in the first draft, which put a fourth diff --git a/src/data/intake.ts b/src/data/intake.ts index 95afb6b..d14e91d 100644 --- a/src/data/intake.ts +++ b/src/data/intake.ts @@ -10,8 +10,15 @@ * * What stops the two drifting is a check rather than a shared import: * **`npm run check:intake`** asserts that the two tables agree on every field - * name, on which are required, and on every length cap — and fails the build - * script if they do not. Independent validation, mechanically cross-checked. If + * name, on which are required, on every length cap, and — since 2026-09-04 — on + * both honeypot names. + * + * ⚠️ **IT IS A KEYBOARD GATE, NOT A DEPLOY GATE, AND THIS COMMENT SAID IT "fails + * the build script".** It does not: `npm run build` is `astro build`, and + * `scripts/deploy-local.sh` runs `check`, `build` and `check:claims` and not this + * one. Run it yourself. A control described as running where it does not is + * `AGENTS.md` Q22, and this change set makes this check the only thing keeping + * the second honeypot's two names in step. Independent validation, mechanically cross-checked. If * you add a field here, add it there, and the check will tell you if you didn't. * * WHAT THIS DATA IS, because it changes how the form is built (`docs/05`): in a @@ -200,6 +207,50 @@ export const CONSENT_TEXT = */ export const HONEYPOT_FIELD = 'company_website'; +/** + * THE SECOND HONEYPOT, AND IT IS A DIFFERENT TRAP RATHER THAN A SECOND COPY OF + * THE FIRST. Pouya's ruling, 2026-09-04, after two automated submissions walked + * through `HONEYPOT_FIELD` (`docs/05` §Observed abuse). + * + * ⚠️ **THE MECHANISM IS INVERTED, WHICH IS THE POINT.** `HONEYPOT_FIELD` is a + * text input that must arrive EMPTY — it catches a bot that fills every input it + * finds. The pair of 2026-09-04 did not fill it, so a second field of the same + * kind would catch them exactly as well as the first did: not at all. + * + * This is a CHECKBOX, and what it catches is a bot that sets every control it + * enumerates rather than one that fills every text field. + * + * ⚠️ **WHAT IT IS AIMED AT, AND WHAT THE EVIDENCE ACTUALLY SUPPORTS — READ THIS + * BEFORE RELYING ON IT.** An earlier version of this comment said the decoy + * targets *"a behaviour anything reaching validation must have"*, because the + * consent box is required and unchecked by default, so a submission that + * validated must have sent `consent=on`. **That argument does not survive its own + * premise.** The 2026-09-04 pair did NOT fill the text honeypot, so they are + * selective about hidden fields — and a bot selective enough to skip a hidden + * text input is selective enough to skip a hidden checkbox. Sending `consent=on` + * shows only that it knows one field name, not that it ticks everything it finds. + * + * **So this trap is very likely INERT against the traffic it was built from**, + * and it is defence in depth against a different and common class: the bot that + * enumerates controls and sets all of them. That is worth having and it is not + * what the observation proved. `docs/05` §Observed abuse states the same limit; + * the two must not drift, because the tempting sentence is the confident one. + * + * ⚠️ **ABSENCE IS THE PASS, AND SO IS AN EMPTY VALUE.** A browser sends nothing + * at all for an unchecked box, so every way this field can fail to arrive — a + * stripping extension, a proxy, a future template that drops it — reads as a + * HUMAN; and a serialiser that emits `updates_optin=` without reading the + * checked state reads as one too, because the handler tests for a NON-EMPTY + * value rather than for presence. The failure mode of a trap is a lost legal + * inquiry that looks like a successful one, and this trap fires only on + * something that deliberately ticked a box no person can see. + * + * The name is a plausible marketing opt-in, which is what a bot expects to find + * and a real form here does not have. Hidden the same way as the first — the + * hiding is standard, the mechanism is not. + */ +export const DECOY_CHECKBOX_FIELD = 'updates_optin'; + /** * WHERE THE FORM POSTS — AND IT IS A SAME-ORIGIN PATH, NOT THE API GATEWAY * HOSTNAME. This is a design decision with four consequences, taken at step 8 @@ -209,6 +260,13 @@ export const HONEYPOT_FIELD = 'company_website'; * Posting to `/api/intake` instead, with a CloudFront behaviour routing `/api/*` * to that origin: * + * ⚠️ **AND IT IS ALL LIVE SINCE 2026-09-02** — see the closing paragraph of this + * block. The same stale sentence was corrected in `handler.mjs`, `docs/01` and + * `docs/05` before it was corrected here; this note was added, in the same pass, + * ABOVE a paragraph that still said the opposite twenty lines below it. **A note + * asserting a correction is not the correction**, and the two sat contradicting + * each other until `adversarial-reviewer` round 2. + * * 1. **`Content-Security-Policy: form-action 'self'`** — `docs/05` specifies * `form-action 'self' <api-endpoint>`; with a same-origin post the second * term is unnecessary, so the policy is strictly tighter. @@ -229,11 +287,10 @@ export const HONEYPOT_FIELD = 'company_website'; * a POST 404s. Under the alternative, clicking Submit on a laptop would * write a real DynamoDB record and send two real emails. * - * ⚠️ **THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: THE FORM DOES NOT - * WORK UNTIL THAT CLOUDFRONT BEHAVIOUR EXISTS AND THE HANDLER IS DEPLOYED.** - * Neither has been done — nothing on this project deploys before cutover (D11), - * and both are checklist items in `docs/06`. Until then the page is complete and - * the pipe behind it is not, which is why `/contact/` also publishes the email - * address rather than treating the form as the only way in. + * ⚠️ **THE COST, WHICH WAS REAL AND IS NOW PAID: THE FORM DID NOT WORK UNTIL + * THAT CLOUDFRONT BEHAVIOUR EXISTED AND THE HANDLER WAS DEPLOYED.** Both ran at + * cutover on 2026-09-02 — `AGENTS.md` §7 holds the state and this comment does + * not restate it. `/contact/` still publishes the email address beside the form, + * which is now a courtesy rather than a fallback. */ export const INTAKE_ACTION = '/api/intake'; diff --git a/src/data/site.ts b/src/data/site.ts index 6c920da..f267fc1 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -303,7 +303,12 @@ export const NEUTRAL_ROLE_LINE = 'party should have their own legal advice.'; /** - * THE SIX CONDUCT UNDERTAKINGS — Q54, ANSWERED BY POUYA 2026-08-29. + * THE CONDUCT UNDERTAKINGS — Q54, ANSWERED BY POUYA 2026-08-29, plus (g). + * + * ⚠️ **(a)–(f) ARE Q54's SIX. (g) IS NOT** — it was attested 2026-09-03 to + * close D20 finding 13 and carries its own stamp on the object below. The + * heading no longer states a count: this comment said "THE SIX" while the + * object held seven for exactly as long as it took to notice. * * A THIRD CLASS OF CLAIM, and the class is his: not a credential (a fact about * him, §4 Verified) and not an offering (a process the practice conducts, §4 @@ -365,7 +370,26 @@ export const CONDUCT_UNDERTAKINGS = { arbitrationAwardDate: 'The date the award is due is fixed in the first procedural order rather ' + 'than left open.', -} as const; // [verified 2026-08-29 — Pouya, Q54] + /** + * (g) `/legal/privacy/` — the conflicts check. **ATTESTED 2026-09-03 by + * Pouya, closing D20 finding 13.** It is NOT one of the Q54 six: its own + * date, its own ruling, and it is stamped separately below. + * + * The page was already stating a conflicts undertaking in prose, and §4's + * gate for this class is one line — he must have made it IN TERMS. He now + * has, so the sentence is rendered from here rather than typed there. + * + * ⚠️ **IT IS HIS WORDING, NOT A RENDERING OF IT, AND THAT IS THE WHOLE GATE.** + * The attestation is *"runs a conflicts check on every inquiry before + * engaging"*. This string shipped for one round as *"before I accept an + * appointment"* — the site's own vocabulary, defensible, and **a paraphrase of + * a commitment the page publishes as his**. §4's gate for this class is that + * he made it IN TERMS, and a substitution recorded in a code comment is not + * that. Do not smooth it back. If "engaging" turns out to be the wrong verb, + * the fix is a second attestation, never an edit here. + */ + conflictsCheck: 'I run a conflicts check on every inquiry before engaging.', +} as const; // (a)–(f) [verified 2026-08-29 — Pouya, Q54]; (g) [attested 2026-09-03 — Pouya] /** * THE HELD-DESIGNATIONS SENTENCE, RENDERED AND NEVER RETYPED. @@ -552,6 +576,40 @@ export const FEES = { * it, and do not price it. */ hourly: 500, // [verified 2026-08-26] + /** + * MED-ARB IS BILLED BY PHASE, AND THAT IS WHY THERE IS NO NUMBER IN HERE. + * + * Pouya's ruling, 2026-09-03, closing D20 finding 10. `/fees/` opens *"Every + * figure is on this page"* while §4 Offerings carries a **Med-Arb** row that + * `docs/07-fees.md` priced nowhere — so the promise was wider than the card. + * The ruling closes it by pricing the offering out of the two rate sets that + * are already published rather than by narrowing the promise: each phase is + * charged at the rates for that process, so no third set of figures exists + * and the sentence becomes true as written. + * + * ⚠️ **THERE IS NO `amount` HERE ON PURPOSE. Do not add one.** A med-arb + * figure would be a fourth price for a process priced twice already, and the + * first thing it would do is disagree with one of them. + * + * ⚠️ **`termsApply` SAYS "as they apply to that process on its own", NOT + * "to both phases".** The additional-party fee is a MEDIATION row; the + * arbitration card has no equivalent. Saying the terms apply to each phase as + * they apply to that process invents nothing; saying they apply throughout + * would invent an additional-party charge in the arbitral phase. + * + * INTERIM, set 2026-09-03, reviewed at the §12 R5 twelve-month fee review. + * `docs/07` §Med-arb — billed by phase carries the rule and the same stamp. + */ + medArb: { + rule: + '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.', + noSeparateFee: 'There is no separate med-arb fee.', + termsApply: + 'The additional-party and cancellation terms apply to each phase as ' + + 'they apply to that process on its own.', + }, // [verified 2026-09-03 — Pouya, interim; R5] cancellation: [ { window: 'More than 30 days before', fee: 'No fee. Disbursements only.' }, { window: '15 to 30 days before', fee: '50% of the booked fee.' }, diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 2738c71..1c5571d 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -38,15 +38,21 @@ */ import BaseLayout from '../layouts/BaseLayout.astro'; import Button from '../components/Button.astro'; +import Undertaking from '../components/Undertaking.astro'; import ContactBand from '../components/ContactBand.astro'; import Eyebrow from '../components/Eyebrow.astro'; import SectionHeading from '../components/SectionHeading.astro'; import { getImage } from 'astro:assets'; import ogDefault from '../assets/og-portrait.jpg'; import { pageGraph } from '../data/schema'; -import { CONTACT, NO_RETAINER_NOTICE } from '../data/site'; +import { + CONDUCT_UNDERTAKINGS, + CONTACT, + NO_RETAINER_NOTICE, +} from '../data/site'; import { CONSENT_TEXT, + DECOY_CHECKBOX_FIELD, HONEYPOT_FIELD, INTAKE_ACTION, INTAKE_FIELDS, @@ -117,11 +123,20 @@ const hintId = (name: string) => `${name}-hint`; </div> <div class="prose"> <p class="statement">{NO_RETAINER_NOTICE}</p> + <p>I ask for the other parties and their counsel for one reason.</p> + { + /* RENDERED FROM `CONDUCT_UNDERTAKINGS`, NEVER TYPED — undertaking (g). + ⚠️ THIS PAGE HAND-TYPED THE SAME PROPOSITION AS *"I cannot accept an + appointment before conflicts are checked"* UNTIL 2026-09-04, and it + survived the change set that struck the identical sentence from + `/legal/privacy/` — one file swept, its sibling missed, which is the + shape R8 exists for. §4 row (g) lists BOTH surfaces. */ + } + <Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking> <p> - I ask for the other parties and their counsel because I cannot accept - an appointment before conflicts are checked, and that check needs - names. Please keep the summary short and leave privileged or - confidential detail out of it — the call is for that. + That check needs names, and the call above is where it happens. Please + keep the summary short and leave privileged or confidential detail out + of it — the call is for that. </p> <p> What is collected, where it is stored, how long it is kept, and how to @@ -248,7 +263,13 @@ const hintId = (name: string) => `${name}-hint`; the form: a browser that helpfully fills a plausible-looking field would make a human look like a bot. */ } - <div class="honeypot" aria-hidden="true"> + { + /* `hidden` ADDED 2026-09-04, for the reason spelled out on the decoy + below: a class-only rule leaves this field on screen wherever author + styles do not apply, and a visitor who fills it loses their inquiry + behind a success page. */ + } + <div class="honeypot" hidden aria-hidden="true"> <label for={HONEYPOT_FIELD}>Company website</label> <input type="text" @@ -287,6 +308,41 @@ const hintId = (name: string) => `${name}-hint`; </p> </div> + { + /* THE SECOND HONEYPOT — a decoy CHECKBOX. The mechanism, and the + limits of what the observed spam supports, are in `src/data/intake.ts` + and are not restated here. Four properties of the MARKUP, each of + which is what stops this field costing a real inquiry: + + · its own CLASS NAME, not `.honeypot` — one selector must not + match both traps. They share a declaration block below, which is + presentation; what matters is that `.honeypot` does not select + this one; + · placed after the consent block, not beside the other honeypot; + · `hidden` as well as the CSS rule, so it stays hidden where + author styles do not apply; + · a label that tells a human not to tick it. With `hidden` in + place a human essentially cannot see it, so this is the last + line rather than the first — and it costs almost nothing, + because the PLAUSIBLE NAME is what a bot matches on and the name + is unchanged. + + ⚠️ NO `required`, AND NO `checked`. An unchecked box sends nothing, + so absence is the pass — and the handler tests for a NON-EMPTY value, + so an empty one passes too. */ + } + <div class="optin-decoy" hidden aria-hidden="true"> + <label for={DECOY_CHECKBOX_FIELD}>Leave this box unticked.</label> + <input + type="checkbox" + id={DECOY_CHECKBOX_FIELD} + name={DECOY_CHECKBOX_FIELD} + value="on" + tabindex="-1" + autocomplete="off" + /> + </div> + { /* ⚠️ `<Button type="submit">`, NOT a hand-written `<button class="btn">`. `.btn` and `.btn-primary` are SCOPED TO `Button.astro`, so a raw @@ -505,7 +561,8 @@ const hintId = (name: string) => `${name}-hint`; the input are belt and braces for the case where a future stylesheet un-hides it. Do not swap this for `visibility` or an off-screen position: an off-screen input is still focusable and still announced. */ - .honeypot { + .honeypot, + .optin-decoy { display: none; } diff --git a/src/pages/fees.astro b/src/pages/fees.astro index d37ed50..287518c 100644 --- a/src/pages/fees.astro +++ b/src/pages/fees.astro @@ -36,6 +36,17 @@ * sentence ships **adjacent to the overtime row**, not in a footnote. Same * structural rule as `PROCESS_FRAMING` beside the five timings under Q43. * + * ✅ **MED-ARB IS PRICED HERE AS OF 2026-09-03, AND IT CARRIES NO FIGURE.** + * Pouya's ruling closing D20 finding 10: it is billed **by phase**, each phase + * at the rates already on this page. The finding was that the hero promises + * *"Every figure is on this page"* while §4 Offerings carries a Med-Arb row + * that `docs/07` priced nowhere — a promise wider than the card. It is closed by + * pricing the offering, not by narrowing the promise, so the hero sentence is + * unchanged and is now true as written. **Do not give the section a rate row:** + * a med-arb figure would be a fourth price for a process priced twice, and the + * first thing it would do is disagree with one of them. `FEES.medArb` holds the + * three sentences; `docs/07` §Med-arb holds the rule. INTERIM, reviewed at R5. + * * ⚠️ **NO TRIBUNAL-SECRETARY RATE AND NO SETTLEMENT COUNSEL.** Both are struck * rows in §4 Offerings — the first removed by Pouya from D14, the second by him * as his own error in `docs/01`. **A rate on a fee page is an offer**, which is @@ -240,8 +251,35 @@ const ARBITRATION_ROWS = [ </div> </section> - {/* ---- 4. Other services ---------------------------------------------- */} + {/* ---- 4. Med-arb ------------------------------------------------------ */} <section class="section section-alt reveal"> + <div class="wrap"> + <div class="section-head"> + <SectionHeading + eyebrow="Med-arb" + level={2} + lede="One appointment, two processes. Each phase is charged at the rates for that process." + > + <span slot="heading">Billed by phase.</span> + </SectionHeading> + </div> + { + /* NO `<dl class="rates">` HERE, AND THE ABSENCE IS THE POINT — see the + header. Every other section on this page pairs an item with a figure; + this one has no figure of its own, and giving it a row would mean + inventing one. The three sentences come from `FEES.medArb` so the rule + lives beside the numbers it points at rather than in this template. */ + } + <ul class="notes" role="list"> + <li>{FEES.medArb.rule}</li> + <li>{FEES.medArb.noSeparateFee}</li> + <li>{FEES.medArb.termsApply}</li> + </ul> + </div> + </section> + + {/* ---- 5. Other services ---------------------------------------------- */} + <section class="section reveal"> <div class="wrap"> <div class="section-head"> <SectionHeading @@ -304,8 +342,8 @@ const ARBITRATION_ROWS = [ </div> </section> - {/* ---- 5. Cancellation ------------------------------------------------ */} - <section class="section reveal"> + {/* ---- 6. Cancellation ------------------------------------------------ */} + <section class="section section-alt reveal"> <div class="wrap"> <div class="section-head"> <SectionHeading @@ -334,7 +372,7 @@ const ARBITRATION_ROWS = [ </div> </section> - {/* ---- 6. Terms -------------------------------------------------------- */} + {/* ---- 7. Terms -------------------------------------------------------- */} <section class="section section-inverse reveal"> <div class="wrap"> <div class="section-head"> diff --git a/src/pages/legal/privacy.astro b/src/pages/legal/privacy.astro index e7c7b6c..ae81f71 100644 --- a/src/pages/legal/privacy.astro +++ b/src/pages/legal/privacy.astro @@ -59,10 +59,16 @@ */ import BaseLayout from '../../layouts/BaseLayout.astro'; import Eyebrow from '../../components/Eyebrow.astro'; +import Undertaking from '../../components/Undertaking.astro'; import { getImage } from 'astro:assets'; import ogDefault from '../../assets/og-portrait.jpg'; import { pageGraph } from '../../data/schema'; -import { ANALYTICS, CONTACT, SITE } from '../../data/site'; +import { + ANALYTICS, + CONDUCT_UNDERTAKINGS, + CONTACT, + SITE, +} from '../../data/site'; import { INTAKE_FIELDS } from '../../data/intake'; const ldImage = await getImage({ @@ -85,7 +91,7 @@ const RETENTION_MONTHS = 24; /** Bump this on ANY substantive edit. A privacy policy with a stale date is a * policy a reader cannot tell they are reading an old version of. */ -const LAST_UPDATED = '3 September 2026'; +const LAST_UPDATED = '4 September 2026'; /* Rendered from the form's own field list, so the two cannot drift. `consent` and the honeypot are absent from `INTAKE_FIELDS` deliberately and are @@ -160,15 +166,34 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label); <p> The form asks for the other parties to the dispute and their counsel. That is information about people who have not filled in the form and - may not know it was sent. It is asked for one reason: I cannot accept - an appointment before conflicts are checked, and the check needs - names. + may not know it was sent. It is asked for one reason, and the reason + is a commitment rather than an observation. </p> + { + /* `<Undertaking>` AND `CONDUCT_UNDERTAKINGS`, NEVER TYPED PROSE — + §4's third class, whose characteristic failure mode is that a + promise gets quietly smaller and nothing fails. Undertaking (g), + attested 2026-09-03. + + ⚠️ IT IS THE COMPONENT FOR THE REASON THE COMPONENT EXISTS: one + treatment on every page, so a reader can tell a promise from a + description. This shipped for one pass as an ordinary paragraph in + `“`/`”` — the only such entities in `src/`, and a + commitment set as body prose reads as another sentence about + process. + + It REPLACED the hand-typed "I cannot accept an appointment before + conflicts are checked", which stated the same proposition as a + constraint; keeping both would have set the undertaking beside its + own paraphrase — the (e)/(f) treatment. */ + } + <Undertaking>{CONDUCT_UNDERTAKINGS.conflictsCheck}</Undertaking> <p> - Please give names and nothing more about them. The form asks you not - to include privileged or confidential detail anywhere in it, and the - summary field says so directly. There is deliberately no field for - amounts in dispute and no way to attach a document. + The check needs names. Please give names and nothing more about them. + The form asks you not to include privileged or confidential detail + anywhere in it, and the summary field says so directly. There is + deliberately no field for amounts in dispute and no way to attach a + document. </p> <h2>Why it is collected, and on what basis</h2> @@ -340,11 +365,21 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label); delete it before the {RETENTION_MONTHS} months are up. {' '}{CONTACT.responseTime} </p> + { + /* ⚠️ THE CLAUSE THAT WAS HERE PROMISED TO DISCLOSE THE OUTCOME OF A + CONFLICTS CHECK — *"I will tell you what its outcome was rather than + pretending the inquiry did not happen"* — and that is an UNDERTAKING, + which §4 may publish only where Pouya has made it in terms. He had + not. D20 finding 13, and it is closed by his attestation of + 2026-09-03, which covers RUNNING the check and says nothing about + reporting it. The sentence now states what deletion does not undo and + stops there. Do not restore the promise without a second attestation: + it is a different commitment from the one he made. */ + } <p> Deletion removes the record. It does not retract the emails already - sent, and if a conflicts check has already been run I will tell you - what its outcome was rather than pretending the inquiry did not - happen. + sent, and it does not undo a conflicts check that has already been + run. </p> <h2>What an inquiry is not</h2>