Skip to content

hl-assets edge policy in three zone rulesets: /f/-scoped caching, five response headers, allowlist-inline disposition — with Hotlink Protection deliberately off and one measured response class the transform phase does not reach

Date: 2026-07-31 Status: Accepted Deciders: Sean Brandt

Milestone v1.5 serves personal assets from i.hl-assets.dev, an R2 custom domain with no origin below the Cloudflare edge. The split read/write ADR fixed the architecture. This record fixes the edge policy that runs on top of it: what gets cached and for how long, what headers every response carries, and which responses download rather than render.

Two facts force the work rather than making it an optimisation.

First, HEIC, HEIF and MOV are absent from Cloudflare’s fixed cacheable-extension allowlist. Without a Cache Everything rule plus an Edge TTL override, every view of this milestone’s primary ingest format is a live, billable R2 GetObject — forever. The design’s cost and latency premise is false until an explicit cache rule lands, which is why it lands before any real asset is shared. This is not an inference: the pre-apply capture in cache-heic.pre.observed.txt records cf-cache-status: DYNAMIC on both fetches of a colo-pinned pair with no cache-control header at all, while a .png in the same pre-state already went MISSHIT. A passing .png would have proven nothing.

Second, anything served from this host is handed to a stranger by URL. The URL is the capability. A .svg or .html that renders inline on i.hl-assets.dev is same-origin active content on a hostname whose whole purpose is to be shared, and the browser has no other signal to go on.

This record covers D-24 … D-41, the phase’s Terraform-only edge work. It is a second ADR rather than an amendment to Phase 1’s because the two have different lifetimes: the split read/write architecture is settled for the life of the milestone, while these edge rules are explicitly revisited in Phase 6 against measured usage.

A discipline this project earned, and applies here. Phase 1’s ADR asserted that the vendor HTML 404 does not mark the surface. Measurement showed it reads “Is this your bucket?” and links to Cloudflare’s own R2 documentation, and the claim had to be narrowed to what was observed after publication. Every claim below therefore carries either the committed evidence file that supports it or an explicit by construction label. Where a measurement contradicted a prediction, the measurement is what is written down — in both directions, including the direction that made the design look better than expected.

Three cloudflare_ruleset resources on hl-assets.dev (cc148ffdfdeaea99cd2f1aa72c5d6715), declared in tf/cloudflare/rulesets-hl_assets_dev.tf:

