Skip to content

ADR-0005 — CMS image pipeline

Status: Accepted Date: 2026-05-10 Deciders: Cathal Dempsey Related: ADR-0004 (adapter pattern, the consumer), ADR-0003 (assembler image handling)

Context

The per-site assembled-build path (ADR-0003) handles images: the crawler downloads from static.wixstatic.com/media/<basename> to builds/<domain>/public/assets/images/, the assembler bundles them into the per-site Astro build, the per-site Cloudflare Pages deploy serves them. This works.

The multi-tenant CMS worker (apps/cms/, ADR-0004) does not. D1 stores Portable Text blocks with imageUrl paths like /assets/images/c00d38_…png — these point at a public/ directory that doesn't exist in the worker bundle. Every CMS-rendered image 404s in production.

Verified during the 2026-05-10 session: Astro's /_image optimizer works fine against R2 source URLs (200 webp output for a test image at replatform-emdash-media/test/garvanbay-hero.jpg). The infrastructure is in place — bucket exists, MEDIA binding wired, public dev URL enabled. The gap is the migration step that takes images from local builds and pushes them to R2 with rewritten paths in D1.

Constraints

  • No egress fee. R2's pricing makes path-keyed multi-tenant storage near-free at portfolio scale (~$0.20/month for ~12 GB).
  • /_image is the existing optimization layer. Already integrated via astro:assets, already verified against R2. Don't introduce a separate Cloudflare Images direct-API path unless /_image itself becomes a problem.
  • Per-site Astro builds keep bundling local images. This ADR is CMS-only. Don't touch ADR-0003's flow.
  • R2 dev URL is throttled for production-style traffic. Map a custom domain (cdn.replatform.fcr.media or similar) before launch. Not blocking the migration step itself.

Options under consideration

replatform-emdash-media/<domain>/<key> where <key> follows the shape locked in §2 below. Reuse the existing bucket, MEDIA binding already wired, public access already enabled. Path-prefix isolation matches the "one CMS worker, many sites" architecture.

  • Migration: ~3.75 days plumbing (transformer step + backfill + spec update + resolver helper + schema field + verification).
  • Steady-state: $0.18/mo storage + ~$45/mo /_image transforms at full traffic.

Option B — One bucket per site

replatform-<domain>/<basename>. Operationally heavier: 1800 buckets, per-bucket binding management, per-bucket dev URL.

  • No real benefit at our scale. Path isolation in option A gives the same logical separation without the operational cost.
  • Bucket-per-tenant is what you'd reach for if R2 had per-bucket billing isolation requirements; it doesn't.

Continue serving Wix's CDN directly. No migration step, no R2 cost.

  • IP/ownership concern from docs/pipeline/known-issues.md: client owns the images, but the URL points at Wix's CDN. Ongoing reliability dependency on a third party we're trying to migrate off.
  • Doesn't solve the actual problem — D1 paths are local /assets/images/..., not wixstatic URLs. Would require a different rewrite step (D1 paths → wixstatic URLs), which is path-shaped work for less benefit.

Recommendation

Option A. ~3.75 days of plumbing, costs are rounding error, infrastructure is already in place. The other options lose on operational complexity (B) or don't solve the actual problem (C).

Implementation specifics (locked 2026-05-10)

Six sub-decisions captured during pre-promotion review. Folded into this ADR so the doc carries the full design, not just the option-pick. The path through these followed two key-shape revisions; see "Decision history" below for the supersession trail.

1. Storage layout

Per Option A: single bucket replatform-emdash-media, path-prefixed per site. MEDIA binding already wired; public access already enabled at pub-4939c2fb5cd540d0862e9c02ac6644ba.r2.dev.

2. Key shape — <domain>/<stem>-<8hex>.<ext>

<stem> is the normalised basename stem (see §3). <8hex> is the first 8 hex characters of the SHA-256 of the file contents.

