From 17e316dc1d9e3790c99f6654229a157f4cfe98bf Mon Sep 17 00:00:00 2001 From: Pouya Lajevardi Date: Fri, 4 Sep 2026 14:52:13 -0400 Subject: [PATCH] feat: park the two policy changes the pricing plan forbids; robots.txt stands in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third --apply of 2026-09-04 reached update-distribution and was rejected atomically: "Distributions with the Free pricing plan can't have the following features: Custom origin request policy, Custom response headers policy." Pouya's ruling: both are PARKED as unavailable — a platform constraint, not a defect. The pre-flight added in the previous commit could not have caught this, and that is the point: every limit in PAYLOAD_LIMITS is a property of the payload, while this is a property of the account, reported only by the call the pre-flight exists to avoid. Both sections now stop before creating anything. The plan is not in the CloudFront API — checked across 167 operations, no operation, shape, member or documentation string mentions one, and PriceClass_All is the edge-location price class, not the plan. So the gate is a constant, PLAN_ALLOWS_CUSTOM_POLICIES, and the two sections report as PARKED under their own heading rather than as skips: the previous commit made a skip exit 3, and a constraint true on every run would have made 3 permanent. Proven with a shim that refuses every mutating verb: --apply now makes zero of them. Substitute (a): Disallow: /pouya-lajevardi-bio.pdf in robots.txt, placed before Allow:/ so first-match crawlers honour it too. It is not an equivalent and the file says so — it stops the PDF being fetched, solving the duplicate-of-/bio/ problem, but does not de-index a URL linked from /bio/ and /about/. Verified: syntax, a match simulation under both crawler semantics, and that the sitemap does not list the PDF. Substitute (b): the WAF web ACL CreatedByCloudFront-f8fbf256 is already attached — 925 WCU, three AWS managed rule groups, no rate-based statement. That corrects §9 Q65, which framed WAF as a cost decision about adding one and named the now-unappliable header forwarding as its groundwork. The real question is one rule on an ACL already paid for, and a rate-based rule matches the viewer address directly, so the capability is superseded rather than lost. Reviewed in two rounds by me rather than a separate agent, per instruction. Nothing was applied to the distribution and nothing was deployed; robots.txt needs one site deploy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5 --- AGENTS.md | 129 ++++++++++++++++++++++++++++++++- docs/04-seo-spec.md | 15 +++- docs/06-deployment.md | 51 +++++++++++-- docs/09-cutover-runbook.md | 122 +++++++++++++++++++++++++++---- infra/cloudfront/configure.mjs | 68 +++++++++++++++-- public/robots.txt | 32 +++++--- 6 files changed, 376 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 006d5a1..874048f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -725,7 +725,7 @@ 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`. 🟢 **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. 🛑 **TWO `--apply` ATTEMPTS WERE MADE ON 2026-09-04 AND BOTH FAILED BEFORE `update-distribution`. THE DISTRIBUTION IS UNCHANGED; THE ACCOUNT IS NOT.** Read "still to apply" as "attempted twice and rejected twice", never as "never tried". **Attempt 1** — section 4, `create-response-headers-policy`, `ParamValidation`: the cloned `SecurityHeadersConfig` carried `"ContentSecurityPolicy": {}`. Nothing was created. **Attempt 2** — section 4 **succeeded** and created the policy; section 5 then failed at `create-origin-request-policy` with `InvalidArgument`, *"The parameter Comment is too big"* — a **182-character** `Comment` against a **128** cap that the service model does not carry, so no client-side check saw it. 🟡 **THE ACCOUNT THEREFORE HOLDS ONE ORPHANED POLICY, AND IT IS EXPECTED, HARMLESS AND MUST NOT BE DELETED BY HAND:** `adr-sml-pdf-noindex` = **`51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**, referenced by no behaviour `[verified 2026-09-04]`. ⚠️ **THE SELF-HEALING CLAIM IS NOW MEASURED RATHER THAN ASSERTED** — this row and `docs/09` Part 3 predicted this exact outcome before it happened, and the next dry run **finds it by name, matches it on every reconciled field, and stages it for attachment**: `· response-headers policy adr-sml-pdf-noindex exists and matches the default behaviour`, with the real id in the change line and the create line **gone** — 4 changes down to 3 `[measured 2026-09-04, dry run, exit 0, nothing written]`. **No duplicate, no name collision, no error.** ⚠️ **`Comment` IS NOT A RECONCILED FIELD**, so `51c4e79b` keeps the original 118-character text while `configure.mjs` now carries a shorter one. Deliberate: adding `Comment` to the drift check would throw on this very policy and block the run that attaches it. Cosmetic, and it resolves only if the policy is ever recreated. State after both attempts, **verified rather than assumed**: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **one** custom response-headers policy (the orphan above), **zero** custom origin request policies `[verified 2026-09-04 — get-distribution-config, list-response-headers-policies --type custom, list-origin-request-policies --type custom]` — **both** policy lists, because sections 4 and 5 each create one **before** `update-distribution`. `update-distribution` is the script's last call, so a throw above it leaves the **distribution** untouched; it says nothing about the account, and this row cited only one of the two list calls for one round. Exact error, cause and fix: `docs/09` Part 3's incident record. 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 | +| 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. 🛑 **SECTIONS 4 AND 5 ARE CLOSED AS UNAVAILABLE, NOT OUTSTANDING — THE PRICING PLAN FORBIDS BOTH.** Pouya's ruling, 2026-09-04, after the third `--apply`: they are a **platform constraint, not a defect**. ⚠️ **THIS ROW READ "Still to apply, and they are the only two outstanding"**, which sent an operator to retry a thing that cannot succeed. **Pricing plan: Free** `[verified 2026-09-04 — the update-distribution rejection below, which is the only signal AWS gives]`. It excludes, in the API's own words, *"Custom origin request policy, Custom response headers policy"*. ⚠️ **THE PLAN IS NOT IN THE CLOUDFRONT API.** Checked 2026-09-04 across **167 operations**: no operation, shape, member or documentation string mentions a pricing plan. **`PriceClass_All`, which this distribution carries, is the EDGE-LOCATION price class — a different and much older concept. Do not read it as the plan.** `configure.mjs` therefore gates both sections on a constant, `PLAN_ALLOWS_CUSTOM_POLICIES`, and reports them as **PARKED** (not skipped, so the exit status stays 0) rather than failing at the write. **Substitutes, both live:** the `X-Robots-Tag` is replaced by `Disallow: /pouya-lajevardi-bio.pdf` in `public/robots.txt` — which stops the PDF being FETCHED and so solves the duplicate-of-`/bio/` problem, but does **not** de-index the URL, and the PDF is linked from `/bio/` and `/about/`; that residual is accepted. And the viewer-address forwarding is **superseded rather than parked**: a WAF web ACL is already attached to this distribution — **`CreatedByCloudFront-f8fbf256`**, `arn:aws:wafv2:us-east-1:327082975128:global/webacl/CreatedByCloudFront-f8fbf256/0846e5d8-2f18-4f34-ac7d-2d10009e80e3` `[verified 2026-09-04 — get-distribution]` — and that, not a forwarded header, is where any future per-IP rate rule belongs. 🛑 **THREE `--apply` ATTEMPTS WERE MADE ON 2026-09-04 AND ALL THREE FAILED. THE DISTRIBUTION IS UNCHANGED THROUGHOUT.** **Attempt 1** — section 4, `create-response-headers-policy`, `ParamValidation`: the cloned `SecurityHeadersConfig` carried `"ContentSecurityPolicy": {}`. Nothing created. **Attempt 2** — section 4 succeeded and created the policy; section 5 failed at `create-origin-request-policy` with `InvalidArgument`, *"The parameter Comment is too big"* — 182 characters against a 128 cap the service model states only in documentation prose. **Attempt 3** — both policies were created and **`update-distribution` rejected the whole change atomically** on the pricing plan. ⚠️ **EACH ATTEMPT GOT ONE STEP FURTHER AND THE LAST ONE FAILED AT THE LAST CALL**, which is the case the pre-flight exists to prevent; both sections now stop before creating anything. **The two orphaned policies (`51c4e79b-…` and `e88b32be-…`) were deleted by Pouya on 2026-09-04** and the account is clean: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **zero** custom response-headers policies, **zero** custom origin request policies `[verified 2026-09-04 — get-distribution-config, list-response-headers-policies --type custom, list-origin-request-policies --type custom]`. ⚠️ **THE "DO NOT DELETE THE ORPHAN BY HAND" GUIDANCE WAS RIGHT FOR A RECOVERABLE RUN AND IS NOW MOOT**: it assumed a later run would adopt the policy by name, which it did — measured — but a run that can never apply cannot adopt anything. Exact errors, causes and fixes: `docs/09` Part 3's incident record. 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`. 🟢 **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,314,989 bytes**, `CodeSha256` **`oMVpQYFxvjGCA6Wbz6UQGnQlS2QgVndT4BdoiCqsP1o=`**, last modified **2026-09-04T15:53:36Z** `[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`, **`spam-score.mjs`**, `node_modules/` and `package.json`, and **all three source files are byte-identical to commit `3c3ba5d`** — sha1 `12cb09a8…`, `d0c0d15f…` and `5b4f9210…` `[verified 2026-09-04, after the 15:53Z redeploy]`. The archive holds **4,277 zip entries: 3,575 files and 702 directories**, 4,273 of the entries under `node_modules/`. ⚠️ **THIS ROW CALLED 4,277 A FILE COUNT** — that is `unzip -l`'s own footer wording, which prints `4277 files`, and 702 of them are directories. An operator re-deriving it with the obvious instrument, `find -type f`, gets **3,575** and a 702-entry mismatch against this register with no way to tell that from a truncated bundle. **Say what a number is a number OF** (`adversarial-reviewer`; recounted from the zip's central directory). ⚠️ **EVERY FIGURE IN THE TWO SENTENCES ABOVE MOVED WITH THAT REDEPLOY**, and nothing in the repository would have said so — the zip is built from the working tree and uploaded, and no committed file records what is running. The previous staleness of this row lasted **two days**; this one was caught **within the hour**, and the difference was that someone happened to be reading the row. It is what an operator consults before touching the Lambda, so **re-read it from `get-function-configuration` after every Part 5 run** rather than at the next convenient session. ⚠️ **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 | @@ -784,7 +784,7 @@ 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 | +| **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 PREMISE CHANGED ON 2026-09-04 AND THE QUESTION IS NOW CHEAPER THAN IT WAS ASKED.** **A web ACL is already attached to the distribution and already running**: `CreatedByCloudFront-f8fbf256`, `DefaultAction: Allow`, **capacity 925 WCU**, three AWS managed rule groups — `AmazonIpReputationList`, `CommonRuleSet`, `KnownBadInputsRuleSet` — and **no `RateBasedStatement`** `[verified 2026-09-04 — wafv2 get-web-acl]`. So this is **not** "should we buy WAF": the web ACL and its three managed groups are already being paid for, and what is actually being asked is **"should we add one rate-based rule to an ACL that already exists"** — an increment, not a deployment. ⚠️ **THE OLD GROUNDWORK IS GONE, AND ITS REPLACEMENT IS BETTER.** This row said the groundwork was `configure.mjs` section 5 forwarding `CloudFront-Viewer-Address` and that it *"needs `--apply`"*. **Section 5 is parked and unappliable** — the pricing plan forbids a custom origin request policy (§7) — so that route is closed. It is **superseded rather than blocked**: a rate-based WAF rule matches on the viewer's address itself and needs no header forwarded to the origin at all, which is why the forwarding was only ever a means to this end. ⚠️ **`docs/reference/AWS-Hosting-Guide.md` still says *"Web Application Firewall (WAF): Do not enable for now"*** — that is the imported guide and it is out of date against the account; this row is the current truth. **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)* | @@ -957,6 +957,131 @@ never being raised again. # Change Log +## 2026-09-04 (ax) — The third `--apply` reached the last call and was refused by the pricing plan; sections 4 and 5 are closed as unavailable, a robots.txt Disallow stands in for the noindex, and the WAF that was treated as a future purchase turns out to be already running + +**Pouya's ruling of 2026-09-04**, after `update-distribution` rejected the change +atomically: *"both changes are PARKED as unavailable on the current pricing plan +— not a defect, a platform constraint."* + +``` +An error occurred (InvalidArgument) when calling the UpdateDistribution operation: +Distributions with the Free pricing plan can't have the following features: +Custom origin request policy, Custom response headers policy +``` + +--- + +### 1. Three attempts, each one step further, the last failing at the last call + +**Attempt 1** — `ParamValidation` on a cloned `"ContentSecurityPolicy": {}`; +nothing created. **Attempt 2** — section 4 created its policy, section 5 failed +on a 182-character `Comment`; one orphan. **Attempt 3** — both policies created, +`update-distribution` refused the whole change; two orphans. + +⚠️ **THE PRE-FLIGHT BUILT IN (av) COULD NOT HAVE CAUGHT THIS, AND THAT IS THE +LESSON.** Every limit in `PAYLOAD_LIMITS` is a property of the **payload**. This +constraint is a property of the **account**, and it is only ever reported by the +call the pre-flight exists to avoid. **Both sections now stop before creating +anything**, which is the only place a payload-shaped guard could not reach. + +Both orphaned policies (`51c4e79b-…`, `e88b32be-…`) were deleted by Pouya. The +account is clean: **zero** custom response-headers policies, **zero** custom +origin request policies `[verified 2026-09-04]`. + +### 2. The plan is not in the API, so the gate is a constant + +Checked across **167 CloudFront operations**: no operation, shape, member or +documentation string mentions a pricing plan. ⚠️ **`PriceClass_All`, which this +distribution carries, is the EDGE-LOCATION price class — a different and much +older concept, and the obvious thing to mistake for the plan.** The only signal +AWS gives is the rejection itself. + +So `configure.mjs` gates both sections on `PLAN_ALLOWS_CUSTOM_POLICIES` and +reports them as **PARKED**, under their own heading, **not counted as skips**. +⚠️ **THAT DISTINCTION IS THE EXIT STATUS AND IT IS DELIBERATE:** entry (av) added +`exit 3` for a skipped section, and a constraint that is true on every run would +have made `3` permanent — **a signal that is always on is not a signal.** + +**Proven, not asserted:** `--apply` run through a shim that refuses every +mutating verb makes **zero** such calls and exits **0** at `NOTHING TO CHANGE`. +It can no longer create a policy or reach `update-distribution`. + +### 3. Substitute (a) — a Disallow, and it is not an equivalent + +`public/robots.txt` gains `Disallow: /pouya-lajevardi-bio.pdf`, placed **before** +`Allow: /` so first-match crawlers honour it as well as longest-match ones. + +⚠️ **THE FILE PREVIOUSLY SAID "NOTHING IS DISALLOWED, DELIBERATELY" AND CARRIED +THE RULE THIS BREAKS**: *"`noindex` is what de-indexes; `Disallow` is what +prevents fetching. Use the one that matches the problem."* This uses `Disallow` +for a de-indexing problem because the other instrument is gone. **It stops the +PDF being fetched — solving the duplicate-of-`/bio/` problem it was raised for — +and does not de-index the URL**, which is linked from `/bio/` and `/about/`, so a +bare listing stays possible. Accepted deliberately, and recorded in the file, in +`docs/04` §Crawlability and in `docs/06`. + +**Verified:** syntax parses, one group, sitemap absolute; a match simulation puts +the PDF as `disallow` and `/`, `/bio/`, `/about/`, `/legal/privacy/`, `/contact/` +as `allow` **under both first-match and longest-match semantics**; the PDF is in +`dist/`; **the sitemap does not list it**. The deploy syncs `dist` and +invalidates `/*`, so one deploy carries it. + +### 4. Substitute (b) — the WAF is not a future purchase, it is already running + +`CreatedByCloudFront-f8fbf256` is attached to the distribution +`[verified 2026-09-04 — get-distribution]`, and reading it changed a parked +decision: **`DefaultAction: Allow`, capacity 925 WCU, three AWS managed rule +groups** (`AmazonIpReputationList`, `CommonRuleSet`, `KnownBadInputsRuleSet`), +and **no `RateBasedStatement`** `[verified 2026-09-04 — wafv2 get-web-acl]`. + +🛑 **§9 Q65 ASKED THE WRONG QUESTION AND IS CORRECTED.** It framed WAF as *"a +cost decision"* about adding one, and named section 5's header forwarding as the +groundwork that *"needs `--apply`"*. **The web ACL and its three managed groups +are already being paid for**, so the real question is whether to add **one rule +to an ACL that exists** — an increment, not a deployment. And the forwarding is +**superseded rather than blocked**: a rate-based rule matches the viewer address +directly and never needed a header forwarded to the origin. `docs/06`'s per-IP +item carried the same stale framing and is corrected too. + +⚠️ **`docs/reference/AWS-Hosting-Guide.md` still says *"Web Application Firewall +(WAF): Do not enable for now"*.** That is the imported guide, out of date against +the account; §7 and Q65 are the current truth. Left as the sourced artefact it is. + +### 5. Review — my own, both rounds, no separate agent (Pouya's instruction) + +Round 1 (sweep for claims this falsifies) found **four**: the Part 3 verification +block that counts `x-robots-tag` on the live PDF and can now never pass; the +`exit 3` note still describing the parked items as skips; the 8-change `Expect` +block that predates the parking; and `docs/09`'s *"NOT YET APPLIED"* on the +viewer-address change. Round 2 (what round 1's fixes touched) found the Q65 and +`docs/06` premise changes above, and confirmed no stale tallies, no broken table +rows, and that the deploy path carries `robots.txt`. + +**Measured rather than assumed on the live PDF**: `x-robots-tag` **0** and the +other five security headers **1** each — which is exactly what the corrected +runbook now tells the operator to expect, and confirms the five come from the +**default behaviour's** policy and are unaffected by the parking. + +### 6. What is retained and why + +`PAYLOAD_LIMITS` and its 53-case suite stay. They are **dormant, not dead**: the +`function-association` entries still run on every invocation (that is the +`--function-arn` guard), and the policy entries come back the moment +`PLAN_ALLOWS_CUSTOM_POLICIES` is flipped. The same is true of `docs/09`'s +section-4 and section-5 record and its three `303` probes, which are marked moot +while parked rather than deleted — they are what makes un-parking safe. + +**Gates, exit status read for each:** `check` 0 · `build` 0 (23 pages) · +`check:claims` 0 · `check:intake` 0 · `og:proof` 0 · `lint` 0 · `router.test` 0 +(30/30) · `policy-shapes.test` 0 (53/53) · `spam-score.test` 0 (39/39) · minifier +grep 1 (clean) · robots.txt validation 0 · live dry run **0**, 0 changes, 2 +parked, 0 skips. + +**Nothing was applied to the distribution and nothing was deployed.** +`public/robots.txt` needs one site deploy to take effect. + +--- + ## 2026-09-04 (aw) — Round 2's addendum: this machine has two AWS CLIs 23 minor versions apart, the script called whichever `PATH` chose, and it rewrites the whole distribution config **Not a third round.** `adversarial-reviewer` returned an addendum to round 2 diff --git a/docs/04-seo-spec.md b/docs/04-seo-spec.md index 6ed5210..d780672 100644 --- a/docs/04-seo-spec.md +++ b/docs/04-seo-spec.md @@ -153,7 +153,20 @@ here.** This spec used to reproduce the file inline and the reproduction had already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md` §7 rule exists to stop. -**It disallows nothing, and that is deliberate.** This spec previously +**It disallows exactly one path, and everything about that exception is in the +file.** ⚠️ **THIS READ "It disallows nothing, and that is deliberate" UNTIL +2026-09-04.** `Disallow: /pouya-lajevardi-bio.pdf` was added that day as the +stand-in for `X-Robots-Tag: noindex` on `*.pdf`, which needs a CloudFront +response-headers policy the distribution's pricing plan forbids (`AGENTS.md` §7). + +⚠️ **IT IS A SUBSTITUTE, NOT AN EQUIVALENT, AND THE RULE BELOW IS WHY.** It stops +the PDF being *fetched* — so its contents are never indexed and the +duplicate-of-`/bio/` problem is solved — but it does **not** de-index the URL, +and the PDF is linked from `/bio/` and `/about/`, so a bare listing remains +possible. That residual is accepted deliberately. **The rule below is unchanged +and this is its exception, not its repeal.** + +The general rule: this spec previously prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two cancel each other: a crawler forbidden to *fetch* a URL never reads the `noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the diff --git a/docs/06-deployment.md b/docs/06-deployment.md index 98a5aa9..6857d58 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -1229,9 +1229,22 @@ the decision is re-readable rather than re-litigated. 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 +- [x] **`CloudFront-Viewer-Address` forwarded on `/api/*` — CLOSED 2026-09-04 + AS NOT AVAILABLE ON THIS PRICING PLAN, AND SUPERSEDED.** Pouya's ruling + after the third `--apply`: `update-distribution` rejected the change + atomically — *"Distributions with the Free pricing plan can't have the + following features: Custom origin request policy, Custom response headers + policy"* — so this is a **platform constraint, not a defect**. It is + **revisited only if the plan changes**; `configure.mjs` now parks section 5 + instead of attempting it, and `AGENTS.md` §7 records the plan. + 🛑 **SUPERSEDED, NOT MERELY PARKED: a WAF web ACL is already attached to + this distribution (`CreatedByCloudFront-f8fbf256`, §7), and that is where + any future per-IP rate rule belongs** — a forwarded viewer address was only + ever the means to an end this already provides. The original item is kept + below because its reasoning about the whitelist is what makes section 5 + safe to un-park. ⚠️ **WRITTEN + 2026-09-04, NOT APPLIED, AND NOW UNAPPLIABLE. 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 @@ -1250,9 +1263,23 @@ the decision is re-readable rather than re-litigated. 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** — the same - run as the item above; one `--apply` does both. +- [x] **`X-Robots-Tag: noindex` on `*.pdf` — CLOSED 2026-09-04 AS NOT AVAILABLE + ON THIS PRICING PLAN. A SUBSTITUTE SHIPPED IN ITS PLACE.** Same rejection + as the item above: a custom response headers policy is not available on the + Free plan, so this is a **platform constraint, not a defect**, revisited + only if the plan changes. + **The substitute is `Disallow: /pouya-lajevardi-bio.pdf` in + `public/robots.txt`** — it needs a **site deploy**, not a `configure.mjs` + run. ⚠️ **IT IS NOT AN EQUIVALENT AND `public/robots.txt` SAYS SO IN THE + FILE.** `Disallow` stops the PDF being **fetched**, which solves the + duplicate-of-`/bio/` problem this item was raised for; it does **not** + de-index the URL, and the PDF is linked from `/bio/` and `/about/`, so a + bare listing remains possible. That residual is accepted deliberately. + `docs/04` §Crawlability carries the general rule this is the exception to. + The original item follows, because its reasoning is what makes section 4 + safe to un-park. ⚠️ **WRITTEN 2026-09-03, NOT + APPLIED, AND NOW UNAPPLIABLE. It needed a `configure.mjs --apply` run, not + a deploy** — the same run as the item above; one `--apply` did 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 @@ -1468,7 +1495,17 @@ the decision is re-readable rather than re-litigated. that spec line is struck and an aggregate route throttle ships instead (`docs/09` Part 6.3). A rate-based WAF rule on the distribution is what would do per-IP. Decide it on price, not on the spec's old wording — and do - not let anything describe the throttle that ships as per-IP + not let anything describe the throttle that ships as per-IP. + 🛑 **THE PRICE QUESTION IS SMALLER THAN THIS ITEM ASSUMES, MEASURED + 2026-09-04.** A web ACL is **already attached and already running** on the + distribution — `CreatedByCloudFront-f8fbf256`, 925 WCU, three AWS managed + rule groups, and **no rate-based statement** (`AGENTS.md` §7 and §9 Q65). + So this is not "buy WAF"; it is "add one rule to an ACL already being paid + for". ⚠️ **AND THE ROUTE THIS ITEM ASSUMED IS GONE:** the + `CloudFront-Viewer-Address` forwarding was parked as unavailable on the + pricing plan — but a rate-based rule matches on the viewer address itself + and never needed that header, so the capability is **superseded, not + blocked** - [ ] ⚠️ **A FOOTER NAV LABEL OVERRUNS ITS COLUMN BY 24 px AT 640 px UNDER MINIMUM FONT SIZE, WITH 7.7 px OF CLEARANCE TO THE NEXT COLUMN.** No document diff --git a/docs/09-cutover-runbook.md b/docs/09-cutover-runbook.md index 3385dcf..150021f 100644 --- a/docs/09-cutover-runbook.md +++ b/docs/09-cutover-runbook.md @@ -309,7 +309,41 @@ status, not the absence of an error. --- -## Part 3 — Apply the five distribution changes +## Part 3 — Apply the distribution changes (three of five; two are parked) + +🛑 **SECTIONS 4 AND 5 CANNOT BE APPLIED ON THIS DISTRIBUTION AND THE SCRIPT NO +LONGER TRIES.** Pouya's ruling of 2026-09-04, after the third `--apply` reached +`update-distribution` and was rejected atomically: + +``` +An error occurred (InvalidArgument) when calling the UpdateDistribution operation: +Distributions with the Free pricing plan can't have the following features: +Custom origin request policy, Custom response headers policy +``` + +**A platform constraint, not a defect.** Both are closed in `docs/06` and +revisited only if the plan changes. `configure.mjs` gates them on +`PLAN_ALLOWS_CUSTOM_POLICIES` and reports them as **PARKED** — printed under +their own heading, **not counted as skips, and not affecting the exit status**, +because a constraint true on every run is not a signal. + +⚠️ **THE PLAN IS NOT IN THE CLOUDFRONT API, WHICH IS WHY THIS IS A CONSTANT AND +NOT A PROBE.** Checked 2026-09-04 across **167 operations**: no operation, shape, +member or documentation string mentions a pricing plan. **`PriceClass_All`, which +this distribution carries, is the EDGE-LOCATION price class — a different and +much older concept. Do not read it as the plan.** The only signal AWS gives is +the rejection above, which is the thing the pre-flight exists to avoid. + +**What replaces them.** The `X-Robots-Tag` is replaced by +`Disallow: /pouya-lajevardi-bio.pdf` in `public/robots.txt` — **a site deploy, +not a `configure.mjs` run** — which stops the PDF being *fetched* but does not +de-index the URL; the file itself carries that distinction. The viewer-address +forwarding is **superseded**: a WAF web ACL is already attached to this +distribution (`AGENTS.md` §7), and that is where a per-IP rule belongs. + +**Everything below is the record of how sections 4 and 5 were built and why they +failed three times. Keep it: it is what makes them safe to un-park.** Changes 1–3 +are unaffected and still apply. 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. @@ -356,9 +390,14 @@ script manages was applied or is already present. `2` — a usage error, before AWS call. **`3` — sections that could run did, and something was SKIPPED: read the `⚠ … SKIPPED, not changed` block.** Anything else is a throw. `3` exists because a skip used to exit `0`, and this document uses `exit 0` as its own -success stamp throughout — so a partial run read as a complete one. A skip is -never nothing: it means the `*.pdf` behaviour has no `X-Robots-Tag`, or `/api/*` -is not forwarding `CloudFront-Viewer-Address`, and the skip line says which. +success stamp throughout — so a partial run read as a complete one. + +⚠️ **PARKED IS NOT SKIPPED, AND ONLY ONE OF THEM MOVES THE EXIT STATUS.** The two +pricing-plan items print under a `· … PARKED` heading and leave the status at +`0`: they are true on every run, and a signal that is always on is not a signal. +A **skip** is the unexpected kind — a source policy that vanished, a generated +payload that breaches a CloudFront limit, a handler-reads probe that matched +nothing — and the skip line always says which. ⚠️ **THE SHAPE CHANGES WITH THE STATE, SO READ THE `+` LINES AND NOT THE TOTAL.** Each item Parts 1–3 have already applied prints `·` when it is found @@ -366,8 +405,13 @@ and `+` when it is staged, so in the Part 0.3 state four lines cross from one column to the other and the totals move with them. **The `+` lines are the check.** -**Expect** — this is the dry run, and on a distribution in the state Part 0.3 -records the change lines are: +**Expect** — ⚠️ **THE BLOCK BELOW IS THE PRE-PARKING SHAPE AND IS KEPT AS THE +RECORD OF WHAT SECTIONS 4 AND 5 WOULD HAVE ADDED.** On a distribution in the +Part 0.3 state **today** the last four `+` lines do not appear: those are +sections 4 and 5, and both park. Expect **four** `+` lines, the `· … PARKED` +block, and exit 0. On the distribution as it now stands, changes 1–3 are already +applied, so expect **no** `+` lines at all and `NOTHING TO CHANGE`. The change +lines, as they were: ``` resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad @@ -399,9 +443,10 @@ 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, after the second `--apply` attempt, the dry run returns exactly -three `+` lines — **one** for section 4 (the behaviour; its policy already -exists, see the incident below) and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag +2026-09-04, after sections 4 and 5 were parked, the dry run returns **no `+` +lines at all** — changes 1–3 are already applied and 4 and 5 are parked, so it +prints `NOTHING TO CHANGE` and **exit 0**, with a two-line PARKED block above +it** — `[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. @@ -523,10 +568,37 @@ duplicate name returns `ResponseHeadersPolicyAlreadyExists`, which is a different error from the `InvalidArgument` above. The script never reaches it, because it looks the policy up by name first.) -⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` keeps its original -118-character text while the script now carries a shorter one. Deliberate: -adding `Comment` to the drift check would throw on this very policy and block -the run that attaches it. +⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` kept its original +118-character text while the script carries a shorter one. Deliberate: adding +`Comment` to the drift check would have thrown on that policy and blocked the +run that attached it. + +--- + +**ATTEMPT 3 — both policies were created, and `update-distribution` rejected the +whole change atomically.** With the `Comment` shortened, section 5's create +succeeded too, so the run reached the last call and was refused there: + +``` +An error occurred (InvalidArgument) when calling the UpdateDistribution operation: +Distributions with the Free pricing plan can't have the following features: +Custom origin request policy, Custom response headers policy +``` + +**The distribution was unchanged — but two orphaned policies were left**, +`51c4e79b-…` and `e88b32be-…`, both since **deleted by Pouya on 2026-09-04**. The +account is clean: **zero** custom response-headers policies, **zero** custom +origin request policies `[verified 2026-09-04]`. + +⚠️ **THE "DO NOT DELETE THE ORPHAN BY HAND" GUIDANCE ABOVE WAS RIGHT FOR A +RECOVERABLE RUN AND IS NOW MOOT.** It rested on a later run adopting the policy +by name — which it did, measured — but a run that can never apply cannot adopt +anything. Deleting them was correct once the sections were parked. + +⚠️ **EACH ATTEMPT GOT ONE STEP FURTHER AND THE LAST FAILED AT THE LAST CALL.** +That is precisely the case the pre-flight was built to prevent, and it could not: +the constraint is not in the payload, it is on the account. **Both sections now +stop before creating anything at all** — see the top of this Part. **WHY NOTHING CAUGHT IT LOCALLY, AND THIS IS THE GENERAL LESSON.** Measured 2026-09-04 **against `aws-cli/2.34.53`'s bundled `botocore/validate.py`**: it @@ -594,6 +666,12 @@ grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \ matches `function headerOf(event, name)`, the definition itself — so an operator comparing it against a documented "four" concludes the handler grew a read. +🛑 **THE THREE PROBES BELOW ARE MOOT WHILE SECTION 5 IS PARKED** — nothing +replaces the origin request policy on `/api/*`, so there is nothing for them to +catch. **They become mandatory again the moment `PLAN_ALLOWS_CUSTOM_POLICIES` is +flipped**, which is why they stay here rather than being deleted. Part 7.1's +probe is unaffected and still applies. + **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 @@ -744,6 +822,15 @@ aws cloudfront get-distribution-config --id "$DIST_ID" \ `b689b0a8-53d0-40ab-baf2-68738e2966ac`, which is the managed policy it replaced and is what a rollback restores. +🛑 **THE HEADER CHECK BELOW CANNOT PASS WHILE SECTION 4 IS PARKED, AND THAT IS +NOT A REGRESSION.** No response-headers policy is attached to `*.pdf`, so +`x-robots-tag` will read `0` — the substitute is `Disallow:` in +`public/robots.txt`, verified by fetching `/robots.txt`, not by fetching the PDF. +⚠️ **THE OTHER FIVE STILL MATTER AND SHOULD STILL READ `1`**: they come from the +**default behaviour's** policy, which is untouched, so a `0` among them is a real +regression and nothing to do with the parking. Run it that way — five `1`s and a +`0` — or skip it until the sections are un-parked. + **Then verify the header actually arrives, because the config landing is not the same fact:** @@ -1165,8 +1252,13 @@ 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 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 +at API Gateway. 🛑 **THAT CHANGE IS PARKED AND WILL NOT BE APPLIED — the pricing plan forbids a +custom origin request policy (Part 3, and `AGENTS.md` §7).** So the handler +stores the edge address and will keep doing so. **The capability it was for is +superseded, not lost**: a rate-based rule on the web ACL already attached to this +distribution matches the viewer address directly and needs no forwarded header +(§9 Q65). ⚠️ **Read the rest of this paragraph as the reasoning that makes the +change safe to un-park, not as a pending action.** 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 diff --git a/infra/cloudfront/configure.mjs b/infra/cloudfront/configure.mjs index de9f15c..3f98e8c 100644 --- a/infra/cloudfront/configure.mjs +++ b/infra/cloudfront/configure.mjs @@ -1,6 +1,9 @@ /** - * Applies the five distribution changes the site needs, as one reviewable - * transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it. + * Applies the distribution changes the site needs, as one reviewable + * transaction. ⚠️ **THREE OF THE FIVE ARE APPLICABLE; 4 AND 5 ARE PARKED** — + * the pricing plan forbids both a custom response headers policy and a custom + * origin request policy, so they are reported and skipped rather than attempted. + * See `PLAN_ALLOWS_CUSTOM_POLICIES` below and `AGENTS.md` §7. `docs/09-cutover-runbook.md` Part 3 is what calls it. * * 1. FunctionAssociations on the default behaviour -> `router.js`, viewer * request. Without it 22 of 23 pages return S3's AccessDenied XML. @@ -494,7 +497,42 @@ function findPdfPolicy() { would both miscount and send an `update-distribution` carrying a config nothing mutated. Skips get their own list and their own heading. */ const skipped = []; -if (!defaultRhpId) { +/* ⚠️ PARKED, NOT SKIPPED, AND THE DISTINCTION IS THE EXIT STATUS. A skip means + something unexpected happened and someone should look; these two are a + standing platform constraint that will be true on every run until the pricing + plan changes. Counting them as skips would make `exit 3` permanent, and a + signal that is always on is not a signal. */ +const parked = []; + +/** + * 🛑 **SECTIONS 4 AND 5 CANNOT BE APPLIED ON THIS DISTRIBUTION'S PRICING PLAN.** + * The third `--apply` of 2026-09-04 reached `update-distribution` and was + * rejected atomically: + * + * Distributions with the Free pricing plan can't have the following features: + * Custom origin request policy, Custom response headers policy + * + * ⚠️ **AND IT FAILED AT THE LAST CALL, AFTER BOTH POLICIES HAD BEEN CREATED** — + * the whole point of the pre-flight is to fail before that, so both sections now + * stop here instead. `AGENTS.md` §7 records the plan; `docs/06` closes both + * items; `docs/09` Part 3 has all three attempts. + * + * ⚠️ **THIS IS A CONSTANT AND NOT A PROBE, BECAUSE THE PRICING PLAN IS NOT IN + * THE API.** Checked 2026-09-04 against the CloudFront model: 167 operations, + * and not one shape, member or documentation string mentions a pricing plan. + * `PriceClass_All` on this distribution is the EDGE-LOCATION price class, a + * different and much older concept — do not read it as the plan. The only + * signal AWS gives is the `update-distribution` rejection itself, which is the + * thing this exists to avoid. So: flip this to `true` when the plan changes, + * and the two sections come back exactly as they were. + */ +const PLAN_ALLOWS_CUSTOM_POLICIES = false; + +if (!PLAN_ALLOWS_CUSTOM_POLICIES) { + parked.push( + `${PDF_PATTERN} / ${PDF_POLICY_NAME} — a custom response headers policy is not available on this distribution's pricing plan. The stand-in is \`Disallow: /pouya-lajevardi-bio.pdf\` in public/robots.txt`, + ); +} else if (!defaultRhpId) { skipped.push( `${PDF_PATTERN} / ${PDF_POLICY_NAME} — the default behaviour has no ResponseHeadersPolicyId, so there is nothing to clone the security headers from`, ); @@ -952,7 +990,11 @@ const apiBehaviour = (cfg.CacheBehaviors?.Items ?? []).find( (b) => b.PathPattern === PATH_PATTERN, ); -if (!apiBehaviour) { +if (!PLAN_ALLOWS_CUSTOM_POLICIES) { + parked.push( + `${PATH_PATTERN} / ${ORP_NAME} — a custom origin request policy is not available on this distribution's pricing plan. Superseded rather than merely parked: the WAF web ACL already attached to this distribution is where a per-IP rule belongs (AGENTS.md §7)`, + ); +} else 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. */ @@ -1160,6 +1202,18 @@ console.log(''); comment on `skipped`. A skip means section 4 did nothing and the PDF is probably not noindexed; that is louder than a silent omission and quieter than a false change. */ +if (parked.length) { + console.log( + `· ${parked.length} thing(s) PARKED — not available on this distribution's pricing plan:`, + ); + for (const k of parked) console.log(` – ${k}`); + console.log( + ' This is expected and does not affect the exit status. AGENTS.md §7', + ); + console.log(' records the plan; flip PLAN_ALLOWS_CUSTOM_POLICIES if it'); + console.log(' changes. Sections 1-3 are unaffected.'); + console.log(''); +} if (skipped.length) { console.log(`⚠ ${skipped.length} thing(s) SKIPPED, not changed:`); for (const k of skipped) console.log(` ! ${k}`); @@ -1182,8 +1236,10 @@ const exitCode = skipped.length ? EXIT_SKIPPED : 0; if (changes.length === 0) { console.log( skipped.length - ? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.' - : 'NOTHING TO CHANGE — the distribution already carries all five.', + ? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry everything this script manages.' + : parked.length + ? 'NOTHING TO CHANGE — the distribution carries everything this script can apply on the current pricing plan. The parked items above are not among them.' + : 'NOTHING TO CHANGE — the distribution already carries all five.', ); process.exit(exitCode); } diff --git a/public/robots.txt b/public/robots.txt index bfc6c29..186329c 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -2,18 +2,30 @@ # AI crawlers are deliberately allowed. Being read by an assistant that counsel # is using to shortlist a neutral is the point. See docs/04-seo-spec.md. -# NOTHING IS DISALLOWED, DELIBERATELY. -# /legal/* is kept out of the index by -# ``, which is the directive that -# actually de-indexes. Disallowing them as well would defeat it: a crawler that -# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are -# linked from the footer of every page, so Google discovers them regardless and -# would have listed the bare URLs as "no information available" — the opposite -# of the intent — with the noindex sitting unread behind the wall. -# Add a Disallow only for something that must not be FETCHED. Use noindex for -# something that must not be LISTED. They are different problems. +# EXACTLY ONE THING IS DISALLOWED, AND IT IS A SUBSTITUTE FOR A MECHANISM THIS +# DISTRIBUTION IS NOT ALLOWED TO HAVE. +# The bio PDF duplicates /bio/. The intended fix was `X-Robots-Tag: noindex` on +# *.pdf via a CloudFront response-headers policy, which the distribution's +# pricing plan forbids — AGENTS.md §7, and docs/09 Part 3 for the three failed +# attempts. Disallow is the remaining lever and it is NOT the same instrument: +# - it stops the PDF being FETCHED, so its contents are never indexed and the +# duplicate-content problem it was raised for is solved; +# - it does NOT de-index the URL. The PDF is linked from /bio/ and /about/, so +# a search engine can still list the bare URL with no snippet. +# That residual is accepted deliberately: a bare link to a bio PDF is not the +# harm the noindex was for. Revisit if the pricing plan ever changes. +# +# /legal/* is still NOT disallowed, and that reasoning is unchanged — it is the +# general rule this one path is the exception to. A crawler forbidden to FETCH a +# URL never reads the `noindex` on it, so the two cancel; the legal pages are +# linked from every footer, so they are discovered regardless, and the pair would +# have produced bare "no information available" listings with the directive that +# would have suppressed them sitting unread behind the wall. +# `noindex` is what de-indexes; `Disallow` is what prevents fetching. Use the one +# that matches the problem, and never both on the same path. User-agent: * +Disallow: /pouya-lajevardi-bio.pdf Allow: / Sitemap: https://adr.smlcompany.ca/sitemap-index.xml