EP-0026: Image API Simplification¶
Status: accepted Type: standards-track
This EP simplifies the EP-0023 image surface. It keeps the narrowed scope: frame/dispatch overlays and the source-stamping
rf/imagemacro remain EP-0028. This EP owns only the public image value, image selection, inline image-local registrations, collision/shadow semantics, and capability cleanup. If accepted, the normative home is primarilyspec/Conventions.md(the:rf.image/*reserved-key grammar), withspec/002-Frames.mdandspec/API.mdupdated to match.Accepted 2026-06-22 (Mike-ruled). Implementation is tracked under beads rf2-o2vfrc (spec graduation — Conventions / 002-Frames / API), rf2-6ls85a (
:select-ns+ image-order resolution), rf2-ke7w5j (shadow report), rf2-dlvmpc (retirements + surgical capability removal), rf2-fsd822 (inline grammar + default image), and rf2-qp8qi8 (conformance). The EP moves tofinalonce the spec graduation and implementation land.
Abstract¶
EP-0023 made image-loaded frames the public model, but the current image surface still has too many levers:
This EP replaces that surface with one selection vocabulary and a returned shadow report. The public image value is just:
An image does two things: it selects existing namespace-authored registrations
with :select-ns, and it defines new registrations inline with
:registrations — the image-level analogue of a namespace's (:require …) and
(def …). Composition (at make-frame) layers images by order — the later
image wins — and returns a shadow report the programmer can inspect or assert
on. Within a single image a [kind id] collision is an error, so every override is
between images; a cross-image shadow never fails assembly, while malformed images
and framework-standard collisions do. rf/image stays a plain function, and
image-level capabilities are removed end-to-end.
Motivation¶
The old :replace and :replace-standard maps are precise, but they force
authors to speak in descriptor coordinates for the common case:
Inline :registrations already say that directly. The API should let the
programmer define the winning registration where it belongs, resolve composition
by explicit image order, and report what got shadowed — so the programmer
applies whatever policy they want (assert none, assert a known set, log, ignore)
rather than writing an acknowledgement for every deliberate override.
The pre-alpha bar is a small, powerful model that hands back data where composition is a deliberate, resolvable choice, and fails loudly where composition is genuinely ambiguous, malformed, or unsafe — with the minimum ceremony that still buys those properties.
Use Cases¶
Three uses cover the surface, and they map cleanly onto the two keys —
:select-ns selects existing registrations, :registrations defines new
ones.
1. Typical — select your app's namespaces¶
The everyday production path: an image selects the registrations already authored across your namespaces. No inline definitions.
(rf/image
{:id :app/main
:select-ns {:include ["app.todo.**" "app.admin.**"]
:exclude ["app.todo.dev.**" "app.admin.fixtures.**"]}})
2. Teaching and explanation — define a whole example inline¶
A self-contained image with no :select-ns — every registration defined inline,
so a doc page, tutorial, or tiny test is one complete, copy-pasteable form.
(rf/image
{:id :quickstart/counter
:registrations
{:reg-event [[:counter/inc
(fn [{:keys [db]} _]
{:db (update db :counter/value inc)})]]
:reg-sub [[:counter/value
(fn [db _]
(:counter/value db))]]
:reg-fx [[:metrics/send
(fn [payload]
(send-metric! payload))]]}})
3. Unit tests and Story — compose the app with an overrides image¶
Use case 1 (the real app image) composed with a separate, small image that
defines overrides — stub effects, coeffects, and the like. The override image is
composed after the app image, so its registrations win, and frame-shadows
reports exactly what it overrode.
(def app-image
(rf/image {:id :app/main
:select-ns {:include ["app.checkout.**"]}}))
(def test-doubles
(rf/image {:id :test/doubles
:registrations
{:reg-fx [[:checkout.http/post recording-post]] ;; stub the effect
:reg-cofx [[:checkout/clock (fn [] fixed-instant)]]}})) ;; stub the coeffect
(let [frame (rf/make-frame {:images [app-image test-doubles]})] ;; test-doubles wins (later)
(rf/frame-shadows frame))
;; =>
[{:registration [:fx :checkout.http/post] :image :app/main :shadowed-by :test/doubles}
{:registration [:cofx :checkout/clock] :image :app/main :shadowed-by :test/doubles}]
An override is a separate image, never a second key: it is just a later image
whose registration shadows an earlier one. Within one image, two definitions of
the same [kind id] are an error, not an override (see Layered Resolution).
Goals¶
- Replace sibling
:include-ns/:exclude-nswith a single:select-nsmap. - Keep inline
:registrationsas the ordinary image-local definition surface. - Replace public
:replacewith deterministic image layering (later image wins) plus a returned shadow report — no upfront acknowledgement key. - Remove public
:replace-standard; standards are protected, not ordinary app extension points. - Remove image-declared host-capability declarations end-to-end.
- Expose the shadow report (
:rf.gen/shadows+ a frame accessor) for Xray, Pair, diagnostics, and test assertions. - Keep fail-loud for within-image collisions, malformed images, and framework-standard collisions.
Non-Goals¶
- Do not reopen the EP-0023 decision that frames run resolved image generations.
- Do not rename
rf/image. - Do not make
rf/imagea macro; it stays a plain function returning an inert image value. (Source-stamping authoring is discussed in EP-0028;rf/imagestays a function regardless.) - Do not remove ordinary namespace-authored
reg-*forms. - Do not specify frame- or dispatch-scoped overlays; EP-0028 owns those.
- Do not standardize an inline grammar for every registration kind in this EP. Kinds without a concrete parser remain namespace-authored until their owning spec defines inline lowering.
Relationships¶
- EP-0023 established image-loaded frames and the current image API. This EP is a simplification amendment.
- EP-0028 carries frame/dispatch overlays and the
rf/imageauthoring macro split out of the earlier EP-0026 draft. - EP-0022 / standard interceptors motivate the protected-standard rule.
- EP-0017 remains the source for causal coeffects; image-level capabilities are not a replacement for causal facts.
- EP-0007 (one name per fact) governs the "mirrors are recomputable projections" rule applied to generation provenance below.
Specification¶
This section is written in final-spec style. While this EP is a proposal, the
language below is the proposed normative contract.
Image Keys¶
The ordinary public image value accepts these top-level keys:
:id is required for named images and SHOULD be stable enough for diagnostics
and tooling. Anonymous test helpers MAY synthesize an id, but generated ids must
still appear in provenance records.
Image ids MUST be unique within a single :images composition. The shadow report
identifies images by id, so two images sharing an id in one composition is an
error (see Diagnostics). A synthesized id for an anonymous image is unique within
its composition.
rf/image accepts this source map and returns an inert image value — a plain
function, not a macro. :id / :select-ns / :registrations are the public
source keys; the :rf.image/* reserved namespace (Conventions) names the
normalized internal form the value carries, not the authoring surface.
An image may carry both :select-ns and :registrations, but the two must be
disjoint: a [kind id] may not be both selected and defined inline in the
same image (see Layered Resolution). To override a selected registration, define
the override in a later image and compose them.
The following keys are retired from the public rf/image surface:
Public image construction MUST reject retired keys with actionable diagnostics.
They MUST NOT be accepted as aliases and MUST NOT be ignored. There is no image
acknowledgement key (:shadows, :replace, …): shadowing is a composition
outcome, reported at make-frame, not a property an image declares about a
composition it cannot see.
Namespace Selection¶
Namespace selection uses :select-ns, a single map of :include and :exclude
pattern vectors:
(rf/image
{:id :app/main
:select-ns {:include ["app.todo.**" "app.admin.**"]
:exclude ["app.todo.dev.**" "app.admin.fixtures.**"]}})
:include is required and MUST be a non-empty vector. :exclude is optional and
defaults to an empty vector.
The selected namespace set is:
Exclusion is global to the image selection: a namespace matched by any :exclude
pattern is never selected, regardless of which :include pattern caught it. This
keeps the option teachable as "select these, never those" with no re-admission
corner cases.
Selection does not load code. The namespaces must already be loaded through normal
ns / build-tool mechanisms; :select-ns only filters registrations the runtime
already knows about, by source-namespace provenance. (This is why the key is
:select-ns, not :require: it selects, it does not load — and it must not defeat
dead-code elimination.)
The glob grammar remains the EP-0023 grammar:
- namespace strings are dot-separated;
*matches exactly one segment;**matches zero or more segments;- matching is case-sensitive;
- selection is by registration source namespace provenance, not by registration id namespace.
An include pattern that matches no registration source namespace MUST fail image assembly. An exclude pattern that matches nothing is allowed.
Selecting the same source namespace through more than one include pattern is idempotent. The descriptor is considered once.
An explicit image with no :select-ns selects no namespace-authored
registrations. It may still define inline :registrations.
Default Image¶
The default image is a frame-construction behavior, not an ordinary
rf/image value.
For the reference implementation:
- omitting
:imagesfrommake-frameuses the default frame image behavior; - the default image selects all ordinary namespace-authored registrations in the default registrar source set and includes framework standards;
- duplicate distinct
[kind id]descriptors in the default image fail loudly (there is no image order to resolve them — see Layered Resolution); - default image hot-reload behavior remains the ordinary same-source replacement case, not general last-writer-wins.
This is a behavior change to call out plainly, and it is ruled (2026-06-22): a
missing :images — make-frame {} — resolves the default image generation,
where a configured frame might previously have run without resolving a generation
at all. The magnitude is real; acceptance MUST cover the omit-:images default
path explicitly (see Acceptance Bar).
:images [] is an error: pass at least one image, or omit :images for the
default. To create a frame with no app registrations, pass a real empty image:
Layered Resolution¶
Image composition is ordered data. A frame created with:
resolves by one rule: the later image in :images wins. Image order is the
only precedence — there is no within-image winner rule, because an image must
resolve cleanly to one descriptor per [kind id].
Within a single image, any [kind id] that resolves two ways is an error:
- two selected descriptors for the same
[kind id](different source namespaces) — ambiguous; assembly fails. (The ordinary same-source hot-reload replacement of one namespace's own descriptor is not a collision.) - two inline entries for the same
[kind id]— malformed; caught atrf/imageconstruction. - an inline entry colliding with a selected one — error: to override, put the registration in a later image.
So an image may carry both :select-ns and :registrations, but they must be
disjoint. Overriding is never a within-image act; it is always a later image
shadowing an earlier one. When more than two images define the same
[kind id], the last image still wins and every earlier definition is a loser
(the Shadow Report names the final winner for each — see below). A cross-image
shadow resolves (later wins) and is recorded in the shadow report; it does
not fail assembly, and the programmer reads the report and applies whatever
policy they want. The one cross-image collision that still fails is an app
descriptor colliding with a framework standard (see Framework Standard
Registrations).
Resolution MUST NOT mutate the global registrar, the image value, or the frame's recorded image composition.
Shadow Report¶
When a later image shadows an earlier image's registration, composition records
it. Each entry says exactly one thing: the registration [kind id], the image it
was defined in, and the image that shadowed it. The report is exposed on the
frame's generation as :rf.gen/shadows and via the rf/frame-shadows accessor:
(let [frame (rf/make-frame {:images [app-image test-doubles]})]
(rf/frame-shadows frame))
;; =>
[{:registration [:fx :checkout.http/post]
:image :app/main
:shadowed-by :test/doubles}]
That is the whole entry — three keys, nothing else: no winner descriptor, image index, tier, source namespace, or scope tag. Images are named by their composition-unique id (see Image Keys). If two earlier images are both shadowed by the same later one, that is two entries.
When images form a chain for one [kind id] — say
[base override-a override-b] — the report names the final winner for every
loser, not the immediate predecessor: base is :shadowed-by override-b and
override-a is :shadowed-by override-b. The report always names the registration
that is actually live, so an assertion never has to walk a chain.
Every shadow is cross-image — it names two distinct images — because within
one image a [kind id] collision is an error rather than a resolved winner (see
Layered Resolution). There is no within-image case and no scope tag.
The programmer applies whatever policy they want — there is no upfront acknowledgement:
(is (empty? (rf/frame-shadows frame))) ;; assert nothing was shadowed
(is (= #{[:fx :checkout.http/post]} ;; assert exactly the intended overrides
(set (map :registration (rf/frame-shadows frame)))))
This replaces public :replace and the upfront acknowledgement model: the
programmer defines the winner in a later image, image order decides, and the
report names which registration each later image shadowed. A tool that wants
fuller detail (source namespace, etc.) reads the live registration's
:rf.provenance/* in the resolver.
Framework Standard Registrations¶
Framework standard registrations are protected. They are not part of ordinary app image layer order, and the report does not silently resolve them.
If an app descriptor has the same [kind id] as a framework standard descriptor,
assembly MUST fail with a standard-collision diagnostic. A standard encodes an
execution invariant (e.g. :rf.interceptor/path and the app-db commit no-op
rule), so shadowing it is a correctness violation, not an app policy choice.
A framework standard is a registration the framework itself ships and marks standard because it encodes such an invariant — not an ordinary product registration. The no-shadowing rule binds public app images; the framework, as the standard's owner, keeps an internal path to define and revise its own standards. A public app-facing standard-extension or standard-replacement hook, if ever wanted, is a separate standards-track decision. This EP does not add one.
Generation Provenance¶
The resolved generation value MUST expose:
:rf.gen/resolver ;; the sealed [kind id] -> descriptor map a frame runs
:rf.gen/images ;; the composed image inputs
:rf.gen/kinds ;; the kinds present
:rf.gen/shadows ;; the shadow report (above)
:rf.gen/requires is retired with the capability feature.
Per-descriptor layer facts (source namespace, owning image, tier) already live on
each resolved descriptor's :rf.provenance/* metadata; a frame's full layer view
is a recomputable projection of the resolver plus that metadata and is not a
separate normative generation key (EP-0007 rule 4 — mirrors are projections, not
co-equal sources). :rf.gen/shadows carries only the three facts above per shadow
— the registration and the two image ids — because the "X was shadowed by Y" fact
is otherwise discarded at resolution; anything richer stays on :rf.provenance/*.
Inline Registration Grammar¶
Inline registrations use this outer tuple shape:
The metadata map is optional and normalizes to {}. Metadata-only [id metadata]
entries are invalid in this EP. (EP-0023's image-fragment text permitted a
metadata-only tuple; this EP deliberately reverses that — see Backwards
Compatibility.)
EP-0026 standardizes only the inline kinds with a concrete parser:
| map key | kind | tuple body |
|---|---|---|
:reg-event |
:event |
event handler body accepted by the event registrar |
:reg-sub |
:sub |
simple db-reader subscription body |
:reg-fx |
:fx |
effect handler function |
:reg-cofx |
:cofx |
coeffect handler function |
The inline form of a kind lowers through that kind's own registrar parser, so the inline contract is exactly the registrar's contract — neither a looser superset nor a stricter subset, and the EP invents no parallel grammar. Two constraints are pinned now:
- inline
:reg-subaccepts only the layer-1 db-reader form(fn [db query] …); static:<-and parametric input-fn subscriptions stay namespace-authored; - inline
:reg-cofxcarries the coeffect's grade (recordable / provided) exactly asreg-cofxdoes, including a generator-less provided/recordable entry where the registrar allows it.
The exact per-kind metadata legal inline — for example :reg-event :interceptors
/ :rf.cofx/requires / event-arg schema / classification — is pinned in the
implementation slice against the live lowering, and is an Open Question until then.
All other inline registration keys MUST fail with an unsupported-inline-kind diagnostic until their owning spec defines: legal tuple forms, a body parser, metadata/body disambiguation, a lowering hook, a provenance shape, and conformance tests. This explicitly does not standardize inline forms for frames, routes, heads, flows, resources, mutations, resource scopes, views, error projectors, or interceptors in EP-0026.
Example:
(rf/image
{:id :quickstart/counter
:registrations
{:reg-event [[:counter/inc
(fn [{:keys [db]} _]
{:db (update db :counter/value inc)})]]
:reg-sub [[:counter/value
(fn [db _]
(:counter/value db))]]
:reg-fx [[:metrics/send
(fn [payload]
(send-metric! payload))]]}})
Capability Removal¶
This EP removes one narrow feature: image-declared host-capability requirements — the key an image used to declare it needs a host service, plus the frame-side check. Exactly these three public surfaces are retired:
:rf.image/requires ;; an image declaring a required host capability
make-frame :capabilities ;; the frame-supplied capability map
:rf.gen/requires ;; the required-capability set on a resolved generation
Assembly-time capability checking tied to those keys MUST be deleted, not left as an unreachable half-feature.
This removal is surgical. The word "capability" is reused across the repo —
:rf.capability/* host-service vocabulary, conformance capability ids, tool
capability flags, runtime profiles — and none of those are touched. The
residue gate MUST target exactly the three keys above, never the bare string
"capability".
If a future host dependency cannot be modeled by ordinary registration selection, frame configuration, adapter setup, or registration metadata, it must return through a new EP with concrete examples and an end-to-end tooling shape.
Diagnostics¶
Implementations MUST fail loudly for at least these cases:
- retired image key;
- invalid
:select-ns(missing/empty:include, or non-vector:include/:exclude); - include pattern with no matches;
- two images sharing an
:idwithin one:imagescomposition; - two definitions of the same
[kind id]in one image — two selected (ambiguous), two inline (malformed, atrf/imageconstruction), or an inline entry colliding with a selected one (an override must be a later image); - app shadowing a framework standard;
- unsupported inline kind;
- invalid inline tuple arity (including a metadata-only tuple);
:images [].
Note what is not here: a cross-image shadow — a later image overriding an earlier one — is reported, not failed. Diagnostics SHOULD include:
{:rf.error/id ...
:rf.error/phase :image/assembly
:kind ...
:id ...
:image-id ...
:image-index ...
:candidates [...]
:recovery ...}
The exact error ids are assigned in the implementation/spec update, but the errors themselves are normative.
Rationale¶
The winning image should be visible in data, not hidden in registration load order. So composition resolves by explicit image order and then reports what it shadowed, rather than demanding an acknowledgement for every deliberate override. The report is data: assert none, assert a known set, log, or ignore. This keeps the common deliberate-override case ceremony-free while keeping every shadow inspectable.
Overriding is a composition act, not a within-image one. An image must resolve
cleanly to one descriptor per [kind id], so a within-image collision is an error;
to override, you compose a later image whose registration wins. That single rule
removes any within-image winner precedence and makes every shadow cross-image —
which is why the report needs no scope tag and the entry is just registration +
two image ids. Image ids are required unique per composition so those two ids name
exactly one image each. Fail-loud concentrates exactly where composition is
genuinely ambiguous, malformed, or unsafe (framework standards), which are
correctness facts, not policy.
Deleting image-declared capabilities end-to-end follows the same rule. A public key should name a recurring application fact. That capability surface is not connected to a strong enough use case and should not survive as design residue — but the deletion is scoped narrowly, because "capability" names several unrelated facts elsewhere in the repo.
Backwards Compatibility¶
re-frame2 is pre-alpha. This EP makes clean breaking changes and does not require compatibility shims.
Migration is source-level:
- replace
:include-ns/:exclude-nswith one:select-ns {:include … :exclude …}; - replace
:replace: move the intended winner into a later image, compose, and read the returned shadow report if you want to assert on the overrides (there is no acknowledgement key, and an override defined in the same image as the thing it overrides is now an error); - remove
:replace-standard; ordinary app images cannot shadow standards; - remove
:rf.image/requires,make-frame :capabilities, and consumers of:rf.gen/requires(image-capability surfaces only — leave other "capability" vocabulary alone); - rewrite metadata-only inline entries (permitted by EP-0023) as explicit metadata-plus-body, or move them back to namespace-authored registrations;
- replace
:images []with omission (for the default) or a real empty image.
Retired keys MUST fail loudly so stale examples do not keep working by accident.
Reference Implementation Plan¶
- Add
:select-nsmap parsing with global exclusion semantics and strict include diagnostics. - Replace replacement-map resolution with image-order resolution (the later image
wins; within one image a
[kind id]collision is an error; image ids are unique per composition; a chain reports the final winner for every loser). Build the shadow report and expose it as:rf.gen/shadows, therf/frame-shadowsaccessor, and thereload-images!report; reflect it in theframe-generationread (rf2-wkw8na). The resolved-generation cache key MUST include the:select-nsselection and the image order. - Fail loud only for: any within-image
[kind id]collision (two selected, two inline, or inline-vs-selected), a duplicate image:idin one composition, a framework-standard collision, retired keys,:images [], and a bad inline kind. A cross-image shadow is reported, never failed. No acknowledgement key, no stale-ack check. - Protect framework standards from app shadowing; keep the standard-owner's internal definition/revision path non-public.
- Remove
:rf.gen/requires; do not add a:rf.gen/layerskey — expose layer facts via descriptor provenance.rf/imagestays a plain function. - Delete the three image-capability keys (
:rf.image/requires,make-frame :capabilities,:rf.gen/requires) and their checks from implementation, specs, tools, guides, and tests — surgically, leaving:rf.capability/*/ conformance / tool capability vocabulary untouched. - Narrow inline grammar to event/sub/fx/cofx via the late-bind inline-lowering publishers (events / subs / fx / cofx) and reject unsupported inline kinds; pin each kind's legal inline metadata and body forms against the live lowering.
- Make
:images []an error and specify the omit-:imagesdefault path. - Add conformance coverage for selection, default image behavior (including the
omit-
:imagespath), the shadow report contents (registration + image + shadowed-by, final-winner for chains), duplicate-image-id failure, within-image-collision failure, standard-collision failure, retired keys,:images [], and inline tuple errors. - Add a static residue gate for live retired spellings — scoped to the exact retired keys, not the bare string "capability" — outside historical prose, migration prose, and negative tests.
Affected Surfaces¶
At minimum, the implementation sweep should cover:
implementation/core/src/re_frame/image.cljcimplementation/core/src/re_frame/image_assembly.cljcimplementation/core/src/re_frame/core.cljcimplementation/core/src/re_frame/live_frame.cljcimplementation/core/src/re_frame/late_bind/directory.cljcand the per-kind inline-lowering publishers (events / subs / fx / cofx)- the
reload-images!report path and theframe-generationread (rf2-wkw8na) - the resolved-generation cache key
spec/API.mdspec/Conventions.mdspec/001-Registration.mdspec/002-Frames.mddocs/guide/concepts/images.md- Pair
describe-image - Xray image panels and reads
- Story schemas/specs that model image values
- examples and migration docs
- conformance and residue gates
Acceptance Bar¶
This EP should not graduate until:
- The shadow report is returned and complete: for every cross-image shadow, one
flat entry naming the registration
[kind id], the image it was defined in (:image), and the image that shadowed it (:shadowed-by) — by their composition-unique ids. No scope tag, no winner/loser descriptors. - A cross-image shadow (a later image overriding an earlier one) does not fail assembly.
- Every within-image
[kind id]collision (two selected, two inline, or inline-vs-selected) and every framework-standard collision fails loud. - Image order is tested across the override cases, including a multi-image chain: the later image wins, and every loser's report names the final winner.
- Image-capability deletion is complete and surgical: the three image keys are
gone, and
:rf.capability/*/ conformance / tool capability vocabulary is untouched. - Default image behavior — the omit-
:imagesdefault path and:images []as an error — is specified and covered. - Unsupported inline kinds fail loudly, and each supported inline kind's legal metadata/body is pinned against the live lowering.
- Two images sharing an
:idwithin one composition fail loud. - Retired spellings are blocked by a static residue gate scoped to the exact keys.
Open Questions¶
The design is settled (see Resolved Decisions). The two remaining items are implementation detail, left to the implementing agents — they do not gate acceptance and are pinned during the implementation slice, not by an operator ruling:
- The exact
:rf.error/*ids for the new diagnostics. - The exact per-kind inline metadata grammar pinned against the live lowering —
e.g. which
:reg-eventmetadata is legal inline (:interceptors,:rf.cofx/requires, event-arg schema, classification), and the precise:reg-cofxgrade encoding.
Resolved Decisions¶
Settled during design:
- Shadow report shape — a flat
[{:registration [kind id] :image <defined-in> :shadowed-by <winner>}]list; no scope tag, no winner/loser descriptor. - Image-id uniqueness — image ids MUST be unique within an
:imagescomposition; the report identifies images by id. - Shadow chains — every loser reports the final winning image, not the immediate predecessor.
- Accessor —
rf/frame-shadows. - Capability scope — only the three image-capability keys are removed; other "capability" vocabulary is untouched.
rf/image— stays a plain function, never a macro.- Default image (Open Issue 3) — a missing
:images(make-frame {}) means the default image generation (ruled 2026-06-22).