fix: omit AWS's empty-object placeholders from the cloned PDF policy
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
`configure.mjs --apply` failed on its first write, 2026-09-04, and nothing
reached the distribution. `get-response-headers-policy` returns
`"ContentSecurityPolicy": {}` for a member the source does not define, and
sending that back fails `create-response-headers-policy` on ParamValidation
before the call leaves the machine — a config AWS hands back is not
necessarily a config AWS will accept.
Of the 16 structures reachable from `ResponseHeadersPolicyConfig` in the CLI's
service model, 15 declare a required field, so `{}` is illegal there and can
only be the placeholder; the one exception is `SecurityHeadersConfig` itself,
which section 4 already skips on when empty. The strip is therefore recursive.
The dry run now asserts the generated config carries no empty object, and does
so as a section-4 SKIP rather than a throw — section 4 must never block
sections 1-3 from re-applying `router.js`.
The two functions move to `policy-shapes.mjs` with a 23-case test (7 of 7
mutations killed), because `configure.mjs` reads argv and calls AWS at import
time and the runbook was otherwise claiming a proof nobody could re-run.
Also: the handler was redeployed 2026-09-04 via docs/09 §5.5. Re-read against
production — the two bundled SDK clients moved 3.1125.0 -> 3.1126.0 with no
file in this repository changing, which is what §7's own row predicted. §12
gains R22, because that row named itself as the reminder covering them while
no such reminder existed. docs/05, docs/06 and docs/09 §5.5 each held their
own stale copy of the deployed commit; all three now cite §7.
Reviewed twice by adversarial-reviewer: 7 findings, then 8, of which five were
defects in the first round's repairs. All 15 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:
co-authored by
Claude Opus 5
parent
3c3ba5dc6e
commit
bbe535d158
+116
-8
@@ -321,8 +321,24 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN"
|
||||
--function-arn "$ROUTER_ARN"
|
||||
```
|
||||
|
||||
**Expect** — this is the dry run, and the output on a distribution in the state
|
||||
Part 0.3 records is exactly:
|
||||
⚠️ **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
|
||||
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 1–3 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"*.
|
||||
|
||||
**Expect** — this is the dry run, and on a distribution in the state Part 0.3
|
||||
records the change lines are:
|
||||
|
||||
```
|
||||
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
||||
@@ -359,6 +375,96 @@ and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98
|
||||
`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:
|
||||
|
||||
```
|
||||
An error occurred (ParamValidation): Parameter validation failed:
|
||||
Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override"
|
||||
Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy"
|
||||
```
|
||||
|
||||
**The cause, and it generalises past this script: a config AWS hands back is not
|
||||
necessarily a config AWS will accept.** `get-response-headers-policy` on
|
||||
`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` — an empty
|
||||
object standing for a member the policy does not define — and
|
||||
`ResponseHeadersPolicySecurityHeadersConfig` has **no required members** while
|
||||
**every one of its six members requires at least `Override`**. So an empty member
|
||||
is always "undefined here" and is **never** a legal input.
|
||||
|
||||
⚠️ **AND THE SAME IS TRUE ONE LEVEL UP, WHICH THE FIRST FIX MISSED.** Every
|
||||
sibling member of `ResponseHeadersPolicyConfig` also declares required fields —
|
||||
`CorsConfig` five of them, `RemoveHeadersConfig` and `CustomHeadersConfig` a
|
||||
`Quantity`, `ServerTimingHeadersConfig` an `Enabled` — while the container
|
||||
itself requires only `Name`. So `{}` is a placeholder at **both** levels, and a
|
||||
fix covering only the inner one turns the outer placeholder into a hard abort
|
||||
instead of an omission. All of that is read out of the CLI's own service model,
|
||||
not inferred from the symptom.
|
||||
|
||||
**State after the failure** ``[verified 2026-09-04 — `get-distribution-config`, `list-response-headers-policies --type custom`, `list-origin-request-policies --type custom`]``**:** `/api/*` still on
|
||||
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, **no** `*.pdf` behaviour, **zero** custom
|
||||
response-headers policies, **zero** custom origin request policies.
|
||||
|
||||
⚠️ **THE "ONE REVIEWABLE TRANSACTION" PROPERTY IS ABOUT THE DISTRIBUTION, NOT
|
||||
THE ACCOUNT — AND THIS RECORD ASSERTED THE WIDER VERSION FOR ONE ROUND.**
|
||||
`update-distribution` is the script's last call, so a throw above it does leave
|
||||
the **distribution** untouched. But sections 4 and 5 each make their own write
|
||||
first — `create-response-headers-policy` and `create-origin-request-policy` —
|
||||
and the script's own comment on section 5's drift throw documents a reachable
|
||||
path where section 4 has **already created `adr-sml-pdf-noindex`** when section
|
||||
5 aborts. **So after any failed `--apply`, check for an orphaned policy as well
|
||||
as for a changed distribution**, with both of these:
|
||||
|
||||
```bash
|
||||
aws cloudfront list-response-headers-policies --type custom --output json \
|
||||
--query 'ResponseHeadersPolicyList.Quantity'
|
||||
aws cloudfront list-origin-request-policies --type custom --output json \
|
||||
--query 'OriginRequestPolicyList.Quantity'
|
||||
```
|
||||
|
||||
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
|
||||
`[verified 2026-09-04]`.
|
||||
|
||||
**Two changes came out of it.** The clone now **omits** any empty member at
|
||||
either of those two levels — a `ResponseHeadersPolicyConfig` member, or a
|
||||
`SecurityHeadersConfig` member — and the dry run **asserts** that the generated
|
||||
config carries no empty object at any *other* level, naming the dotted path if
|
||||
it does. The asymmetry is deliberate: those two levels are where AWS is known to
|
||||
synthesise a placeholder, and anywhere else is unaccounted for and stops the
|
||||
run rather than being discarded quietly. The assertion runs before every branch, so **the dry run now catches this
|
||||
class** rather than an `--apply` discovering it — and if it ever does fire it
|
||||
**skips section 4** rather than throwing, so `router.js` can still be
|
||||
re-applied.
|
||||
|
||||
**The proof is a command rather than a session**, which is the point of
|
||||
`infra/cloudfront/policy-shapes.mjs` existing as its own module — `configure.mjs`
|
||||
reads argv and calls AWS at import time, so the two functions could not
|
||||
otherwise be reached:
|
||||
|
||||
```bash
|
||||
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]`.
|
||||
|
||||
**One of the `·` lines carries a number worth reading**, and it is not a
|
||||
warning:
|
||||
|
||||
```
|
||||
· cloning 5 defined security header(s); omitting 1 the source does not define (ContentSecurityPolicy)
|
||||
```
|
||||
|
||||
**Five is the number to read.** It is the count of security headers the PDF
|
||||
policy will carry, and the verification block at the end of this Part counts the
|
||||
same five arriving on the live PDF. A drop in this number is a partial clone
|
||||
announcing itself one step earlier than that `curl` would.
|
||||
|
||||
⚠️ **RUN IT WITHOUT `--function-arn` ONLY IF THE ROUTER IS ALREADY ATTACHED.**
|
||||
Omitting the flag prints `· no --function-arn given, leaving FunctionAssociations
|
||||
alone` and skips change 1 — which is right on a re-run and wrong on a first one,
|
||||
@@ -734,12 +840,14 @@ file: `CLAUDE.md`'s rule is that a version is checked against the registry and
|
||||
never recalled, and a literal here would be stale the week after it was written.
|
||||
|
||||
⚠️ **THIS PATH WAS TAKEN — 2026-09-02, and the live function is the bundled
|
||||
zip** `[measured 2026-09-04 — the deployed artefact was downloaded via
|
||||
`get-function` `Code.Location` and read]`. Its two source entries were
|
||||
byte-identical to commit `02739ad`, and the two packages inside it are
|
||||
**`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**.
|
||||
`AGENTS.md` §7 now records them, which this step required in terms and which did
|
||||
not happen at the time.
|
||||
zip.** ⚠️ **THE TWO RESOLVED VERSIONS AND THE COMMIT THE DEPLOYED SOURCES MATCH
|
||||
ARE IN `AGENTS.md` §7 AND ARE DELIBERATELY NOT REPEATED HERE.** They were
|
||||
repeated here until 2026-09-04, and the paragraph directly above is the argument
|
||||
against it — **the literals went stale in two days rather than a week**: the
|
||||
2026-09-04 redeploy moved both packages one patch and added a third source file,
|
||||
and this copy still named the old versions and a superseded commit while reading
|
||||
as a measurement. Read §7's two Lambda rows; this step's duty is to **update**
|
||||
them, not to mirror them.
|
||||
|
||||
✅ **THE `cp` AND `zip` LINES BELOW DERIVE THE FILE LIST THE SAME WAY 5.1 DOES.**
|
||||
They were a second hand-typed copy until 2026-09-04, not derived from 5.1's and
|
||||
|
||||
Reference in New Issue
Block a user