diff --git a/AGENTS.md b/AGENTS.md index 9472bf1..567a48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -725,11 +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`. 🟢 **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 | +| 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. ⚠️ **AN `--apply` WAS ATTEMPTED ON 2026-09-04 AND FAILED ON ITS FIRST WRITE — so read "still to apply" as "attempted and rejected", not "never tried".** The state recorded above is unchanged and was **verified rather than assumed** after the failure: `/api/*` still on `b689b0a8-…`, **no** `*.pdf` behaviour, **zero** custom response-headers policies and **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` and a throw between them can orphan a policy. `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 | | 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,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 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 | +| **Intake Lambda — the two bundled SDK pins** | **`@aws-sdk/client-dynamodb@3.1126.0`** and **`@aws-sdk/client-sesv2@3.1126.0`**, read out of the deployed zip `[verified 2026-09-04, after the 15:53Z redeploy]`. 🛑 **THEY MOVED FROM `3.1125.0` ON THE SAME DAY THIS ROW FIRST RECORDED THEM, AND NOBODY CHOSE IT.** The row's own closing sentence predicted precisely this — *"the next handler deploy moves them one patch without anyone choosing to"* — and the §5.5 redeploy did it within hours. **The prediction is now a measurement, which is the argument FOR this row rather than against it:** two production dependencies changed version, no file in this repository changed, and nothing but this row would have said so. ⚠️ **`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 covers them is §12 R22**, added 2026-09-04 — this row held that claim for itself while no such reminder existed, which is the Q22 shape (a control that is documented and absent). §7 holds the values; R22 holds the obligation to re-read them. 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]`, which is exactly what the bundle now carries, **because** the redeploy resolved them afresh. Registry and running agree today; they diverge again at the next publish upstream, with no diff anywhere to show it. 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.** | @@ -949,6 +949,7 @@ never being raised again. | R19 | ⚠️ **DYNAMODB TTL BACKS A PUBLISHED PRIVACY PROMISE AND `/legal/privacy/` ASSERTS THE MECHANISM, NOT JUST THE PERIOD.** **§7 records the status and its stamp; this row deliberately does not restate it** — one place for a service status, or the copy that goes stale is the one nobody re-reads. **THE TRIGGER, and its two halves are not interchangeable: re-run `describe-time-to-live` and confirm `ENABLED`, THEN write a record with a near-future `ttl` and confirm it actually disappears.** `ENABLED` proves the setting; only the test record proves the behaviour. Writing the attribute proves neither — the handler's side is verified and is not what this row is about. Both halves are on `docs/06`'s cutover checklist and the question is §9 Q60. Re-stamp §7 the same day, **and when you do, sweep for the copies: this fact reached five files outside §7 in one change set and had to be pulled back.** Close this row only when the test record has been observed to vanish | 2026-08-31 | **This is R9's exact shape at higher stakes.** R9 exists because the SES alarms are configured and notify nobody until one subscription is confirmed — a control that reads as covered and is not. Here the control backs a **statement to the public on a privacy policy**, which is the one class of claim this project treats as unrecoverable, and the failure is silent in both directions: nothing in the repo, the build or AWS reports that records are accumulating forever. A cutover checklist fires once; §12 is read aloud every session | | R20 | ⚠️ **THE SEVENTH NAV ITEM ARMS TWO MEASURED HEADER DEFECTS, AND ITS TRIGGER IS A CONTENT EVENT RATHER THAN A DATE.** `SiteHeader` computes `showInsights` from the collection — Insights joins the masthead **automatically at two published articles** — so nothing in the build, the specs or a person's memory stands between publishing article #2 and arming both of these. With seven items **and fallback font metrics** (what a reader on `docs/04`'s Slow 4G profile sees during the `font-display: swap` window, at the DEFAULT text size, no reader setting involved) the header measures **141 px across a contiguous 1056–1091 px band** instead of 81 px: **(a)** it then collapses **60 px** when Geist swaps in, on all 22 pages, against the CLS < 0.05 budget; and **(b)** 141 px exceeds the 97 px `scroll-padding-top`, so "Skip to content" lands with **44 px of `#main` behind the sticky header** — and (b) is **new as of 2026-09-01**, the previous build's 86.97 px stayed under 97 px and covered 0. ⚠️ **HARDENED FROM A TRIGGER INTO A GATE — Pouya's ruling, 2026-09-01: NO SEVENTH NAV ITEM SHIPS UNTIL THE FALLBACK-METRICS DEFECT IS FIXED.** So fixing it is a **prerequisite of publishing the second Insights article**, not a follow-up to it, and *"font metric overrides on the fallback face or equivalent — to be designed then, not now"*. ⚠️ **AND THE GATE IS A BUILD FAILURE, NOT A CROSS-REFERENCE — corrected 2026-09-01 by `adversarial-reviewer`, round 2, in the same session that wrote the weaker version.** It was first implemented as three prose pointers, justified with the claim that the comment on `showInsights` in `SiteHeader.astro` is *"the only one of the three a person editing an article's front matter is likely to be reading"*. **That was backwards**: someone editing `src/content/insights/*.mdx` has no reason to open a header component. And it did not gate: with two articles flipped to `draft: false`, `npm run build` succeeded and `check`, `check:claims`, `og:proof`, `check:intake` and `lint` all exited 0 while both defects shipped. **`SiteHeader.astro` now THROWS when `published.length >= 2`**, with the measurements and the instruction in the message; it fires on both deploy paths, on the machine of whoever publishes. **Proven, not assumed:** two articles were temporarily published, `npm run build` exited **1** naming R20, and the files were restored and the restoration verified by `git diff --exit-code` plus an unchanged `dist` digest. The prose pointers remain — `docs/06`'s `/insights/` state item and its seventh-nav-item item under **Technical**, the latter deliberately unticked and marked NOT a cutover blocker — but they document the gate rather than being it. This project already knew the remedy: `content.config.ts` refuses `draft: false` without `reviewedByPouya: true` rather than trusting a comment, and `check:intake`/`og:proof` exist because a duplicated fact needs a mechanism. *Previous wording follows, and it was too weak: it asked for a re-measurement and a ruling at publication time, which leaves the defect shipping if the person publishing does not read this file.* **THE TRIGGER: before publishing the second Insights article, re-measure the masthead with seven items under blocked webfonts, and rule.** The two candidate fixes are raising the desktop breakpoint past 1091 px — which changes the normal-settings layout in that band — or giving Geist a metric-matched `size-adjust` fallback; both are outside the scope the header step was given, and both close (a) and (b) together. **Why this is a §12 row and not only a `docs/06` line:** a cutover checklist fires once, at cutover, and this arms itself later, on an editorial decision taken by someone who will not be reading the deployment spec. `docs/02` §Reflow carries the measurements | 2026-09-01 | It is latent today and invisible from inside the repo: six nav items never wrap, so every check passes, and the defect appears the day a second article ships. That is R13's shape — a temporary state that becomes permanent because nothing prompts anyone — with the added twist that the prompt would have to fire on a content event. Raised by `adversarial-reviewer`, round 2 | | R21 | 🛑 **`/legal/privacy/` PUBLISHES CLAIMS ABOUT SYSTEMS OUTSIDE THIS REPOSITORY, AND NOTHING IN AWS, GOOGLE WORKSPACE, THE BUILD OR THIS REPO REPORTS WHEN ANY OF THEM CHANGES.** ⚠️ **RE-SCOPED AGAIN 2026-09-02 BY POUYA'S MECHANICS RULING — AND READ THE NEXT SENTENCE BEFORE TREATING THAT AS RELIEF. THE LIVE CLAIMS ARE NAMED, NOT COUNTED**: the headline of this row said FOUR over a list of five, then TWO over a list of three, in successive versions of the row whose whole subject is a fact going stale unnoticed (`adversarial-reviewer`, rounds 1 and 2). **What `/legal/privacy/` publishes now, in full:** (1) that the record in the table can be read by Pouya and the small number of people who administer the account it sits in with him; (2) that the receiving system can only add a record and cannot read the table back — **(ii)**'s first half; (3) that `info@smlcompany.ca` is read by Pouya and administrative staff — **(iv)**; and (4) that the table sits in an Amazon Web Services account that also runs systems unrelated to this practice, in §Where it is stored. **Deleted from the page and published nowhere now: (iii) root custody, (v) the single-sign-on and resource-policy findings, (ii)'s second half — the deploy credential's lack of access — and (i)'s recital of the method.** ⚠️ **THE FALSIFIERS BELOW ARE UNCHANGED AND SO IS THE TRIGGER.** Every deleted sentence is still asserted by **§7**, still cited by `docs/06`, and still what `docs/reference/intake-table-access-verification.md` exists to prove. **A claim moved off a public page into a register is still a claim — and it is one fewer reader likely to notice it going stale.** What genuinely improved: several falsifiers can now only make a *record* false rather than a public page. Original scoping follows, kept in full because §7 still asserts every one of these. ⚠️ **RE-SCOPED 2026-09-02 BY THE Q63 RULING, AND THE HEADLINE CLAIM IT WAS WRITTEN FOR IS GONE:** the page no longer publishes a **count of people** — Pouya ruled that a simulation counts identities and not humans, so *"two people can"* came off and *"the account's administrators — me, and the small number of people who administer it with me"* went on. **That is a weaker claim and therefore a more durable one: adding an administrator no longer falsifies the page.** The five claims as scoped before the mechanics cut: **(i)** that every user and every role was enumerated and only administrative identities can read the table; **(ii)** that the writing function cannot read it back and the deploy credential has no access at all; **(iii)** that the account's root credential has no programmatic key and Pouya holds it — ⚠️ **held, not held EXCLUSIVELY: nothing measured or attested rules out a second holder, and §9 Q64 is the one line that would settle it**; **(iv)** that `info@smlcompany.ca` is read by Pouya and administrative staff; **(v)** that the table carries no resource-based policy of its own and the account has no single sign-on or federated login. **(iv) is an ATTESTATION and nothing in this repo can check it** — the other three are re-runnable. **Attach a DynamoDB policy to any role, grant the deploy or Lambda role a read, **put a resource-based policy on the table itself** (invisible to `describe-table` and to every principal simulation — it grants from the other side, and it is claim (v) below), **join the account to an AWS Organization** (which would make the Identity Center zero local rather than conclusive), create a root access key, move root custody away from Pouya, delegate the mailbox more widely, or resolve Q23's Gitea dependency by changing `lars`'s access — and the privacy policy becomes false with every check still green.** Original text follows. **THE PAGE SAID "TWO", MEASURED (§7).** **Add a third administrator, remove `lars` from `admins`, attach a DynamoDB policy to any of the 33 roles, **hand the root credential to a third person**, or resolve Q23's Gitea dependency by changing his access — and the privacy policy becomes false with every check still green.** `check:claims`'s `sole-administrator-q62` pattern does **not** cover this: it bars the OLD false shape from returning and is blind to the world moving under the NEW sentence. That asymmetry is the whole reason this row exists. **THE TRIGGER, and it is the same event trigger R10 and R18 use: re-run the verification in `docs/reference/intake-table-access-verification.md` before any cutover, and before any republish that turns on it. Then re-stamp §7 the same day.** The counts are the assertion — seven decisions per role call, six per CDK-path call, two per-resource decisions per assume call — because a call that silently received one bogus action name answers `implicitDeny` and reads exactly like a clean row, which is how the first run of that sweep produced 22 uniformly clean rows and no measurement at all. **Removal is the live direction:** Q23 records the Gitea instance as jointly administered and blocked on its second administrator, so `lars`'s access is plausibly load-bearing elsewhere — and if it goes, `check-claims.mjs`'s `rule:` line carries the instruction (rewrite the page, then narrow the pattern deliberately). Raised by `adversarial-reviewer`, D20 cutover pass round 1, 2026-09-02: R9's and R19's shape at R19's stakes — a statement to the public on a privacy policy, backed by a fact with no owner. | 2026-09-02 | The 2026-09-01 verification is a photograph of a shared AWS account that two people administer and that runs four other projects. A stamp with no trigger behind it goes stale silently, and §4 already records OCNI as the precedent for exactly that | +| R22 | ⚠️ **THE TWO BUNDLED `@aws-sdk` CLIENTS ARE PRODUCTION DEPENDENCIES THAT NO PIN IN THIS REPOSITORY CONSTRAINS, AND NOTHING BUT A DELIBERATE RE-READ REPORTS WHEN THEY MOVE.** `docs/09` §5.5 resolves `@aws-sdk/client-dynamodb` and `@aws-sdk/client-sesv2` from the registry **at package time**, so every handler deploy takes whatever is current. They are **not** in `package.json` and never will be — so **R11's phase-boundary check does not reach them**, because R11's own instruction is scoped to *"every pin in `package.json`"*. **This row is that extension: at every R11 phase boundary, and after every `docs/09` Part 5 run, read the two versions out of the deployed zip and re-stamp §7.** ⚠️ **§7's bundled-SDK row asserted that a reminder covered them while no such reminder existed** — the Q22 shape, a documented control that is not there. Raised by `adversarial-reviewer`, round 2, 2026-09-04. §7 holds the current values; this row holds the obligation to re-read them | 2026-09-04 | Measured: they moved **`3.1125.0` → `3.1126.0`** on 2026-09-04, hours after §7 first recorded them, because a redeploy re-resolved them. **No file in this repository changed**, so no diff, no check and no build could have said so. A dependency that can move without a diff has no other tripwire | | ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — | | ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — | @@ -956,6 +957,157 @@ never being raised again. # Change Log +## 2026-09-04 (au) — The first `--apply` failed on its first write because a policy AWS handed back is not a policy AWS accepts; the fix was wrong twice before it was right; and two production dependencies moved with no diff anywhere to show it + +**Pouya's ruling of 2026-09-04**, after `configure.mjs --apply` failed: *"Fix §4's +clone to OMIT any SecurityHeadersConfig member the source does not define — never +emit an empty object — and add a dry-run assertion that the generated policy JSON +contains no empty sub-objects, so the dry run catches this class before an +--apply does. Record the incident in docs/09 Part 3 with the exact error. Also: +the handler was redeployed 2026-09-04 via 5.5 — re-read the two @aws-sdk pins in +the bundle and update §7 if they moved from 3.1125.0. No other changes."* + +--- + +### 1. What failed, and the state it left + +`create-response-headers-policy` was rejected **client-side**, before the call +left the machine: + +``` +An error occurred (ParamValidation): Parameter validation failed: +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override" +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy" +``` + +**The cause generalises past this script: a config AWS hands back is not +necessarily a config AWS will accept.** `get-response-headers-policy` on +`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` for a +member it does not define. Reproduced offline with the endpoint pointed at a +dead local socket, so no call could reach AWS. + +**Nothing had reached the distribution, and that was verified rather than +assumed** `[2026-09-04]`: `/api/*` still on `b689b0a8-…`, no `*.pdf` behaviour, +**zero** custom response-headers policies, **zero** custom origin request +policies. + +### 2. The rule was derived from the API model, and the first two attempts at it were wrong + +Walking every structure reachable from `ResponseHeadersPolicyConfig` in the CLI's +own service model: **15 of 16 declare at least one required field**, so `{}` is +not a legal value there and can only be the placeholder. The **one** exception is +`SecurityHeadersConfig` itself — which section 4 already skips on when empty, +because a PDF policy cloning no security headers is what that section exists to +prevent. + +**Attempt 1 stripped only `SecurityHeadersConfig`**, on the stated ground that the +reasoning had been checked for one shape only. The four sibling members were +still cloned by a truthiness test, and `{}` is truthy — so the same placeholder +one level up became a **hard abort** instead of an omission. **Attempt 2 stripped +two levels** and aborted on the third (`CorsConfig.AccessControlExposeHeaders`). +Both found by `adversarial-reviewer` by fault-injecting the AWS responses, not by +reading. **The strip is now recursive**, which the measurement above licenses at +every depth. + +⚠️ **AND THE ASSERTION IS A SKIP, NOT A THROW — it was a throw for two rounds.** +An empty object is section 4's problem alone, and this file's contract is that +section 4 never blocks sections 1–3 from re-applying `router.js`, without which +22 of 23 pages return S3's `AccessDenied`. Measured with the strip broken: the run +**exits 0**, section 4 skips by name, and **section 5 still produces its two +changes**. + +### 3. The two functions moved to their own module so the claim about them is checkable + +`infra/cloudfront/policy-shapes.mjs` + `policy-shapes.test.mjs` — **23 cases, 7 of +7 mutations killed**. `configure.mjs` reads argv and calls AWS at import time, so +the functions could not otherwise be reached; this is `fields.mjs`'s precedent +beside `handler.mjs`, and the same reason. **R14**: the runbook previously said +*"proven both ways"* about a session nobody could re-enter, and one half of that +proof required editing the script on a machine holding live credentials. It now +cites `node infra/cloudfront/policy-shapes.test.mjs`. Its first case is the +incident verbatim. + +### 4. Records + +- **`docs/09` Part 3** — the incident, the exact error, the post-failure state, + and the orphan check. ⚠️ **The "one reviewable transaction" property is about + the DISTRIBUTION, not the account**, and this record asserted the wider version + for one round: sections 4 and 5 each `create-*` **before** `update-distribution`, + and the script's own comment documents a path where section 4's policy survives + section 5's abort. Both policy lists are now run and both results reported. +- **`docs/09` Part 3's Expect block** said the output was *"exactly"* 14 lines. It + is **60**, and the correction itself then quantified the gap at one line and + enumerated only 56 of the 60. All 60 now account for, with the `·`/`+` split + attributed to the state it was measured in. +- **`docs/09` §5.5, `docs/05` §34 and `docs/06`** each carried their own copy of + the deployed commit and file count. §5.5's opening paragraph argues a literal + there *"would be stale the week after it was written"*; **it went stale in two + days**. `docs/05` named the commit in the same sentence as *"§7 holds the full + state and this spec does not restate it"*. All three now cite §7. + +### 5. §7, re-read against production + +- **Intake Lambda** — code **3,314,989** bytes (was 3,307,021), `CodeSha256` + `oMVpQYFxvjGCA6Wbz6UQGnQlS2QgVndT4BdoiCqsP1o=`, modified **2026-09-04T15:53:36Z**, + and all **three** source files (`spam-score.mjs` now among them) byte-identical + to commit **`3c3ba5d`**. ⚠️ **The row said "4,277 files"** — that is `unzip -l`'s + own footer wording, and **702 of them are directories**. The file count is + **3,575**. An operator re-deriving it with `find -type f` would have hit a + 702-entry mismatch and no way to tell that from a truncated bundle. +- **The two bundled SDK pins moved `3.1125.0` → `3.1126.0`**, hours after the row + that first recorded them predicted exactly that. **The prediction is now a + measurement**: two production dependencies changed version and no file in this + repository changed. +- **§12 R22** — because that row named itself as the reminder covering them while + **no such reminder existed** (the Q22 shape). R11 is scoped to `package.json` + and these two are deliberately not in it. R22 is that extension. + +### 6. Review — two rounds, and the cap held + +`adversarial-reviewer` only; **`claims-auditor` did not run (D20)**, and this +change set reaches no public copy. **Round 1: 7 findings. Round 2: 8** — five of +the eight were defects in round 1's own repairs, which is the measurement D19's +cap rests on. All 15 fixed, none declined. **No round 3.** + +### 7. Not done, and flagged to Pouya rather than built + +**`configure.mjs` validates `--api-domain` but not `--function-arn`.** Found by +tripping it: an ad-hoc `list-functions` query returned the ARN **tab-doubled** +(the function exists in two stages), and the dry run duly staged replacing the +working router association with the doubled value — which on an `--apply` takes +22 of 23 pages down. **The runbook is not exposed** — Part 2 derives it with +`describe-function --stage LIVE`, which returns one — so this was a broken +instrument, not a repo defect. Six lines mirroring the existing guard would close +it; not built, because the ruling said *"no other changes"*. + +### 8. Sweeps, with the commands + +``` +$ grep -rn "3\.1125\.0" --include='*.md' --include='*.mjs' . | grep -v node_modules | grep -v '^./dist/' +AGENTS.md:732 (the new row, quoting the superseded value deliberately) +AGENTS.md:1188 (Change Log entry (at) — never edited) + +$ grep -rn "02739ad" --include='*.md' --include='*.mjs' . | grep -v node_modules | grep -v '^./dist/' +docs/05-backend-spec.md:36 (the correction notice, quoting the old value) +docs/06-deployment.md:797 (the correction notice, quoting the old value) +``` + +Both live restatements are gone; what remains quotes the superseded value inside +a notice saying it was superseded. **All AGENTS.md edits are in Current Truth** +— §7 rows 728 and 731, §12 row 952 — and **none is in the Change Log**, asserted +against the `# Change Log` line number rather than by eye. + +**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 (23/23) · `spam-score.test` 0 +(39/39) · minifier grep 1 (clean) · live dry run 0 (60 lines, 4 changes, 0 skips, +nothing written). **`lighthouse` not run** — this change set touches no file under +`src/` or `dist/`. + +**Nothing was applied to the distribution and nothing was deployed.** + +--- + ## 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 diff --git a/docs/05-backend-spec.md b/docs/05-backend-spec.md index b78aea8..24cd74e 100644 --- a/docs/05-backend-spec.md +++ b/docs/05-backend-spec.md @@ -31,9 +31,12 @@ behaviour the form posts to does not exist"* — true when written under D11, fa 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 +variables and its deployed source entries match a commit **§7 names**; 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, +not restate it. ⚠️ **THIS SENTENCE NAMED THE COMMIT — `02739ad` — IN THE SAME +BREATH AS DISCLAIMING RESTATEMENT, AND THE 2026-09-04 REDEPLOY MADE IT FALSE** +(three source entries now, matching a later commit). The count is gone with it: +both were facts §7 owns. `/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 diff --git a/docs/06-deployment.md b/docs/06-deployment.md index 4ba1170..98a5aa9 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -792,8 +792,11 @@ the decision is re-readable rather than re-litigated. 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 + were read: `handler.handler`, six environment variables, and source files + byte-identical to the commit **§7 records**. ⚠️ **THIS SAID "both source + files … `02739ad`" AND THE REDEPLOY LATER THAT DAY MADE BOTH HALVES + FALSE** — three files now, and a later commit. A count and a commit are + §7's to hold; this line cites it. 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. diff --git a/docs/09-cutover-runbook.md b/docs/09-cutover-runbook.md index c1f01c4..4af7961 100644 --- a/docs/09-cutover-runbook.md +++ b/docs/09-cutover-runbook.md @@ -321,8 +321,24 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN" --function-arn "$ROUTER_ARN" ``` -**Expect** — this is the dry run, and the output on a distribution in the state -Part 0.3 records is exactly: +⚠️ **THE BLOCK BELOW IS THE `+` CHANGE LINES AND THE TWO RESOLVED-POLICY LINES. +IT IS NOT THE WHOLE OUTPUT, AND IT SAID "exactly" UNTIL 2026-09-04.** Against +the **live** distribution the dry run is **60 lines**, and all 60 account for: +2 resolved-policy lines, **10** `·` lines, **4** `+` lines, a **40-line JSON +dump** of the `*.pdf` behaviour it would add, 2 blank lines, the +`N change(s) to distribution …` header and the `DRY RUN — nothing was sent.` +footer `[measured 2026-09-04, exit 0, nothing written]`. + +⚠️ **THAT 10/4 SPLIT IS THE LIVE STATE, NOT THE STATE THE BLOCK BELOW +DESCRIBES.** Each of the four things Parts 1–3 have already applied prints `·` +when it is found and `+` when it is staged, so in the Part 0.3 state those four +move across: **6** `·` and **8** `+`, same 60 lines. **The total is not the +check — the `+` lines are.** An operator told the output is "exactly" 14 lines +either stops on a wall of unexplained text or stops comparing, and the paragraph +after the block tells them a different set means *"stop and re-read it"*. + +**Expect** — this is the dry run, and on a distribution in the state Part 0.3 +records the change lines are: ``` resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad @@ -359,6 +375,96 @@ and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98 `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. +🛑 **INCIDENT, 2026-09-04 — THE FIRST `--apply` FAILED ON ITS FIRST WRITE, AND +NOTHING REACHED THE DISTRIBUTION.** Section 4's clone was sent to +`create-response-headers-policy` verbatim and the AWS CLI rejected it +**client-side**, before the call left the machine: + +``` +An error occurred (ParamValidation): Parameter validation failed: +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override" +Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy" +``` + +**The cause, and it generalises past this script: a config AWS hands back is not +necessarily a config AWS will accept.** `get-response-headers-policy` on +`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` — an empty +object standing for a member the policy does not define — and +`ResponseHeadersPolicySecurityHeadersConfig` has **no required members** while +**every one of its six members requires at least `Override`**. So an empty member +is always "undefined here" and is **never** a legal input. + +⚠️ **AND THE SAME IS TRUE ONE LEVEL UP, WHICH THE FIRST FIX MISSED.** Every +sibling member of `ResponseHeadersPolicyConfig` also declares required fields — +`CorsConfig` five of them, `RemoveHeadersConfig` and `CustomHeadersConfig` a +`Quantity`, `ServerTimingHeadersConfig` an `Enabled` — while the container +itself requires only `Name`. So `{}` is a placeholder at **both** levels, and a +fix covering only the inner one turns the outer placeholder into a hard abort +instead of an omission. All of that is read out of the CLI's own service model, +not inferred from the symptom. + +**State after the failure** ``[verified 2026-09-04 — `get-distribution-config`, `list-response-headers-policies --type custom`, `list-origin-request-policies --type custom`]``**:** `/api/*` still on +`b689b0a8-53d0-40ab-baf2-68738e2966ac`, **no** `*.pdf` behaviour, **zero** custom +response-headers policies, **zero** custom origin request policies. + +⚠️ **THE "ONE REVIEWABLE TRANSACTION" PROPERTY IS ABOUT THE DISTRIBUTION, NOT +THE ACCOUNT — AND THIS RECORD ASSERTED THE WIDER VERSION FOR ONE ROUND.** +`update-distribution` is the script's last call, so a throw above it does leave +the **distribution** untouched. But sections 4 and 5 each make their own write +first — `create-response-headers-policy` and `create-origin-request-policy` — +and the script's own comment on section 5's drift throw documents a reachable +path where section 4 has **already created `adr-sml-pdf-noindex`** when section +5 aborts. **So after any failed `--apply`, check for an orphaned policy as well +as for a changed distribution**, with both of these: + +```bash +aws cloudfront list-response-headers-policies --type custom --output json \ + --query 'ResponseHeadersPolicyList.Quantity' +aws cloudfront list-origin-request-policies --type custom --output json \ + --query 'OriginRequestPolicyList.Quantity' +``` + +An orphan is harmless and self-healing — the next run finds it by name, matches +it and attaches it — **so do not delete it by hand.** Both returned `0` after +the 2026-09-04 failure, which is why nothing needed doing that time +`[verified 2026-09-04]`. + +**Two changes came out of it.** The clone now **omits** any empty member at +either of those two levels — a `ResponseHeadersPolicyConfig` member, or a +`SecurityHeadersConfig` member — and the dry run **asserts** that the generated +config carries no empty object at any *other* level, naming the dotted path if +it does. The asymmetry is deliberate: those two levels are where AWS is known to +synthesise a placeholder, and anywhere else is unaccounted for and stops the +run rather than being discarded quietly. The assertion runs before every branch, so **the dry run now catches this +class** rather than an `--apply` discovering it — and if it ever does fire it +**skips section 4** rather than throwing, so `router.js` can still be +re-applied. + +**The proof is a command rather than a session**, which is the point of +`infra/cloudfront/policy-shapes.mjs` existing as its own module — `configure.mjs` +reads argv and calls AWS at import time, so the two functions could not +otherwise be reached: + +```bash +node infra/cloudfront/policy-shapes.test.mjs +``` + +**Expect** `policy-shapes: 23 of 23 cases pass`, exit 0. Its first case is this +incident verbatim — the live `SecurityHeadersConfig`, empty `ContentSecurityPolicy` +and all `[verified 2026-09-04; 7 of 7 mutations killed]`. + +**One of the `·` lines carries a number worth reading**, and it is not a +warning: + +``` +· cloning 5 defined security header(s); omitting 1 the source does not define (ContentSecurityPolicy) +``` + +**Five is the number to read.** It is the count of security headers the PDF +policy will carry, and the verification block at the end of this Part counts the +same five arriving on the live PDF. A drop in this number is a partial clone +announcing itself one step earlier than that `curl` would. + ⚠️ **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, @@ -734,12 +840,14 @@ 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. +zip.** ⚠️ **THE TWO RESOLVED VERSIONS AND THE COMMIT THE DEPLOYED SOURCES MATCH +ARE IN `AGENTS.md` §7 AND ARE DELIBERATELY NOT REPEATED HERE.** They were +repeated here until 2026-09-04, and the paragraph directly above is the argument +against it — **the literals went stale in two days rather than a week**: the +2026-09-04 redeploy moved both packages one patch and added a third source file, +and this copy still named the old versions and a superseded commit while reading +as a measurement. Read §7's two Lambda rows; this step's duty is to **update** +them, not to mirror them. ✅ **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 diff --git a/infra/cloudfront/configure.mjs b/infra/cloudfront/configure.mjs index 3a3865e..214484d 100644 --- a/infra/cloudfront/configure.mjs +++ b/infra/cloudfront/configure.mjs @@ -41,6 +41,14 @@ */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +/* Section 4's clone, and why an empty member is always AWS's placeholder + rather than a value: `policy-shapes.mjs`. Tested there, because this file + reads argv and calls AWS at import time. */ +import { + withoutEmptyMembers, + isEmptyObject, + emptyObjectPaths, +} from './policy-shapes.mjs'; const args = process.argv.slice(2); const flag = (name) => { @@ -383,18 +391,49 @@ if (!defaultRhpId) { instead, naming the diff. */ const source = getResponseHeadersPolicy(defaultRhpId); const srcCfg = source?.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig; + const clonedShc = withoutEmptyMembers(srcCfg?.SecurityHeadersConfig); + const undefinedMembers = Object.keys( + srcCfg?.SecurityHeadersConfig ?? {}, + ).filter((k) => !(k in clonedShc)); /* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was inconsistent for one round. A policy carrying only `CorsConfig` is legal; an ABSENT source is section 4's problem alone and must not stop sections - 1-3 from re-applying `router.js`. The DRIFT throw below is different: that - is a divergence, not an absence, and `docs/09` Part 3 argues for it. */ + 1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE + EXCEPTIONS, and they are exceptions for the same reason: DRIFT is a + divergence rather than an absence (`docs/09` Part 3 argues for it), and the + EMPTY-OBJECT throw means this script generated an invalid config — a bug in + the script, not a state of the world. Neither is something a later section + should be allowed to paper over. */ if (!srcCfg?.SecurityHeadersConfig) { skipped.push( `${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`, ); + } else if (Object.keys(clonedShc).length === 0) { + /* Distinct from the branch above, and the distinction is the operator's: + ABSENT means the source policy is a different kind of thing, while + ALL-EMPTY means it is the right kind and defines nothing. Both leave the + PDF without security headers; only the second would read as a bug. */ + skipped.push( + `${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has a SecurityHeadersConfig that defines nothing` + + (undefinedMembers.length + ? ` (all ${undefinedMembers.length} of its members are empty: ${undefinedMembers.join(', ')})` + : ' — it is an empty object') + + `, so there are no security headers to clone`, + ); } else { - const wanted = { - SecurityHeadersConfig: srcCfg.SecurityHeadersConfig, + /* ⚠️ UNCONDITIONAL. This was gated on `undefinedMembers.length`, so on a + source that defines all six the line DISAPPEARED rather than reading six + — and `docs/09` Part 3 tells the operator to read this number as the + count of headers the PDF will carry. An absent line is not a smaller + number; it is nothing to compare (`adversarial-reviewer`). */ + console.log( + `· cloning ${Object.keys(clonedShc).length} defined security header(s)` + + (undefinedMembers.length + ? `; omitting ${undefinedMembers.length} the source does not define (${undefinedMembers.join(', ')})` + : ''), + ); + const wanted = withoutEmptyMembers({ + SecurityHeadersConfig: clonedShc, ...(srcCfg.CorsConfig ? { CorsConfig: srcCfg.CorsConfig } : {}), ...(srcCfg.RemoveHeadersConfig ? { RemoveHeadersConfig: srcCfg.RemoveHeadersConfig } @@ -406,176 +445,210 @@ if (!defaultRhpId) { Quantity: (srcCfg.CustomHeadersConfig?.Items ?? []).length + 1, Items: [...(srcCfg.CustomHeadersConfig?.Items ?? []), XRT], }, - }; + }); - if (existingPdfPolicy) { - const have = existingPdfPolicy.ResponseHeadersPolicyConfig; - const norm = (o) => JSON.stringify(o ?? null); - const drift = [ - 'SecurityHeadersConfig', - 'CorsConfig', - 'RemoveHeadersConfig', - 'ServerTimingHeadersConfig', - ] - .filter((k) => norm(have[k]) !== norm(wanted[k])) - .concat( - norm(have.CustomHeadersConfig?.Items) !== - norm(wanted.CustomHeadersConfig.Items) - ? ['CustomHeadersConfig'] - : [], - ); - if (drift.length) { - /* Print BOTH SIDES of every drifted key. Naming the field alone does not - tell the operator which header moved, nor which direction to reconcile - in — the same message fires whether the source gained a header or the - PDF policy lost its X-Robots-Tag, and those need opposite repairs. */ - const detail = drift - .map( - (k) => - ` ${k}\n pdf policy : ${norm( - k === 'CustomHeadersConfig' - ? have.CustomHeadersConfig?.Items - : have[k], - )}\n default : ${norm( - k === 'CustomHeadersConfig' - ? wanted.CustomHeadersConfig.Items - : wanted[k], - )}`, - ) - .join('\n'); - throw new Error( - `${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` + - `${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` + - `different headers from the pages — read which way before repairing:\n` + - `${detail}\n` + - `Reconcile with update-response-headers-policy (it needs the policy's ` + - `own ETag), then re-run. This script will not silently paper over it.`, - ); - } - console.log( - `· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`, - ); - } else if (!APPLY) { - console.log(`· would CREATE response-headers policy ${PDF_POLICY_NAME}`); - changes.push( - `create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`, + /* ⚠️ ASSERT BEFORE ANY BRANCH, SO THE DRY RUN CARRIES IT TOO — Pouya's + ruling of 2026-09-04. The clone that failed was syntactically fine and + printed a clean dry run; a check that runs only on the writing path + reports the class after it has already cost the run. + + ⚠️ A SKIP, NOT A THROW — it was a throw for one round. An empty object + here is section 4's problem alone, and this file's contract is that + section 4 never blocks sections 1-3 from re-applying `router.js`, without + which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it + prints under its own heading and the NOTHING TO CHANGE guard names it. */ + const empties = emptyObjectPaths(wanted); + if (empties.length) { + skipped.push( + `${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` + + `contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` + + `AWS rejects those on ParamValidation before the call leaves the machine. ` + + `withoutEmptyMembers should have removed them, so either it is broken or ` + + `this script built one itself — see policy-shapes.mjs and its test.`, ); } else { - const created = aws([ - 'cloudfront', - 'create-response-headers-policy', - '--response-headers-policy-config', - JSON.stringify({ - Name: PDF_POLICY_NAME, - Comment: - 'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.', - ...wanted, - }), - '--output', - 'json', - ]); - pdfPolicyId = created?.ResponseHeadersPolicy?.Id; - if (!pdfPolicyId) { - throw new Error('create-response-headers-policy returned no Id'); - } - console.log( - `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, - ); - changes.push( - `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, - ); - } - - const pdfBehaviours = cfg.CacheBehaviors?.Items ?? []; - const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN); - if (foundPdf) { - /* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf` - behaviour existed, so one added by hand — while chasing the - `aws s3 sync --metadata` route this file's header records as the original - instruction — would report `already present`, push nothing, and print - NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1 - compares the FunctionARN before declaring a match; so does this now. */ - const wrong = []; - if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) { - wrong.push( - `ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`, - ); - } - if (foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId) { - wrong.push( - `TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`, - ); - } - const hasViewerRequest = ( - foundPdf.FunctionAssociations?.Items ?? [] - ).some((i) => i.EventType === 'viewer-request'); - if (!hasViewerRequest) { - wrong.push( - 'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301', - ); - } - if (wrong.length) { - throw new Error( - `a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` + - `script manages:\n - ${wrong.join('\n - ')}\n` + - `Reconcile or remove it before re-running; this script will not adopt ` + - `a behaviour it cannot account for.`, - ); - } - console.log( - `· cache behaviour ${PDF_PATTERN} already present and correctly configured`, - ); - } else { - const d = cfg.DefaultCacheBehavior; - const behaviour = { - PathPattern: PDF_PATTERN, - TargetOriginId: d.TargetOriginId, - ViewerProtocolPolicy: d.ViewerProtocolPolicy, - AllowedMethods: d.AllowedMethods, - CachePolicyId: d.CachePolicyId, - /* Placeholder only in a dry run — the real id exists by the time --apply - reaches this line, because the branch above created it. */ - ResponseHeadersPolicyId: pdfPolicyId ?? '', - Compress: d.Compress, - SmoothStreaming: false, - FieldLevelEncryptionId: '', - /* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS. - `router.js` normalises `\` to `/` and collapses a leading `//` run - BEFORE it tests for an extension, and 301s when normalisation changed - anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical - path today. Omitting the association here would silently drop that and - hand S3 the doubled key instead. The `/api/*` reason for omitting it — - a 301 turning a POST into a GET and losing the body — does not apply to - a GET-only PDF. */ - FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 }, - LambdaFunctionAssociations: { Quantity: 0 }, - TrustedKeyGroups: { Enabled: false, Quantity: 0 }, - }; - /* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole - job is to show what would touch a distribution serving 23 pages; reporting - only the harmless policy creation and staying silent about the behaviour - would mean the first sight of it is `update-distribution` writing it. The - `cfg` mutation stays gated on a real id; the REPORT does not. */ - changes.push( - `CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` + - (pdfPolicyId - ? ` (${pdfPolicyId})` - : ' (policy id created in the same --apply pass)'), - ); - if (!APPLY && !pdfPolicyId) { + if (existingPdfPolicy) { + const have = existingPdfPolicy.ResponseHeadersPolicyConfig; + /* ⚠️ `{}` AND ABSENT MUST NORMALISE ALIKE, ON BOTH SIDES. `wanted` has + had AWS's placeholders stripped; the live policy may still echo them. + Stripping alone is not enough — that leaves `norm({})` as `"{}"` + against `norm(undefined)` as `"null"`, which reports drift that no + `update-response-headers-policy` can clear, on the intake form's own + path. An empty member is never a real divergence. */ + const norm = (o) => { + const v = withoutEmptyMembers(o); + return JSON.stringify(isEmptyObject(v) ? null : (v ?? null)); + }; + const drift = [ + 'SecurityHeadersConfig', + 'CorsConfig', + 'RemoveHeadersConfig', + 'ServerTimingHeadersConfig', + ] + .filter((k) => norm(have[k]) !== norm(wanted[k])) + .concat( + norm(have.CustomHeadersConfig?.Items) !== + norm(wanted.CustomHeadersConfig.Items) + ? ['CustomHeadersConfig'] + : [], + ); + if (drift.length) { + /* Print BOTH SIDES of every drifted key. Naming the field alone does not + tell the operator which header moved, nor which direction to reconcile + in — the same message fires whether the source gained a header or the + PDF policy lost its X-Robots-Tag, and those need opposite repairs. */ + const detail = drift + .map( + (k) => + ` ${k}\n pdf policy : ${norm( + k === 'CustomHeadersConfig' + ? have.CustomHeadersConfig?.Items + : have[k], + )}\n default : ${norm( + k === 'CustomHeadersConfig' + ? wanted.CustomHeadersConfig.Items + : wanted[k], + )}`, + ) + .join('\n'); + throw new Error( + `${PDF_POLICY_NAME} has DRIFTED from the default behaviour's policy ` + + `${defaultRhpId} on ${drift.length} field(s). The PDF is being served ` + + `different headers from the pages — read which way before repairing:\n` + + `${detail}\n` + + `Reconcile with update-response-headers-policy (it needs the policy's ` + + `own ETag), then re-run. This script will not silently paper over it.`, + ); + } console.log( - `· would ADD cache behaviour ${PDF_PATTERN}:\n` + - JSON.stringify(behaviour, null, 2) - .split('\n') - .map((l) => ' ' + l) - .join('\n'), + `· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`, + ); + } else if (!APPLY) { + console.log( + `· would CREATE response-headers policy ${PDF_POLICY_NAME}`, + ); + changes.push( + `create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`, ); } else { - pdfBehaviours.push(behaviour); - cfg.CacheBehaviors = { - Quantity: pdfBehaviours.length, - Items: pdfBehaviours, + const created = aws([ + 'cloudfront', + 'create-response-headers-policy', + '--response-headers-policy-config', + JSON.stringify({ + Name: PDF_POLICY_NAME, + Comment: + 'Cloned from the default behaviour, plus X-Robots-Tag: noindex for *.pdf. See infra/cloudfront/configure.mjs section 4.', + ...wanted, + }), + '--output', + 'json', + ]); + pdfPolicyId = created?.ResponseHeadersPolicy?.Id; + if (!pdfPolicyId) { + throw new Error('create-response-headers-policy returned no Id'); + } + console.log( + `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, + ); + changes.push( + `created response-headers policy ${PDF_POLICY_NAME} = ${pdfPolicyId}`, + ); + } + + const pdfBehaviours = cfg.CacheBehaviors?.Items ?? []; + const foundPdf = pdfBehaviours.find((b) => b.PathPattern === PDF_PATTERN); + if (foundPdf) { + /* ⚠️ PRESENCE IS NOT CORRECTNESS. This checked only that a `*.pdf` + behaviour existed, so one added by hand — while chasing the + `aws s3 sync --metadata` route this file's header records as the original + instruction — would report `already present`, push nothing, and print + NOTHING TO CHANGE while the PDF served no `X-Robots-Tag` at all. Section 1 + compares the FunctionARN before declaring a match; so does this now. */ + const wrong = []; + if (foundPdf.ResponseHeadersPolicyId !== pdfPolicyId) { + wrong.push( + `ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`, + ); + } + if ( + foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId + ) { + wrong.push( + `TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`, + ); + } + const hasViewerRequest = ( + foundPdf.FunctionAssociations?.Items ?? [] + ).some((i) => i.EventType === 'viewer-request'); + if (!hasViewerRequest) { + wrong.push( + 'no viewer-request FunctionAssociation — router.js normalises `//` and `\\` on file paths, so `//pouya-lajevardi-bio.pdf` would 404 instead of 301', + ); + } + if (wrong.length) { + throw new Error( + `a ${PDF_PATTERN} cache behaviour already exists but is NOT the one this ` + + `script manages:\n - ${wrong.join('\n - ')}\n` + + `Reconcile or remove it before re-running; this script will not adopt ` + + `a behaviour it cannot account for.`, + ); + } + console.log( + `· cache behaviour ${PDF_PATTERN} already present and correctly configured`, + ); + } else { + const d = cfg.DefaultCacheBehavior; + const behaviour = { + PathPattern: PDF_PATTERN, + TargetOriginId: d.TargetOriginId, + ViewerProtocolPolicy: d.ViewerProtocolPolicy, + AllowedMethods: d.AllowedMethods, + CachePolicyId: d.CachePolicyId, + /* Placeholder only in a dry run — the real id exists by the time --apply + reaches this line, because the branch above created it. */ + ResponseHeadersPolicyId: pdfPolicyId ?? '', + Compress: d.Compress, + SmoothStreaming: false, + FieldLevelEncryptionId: '', + /* ⚠️ THE ROUTER FUNCTION IS ATTACHED, AND IT IS NOT A NO-OP ON FILE PATHS. + `router.js` normalises `\` to `/` and collapses a leading `//` run + BEFORE it tests for an extension, and 301s when normalisation changed + anything — so `//pouya-lajevardi-bio.pdf` redirects to the canonical + path today. Omitting the association here would silently drop that and + hand S3 the doubled key instead. The `/api/*` reason for omitting it — + a 301 turning a POST into a GET and losing the body — does not apply to + a GET-only PDF. */ + FunctionAssociations: d.FunctionAssociations ?? { Quantity: 0 }, + LambdaFunctionAssociations: { Quantity: 0 }, + TrustedKeyGroups: { Enabled: false, Quantity: 0 }, }; + /* ⚠️ STAGE THE REPORT EVEN WHEN THE ID IS NOT KNOWN YET. The dry run's whole + job is to show what would touch a distribution serving 23 pages; reporting + only the harmless policy creation and staying silent about the behaviour + would mean the first sight of it is `update-distribution` writing it. The + `cfg` mutation stays gated on a real id; the REPORT does not. */ + changes.push( + `CacheBehaviors += ${PDF_PATTERN} -> ${d.TargetOriginId}, default cache policy, ${PDF_POLICY_NAME}` + + (pdfPolicyId + ? ` (${pdfPolicyId})` + : ' (policy id created in the same --apply pass)'), + ); + if (!APPLY && !pdfPolicyId) { + console.log( + `· would ADD cache behaviour ${PDF_PATTERN}:\n` + + JSON.stringify(behaviour, null, 2) + .split('\n') + .map((l) => ' ' + l) + .join('\n'), + ); + } else { + pdfBehaviours.push(behaviour); + cfg.CacheBehaviors = { + Quantity: pdfBehaviours.length, + Items: pdfBehaviours, + }; + } } } } diff --git a/infra/cloudfront/policy-shapes.mjs b/infra/cloudfront/policy-shapes.mjs new file mode 100644 index 0000000..615c90e --- /dev/null +++ b/infra/cloudfront/policy-shapes.mjs @@ -0,0 +1,77 @@ +/** + * Shape helpers for the CloudFront policy configs `configure.mjs` builds. + * + * ⚠️ **A POLICY AWS HANDS BACK IS NOT A POLICY AWS WILL ACCEPT.** + * `get-response-headers-policy` returns `{}` for a member the source does not + * define — `Managed-SecurityHeadersPolicy` does it for `ContentSecurityPolicy` + * — and sending that back fails `create-response-headers-policy` on + * ParamValidation before the call leaves the machine. `docs/09` Part 3 carries + * the incident and the exact error. + * + * **Dropping an empty member is safe at every depth, and that is a measurement + * rather than a hope.** Of the 16 structures reachable from + * `ResponseHeadersPolicyConfig` in the CLI's own service model, **15 declare at + * least one required field** — so `{}` is not a legal value there and can only + * be the placeholder. The single exception is `SecurityHeadersConfig` itself, + * and `configure.mjs` skips before it can build one of those empty, because a + * PDF policy cloning no security headers is the thing that section exists to + * avoid. + * + * They live in their own module so they can be tested: `configure.mjs` reads + * argv and calls AWS at import time, so importing THAT to reach two pure + * functions is not possible. Same reason `fields.mjs` sits beside + * `handler.mjs`. See `policy-shapes.test.mjs`. + */ + +/** + * Every empty-object member removed, at every depth, bottom-up — so a member + * left empty by stripping its own children is removed in turn. + * + * Arrays are recursed into but never have elements removed: an element index is + * load-bearing against its `Quantity` sibling, and an empty object inside one + * would be this script's own construction rather than an AWS placeholder. That + * case is left for `emptyObjectPaths` to report. + */ +export const withoutEmptyMembers = (value) => { + if (Array.isArray(value)) return value.map(withoutEmptyMembers); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const [k, v] of Object.entries(value)) { + const cleaned = withoutEmptyMembers(v); + const isEmptyObject = + cleaned && + typeof cleaned === 'object' && + !Array.isArray(cleaned) && + Object.keys(cleaned).length === 0; + if (!isEmptyObject) out[k] = cleaned; + } + return out; +}; + +/** True for `{}` — the value AWS accepts nowhere in these configs. */ +export const isEmptyObject = (v) => + Boolean(v) && + typeof v === 'object' && + !Array.isArray(v) && + Object.keys(v).length === 0; + +/** + * The dotted path of every empty object left in a config. A post-condition on + * the strip above, not a filter: if this returns anything, the strip did not do + * what this module claims it does. + * + * Empty ARRAYS are not reported — `{Quantity: 0, Items: []}` is valid and + * common, while an empty object is valid nowhere. + */ +export function emptyObjectPaths(value, path = '') { + if (Array.isArray(value)) { + return value.flatMap((v, i) => emptyObjectPaths(v, `${path}[${i}]`)); + } + if (value && typeof value === 'object') { + if (Object.keys(value).length === 0) return [path || '(root)']; + return Object.entries(value).flatMap(([k, v]) => + emptyObjectPaths(v, path ? `${path}.${k}` : k), + ); + } + return []; +} diff --git a/infra/cloudfront/policy-shapes.test.mjs b/infra/cloudfront/policy-shapes.test.mjs new file mode 100644 index 0000000..e691773 --- /dev/null +++ b/infra/cloudfront/policy-shapes.test.mjs @@ -0,0 +1,204 @@ +/** + * Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04 + * `--apply` failure recorded in `docs/09` Part 3. + * + * The first case is that failure verbatim: the `SecurityHeadersConfig` the live + * `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and + * all, which is what `create-response-headers-policy` rejected. + * + * node infra/cloudfront/policy-shapes.test.mjs + */ +import { + withoutEmptyMembers, + emptyObjectPaths, + isEmptyObject, +} from './policy-shapes.mjs'; + +let pass = 0; +const failures = []; +const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const t = (name, got, want) => { + if (eq(got, want)) pass += 1; + else + failures.push( + `${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`, + ); +}; + +/* The live source policy, copied from `get-response-headers-policy` on + 67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */ +const LIVE_SECURITY_HEADERS = { + XSSProtection: { Override: false, Protection: true, ModeBlock: true }, + FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' }, + ReferrerPolicy: { + Override: false, + ReferrerPolicy: 'strict-origin-when-cross-origin', + }, + ContentSecurityPolicy: {}, + ContentTypeOptions: { Override: true }, + StrictTransportSecurity: { + Override: false, + AccessControlMaxAgeSec: 31536000, + }, +}; + +/* ---- the incident itself ------------------------------------------------ */ +const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS); +t( + 'the 2026-09-04 breach: ContentSecurityPolicy is dropped', + Object.keys(stripped).sort(), + [ + 'ContentTypeOptions', + 'FrameOptions', + 'ReferrerPolicy', + 'StrictTransportSecurity', + 'XSSProtection', + ], +); +t( + 'and five survive — the count docs/09 Part 3 tells the operator to read', + Object.keys(stripped).length, + 5, +); +t( + 'the surviving members are untouched', + stripped.StrictTransportSecurity, + LIVE_SECURITY_HEADERS.StrictTransportSecurity, +); +t('nothing empty is left behind', emptyObjectPaths(stripped), []); + +/* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip + turned into a hard abort (adversarial-reviewer, round 1) ------------- */ +t( + 'a top-level policy-config member is dropped', + withoutEmptyMembers({ + Name: 'p', + CorsConfig: {}, + SecurityHeadersConfig: stripped, + }), + { Name: 'p', SecurityHeadersConfig: stripped }, +); + +/* ---- and the one BELOW that, which the first repair still aborted on + (adversarial-reviewer, round 2) -------------------------------------- */ +t( + 'a CorsConfig member is dropped, and the emptied CorsConfig with it', + withoutEmptyMembers({ + Name: 'p', + CorsConfig: { AccessControlExposeHeaders: {} }, + }), + { Name: 'p' }, +); +t( + 'but a CorsConfig that still has content survives', + withoutEmptyMembers({ + CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false }, + }), + { CorsConfig: { OriginOverride: false } }, +); + +/* ---- things that must NOT be discarded ---------------------------------- */ +t( + 'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common', + withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }), + { RemoveHeadersConfig: { Quantity: 0, Items: [] } }, +); +t( + 'false, 0, null and empty string are kept', + withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }), + { a: false, b: 0, c: null, d: '' }, +); +t( + 'array elements are recursed into but never removed', + withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }), + { Items: [{ Header: 'X' }, {}] }, +); +t( + 'the custom-headers list the script builds is untouched', + withoutEmptyMembers({ + CustomHeadersConfig: { + Quantity: 1, + Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], + }, + }), + { + CustomHeadersConfig: { + Quantity: 1, + Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], + }, + }, +); +t('stripping is idempotent', withoutEmptyMembers(stripped), stripped); + +/* ---- the drift comparison: {} and absent must normalise alike ------------ + Round 1's repair stripped children but left `norm({})` as "{}" against + `norm(undefined)` as "null", which reported permanent, unrepairable drift on + the intake form's own path. */ +const norm = (o) => { + const v = withoutEmptyMembers(o); + return JSON.stringify(isEmptyObject(v) ? null : (v ?? null)); +}; +t('norm({}) equals norm(undefined)', norm({}), norm(undefined)); +t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({})); +t( + 'but a real difference still differs', + norm({ a: 1 }) === norm({ a: 2 }), + false, +); + +/* ---- emptyObjectPaths, the post-condition ------------------------------- */ +t( + 'reports the incident path', + emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }), + ['SecurityHeadersConfig.ContentSecurityPolicy'], +); +t( + 'reports round 2s deeper path', + emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }), + ['CorsConfig.AccessControlExposeHeaders'], +); +t( + 'reports an empty object inside an array, with its index', + emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }), + ['Items[1]'], +); +t( + 'reports every one, not just the first', + emptyObjectPaths({ a: {}, b: { c: {} } }), + ['a', 'b.c'], +); +t('silent on an empty array', emptyObjectPaths({ a: [] }), []); +t( + 'silent on null, undefined and primitives', + emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }), + [], +); +t('names the root when the whole config is empty', emptyObjectPaths({}), [ + '(root)', +]); + +/* ---- the invariant the two functions exist to hold together ------------- */ +t( + 'THE INVARIANT: nothing survives the strip that the assertion would report', + emptyObjectPaths( + withoutEmptyMembers({ + Name: 'adr-sml-pdf-noindex', + SecurityHeadersConfig: LIVE_SECURITY_HEADERS, + CorsConfig: { AccessControlExposeHeaders: {} }, + ServerTimingHeadersConfig: {}, + CustomHeadersConfig: { + Quantity: 1, + Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }], + }, + }), + ), + [], +); + +if (failures.length) { + console.error( + `policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`, + ); + process.exit(1); +} +console.log(`policy-shapes: ${pass} of ${pass} cases pass`);