8 hex = 32 bits = 4.3B namespace per <domain>+<stem> pair. Even at N=100 re-uploads of the same basename within one site, P(collision) ≈ 1.2e-6 — statistically zero. Collision space is per-stem, not global.

This shape was chosen over a pure content-addressed alternative (<domain>/<sha256>.<ext>) for human-grep-friendly bucket inspection. The opaqueness cost of pure SHA keys was not worth saving 8 chars when the stem is also recoverable via the originalName field (§6).

3. Filename normalisation

Pipeline: 1. Strip extension (preserve for re-attachment in step 7). 2. Reverse Wix's _<2hex> filesystem-safe substitution (Wix replaces % with _ when persisting URL-encoded names): match /_([0-9a-fA-F]{2})/g, replace with %$1. 3. URL-decode (decodeURIComponent); on malformed sequence, fall back to the pre-decode stem. 4. Transliterate via the transliteration library's slugify(str, { lowercase: true, separator: '-' }). This lowercases, NFD-decomposes, ASCII-folds Latin diacritics (á→a, é→e, í→i, ñ→n, ç→c, ó→o, etc. — including the broader Latin Extended ranges that cover Irish, Spanish, French, German, Portuguese), replaces non-[a-z0-9] runs with -, collapses - runs, trims leading/trailing -. 5. Empty-stem fallback → image. 6. Append -<8hex> (the SHA-256 prefix from §2). 7. Re-attach lowercased extension.

Before/after:

Original Normalised key (under wfpainters/)
Fair_20Pricing.png fair-pricing-a1b2c3d4.png
IMG-20251107-WA0012.jpg img-20251107-wa0012-9d8c7b6a.jpg
COMMERCIAL_20CLEANING.jpg commercial-cleaning-2c1b0a9f.jpg
Naíonra Cnoc na Sí.jpg naionra-cnoc-na-si-4e5d6c7b.jpg
Café Photo.jpg cafe-photo-8a9b0c1d.jpg
Naïve résumé Ñoño.png naive-resume-nono-1f2e3d4c.png
8b4be4_aca71b0c670a40c8bf05565ebc232510_mv2.png 8b4be4-aca71b0c670a40c8bf05565ebc232510-mv2-5d4c3b2a.png
___.jpg image-0a1b2c3d.jpg
Test_20cm.jpg (false-positive on _20 decode) test-cm-e1f2a3b4.jpg

Irish-language and other non-ASCII Latin filenames round-trip cleanly via transliteration — no accent stripping that erases characters. Verify the transliteration lib's behaviour with a quick install + sanity check before locking the lib choice in implementation; the table above asserts documented behaviour for the relevant Unicode blocks.

Known false positive: step 2 decodes literal _<2hex> substrings that weren't actually URL-encoded characters. Test_20cm.jpg ("test, 20 cm") becomes test cm then test-cm. The hash still differentiates content; the original name is preserved in metadata and the Portable Text sibling field (§6), so forensic recovery is intact.

4. Replacement semantics — immutable keys + deferred GC with explicit triggers

When an SME re-uploads an image:

  • New bytes → new SHA-256 → new key under <domain>/.
  • D1's logical reference is rewritten to point at the new key.
  • Old object is orphaned in R2.
  • Cache headers on R2 objects can be aggressive (Cache-Control: public, max-age=31536000, immutable) because keys never change content.

The alternatives — mutable keys with cache purge, or D1↔R2 transactional cleanup — fight three caches (/_image, R2 dev URL, browser) and add complexity for a problem ($0.18/mo orphan storage) that doesn't merit it. Mutable-key in particular is the silent-cache-staleness pattern we just spent two days eliminating at the primitive layer.

GC implementation deferred with explicit triggers, so "when justified" doesn't become "never":

  • Trigger A — storage threshold: total R2 bucket size exceeds 10× expected portfolio size (>120 GB, given 12 GB expected). At that point >90% of objects are orphans.
  • Trigger B — clean-slate event: bulk migration completes and we want a known-clean state before SMEs start editing through emdash.
  • Whichever fires first.

