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
+96 -20
View File
@@ -323,19 +323,26 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN"
⚠️ **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
the **live** distribution the dry run is **59 lines**, and all 59 account for:
2 resolved-policy lines, **10** `·` lines, **3** `+` 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 13 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"*.
🛑 **READ THE EXIT STATUS, AND IT HAS THREE VALUES.** `0` — everything this
script manages was applied or is already present. `2` — a usage error, before any
AWS call. **`3` — sections that could run did, and something was SKIPPED: read
the `⚠ … SKIPPED, not changed` block.** Anything else is a throw. `3` exists
because a skip used to exit `0`, and this document uses `exit 0` as its own
success stamp throughout — so a partial run read as a complete one. A skip is
never nothing: it means the `*.pdf` behaviour has no `X-Robots-Tag`, or `/api/*`
is not forwarding `CloudFront-Viewer-Address`, and the skip line says which.
⚠️ **THE SHAPE CHANGES WITH THE STATE, SO READ THE `+` LINES AND NOT THE
TOTAL.** Each item Parts 13 have already applied prints `·` when it is found
and `+` when it is staged, so in the Part 0.3 state four lines cross from one
column to the other and the totals move with them. **The `+` lines are the
check.**
**Expect** — this is the dry run, and on a distribution in the state Part 0.3
records the change lines are:
@@ -370,15 +377,19 @@ Fewer than eight changes means part of this is already done — read which lines
are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
for *already present* **and** for *would CREATE / would SET / would ADD*, so the
prefix alone does not say whether a line is done or still pending. **On the live distribution as at
2026-09-04 the dry run returns exactly four `+` lines — the two for section 4
and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
2026-09-04, after the second `--apply` attempt, the dry run returns exactly
three `+` lines — **one** for section 4 (the behaviour; its policy already
exists, see the incident below) and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
`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:
🛑 **INCIDENT — TWO `--apply` ATTEMPTS FAILED ON 2026-09-04, FOR TWO DIFFERENT
REASONS, AND THE SECOND ONE LEFT A POLICY BEHIND.** The distribution is
unchanged after both. Read both before the next attempt.
**ATTEMPT 1 — nothing reached the distribution and nothing was created.**
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:
@@ -425,8 +436,12 @@ aws cloudfront list-origin-request-policies --type custom --output json \
```
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
it and attaches it — **so do not delete it by hand.**
⚠️ **THE EXPECTED VALUES DIFFER BY WHICH FAILURE YOU ARE RECOVERING FROM.** After
**attempt 1** both returned `0` and nothing needed doing. After **attempt 2**
they return **`1` and `0`** — the response-headers policy is the orphan recorded
below, and `1` is the correct reading, not a second problem
`[verified 2026-09-04]`.
**Two changes came out of it.** The clone now **omits** any empty member at
@@ -449,9 +464,70 @@ otherwise be reached:
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]`.
**Expect** `policy-shapes: 53 of 53 cases pass`, exit 0. Its first case is this
incident verbatim — the live `SecurityHeadersConfig`, empty
`ContentSecurityPolicy` and all. The same suite covers attempt 2's limit checks,
so this is the only command in this Part that proves both.
---
**ATTEMPT 2 — section 4 SUCCEEDED, section 5 FAILED, and the run left an orphan.**
With the clone fixed, `create-response-headers-policy` created
**`adr-sml-pdf-noindex` = `51c4e79b-d9c6-4c6f-907c-dbb0e73dd374`**. Section 5
then failed:
```
An error occurred (InvalidArgument) when calling the CreateOriginRequestPolicy
operation: The parameter Comment is too big
```
Its `Comment` was **182 characters** against a **128** cap. `update-distribution`
never ran, so the distribution is untouched — **but the account now holds a
response-headers policy that no behaviour references.**
🛑 **DO NOT DELETE THAT POLICY BY HAND.** This is the orphan case Part 3 and §7
predicted before it happened, and the recovery is **measured, not asserted**
`[measured 2026-09-04, dry run, exit 0, nothing written]`: the next 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
+ CacheBehaviors += *.pdf -> …, adr-sml-pdf-noindex (51c4e79b-d9c6-4c6f-907c-dbb0e73dd374)
```
— with the **create line gone** and the change count down from 4 to 3. **No
duplicate and no name collision.** (A collision would not be silent either: a
duplicate name returns `ResponseHeadersPolicyAlreadyExists`, which is a
different error from the `InvalidArgument` above. The script never reaches it,
because it looks the policy up by name first.)
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` keeps its original
118-character text while the script now carries a shorter one. Deliberate:
adding `Comment` to the drift check would throw on this very policy and block
the run that attaches it.
**WHY NOTHING CAUGHT IT LOCALLY, AND THIS IS THE GENERAL LESSON.** Measured
2026-09-04: **`botocore/validate.py` checks neither `max` nor `pattern`** — its
`range_check()` reads only `min`, and the word `pattern` does not appear in the
file. And the 128 cap is not modelled as a constraint at all: on both policy
configs `Comment` is a bare `string`, and the number lives in the shape's
**`documentation` prose**. So **no client-side validation of any kind stood
between that 182-character string and the CloudFront API**, which is exactly why
the pre-flight below had to be built rather than relied upon.
**BOTH POLICY COMMENTS ARE NOW UNDER 80 CHARACTERS**, and the dry run enforces
the limits it knows about — `infra/cloudfront/policy-shapes.mjs`,
`PAYLOAD_LIMITS`, one entry per limit with the source it came from. **Every
entry with a cited AWS source is enforced by the service and by nothing local**
— see below. Two entries are stamped `[assumed]` and are not: no AWS source
states a policy **name** length, and those two constrain nothing this script
sends (our names are 19 and 26 characters). A breach **skips its section** rather than throwing, and — the part that
matters — **skips the whole section**, so a policy that is not created is never
staged for attachment. The suite named earlier in this Part
(`node infra/cloudfront/policy-shapes.test.mjs`) covers both the 182-character
`Comment` that failed here and the 118-character one that did not.
---
**One of the `·` lines carries a number worth reading**, and it is not a
warning: