The DCP half that outstanding.md listed as deferred is built (D20). What stays open is the profiles directory, which does not sync, and the profile tone curve and baseline exposure, which are read and not applied — and which camera-profiles.md §1 measured as where the rest of the gap to Lightroom's colour is.
44 KiB
DarkRoom — Outstanding work
Status: Living document · first written 2026-08-29 Companion to: requirements.md §7, technical-debt.md, traceability.md
What is specified and not built, and for each cluster whether that is a decision, a dependency, or a gap nobody has looked at.
This document exists because traceability.md cannot tell those apart. It reports
one number — the share of requirements carrying a TRACES tag — and a missing tag means either
"nobody has built this" or "somebody built it and did not say so". Both read the same way in the
summary table, which makes that figure pessimistic and uninformative at once: it understates what
works while hiding which of the remainder matters. Eight requirements gained a tag on this branch
because the code already satisfied them and nobody had said so. Everything below is the other kind.
It is also not a plan. requirements.md §7 records what was deferred deliberately and needs no argument; this records what is still nominally in scope, so that the distance between the register and the binary is visible rather than something a reader has to reconstruct from a percentage. Where the honest answer is "this requirement should be amended rather than met", it says so — an unbuilt requirement that nobody intends to build is worse than a deferred one, because it keeps costing attention.
Several entries were struck between 2026-08-29 and 2026-08-30, as two waves of work landed: burst grouping (FR-CULL-5), Flatpak packaging (FR-PLAT-LIN-3), FR-CULL-3 in full, Android memory pressure and lost-root recovery (FR-PLAT-AND-5, FR-PLAT-AND-2), image intents (FR-PLAT-AND-6), and the job runner (FR-PLAT-AND-4's Rust half). What remains of each is recorded where it appears rather than deleted, because a requirement that is half met is the one most likely to be reported as closed.
Swept again on 2026-09-26, for 0.16.0. Progressive refinement (FR-DSP-4) and the panorama (§3.11) were built and are struck below; consolidating duplicate originals (FR-CAT-11a) now sits beside re-import detection; the accessibility and localisation counts in §6 had not been read against the tree since 2026-08-30 and are replaced; and §4a records what the develop and keyboard work of 0.15.0 and 0.16.0 left open.
And again for 0.17.0. Folders are chosen through the platform's dialogue now, so FR-PLAT-LIN-3 in §5 is rewritten around what the Flatpak has still not shown; albums brought the first SAF code, which FR-PLAT-AND-1's entry now describes, along with why its new tag overstates it.
And for 0.18.2. FR-CULL-13's write-path half is held by a test now, and §2 records the evidence
half it leaves; NFR-ARCH-1's executors are named and guarded but not pooled, recorded in §4. Three
things closed without having had an entry: a mask layer's film settings, which the panel offered
and which moved nothing until 0.18.2 (FR-DEV-3f); hot and dead photosites, repaired on the mosaic
before the demosaic, for Bayer and X-Trans alike and with no setting (FR-RAW-3; the defect-map
reader in dr-decode is still not wired in, and a CR2 carries no map for it to read); and the
tablet's scrollers, which §4a now describes.
And for 0.19.0. D19 rebuilt the develop pipeline around one rule — scene-linear from the camera matrix to a single view transform, last (architecture.md §6.14) — which neither closed nor opened an entry here: FR-DEV-3e's per-body base curves are retired by decision rather than left outstanding, and its DCP half stays deferred as before. §4's FR-DSP-2 and NFR-RES-2 entries record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame choice, which changes how FR-MRG-5 is met rather than whether.
And for 0.20.0. FR-DEV-3e's DCP half is built (D20, camera-profiles.md):
the HueSatMap and LookTable from a DNG's embedded profile or a matched .dcp. Two pieces stay
open, both named in that design's §6: the profiles directory does not sync, so a CR2 can render
with a copied profile on one device and without it on another; and ProfileToneCurve and
BaselineExposure are read and not applied. The measurement in its §1 says the second is where the
remaining gap to Lightroom's colour lies.
1. Plugins — post-v1 since 2026-09-19
Resolved, in the register. The contradiction below was settled on 2026-09-19 the way the last paragraph of this section asked: §3.10 is marked
(post-v1)clause by clause, §7's row says so with a reason, D16 defers with it, and the traceability tool lists deferred clauses in their own table instead of counting them. Coverage went from 72.2% of 194 to 80.6% of 170 on that edit alone. What follows is kept as the record of what was decided and why; nothing in it is owed a tag.
Untagged: FR-PLG-1, -1a, -2a, -2b, -2c, -3, -3a, -4, -4a, -5, -5a, -5b, -5c, -6, -6a, -7, -8, -9, -10, -11, -12.
No plugin host exists. No crate loads anything at runtime: there is no manifest reader, no WASM or
Lua engine, no registry, no signature check, no install path, no capability grant, no per-plugin
failure ledger. declared/mod.rs says as much in its own documentation — the operation format is
"not a plugin directory read at startup".
Two of §3.10's requirements are met, and they are the interesting two. FR-PLG-2 and FR-PLG-2d —
the declarative node format — are built and tagged: core/dr-pipeline/ops/*.yaml compiled by
build.rs, with the restricted expression grammar in declared/expr.rs and a parity test asserting
a declared operation and a hand-written one produce identical output.
code-health.md §3 calls it "a working plugin system that happens to resolve at
build time", and that is exactly right. What is missing is not the format; it is everything that
would let somebody who is not in this repository use it.
The contradiction. requirements.md §7 lists | Plugin API | — | among the
things deferred for v1 — a bare row, where most deferrals carry a justifying note. §3.10 then spends
roughly 280 lines and 23 requirement IDs specifying that same Plugin API in detail. Both statements
are in the register of record, and the traceability denominator counts the second one: 21 IDs, 12%
of all 179 defined requirements, worth about twelve points of coverage on their own — and nearly a
third of everything the matrix reports as uncovered. A reader looking at the coverage figure has no
way to know that, or that the subsystem behind it is one the same document says is not in this
version.
And D16 is open. Decision D16 — plugin licensing — records that GPLv3 answers the derivative-work question differently for each of §3.10's three plugin forms, and that this "must be answered before an ecosystem exists, not after", because a term introduced later cannot be applied to plugins already written. D16 explicitly does not block FR-PLG-2; it blocks publishing a third-party format as stable.
What would resolve this: an edit to requirements.md, not code. Either §7 drops the row, or
§3.10 is marked deferred with the two built requirements carved out. Until one of those happens the
coverage figure is measuring a decision that has already been taken, and taking it again every time
somebody reads the matrix.
2. Culling — the stated differentiator, half built
D11 names culling "the core differentiator". FR-CULL-1 through -5 and -8 through -12 are built, and FR-CULL-13 is half built. Two are not built at all.
FR-CULL-3 — Raw-truth overlays. Built, all three bullets. Focus peaking is
core/dr-gpu/src/focus.rs and ui/dr-ui/src/peaking.rs; the raw histogram and the raw clipping
indicators are core/dr-gpu/src/raw_histogram.rs and the second reading of the panel in
histogram.slint.
Worth recording, because it is the thing this entry previously got wrong and the next reader will
have to check again. The display histogram — dr-gpu/src/histogram.rs, ui/dr-ui/src/histogram.rs
— is not this requirement and never was: it is tagged FR-DSP-7, it reads AdjustPass's 8-bit
output, it counts clipping as r == 255, and so it describes the frame the display is about to
show, after the whole develop chain. FR-CULL-3 asks for the sensor data, on the explicit grounds
that a rendered image "systematically lies about what is recoverable in the raw". The two now sit in
one panel behind a chip row, which is the arrangement that keeps them from being mistaken for each
other: they answer different questions and both are true.
What the raw reduction cannot answer is written down rather than left to be discovered — architecture.md §5.5 records why it reduces over the demosaiced texture instead of the CFA samples §5.5 originally specified, and what that costs in what it can say.
FR-CULL-5 — Burst and near-duplicate grouping. Built. core/dr-catalog/src/bursts.rs:
frames join a burst when they are adjacent in time and look like the frame before them, compared
adjacent-pair-only in one ordered walk. Signatures are 64-bit difference hashes taken from the
thumbnails dr-thumbs already holds, on a background pass after the thumbnail sweep — nothing at
import, nothing at query time. Nothing scores or rejects a frame: the representative is the
earliest, a fact about the clock, and a new burst arrives open so the pass never takes a row off
the screen.
Two threads left hanging. core/dr-face/src/calibrate.rs still says "since FR-CULL-5 already
groups bursts, positives are bootstrapped from bursts" while in fact bootstrapping from confirmed
labels — that comment was a forward reference and is now simply wrong, rather than premature.
And dr_catalog::bursts::choose_representative is written and tested but bound to no gesture, so
today the only override is expanding the burst.
Neither is deduplication, which is about one file held twice rather than two frames that look
alike. core/dr-catalog/src/dedup.rs is re-import detection under FR-CAT-11, matching a file on
a card against one already catalogued. Its other half, FR-CAT-11a, is built since 0.16.0:
core/dr-catalog/src/duplicates.rs groups the copies a library already holds (same root, camera,
capture instant and size), ui/dr-ui/src/duplicates.rs proves each group the same by digest and
compares their edits, and a group is folded onto one survivor with the others trashed in one
transaction, from the Duplicate originals review in the sidebar and in Settings.
FR-CULL-13 — Evidence, never verdicts. The write-path half. Every write of a rating, flag,
colour label or trash membership in the shipped code is enumerated by
tools/traceability/src/verdicts.rs, which parses the tree with syn and holds each site to a
reviewed list with its reason: a key, click or tap, a function writing for callers that are checked
in turn, or a verdict carried from elsewhere (a sidecar or .xmp pull, the sync merge, the catalog
mirrored to a file, duplicates consolidation). An unlisted write, or a listed one that is gone,
fails cargo test -p traceability. What is not built is the evidence itself: chips for clipping,
focus, burst membership and face counts on the grid cell and in FR-CULL-4's mode, shown as absent
rather than zero, with a filter per signal. Eye state is the one signal shown today, on People's
face cells and as a library filter.
FR-CULL-6 — Compare and survey. Absent. No side-by-side view, no synchronised zoom or pan. This is the one of the four with no adjacent machinery at all, and it is also the one that most directly distinguishes culling from browsing.
FR-CULL-7 — Culling on tablet. Absent, and blocked by the three above rather than independent of them: there is no separate tablet culling surface to build until there is something to put on it.
3. FR-DEV-3g — AI denoise
Promoted into v1 by D11, and named there as the precondition for deferring AI
masking — the argument being that one learned stage earns the runtime that a second could then
reuse. Only classical noise reduction exists: ops/noise_reduction.rs, a bilateral filter in two
arrangements, exact for luminance and separable for chroma. It is good, and it is not this.
models/ holds two face models and nothing else; core/dr-segment/models/ holds a YOLO
segmentation model for subject masks. There is no denoise model, no learned demosaic, and no
inference path that is not face or segmentation.
The obstacle is not the pipeline. It is that D13 — model licensing — is still open for the models that already ship, and adding a third learned stage adds a third licence to answer for. Building the runtime before that is settled means owning the same problem in one more place.
4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2, NFR-ARCH-1
FR-DSP-2 — Tiled computation. Built for one case, and otherwise under challenge. architecture.md §6.2
calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built,
and the evidence has since moved. core/dr-gpu/tests/frame_budget.rs carries the argument in its
own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget,
and "if that stops being true, the recommendation to strike tiled computation from the interactive
path stops being supported, and this test is what says so."
technical-debt.md TD-4 reaches the same place from the other direction — a
tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the
stage that looks most like it wants a tile cache is the stage that would be hurt most by one.
What existed until 0.19.0 was the declaration and not the mechanism: DetailPass::radius, the halo
a tile would have to be grown by, with a test that pins it, and nothing to read it. The export of an
oversized linear DNG (below) is now what reads it, through ComposedDetail::reach; there is still
no scheduler and no tile cache.
So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement" — and on 2026-09-19 the answer was: as written, until S6 runs. FR-DSP-2 now carries a status note saying exactly that, and R5's note no longer claims it was rewritten. Two measurements say it costs more than it saves on the interactive path. Neither says anything about the export path or about a device under memory pressure, which is where the case for it actually lives — and that is spike S6, which has not run.
2026-09-27: the export path does now tile, for the one case that forced it — a linear DNG wider than any texture, a 22 927 × 8966 Lightroom panorama in the case that prompted it. See the note under FR-DSP-2 in requirements.md and ARCH §5.3. The interactive path renders such a file from a reduced copy and one full-resolution window rather than tiles, and S6 is still unrun.
FR-DSP-4 — Progressive refinement. Built in 0.15.0. While a gesture moves the canvas renders
a half-resolution draft, and the sharp frame lands once, 120 ms after the last movement: the
decision is ui/dr-ui/src/refine.rs, a debounce whose every draft re-arms the settle timer, driven
through simulated timelines in its tests. The draft flag reaches the interface (canvas-draft,
Levels.provisional), so the histogram dims while it describes a frame older than the one on
screen, and the last draft is kept and faded out over 150 ms when the sharp frame arrives, so
refinement is not a swap. Draft frames themselves date from 2026-08-09; what this entry missed,
and 0.15.0 finished, was a settle that waited for the gesture to stop, a provisional state the
interface could see, and a refinement that was not a jarring swap.
NFR-RES-2 — Images larger than GPU memory. Half answered. NFR-R8's "decide explicitly" was decided on 2026-09-19: there is no CPU render pipeline, the degraded mode is the viewer on embedded previews with develop withheld, and NFR-RES-2 no longer promises a fallback render. Since 0.19.0 that mode no longer catches a linear DNG larger than one texture, which develops from a reduced copy and exports in tiles; a CFA file that large still falls to it. What remains unbuilt is the memory half: there is no headroom budget, no allocation-failure staging, and no spill. Spike S6 — a tiled pipeline on a mid-range Android device with an image larger than available GPU memory — is the one that would settle both this and FR-DSP-2, and there is no evidence it has run.
NFR-ARCH-1 — Named executors. Named and guarded, not bounded. dr_ui::executors names the
five executors of architecture.md §7.1 with their thread counts, every
long-lived worker in dr-ui and the Android entry point starts through its spawn as
<executor>:<role>, and net_runtime's block_on fails a debug or test build on the UI thread.
The counts are a budget and not yet a limit: each job still gets a thread of its own, and a pool
sized from Executor::threads is where NFR-ARCH-2's priority classes would live. The guard covers
block_on only — not a synchronous file read or a catalog query on the UI thread, which the library
and People screens make on a click by design (catalog.md §1). The two mask workers in
masks_ui.rs still call std::thread::spawn, and the threads the core crates start are outside the
module.
4a. Develop, masks and the keyboard — what the 0.15.0 and 0.16.0 work left open
Most of what these two releases built closed a clause outright, and is listed here only so that
nobody looks for it below: perspective correction (FR-DEV-20, Vertical and Horizontal in Compose),
the crop that says when it orphans a mask (FR-DEV-17), intersection as a third mask join
(FR-DEV-19a), the keyboard vocabulary for develop (FR-DEV-16, met and held by gestures-check in
both directions), colour labels set, shown and filtered (NFR-A11Y-3's first example), the RAW
decoder behind a trait (FR-RAW-2), and duplicate originals (FR-CAT-11a, §2 above). What they left:
- FR-UI-5's pointer half. Keys cover navigation, rating and common adjustments in every view, and judging works in develop on the open photograph; but no develop slider takes the scroll wheel, which the clause names. Its status note in the register says so rather than rounding up.
- FR-RAW-2's second decoder. The trait is built and a stub decoder is proved to reach the scan, the preview ladder and export without a caller changing; LibRaw itself (D2) is not built, and S7 is what would say when it is needed.
- FR-DEV-19's M2 remainder (mask-editing.md §11). The panel's Add, Subtract and Intersect buttons join a painted part only, so an intersection with a gradient or a range is painted where the mask should survive; per-part distance fields and folding two layers are unbuilt. From M1, the brush has no ring drawn on the photograph and a layer's whole stroke history is redrawn per dab.
- FR-DEV-17's blind spot, by design. A layer that adds a range or a region selection is never
reported, because the check has no label map to measure it with (
dr_pipeline::orphan); a false alarm on the common path would teach the notice to be dismissed unread.
The desktop's scrollbars (the develop column, the grid, the sidebar, Settings, the film list and
the help sheet) are drawn only where dr_plat::is_touch_first() is false. On Android the same
scrollers show instead a 3 px position cue (#69): Scrolling.cue in widgets.slint, the thumb
alone, drawn while the viewport moves and faded 500 ms after, with no touch target, so a flick that
starts on it scrolls the list (tests/scroll_cue.rs). A desktop build shows the cue when
DR_SCROLL_CUE=1 is set, for checking it without a device. Not yet checked on the tablet.
5. Android beyond running, and Flatpak
The Android app is not a stub — it builds an APK, runs the whole application, unpacks bundled face models, has been measured on a tablet (faces.md §12.1), and since 0.15.0 draws the develop view zero-copy as the desktop does (technical-debt.md TD-1, paid off). It carries the manual and opens it in a WebView. What is missing is the platform contract around it.
FR-PLAT-AND-1 — SAF is built for export folders, not for the library. The requirement demands
that library access be obtained exclusively through the Storage Access Framework. Until 0.17.0
there was no SAF code at all, and the requirement's two tags rested on a SourceRef::Document
variant constructed only inside #[cfg(test)] and on dr_plat::imports_supported, which returns
false on Android and whose own documentation says it "stops being false when a SAF implementation
lands". Both were removed as intent rather than code — the "plumbing a future feature would use"
case CONTRIBUTING.md and code-health.md CH-4 both warn
about.
0.17.0 brought the first real SAF code, for albums (FR-EXP-10): FolderPicker.java starts
ACTION_OPEN_DOCUMENT_TREE from a translucent activity of its own (the main activity is
NativeActivity, whose results are not ours) and takes a persistable grant; Saf.java writes each
export through DocumentsContract; ui/dr-ui/src/saf.rs is the JNI bridge. They shipped tagged
TRACES: FR-PLAT-AND-1, which made the matrix count the requirement as covered, and that overstated
it: the mechanism is the one the requirement names, but its subject is the library, and Android
still reaches a library through a Nextcloud account or a folder, over paths, like the desktop. The
tags now say FR-EXP-10 alone (0.17.1), so FR-PLAT-AND-1 reads as uncovered again until the library
itself is reached through SAF.
That has a consequence for the rest of the cluster: FR-PLAT-AND-2 — detecting the loss of a granted tree permission and marking images offline rather than deleting rows — is still blocked for the library, because there is no library grant to lose. An album's folder has a grant now, and nothing checks for its loss: an export into a folder whose grant has gone fails with whatever the write raises, rather than the album saying beforehand that it needs a folder again.
FR-PLAT-AND-4 — half built. The runner is done (core/dr-catalog/src/runner.rs): the
queue that jobs.rs always had is now claimed from, completed, failed and recovered after a
crash, which is FR-PLAT-AND-3's resumability as much as this requirement's. What is missing is
the platform half — a foreground Service, FOREGROUND_SERVICE and POST_NOTIFICATIONS in the
manifest, and a stated Doze behaviour. The build step that blocked it is no longer a blocker: the
APK now compiles its own Java.
Note also that no handler is registered, deliberately — and, since #73, nothing enqueues
either. The remote scan's per-photograph Thumbnail jobs, and walk::scan_root's Thumbnail and
ExtractMetadata jobs, duplicated debts the thumbnail store and metadata_state already record,
and were never claimed; they were removed and Thumbnail retired (catalog.md §6.1).
Feeding FR-PLAT-AND-4's platform half is therefore a matter of choosing a kind whose work has no
better source of truth, registering its handler, and enqueuing it in the same change —
every_queued_kind_has_a_consumer refuses the enqueue without the handler.
FR-PLAT-AND-5 — built. A tiered eviction registry drives GPU caches, then proxies, then
thumbnails, from MainEvent::LowMemory and MainEvent::Stop.
FR-PLAT-AND-6 — built, with one half unwired. VIEW, SEND and SEND_MULTIPLE filters, the launch
Intent read over JNI, and an ExportProvider rooted at getFilesDir() rather than AndroidX's
FileProvider. The outbound share has no caller in ui/ yet. None of the runtime behaviour has
been exercised on a device — the tests read the manifest and the Java through include_str!,
which catches a deleted filter but not a class loader that cannot find the class.
FR-PLAT-LIN-3 — packaged and wired, not yet proved. There is a Flatpak manifest, granting no
filesystem permission of any kind, plus AppStream metainfo and docs/distribution.md. Until 0.17.0
the requirement could not be met by packaging: a folder library was chosen by typing an absolute
path, nothing called the FileChooser portal, and inside the sandbox $HOME holds only
.var/app/.... Since 0.17.0 every folder the desktop asks for — the library, an import's source
and second copy, Lightroom presets, an album's folder — is chosen in the platform's dialogue
(ui/dr-ui/src/folder_dialog.rs, rfd over the XDG portal), which is what a sandbox needs. Two
things stop this entry being struck. No Flatpak has been built here — flatpak-builder is not
installed — so the portal path has never been seen to open a library, reopen it after a restart,
or take a sidecar inside the sandbox; distribution.md §4 says what has to be
checked. And
dr_plat::volumes() still reads /proc/self/mountinfo, so a card mounted on the host is invisible
to a sandboxed process; the import page's Browse… reaches one through the portal instead. The
chooser landed in ui/ rather than behind the dr-plat seam distribution.md had proposed.
NFR-COMPAT-2 — distribution channels. Stated, which is all this requirement asks. The paragraph above cites distribution.md and it is the same document that answers this: §1 names five channels and their state — Arch source package and Flatpak in tree, AppImage a v1 channel whose recipe is not written, F-Droid a v1 channel not yet submitted, and Play explicitly not v1. The requirement is to state the channels, and they are stated, including the deferral.
What remains is the coupling the requirement points at rather than the statement it demands. §4.8 observes that publishing on Play is what turns SAF from a preference into a constraint, and distribution.md §6 argues the coupling runs the other way for this project — F-Droid asks nothing that ARCH §6.9 does not already require. Spike S11, the Play permissions dry-run, has not run, and until it does that argument is reasoned rather than confirmed. Two of the five channels also exist as decisions rather than as recipes, and no Flatpak has been built here at all.
Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android
Dockerfile and are checked in CI against the built ELF, which is good — while the items the
requirement singles out are missing: whether shaderFloat16 and 16-bit storage are required (the
one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device
from a second GPU vendor.
NFR-OPS-2 is met, and NFR-OPS-4 is not. Crash reporting is platform/dr-plat/src/crash.rs: a
panic on either platform writes a local record with a redacted message and backtrace, ten are kept,
and there is deliberately no upload path — the requirement's "upload only on explicit opt-in" is
satisfied by there being nothing to opt into, and the module says why a transport built ahead of the
consent is the wrong order. (This paragraph said the opposite until 2026-09-12; the record had landed
on 2026-08-30 and the paragraph had not been read against it.) Update and first run are undefined;
the concrete reason NFR-OPS-4 gives — that D2 pins rawler at a non-SemVer alpha whose camera-support
fixes users will need — is unaddressed, and there is no update mechanism of any kind.
6. Accessibility and internationalisation — the hard half is done and the easy half is not
NFR-A11Y-1 — Localisation. One screen of twenty-eight Slint files is converted: the launch
screen wraps its strings in @tr(, and ui/dr-ui/build.rs records the mechanism — Slint's bundled
translations, a .po per language under ui/dr-ui/lang/, extracted with slint-tr-extractor —
and why bundling rather than gettext (Android has no path a .mo could sit at). No .po exists
yet, so every build is the original English, and the rest of the interface (about 23,600 lines of
markup) is unwrapped. The part that is genuinely architectural was got right: LocalizedKey keeps
display strings out of core/ entirely, every operation publishes a key rather than a label, and
labels.rs is the single point where a key becomes text — but that point is a hardcoded English
match, and build.rs says the Rust-side mechanism is not built. Two things the requirement asks
remain against it even when the conversion is finished: a bundled translation is compiled in, so
changing one still needs a rebuild, which the clause forbids; and there is no locale-resolution rule
and no decision recorded about RTL.
NFR-A11Y-2 — Accessibility. Tagged, and half tested. accessible-* properties now appear on
some 127 lines across eleven Slint files — the shared controls in widgets.slint and
controls.slint, every slider, the rail, the grid's cells (named by file), the rating stars, the
labels and the duplicates review — and ui/dr-ui/tests/ui_controls_are_accessible.rs fails when a
shared control stops declaring its role or a slider is added without a name. The manual's recording
scripts find every control they press through the same names (dr_ui::automation), so a control a
screen reader cannot name is also one the manual cannot record. What is unchecked is stated in the
test: whether the words are good, and whether Android exposes any of it — spike S13, which has not
run. The requirement's other two clauses are debt with entries of their own: platform font scaling
(TD-7) and WCAG AA contrast (TD-6).
NFR-A11Y-3 — Colour-independent status. Built, and now asserted. The clipping readout pairs a marker that appears or disappears with a figure in words; the rating strip is a solid star against an outline in an achromatic palette; the pick/reject mark is a tick against a cross; the focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches; and colour labels — the requirement's first named example — now have an interface built with a shape from the start: every mark carries its label's initial (R, Y, G, B, P) on its colour, and every chip, picker and the develop bar name the label in words.
The clipping clause is pinned by a_clipping_figure_distinguishes_none_from_nearly_none. The other
four are pinned by ui/dr-ui/tests/status_is_not_colour_alone.rs, which reads the markup and fails
if a star, a flag or a label comes to differ in tint alone — if the glyph a state selects stops
depending on the state, if two states select the same drawing, if two labels share a letter, or if
the peaking chips stop being words. It is a structural check, not a perceptual one: it cannot say
whether the letters are legible at 14px on a given screen, which is still a matter of looking.
7. Catalog and sync
FR-CAT-14 — Migration import. Reading ratings, labels, keywords and collections out of a
Lightroom .lrcat or a darktable library.db. Unbuilt. The destination is not: keywords,
collections, ratings and the cross-device merge rules are all built and tested, and
keywords.rs already anticipates the arrival ("an import from Lightroom can bring in…"). What is
missing is only the two source adapters — which is a comparatively contained piece of work for a
requirement that decides whether somebody can try this software on a library they already have.
FR-NC-11 — Initial catalog build. Using WebDAV SEARCH (RFC 5323) against /remote.php/dav/,
filtered by mimetype and paginated, in preference to walking folders with PROPFIND. Unbuilt: no
SEARCH request is issued anywhere. The PROPFIND walk this exists to replace is fully built and
well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library into one request — so the
gap is narrower than it reads. It is the first build against a large remote library that pays, and
that is the moment a new user meets.
FR-CAT-13 — XMP interoperability, wired on 2026-09-12. core/dr-xmp reads and writes
standard XMP sidecars: dc:subject and lr:hierarchicalSubject, xmp:Rating and xmp:Label, and
the IPTC core fields, in both the attribute and the element form and whatever RDF container a file
happened to use. It states the ownership rule in one place — DarkRoom owns the properties in
PROPERTIES and nothing else in the document, identified by namespace URI rather than by prefix —
and enforces it by rewriting a packet event by event rather than serialising over it, so another
application's crs: settings, comments and processing instructions survive a write byte for byte.
The wiring is ui/dr-ui/src/xmp_sync.rs. The scan collects .xmp beside .drsc from the
listings it was already making; the pull reads each one whose ETag has moved and reconciles it
against the catalog with the catalog winning — keywords union, and a rating, label or caption taken
only where the catalog holds none. Both naming conventions resolve: IMG_0001.CR3.xmp names its
file, IMG_0001.xmp the stem, and the JPEG beside a RAW is the same photograph. A genuine
disagreement is written to xmp_conflicts and the settings page offers "Take the sidecars' values",
which is the reload the requirement asks for; the detection it asks for is the ETag that moved. The
write in the other direction is behind a setting that starts off (NFR-R4): a judgement or a keyword
then also rewrites the sidecar beside the original, keeping the file's own caption, copyright and
hierarchy, which the catalog has no columns for and would otherwise have deleted.
What remains. GPS is not carried — exif:GPSLatitude is a format of its own and dr-decode
produces no location for it to carry yet. Title, description and copyright are read and reconciled
but the catalog has nowhere to put them, so they pass through a rewrite rather than being editable.
An XMP write made offline is not queued: the catalog and the .drsc are authoritative, and the next
judgement online writes the file whole again.
dr-preset-xmp remains what it always was and is still not the counter-example it looks like: a
reader of Lightroom presets under FR-DEV-6, a different file for a different purpose.
8. The performance targets are half-verified, and the half that is left is the hard one
§8 and §4.1 both require the same thing in the same words: an automated benchmark suite against a
synthetic 50k catalog, run per commit, where "a regression beyond a stated tolerance is a build
failure, not a notification." For most of this project's life it did not exist — no benches/, no
criterion, no synthetic catalog, and three CI workflows that between them measured nothing.
It exists now, for everything that does not need a frame. tools/bench builds
a deterministic 50,000-row catalog over a pool of a dozen real files, measures against it, and fails
the build on a violated budget or a drift past tolerance;
.gitea/workflows/benchmark.yml runs it on every push, and
benchmarks.md is the account of what it does and does not cover. NFR-P1 and
NFR-P3 are now genuinely gated, and R2's "catalog opens in under 2s" clause with them.
Three qualifications, all of them stated in the harness itself rather than only here:
- The numbers have not been recorded yet. Every
recordedfield in bench-baseline.json isnull, deliberately: a fabricated baseline is worse than none. Untildr-bench record --referenceis run on the reference desktop and committed, the budget gate works and the regression gate does not. - NFR-P7 and NFR-P8 are half-measured and are not tagged. The export row covers the encode half
of the chain and no GPU render, so it can fail the requirement and cannot pass it. The memory row
covers a process holding the catalog and nothing else — no toolkit, no adapter — so it is the
catalog layer's share of the 500 MB rather than the figure NFR-P8 is about. Neither carries a
TRACES:tag, which is the point. - NFR-P8 needs a decision, not more code. How much of its 500 MB belongs below the UI is unstated, and until somebody says, the metric can record but not judge. benchmarks.md also answers the question §4.1 raises about GPU memory — RSS cannot see device-local allocations at all — and recommends restating the requirement as two figures.
What is left is the frame-timing half, and it is the hard one. NFR-P2, -P4, -P5, -P6, -P9, -P10,
-P11, -P12, -P13, -P14 and -P15 all need a probe inside a running Slint application, a GPU adapter,
or both. dr-gpu/examples/frame_budget is a real instrument for the GPU part and its results are
committed in frame-budget.md with the machine and profile named — but it is run
by hand, and the guard version in CI skips itself where there is no adapter, which is the normal
case on a runner. So the claim to take from this section is now narrower than it was, and still
true: a scroll that dropped to 30 fps tomorrow would reach a user before it reached CI.
9. Two core requirements that cannot be closed as written
R1 — Cross-platform output within a bounded tolerance. §2 states that the threshold "must be fixed before spike S9", because S9 both validates R1 and calibrates what tolerance is achievable. The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at all — there is nothing a test could assert.
The matrix used to report R1 as covered, and what covered it was two string literals: fixtures
inside the traceability tool's own unit tests, which the tool scans along with everything else,
because a fixture demonstrating tag extraction was indistinguishable from a tag. The extractor now
asks where the tag sits — a tag is the first word of a comment, not a string appearing anywhere on a
line — and R1 is untagged again, which is the honest reading while it has no acceptance criterion to
tag anything against.
NFR-OPS-1 was covered by tags that were real rather than fixtures, which is the worse case of the
two: one on compute_coverage and one on the gesture extractor, both on the traceability tool. A
coverage calculation and a documentation generator are not diagnostics under any reading, so both
tags were removed. It is the case CONTRIBUTING.md warns about in its own words:
a tag proves a tag exists. The requirement has since been built where it says: the rotating,
size-capped log and its redaction in platform/dr-plat/src/diagnostics.rs (2026-08-30), and the
bundle in diagnostics/bundle.rs (2026-09-12) — the log, the crash records, the version, the schema
and the GPU as one text file, shown in full in Settings before a second press writes it, and sent
nowhere by either press.
R2 — Efficient display of huge RAW libraries. Its acceptance criterion contains "(figure TBD)" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated prefetch margin and cache-hit rate. No figure is stated anywhere in the tree, neither quantity is measured, and TD-2 and TD-3 both describe the thumbnail path falling short of it in ways that were measured. R2 was deliberately left untagged on this branch for that reason: the machinery is substantial and the criterion is unmet and partly undefined.
Both belong with §8 above. A requirement whose threshold was never chosen and a target nothing measures fail in the same way — not by being wrong, but by being unfalsifiable.
10. Spikes
§9 defines fourteen validation spikes and says of three of them: "S1, S2 and S10 are the three that can invalidate the architecture."
Only S1 (Slint + wgpu zero-copy on Linux) and S14 (the face pipeline on a real library) have recorded results. S14's are the best evidence of any spike — a dedicated document, a measured pass over an 18,143-face library, a named device and a reproducible command — though D13's licensing half remains open.
S6, S9, S10, S11 and S13 show no evidence of having run at all. Each is referenced only from the requirement text that asks for it:
| Spike | Would settle | Blocked on |
|---|---|---|
| S6 | FR-DSP-2, NFR-RES-2 — tiling and images larger than GPU memory | Nothing; needs a device and a large image |
| S9 | R1's tolerance threshold, and therefore R1 | Nothing; the threshold is defined by running it |
| S10 | Whether SAF at 10k files meets NFR-P1/P3 | §5 — SAF reaches album folders only, never a library to enumerate |
| S11 | NFR-COMPAT-2, and whether Play makes SAF binding | Nothing |
| S13 | NFR-A11Y-2 on Android | §6 — there is almost nothing to test with |
S2, S3, S4, S5, S7, S8 and S12 are also unrun, several with acknowledgements in the code that say
so (dr-sync/src/upload.rs on S8, dr-sync-nextcloud/src/lib.rs on S3). S2 is one of the three
architecture-invalidating spikes and needs Adreno and Mali hardware, which the manifest notes no
emulator represents.
The pattern is worth stating rather than leaving to be inferred: the spikes that ran are the ones whose subject was being built anyway. The ones that did not are the ones that would have said whether something should be built — which is the opposite of the order §9 asks for.
11. Merging — the panorama built, three clauses open
§3.11 was written on 2026-09-19 under D18, undeferring the panorama from §7 and leaving HDR merge and focus stacking there with their data model decided. Its clauses entered the register with no code behind them, which is why the coverage figure fell from 83.0% to 77.2% on that day — and the panorama was then built in the same week: alignment, projections, the chunked composite written as a DNG beside its sources, the auto-crop and the model's border fill. In 0.19.0 a frame that cannot be placed no longer ends the job: it is named on its row and the merge waits for it to be unticked, and any frame can be left out that way without reading the rest again (panorama.md §15). panorama.md §11 and §13 are where it stands.
Three clauses carry no tag:
- FR-MRG-9 — the tablet. Android runs the same code, but the stated ceiling on frame count and source resolution, refused with a message rather than an out-of-memory kill, is not set.
- NFR-MRG-1 — merge latency. Measured on the desktop and inside its 60 s (panorama.md §11); the tablet's figure is S15.4's open half, and nothing asserts either per commit.
- NFR-MRG-2 — reproducible merges. Nothing checks that the same sources and settings give a byte-identical composite.
12. D12, which governed all of the above
Decided 2026-09-19, by events. The scope stands as calibrated, v1 has no date, and
(post-v1)in §7 is the one way a clause leaves the count — used for the plugin API and nothing else. The argument below is kept because it is what the decision weighed; its prediction about tablet editing was right, and the cluster was built anyway.
Decision D12 — scope versus pace said, while it was open:
The calibration selected an ambitious feature set — full tablet editing, full ingest, culling as a differentiator, complete GPU masking, AI denoise, Fuji-first colour, deep sync, sidecar durability — against a stated pace of evenings and weekends, indefinitely.
Those are not compatible as stated.
Sections 1 through 10 are what that incompatibility looks like eleven versions later, and they land almost exactly where D12 predicted: tablet editing carries SAF at unproven scale, background execution limits and two GPU vendors to validate (§5), and every one of those is unbuilt or unrun. The parts that were built — the develop pipeline, sync, faces, the catalog — are the parts that did not need a decision first.
D12 was not resolved by choosing to work faster, and in the end not by moving clusters into §7 either, except the one: plugins. Every other cluster above stays in scope, and each still says what it would take to build. That is the list. D3 is delivered, and architecture.md §11's build order is what followed.