feat: park the two policy changes the pricing plan forbids; robots.txt stands in
Build and deploy / build-and-deploy (push) Failing after 4s

The third --apply of 2026-09-04 reached update-distribution and was rejected
atomically: "Distributions with the Free pricing plan can't have the following
features: Custom origin request policy, Custom response headers policy."
Pouya's ruling: both are PARKED as unavailable — a platform constraint, not a
defect.

The pre-flight added in the previous commit could not have caught this, and
that is the point: every limit in PAYLOAD_LIMITS is a property of the payload,
while this is a property of the account, reported only by the call the
pre-flight exists to avoid. Both sections now stop before creating anything.

The plan is not in the CloudFront API — checked across 167 operations, no
operation, shape, member or documentation string mentions one, and
PriceClass_All is the edge-location price class, not the plan. So the gate is a
constant, PLAN_ALLOWS_CUSTOM_POLICIES, and the two sections report as PARKED
under their own heading rather than as skips: the previous commit made a skip
exit 3, and a constraint true on every run would have made 3 permanent. Proven
with a shim that refuses every mutating verb: --apply now makes zero of them.

Substitute (a): Disallow: /pouya-lajevardi-bio.pdf in robots.txt, placed before
Allow:/ so first-match crawlers honour it too. It is not an equivalent and the
file says so — it stops the PDF being fetched, solving the duplicate-of-/bio/
problem, but does not de-index a URL linked from /bio/ and /about/. Verified:
syntax, a match simulation under both crawler semantics, and that the sitemap
does not list the PDF.

Substitute (b): the WAF web ACL CreatedByCloudFront-f8fbf256 is already
attached — 925 WCU, three AWS managed rule groups, no rate-based statement.
That corrects §9 Q65, which framed WAF as a cost decision about adding one and
named the now-unappliable header forwarding as its groundwork. The real
question is one rule on an ACL already paid for, and a rate-based rule matches
the viewer address directly, so the capability is superseded rather than lost.

Reviewed in two rounds by me rather than a separate agent, per instruction.