GC implementation when triggered: walk all logical refs in D1 → set of in-use keys; list R2 objects under each <domain>/ prefix → set of present keys; delete the difference. Cloudflare Cron Trigger on the worker, weekly cadence.

5. Path rewrite location — logical references, resolved at render

transformer.js does NOT emit fully-qualified R2 URLs into D1 or seed.json. It emits a logical reference of shape r2:<domain>/<key> (string scalar). The Image primitive (or a small worker-side helper) resolves the reference to a public URL at render time.

// packages/components-v3/src/lib/resolve-image-ref.ts
export function resolveImageRef(ref: string | undefined): string | undefined {
  if (!ref) return undefined;
  if (ref.startsWith('r2:')) {
    const base = import.meta.env.PUBLIC_MEDIA_BASE;
    return `${base}/${ref.slice(3)}`;
  }
  return ref;  // external URLs and local-dev paths pass through
}

PUBLIC_MEDIA_BASE comes from env. Astro's /_image then optimises the resolved public URL as today.

Why: - Seeds stay portable. Test environments point at a different bucket with one config change, no D1 migration. - Custom-domain swap (pub-…r2.devcdn.replatform.fcr.media) is a single env change. - Provider migration (R2 → Cloudflare Images, R2 → S3) is a single resolver swap.

6. Provenance — originalName in BOTH metadata and sibling field

When uploading to R2, attach the original filename as object metadata: x-amz-meta-original-name: <original-basename>.

ALSO: extend the Portable Text image-node schema with an optional originalName: string sibling to src and alt:

{
  "_type": "image",
  "src": "r2:wfpainters/fair-pricing-a1b2c3d4.png",
  "alt": "Fair pricing graphic",
  "originalName": "Fair_20Pricing.png"
}

Both, not either. Reasoning:

  • R2 metadata is forensic-only. Discoverable when you go looking; invisible when you don't. A future session asked to recover a name has to know to inspect R2 headers and have permissions to fetch.
  • The Portable Text sibling is in the data path. Travels with every export, every D1 dump, every seed. Survives R2 migrations. Queryable. Costs ~12 bytes per image and one optional schema field.
  • We're migrating off a vendor. Provenance trail belongs in our data, not in a sidecar nobody reads. If an SME ever says "that's not what I uploaded" or an audit ever asks "where did this come from", the breadcrumb is in our database, not in a third-party object store's metadata.
  • The "emdash media library probably handles original names" argument is doing work the word "probably" shouldn't be doing. We don't know yet. Cost of being wrong: re-crawl Wix to recover names. Cost of belt-and-braces: 12 bytes per image.

The transformer emits both on every upload. Adapters and primitives ignore originalName at render time — it's data-path provenance, not display.

§6 amendment (2026-05-12, Slice 3) — block-level scalar fields

The §6 example above shows a Portable Text image node (_type: "image"). The FCR block schema (apps/cms/src/plugins/marketing-blocks/) doesn't carry standalone image nodes — image refs live as fields on blocks instead. Three shapes appear in the live data:

Field shape Where Migration
block.imageUrl: string (scalar) fcr.hero, fcr.about, fcr.cta-strip, fcr.service-grid items, fcr.team-grid items, fcr.blog-posts items Restructured to block.imageUrl: { src, originalName?, alt? }. The object-shaped value carries the §6 sibling-fields for free.
block.logoUrl: string (scalar) fcr.header Same restructure as imageUrl.
items[].src: string already inside an object with alt fcr.gallery items, fcr.logo-strip items, fcr.footer partnerLogos Kept scalar; originalName? added as a sibling next to the existing alt field.

The first two cases match §6's spirit directly — the block field becomes the image node's analog. The third case already has the "sibling to src" structure, so adding originalName is purely additive.

