fix: pre-flight the CloudFront payload limits the dry run is the only guard for
Build and deploy / build-and-deploy (push) Failing after 4s

The second `--apply` of 2026-09-04 created adr-sml-pdf-noindex and then failed
at create-origin-request-policy: InvalidArgument, "The parameter Comment is too
big" — 182 characters against a 128 cap. update-distribution never ran, so the
distribution is unchanged, but the account was left holding an orphaned policy.

Nothing local could have caught it, and that is now measured rather than
assumed: botocore/validate.py checks neither `max` nor `pattern` (range_check
reads only `min`; the word `pattern` does not appear in the file), and the 128
is not modelled as a constraint at all — `Comment` is a bare `string` and the
cap lives in the shape's documentation prose. So the dry run really is the only
pre-flight, and it now enforces PAYLOAD_LIMITS: 13 entries across both policy
payloads and the function ARN, each with the source it came from.

The entries that matter guard CLONED values rather than literals this file
authors — a literal is reviewed when it is written, while a value copied out of
the default behaviour's policy changes with no diff here. The API declares
TooLongCSPInResponseHeadersPolicy for exactly that case and docs/05 already
specifies a CSP that would land there. RemoveHeadersConfig is a recorded gap:
its cap is real but unpublished, and inventing a number would be worse.

Both comments are now 76 and 74 characters. `--function-arn` is validated
before any AWS call, and an unrecognised `--flag` is a usage error — the `=`
form was invisible to the parser and to the presence check, for a clean exit 0
with no router attached. A skipped section now exits 3, because docs/09 uses
exit 0 as its own success stamp and a partial run read as a complete one.

Confirmed by measurement, as asked: the next run REUSES the orphan by name,
matches every reconciled field, and stages it — create line gone, 4 changes
down to 3, no duplicate and no collision.