Ruleset Phase Id What it does
hl_assets_dev_cache_settings http_request_cache_settings 231ea61aa69248b6b3b0e5d33de8f592 Cache Everything under /f/*; edge TTL 2592000 s, browser TTL 3600 s, 404 TTL 30 s
hl_assets_dev_response_headers http_response_headers_transform 9b4fc274996f4baf96eff283f2935660 Five security headers on true; Content-Disposition: inline for an eleven-extension allowlist under /f/, attachment for everything else under /f/
hl_assets_dev_request_transform http_request_transform 15851f44b8ca452a9de379183722c3a6 Blanks the query string under /f/ before the cache phase

Applied by two VCS-triggered HCP runs: run-GtWfmNcPwaXp26mV (1 added) and run-BK9LU9AjR1hH2RQw (2 added). No run was started by hand — main is branch-protected and tf/CLAUDE.md:84 is GitOps-only.

Alongside them: Cloudflare Hotlink Protection stays off, Cross-Origin-Resource-Policy is sent at its permissive value, and robots.txt permits crawling of /f/ rather than disallowing it.

The cache rule matches a /f/ path predicate, not the hostname (D-24). Two consequences follow, and both are the point of the decision rather than side effects of it. Phase 6’s uptime monitor probes /_health/canary, which stays uncached, so it keeps reaching R2 for real instead of returning a month-old edge copy while R2 is unreachable — a monitor whose probe is cached measures the cache. And a robots.txt edit takes effect without a purge. Cost control still lands where the cost is: /f/ is the only prefix that ever sees volume.

The scoping is measured, not assumed. headers-by-class.observed.txt records cf-cache-status: DYNAMIC on /_health/canary beside HIT on /f/_probe/cacheprobe.heic in the same capture.

Edge TTL is one month (2592000 s), not the immutable year the roadmap’s prose assumed (D-25). Content-addressed keys make the bytes immutable, but immutability is a claim about the bytes, not about whether they should still be served — and takedown is the operation that breaks the equivalence. Takedown is an operator action that can be forgotten, and nothing in this design detects a forgotten purge. One month is a self-healing window on the one failure mode that is otherwise silent, bought for a small number of extra R2 GetObject calls on genuinely long-lived links. This is an argument by construction: no forgotten purge has been observed, because the operation does not exist until Phase 6.

Browser TTL is one hour (D-26). A zone purge clears Cloudflare’s edge; it cannot reach a browser that already holds the bytes. With a long browser TTL an unshare is invisible to everyone who already opened the link — the one cache no operator action can invalidate. One hour keeps the session that matters cheap (someone scrolling a chat thread) while making takedown effective within the hour. Same reasoning as D-25, applied to the tier further from the operator. Argument by construction, for the same reason.

The measured result. cache-heic.observed.txt and cache-mov.observed.txt record colo-pinned pairs on IAD going cf-cache-status: MISSHIT with cache-control: max-age=3600 on both fetches. They invert, assertion for assertion and against the same URLs, the DYNAMIC / DYNAMIC pre-state committed in cache-heic.pre.observed.txt — where no cache-control header appeared at all. Both extensions were asserted, because a .png was already MISSHIT before any rule existed.

Negative caching as a correctness guard — D-41

Section titled “Negative caching as a correctness guard — D-41”

The 404 TTL is framed the way the research framed it, and the framing matters. Because D-24 scopes the rule to /f/*, a 404 under that prefix would otherwise inherit the month. So anyone who fetches a key before its object exists — a race between issuing a link and completing an upload — gets a cached miss served back for a month afterwards, from an edge that has no reason to re-ask. Content-addressed random keys make that vanishingly unlikely, but a per-status-code TTL of 30 s removes the class outright rather than making it rare. Argument by construction: the race has not been observed, because no uploader exists yet.

The cost argument is real and secondary: an unbounded 404 flood under /f/* is unbounded billable R2 GetObject attempts, and 30 s bounds it to roughly one origin request per key per interval.

The mechanism is measured: the harness’s ep-cache-neg404 fetched a random absent /f/ key MISSHIT (so the 404 is cached), then EXPIRED 35 s later from the same colo, with the age bound evaluated before the status — so a 404 silently inheriting the 2592000-second month is a failure rather than an unnoticed pass.

D-27: the intent held, the mechanism amended

Section titled “D-27: the intent held, the mechanism amended”

This did not ship as specified, and the record says so. D-27’s intent is unchanged and is what the shipped rule serves: a query string must not fragment the cache key or amplify billable origin fetches. Cloudflare’s default cache key includes the full query string, so anyone holding a link can generate unbounded misses by appending random parameters; and content-addressed keys carry no parameters, so no query string is ever legitimate here.

The mechanism D-27 names — cache_key.custom_key.query_string.exclude — is not available:

  • Cloudflare places the whole query-string cache-key section behind Enterprise/PAYG, and hl-assets.dev reports plan.name = "Free Website". Measured against the live zone.
  • Separately, on provider 5.22.0 exclude is an object ({ all = true }) rather than the string "*" the decision was written with, so that shape is a type error before it is a plan error. Measured against the pinned provider’s own schema, via a local terraform providers schema -json dump that starts no HCP run.

The obvious fallback is wrong, and was rejected rather than overlooked. cache_level = "simplified" (Ignore Query String) disregards the query string only for static file extensions — which is exactly the set .heic and .mov are absent from, the entire premise of this phase. It is not a substitute; it is the same gap wearing a different name.

What shipped instead: a request-phase URL rewrite (hl_assets_strip_query_string) that blanks the query string under /f/ when one is present. Cloudflare’s documented phase order places http_request_transform before the cache-settings phase, which is what makes the substitute equivalent — the rewritten http.request.uri is what the cache phase sees.

This is an amendment to D-27’s mechanism, not a change to its intent, and it is named as such in the Terraform file itself as well as here. This project has precedent for exactly that move: D-35 amended SC#4’s mechanism while keeping its goal.

The pre-state was captured in the one window where it was observable at all, and it is unrepeatable on this zone. cache-qs.pre.observed.txt records QS bare HIT beside QS variant MISS from colo IAD — the same bytes, a random parameter, a separate cache entry, a billable GetObject. Post-apply, the harness’s ep-cache-qs records the variant returning HIT at the bare key’s exact age (6877 s, drift 0 s, colo IAD), because a variant that fragmented the key an hour ago also reports HIT from its own entry.

Response headers — D-28, D-29 — and the one class the transform phase does not reach

Section titled “Response headers — D-28, D-29 — and the one class the transform phase does not reach”

The response-header rule uses expression true (D-28), not a /f/ predicate matching the cache rule. Scoping it to /f/* would leave bare the root 404 — the single surface Phase 1 spent an entire success criterion and a committed 404-body.observed.html fixture on.

Five headers, not the two the success criterion names (D-29):

Header Value Why
X-Robots-Tag noindex, noimageindex, nofollow, noarchive The host is not indexed. This is the mechanism that enforces it
X-Content-Type-Options nosniff Stops a browser re-deciding the type of bytes it was handed
Content-Security-Policy sandbox Backstops the disposition rule — neuters scripts, forms and same-origin access if anything renders inline despite Content-Disposition. The header GitHub uses on raw user content
Referrer-Policy no-referrer The URL is the capability; stop it leaking onward when a viewer clicks away
Cross-Origin-Resource-Policy cross-origin See D-30 below

All five are static, zero-config and free at the edge.

SC#2’s claim, stated honestly. The criterion was written as “every response from the domain”. What is true, and what the gate asserts, is every response the transform phase reaches — and here is the one it does not, measured.

headers-by-class.observed.txt records five response classes verbatim. Four of them — a 200 object under /f/, an absent-key 404 under /f/, the root 404, and /_health/canary — carry all five headers. The fifth, GET /robots.txt, carries none of them. Cloudflare’s managed-robots.txt module synthesises that response at the edge, upstream of the response-header transform phase, so no transform rule reaches it. Measured post-apply, and recorded in deferred-items.md § D4:

Request Transform-phase headers Body
GET /robots.txt 0 of 5 2754 B, managed block prepended
GET /robots.txt?x=1 0 of 5 2754 B
HEAD /robots.txt 5 of 5 918 B from R2, cf-cache-status: HIT
GET /ROBOTS.TXT 5 of 5 R2 404
GET /robots.txt/ 5 of 5 R2 404

That table also records a methodology trap worth carrying forward: curl -I sends HEAD, which is precisely the case that does carry all five. A HEAD-based check would have reported the gap as absent. The gap was found with a real GET.

This is accepted, and the accept is argued rather than asserted. The un-stamped response is a 2,754-byte text/plain policy file with no active content. Content-Security-Policy: sandbox and Cross-Origin-Resource-Policy are inert for it. Referrer-Policy has nothing to refer from. X-Robots-Tag: noindex is moot on a file whose entire purpose is to be fetched by crawlers. X-Content-Type-Options: nosniff is the only one of the five with even an arguable role, and the payload is a robots.txt body served as text. Against that, closing the gap means reopening Q-2 (below): a second dashboard grant, an Enterprise-attribute resource with no verified rollback shape on provider 5.22.0 introduced to a shared workspace on a Free zone, the loss of nine per-bot Disallow: / groups Cloudflare maintains for free, and rework of assertions that are already green. Sean was shown that trade and chose accept on 2026-07-31.

What keeps the accept honest. The gate’s ep-hdr-unreached check asserts the exclusion set equals exactly {robots} by set equality, so it goes red if the gap ever widens and if it silently closes. A tolerated exclusion decays into folklore; an exclusion pinned by set equality cannot.

Section titled “Hotlink Protection stays off — and Cross-Origin-Resource-Policy is permissive (D-30)”

These two sit together because they are the same argument reached by different mechanisms.

Cloudflare Hotlink Protection is off, deliberately. Four reasons, each disqualifying on its own:

  1. It covers five image extensions, none of which are phone formats — so it would not protect the one thing this host actually serves.
  2. It permits a blank Referer, which is every command-line fetch and most chat clients — so the traffic it does cover is the traffic least in need of covering.
  3. It breaks legitimate embedding, which is a use of a shared link rather than an abuse of one.
  4. It adds a magic hotlink-ok key-space exemption, a second naming convention layered on a key format this milestone deliberately made opaque.

The cache is the cost control; hotlink protection never was. Argument by construction — these are properties of Cloudflare’s feature as documented, not measurements taken on this zone, and the feature was never enabled here to measure. If hotlinking ever bites, the precise instrument is a cloudflare_ruleset rule on http.referer, not this switch.

Cross-Origin-Resource-Policy is cross-origin, not same-site (D-30). The restrictive value blocks cross-origin no-cors subresource loads — which is exactly what an <img src="https://i.hl-assets.dev/f/…"> on another site is. That is hotlink protection by another mechanism, arriving through a header instead of a dashboard toggle, and it is rejected for reason 3 above.

Sending the permissive value is behaviourally identical to sending nothing today. It is sent anyway because it records the intent in the response itself, so a future reader — or a browser that changes its default — sees a deliberate choice rather than an omission. Argument by construction: no browser was observed changing its default; the value is insurance against one doing so. The Terraform file asserts the permissive value positively and the restrictive value negatively, with the reason inline, so that a later “tighten the headers” pass cannot flip it silently.

Download versus render — D-31, D-32, D-33

Section titled “Download versus render — D-31, D-32, D-33”

Allowlist-inline (D-31). Everything under /f/ gets Content-Disposition: attachment except a named list of extensions that render inline. It fails closed: a format nobody anticipated downloads rather than executes. The set of things a browser will execute is long, grows without notice, and a miss under the opposite (denylist) design is silent — the object renders, nothing errors, and nobody finds out until it matters. Argument by construction. The cost is stated: adding a renderable format later is a Terraform change and an apply.

The allowlist is exactly eleven extensions (D-32): .jpg .jpeg .png .gif .webp .avif (common web images), .heic .heif (phone-native stills), .mp4 .mov .webm (video).

.pdf was considered and excluded. Browsers render it through a built-in viewer that executes embedded JavaScript, it is not a phone format, and it is the only candidate on the list with an active-content surface. It downloads.

The edge rule is authoritative in both directions (D-33) — it sets attachment for non-allowlisted extensions and sets inline for allowlisted ones, overwriting whatever R2 serves from object metadata either way. One source of truth, fully verifiable in this phase against hand-placed objects, and an uploader bug can never make a .svg render.

Its named cost: this forecloses serving a friendly download filename through Content-Disposition: attachment; filename="…". The key format constrains the key, not a response header, so that option was otherwise open; reopening it means relaxing this rule for allowlisted types.

Why extension keying is sound here in a way it usually is not. Normally an attacker controls the URL extension, which is what makes extension-keyed rules fragile. Here the extension is baked into the content-addressed key, so a mismatched extension simply has no object to serve — it 404s. Argument by construction, resting on the key format fixed in the split read/write ADR.

Measured. disposition.observed.txt records fourteen probed URLs, one line per URL, with absence rendered (absent) rather than omitted: svg and html return attachment, and all eleven allowlisted extensions return inline. It inverts disposition.pre.observed.txt, where the same extractor read heic=absent html=absent svg=absent. The mapping is asserted per object by set equality, never by counting attachment observations — a count passes while the wrong object is the one downloading.

robots.txt — D-34, D-35 — and the two contradictions the measurements surfaced

Section titled “robots.txt — D-34, D-35 — and the two contradictions the measurements surfaced”

D-35 reversed the original criterion, and the reasoning is the load-bearing part. A strict Disallow: / stops a compliant crawler fetching the object, so it never reads the X-Robots-Tag: noindex header — which is why the vendor’s own guidance is not to use this file to block something you want de-indexed. The header is the stronger mechanism; a disallow-all body actively undercuts it. So the committed body permits /f/ and disallows /_health/ — the latter for cost, not indexing: /_health/ is uncached by D-24, so every crawl of it is a live billable GetObject, and it is the one object in the bucket with a fixed, guessable name.

The object must exist regardless of its content: without it, GET /robots.txt falls through to R2’s 404 page, which Phase 1 measured as reading “Is this your bucket?” with a link to Cloudflare’s R2 documentation — vendor attribution served to every crawler that asks.

Then the measurement changed what could be asserted. Probe R fetched the live path and captured the served body verbatim as robots-served.observed.txt. Cloudflare’s managed- robots.txt module intercepts that path at the edge and prepends managed content:

  • status 200, content-type: text/plain; charset=utf-8, content-length: 2754;
  • no cf-cache-status header at all — a response that reached an origin carries one, so this one never reached R2;
  • 2754 − 918 = 1836, exactly the managed block’s length; tail -c 918 + cmp -s against the committed file compared equal, and an independent containment cross-check returned byte offset 1836, corroborating the verdict rather than merely being consistent with it.

Verdict: RELATIONSHIP: suffix. The repo’s committed bytes do reach every visitor — they are the tail of what is served.

Two contradictions surfaced, and neither is left implicit.

First: the zone was already blocking named AI scrapers — by the exact mechanism D-35 rejected as unmaintainable. The managed block carries nine per-bot groups whose sole directive is Disallow: /: Amazonbot, Applebot-Extended, Bytespider, CCBot, ClaudeBot, CloudflareBrowserRenderingCrawler, GPTBot, Google-Extended, meta-externalagent. D-35 rejected a by-name blocklist because the list is maintenance nothing will remind anyone about. Cloudflare is maintaining one at the edge for free — which answers the maintenance objection, and does not answer the voluntary-compliance one.

Second: the zone emits a search-permitting content signal on a host whose whole purpose is noindex. The managed block’s User-agent: * group carries Content-Signal: search=yes,ai-train=no,use=reference together with Allow: /.

The Content-Signal: search=yes caveat, with its mechanism — not merely noted. The managed body ALLOWs User-agent: *, so a compliant crawler actually fetches the object, and in fetching it reads the X-Robots-Tag: noindex, noimageindex, nofollow, noarchive header that D-29 stamps on every response the transform phase reaches. The header is what enforces; the file only decides whether the crawler gets far enough to read it. A permissive robots.txt plus an enforcing response header is therefore the stronger combination, not the weaker one. This is precisely the reasoning D-35 already used to reverse the disallow-all body; applying it to the managed block’s Allow: / yields the same conclusion, so keeping the module is consistent with D-35 rather than in tension with it. The search=yes token contradicts our intent on its face while the mechanism that actually governs indexing points the other way — and both halves belong on the record.

Q-2 DECISION: keep-managed (Sean, 2026-07-31, with the measurement in front of him).

Q-2 REASON: Probe R measured suffix, so the committed 918 bytes already reach every visitor as the tail of what Cloudflare serves — keeping the managed module therefore costs nothing in reach, while disable-managed would buy byte-identity at the price of a second dashboard grant (Bot Management Write, permission-group id 3b94c49258ec4573b06d51d99b6416c0), a cloudflare_bot_management resource carrying Enterprise-only attributes with no verified rollback shape on provider 5.22.0 added to a shared workspace on a Free zone, and the loss of nine per-bot Disallow: / groups Cloudflare maintains for free.

What that concedes, stated plainly rather than hidden. D-34 becomes “the served body ENDS WITH our committed bytes” rather than “EQUALS our committed bytes.” That is strictly weaker than byte-identity and it is the whole of what this branch gives up. It remains a checkable assertion — the suffix comparison Probe R already performed is exactly reproducible — so nothing became unverifiable, only less strong. The gate’s ep-robots-body check asserts the suffix relationship by name, in the assertion’s own text and in its failure message; it is written neither as a contains match nor as equality, and it reads the branch out of the operator’s recorded decision line rather than defaulting to one. The weakening is visible because robots-served.observed.txt is committed verbatim, not because anyone remembers it.

One inference is recorded as an inference: the served body now contains two User-agent: * groups, and RFC 9309 says a crawler merges records with the same product token, so a compliant crawler would see both Allow: / and Disallow: /_health/. This was not measured against any real crawler, and nothing above depends on it.

Probe A — the plaintext redirect, measured, and the prediction it falsified

Section titled “Probe A — the plaintext redirect, measured, and the prediction it falsified”

always_use_https runs earlier in Cloudflare’s pipeline than the response-transform phase, and the phase’s research predicted that the http→https 301 would therefore sit outside the transform phase entirely. The bar set in D-28 was to measure it and record the observed answer either way.

Measured, the prediction is wrong. probe-a-http301.observed.txt records HTTP/1.1 301 Moved Permanently for http://i.hl-assets.dev/f/_probe/cacheprobe.heic carrying all five D-29 headers — x-content-type-options, content-security-policy, referrer-policy, cross-origin-resource-policy and x-robots-tag — and a content-disposition besides. The transform phase does reach it, and no exclusion needs stating for this response.

An earlier pre-apply reading recorded 0 of 5 on the same URL. That reading was taken before the response-headers ruleset existed, so it measured the absence of a rule rather than the unreachability of a response. It is superseded by the post-apply capture and is not the value carried here. The probe was kept exactly as written even though it came back the convenient way: a probe that only reports the answer it expected is not a probe.

The harness is Python, run via uv, not shell (D-38). The motivation is this project’s own record: every gate-integrity bug found so far has been a shell bug — jq’s // swallowing a genuine false, -le N passing on 0, an rg gate matching the wrong section of its own output and reporting the inverse, .KeyCount reading 0 for every bucket. Python removes that class outright: real JSON types, real comparisons, and gate logic that can be unit-tested rather than hand-mutation-tested.

Phase 1’s 831-line bash gate is superseded, and the acceptance test for the port is emitted-check-id equality, not a matching pass count (D-40). A matching number is not evidence the same things were measured: two checks lost and two gained nets to zero, and Phase 1’s own harness carried three checks that could never fail while still counting as passes. The comparison is over the emitted-id stream from both gates run live in one session, compared as one sorted block, which carries “no fewer” and “no more” in a single check.

Each was considered, has no v1.5 requirement mandating it, and is deferred to Phase 6 against measured usage rather than a guess:

  • cloudflare_tiered_cache — one zone-level resource, no new permission, but marginal at family volume with a one-month edge TTL. Revisit against Phase 6’s Class A/B usage notifications.
  • WAF per-IP request-rate limit — net-new surface, needs an undocumented permission grant, tunable only against live traffic, and a threshold set too low silently breaks a family member loading a page full of images.
  • WAF 404-rate limit — same bucket; D-41’s negative caching is the cheap substitute for the specific cost vector this phase actually has.
  • Rejecting query strings outright via a WAF custom rule — strictest, but net-new surface, and it would break a legitimate share whose link picked up a tracking parameter in transit. D-27’s rewrite collapses those links instead of rejecting them.

Each is a decision on the record, not an oversight. None is reopened by this record.

1. GET /robots.txt carries none of the five headers. Argued in full above. Accepted by Sean on 2026-07-31 with the amended cost table in front of him. Pinned by ep-hdr-unreached’s set equality so it cannot silently widen or silently close.

2. Content-Signal: search=yes is emitted on a noindex host. Cosmetic, and argued above via the allow-then-noindex mechanism. Not a reason to spend a dashboard grant.

3. Managed Transforms are unmeasured. The plan was to declare or disable them explicitly so they could not silently add or strip headers underneath the rule. That needs Managed headers Write, which the operator deliberately did not grant; the live read returns request is not authorized. Consequence, stated plainly: if a Cloudflare Managed Transform is enabled on this zone and adds or removes one of the five headers or Content-Disposition, this project will not detect it from configuration — only from the gate’s live response assertions. Those assertions are the compensating control and they are real: a managed transform that strips one of the five is caught by token-set equality on live responses. A managed transform that adds an unrelated header is not caught, and that is accepted.

4. Five permanent objects in the public bucket are not user content. f/_probe/cacheprobe.heic, f/_probe/cacheprobe.mov, f/_probe/dispositionprobe.svg, f/_probe/dispositionprobe.html and robots.txt at the root. They are permanent because of D-25: with a one-month edge TTL a deleted fixture keeps serving from the edge for a month unless someone also purges, and a purge that silently fails leaves stale bytes serving with nothing noticing. Permanent probes need no purge permission, leave no edge residue, and are idempotent by construction. The three-namespace invariant is untouched — all four probes live inside f/. The operational instruction lives in Cloudflare Operations; the fixture provenance lives in tools/hl-assets-verify/fixtures/README.md.

5. The camera fixtures are published world-readable, permanently, at fixed guessable keys. Capture geolocation is closed: both fixtures were re-captured on 2026-07-31 with Location Services disabled for the Camera app, verified at 0 GPS EXIF tags and 0 ISO6709 coordinate strings against a non-vacuous control (the superseded geotagged capture, which the same scan matched twice). Re-capture was chosen over stripping because stripping would violate the unmodified-camera-output requirement, whereas never recording the coordinates leaves the bytes genuinely unmodified. One residual is accepted rather than closed: the .mov carries com.apple.quicktime.detected-face atoms, so the permanently published frames likely show a person. face-id is a session-local tracking integer rather than an identity, and no measurement resolves the content question. Sean was shown this on 2026-07-31 and chose to publish as-is, declining a re-capture with nobody in frame.

6. The per-fixture size ceiling was amended from 2 MB to 5 MB, with the cost stated. The 2 MB figure was a planner’s repo-hygiene estimate, not a measured constraint: iPhone 1080p30 HEVC cannot reach 2 MB at any usable clip length, so meeting it would have required a re-encode — which voids the provenance the fixtures exist to carry. The committed pair is 3,363,212 B and 3,957,709 B, both inside 5 MB, and the .mov still exceeds 2 MB, so the amendment stays load-bearing rather than becoming vestigial.

  • Three zone rulesets scoped as above (chosen).
  • A hostname-wide cache rule instead of a /f/ predicate — rejected. It would cache the health canary Phase 6 monitors with, so an R2 outage would be invisible behind a month-old edge copy, and it would make a robots.txt edit require a purge.
  • A one-year immutable edge TTL — rejected. Immutability is a property of the bytes, not a statement that they should still be served; takedown breaks the equivalence and a forgotten purge is silent.
  • cache_key.custom_key.query_string.exclude (D-27 as written) — unavailable. The query-string cache-key section is plan-gated and this zone is Free; separately the attribute shape in the decision is the older provider’s.
  • cache_level = "simplified" as the query-string fallback — rejected. It only disregards the query string for static file extensions, which is exactly the set this phase’s target formats are absent from.
  • Scoping the header rule to /f/* — rejected. It would leave the root 404 unstamped, the one surface Phase 1 spent a whole success criterion on.
  • Cross-Origin-Resource-Policy: same-site — rejected. It blocks cross-origin no-cors subresource loads, which is hotlink protection by another mechanism.
  • Cloudflare Hotlink Protection — rejected on the four grounds above.
  • A denylist of dangerous extensions instead of an inline allowlist — rejected. A miss is silent under a denylist and loud under an allowlist.
  • .pdf on the inline allowlist — rejected. Its viewer executes embedded JavaScript and it is not a phone format.
  • A disallow-all robots.txt — rejected. A crawler blocked there never reads the header that actually de-indexes.
  • Blocking AI scrapers by name in the committed body — rejected as unmaintainable, and since made moot: Cloudflare’s managed block already carries nine such groups for free.
  • disable-managed — turning Cloudflare’s managed robots.txt module off — rejected on cost, itemised in the Q-2 reason above.
  • Matching Phase 1’s total pass count as the port’s acceptance test — rejected. Two checks lost and two gained nets to zero.

Positive:

  • Repeat views of .heic and .mov are answered by the edge, measured MISSHIT on both, which is what makes the milestone’s cost premise true rather than aspirational.
  • Every response the transform phase reaches carries all five headers, across four measured response classes, and the one class it does not reach is named and pinned by set equality.
  • An .svg and an .html download rather than render, proven per object by set equality.
  • A query-string variant resolves to the same cache entry as the bare key, proven at the bare key’s exact age rather than by a bare HIT.
  • The repo’s robots.txt bytes reach every visitor as the tail of what is served, and the suffix relationship is asserted by name.
  • Hotlink Protection’s absence is a recorded decision with four reasons rather than folklore in a roadmap bullet, and the permissive CORP value records the same intent inside the response.

Negative:

  • GET /robots.txt carries none of the five headers, and closing that gap is a dashboard grant plus an Enterprise-attribute resource in a shared workspace. Accepted.
  • D-34 is a suffix assertion rather than a byte-identity one. Strictly weaker, still checkable.
  • The zone emits Content-Signal: search=yes from a file this project does not fully control, on a host whose intent is noindex.
  • Managed Transforms cannot be read with the granted permissions, so a transform that adds a header is undetectable from configuration.
  • The disposition rule forecloses a friendly download filename via Content-Disposition unless it is later relaxed for allowlisted types.
  • Five permanent objects live in the public bucket that are not user content, and deleting one breaks a gate while leaving stale bytes serving for up to a month.
  • Adding a renderable format later is a Terraform change and an apply, not a config toggle.

Neutral:

  • This is the repository’s first cloudflare_ruleset — three of them, in a shared workspace with no in-repo precedent for phase names or rule ordering. The adopt-then-declare import{} idiom every DNS record and zone setting in this repo uses does not transfer: provider 5.22.0 has no entrypoint-adoption fallback, so a pre-existing entrypoint is a hard apply failure rather than a silent adoption. All three phases were measured empty immediately beforehand (error code 10003, distinguished from a disguised 403 by a ddos_l7 positive control that returns request is not authorized with no error code), so zero import{} blocks were written. The recovery shape is recorded in the Terraform file for the day a read returns an id.
  • Whether the plaintext 301 carries the headers was measured rather than assumed. Observed 2026-07-31: it does, contradicting the phase’s own research prediction. Had it gone the other way, this record would state the exclusion instead — as it does for /robots.txt.
  • The bootstrap token gained Cache Settings Write, Zone Transform Rules Write and Cache Purge, taking it from 41 permission groups to 44 — exactly +3, with no collateral widening. This phase uses the first two. Cache Purge exists for Phase 6’s takedown.

Sign-off: decision, date (2026-07-31) and deciders (Sean Brandt) are recorded above. Human approval of the pull request that lands this ADR is the sign-off for the accepted residuals.