Editor schema constraint. emdash's editor schema can only declare scalar field types in repeaters (no nested object groups). The data shape (Portable Text in D1 / seed.json) is decoupled from the editor input shape — schemas continue to declare imageUrl: text_input even though the rendered data is an object. Editor authoring of structured image fields is a separate workstream (per "Per-edit upload UX" in §"Open decisions for build-time").

Adapter contract. Adapters mediate between the data shape and the canonical block prop signature via a small helper at packages/components-v3/src/lib/get-image-src.ts:

export function getImageSrc(field: ImageField): string | undefined
export function getImageAlt(field: ImageField, fallback?: string): string

ImageField accepts either the legacy scalar string or the new object shape. Both paths funnel through resolveImageRef (§5) so r2: refs resolve to public URLs at render time. Adapters that use canonical primitives delegating to the Image primitive don't need the helper — the primitive resolves internally — but adapters that emit raw <img> (Hero, About, CtaStrip, BlogPosts, Header, Footer) do.

videoUrl is migrated identically to imageUrl even though videos themselves are deferred per §"Open decisions for build-time" — the field is rare in current seed data (typically empty), and treating it consistently with imageUrl avoids a separate migration when videos do land.

slideshowUrls stays a multiline scalar. The editor schema's multiline text_input for slideshow lines doesn't compose well with object shapes (it's a string-list input, not a repeater). Each line is rewritten to a r2: ref scalar — provenance stays in R2's object metadata only. This is a known §6 divergence for one field; restructuring slideshow inputs to a repeater of {src, originalName?} is editor-side work, not migration work.

Pipeline shape (Option A)

[1] CRAWL          [2] BUILD              [3] CMS SEED
─────────          ──────────             ──────────────
dom-pipeline       assembler-fulldev      transformer
  ↓                  ↓                      ↓
downloads          bundles into           per image:
to local           per-site Astro           hash bytes (SHA-256)
public/assets/     build (Pages             normalise stem (§3)
images/            deploy — no              upload to MEDIA
                    change)                   <domain>/<stem>-<8hex>
                                              with original-name
                                              metadata
                                            write logical ref
                                              r2:<domain>/<key>
                                              + originalName sibling
                                              into D1
                                          per-edit uploads via
                                          emdash admin go through
                                          the same path (separate
                                          workstream)

Four new pieces of code: 1. R2 upload step in the seed transformer — walks imageUrl refs, computes SHA-256, normalises basename, uploads to MEDIA:<domain>/<stem>-<8hex>.<ext> with original-name metadata, rewrites the field to r2:<domain>/<key> and adds the originalName sibling. Idempotent (same content + same normalised stem → same key). 2. Backfill script for already-seeded D1 rows — reads each ec_pages row, walks Portable Text content, applies the same upload+rewrite pipeline, writes back. 3. Seed JSON spec update so a fresh npm run seed produces logical refs (not local paths). 4. Image-ref resolver helper in packages/components-v3/src/lib/resolve-image-ref.ts. Image primitive consumes it on the render path. Reads PUBLIC_MEDIA_BASE from env.

Cost (Option A)

Dimension Number
Sites 1800
Images/site (waterfordcountypainters sample) 159
Total file count ~290k
Total storage ~12 GB
One-time R2 PUT ~$1.50
Steady-state storage ~$0.18/month
/_image transforms (50/site/mo @ 1800 sites) ~$45/month

R2 has no egress fee, so the cost ceiling is transform volume — and transforms scale with traffic, not with migration. At low CMS-edit traffic (the realistic scenario for replatformed FCR sites), the real cost is closer to $0.20/month total.

Effort (Option A)

Task Estimate
Upload step in transformer (incl. SHA, normaliser, metadata) 1 day
Backfill script 0.5 day
Seed JSON spec update 0.25 day
Resolver helper + Image primitive integration 0.25 day
Schema field add (originalName on image nodes) 0.25 day
Parallelised mass run + verification 1 day
Buffer (R2 rate-limit, video paths, edge cases) 0.5 day
Total ~3.75 days

The 290k uploads are the wall-clock-heaviest item. R2 PUT is fast (~50ms each). Single-threaded would take ~4 hours; parallelised 20–50 wide with 429-retry it lands in ~30 minutes.

Open decisions for build-time, not blockers

  • Custom domain or pub-…r2.dev. r2.dev is fine for the migration step itself. Map a custom domain before public traffic. ~30 minutes DNS + R2 config. Update PUBLIC_MEDIA_BASE env (§5).
  • Video files. video.wixstatic.com/.../file.mp4 — 10–50 MB each. Defer until a CMS site needs them; same R2 pattern would apply.
  • Per-edit upload UX. When an operator uploads a new image through emdash admin, where does it go? Same MEDIA bucket, same key scheme (§2-3), same metadata + sibling-field discipline (§6), but the upload pathway through /_emdash/api/* is a separate workstream.
  • ~~Dedup across sites.~~ Closed by §2 + per-domain prefix: same content uploaded for two different sites occupies two keys by design. Per-tenant isolation > 5-cents-per-month dedup gain.

Promotion record

Promoted from DRAFT to Accepted on 2026-05-10. Sign-off criteria at promotion:

  • (a) Option A and the §1–§6 specifics confirmed by Cathal.
  • (b) Start time: implementation begins next session (sequencing picked up from apps/cms/README.md open-work list).
  • (c) Backfill run by Cathal (solo dev).

File renamed 0005-image-pipeline-DRAFT.md0005-image-pipeline.md. Cross-refs updated in apps/cms/README.md and docs/pipeline/known-issues.md. Session handover (docs/pipeline/session-2026-05-10-handover.md) preserves the DRAFT-era references as historical record.

Slice tracker

Implementation is sliced. Slice 4 (SDK swap, new addition) executed ahead of Slice 3 (transformer integration) after Slice 2 surfaced that wrangler-shellout perf was throughput-blocking at portfolio scale: the SDK swap moved from "polish" to a precondition for transformer integration. Slice numbers stay stable identifiers — Slice 3 has always been transformer integration; Slice 4 is the new SDK-swap slice.

Slice Scope Status
1 Logical-ref rendering — r2:<key> resolved at render via PUBLIC_MEDIA_BASE (§5). Resolver helper at packages/components-v3/src/lib/resolve-image-ref.ts. Done 2026-05-10 (commit 9c99bd2).
2 R2 upload helper, wrangler-shellout baseline. SHA-256 + transliteration normaliser per §3, idempotent re-runs via r2 object get exit-code probe. Helper at apps/cms/scripts/upload-image.mjs. Validated against 158 wfpainters images. Done 2026-05-10.
3 Transformer integration — walk seed.json imageUrl refs through the SDK upload helper; rewrite to r2:<key> + add originalName sibling per §6; backfill the 11 already-seeded garvanbay pages. Done 2026-05-12. Transformer at apps/cms/scripts/transform-seed-images.mjs (40 images uploaded, idempotent on re-run). D1 backfill at apps/cms/scripts/backfill-d1-image-refs.mjs (33 fields rewritten across 11 rows). Adapter helper at packages/components-v3/src/lib/get-image-src.ts. §6 amendment landed (block-level scalar fields). Smoketest verified on /, /smoketest, /adapter-test, /contact — zero broken /assets/images/ refs, only deliberate-fixture loud-fail placeholders remain.
4 SDK auth swap — @aws-sdk/client-s3 against R2's S3-compatible endpoint with R2 API tokens. Closed the §6 metadata gap (sets x-amz-meta-original-name on every upload; backfilled the 158 wrangler-era wfpainters objects via CopyObject + MetadataDirective: REPLACE). Helper signature uploadImage(localPath, domain) → { logicalRef, key, alreadyExisted } unchanged across the swap. Done 2026-05-11 (mostly complete; throughput sub-issue deferred — see below).

Slice 4 throughput sub-issue — deferred. The 30-min/290k target was missed: measured throughput is ~50–70 PUT/sec regardless of concurrency (sweep across 50/100/200/500/1000), projecting to ~70–110 min wall. Curve shape (flat with noise) points at a remote-imposed ceiling — likely per-token or per-account rate-limit on Cloudflare's S3 API. EC2 pre-stage confirmed reachable but the EC2 sweep wasn't run; multi-token sharding wasn't designed.

Deferral rationale: 60 PUT/sec is sufficient for the 50-site gate (8000 images / 60 PUT/sec = ~2 min). Throughput architecture matters at wholesale-fleet scale; pre-empting it before end-to-end is proven would be optimisation without data. See known-issues.md "R2 upload — throughput ceiling" for the curve data, hypothesis status, EC2 pre-stage state, and the explicit deferral-trigger contract.

Slice 3 deriver hardening (2026-07-05). The transformer's basename deriver originally matched only a single clean segment under /assets/images/, silently skipping Wix transform-suffixed paths and /assets/logos/*.png (they stayed /assets/… refs that 404). Reworked to resolveAssetSource → { basename, subdir, isWixMedia } (commit 77bdb33); a --dry on the wfpainters seed dropped underivable refs ~178 → 1. The residual (/assets/videos/*.mp4) is a distinct shape on a different host (video.wixstatic.com) — tracked in known-issues.md. No §-contract change: §3 normalisation is untouched; this only widens which source paths reach it.

Decision history

The key shape went through two supersessions during pre-promotion review on 2026-05-10:

  • DRAFT V1 — <domain>/<basename>. Initial scoping. Path- keyed by raw basename. Pre-promotion review surfaced collision risk in the human-named long tail (operator re-uploads of Fair_20Pricing.png-style filenames silently overwrite). The silent-overwrite pattern matched the exact failure mode we'd spent two days eliminating at the primitive layer. Superseded.
  • DRAFT V2 — <domain>/<sha256>.<ext> (briefly recommended during follow-up). Pure content-addressed keys solve collision and idempotency in one move, but bucket inspection becomes opaque (wfpainters/9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c.jpg is not human-readable). Superseded.
  • DRAFT V3, final — <domain>/<stem>-<8hex>.<ext>. Hybrid: human-grep-friendly stem + per-stem 4.3B-collision namespace via 8-hex-of-SHA256. Combined with originalName preservation in both R2 metadata and Portable Text sibling field (§6), bucket inspection and forensic recovery both work. Locked pending sign-off.

Followup — operator-editable image fields (2026-07-04, "Option A")

The §6 migration stored block image fields as { src, originalName } objects. That made them uneditable in the emdash admin: the editor schema declared imageUrl as a scalar text_input, which can't bind an object value, so the field rendered blank — the operator couldn't see or change the image (surfaced on the Payroll fcr.about block).

Decision: for operator-editable image fields, use emdash's native media_picker element and store the value as a scalar asset-URL string. Confirmed from @emdash-cms/admin BlockKitMediaPickerField: the picker's stored value is a plain URL string, interchangeable with text_input. So the object shape was the whole problem.

  • Schema: fcr.about imageUrl text_inputmedia_picker.
  • Data: migrate-about-imageurl-to-string.mjs flattens { src: "r2:<key>", originalName } → the resolved public URL string (PUBLIC_MEDIA_BASE/<key>). Idempotent.
  • Provenance: retained in R2 object metadata (x-amz-meta-original-name, Slice 4 backfill); dropped from the D1 field. This is a conscious amendment to §6's "provenance in the data too" for operator-editable fields — the picker forces a scalar string, and R2 metadata is the durable store. (Belt-and-braces reduces to belt for these fields.)
  • Render: getImageSrc already handles the scalar string; the About adapter now also picks content-2 (side-by-side) vs content-1 (text-only) — see ADR-0004 followup.

Scoped to fcr.about as the proof. Hero / CTA-strip / Header (scalar imageUrl/logoUrl) and repeater-item images (service-grid, team-grid — Block-Kit repeaters are scalar-only, so media_picker may not apply inside them) remain object+text_input; extend later (see known-issues).

Two image models now coexist and need reconciling: (1) these structured fcr.* image fields (R2 r2:-scheme, ADR-0005), and (2) the native emdash image block an operator inserts via the toolbar (emdash media storage, ULID keys, no x-amz-meta-original-name). The per-edit upload path (§"Open decisions") is where this reconciles.

Commits: 8ffcf5f (Option A code + migration), ffdf270 (content-2 routing), 63beb5f (Base @container). D1 applied via reseed-d1-from-json.mjs using a D1-scoped token.

Extension to all top-level image fields (2026-07-04 pt3)

The fcr.about proof was extended to the remaining top-level image fields: fcr.hero.imageUrl, fcr.cta-strip.imageUrl, fcr.header.logoUrlmedia_picker + scalar URL string. The about-only migration was generalized to migrate-block-image-fields-to-string.mjs (a {type: field} map; idempotent). 28 seed fields flattened; adapters unchanged (all already read via shape-agnostic getImageSrc). Commit 8a51bc5.

Still object+text_input: repeater-item images (Block-Kit repeater sub-fields are scalar-only — media_picker can't drop in). See known-issues; that's the remaining piece before a 2nd site.

Reseed-globals bug found and fixed along the way (commit 1d31300): reseed-d1-from-json.mjs upserted globals by id, but emdash's globals row uses a ULID that never matches the seed's id: "site" — so every prior globals reseed silently no-op'd the live row and created a junk draft. Header logoUrl is a global, so its migration didn't land until this was fixed to UPDATE-by-slug. See known-patterns "Reseeding a global must UPDATE by slug". (Pages were never affected — DELETE-by-slug is id-independent.)

Followup — the migration walker must cover INVISIBLE image fields (2026-07-13)

FIELD_MIGRATIONS (the {_type → image field} table in transform-seed-images.mjs) was enumerated from the fields that render. fcr.business.imageUrl renders nothing — it is the og:image + LocalBusiness JSON-LD image — so it was never in the table, and stayed a raw /assets/logos/… path that is dead on an R2-served worker. It survived every image migration and a placeholder sweep that drove visible loud-fails to zero, because no page-facing instrument can see it. Only the ADR-0010 §E asset reconciliation could (referenced-but-missing: 1), and that is reporting-only.

Two fixes, both pipeline-level (so the next site inherits them): - FIELD_MIGRATIONS['fcr.business'] = [{ path: ['imageUrl'], shape: 'object' }]. - apps/cms/src/layouts/Base.astro resolves the field through getImageSrc (the §6-amendment ImageField contract) for both og:image and the JSON-LD image. It was the one CMS image consumer resolving nothing — feeding the raw field straight into new URL(...), which an r2: ref or an object shape cannot survive.

Zero uploads. The logo was already in R2 under the exact key fcr.header.logoUrl uses — the field hashed straight onto the existing object. The asset was never missing; only the reference was. referenced-but-missing 1 → 0; garvanbay unaffected (its value is already a resolved URL string, which getImageSrc passes through — verified byte-identical after redeploy).

Contract note: enumerate image fields from the schema, not from what a page renders. See known-patterns "An image field that never renders on-page is still a shipped image field".

References

  • ADR-0003 (per-site image flow — already works, not changing)
  • ADR-0004 (the adapter layer, which surfaces the broken path)
  • docs/pipeline/known-issues.md — "CMS image migration pipeline"
  • lib/backfill-assets.js — the per-site safety-net downloader
  • apps/cms/wrangler.jsonc — MEDIA binding already wired
  • transliteration npm package — https://www.npmjs.com/package/transliteration