Reviewed twice. Round 2 found that the §7 record broke the table it lives in,
and that two comments asserted behaviour the code did not have. 17 findings
across both rounds, all fixed.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-04 14:24:33 -04:00
co-authored by Claude Opus 5
parent bbe535d158
commit a07193d561
5 changed files with 1092 additions and 168 deletions
+132 -1
View File
@@ -725,7 +725,7 @@ the audience it targets. Revisit at month 1218. `[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 13 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 |
| 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 13 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 |
| 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 |
@@ -957,6 +957,137 @@ never being raised again.
# Change Log
## 2026-09-04 (av) — The second `--apply` got one policy further and failed on a 128-character cap that nothing local enforces; the orphan it left is reused by name, measured; the dry run gains the only pre-flight this script has; and §7's own record broke the table it lives in
**Pouya's ruling of 2026-09-04**, after the second attempt: *"Fix: shorten BOTH
policy comments to well under 128 characters… Add a dry-run assertion enforcing
every CloudFront Comment field ≤128 characters — and while there, enumerate any
other length/format limit on the payloads we send… since the dry run is the only
pre-flight we have. Confirm on the next run section 4 REUSES 51c4e79b by name
rather than creating a duplicate or erroring on the name collision — measure it
in the dry run output. Record both failed attempts in docs/09 Part 3 and §7."*
---
### 1. What happened, and what it left behind
Attempt 2 got past attempt 1's `ParamValidation`: section 4 **created**
`adr-sml-pdf-noindex` = **`51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
then failed on a **182-character** `Comment` against a **128** cap —
`InvalidArgument`, *"The parameter Comment is too big"*. `update-distribution`
never ran. **The distribution is unchanged; the account is not.**
This is the orphan case `docs/09` Part 3 and §7 described **before it occurred**,
including the instruction not to delete it by hand.
### 2. The reuse is measured, not asserted
`[measured 2026-09-04, dry run, exit 0, nothing written]` — the next run finds
the orphan **by name**, matches every reconciled field, and stages it. The create
line is gone and the change count drops **4 → 3**, with the real id inline. No
duplicate, no collision — and a collision would not be silent either: a duplicate
name returns **`ResponseHeadersPolicyAlreadyExists`**, a different error from the
`InvalidArgument` above, and the script never reaches it because it looks the
policy up by name first.
### 3. Why nothing local caught a 182-character string — two measurements
1. **`botocore/validate.py` checks neither `max` nor `pattern`.** `range_check()`
reads `shape.metadata['min']` and nothing else; the word `pattern` does not
appear in the file. A **declared** constraint is not applied.
2. **The 128 is not declared as a constraint anyway.** On both policy configs
`Comment` is a bare `string`; the cap lives in the shape's **`documentation`
prose**.
⚠️ **BOTH CORRECTED CLAIMS IN THIS CHANGE SET'S OWN FIRST DRAFT.** The two
`FunctionARN` entries were marked `enforcedBy: 'client'` because the model
declares `{max: 108, pattern: …}`. And the first draft searched the model for
`min`/`max`/`pattern` **keys**, concluded it was silent on `Comment`, and sourced
128 as `[assumed]`. The model states it — in a field the search never read.
### 4. What was built
- **Both comments shortened** — 118 → **76**, 182 → **74**, each ~52 characters
clear of the cap.
- **`PAYLOAD_LIMITS`** in `policy-shapes.mjs`: **13** entries (7 + 4 + 2), four
rule kinds (`maxLength`, `pattern`, `maxCount`, `maxCombinedLength`), each
carrying its source. Count and combined-length quotas read off the CloudFront
quotas page; `Comment` off the model's own documentation.
- ⚠️ **THE ENTRIES THAT MATTER MOST GUARD *CLONED* VALUES.** Round 1's sharpest
finding: every original entry constrained a literal this file authors —
reviewed when written — while the values section 4 **copies** from the default
behaviour's policy had none. `CreateResponseHeadersPolicy` declares
`TooLongCSPInResponseHeadersPolicy` for exactly that, and `docs/05` already
specifies a CSP that would land there. The documented **1,783** cap is now
covered.
- ⚠️ **KNOWN GAP, RECORDED RATHER THAN GUESSED.** `RemoveHeadersConfig` is also
cloned and its count cap is **not published**; the operation declares
`TooManyRemoveHeadersInResponseHeadersPolicy`, so a cap exists. Inventing a
number would be worse than the gap.
- **`--function-arn` validated before any AWS call**, closing the gap flagged and
deliberately not built in entry (au). **An unrecognised `--flag` is now a usage
error too** — `flag()` reads only the space form, so `--function-arn=<arn>` was
invisible to it *and* to the presence check, for a clean exit 0 with no router
attached.
- **`Name` is `[assumed]` and says so.** No AWS source states a policy name
length; the documented `Name` rule is **uniqueness**.
- **Exit `3` when anything was skipped.** `docs/09` uses `exit 0` as its own
success stamp, so a partial run read as a complete one — and two skip paths are
new in this change set.
### 5. Two repairs contained defects worse than what they fixed
**A live-form-breaking one, found by its own probe.** Skipping only the *create*
left the dependent staging in place: an `--apply` would have set `/api/*`'s
`OriginRequestPolicyId` to **null**, removing header forwarding so every
submission fails while looking like the visitor's browser. Section 4 had the same
shape. Both now skip the whole section.
**And §7's own record broke the table it lives in** — the rewritten CloudFront
row carried five literal newlines, so GFM parsed it as one two-cell row plus five
single-cell rows, rendering the orphan warning and the policy id **in the label
column** while the CloudFront row's value cell ended mid-sentence. Round 2 caught
it. Rejoined as one row using plain spacing, not `<br>`: `AGENTS.md` contains no
HTML and this change set is not the place to introduce it.
### 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: 10**, one
blocking, and **two of the ten were comments asserting things the code does not
do** — a comment naming an "EMPTY-OBJECT throw" that had been converted to a skip
a session earlier, and a comment promising the payload is checked-then-sent while
section 4 rebuilt a second literal at the call. That is D19's prediction in its
own terms. All 17 fixed, none declined. **No round 3.**
### 7. Verification
`policy-shapes.test.mjs` **53 cases**; **19 of 19 mutations killed, 0 survived**.
Two mutations survived earlier rounds and both were real coverage holes — the
response-headers caps and the cloned-CSP cap could each be raised tenfold without
failing anything, because every over-cap case had been written against the other
policy. Cases were added and both now die.
Fault-injected and measured, each exit status read: an over-long cloned `Comment`
**skips its whole section** (exit 3) while the other section still produces its
changes; a missing `handler.mjs` skips section 5 entirely instead of falling
through to the create; a broken reads-probe is caught rather than reporting
`covers all 0 headers`; `--function-arn=<arn>` and an empty `--function-arn` both
exit 2; the drift throw still fires; a matching policy still reports
`exists and matches`.
**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) · live dry run **0**, 59 lines, 3 changes, 0
skips, nothing written. **`lighthouse` not run** — nothing under `src/` or
`dist/` changed.
**Nothing was applied to the distribution and nothing was deployed. The orphaned
policy was not touched.**
---
## 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