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
+14 -1
View File
@@ -153,7 +153,20 @@ here.** This spec used to reproduce the file inline and the reproduction had
already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
§7 rule exists to stop.
**It disallows nothing, and that is deliberate.** This spec previously
**It disallows exactly one path, and everything about that exception is in the
file.** ⚠️ **THIS READ "It disallows nothing, and that is deliberate" UNTIL
2026-09-04.** `Disallow: /pouya-lajevardi-bio.pdf` was added that day as the
stand-in for `X-Robots-Tag: noindex` on `*.pdf`, which needs a CloudFront
response-headers policy the distribution's pricing plan forbids (`AGENTS.md` §7).
⚠️ **IT IS A SUBSTITUTE, NOT AN EQUIVALENT, AND THE RULE BELOW IS WHY.** It stops
the PDF being *fetched* — so its contents are never indexed and the
duplicate-of-`/bio/` problem is solved — but it does **not** de-index the URL,
and the PDF is linked from `/bio/` and `/about/`, so a bare listing remains
possible. That residual is accepted deliberately. **The rule below is unchanged
and this is its exception, not its repeal.**
The general rule: this spec previously
prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
cancel each other: a crawler forbidden to *fetch* a URL never reads the
`noindex` on it. `/legal/privacy/` and `/legal/terms/` are linked from the
+44 -7
View File
@@ -1229,9 +1229,22 @@ the decision is re-readable rather than re-litigated.
renders a field nothing checks, and a handler ahead of the form checks a
field nothing renders. Both are inert, so the only cost of doing one and
not the other is that the mitigation is not yet in force
- [ ] **`CloudFront-Viewer-Address` forwarded on `/api/*`**⚠️ **WRITTEN
2026-09-04, NOT YET APPLIED. Same `configure.mjs --apply` run as the item
below; not a deploy.** `infra/cloudfront/configure.mjs` §5 creates a custom
- [x] **`CloudFront-Viewer-Address` forwarded on `/api/*` — CLOSED 2026-09-04
AS NOT AVAILABLE ON THIS PRICING PLAN, AND SUPERSEDED.** Pouya's ruling
after the third `--apply`: `update-distribution` rejected the change
atomically — *"Distributions with the Free pricing plan can't have the
following features: Custom origin request policy, Custom response headers
policy"* — so this is a **platform constraint, not a defect**. It is
**revisited only if the plan changes**; `configure.mjs` now parks section 5
instead of attempting it, and `AGENTS.md` §7 records the plan.
🛑 **SUPERSEDED, NOT MERELY PARKED: a WAF web ACL is already attached to
this distribution (`CreatedByCloudFront-f8fbf256`, §7), and that is where
any future per-IP rate rule belongs** — a forwarded viewer address was only
ever the means to an end this already provides. The original item is kept
below because its reasoning about the whitelist is what makes section 5
safe to un-park. ⚠️ **WRITTEN
2026-09-04, NOT APPLIED, AND NOW UNAPPLIABLE. Same `configure.mjs --apply`
run as the item below; not a deploy.** `infra/cloudfront/configure.mjs` §5 creates a custom
origin request policy `adr-sml-api-viewer-address` and points the `/api/*`
behaviour at it. Pouya's ruling of 2026-09-04, after the first real spam:
forward it **so per-IP measures become possible later — measured, not yet
@@ -1250,9 +1263,23 @@ the decision is re-readable rather than re-litigated.
Forwarding is infrastructure; **storing** the viewer address is a
`/legal/privacy/` change governed by `docs/09` §7.2's decision table, and
it is deliberately not made here
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**⚠️ **WRITTEN 2026-09-03, NOT YET
APPLIED. It needs a `configure.mjs --apply` run, not a deploy** — the same
run as the item above; one `--apply` does both.
- [x] **`X-Robots-Tag: noindex` on `*.pdf` — CLOSED 2026-09-04 AS NOT AVAILABLE
ON THIS PRICING PLAN. A SUBSTITUTE SHIPPED IN ITS PLACE.** Same rejection
as the item above: a custom response headers policy is not available on the
Free plan, so this is a **platform constraint, not a defect**, revisited
only if the plan changes.
**The substitute is `Disallow: /pouya-lajevardi-bio.pdf` in
`public/robots.txt`** — it needs a **site deploy**, not a `configure.mjs`
run. ⚠️ **IT IS NOT AN EQUIVALENT AND `public/robots.txt` SAYS SO IN THE
FILE.** `Disallow` stops the PDF being **fetched**, which solves the
duplicate-of-`/bio/` problem this item was raised for; it does **not**
de-index the URL, and the PDF is linked from `/bio/` and `/about/`, so a
bare listing remains possible. That residual is accepted deliberately.
`docs/04` §Crawlability carries the general rule this is the exception to.
The original item follows, because its reasoning is what makes section 4
safe to un-park. ⚠️ **WRITTEN 2026-09-03, NOT
APPLIED, AND NOW UNAPPLIABLE. It needed a `configure.mjs --apply` run, not
a deploy** — the same run as the item above; one `--apply` did both.
`infra/cloudfront/configure.mjs` §4 creates a response-headers policy
`adr-sml-pdf-noindex` and a `*.pdf` cache behaviour carrying it. ⚠️ **S3
OBJECT METADATA CANNOT DO THIS, which is the natural first reach and was
@@ -1468,7 +1495,17 @@ the decision is re-readable rather than re-litigated.
that spec line is struck and an aggregate route throttle ships instead
(`docs/09` Part 6.3). A rate-based WAF rule on the distribution is what
would do per-IP. Decide it on price, not on the spec's old wording — and do
not let anything describe the throttle that ships as per-IP
not let anything describe the throttle that ships as per-IP.
🛑 **THE PRICE QUESTION IS SMALLER THAN THIS ITEM ASSUMES, MEASURED
2026-09-04.** A web ACL is **already attached and already running** on the
distribution — `CreatedByCloudFront-f8fbf256`, 925 WCU, three AWS managed
rule groups, and **no rate-based statement** (`AGENTS.md` §7 and §9 Q65).
So this is not "buy WAF"; it is "add one rule to an ACL already being paid
for". ⚠️ **AND THE ROUTE THIS ITEM ASSUMED IS GONE:** the
`CloudFront-Viewer-Address` forwarding was parked as unavailable on the
pricing plan — but a rate-based rule matches on the viewer address itself
and never needed that header, so the capability is **superseded, not
blocked**
- [ ] ⚠️ **A FOOTER NAV LABEL OVERRUNS ITS COLUMN BY 24 px AT 640 px UNDER
MINIMUM FONT SIZE, WITH 7.7 px OF CLEARANCE TO THE NEXT COLUMN.** No document
+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