Nothing was applied to the distribution and nothing was deployed; robots.txt
needs one site deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-04 14:52:13 -04:00
co-authored by Claude Opus 5
parent 07a8ff6989
commit 17e316dc1d
6 changed files with 376 additions and 41 deletions
+107 -15
View File
@@ -309,7 +309,41 @@ status, not the absence of an error.
---
## Part 3 — Apply the five distribution changes
## Part 3 — Apply the distribution changes (three of five; two are parked)
🛑 **SECTIONS 4 AND 5 CANNOT BE APPLIED ON THIS DISTRIBUTION AND THE SCRIPT NO
LONGER TRIES.** Pouya's ruling of 2026-09-04, after the third `--apply` reached
`update-distribution` and was rejected atomically:
```
An error occurred (InvalidArgument) when calling the UpdateDistribution operation:
Distributions with the Free pricing plan can't have the following features:
Custom origin request policy, Custom response headers policy
```
**A platform constraint, not a defect.** Both are closed in `docs/06` and
revisited only if the plan changes. `configure.mjs` gates them on
`PLAN_ALLOWS_CUSTOM_POLICIES` and reports them as **PARKED** — printed under
their own heading, **not counted as skips, and not affecting the exit status**,
because a constraint true on every run is not a signal.
⚠️ **THE PLAN IS NOT IN THE CLOUDFRONT API, WHICH IS WHY THIS IS A CONSTANT AND
NOT A PROBE.** Checked 2026-09-04 across **167 operations**: no operation, shape,
member or documentation string mentions a pricing plan. **`PriceClass_All`, which
this distribution carries, is the EDGE-LOCATION price class — a different and
much older concept. Do not read it as the plan.** The only signal AWS gives is
the rejection above, which is the thing the pre-flight exists to avoid.
**What replaces them.** The `X-Robots-Tag` is replaced by
`Disallow: /pouya-lajevardi-bio.pdf` in `public/robots.txt` — **a site deploy,
not a `configure.mjs` run** — which stops the PDF being *fetched* but does not
de-index the URL; the file itself carries that distinction. The viewer-address
forwarding is **superseded**: a WAF web ACL is already attached to this
distribution (`AGENTS.md` §7), and that is where a per-IP rule belongs.
**Everything below is the record of how sections 4 and 5 were built and why they
failed three times. Keep it: it is what makes them safe to un-park.** Changes 13
are unaffected and still apply.
One script, `infra/cloudfront/configure.mjs`, because the alternative is
hand-editing a 300-line JSON document and posting it back with an `IfMatch` ETag.
@@ -356,9 +390,14 @@ script manages was applied or is already present. `2` — a usage error, before
AWS call. **`3` — sections that could run did, and something was SKIPPED: read
the `⚠ … SKIPPED, not changed` block.** Anything else is a throw. `3` exists
because a skip used to exit `0`, and this document uses `exit 0` as its own
success stamp throughout — so a partial run read as a complete one. A skip is
never nothing: it means the `*.pdf` behaviour has no `X-Robots-Tag`, or `/api/*`
is not forwarding `CloudFront-Viewer-Address`, and the skip line says which.
success stamp throughout — so a partial run read as a complete one.
⚠️ **PARKED IS NOT SKIPPED, AND ONLY ONE OF THEM MOVES THE EXIT STATUS.** The two
pricing-plan items print under a `· … PARKED` heading and leave the status at
`0`: they are true on every run, and a signal that is always on is not a signal.
A **skip** is the unexpected kind — a source policy that vanished, a generated
payload that breaches a CloudFront limit, a handler-reads probe that matched
nothing — and the skip line always says which.
⚠️ **THE SHAPE CHANGES WITH THE STATE, SO READ THE `+` LINES AND NOT THE
TOTAL.** Each item Parts 13 have already applied prints `·` when it is found
@@ -366,8 +405,13 @@ and `+` when it is staged, so in the Part 0.3 state four lines cross from one
column to the other and the totals move with them. **The `+` lines are the
check.**
**Expect**this is the dry run, and on a distribution in the state Part 0.3
records the change lines are:
**Expect**⚠️ **THE BLOCK BELOW IS THE PRE-PARKING SHAPE AND IS KEPT AS THE
RECORD OF WHAT SECTIONS 4 AND 5 WOULD HAVE ADDED.** On a distribution in the
Part 0.3 state **today** the last four `+` lines do not appear: those are
sections 4 and 5, and both park. Expect **four** `+` lines, the `· … PARKED`
block, and exit 0. On the distribution as it now stands, changes 13 are already
applied, so expect **no** `+` lines at all and `NOTHING TO CHANGE`. The change
lines, as they were:
```
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
@@ -399,9 +443,10 @@ Fewer than eight changes means part of this is already done — read which lines
are prefixed `·` — but READ THE WORDS, not the bullet: `configure.mjs` uses `·`
for *already present* **and** for *would CREATE / would SET / would ADD*, so the
prefix alone does not say whether a line is done or still pending. **On the live distribution as at
2026-09-04, after the second `--apply` attempt, the dry run returns exactly
three `+` lines — **one** for section 4 (the behaviour; its policy already
exists, see the incident below) and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
2026-09-04, after sections 4 and 5 were parked, the dry run returns **no `+`
lines at all** — changes 13 are already applied and 4 and 5 are parked, so it
prints `NOTHING TO CHANGE` and **exit 0**, with a two-line PARKED block above
it** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag
`E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set,
means the distribution is not in the state 0.3 recorded: stop and re-read it.
@@ -523,10 +568,37 @@ duplicate name returns `ResponseHeadersPolicyAlreadyExists`, which is a
different error from the `InvalidArgument` above. The script never reaches it,
because it looks the policy up by name first.)
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` keeps its original
118-character text while the script now carries a shorter one. Deliberate:
adding `Comment` to the drift check would throw on this very policy and block
the run that attaches it.
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` kept its original
118-character text while the script carries a shorter one. Deliberate: adding
`Comment` to the drift check would have thrown on that policy and blocked the
run that attached it.
---
**ATTEMPT 3 — both policies were created, and `update-distribution` rejected the
whole change atomically.** With the `Comment` shortened, section 5's create
succeeded too, so the run reached the last call and was refused there:
```
An error occurred (InvalidArgument) when calling the UpdateDistribution operation:
Distributions with the Free pricing plan can't have the following features:
Custom origin request policy, Custom response headers policy
```
**The distribution was unchanged — but two orphaned policies were left**,
`51c4e79b-…` and `e88b32be-…`, both since **deleted by Pouya on 2026-09-04**. The
account is clean: **zero** custom response-headers policies, **zero** custom
origin request policies `[verified 2026-09-04]`.
⚠️ **THE "DO NOT DELETE THE ORPHAN BY HAND" GUIDANCE ABOVE WAS RIGHT FOR A
RECOVERABLE RUN AND IS NOW MOOT.** It rested on a later run adopting the policy
by name — which it did, measured — but a run that can never apply cannot adopt
anything. Deleting them was correct once the sections were parked.
⚠️ **EACH ATTEMPT GOT ONE STEP FURTHER AND THE LAST FAILED AT THE LAST CALL.**
That is precisely the case the pre-flight was built to prevent, and it could not:
the constraint is not in the payload, it is on the account. **Both sections now
stop before creating anything at all** — see the top of this Part.
**WHY NOTHING CAUGHT IT LOCALLY, AND THIS IS THE GENERAL LESSON.** Measured
2026-09-04 **against `aws-cli/2.34.53`'s bundled `botocore/validate.py`**: it
@@ -594,6 +666,12 @@ grep -o "headerOf(event, '[a-z-]*'" backend/intake/handler.mjs \
matches `function headerOf(event, name)`, the definition itself — so an operator
comparing it against a documented "four" concludes the handler grew a read.
🛑 **THE THREE PROBES BELOW ARE MOOT WHILE SECTION 5 IS PARKED** — nothing
replaces the origin request policy on `/api/*`, so there is nothing for them to
catch. **They become mandatory again the moment `PLAN_ALLOWS_CUSTOM_POLICIES` is
flipped**, which is why they stay here rather than being deleted. Part 7.1's
probe is unaffected and still applies.
**The failure mode is not an error.** Every submission would validate short and
redirect to `/contact/could-not-send/` — a real inquirer would read it as their
own browser misbehaving, and nothing would appear in a log as a fault. So the
@@ -744,6 +822,15 @@ aws cloudfront get-distribution-config --id "$DIST_ID" \
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, which is the managed policy it replaced
and is what a rollback restores.
🛑 **THE HEADER CHECK BELOW CANNOT PASS WHILE SECTION 4 IS PARKED, AND THAT IS
NOT A REGRESSION.** No response-headers policy is attached to `*.pdf`, so
`x-robots-tag` will read `0` — the substitute is `Disallow:` in
`public/robots.txt`, verified by fetching `/robots.txt`, not by fetching the PDF.
⚠️ **THE OTHER FIVE STILL MATTER AND SHOULD STILL READ `1`**: they come from the
**default behaviour's** policy, which is untouched, so a `0` among them is a real
regression and nothing to do with the parking. Run it that way — five `1`s and a
`0` — or skip it until the sections are un-parked.
**Then verify the header actually arrives, because the config landing is not the
same fact:**
@@ -1165,8 +1252,13 @@ the client sent. The fix, if a usable value is wanted, is a **custom** origin
request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
CloudFront generates and overwrites — not the managed
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
at API Gateway. ⚠️ **THAT CHANGE IS NOW IN THIS RUNBOOK — Part 3, change 8, written
2026-09-04 on Pouya's ruling and NOT YET APPLIED.** This paragraph said it was
at API Gateway. 🛑 **THAT CHANGE IS PARKED AND WILL NOT BE APPLIED — the pricing plan forbids a
custom origin request policy (Part 3, and `AGENTS.md` §7).** So the handler
stores the edge address and will keep doing so. **The capability it was for is
superseded, not lost**: a rate-based rule on the web ACL already attached to this
distribution matches the viewer address directly and needs no forwarded header
(§9 Q65). ⚠️ **Read the rest of this paragraph as the reasoning that makes the
change safe to un-park, not as a pending action.** This paragraph said it was
*"deliberately not in this runbook: measure first"*, which was true until the
ruling and false afterwards. **Forwarding the header does not change what is
stored:** `viewerIp()` still records `requestContext.http.sourceIp`, and the