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
+127 -2
View File
File diff suppressed because one or more lines are too long
+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` already drifted from it by 2026-08-26, which is the failure mode the `AGENTS.md`
§7 rule exists to stop. §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 prescribed `Disallow: /legal/` alongside `noindex` on those pages, and the two
cancel each other: a crawler forbidden to *fetch* a URL never reads the 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 `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 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 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 not the other is that the mitigation is not yet in force
- [ ] **`CloudFront-Viewer-Address` forwarded on `/api/*`**⚠️ **WRITTEN - [x] **`CloudFront-Viewer-Address` forwarded on `/api/*` — CLOSED 2026-09-04
2026-09-04, NOT YET APPLIED. Same `configure.mjs --apply` run as the item AS NOT AVAILABLE ON THIS PRICING PLAN, AND SUPERSEDED.** Pouya's ruling
below; not a deploy.** `infra/cloudfront/configure.mjs` §5 creates a custom 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/*` 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: 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 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 Forwarding is infrastructure; **storing** the viewer address is a
`/legal/privacy/` change governed by `docs/09` §7.2's decision table, and `/legal/privacy/` change governed by `docs/09` §7.2's decision table, and
it is deliberately not made here it is deliberately not made here
- [ ] **`X-Robots-Tag: noindex` on `*.pdf`**⚠️ **WRITTEN 2026-09-03, NOT YET - [x] **`X-Robots-Tag: noindex` on `*.pdf` — CLOSED 2026-09-04 AS NOT AVAILABLE
APPLIED. It needs a `configure.mjs --apply` run, not a deploy** — the same ON THIS PRICING PLAN. A SUBSTITUTE SHIPPED IN ITS PLACE.** Same rejection
run as the item above; one `--apply` does both. 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 `infra/cloudfront/configure.mjs` §4 creates a response-headers policy
`adr-sml-pdf-noindex` and a `*.pdf` cache behaviour carrying it. ⚠️ **S3 `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 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 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 (`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 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 - [ ] ⚠️ **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 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 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. 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 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 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 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 success stamp throughout — so a partial run read as a complete one.
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. ⚠️ **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 ⚠️ **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 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 column to the other and the totals move with them. **The `+` lines are the
check.** check.**
**Expect**this is the dry run, and on a distribution in the state Part 0.3 **Expect**⚠️ **THE BLOCK BELOW IS THE PRE-PARKING SHAPE AND IS KEPT AS THE
records the change lines are: 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 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 `·` 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 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 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 2026-09-04, after sections 4 and 5 were parked, the dry run returns **no `+`
three `+` lines — **one** for section 4 (the behaviour; its policy already lines at all** — changes 13 are already applied and 4 and 5 are parked, so it
exists, see the incident below) and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98KNKUTA`, ETag 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, `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. 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, different error from the `InvalidArgument` above. The script never reaches it,
because it looks the policy up by name first.) because it looks the policy up by name first.)
⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` keeps its original ⚠️ **`Comment` IS NOT RECONCILED**, so `51c4e79b` kept its original
118-character text while the script now carries a shorter one. Deliberate: 118-character text while the script carries a shorter one. Deliberate: adding
adding `Comment` to the drift check would throw on this very policy and block `Comment` to the drift check would have thrown on that policy and blocked the
the run that attaches it. 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 **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 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 matches `function headerOf(event, name)`, the definition itself — so an operator
comparing it against a documented "four" concludes the handler grew a read. 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 **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 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 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 `b689b0a8-53d0-40ab-baf2-68738e2966ac`, which is the managed policy it replaced
and is what a rollback restores. 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 **Then verify the header actually arrives, because the config landing is not the
same fact:** 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 request policy on `/api/*` forwarding `CloudFront-Viewer-Address`, which
CloudFront generates and overwrites — not the managed CloudFront generates and overwrites — not the managed
`AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request `AllViewerAndCloudFrontHeaders`, which forwards `Host` and would 403 every request
at API Gateway. ⚠️ **THAT CHANGE IS NOW IN THIS RUNBOOK — Part 3, change 8, written at API Gateway. 🛑 **THAT CHANGE IS PARKED AND WILL NOT BE APPLIED — the pricing plan forbids a
2026-09-04 on Pouya's ruling and NOT YET APPLIED.** This paragraph said it was 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 *"deliberately not in this runbook: measure first"*, which was true until the
ruling and false afterwards. **Forwarding the header does not change what is ruling and false afterwards. **Forwarding the header does not change what is
stored:** `viewerIp()` still records `requestContext.http.sourceIp`, and the stored:** `viewerIp()` still records `requestContext.http.sourceIp`, and the
+62 -6
View File
@@ -1,6 +1,9 @@
/** /**
* Applies the five distribution changes the site needs, as one reviewable * Applies the distribution changes the site needs, as one reviewable
* transaction. `docs/09-cutover-runbook.md` Part 3 is what calls it. * transaction. ⚠️ **THREE OF THE FIVE ARE APPLICABLE; 4 AND 5 ARE PARKED** —
* the pricing plan forbids both a custom response headers policy and a custom
* origin request policy, so they are reported and skipped rather than attempted.
* See `PLAN_ALLOWS_CUSTOM_POLICIES` below and `AGENTS.md` §7. `docs/09-cutover-runbook.md` Part 3 is what calls it.
* *
* 1. FunctionAssociations on the default behaviour -> `router.js`, viewer * 1. FunctionAssociations on the default behaviour -> `router.js`, viewer
* request. Without it 22 of 23 pages return S3's AccessDenied XML. * request. Without it 22 of 23 pages return S3's AccessDenied XML.
@@ -494,7 +497,42 @@ function findPdfPolicy() {
would both miscount and send an `update-distribution` carrying a config would both miscount and send an `update-distribution` carrying a config
nothing mutated. Skips get their own list and their own heading. */ nothing mutated. Skips get their own list and their own heading. */
const skipped = []; const skipped = [];
if (!defaultRhpId) { /* ⚠️ PARKED, NOT SKIPPED, AND THE DISTINCTION IS THE EXIT STATUS. A skip means
something unexpected happened and someone should look; these two are a
standing platform constraint that will be true on every run until the pricing
plan changes. Counting them as skips would make `exit 3` permanent, and a
signal that is always on is not a signal. */
const parked = [];
/**
* 🛑 **SECTIONS 4 AND 5 CANNOT BE APPLIED ON THIS DISTRIBUTION'S PRICING PLAN.**
* 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
*
* ⚠️ **AND IT FAILED AT THE LAST CALL, AFTER BOTH POLICIES HAD BEEN CREATED** —
* the whole point of the pre-flight is to fail before that, so both sections now
* stop here instead. `AGENTS.md` §7 records the plan; `docs/06` closes both
* items; `docs/09` Part 3 has all three attempts.
*
* ⚠️ **THIS IS A CONSTANT AND NOT A PROBE, BECAUSE THE PRICING PLAN IS NOT IN
* THE API.** Checked 2026-09-04 against the CloudFront model: 167 operations,
* and not one shape, member or documentation string mentions a pricing plan.
* `PriceClass_All` on this distribution 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 `update-distribution` rejection itself, which is the
* thing this exists to avoid. So: flip this to `true` when the plan changes,
* and the two sections come back exactly as they were.
*/
const PLAN_ALLOWS_CUSTOM_POLICIES = false;
if (!PLAN_ALLOWS_CUSTOM_POLICIES) {
parked.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — a custom response headers policy is not available on this distribution's pricing plan. The stand-in is \`Disallow: /pouya-lajevardi-bio.pdf\` in public/robots.txt`,
);
} else if (!defaultRhpId) {
skipped.push( skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the default behaviour has no ResponseHeadersPolicyId, so there is nothing to clone the security headers from`, `${PDF_PATTERN} / ${PDF_POLICY_NAME} — the default behaviour has no ResponseHeadersPolicyId, so there is nothing to clone the security headers from`,
); );
@@ -952,7 +990,11 @@ const apiBehaviour = (cfg.CacheBehaviors?.Items ?? []).find(
(b) => b.PathPattern === PATH_PATTERN, (b) => b.PathPattern === PATH_PATTERN,
); );
if (!apiBehaviour) { if (!PLAN_ALLOWS_CUSTOM_POLICIES) {
parked.push(
`${PATH_PATTERN} / ${ORP_NAME} — a custom origin request policy is not available on this distribution's pricing plan. Superseded rather than merely parked: the WAF web ACL already attached to this distribution is where a per-IP rule belongs (AGENTS.md §7)`,
);
} else if (!apiBehaviour) {
/* Unreachable in practice — section 3 either found it or pushed it — so if it /* Unreachable in practice — section 3 either found it or pushed it — so if it
fires, something above changed. Skip rather than throw, for the reason fires, something above changed. Skip rather than throw, for the reason
section 4 gives: sections 1-3 have already staged their mutations. */ section 4 gives: sections 1-3 have already staged their mutations. */
@@ -1160,6 +1202,18 @@ console.log('');
comment on `skipped`. A skip means section 4 did nothing and the PDF is comment on `skipped`. A skip means section 4 did nothing and the PDF is
probably not noindexed; that is louder than a silent omission and quieter probably not noindexed; that is louder than a silent omission and quieter
than a false change. */ than a false change. */
if (parked.length) {
console.log(
`· ${parked.length} thing(s) PARKED — not available on this distribution's pricing plan:`,
);
for (const k of parked) console.log(` ${k}`);
console.log(
' This is expected and does not affect the exit status. AGENTS.md §7',
);
console.log(' records the plan; flip PLAN_ALLOWS_CUSTOM_POLICIES if it');
console.log(' changes. Sections 1-3 are unaffected.');
console.log('');
}
if (skipped.length) { if (skipped.length) {
console.log(`${skipped.length} thing(s) SKIPPED, not changed:`); console.log(`${skipped.length} thing(s) SKIPPED, not changed:`);
for (const k of skipped) console.log(` ! ${k}`); for (const k of skipped) console.log(` ! ${k}`);
@@ -1182,8 +1236,10 @@ const exitCode = skipped.length ? EXIT_SKIPPED : 0;
if (changes.length === 0) { if (changes.length === 0) {
console.log( console.log(
skipped.length skipped.length
? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry all five.' ? 'NOTHING TO CHANGE — but see the skips above; the distribution does NOT carry everything this script manages.'
: 'NOTHING TO CHANGE — the distribution already carries all five.', : parked.length
? 'NOTHING TO CHANGE — the distribution carries everything this script can apply on the current pricing plan. The parked items above are not among them.'
: 'NOTHING TO CHANGE — the distribution already carries all five.',
); );
process.exit(exitCode); process.exit(exitCode);
} }
+22 -10
View File
@@ -2,18 +2,30 @@
# AI crawlers are deliberately allowed. Being read by an assistant that counsel # AI crawlers are deliberately allowed. Being read by an assistant that counsel
# is using to shortlist a neutral is the point. See docs/04-seo-spec.md. # is using to shortlist a neutral is the point. See docs/04-seo-spec.md.
# NOTHING IS DISALLOWED, DELIBERATELY. # EXACTLY ONE THING IS DISALLOWED, AND IT IS A SUBSTITUTE FOR A MECHANISM THIS
# /legal/* is kept out of the index by # DISTRIBUTION IS NOT ALLOWED TO HAVE.
# `<meta name="robots" content="noindex,follow">`, which is the directive that # The bio PDF duplicates /bio/. The intended fix was `X-Robots-Tag: noindex` on
# actually de-indexes. Disallowing them as well would defeat it: a crawler that # *.pdf via a CloudFront response-headers policy, which the distribution's
# is forbidden to FETCH a URL never reads the noindex on it. The legal pages are # pricing plan forbids — AGENTS.md §7, and docs/09 Part 3 for the three failed
# linked from the footer of every page, so Google discovers them regardless and # attempts. Disallow is the remaining lever and it is NOT the same instrument:
# would have listed the bare URLs as "no information available" — the opposite # - it stops the PDF being FETCHED, so its contents are never indexed and the
# of the intent — with the noindex sitting unread behind the wall. # duplicate-content problem it was raised for is solved;
# Add a Disallow only for something that must not be FETCHED. Use noindex for # - it does NOT de-index the URL. The PDF is linked from /bio/ and /about/, so
# something that must not be LISTED. They are different problems. # a search engine can still list the bare URL with no snippet.
# That residual is accepted deliberately: a bare link to a bio PDF is not the
# harm the noindex was for. Revisit if the pricing plan ever changes.
#
# /legal/* is still NOT disallowed, and that reasoning is unchanged — it is the
# general rule this one path is the exception to. A crawler forbidden to FETCH a
# URL never reads the `noindex` on it, so the two cancel; the legal pages are
# linked from every footer, so they are discovered regardless, and the pair would
# have produced bare "no information available" listings with the directive that
# would have suppressed them sitting unread behind the wall.
# `noindex` is what de-indexes; `Disallow` is what prevents fetching. Use the one
# that matches the problem, and never both on the same path.
User-agent: * User-agent: *
Disallow: /pouya-lajevardi-bio.pdf
Allow: / Allow: /
Sitemap: https://adr.smlcompany.ca/sitemap-index.xml Sitemap: https://adr.smlcompany.ca/sitemap-index.xml