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
Context
Section titled “Context”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 MISS→HIT. 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.
Decision
Section titled “Decision”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.
Rationale
Section titled “Rationale”Cache scope and TTLs — D-24, D-25, D-26
Section titled “Cache scope and TTLs — D-24, D-25, D-26”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: MISS → HIT 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 MISS→HIT 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
MISS→HIT (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.devreportsplan.name = "Free Website". Measured against the live zone. - Separately, on provider 5.22.0
excludeis 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 localterraform providers schema -jsondump 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.
Hotlink Protection stays off — and Cross-Origin-Resource-Policy is permissive (D-30)
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:
- It covers five image extensions, none of which are phone formats — so it would not protect the one thing this host actually serves.
- 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. - It breaks legitimate embedding, which is a use of a shared link rather than an abuse of one.
- It adds a magic
hotlink-okkey-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-statusheader 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 -sagainst 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.
Verification — D-38, D-40
Section titled “Verification — D-38, D-40”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.
Ungated, and not shipped
Section titled “Ungated, and not shipped”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.
Accepted residuals
Section titled “Accepted residuals”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.
Alternatives Considered
Section titled “Alternatives Considered”- 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 arobots.txtedit 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-originno-corssubresource 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.
.pdfon 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 managedrobots.txtmodule 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.
Consequences
Section titled “Consequences”Positive:
- Repeat views of
.heicand.movare answered by the edge, measuredMISS→HITon 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
.svgand an.htmldownload 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
agerather than by a bareHIT. - The repo’s
robots.txtbytes 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.txtcarries 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=yesfrom a file this project does not fully control, on a host whose intent isnoindex. - 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-Dispositionunless 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-declareimport{}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 code10003, distinguished from a disguised 403 by addos_l7positive control that returnsrequest is not authorizedwith no error code), so zeroimport{}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 WriteandCache Purge, taking it from 41 permission groups to 44 — exactly +3, with no collateral widening. This phase uses the first two.Cache Purgeexists 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.