Skip to contents

rfp 0.75.0

  • A map theme recorded which groups it checks by PATH, so renaming a group unchecked it in every theme at once (#329). The reference dangled, the group read as unchecked, and every layer inside it was present, listed in the theme, visible="1" and drew nothing (#216). No path is stored any more: group state is derived when a theme is applied, from the ancestors of the layers it makes visible, plus inst/lookups/rfp_qgs_theme_keep.csv - group names, for the slots derivation cannot reach. 322 rows, every one of them template-scoped, became 57, of which 41 are portable.
  • This closes #221 as well, which proposed the weaker half - make group_path root-relative, 28 paths to 17. Root-relative survives renaming the root group and nothing else; a rename of any other segment, a move or a delete still strands it. Storing no path subsumes it, and takes the per-template duplication with it: a theme is now one definition rather than one per template.
  • Deriving is safe rather than merely smaller, and that is measured. Per preset, the derived checked-group set contains nothing the stored one lacks - 0 across both templates’ 9 presets and across hornby_2026, sern_skeena_2023 and sern_thompson_2026 - so it can never reveal a layer a theme meant to hide. On a project already broken it differs by exactly the burial, which is why deriving is also the repair.
  • rfp_qgs_theme_unbury() is that repair, and it is cause-agnostic - it asks what each theme shows and makes those layers reachable, without being told whether a group was renamed, moved, deleted or hand-edited. Nothing is ever unchecked, so it cannot reveal a layer a theme conceals. Measured over five field projects it takes every one to 0 buried entries and 0 dangling references, and ticks nothing at all on the healthy one. Re-applying the registry is not a general substitute and the first draft of this note said it was: rfp_qgs_theme_add(force = TRUE) rewrites only the themes the registry carries, which took sern_fraser_2024 from 29 buried to 23 and left restoration_wedzin_kwa at 22 - those two hold 16 and 15 presets against the registry’s 8 (across the seven local projects the range is 4 to 16).
  • rfp_qgs_rename() reconciles rather than repoints. It drops group references that resolve nowhere, ticks the ancestors of every visible layer any preset now buries, and refuses to write if the operation buried anything - differentially, and before the backup as well as the write, because real projects arrive buried from unrelated causes and an absolute check would refuse to operate on them. Its roxygen stated the invariant it missed: the layer ID anchor keeps a layer’s three name locations in sync, and a group has a fourth reference the id cannot reach.
  • rfp_project_audit() read no theme state at all, which is why none of this was visible without writing a probe. It now reports buried theme entries and dangling group references, repairing nothing. Measured on the fleet: sern_thompson_2026 0 buried, sern_fraser_2024 29, nelson_20260827 34 - the last being #217’s shipped stub, which this must not be credited with fixing.
  • The shipped templates are the control, which makes the acceptance test absolute rather than differential: both carry 0 dangling references and 0 buried entries, and both carry a comma group and a group/layer name collision, so one rfp_qgs_rename() exercises both rename paths. Measured at the merge-base: a rename left bcrestoration_mobile with 20 dangling references and 31 buried entries, and bcfishpass_mobile with 16 and 25. Now 0 and 0 on both. data-raw/qgs/themes_extract.R rebuilds all nine presets from the layer rows plus the keep rows and stops if any checked or expanded group set differs, so “lossless” is asserted rather than claimed.

rfp 0.74.0

  • Nothing in rfp wrote a layer subset, so every filtered layer in the fleet was hand-made and unreproducible (#318). rfp_qgs_vector_add() gains subset =, and rfp_qgs_subset_get() / rfp_qgs_subset_set() read and change one on a layer already in a project. Measured across five local field projects and both shipped templates: 62 subsets, on layers that look entirely ordinary in every list. Not #211 and not a substitute for it - a named style changes the renderer, a subset changes the feature set, and a style cannot make 8,776 of 9,432 observations absent.
  • The issue’s acceptance criterion 1 could not hold, and asserting it would have refused correct projects. <datasource> and layer-tree-layer/@source already differ in 48 of 54 bcfishpass_mobile layers and 53 of 59 in bcrestoration_mobile - the tree carries the authoring machine’s absolute path where the maplayer has a project-relative one. Byte identity fails for a stronger reason too: an attribute must escape " and a newline where element text need not, and 55 of 58 fleet subsets contain a ", so the two serialised surfaces can never match. The invariant is scoped per writer - an add asserts full value identity (what QGIS itself writes: 0 of 140, 276 and 169 layers differ in the three QGIS-saved projects), while the setter and the audit compare the subset= option alone, matched by layer id.
  • A restyle silently dropped every datasource option, and that was live data loss. sern_fraser_2024 carries four form layers whose form_*.gpkg datasource holds a |subset=. Proven against the merge-base with nothing from this branch present: a form deployed with |geometrytype=Point|subset="site_id" IS NOT NULL comes back from rfp_qgs_form_add(restyle = TRUE) bare. rfp_qgs_raster_add(restyle = ) is deliberately not extended - a raster has no provider subset, and carrying options onto one would make R/rfp_project_subset.R:299’s $-anchored regex reachable. Relates to #317.
  • rfp_qgs_form_add() and rfp_qgs_tracking_add() wrote a bare ./<rel> layer-tree source over maplayers whose datasource is ./<rel>|layername=<table>, so rfp-added vector, form and tracking layers each disagreed with themselves on the layername= half from the day the writer shipped. All three now carry the maplayer’s own datasource - what QGIS writes on its next save. A side effect worth knowing, and narrower than this entry first claimed: .qgs_point_datasource() previously read the bare tree entry as unmanaged and skipped it, so rfp_project_create() and rfp_project_split() now repoint more references than before - but only rfp_qgs_vector_add()’s. .qgs_is_managed() claims a path only under background_layers.gpkg or background_layers/, and a form’s form_<type>.gpkg and the tracking layer’s tracking_layer.gpkg both sit at the project root, so those two were unmanaged before this change and are unmanaged after it.
  • A double-quoted subset literal is refused, decided against the layer’s real columns. A blanket refusal of " would be wrong - restoration_wedzin_kwa carries "array_to_string" LIKE '%MEINTS%', a correct identifier reference - and the defect is in two projects, not the one the issue names: sern_fraser_2024 writes = "Yes" on two layers beside sern_peace_fwcp_2023’s IN ("KO") and >= "3". An unreadable GeoPackage warns rather than refuses (#108/#201). Its reach is narrower than “the error the fleet makes” and the docs say so: it is blind to cleaned == TRUE and to "stream_order" >= "3", where the token resolves.
  • An embedded datasource option tag is refused - and QGIS is not why. The plan review argued QGIS’s unanchored option regexes would delete a |layername= out of the middle of a subset; asked of qgis/qgis:4.2, they do not, and the negative result is pinned. What breaks is rfp’s own greedy sub("^.*\|layername="), which reads the LAST occurrence: the adder’s no-op check would then miss the layer and write a duplicate, and a form restyle would fail to find it.
  • .qgs_layer_id() digests name and relative path, not the table, so two layers with one name over different tables of a monolith GeoPackage derive one id - invisible to .qgs_dangling_refs(), because the id resolves. rfp_qgs_vector_add() now refuses it, as rfp_qgs_raster_add() already did for its companion.
  • rfp_project_audit() gains a subset section: the filters a project carries, whether its two datasource surfaces agree about each, and whether the quoting is suspect. It reports rather than refuses - a survey that refused what the fleet contains could not survey it.
  • The adder reports how many features pass the filter, as SQLite’s answer. OGR has its own dialect handling, so the two engines need not agree, and the report can neither error nor warn.
  • Breaking, mildly: subset sits between geometry and group in rfp_qgs_vector_add(), so a caller passing group positionally now supplies it as subset. That fails loudly on the required-group error rather than silently. No in-repo caller passes seven arguments positionally.

rfp 0.73.0

  • rfp’s style path ran one way, so restyling a declared layer meant hand-editing XML (#317). rfp_qgs_style_export() lifts a layer’s style out to a .qml and is documented read-only on the project; rfp_styles_apply() writes layer_styles rows into the data file. Nothing put an edited .qml back. rfp_qgs_style_set(qgs, layer, qml) does, editing the style block in place - the <id> and <layername> are read from the node and never derived, the node is never rebuilt, and <datasource> is not touched at all. So a |subset= survives by construction rather than by being carried, and #318 - which the issue body said had closed that question - is still open and not needed.
  • The alternative loses curated state, which is why this is not cosmetic. Measured through remove + re-add on one layer, theme entries went 1 -> 0 -> 5: layerorder and custom-order came back because .qgs_layer_id() reproduced the id, and membership did not, because it is enumerated per theme (#304) and a re-add joins all of them. The issue’s stronger claim that a re-add always changes the id is wrong as stated - across bcrestoration_mobile’s 59 maplayers rfp reproduces 1, Trails, the one it added by script - and right where it matters, since a person restyles the QGIS-authored layers.
  • Which of a .qml’s ~35 elements move is one table doing two jobs. The default apply set is the QML’s style children minus every tag whose role is bound; the check set is the tags being applied intersected with the tags carrying an xpath. There is no second list, so a schema-bound tag cannot be added to an exclusion list with its check forgotten. The criterion for bound is #191’s, not a new one - a surface is schema-bound iff a stale entry in it has a consequence - so of the five checked bound surfaces four are exactly .rfp_form_styled_fields()’s, asserted to agree on the fields and not merely the names (the fifth is previewExpression, which #201 calls data rather than symbology), while editable/labelOnTop/reuseLastValue are applied and never checked (form_pscis ships 164 flags for a 101-column table).
  • labeling is schema-bound and the issue did not say so. labeling//text-style/@fieldName is 24 and 25 per template, and isExpression is present on every shipped text-style, so the two arms are keyed rather than guessed. A label on an absent column draws nothing, silently - the same class as a categorized renderer’s @attr painting every feature in the fallback class, which is checked too, along with renderer-v2’s second surface .//rule/@filter (20 per template).
  • Three surfaces that look schema-bound are not, for two different reasons. conditionalstyles//fieldstyle and dataDefinedFieldProperties/* are zero occurrences, so a check is vacuous and untestable from the corpus (#227). widgets/widget/@name is one occurrence per template and in each it is the same relation name, so a check would false-refuse every layer carrying a relation widget. The occupancy assertion catches the first two and cannot catch the third - a comment claiming otherwise was written from reasoning and executed false - so widgets is pinned by name with its measurement.
  • An empty attribute is no reference. All 71 shipped diagram @field - 34 and 37 across the two templates - are the empty string, and as.character() on an xml2 attribute node returns field="", which is never empty - so the extractor reads values with xml_text().
  • A moved element naming an absent column is an error and nothing is written. Stricter than the same check while adding a layer, which warns and drops (#201): assemble() is building a node, so a dropped element still yields a complete one, while this replaces children of a live node, and a dropped renderer leaves the layer wearing its old style while the caller believes the new one landed. A layer whose columns rfp cannot read - a service, a Postgres table, a locked file - is applied unchecked with a message, because absence has to be proven (#108).
  • Two refusals the issue did not name. A style carrying source elements is a pre-#130 export that would overwrite the layer’s identity, and is refused citing it. An element rfp has never seen is held with a catchable warning rather than moved - it may name a column, and holding cannot write a dead reference. Completeness is asserted at test time over all 45 style tags the corpus carries.
  • What can be checked is a partition, and three review rounds were needed to say so. The same class turned up on three paths - a repeated tag bypassing the check, an unrecognised tag opted into elements =, then a known tag with no rule opted in - each fix closing the path in front of it. A style element reaches the maplayer by exactly two routes, and on both the check runs only for a tag carrying a rule; so every column-referencing tag is either checked (a stale entry has a consequence, #191) or inert, and there is no third case. The inert set is constraints, constraintExpressions, splitPolicies, attributetableconfig, editable, labelOnTop and reuseLastValue - not because rfp “cannot judge” them, which is what the first version said, but because checking them would refuse rfp’s own artifacts: Form PSCIS carries 78 stale entries in each flag surface, 3 in attributetableconfig, 3 in splitPolicies and 1 in constraints, and the form works. Naming an inert tag in elements = now warns; the default path stays silent, because that is the policy. Both halves of the partition are asserted, and the reverts redden - though the evidence half took two further corrections to actually do so, below.
  • The mapTip check refused valid styles, which is a fix over-reaching into a false refusal. A map tip is HTML with [% ... %] blocks in it, not an expression - read whole through .qgs_expr_fields(), every double-quoted HTML attribute became a column identifier and a perfectly good tip was refused. Measured: adding style="font-weight:bold" to the shipped tip made it error claiming the layer has no column font-weight:bold, while the same HTML single-quoted passed. The scan now reads only the [% ... %] blocks, and needs (?s) to do it - a real map tip is multi-line, and without it the shipped Form PSCIS tip yields zero blocks and checks nothing at all. customproperties was the same error one notch narrower: .//@value swept 79 values across five keys - embeddedWidgets/count, identify/format, variableNames and two WMS flags - where only dualview/previewExpressions is an expression. Both narrowings are lossless on the corpus, 16 and 50 references either way.
  • The evidence test had a THIRD proxy predicate, so round 4’s widgets finding was not closed by the commit that claimed to close it. It measured staleness as .//@field | .//@name minus the layer’s columns, which counts QGIS <Option name=> keys as column references - widgets 6, elevation 4278, geometryOptions 144 - so restoring widgets, or adding elevation or geometryOptions, still left the suite green. A candidate reference is now restricted to a value that is a column somewhere in the corpus, which discriminates exactly: all seven real members keep their evidence, all three impostors go to zero and redden.
  • The sweep asked a proxy question twice, and the control was inside the corpus. The test that proves there is no third bucket measured column references first by three attribute spellings, then by value-equals-a-column-name. previewExpression settles it: quoted references on 95 layers and zero values equal to a column name - a sweep on equality sees nothing in the surface this package was built around, and it escaped only because it was already checked. The sweep now runs .qgs_expr_fields() over every attribute value and every text node, the call the checker itself makes. mapTip and customproperties are checked as a result, both being #201’s defect on a further surface - customproperties references live only in dualview/previewExpressions, on 34 layers. Measured safe: 16 and 48 quoted refs, 0 stale. And the sweep is now guarded: narrowing it reddens.
  • widgets left the inert bucket, where it had a guard on neither axis. Both predicates return zero for it - its one value per template is a relation name - so it references no column and belongs with flags and legend. Listed as inert it had no corpus references to find and was excluded from the evidence test, so dropping it left the suite green, falsifying the claim that truncating the list reddens. The evidence test now covers every member with no exclusions; measured across both templates all seven carry stale entries - 1112/1112/504 in the flag surfaces, 252 attributetableconfig, 18 splitPolicies, 14 constraints, 12 constraintExpressions.
  • temporal was in neither bucket, and the test written to prove there is no third bucket could not see it. That test measured column references with three attribute spellings - field, name, fieldName - chosen from the tags already known to have them. Sweeping every attribute whose value is exactly a column of its own layer finds temporal’s startField and endField, which filter features by that column. They are now checked, predicated on enabled="1": all 113 shipped <temporal> blocks are disabled while four per template still name a column, so the unpredicated form would false-refuse them. Measured, 0 of 78 shipped QMLs carrying a <temporal> block are refused, and an enabled block naming an absent column is. The test now matches every attribute rather than a list of three.
  • The style-child order is a committed constant, per layer kind, because the corpus says so. Over 131 style blocks - both templates plus the QGIS-4.2 oracle in inst/testdata/nodes/ - the precedence graph has zero conflicting pairs within a kind (vector n = 109, raster n = 22) and exactly two across kinds: blendMode/mapTip and legend/mapTip. A test pins the count at two, so a future QGIS reconciling them says so rather than silently justifying a merge. The target maplayer is precisely the one missing a tag, so it cannot say where the tag goes.
  • The in-place splice was promoted out of data-raw/forms/styles_splice-form.R, carrying three fixes. Its boundary slice ran seq(i, length(names_k)), which counts down when a maplayer has no style block - seq(4, 3) is c(4, 3), so the names came back c(NA, "layername"); it never fired in data-raw because every template maplayer has a style block, and all six subset_fixture maplayers fire it. Anchors are now restricted to the style half, because expressionfields is in both blocks and 47 of bcfishpass_mobile’s 54 maplayers carry it twice, so a name lookup replaces the layer’s data binding with a style element. carry_fid() did not come along; the script applies it to the incoming document, so the helper needs no hook.
  • Re-applying a style the layer already carries writes nothing at all (#89), because the write is skipped rather than performed and compared - an xml2 read/write of an unchanged .qgs moves thousands of bytes. That test runs against a copy of a shipped template, and captures the md5 before the first call as well as after: an xml2 write is a fixed point after one generation, so an md5 taken only afterwards compares xml2 output against xml2 output and passes whether the write was skipped or performed. The mtime is what discriminates. Its first version also lifted its donor from the layer’s own renderer, so the first call was a no-op too and the test passed with neither call writing.
  • rfp_qgs_form_add(restyle = TRUE) gains backup =. It carried the full guard block and was the only maintenance-shaped writer in the package with no backup at all; the idiom is .qgs_backup() - 9 call sites before this change, 11 after - not rfp_fs_backup(), which the issue named and which has zero callers anywhere and a fully commented-out test file.
  • The equivalence oracle found a live defect it does not fix: both templates ship Form PSCIS with 5 of 99 fields as TextEdit where the generated .qml declares CheckBox and, for my_priority, a ValueMap - so a crew types free text where a controlled value was intended, in every project created from either template. Filed as #323. Not undetected, as this entry first said - test-rfp_qgs_form_add_qgis_reads.R:323 asks QGIS the same question and reports FAIL 8 at the merge-base, so the full suite has been red on main; it covers 4 of the 5, since it loops schema$widget == "checkbox" and my_priority is a ValueMap. The acceptance test here therefore asserts idempotence rather than a first-pass no-op, which is false today and would pin the staleness rather than the helper.

rfp 0.72.0

  • rfp_qgs_theme_create(add =) buried the layer it added (#316). It copied the base theme’s checked-group-nodes verbatim and appended layer rows only, so an add = naming a layer from a group the base does not check produced exactly the #216 failure the derivation exists to avoid: listed, visible="1", drawing nothing. Measured on the shipped restoration template, add = "Fire Perimeters - Current" onto Land Tenure left /Web Mapping Services unchecked against 14 checked groups; it is 15 now with nothing missing. The groups holding each added visible layer, and every ancestor of them, are derived and unioned in.
  • The base’s rows are never modified and nothing is ever unticked, so a derivation adding only within the base’s already-checked groups is unchanged. Checked, not merely present: Land Tenure carries /Other point features as expanded only, so adding FISS obstacles - which that theme already lists, off - stays inside the base’s groups and still needs one ticked. drop is deliberately not the mirror; a group stays checked when its last visible layer goes, because unticking could hide a layer another preset row still shows.
  • Only the layers add makes visible are aimed at. A layer the base already shows from an unchecked group is not sought out - rfp_qgs_theme_layer_add(check_groups = TRUE) is where repairing that belongs - though ticking is per group, so such a layer is unburied as a side effect when it shares a group with an added one.
  • The gate asks whether a carried checked row RESOLVES, not whether one exists, and the difference is not cosmetic. A base whose groups have since been renamed or removed carries rows that resolve nowhere and is on .theme_resolve_groups()’s inference branch; appending one derived path, which resolves by construction, flips it off that branch. Measured with Land Tenure’s checked paths renamed: 0 buried of 23 visible layers as shipped, 22 under a presence-only gate. .theme_carry_resolve() is the exact-then-re-root matching rule lifted out of .theme_resolve_groups() so it has one definition and two callers rather than a copy that would drift.
  • #304’s acceptance could not have caught this: the derivation it was validated against swaps one streams_* layer for another inside the same, already-checked group. That test is unchanged and still passes.

rfp 0.71.0

  • Seven PSCIS fields were configured and never drawn, including all three assessment types (#318 follow-up). Under tablayout QGIS draws only what attributeEditorForm lists, so a field with a blank group in the schema is invisible however complete its widget is - the #159 failure, in rfp’s own shipped form. assess_type_phase1, assess_type_reassessment, assess_type_phase2, moti_chris_culvert_id2, moti_chris_culvert_id3, my_priority and uav_flight_completed are now placed, matching the hand-curated form on sern_fraser_2024 @ v107 field-for-field in tab, order, widget and label.
  • The GeoPackage is byte-identical (74d9bf0e… before and after), which is the proof that this is a layout change and not a schema change - the property fpr and the provincial PSCIS submission format depend on. Placed fields go 69 -> 76 with none lost.
  • Widgets match the field-proven form rather than the schema’s text default: the three assessment types and uav_flight_completed become checkbox storing Yes/No, and my_priority a valuemap of high|mod|low. Asked of qgis/qgis:4.2 rather than of rfp’s own reader.
  • The schema CSV was edited at byte level, seven lines, not rewritten. readLines() strips the two \r characters inside embankment_fill_issues’s multi-line cell and writeLines() does not restore them - a whole-file rewrite silently altered that cell on the first attempt.

rfp 0.70.0

  • The #216 buried-layer guard asked the wrong question and so could not fire (#313). rfp_qgs_theme_layer_add() tested !any(paths %in% checked) — “is the WHOLE ancestor chain off” — where a layer draws only when EVERY ancestor is checked. The root container is checked in every shipped preset (5 of 5 on bcrestoration_mobile, 4 of 4 on bcfishpass_mobile), which satisfies any() for every layer in the tree, so a layer whose immediate group was unchecked drew nothing and was reported fine. Fire Perimeters - Current in Land Tenure is the live case: its root is checked, /Web Mapping Services is not, and no shipped preset checks that group. Tightened to !all(...), which costs no false alarms — over both templates’ 112 and 90 visible entries all() flags 0, the same as any(), because the templates themselves are correct.
  • check_groups = TRUE on rfp_qgs_theme_layer_add() now ticks the group it used to only name. The warning told callers to “check the group there, or rebuild the theme with rfp_qgs_theme_set()” and gave them no supported way to do the first — the second replaces a preset wholesale, dropping whatever the caller’s spec lacks. Every piece was already in hand: .qgs_layer_group_paths() returns the group path and every ancestor. Default FALSE, so no existing caller changes behaviour. It acts only on a preset carrying has-checked-group-info="1" (an absent assertion means “not recorded”, not “nothing checked”), ticks ancestors rather than just the containing group, and never unticks.
  • A ticked group counts as a change. Repairing a layer that is already listed but buried resolves to action = "unchanged", so without this the early no-op return discarded the group node just written — a silent no-op on the one path the feature exists for. Proven by reverting the term: the repair reports success and ticks nothing.
  • want was overwritten inside the loop, so the summary reported “in 1 theme” however many were touched. want holds the resolved theme names; want <- if (visible[[i]]) "1" else "0" shadowed it. Conditional on the already-present branch running, which is why a pure-add call reported correctly and it survived. The returned data frame was always right — read nrow(res) / unique(res$theme), never the message.
  • check_groups is refused on the removal path rather than silently ignored.

rfp 0.69.0

  • Theme membership is enumerated per layer per theme, so every operation on it was lossy (#304). A Mergin theme hides a layer by ABSENCE, so membership and visibility are one axis: adding a layer to N themes is N edits, a created theme starts empty and therefore shows nothing, a port drops whatever the incoming definition lacks, and removing a layer thins every theme naming it. All four now have an operation and a report; the model change ships opt-in.
  • .qgs_theme_join() wrote a <layer> node QGIS never writes, and it is the sole path by which all five layer-adding writers join a theme: no expanded, style="" rather than style="default", no paired <expanded-legend-nodes> sibling, appended after the group-node holders. The oracle is the shipped templates, which are QGIS-authored - 103/103 and 129/129, 232 of 232 entries carry expanded,id,style,visible with style="default", each paired with a legend holder, layer ids and holder ids setequal in all 9 presets. Across five field projects 304 of 1571 theme entries carry the divergent shape, 295 of them in nelson_20260827. Repairing existing projects is #310; this changes what a project built from here gets.
  • That looked like the explanation for rtj#244 and it is not, which is worth more than the hypothesis was. nelson_20260827 is the one project both contradictory device observations about visible="0" came from, and NewGraphEnvironment/rtj#287 closed naming “a missing sibling attribute (expanded, style)” as one of three untested candidates for the airphoto anomaly. The shape matched exactly. Asked of QGIS 4.2.1 rather than reasoned about: the old shape is honoured - a record is created and isVisible is read from the attribute. The negative result is pinned as a test rather than dropped. Bounded: a container answers a desktop parse, not a mobile render, so the candidate survives for mobile alone, and nelson still carries 10 driver-written rasters at visible="0" in each Airphoto theme as the live discriminating case. What the divergence does cost is measured in the same probe - currentStyle empty with usingCurrentStyle still TRUE, a theme asking for a style that names nothing (#211).
  • rfp_qgs_theme_layer_add() / rfp_qgs_theme_layer_rm() put a layer the project already holds into themes, or take it out while leaving the layer. Surgical - one entry and its legend sibling - idempotent, since a preset is a list and a repeated id is the layer listed twice, and visible = FALSE writes present-and-off, which is what #162 needs and nothing exposed. They also report #216 at the moment it is created: a visible layer inside a group the preset leaves unchecked draws nothing, and reads as “the theme is broken” rather than “a group is off”.
  • rfp_qgs_theme_create(base =, add =, drop =) derives a theme from another and carries the base’s group rows, legend state and preset flags. Derivation is how the shipped themes were actually authored, and the test is the proof rather than the argument: deriving Low Detail - Bull Trout Model from Low Detail - Salmon Model by swapping one streams_* layer reproduces the template’s own theme exactly - same members, same visibility, same 27 group rows. Closes the create half of #203.
  • .theme_warn_replace() gained a layer arm. It had a group arm and a legend arm and no layer arm, which is why #304 could say a port drops members silently - the other two halves were loud. A set difference on names, because a port crosses projects and ids do not survive that (#174); an unresolvable id is reported as itself, since a dangling reference is still a member going away. Both new arguments default NULL, so the arm is inert for a caller that cannot supply them - no opinion is not “no members” (#201).
  • A removal now says what each theme lost. .qgs_theme_thinning() counts before the sweep, because afterwards there is nothing left to count, and is wired into rfp_qgs_layer_rm(), rfp_qgs_service_rm() and .qgs_trim_absent() - the three routes NewGraphEnvironment/rtj#316’s themes_losing_layers() enumerates downstream. The trim route is the one that surprised it: a layer never downloaded is trimmed DURING create, so the post-create project is already short. Silent when no theme named the layer, or it becomes the noise #178 warns against.
  • membership = "universal" on rfp_qgs_theme_set() and rfp_qgs_theme_add() lists every layer the destination holds, off for those the theme does not name, so absence carries no information and visible carries all of it. Measured on bcrestoration: 28/26/25/25/25 declared members become 59/59/59/59/59 with 27/22/21/21/21 visible, and no declared member’s visibility moves. declared remains the default, for three measured reasons: rfp_project_subset(themes = ) derives its kept set from a preset’s entries with no visible filter, so a universal theme selects the whole project - wedzin_kwa_floodplain’s own pipeline, failing silently; themes_extract.R would scope every shipped theme per template, taking rfp_qgs_themes.csv from 195 rows to over 450; and a preset roughly doubles, 1571 fleet entries becoming 8639, up to 7.7x on the largest project, with five remote basemaps written into every preset of every project. rfp_project_subset() now reports the first of those rather than no-opping silently.
  • Synthesized universal rows carry an id and are resolved by it, which keeps names_used at the spec’s own names so this does not widen the duplicate-name refusal #293 scoped - widening turns a 276-layer project with 7 duplicated names from working into a hard refusal. It is also simply correct: name_to_id maps a duplicated name to one id, so name resolution writes 59 entries with 58 distinct, one layer listed twice and one missing from every preset. The first proof of that did not fire, because it asserted on rfp_qgs_themes(), which resolves id to name and reports 59 rows either way - assert on the XML.
  • Two of the issue’s own premises were wrong. The visible question is settled, not open: NewGraphEnvironment/rtj#287 closed COMPLETED on 2026-09-05 with mobile honouring visible="0". And membership by tree group - its leading proposal - is refuted by the shipped data: Streams/Habitat models holds exactly streams_salmon, streams_st and streams_bt, and the three Low Detail themes take one each, so a group rule puts all three in every model theme and destroys the only thing distinguishing them. A base+delta encoding does fit (182 rows to 61, with 5 visibility overrides in total) and buys nothing, because that file is derived and nobody hand-edits it.

rfp 0.68.0

  • Both templates named a table the bucket stopped serving, so the fish-observations layer resolved to nothing (#305). db_newgraph renamed the staged object; measured against newgraph.s3.us-west-2.amazonaws.com, bcfishobs.observations.fgb.zip is 200 and bcfishobs.fiss_fish_obsrvtn_events_vw.fgb.zip is 404. Absent on a device is indistinguishable from a layer that is simply empty in the AOI, which is what made this quiet.
  • source_layer is three things at once, so a rename is never local: the S3 object stem (s3_url()), the GeoPackage table name (ogr2ogr -nln), and the .qgs layername=. It cannot be aliased through rfp_source_aws.csv (#268) - s3_url() composes the stem from the layer name itself.
  • A datasource is written in TWO places per layer and only one is obvious. The <maplayer><datasource> element and the layer-tree-layer source= attribute are independent strings, so a rename applied to one leaves the project half-repointed with nothing to see in the diff. The issue had to say “two occurrences per template, not one” in prose; test-rfp_templates_datasource.R now asserts it, matched on the layer id - the only key the two halves share, since the tree’s path is absolute from the authoring machine and the maplayer’s is project-relative. Absolute rather than differential: 47 of 54 layers in bcfishpass and 52 of 59 in bcrestoration carry a layername= in both places, and 0 disagree. Proven to fire by reverting either half.
  • Both template edits are bounded text, not a restamp - nothing is re-serialised. Verified the strong way rather than by a line count: with the layer and its tree entry removed from both the old and new file, the remainders are byte-identical, and across all 54 and 59 layers x 10 element surfaces exactly 6 cells moved, all on this layer. The whole change reads 3 63 per template (the rename alone was 2 2). An xml2 round trip instead moves +2976 and +3120 bytes; #146’s +2826 was that measurement on an earlier state of the templates. The layer id is QGIS-minted and does not embed the table name and the display name is unchanged, so the 7 theme rows referencing this layer need no edit. roundtrip_check.R still rebuilds both templates identically.
  • It was a schema change riding in on a rename, which the approved plan got wrong. The plan reasoned that the layer id and display name were unchanged so nothing else needed editing - true of the id-keyed surfaces, false of the column-keyed ones. Measured against the object the layer now names (373,050 features, 34 fields): 5 of 24 entries in each of fieldConfiguration, aliases, defaults, constraints, constraintExpressions and splitPolicies named columns that no longer exist, and previewExpression was "fish_obsrvtn_pnt_distinct_id", absent. Both fail silently (#159, #201) - a crew would have read bcfishobs.observations.fid--<uuid> on tap. The five are dropped rather than remapped, because three of them have two candidate successors each and a widget bound to the wrong column is silent and confident; the preview now reads "species_name". #307 carries the remapping and the 15 columns with no widget.
  • The style lives in two places and both had to move. styles_export-template.R derives the store from the template (#174/#191), so editing only the template made roundtrip_check.R rebuild the old fields back in - it reported 58 of 59 canonically identical, naming exactly those six surfaces. Re-derived, it is 59 of 59 again.
  • gq (>= 0.14.0) is now declared in Suggests, and the reason is sharper than “keep them in step”. .qgs_trim_absent() drops a layer whose target is both absent and not requested - and expected is what gq asked for. On gq 0.10.0 the template’s new name is unexpected, so the layer is deleted from the project where before the rename it was kept; rfp_project_create() refuses to run into an existing directory, so no refresh recovers it. A Suggests floor is not enforced at runtime, which is why it is said here too.
  • The issue’s file table named two of the four surfaces. Also updated: inst/extdata/rfp_xref_layers_custom.csv, whose tracked data/rfp_xref_layers_custom.rda is generated from it - so editing the CSV alone would ship a stale binary - and data-raw/qgs/roster/template_layers.csv, re-derived rather than edited. inst/lookups/rfp_source_aws.txt gains the layer back under the new name: it is the gq-absent fallback, and both templates declare the layer, so without it that path built a project whose .qgs named a table the download never created.
  • Existing field projects are NOT migrated by this. They carry the old table name in their own .qgs and manifest and keep doing so; this changes what a project built from here gets. Migrating one is a separate operation against a live, synced artifact.
  • The issue’s premise was false when it was filed and true when the work started. It said gq had adopted the new name and only the templates remained. Measured 2026-09-08 at ~13:0x, gq#82 was open, gq_template_layers() still returned the old name, and the download was broken too - so renaming the templates alone would have fixed nothing. gq#82 closed at 13:17, mid-planning. A measurement carries the time it was taken, and this one expired inside one session.
  • The issue’s “Downstream” note is wrong and should not be chased: gq’s “delete this block” correction is about ACCESS labels and bcfishpass#13, unrelated to this rename, and fires only if that lands and the templates are regenerated - which a string replace does not do.

rfp 0.67.0

  • Floodplain and Floodplain - Habitat Lateral are declarable themes, derived from seven field projects that disagree (#302). The two theme lookups are different things and the gap between them was the issue: rfp_qgs_theme_registry.csv is a hand-written catalogue read by no code, rfp_qgs_themes.csv is the buildable definition rfp_qgs_theme_add() consumes. Floodplain was status = standard in the first and absent from the second, so the only route was porting by hand from a sibling project - done twice on 2026-09-07. The registry goes from 6 themes to 8, and both new ones carry group rows for both templates.
  • The issue said the layer lists exist; the layer list does not. Seven projects carry it - the catalogue said five - and no two definitions are identical even restricted to layers both templates have: 20/21/20/24/20/25/21 layers with 15/18/14/15/11/16/21 visible. So a definition had to be decided, which is the whole difference from Imagery, whose one source project makes its guard equality. sern_peace_fwcp_2023 is excluded from the visibility vote and the exclusion is asserted, not assumed: all 21 of its shared-template layers are visible, which is #217’s stub with the sign flipped. Over the remaining six, 20 of the 28 carried layers are unanimous (17 on, 3 off) and 8 split - every one linear infrastructure or a label, along lineage. Al chose ON, with Stream labels (on in 1 of 6) and streams_st (1 of 2) flagged as thin before the call, and ESRI World Topo on, making this the one shipped theme row that overrides #162 rather than choosing between projects.
  • Every registry theme reaching a project of the other template rendered NOTHING, and had since the registry shipped. .theme_registry_spec() declared group_info = TRUE for every theme; .theme_resolve_groups() reads that as “declared its group state, and declared it empty”, so a theme whose group rows are scoped elsewhere wrote a preset asserting zero checked groups - which under #216 unchecks every group and hides every layer. Measured on a bcfishpass_mobile copy: Imagery 11 layers with 8 visible and Land Tenure 25 with 21, both over 0 checked groups. Healthy counts, nothing drawn. Now 7 and 9. Asked of QGIS 4.2.1 in the pinned container, not just of rfp’s own reader.
  • The inference branch already existed, and so did the documentation. rfp_qgs_theme_add()’s roxygen has said “the theme still lands, with its group state inferred from where its layers sit” since #178; .theme_resolve_groups() implemented it and .theme_registry_spec() defeated it one call up. #121 named the failure in advance - “a theme that recorded no group state comes back asserting a full one”. The flag to fix is group_info, not group_attr: dropping the attribute makes QGIS discard the state instead, measured in the container as checkedGroupNodes = 0 despite three nodes in the XML.
  • A guard on “is this collection empty” when the property is about one KIND of member in it - three rounds, three layers. The second route to the same preset is a carry whose paths all fail to resolve; guarding !length(keep) was wrong because only the checked rows decide rendering, and 7 of the 14 shipped theme/template combinations carry an expanded-only path. Fixing the predicate then left the remedy container-shaped, silently deleting expanded paths that had resolved. Round 1 found the first, round 2 found the second inside round 1’s fix.
  • The #216 invariant guard found the shipped group rows short before they shipped. The bcfishpass rows are carried from sern_skeena_2023, which has roads and labels off, so its group set never checked the groups holding them - and with the split set decided ON, every bcfishpass project would have got roads and stream labels present, visible and buried. Two rows added deliberately. The check runs against the templates, so it needs no field project.
  • The catalogue is derived now (data-raw/qgs/themes_refresh-registry.R) and gains a buildable column read from the buildable lookup, so a row calling a theme standard that no caller can build is a red test rather than an invisible contradiction. On the first run: 6 rows understated their project count, 5 fleet themes were missing, and 1 named a theme no project has any more. duplicate_of was mutual in both shipped pairs, and in both the buildable name was among those flagged as the duplicate; it is directional now. The file is written in Python csv.writer’s shape - CRLF, minimal quoting - proven byte-identical on a round trip of the unchanged file before being trusted.
  • themes_extract-floodplain.R is sourced by themes_extract.R, never run. That script writes both CSVs with utils::write.csv(), which replaces them wholesale, so a second script appending to the same files would have its rows deleted by the next run of the first - silently. One writer.
  • Seven false numeric claims in this branch’s own prose were caught by measuring rather than reading, six of them by review: the template-extracted count (five, not six), the both-templates claim (six themes, not two), the bcfishpass-rooted project count (two, not one), the excluded-names count (30 of 58, not 29 of 57), the unanimity split (17 on, not 18), the meta-flag assertion’s subject, and an expanded-only-path count restated from a review’s examples where the measured figure is 7 of 14.

rfp 0.66.0

  • A schema-backed form for inventorying and monitoring restoration sites, derived from a form with a field record rather than designed fresh (#299). monitoring_general - 50 fields, label General Monitoring, #A6CEE3 - is a companion to monitoring_fish_passage, not a replacement: one is effectiveness monitoring of a specific crossing remediation, the other is site inventory and condition tracking, and a project is meant to carry both. The fleet had three monitoring forms and no two projects agreed: monitoring is registered with no schema_source, so rfp_form_build() refuses it and the 124-column copy on one project cannot be repaired against the 126-column copies on two others, while monitoring_ree - 15 records across 2024/2025, feeding a report appendix - was registered nowhere at all. Registering makes the form available; rfp_project_create() still defaults forms to the two template forms, so nothing deploys by itself.
  • No rating scale, and the measurement is the argument. The source form carried ten integer ratings beside their _notes. Rows carrying a rating with no note: 0 of 9 in 2025, and 1 of 6 in 2024 (two occurrences on that one row). The ratings were used - erosion is populated 4/9 and 4/6 - so this is a deliberate move to prose that costs the only rankable columns the form had, and it is safe because the crew wrote the description every time. They were also 1-4 by intent and 1-5 in the data, 2024 carrying a 5 in four of the ten columns: the same drift research/monitoring_effectiveness_ratings.md already recorded for the fish-passage form, so a second independent instance of its central finding rather than a complication of it. That note now covers both forms.
  • The acceptance criterion was replaced by a stronger one. #299 asked for “no integer fields in the built form” as shorthand for “no ratings”. time_epoch is an integer for an unrelated reason, so the shorthand was a proxy rather than the property: the guard now names the ten rating columns directly and asserts every rating’s _notes survived, with a second assertion pinning time_epoch as the only MEDIUMINT. The round-trip check is in-repo - the built column set against the 47 names carried from the field form plus the 3 added, pinned with a source-and-date provenance comment because that GeoPackage is a field project and cannot ship - and is proven reachable against a misspelling, a dropped field, a restored rating, and a name in both groups.
  • Four fields were added to the filed set of 46, each measured. photo_upstream and photo_downstream were filed as “crossing photos” and dropped; only inlet/outlet/access are, these two name a reach, monitoring_fish_passage carries both, and they are the two most-populated photo columns in the source (15/15 and 14/15 across both seasons). mergin_user - 9 of the 11 schemas shipping before this one carried it. time_epoch - 3 of them did, where #299 said two. The result is 50, matching an independent estimate made in NewGraphEnvironment/rtj#285 before the issue was written. Three free-text yes/no columns also became valuemap Yes|No with a blank default: measured over one crew and two seasons, new_site and works_completed hold yes while uav_flight holds Yes, and a Yes default was rejected because 15/15 is a sampling artifact and blank has to keep meaning unrecorded.
  • Three of the issue’s own numbers were wrong and the body is reconciled. 20 fields are dropped, not 21; the source carries 15 distinct records rather than 25, since the 25 summed three files while all 9 real deployed rows match the 2025 cleaned copy by geometry; and the stale monitoring is 124 vs 126, with the project that looked behind being the one that still has utm_zone/easting/northing.
  • monitoring_general is the fourth form fpr::fpr_photo_rename() can run against and the third with default arguments (#251) - the first movement in that population since observation replaced trail_feature. It carries all five photo_extraN slots, all five _tag columns and site_id.
  • The vignette’s “Available forms” table is rendered from the registry. It was hand-written and had listed 6 of 16 forms for several releases; a hand-maintained copy of something the package can print is the drift this repo already names for the function table.
  • A historical measurement and a present-tense justification look identical and must be treated oppositely, which cost a review round. Re-pointing .rfp_form_option_pairs()’s “measured before changing this: across the 101 valuemap cells in the eight shipped schemas, none contains = at today’s population made it false - 12 of today’s 125 cells contain one, precisely because the schemas that arrived since are the ones using the feature it introduced. CLAUDE.md carried the same sentence correctly all along: one fact written twice, and the sweep moved the wrong copy.

rfp 0.65.0

  • A theme is refused for a name it resolves, not for one the project happens to carry twice (#293). rfp_qgs_theme_set() checked layer-name uniqueness over every <maplayer> in the destination, so a project carrying a duplicate anywhere could never gain a theme - including one naming only unambiguous layers. .qgs_theme_layer_map() now takes a required names_used and refuses only on intersect(dup, names_used); rfp_qgs_theme_set() passes spec$layers$layer and rfp_qgs_themes() passes NULL. Measured on a live 168-layer project with 12 duplicated names, none referenced: 0 themes before, 4 after.
  • The old guard had zero correct callers, which is what makes it a scope bug rather than a threshold to loosen. The read path resolves id -> name and never needed it; the write path resolves a known subset. Scope is now an argument with no default, so a third caller has to answer which names do I resolve? rather than inherit an answer - and it is force()d, because R has no compile-time arity check and the structural checks below would otherwise raise first and give the wrong diagnosis.
  • The refusal that remains is correct, and it does not clear every project. A project whose duplicated name is one every shipped theme references - ESRI World Topo - is still refused, because the ambiguity is real. Two of three local projects with duplicates are in that position. Whether such a layer should instead be dropped and reported like a missing one is #296.
  • rfp_qgs_rename() was the remedy four messages named, and it fixes none of them. It renames every copy of a duplicated name, so the name you asked for survives on none: rfp_project_subset()’s “Duplicate layer names in source” becomes “Layer(s) not found in source”, rfp_qgs_preview_set()’s “2 layers are named ‘X’” becomes “No layer named ‘X’”, and rfp_qgs_theme_set() writes four themes without the basemap they name while reporting them added - a bigger number that reads as success. Rename all but one copy instead. .rfp_dup_name_remedy() is now the one place that sentence is written, with a test asserting each caller reaches it.
  • The spec-side refusal is deliberately different. Two rows of the caller’s own themes frame disagreeing about one destination layer is not an ambiguity in the project: renaming the destination changes nothing, renaming the source writes the layer out of every theme, and dropping the conflicting row writes cleanly. Its message now says so.

rfp 0.64.0

  • The runtime now asks for the QGIS rfp pins, not whatever :latest was last re-pushed (#282). rfp_map_render() and rfp_styles_apply(registry = ) take image = NULL and resolve to .rfp_qgs_image[["digest"]] — the same image the test suite and the reference-authoring scripts run, so #227’s “which QGIS answered?” now has one answer for a render as well as for a test. Pass a reference to override; rfp adds --platform only for the image it knows is amd64-only, so your own arm64-native build is run as-is. On a machine that has never pulled it: docker pull --platform linux/amd64 qgis/qgis:4.2 — without the flag that pull fails on Apple Silicon, and the digest does not substitute for it, since it is a manifest-list digest.
  • The gate was a smoke run of both PyQGIS scripts at 4.2.1, and both of its first measurements were wrong, in opposite directions. apply_styles.py appeared to regress — the written QML differed between the pin and :latest on line_color — until the same image was run twice and differed identically: QGIS mints elevation/profileLineSymbol with a random colour, filed as #294. Then render_map.py appeared byte-identical across versions, and that was written up as the evidence; two runs at one image are not byte-identical either, and the pair had agreed by luck. Re-measured on painted-pixel count — 7779 across three runs at each version, 4 layers, 1200×900 — the geometry is unchanged, and the renderer and labelling blocks of the written style are identical to :latest. The lesson is cheap and general: run the same image twice before comparing two of them.
  • @param image documents the platform behaviour, and the resolver lives beside the constant so R/rfp_docker.R stays free of QGIS knowledge for #281 and #272 to reuse.

rfp 0.63.0

  • osm, url and stac parameters can come from a caller’s own CSV; rfp’s stay the default (#285, NewGraphEnvironment/rtj#299). A manifest row is source_layer,source_type, so those three tiers resolve a catalogue and collection, an Overpass selector, an asset and which layer inside it from a lookup rfp ships — which made every one of those an rfp release plus a reinstall on every host. rfp_source(lookups = list(stac = "…csv")) takes a caller’s rows; rfp_source_lookup() returns the resolved result, and rfp_lookup_types() names the tiers that accept one so a consumer stops keeping a copy of the list.
  • Merged row-wise, keyed on source_layer — not substituted for the shipped file. Each pass rejects a layer it cannot find (unknown <- setdiff(layers, lookup$source_layer), then stop()), and that rejection sits outside the per-layer tryCatch, so it aborts the run rather than degrading one layer to stale. A one-row replacement file would therefore make every other shipped layer unknown: in rtj’s refresh driver that lands after a network pull, after hold_backup(), and after every earlier pass has rewritten the project, because stac is the LAST refreshable type in rfp_manifest_types() (frozen is last overall, and is never a pass). The operator’s model is “I added a row”; the effect would be “I deleted the rest”. Merging also loses nothing, because this file is a catalog of parameters and the manifest is what selects layers — nothing has to delete a row to stop using it. .aws_url() already resolves per-layer overrides exactly this way, in the same tier s3_base comes from.
  • Row-level, not cell-level. item_filter, keep, n_min and n_max are all legitimately blank, so “a blank cell inherits the shipped value” is ambiguous where “this row replaces that row” is not. Order is stable and assertable: shipped rows keep shipped order with replacements swapped in place, and override-only rows are appended in the override’s own order.
  • The lookup is read ONCE per call. rfp_source() resolved it twice — once for the pass and once for the default layer list when layers = NULL — so plumbing the override into only the pass gives a split brain: shipped names resolved in one branch, handed to a pass reading the override, and rejected as unknown against the wrong file. It now resolves one frame and both use it. The regression test drops a shipped row and adds one, so the two sets differ in both directions.
  • Unknown columns in any of these CSVs are now an error. The validators checked for missing columns and ignored extra ones, so a caller-side override carrying a column nobody reads would parse, validate and be silently dropped. The live case is a per-row watershed_groups, deferred from rtj#299 on purpose: ignored, it would clip the layer to the project AOI while n_min still passed, because .stac_check_n() runs on the merge before the clip. This is the same rule read_project_yml() already applies to project.yml keys.
  • An override with no rows is refused. Every per-column guard is a vectorised all(), and all(logical(0)) is TRUE, so a header-only file validates and merges to exactly the shipped set — configuration-shaped and a no-op.
  • lookups names are validated, and read with [[ rather than $. Measured: list(stacular = x)$stac returns x, because list $ partial-matches — so $ would silently adopt the very typo the validation exists to catch, and the run would look configured while using the shipped file. Unknown names, unnamed entries, duplicate names ([[ takes the first, silently) and a path that does not exist are all refused.
  • .osm_lookup() gained path =, which its two siblings already had, and with it the five error messages that hardcoded "rfp_source_osm.csv" now use basename(path) — otherwise a malformed override reports a defect in a package file the operator cannot edit and did not touch. It also gained colClasses = "character", which .stac_lookup() and .url_lookup() have: an override is the first of these CSVs anyone hand-writes, so type-guessing becomes reachable.
  • An osm test that could not fail now can. test-rfp_source_osm_pass.R’s schema-name test mocked .osm_lookup() with a function that reimplemented the regex inline and raised its own error, so deleting the real check from .osm_lookup() left it green — it asserted that a function written three lines above it did what it had just been told to do. It existed only because .osm_lookup() took no path. It drives the real validator now, and was confirmed to go red with the real check deleted.
  • Not recorded in the project identity record, unlike s3_base. The override file lives in the caller’s repo, so an absolute path written into a Mergin-synced project would be machine-specific and would sync to field devices. .rfp_identity_read() also discards the whole record when template is unusable, so the path could go missing for a reason with nothing to do with lookups.
  • Plumbed through rfp_project_update(), rfp_project_create() and rfp_source_test() — the other three rfp_source() callers. Without the last one the audit would verify the shipped parameters while a caller runs overrides, reporting on a configuration nobody uses.
  • Override paths are absolutised, because every caller runs the pass inside withr::with_dir(<project>). rfp_project_update(), rfp_project_create(), rfp_source_test() and rtj’s refresh driver all do, and lookups is the only caller-supplied file path threaded through it. Measured: the same relative value returned 3 rows from rfp_source_lookup() at the caller’s cwd and then failed does not exist inside the run — after the pull, after the backup, after the osm and url passes had written. It fixes the route through rfp_project_update()/rfp_project_create()/rfp_source_test(), which absolutise before their own with_dir. It cannot fix a caller that has already moved cwd before calling rfp_source() — rtj’s refresh driver does exactly that — so for that route a relative path still fails; rtj passes absolute paths, so it is not live there.
  • .lookups_preflight() runs at the top of all three project functions, before the dry-run return. lookups was touched only at the rfp_source() call, so dry_run = TRUE — the approval gate — never read the override at all: a typo’d tier, a missing file, an unknown column or a bad source_layer reported a clean plan and then aborted mid-write. The plan is the approval artifact and must not promise a run that cannot start.
  • A per-layer failure whose message contained { aborted the whole pass instead of degrading that layer to stale. cli parses its argument as a glue template, so cli_alert_danger(paste0(layer, ": FAILED - ", conditionMessage(e))) raised a new error inside the error = handler, which escapes the tryCatch. Reachable on shipped data, not hypothetical: .stac_layer_name() quotes the asset_layer template in its stop(), and two of the three shipped stac rows carry {scenario}. Pre-existing in all four passes (bcdata, osm, stac, url); lookups widens the reachable input set, which is how it surfaced. Found by enumerating every non-literal cli_* template in the package rather than by fixing the one instance a reviewer named — 77 at merge-base, 72 here; this branch converts the 5 reachable from lookups. The first enumeration reported 41, because the AST walker wrapped its recursion in try(silent = TRUE) and an empty argument (the x[i, ] shape) aborted whole subtrees in silence — a probe that under-counted and published the number. The remaining 72 are pre-existing and none is reachable from lookups; they are filed as #289 with the enumeration attached, rfp_project_identity.R being the sharpest (it interpolates a template read from a synced project, on a degrade branch — the same shape as the four handlers fixed here).
  • aws is deliberately not one of the tiers, though .aws_lookup() is a fourth lookup of the same shape: it already has two override routes. .rfp_lookup_types is the one place that decision lives, so revisiting it is a line rather than four signature edits.
  • Six restored-defect proofs, each confirmed to fire the named test rather than merely exiting non-zero: whole-file replacement, the second lookup read, unknown columns ignored, the osm message naming the shipped file, an unknown lookups name ignored, and an empty override accepted. Full-suite differential against merge-base: 4856 → 4971 passing, with an identical failing set on both sides (re-measured at the merge-base after #282 landed) (see CLAUDE.md — those are #149 and #269, and which of them fails depends on the host’s GDAL).

rfp 0.62.1

  • Both docker-backed functions failed on a clean arm64 machine, and only a cached image hid it (#282). qgis/qgis publishes amd64 only, so on the arm64 Macs a pull without --platform linux/amd64 fails with no matching manifest for linux/arm64/v8at the pull, which is why rfp_map_render() and rfp_styles_apply(registry = ) worked on every machine that had ever cached an image and failed on a new laptop, in CI, or after a docker system prune. The pinned digest does not substitute: it is a manifest-list digest and fails the same way.
  • The split between the correct and the broken invocations was not random. Five sites existed; the three that produce evidence — the test harness and both data-raw/qgs/ authoring scripts — carried the flag and a pinned digest, and both sites a user calls carried neither. Nothing could see it, because an argv could not be inspected without starting a container. .rfp_docker_args() is now the one place a docker run vector is built for the runtime and the harness, and it is pure, so the invocation is assertable with no Docker — the .osm_post_body() / .aws_url() pattern. .rfp_docker_run() is a seam beside it purely so a test can assert that a runtime function calls the builder rather than re-inlining a vector.
  • The flag is scoped to the image it is a fact about. .rfp_docker_platform() answers linux/amd64 for the Docker Hub qgis/qgis repository however it is spelled — bare, tagged, digest-pinned or docker.io/-qualified — and NULL for anything else, so a caller pointing image = at a private mirror or their own arm64-native build does not have emulation forced on them.
  • A missing image is deliberately not a refusing guard. docker image inspect returns non-zero when the daemon is down as well as when the image is absent — reproduced live while planning this — so a guard on it names the wrong cause for the commoner failure; and image = is public, so refusing one the registry would serve is worse than letting docker run pull. .rfp_docker_hint() interprets the failure afterwards and names docker pull --platform …, because the obvious advice without the flag is the command that fails on these machines.
  • The image default is unchanged. Moving it to the pinned 4.2.1 digest changes which QGIS answers and no test starts a container, so that is gated on a smoke run and lands separately.

rfp 0.62.0

  • A pinned Mergin read works at any version, not only HEAD (#265). _project_files() asked project_version_info(), which builds /v1/project/version/{project_id}/{version} and was handed the project path, so every non-HEAD version answered HTTP 404. rfp_mergin_files(), rfp_mergin_file_get() and rfp_mergin_conflicts() could only ever read the current state, which makes a committed (project, version) pin work exactly as long as it happens to be HEAD. Unblocks NewGraphEnvironment/trap#22, whose form harvest is pinned at v112 against a project now at v120.
  • The obvious repair is a silent wrong answer, and it is the one the issue points at. cmd_changeset already resolves the project id before calling that endpoint, under a comment saying it wants a UUID — so copying that up into _project_files() looks like consistency and clears the 404. Measured 2026-09-05, the version record carries no files key at all, so the existing changes.added fallback substitutes the files added in a version for the files present at it: 100 files for restoration_wedzin_kwa v112 where the truth is 1028, and 0 for hornby_2026 v18 where the truth is 58. A 404 is loud; 100 of 1028 is a listing plausible enough to use. project_info(path, version=) answers the question in one call and returns the HEAD payload’s exact shape, so the shape normalisation and the fallback are both gone. cmd_changeset keeps its id lookup — it genuinely wants the record, and the two must not be made “consistent”.
  • A fourth function was broken and the issue does not name it. rfp_mergin_diff() reaches the server through rfp_mergin_download_file() -> cmd_download_file -> _project_files(), so it failed for every non-HEAD from/to — essentially all of its documented use; its own example uses from = "2025-03-31". Introduced by #115, which correctly changed that lookup from asking HEAD to asking the requested version.
  • A pin that is not a pin is now refused. chk::chk_string("") passes, and the mergin client tests the version with if version: before sending it — so an empty string is falsy, never reaches the server, and HEAD comes back wearing the pin’s label. That is the shape a blank cell in a committed pin list takes. rfp_mergin_chk_version() is one implementation of the rule (#268) and refuses it in rfp_mergin_files(), rfp_mergin_conflicts(), rfp_mergin_file_get(), rfp_mergin_changeset() and rfp_mergin_download_file() — the last of which is the only one covering the rfp_mergin_diff() path. rfp_mergin_file_get() checks it before the destination-exists check, so a junk version is not reported only after the user has cleared an unrelated error.
  • _project_files() asserts the server echoed the version it was asked for, because ?version= being honoured is the assumption the whole function rests on and a request that is ignored comes back as a perfectly good HEAD listing. The comparison is on numbers, not strings: the server normalises v018 and v0018 to v18 and returns the right listing, so a string check would refuse data the server handled correctly — worse than the bug it guards. v0 is accepted; it is a valid shape answered honestly with an empty listing, and rfp_mergin_changeset() has always admitted it.
  • cmd_download_file sent args.version, not the version it had just resolved. With --version omitted that is None, which urlencodes to the literal string version=None and the server’s own ^$|^v\d+$ rejects. Unreachable from R only because both R callers always pass a concrete version.
  • The first test in this subsystem that runs below the rfp_mergin_py_json() seam without a network. Every mergin example is \dontrun and every mergin test mocked that seam, which is how a total failure of all non-HEAD reads survived from #115 to now — mocking the transport means the request is never built, and the request was the bug (#168, #268). _project_files() already took client as its first argument, so a fake client needed no new seam. Proven by restoring each defect, and honest about which half each proof covers: the two project_version_info variants — handing it the path (which 404s live) and handing it the id (which does not) — are indistinguishable to a fake client, since a fake answers either one, so both fail the same 13 assertions. What the suite proves is the wrong-answer half; the 404 itself is proven live, by the shipped script exiting 1 on any non-HEAD version. The args.version regression and the lost-files-key guard fail 2 each, and the fix fails none of the 24.

rfp 0.61.0

  • natural_key may name more than one column, so a merged layer has an expressible identity (#275). |-separated, matching the separator rfp_source_url.csv and rfp_source_stac.csv already use. Single-column rows are unchanged.
  • The stac tier (#274) is what made this necessary: merging N items into one layer makes identity composite, because a per-item id stops being unique the moment two items share a layer. Two of the three shipped stac layers could not be keyed at all, and fell back to a geometry hash silently — measured, one item entering or leaving a merge rewrote the fid of 100% of surviving rows, so a change touching one item pushed the whole layer to every device.
  • Re-measured on a second machine before the rows shipped. #275’s figures were taken on m4; this branch re-derived them on m1 against live images.a11s.one (BULK+MORR, 212 s), using .gpkg_key_map()’s own rule rather than a paraphrase. Every figure reproduced exactly — floodplains.floodplain stac_item 2/2, landcover_2023 stac_item|class_name 16/16, transition stac_item|patch_id 11,921/11,921 — including the refuted one: patch_id|name_basin, named as transition’s key in rtj#275 and in this repo’s own #274 archive, comes back 9,652 of 11,921. It is unique within an item and breaks across them, because MORR contributes two items and both carry name_basin = "morr" — a per-tenant key looking global because the test data had one tenant per group.
  • One renderer, because there are two engines. .gpkg_key_map() builds the map through RSQLite and .gpkg_reidentify() joins on it through ogr2ogr; two implementations of one rule is the shape that drifts silently (#268). .nk_sql() is now the only place a key becomes SQL and both call it. A single-column key renders character-for-character as before, pinned as a literal — a one-character move would make every existing project’s join miss every row.
  • The table alias is an argument, not a patch. The first draft aliased the join by gsub('CAST("', 'CAST(t."', sql), which rewrites the pattern anywhere it appears — including inside a quoted column name. A column named x CAST("y is the test that separates the two.
  • CHAR(31) as the separator, verified byte-identical in both engines before the design was fixed: RSQLite returns 61 1f 31 32 and ogr2ogr’s sqlite dialect returns the same. The unit separator cannot occur in GeoPackage text, so it cannot forge a collision between ("ab","c") and ("a","bc"). In SQLite x || NULL is NULL, so a NULL in any part voids the whole key and anyNA() fails closed — wanted, since mapping rows on a partial identity is worse than keeping naive fids.
  • rfp_natural_keys.csv is now validated at read time — an empty key part, a column repeated within one key, a duplicated source_layer. Every one of those failed silently before: the layer fell back to a geometry hash and nothing said so. Whether the named columns exist still cannot be checked there (it needs a GeoPackage, not a lookup) and stays the runtime fail-closed path; the roxygen says so rather than implying coverage it lacks. Untrimmed whitespace is normalised, not refused — a| b and a|b name the same columns, and a guard that rejects a working file is worse than the bug.
  • strsplit() drops a trailing empty field, so .nk_cols("a|") returned just "a" and a trailing separator was absorbed in silence — meaning the empty-part guard could never fire on the spelling most likely to be typed. Found by that guard failing to throw, not by review. Fixed with a sentinel, and the trap is pinned as its own test. Same shape as #268’s layer<TAB>base<TAB> TSV.

rfp 0.60.0

  • New stac manifest source type — the vector half, so a layer assembled from a STAC catalogue stops being a standalone GeoPackage with no provenance row (NewGraphEnvironment/rtj#275). N items matched by watershed group and an item filter, one asset each, merged into a single layer. The raster half is #125; the column words (collection, asset) are shared with it, and with rtj’s aoi.csv, so the two halves do not end up as two vocabularies for one idea.
  • The third tier added through rfp_manifest_types()’s extension contract, after osm (#168) and url (#266). rtj’s refresh and audit drivers derive the vocabulary rather than keeping a copy, so they pick it up with no change there.
  • url genuinely could not express this, and the reason is narrower than “no filter”. rfp_source_url.csv has had a query column (ogr2ogr -where) all along. What is missing is that spacehakr::spk_source_url() writes one layer per URL and never appends, so N sources cannot become one layer — and that a pinned href discards the catalogue search that resolves the current build.
  • item_filter compares as text, because flood_factor is published as an integer. A predicate written against the string ff04 matches nothing and returns an empty layer, silently. A selection matching nothing is therefore an error naming the collection, the groups and the filter — not an empty result.
  • asset_layer is a layer pick, not a -where. Measured against stac-floodplains-bc: the floodplain asset is a multi-layer GeoPackage whose layer names encode species and flood factor (co_ff04, ch_ff06), so selection resolves the item’s own scenario through a {property} template.
  • That template is also what makes the merge safe. morr_co_ff04 and morr_ch_ff06 publish a byte-identical floodplain asset — same file:checksum at two different addresses — so merging one asset per item would count MORR twice. Resolving {scenario} makes the two items take different layers out of the same file, and a repeated (asset identity, asset_layer) pair is refused outright. Identity is the checksum where one is published and the href otherwise: a constant fallback would make every contribution sharing a layer name look like a duplicate of every other, which is the shape of a legitimate transition merge.
  • CRS is normalised per contribution, before the bind, and asserted there rather than after. The collection publishes BULK and MORR in EPSG:32609 and PARS and WILL in 32610, so a merge across groups without reprojection puts two zones in one layer. The assertion cannot sit after the merge because rbind.sf refuses mismatched CRS itself — a post-merge check has no state in which it can fail. Proven live at 32610/32609 -> 3005; a project whose groups share a zone cannot expose it, which is why the offline test uses two.
  • Contributions are cast to their MULTI form before binding. Mixing POLYGON and MULTIPOLYGON yields sfc_GEOMETRY, and .aoi_clip() derives its base type from that class and returns early for anything outside POLYGON/LINESTRING/POINT — skipping the GEOMETRYCOLLECTION repair that keeps one source feature to one row.
  • Deprecated items are excluded and the exclusion is reported. Two of the collection’s items carry deprecated: true; one of them is a watershed group a fleet project would otherwise expect.
  • Each merged feature records the item it came from (stac_item). N items collapse into one table, so without it the item-to-feature mapping survives only in a run log and the count the pass reports cannot be checked against the artifact anyone receives.
  • asset_layer may not name a QGIS sidecar table. Every asset in this collection carries layer_styles; reading one into a project GeoPackage puts a foreign table in a shipped artifact (NewGraphEnvironment/rtj#259).
  • /vsicurl/ is applied only to http(s) hrefs, so a catalogue serving assets from disk works, and the asset read is testable against a real GeoPackage without a network.
  • A total failure warns rather than raises, matching the osm and url passes: a third-party catalogue outage must degrade a refresh to stale, never to aborted.
  • Housekeeping the tier surfaced: rfp_source_test() decided which types need a seeded GeoPackage in two places as two copies of one literal — named once now; the dependency-free roster in the type tests was hardcoded; and the source-type table in vignettes/data-sources.Rmd still said “Three source types” and named neither osm nor url.
  • rstac joins Imports. It is a hard dependency for one tier, which matches how bcdata already sits there for another.

rfp 0.59.0

  • The aws source type reads from a configurable base, so a layer we stage ourselves is reachable (#268). The host was hardcoded at three sites in inst/scripts/rfp_source_aws.sh, so the type meant “whatever one upstream bucket last published” rather than “sourced from object storage”. It is now spelled once, and rfp_source(s3_base =) overrides it. Every existing caller passes nothing and behaves identically.
  • A base alone is not enough, which is why there is also a catalog. The script builds URLs flat (${base}/${layer}.fgb.zip), so swapping the host reaches a mirror of upstream’s naming and nothing else. inst/lookups/rfp_source_aws.csv carries a per-layer base and path, which is what makes a layer staged under bcgw/<schema.table>/<edition>/ expressible — the rfp_source_osm.csv / rfp_source_url.csv shape, for the #168 reason that a manifest row is two columns.
  • The base is recorded in rfp_project.json, because rfp_project_update() calls rfp_source() on the project’s behalf. Without it a refresh of a self-staged layer would go silently back to the upstream bucket and return whatever it last published — the silence being the defect, not the hardcoded host on its own. Resolved argument → record → default, and re-resolved after a Mergin pull for the same reason the template is: the record syncs.
  • The key is written only when it is not the default. An absent key and a key holding the default mean the same thing, and absent is the spelling that does not dirty the record for every push — the write-once rule R/rfp_project_identity.R already states. A pre-#268 three-key record still reads and still resolves, which is pinned.
  • Two implementations of one rule, pinned to agree. .aws_url() in R is what every rfp-driven run uses; s3_url() in the shell keeps the script runnable alone. test-rfp_source_aws_script.R extracts both functions from the shipped script and compares them against R over a 30-case matrix — 5 layers x 3 extensions x 2 base spellings — rather than one happy path, because two implementations of one rule is the shape that drifts silently. Restating the shell code in the test would be proof about a copy.
  • URLs are resolved in R, not assembled in the shell, so the wire format is assertable offline. .osm_post_body() (#168) is split out as a pure function for exactly this reason: mocking a transport means the request is never built, which is where that issue’s one real bug was.
  • awk -F'\t', not a split helper. layer<TAB>base<TAB> is three fields to awk and two to most split implementations, so a path would otherwise be read silently as a base.
  • Trailing slashes are normalised once, in both halves: a base ending in / beside a path starting with one gives //, which S3 serves as a different key and answers 404 for.
  • The catalog ships empty, and whse_forest_vegetation.veg_comp_lyr_r1_poly stays commented out. No artifact in a bucket we control is confirmed staged — fresh-bc exists but answers 403 AccessDenied to a list, which is expected for a GetObject-only policy and means the presence of a key cannot be established without guessing one. A catalog row is a claim that the row works. Every row-level guard is therefore driven against a fixture, and the test file’s header says so rather than letting a 0-row file read as coverage.
  • A malformed catalog warns and degrades rather than killing the pass: layers without an override still resolve, and a catalog typo must not cost a whole refresh.

rfp 0.58.0

  • New url manifest source type, so a layer fetched from a documented address stops being recorded as frozen (#266). frozen means “wanted, never refreshed, unrecoverable if missing” — a false thing to say about data that regenerates from a published URL, and it also arms the missing-frozen-layer stop in a consumer’s refresh for a layer that would come back fine.
  • The second tier added through rfp_manifest_types()’s extension contract, after osm (#168), and it reached rtj’s drivers with zero lines changed there — they now derive the vocabulary rather than keeping a copy (NewGraphEnvironment/rtj#264). That is the property the contract promised, observed rather than assumed.
  • Parameters live in inst/lookups/rfp_source_url.csv, keyed by source_layer, for the same reason the OSM selector does: a manifest row is two columns. Carries the address, GDAL open options, encoding, an assigned or target CRS, an optional -where, a declared column set, feature-count bounds, and the licence/attribution obligation. | rather than , as the internal separator — a comma does not survive a consumer writing CSV with quote = FALSE, which rtj’s manifest generator does.
  • n_min / n_max exist because a WFS answers 200 with zero features as readily as with the right one, so HTTP status proves nothing about whether a filter matched. Checked against what the source returned, before any AOI clip — a clip legitimately removes features, a wrong filter does not. Geometry-level assertions (an expected area with a tolerance) are deliberately not modelled: they mean something for one polygon and nothing for a point layer.
  • Bounds refuse a fractional value rather than truncating it. as.integer("2.5") is 2 and as.integer("0.9") is 0 — silently — so a guard watching for NA misses exactly what it exists for. "0.9" is the sharp case: a floor of zero can never fail.
  • a_srs and t_srs together are refused at read time. ogr2ogr rejects the pair (spacehakr#22), and catching it in the catalog turns a run-time GDAL error into a config error naming the row.
  • A total failure warns rather than raises, matching the osm pass: a third-party endpoint outage must degrade a refresh to stale, never to aborted. The bcdata pass is right to raise because every project’s integrity depends on it; no project’s depends on a supplementary source.
  • Seeded with cabin.sites — measured 2026-09-04 at 4,162 features, 20 columns, EPSG:4326, published as UTF-16LE with lat/lon columns rather than geometry. It is the layer whose absence from any catalog caused the same fetch to be written independently into two repos months apart.
  • A WFS row is not shipped yet, deliberately. spk_source_url() opens streaming sources through /vsicurl/, which cannot open a service endpoint with a query string; /vsicurl_streaming/ opens the same URL fine and -if GeoJSON does not help (spacehakr#23). A catalog row is a claim that the row works.

rfp 0.57.0

  • A form’s embedded style can now be refreshed in place: rfp_qgs_form_add(restyle = TRUE). A form’s style lives inside the .qgs — the insert copies the QML’s children straight into the <maplayer> — while rfp_form_build() writes only the GeoPackage and its sidecar, and QGIS does not re-read a sidecar for a layer a project already declares. So a schema change left the project holding the new columns under the old <fieldConfiguration> / <attributeEditorForm> / <defaults> block (#260).
  • The failure it removes is silent in the worst way. The new fields are unreachable, because a tab layout renders only what attributeEditorForm names, and NULL, because the now() defaults live in <defaults> — but every structural check passes and so does QGIS Desktop, where the form opens and looks correct. It is wrong only on the device, in front of a crew. Measured on a live 555 MB project about to take cabin_visit from 39 columns to 41: fieldConfig=38, attrEditorField=37, defaults=38, with neither new column anywhere.
  • The id and the layer name are read from the node being replaced, never derived. That is the whole safety argument: layer tree, layerorder, map themes, custom-order, the legacy legend and any <relation> still point at a node that exists, and none of them is edited. Deriving the id would work on a project rfp built and fail on one QGIS touched — the same split that made the existing no-op check compare datasources rather than ids. Asserted against a hand-minted id no digest can reproduce.
  • Remove-and-re-add was the alternative, and this package already argues against it (rfp_qgs_raster_add(): .prune_empty_groups() drops a group once its last layer goes, and the theme entries go with it”). Beyond that it changes the id, so on the project measured above 12 theme memberships and one relation would have had to be restored rather than preserved.
  • Two refusals, both failing toward stopping because this replaces a node rather than adding one: an ambiguous match stops and asks for name, since two styled views over one GeoPackage differ only by name (#202) and refreshing both from one style would silently make them the same layer twice; and a missing style stops rather than replacing a styled node with QGIS’s generated layout. The add path warns and continues there, which is right for a new layer and wrong here.
  • A declared child with no style is skipped, not refused — and an earlier draft had that backwards. rfp_form_create() copies the parent’s sidecar and not a child’s, so a cabin_visit project normally declares Form CABIN Pebbles bare; refusing would have blocked every such project, and rebuilding it from no style would strip the layout of a child that does carry one. Skipping does neither. Found by running the thing rather than reading it.
  • Every test was re-run against the restored defect. Six of eight go red; the two that stay green are deliberate regression guards on unchanged behaviour. One of them did not start out that way — “a restyle leaves every surface untouched” passed against a no-op, because a call that does nothing also leaves every surface identical. It now asserts that the refresh happened, in the same test.

rfp 0.56.0

  • trail_feature is retired and replaced by observation, a generic incidental-observation form. The old form could not use the photo tooling at all: fpr::fpr_photo_rename() names photo_extra1_tag through photo_extra5_tag in a case_when, every right-hand side of which is evaluated, so its photo_1/photo_tag1 idiom does not mis-name files — it errors. It also had no site_id, which is the column that call keys photo directories on (#243).
  • Its records were not test data, and finding that out changed the work. The issue was filed saying they were. Measured across both projects — the second found by reading the Mergin server, since it is not cloned locally — there are 11 real records: 7 in hornby_2026 and 4 in nelson_20260827, the most recent from the day before the work started, carrying substantial field prose and 14+ photos. All were harvested and verified before anything was deregistered: 11 of 11 rows, 20 of 20 photos. The premise mattered because it was the sentence that made the harvest look optional.
  • passable is kept, against the issue’s own field list. It proposed dropping passable, exposure and action as trail-specific. Measured over those 11 records, passable is meaningful in 6, where the other two are 2 each. The other two are dropped as proposed.
  • observation_type is evidence-led rather than invented: it covers all six values the real records actually chose under the old trail-specific list.
  • form_wanted is the first shipped consumer of {{form_types}} (#242). Until now no schema used the token, so the expansion short-circuited on every build and the machinery had never run for real. It records which protocol form a crew needed and could not use — and its premise measures out wider than the issue claimed: across all 13 live Mergin projects the median is 3 of 14 registered forms and the maximum is 8, so “the form I need is not loaded” is the normal case.
  • The token now excludes the form it is expanding into. Emitted unchanged, observation’s own dropdown would offer Observation — the form asking which form you wanted while listing itself. Excluded on #242’s own reasoning for child forms: do not offer something unchoosable. Resolved from the registry by matching basename(schema_source), never by stripping .csv, because rfp_form_build(schema = ) takes an override path that need match no row.
  • Retirement leaves deployed projects untouched. Nothing globs form_*.gpkg against the registry and there is no deploy-every-registered-form loop, so projects already carrying the layer keep working until someone removes it deliberately. The symbols README keeps a struck-through row for #B15928 so the hue is not reused while those projects still show a brown Trail feature.
  • Two guards earned their keep during the swap. The drift test caught form_observation.qml shipping a stale form_wanted list — built while trail_feature was still registered — and the token test’s “no shipped schema carries a token” premise expired exactly as code-check.md predicts a negative-case fixture will; it is partitioned rather than loosened, with both halves asserted non-empty. The container valuemap test was also comparing QGIS’s readback against the raw CSV cell, and now reads the expanded schema and asserts both the labels a crew sees and the values the column stores.

rfp 0.55.0

  • The three generic rfp_source_* fetchers now live in spacehakr, and deprecate to it from here. rfp_source_url(), rfp_source_csv() and rfp_source_bcdata() had no coupling to anything rfp is for — measured on the whole set, the only match for manifest|rfp_project|background_layers|mergin|rfp_tracking in each was a filename inside a roxygen @examples block, and zero in the code (#257).
  • The cost of the split was reproducibility, not tidiness. Sourcing from public endpoints — federal open data, the BC Data Catalogue — is exactly what an outside reader needs to regenerate a report’s inputs, and a thin ogr2ogr wrapper sitting in a private package left those inputs unreproducible for anyone without it. spk_geoserv_dlv() already did the same job for WFS in the open. The moat is project assembly, the form lifecycle and manifest curation; it was never a wrapper around a GDAL command line.
  • rfp_source() itself is unchanged and stays here. It carries three real manifest/project/layout references and a signature taking watershed_groups, dir_layers, file_gpkg and layout — it is the orchestrator that knows how a project is laid out, not one of the fetchers. Nothing in R/rfp_source.R was touched.
  • Two functions shipped in the new home, not three. rfp_source_csv() turned out to be rfp_source_url() with the SQL filter omitted — same validation, same layer naming, same argument vector, same /vsicurl/ prefix — so the public toolkit has one URL entry point and rfp_source_csv() deprecates to spk_source_url() with an explicit urls_csvurls mapping.
  • Every name still works and still exports; each emits a lifecycle deprecation naming its replacement. spacehakr joins Imports.
  • The oracle guarding the delegation reads the installed function bodies, not the source tree. Scanning ../../R/*.R is green under devtools::test() and finds nothing under R CMD check, where tests run against the installed package and its R/ holds rfp.rdb rather than any .R file — so the check silently verified nothing. Its own expect_true(length(targets) > 0) premise is what caught that, and it now also asserts the scan finds exactly what the shim table declares. Reproduced both known answers in a directory with no source R/ beside the tests: the old version fails, this one passes.

rfp 0.54.0

  • A phone LiDAR scan is now a field record, not a filename. scan /form_scan.gpkg captures the metadata the model host cannot hold, and stores only the model’s uid (#244). Measured against the live NewGraph Sketchfab account rather than assumed: createdAt is upload time — model aa2a61e1… is named 20230630_beetlebath and was created 2024-11-01, a 16-month lag — and zero keys on /v3/models/<uid> match geo|loc|lat|lon|coord. The host can supply neither capture date nor position, so the form point is the only georeference a scan has.
  • Two-phase, because the model does not exist while you are standing there. The Capture tab is one typed value — site_id, which the crew also types as the scan’s title — plus taps and two auto-stamps. The Model tab is blank in the field and filled at the desk after upload.
  • Only the uid is stored, because everything else is derived. A Sketchfab embed is sketchfab.com/models/UID/embed — pure string construction, verified byte-identical to the URL the API reports — and the public v3 API returns name, counts and licence from the uid alone. So a re-upload under a new uid is one cell to fix rather than a hunt through report source, and rfp ships no Sketchfab code: a URL builder for a third-party web API is neither QGIS, Mergin, GPKG nor GDAL plumbing.
  • export_format is load-bearing rather than decoration. The capture app’s LAS export is georeferenced and its PLY export is not, so the answer decides whether the form point is the model’s only georeference or merely a cross-check. capture_method carries the same weight for the same reason: a gaussian splat is a third artifact class, neither embeddable on the mesh host nor positioned on its own.
  • Five photo slots with five tags, and that is mechanical rather than stylistic. fpr::fpr_photo_rename() names photo_extra1_tag through photo_extra5_tag in a case_when, and every right-hand side of a case_when is evaluated — so a frame carrying only three errors with object 'photo_extra4_tag' not found. Measured by driving the real function, and already worked around by hand in the field: fish_passage_fraser_2023_reporting carries a ### HACK for Fraser 2024 … comment about exactly it.
  • Which forms satisfy that contract is now a recorded number rather than an absence. Two of eleven shipped schemas carry the five slots and five tags, and only scan also carries the site_id the renamer defaults to — so it is the only form the call runs against unmodified. The other nine are short or, in trail_feature’s case, use photo_tagN; widening a deployed schema is an on_records migration (#218) rather than an additive change, so it is filed separately and the pinned set turns red when it lands.
  • time_epoch goes beyond the issue’s field list, on #241’s argument: a Mergin track’s M ordinate is UTC epoch seconds, and a scan point exists to be georeferenced against one. Known and unchanged: it is a GeoPackage MEDIUMINT, a 32-bit Integer to GDAL, so epoch seconds wrap on 2038-01-19. trail_feature and cabin_visit ship the identical declaration and this makes three; diverging for one form would be worse than sharing the ceiling.
  • The capture-instant guard now reaches ten schema-built spatial forms, and the count-bearing comments that a new schema stales are re-measured rather than renumbered — 499 options cells and 123 valuemap cells, with the #242 guard’s headroom re-confirmed at zero registry hits and zero braces. The colour table in the symbols README also gains the cabin_visit row it has been missing since #206.
  • Three review rounds found eight things and none was a bug — every one a count-bearing comment asserting something the code does not do. The expensive one was introduced by a fix: a stale count was corrected by widening its population, which made the sentence’s own consequence clause false. A count and its consequence have to share one population. data-raw/forms/styles_harvest.R carried the same shape and is corrected, and R/rfp-constants.R’s “no group contains >” — true when written, false 73 times over now — gains the explicit anchor it was missing.

rfp 0.53.0

  • A valuemap’s options may be generated from the form registry instead of hardcoded. {{form_types}} in a schema CSV expands at build time from inst/lookups/rfp_form_types.csv as label=type — the crew reads PSCIS, the column stores pscis. A hardcoded list is correct until the next form is registered and silent when it stops being: the form keeps building, the list keeps looking deliberate, and a crew member cannot select a form that exists (#242).
  • The writer needed no change — .rfp_form_option_pairs() already split Label=code on the last = (#206) and the valuemap branch already emitted <Option value= name=>, so expansion only had to produce label=type. {{...}} rather than @... because it is already the placeholder idiom in inst/extdata/forms/symbols/, and because .rfp_form_default() reads a leading @ in the adjacent default cell as a QGIS variable. Children are excluded (13 of 14 rows): a child has no GeoPackage of its own and both build and create refuse one directly.
  • Expansion is at build time, so a shipped .qml still carries a literal list and registering a form appears as a reviewable QML diff rather than resolving on a phone. The registry is passed as an argument, so what the dropdown holds when the registry changes is assertable against a synthetic one.
  • A test now fails if any schema hardcodes the registry instead. It reads both halves of every option, case-insensitively, against the registry’s type, label and description, and derives which schemas to scan from the registry the way the builder does — so a schema outside forms/schema/ cannot escape it. Its reach is pinned by an enumerated matrix of 13 shapes with both known answers rather than asserted.
  • No shipped form uses the token yet (#243 is the first consumer), so forms_build.R remains a byte-level no-op — asserted over every shipped schema rather than sampled.

rfp 0.52.0

  • cabin_visit gains a capture instant, so its records can be georeferenced by time. It stored capture time only as site_visit_datetext holding format_date(now(),'yyyy-MM-dd'), a date with no time of day. Time of day is what joins a record to a Mergin position track; a date resolves to a whole day of track, which is not a position. It was the one form whose records could not be placed (#241).
  • Two fields, not a retype. site_visit_date is CABIN’s own template field, is the yyyy-mm-dd shape CABIN’s upload wants, and feeds the registry’s label_expression — the visit date and the capture instant are two different facts. date_time_start (datetime, now()) is the convention eight of the nine schema-built forms already carried, so this was a form that had missed it rather than a new design. time_epoch (round(epoch(now())/1000)) follows trail_feature (#140): a track’s M ordinate is UTC epoch seconds, so the join needs no timezone reasoning — and sf returns a datetime with a NULL tzone that prints in session time, right value and wrong rendering, which is the shape of mistake that survives review.
  • The convention is now asserted absolutely, and its scope is stated rather than assumed. All nine schema-built spatial forms comply, so the guard is a floor for the tenth. It reaches nine of thirteen spatial forms, because it reads schema CSVs; the other four are pinned by their own test on the criterion the convention actually uses — a now() default, not a column with a date-ish name. monitoring is why that distinction is load-bearing: it carries date_time_start and zero non-empty defaults in its whole QML, so a presence check calls it compliant on a column nothing fills.
  • Adding a column to a deployed form is a migration, and the acceptance test asserts the new columns are present and NA as well as that the row count held — a migration dropping them entirely would preserve every row and pass a count-only check.
  • The cabin_visit derive script’s rfp-local branch previously fell back to uuid’s label and a hardcoded help string about the pebble relation, which would have described the next such field as a child-table key. It now takes label/help per decision and refuses a blank — in both directions, since a CABIN row declaring either is silently overwritten by the guide.
  • Known and unchanged: time_epoch is a GeoPackage MEDIUMINT, a 32-bit Integer to GDAL, so epoch seconds wrap on 2038-01-19. trail_feature ships the identical declaration; diverging for one form would be worse than sharing the ceiling.

rfp 0.51.0

  • rfp_mergin_conflicts() resolves a project’s has_conflict flag to the files it is about. The server reports conflict per project, which is the right signal for “does this need attention” and the wrong one for “is it safe to read this file” — it cannot tell a conflict over an empty rebuilt form from one holding a season’s records. Measured on restoration_wedzin_kwa v112: the project reports TRUE and carries eight conflicted copies, of which the four current-season ones are empty. A consumer failing closed on the project flag refuses to read the project over four empty files; one ignoring it silently drops whatever a populated conflict holds.
  • The parse is anchored to Mergin’s naming rather than matching the substring "conflicted", which the workflow this replaces used and which also matches a file legitimately named notes (conflicted requirements).md. Two details decide whether the resulting guard works or silently never fires: the extension sits on the far side of the parenthetical, so a naive strip yields form_vri_qa and never matches form_vri_qa.gpkg; and matching is on the full path, so a stale conflict under data_field/2023/archive/ cannot be mistaken for one over a same-named file at the root.
  • Row counts are deliberately not returned. The file listing carries a size and none is derivable — an empty 53-column GeoPackage is a nonzero, unpredictable size — so deciding whether a conflict holds records means fetching the file, which stays the caller’s step rather than making a listing function transfer files.

rfp 0.50.0

  • The raster stretch writer dispatches on renderer TYPE, not on which element it happens to find. A presence check matches nothing on a multibandcolor, which names its enhancements redContrastEnhancement / green… / blue… - a stretch silently not applied, which is #235 in a new place. An unlisted renderer type is now an error (#232).
  • An explicit stretch is pinned so nothing re-derives it. Measured in the pinned container over both a project read and a bare .qml sidecar: QGIS recomputes a raster’s range iff minMaxOrigin/limits != None and minMaxOrigin/extent == UpdatedCanvas, and it does so at render - the renderer still reports the written values when queried. The pin applies to every renderer type, because the behaviour belongs to QgsRasterRenderer.
  • airphoto_gray no longer renders flat as a bare sidecar. It shipped carrying the 1-9 range of the 3x3 fixture its renderer came from; applied to scan_tails.tif that is 2 colours at 99.5% white. stretch = "none" and rfp_styles_apply() never reach the stretch writer, so the style now ships extent = UpdatedCanvas and self-adapts to whatever raster it meets.
  • A range written under a NoEnhancement algorithm is refused rather than written and ignored, and the shader path no longer sets pseudocolor attributes on a greyscale renderer.
  • A fifth QGIS-authored reference, raster_singlebandgray, built from the PyQGIS API. airphoto_gray carries raster_gdal’s renderer, so comparing it against that file compared a file with itself. The renderer oracle now requires the registry and the reference set to name the same renderers, so a reference nobody registers is reported as dead coverage.

rfp 0.49.0

  • stretch= was a silent no-op on every greyscale raster. .qml_set_stretch() returned early when a document had no <colorrampshader>, and a singlebandgray renderer never has one - it keeps its range in <contrastEnhancement>. So a shipped style’s baked-in range applied unchanged, nothing errored, and the layer drew flat (#235). Measured on the package’s own references with a positive control: the pseudocolor path set classificationMin correctly while the gray path returned FALSE and left minValue at 1 against a 0-255 airphoto
  • The new branch is gated on the presence of a <contrastEnhancement>, not the absence of a shader. paletted and hillshade carry neither element, so both still fall through untouched - a <paletteEntry value=> is a class code, and rescaling one is data corruption rather than restyling
  • classificationMin/Max are deliberately not written on that path. QGIS does not put them on a singlebandgray, and adding an attribute QGIS itself does not write is the inference #227 exists to stop
  • New stretch = "from_data_p2_98", a 2nd-98th percentile cut for scanned imagery, where a scan border or a dust speck puts min/max at an extreme no meaningful part of the image occupies and the rest compresses toward flat. Read from GDAL’s own histogram, so it costs one gdalinfo and adds no dependency. Validated against four scanned airphotos whose stretches had been chosen by hand in QGIS: three of four reproduce the human’s numbers exactly (63-172, 72-162, 167-206); the fourth differs only because the hand value pooled all bands of a multiband scan where this reads band 1, which is the band a grayBand="1" renderer draws. Runs under GDAL_PAM_ENABLED=NO - -hist otherwise drops a .aux.xml beside the raster that Mergin pushes as an untracked file
  • New restyle = argument to rfp_qgs_raster_add(). The layer id is derived from name and datasource, so a restyled layer hashes to the id already present and the call returned early - there was no way to change a layer’s style at all, and a driver re-running from config reported success while the old renderer survived. Removing and re-adding is not the workaround it looks like: .prune_empty_groups() drops the layer-tree group once its last layer goes, taking hand-configured map-theme entries with it. Only <maplayer> is replaced, and because the id is unchanged every other surface holding it - layer tree, themes, custom-order, legend
    • keeps pointing at a node that still exists
  • New airphoto_gray raster style, backed by a QGIS-authored singlebandgray QML and carrying from_data_p2_98. It is the raster_gdal.qml reference byte for byte; the stretch is the only thing computed per layer
  • New inst/testdata/scan_tails.tif: a 64x64 Byte raster with thin tails at 1 and 254 against a body around 120, so from_data (1-254) and from_data_p2_98 (64-176) give measurably different answers. The dtm.tif fixture spans 1-9 with no tail, so the two modes coincide on it and it cannot tell them apart

rfp 0.48.0

  • QGIS now answers for the things rfp writes. Every rfp_qgs_* assertion used to be a round trip through rfp’s own reader, which proves only self-consistency - the failure #17 records, where layer_styles rows were invisible to QGIS with every test green. Map themes, preview expressions, draw order, raster renderers and whether a raster paints are now asked of the application (#227).
  • One QGIS-authored reference per raster renderer type, in inst/testdata/nodes/. Each is constructed from the PyQGIS API and attached with setRenderer(); loading rfp’s own shipped .qml and saving it back would hand back what it was given, so every comparison would pass by construction. All four share the same six <pipe> children and the same four universal attributes - alphaBand, nodataColor, opacity, type - and diverge in everything else, which is why adapting one renderer into another failed twice.
  • The render assertion is not “not blank”. A renderer painting an opaque slab over a background is two colours and would pass that. The fixture raster holds nine distinct values, so a working interpolated ramp must give exactly nine distinct colours; the control is a paletted renderer with an empty palette, which loads clean, reports its type correctly and paints nothing.
  • One gate, one pin, one invocation, one parser. tests/testthat/helper-qgis.R replaces two gate variants, four container-invocation spellings and three sentinel encodings across five files. The image is .rfp_qgs_image - tag, digest and version - read by the tests and both data-raw scripts.
  • Three probes moved from the moving qgis/qgis:latest (4.3-Master) to the pinned 4.2.1, which is the release the field Macs carry. That moves coverage rather than adding it: nothing runs against 4.3-Master any more (#69).
  • Every probe now runs --network none and sends stderr to a file rather than merging it. Both were already measured as necessary and only some files did them.
  • Fixed: data-raw/qgs/nodes_author.R copied its .qml sidecars with dir_ls(glob = "style_*.qml"), which matches the full path - so the loop had matched zero files since #126 introduced it, failing in the safe-looking direction. vector_ogr_table.qml and vector_ogr_parent_relation.qml exist now because their absence was that bug, not a decision.
  • data-raw/qgs/ scripts renamed artifact-first per the noun_verb-detail convention - nodes_author.R, themes_extract.R, templates_restamp.R and eight more (#230).

rfp 0.47.0

  • bcrestoration_mobile ships High Detail - Crossings working. The theme was a stub - 28 layers enumerated, none visible, and no group checked - so switching to it turned everything off, in the most used theme in the fleet (8 of 11 projects) and in every project ever created from that template. It now matches the healthy copy in bcfishpass_mobile layer for layer: 27 of 28 visible, and 15 groups checked and expanded (#217).
  • Two guards that could not see it. #216’s check asks whether a theme shows a layer whose group it leaves unchecked, which a theme showing no layer passes vacuously; the complement is now asserted - no shipped preset enumerates layers and shows none of them, and none checks zero groups. Both halves are needed: layers switched on inside an unchecked group still draw nothing.
  • The theme registry carries no scoped rows. High Detail - Crossings was the one theme recorded per template, on the reading that the two templates legitimately disagreed about it; they did not, and repairing the stub made data-raw/qgs/extract_themes.R collapse 56 scoped layer rows into 28 portable ones by itself. One behaviour follows: High Detail - Crossings now lands in a project whose template is unknown, where it previously reported no rows. rfp_qgs_theme_add(template = ) is otherwise unchanged and still selects a scoped row where one is authored.

rfp 0.46.0

  • bcrestoration_mobile gains the Floodplain and Restoration layer-tree groups, immediately above Basemap, and the existing Floodplains layer moves into the first of them. Tree order is draw order, and Basemap holds the waterbody fills a floodplain product is read against, so roughly half of such a product drew underneath. Restoration ships empty - it is the labelled slot for project-specific harvest and planting layers. Basemap/Terrestrial Ecology stays bcrestoration-only, deliberately (#216).
  • rfp_qgs_theme_add()’s documentation of why map-theme group rows are not portable was wrong: it named a spelling divergence between the two templates that does not exist. The reason is the project-root prefix on every group path. Behaviour is unchanged.

rfp 0.45.0

  • rfp_form_build() no longer destroys the records a form has collected. Rebuilding replaces the whole GeoPackage, and that GeoPackage is also the surface a field crew captures onto once the form is deployed - so a schema fix and a data wipe were the same operation. A changed schema over a form holding records now stops, naming the row count, and the new on_records argument says what to do instead: "migrate" carries the records into the new schema, matching on column name and reporting what was dropped or retyped, and "overwrite" is the old behaviour asked for deliberately. A form with no records - every form rfp ships (#49) - rebuilds silently as before (#218).

rfp 0.44.0

  • site_id on the four template-free form schemas - vri_qa, viewscape, exceedance and transition_qa. They had no identifier in common: vri_qa keyed on vri_feature_id, viewscape on station_id, exceedance on gps_waypoint_number, so nothing generic could name a photo or join a record across forms. The value is crew-assigned in the field, deliberately, so it matches what goes on sample labels and notes. Existing captured records predate the field and need back-filling from the per-form identifiers above.

rfp 0.43.0

  • A layer or group added without a declared position no longer sinks below the basemaps. The layer tree is draw order, so the end of the tree is underneath the opaque raster basemaps - a layer that lands there is added, syncs, shows in the layers list with its toggle on, and draws nothing. Four instances were found by eye on one project. .qgs_group_find() now creates a group above the basemaps, position = "bottom" means the bottom of the data rather than the bottom of the tree, and every writer refuses a write that sank a layer.
  • rfp_project_audit() gains sunk, reporting layers that already draw below a basemap. Nothing reported this before: QGIS shows such a layer as present and checked, and the only symptom is an absence on a map.
  • Breaking: group is now required in rfp_qgs_vector_add(). It defaulted to NULL, documented as inserting at the tree root; measured, it created an unnamed group at the end of the tree. rfp_qgs_service_add() had the same defect and now uses each service’s registry group, as it always documented.

rfp 0.42.0

  • inst/lookups/rfp_qgs_theme_registry.csv records what every map theme in the fleet is for. Surveyed from 11 active projects: 79 theme instances under 41 distinct names, none of which carried a description anywhere, because a QGIS visibility preset has a name and no description field.

    duplicate_of marks the same thing under two names and a decision that is owed — Land Ownership (3 projects) against Land Tenure (2), Aerial Imagery (2) against Imagery (1). family is deliberately separate: it groups a shared subject whose members are genuinely different, one species model per species. Collapsing the two would list Low Detail - Salmon Model as a duplicate of Low Detail - Bull Trout Model.

    purpose = UNKNOWN is written into the data rather than left blank. Blank reads as nothing to say; UNKNOWN reads as nobody has said.

    Filling the purpose column immediately found a defect (#217): the most used theme in the fleet, High Detail - Crossings in 8 of 11 projects, ships from bcrestoration_mobile with all 28 layers enumerated and none visible.

rfp 0.41.0

  • rfp_qgs_layer_rm() removes a layer’s reference from a project - the <maplayer>, its tree entry, every map theme that lists it, and the ordering lists (#202). Two cases needed it: a dead reference left behind when rfp_project_create() drops a layer that is empty in the AOI, which makes QGIS raise Handle Unavailable Layers on every open; and a live layer when the data model changes underneath it. No file is deleted, so the operation is reversible and safe on a live project.

  • rfp_project_audit() gains dead - layers pointing at a project-local artifact that is demonstrably absent. Nothing reported these before, so they were invisible until QGIS complained.

  • One dataset may carry two styled views. The no-op check in rfp_qgs_vector_add() keyed on the datasource alone, which refused a second layer over a GeoPackage already referenced and cost a physical second copy of the data. It now keys on the datasource and the layer name.

rfp 0.40.0

  • A form may own child tables, bound by a 1-N relation (#207). A parent feature with an unbounded number of child records - 100 pebbles in a Wolman count, photos with per-photo metadata - is a relation, not a wide row, and rfp could not express one: <relations> is empty in both templates and nothing wrote it. A child is now a registry row whose parent names another type; rfp_form_build() writes it into the parent’s GeoPackage and rfp_qgs_form_add() declares both layers, the relation, and the editor element that makes the child reachable on a phone.

  • cabin_visit gains its pebble count table, the first real 1-N. Every one of CABIN’s thirteen Substrate Data parameters is computed from it, so the table records the measurement and none of the parameters is a field.

  • A non-spatial table is a layer rfp can write. Recognised by gpkg_contents.data_type = 'attributes' rather than by a missing geometry row, so a typo’d table name is still an error; kept out of both layer ordering lists, which QGIS restricts to spatial layers.

  • Fixes a schema-signature failure on a table with no geometry column, which surfaced only when rebuilding a form in place.

rfp 0.39.0

  • A CABIN benthic field form, derived from CABIN’s published guide (#206). Benthic sampling under the CABIN Wadeable Streams protocol was collected on paper and transcribed afterwards, which is where the errors are: three values in the 2025 Neexdzii Kwa upload are outside CABIN’s vocabulary, including a parameter name that appears nowhere in the guide. cabin_visit puts the 37 fields of the Sites, Visits, Chemistry and Channel-habitat sheets on one point per site visit, with 17 dropdowns constrained to CABIN’s own values. The guide is vendored and the schema derived from it, so the form cannot drift from the standard without a test failing.

  • A valuemap may store something other than what it displays. A schema’s options gains an optional Label=code form, split on the last =. This is what a published coded vocabulary needs - a crew picks Coniferous Trees and the column holds 4. Every schema written before this is unaffected, and all eight shipped forms rebuild byte-identical.

  • data-raw/forms/derive_schema.R is now derive_schema_risc.R. It carried the generic name while handling pscis and fiss_site specifically; the directory’s rule is one derive per upstream standard.

rfp 0.38.0

  • A preview expression is checked against the layer it lands on (#201). .qgs_maplayer_assemble() copies every child of the QML root into the <maplayer>, and previewExpression is the one that names a column - so a style authored against one layer carried its preview expression onto any layer it was applied to. QGIS answers one naming an absent column by falling back to the raw feature id, silently, which is what a field crew saw when tapping a floodplain polygon.

    The expression is now dropped with a catchable warning when a reference does not resolve, and rfp_qgs_preview_set() sets or clears one - erroring on the same condition, because adding a layer is a bulk operation that must not abort while setting an expression by name is someone asking for this one. rfp_project_audit() reports the layers already in that state, which is the only way to find what there is to repair.

    Two shipped artifacts were in it. form_edna.qml previewed on gazetted_names, which form_edna.gpkg does not have, so every eDNA form rfp deployed showed feature ids; data-raw/forms/harvest_styles.R now drops one the form’s GeoPackage cannot satisfy, the question it already asked of fieldConfiguration. And bcrestoration_mobile’s Floodplains previewed on feature_name, which on the CWB data is the constant "Floodplain" on every row while floodplain_name carries the actual name - valid, resolvable and useless, which is why nothing reported it.

    Only quoted references are checked and matching is case-insensitive: bare is indistinguishable from a function name, and QGIS’s own field lookup falls back to a case-insensitive match. Both choices fail toward keeping an expression, because the failure is silent in both directions. Passing no fields at all is no opinion, so rasters, remote services and the template build are unchanged.

rfp 0.37.0

  • The layout resolver is exported (#198). .rfp_layout()’s own header calls it “the single place resolution happens”, but it was @noRd - so rtj’s scripts/gis drivers had no way to obey it and hardcoded background_layers.gpkg instead, which meant any project created with rfp’s own layout = "split" default failed four of the five. rfp_layout() returns the spec, rfp_layout_layers() lists what a project holds either way, and rfp_layout_layer_path() maps a layer to its file.

    The resolver alone would not have been enough. Three of those drivers want the layer list where they call sf::st_layers(path)$name and a fourth works per-layer file, so a spec-only export would have left the accessors to be written a second time outside rfp - the drift the export exists to prevent.

    Each export holds the body and the internal spelling is a one-line alias, so there is one implementation and no call site inside rfp moved. The accessors take a spec and refuse a bare path, which .as_layout() reads as the monolith layout at that path - right for rfp_source(path_gpkg = ), silently wrong for a project directory.

rfp 0.36.0

  • A recorded track is visible, and a track type says what the session was (#196, #186). rfp_qgs_tracking_add() inserted Position track at the layer tree root, last, and into no map theme. The tree is draw order, so last means underneath ESRI World Topo — an opaque basemap that ships checked. Three tracks recorded in the field with full geometry and were completely invisible. It now goes into Project Specific at the top and joins every theme, asserted as an invariant over both templates rather than as a position.

    The field set is reworked from use: stream survey replaces reach walk, track_description is added, and track_type defaults to day log — the normal case, where a track runs all day so anything recorded separately can be found by its time. rfp_qgs_tracking_fields_add(force = TRUE) updates a project that already carries an older version of the set.

    Position tracking with editable fields is now proven end to end on a phone, into a layer rfp created rather than one the Mergin plugin made — which also closes the open item from #139.

rfp 0.35.0

  • A track can be named in the field (#186). rfp_tracking_fields_add() and rfp_qgs_tracking_fields_add() add track_name, track_type and named_by to a position tracking layer and give them widgets, so a crew can say what a session was rather than reconstructing it off a map later. rfp_project_create(track_fields = TRUE) runs both; rfp_project_audit() reports them and warns when the two halves disagree. The set is declared in inst/lookups/rfp_tracking_fields.csv, so a layer’s shape does not depend on where it was created from.

    The capture schema is fixed by the Mergin plugin, which is why this looked impossible — NewGraphEnvironment/trap#14 closed on that premise. Reading the plugin settled it: create_tracking_layer() has one invocation, unreachable for an rfp project, and nothing anywhere reconciles the schema. A test asserts that against the plugin’s own source, because an upstream release adding a reconcile path would take the columns silently.

    track_fields defaults to FALSE until a phone confirms the mobile app opens an attribute form for the tracking layer at all. Existing calls are unchanged.

rfp 0.34.0

  • Position tracking survives project creation (#139). rfp_project_create(tracking = TRUE) writes tracking_layer.gpkg, declares a Position track layer, and switches the three Mergin/PositionTracking properties on — no QGIS Desktop round trip. Tracking has worked in the field since 2026-08-15, but only because one project was configured by hand; every new project started disabled.

  • rfp_tracking_layer_create(), rfp_qgs_tracking_add(), rfp_qgs_tracking_set() and rfp_qgs_tracking_get(). The three properties are written together, because TrackingLayer holds a layer id and a project with tracking on and no layer records nothing and reports nothing. rfp_project_audit() warns on exactly that state.

  • The schema is fixed by the app, so it was recovered from a layer the Mergin plugin made rather than designed — LineStringZM, EPSG:4326, z and m registered, and the four default expressions. A test compares those expressions against the installed plugin’s source: @mergin_username works today, upstream master writes @mm_username, and picking wrong leaves tracked_by a silently empty column.

  • inst/extdata/srs/EPSG_4326.xml — no layer in either template uses that CRS, so a <srs> block had to ship, the same way #161’s basemaps needed theirs.

  • The tracking keys stay out of rfp_project_settings.csv: that file holds fleet defaults and TrackingLayer is per project.

rfp 0.33.0

  • A QGIS template is now a build output. data-raw/qgs/build_blocks.R generates all five blocks a template is made of, and data-raw/qgs/roundtrip_check.R reproduces both shipped templates canonically — 54/54 and 59/59 maplayers, 31/31 root elements, no theme drift. Adding a layer is one CSV row: measured, one maplayer and one tree entry added, all 54 existing layers unchanged. (#174)

  • .rfp_form_styled_fields() is the one definition of “a styled field” — the four surfaces that decide what a field crew sees. editable, labelOnTop and reuseLastValue are deliberately excluded: they are per-field booleans, both templates carry stale ones, and an entry naming a field that does not exist is inert. A guard that could not tell them apart refused both shipped forms and blocked the build. (#191)

  • The form style store ships at inst/extdata/styles/forms/, derived from the templates and guarded against drift from the forms it describes.

  • Fixed: rfp_qgs_service_add() wrote encoding="UTF-8" on every service’s <provider>. Measured: OGR-backed layers carry it, wms and gdal services carry no attribute, and arcgisfeatureserver carries an empty one.

  • restamp_templates.R stays. The build reproduces a template canonically, not byte-for-byte, and the shipped files are still QGIS-authored.

rfp 0.32.0

  • Four of the five blocks a QGIS template is built from are now generated from the registry (#174, PR 2 of 2). data-raw/qgs/build_blocks.R generates the layer tree, snapping-settings, layerorder and the visibility presets, and data-raw/qgs/roundtrip_check.R reproduces both shipped templates canonically with every layer id re-derived. projectlayers reaches 51 of 54 maplayers with no attribute or content differences and stays injected, blocked on #191.

  • The roster carries what the node authors cannot derive: geometry, authid, wkb_type, simplify_hints, the datasource verbatim, tree state, and which themes each template ships. Four layers per template are Multi* variants whose geometry still reads Point or Line, and two datasources carry SQL |subset= filters that a composed path would drop.

  • .qgs_maplayer_attrs() gained the non-spatial vector shape - an attribute-only table is a third node kind, not a vector with an odd geometry.

  • Fixed: rfp_qgs_service_add() wrote encoding="UTF-8" on a service’s <provider>, where every raster and service in both templates carries none.

  • Found: both templates’ form styles name fields their tables do not have, so those widgets have never drawn (#191).

rfp 0.31.1

  • The template build harness (#174, PR 1 of 2). A template is a 4 MB .qgs treated as an opaque source artifact; #174 makes it a build output. This lands the machinery and its proof, without generating a layer yet: data-raw/qgs/extract_skeleton.R derives the ~32 KB of project chrome a build starts from, .qgs_remap_ids() re-points every layer reference, and compare_built() compares a built template against the shipped one keyed on layer name rather than on ids QGIS mints at random.

  • data-raw/qgs/roundtrip_check.R is the falsifiable part: it takes a shipped template apart, puts it back, replaces every layer id with a derived one, and requires the result to compare identical. Both templates pass — 54/54 and 59/59 maplayers, no theme drift, no dangling references gained.

  • Both shipped templates are unchanged, and a test pins that.

  • A standing cross-repo guard asserts that gq’s themes.csv and rfp’s rfp_qgs_themes.csv still agree once bridged by the layer display name. They were derived independently and agree on all nine themes.

rfp 0.31.0

  • Map themes are data. A theme lived only inside one project’s .qgs — it could not be reused, given to a second project, or restored by a rebuild. inst/lookups/rfp_qgs_themes.csv and rfp_qgs_theme_groups.csv now describe every theme both templates carry, plus Imagery; rfp_qgs_theme_add() writes named themes into any project, and rfp_project_create(themes = ) applies them at creation. The registry is lossless — strip every theme from a template, rebuild from it, and all nine come back exactly, group row for group row. Derived by data-raw/qgs/extract_themes.R, never hand-edited. (#178)

  • An existing theme is left alone rather than replaced, unless force = TRUE. Writing a theme regenerates it from scratch, so a re-run could silently flatten one someone curated in QGIS Desktop. This is also what makes the full-registry default on rfp_project_create() safe: a template’s own themes are skipped and only what it lacks is written.

  • rfp_qgs_theme_set() is unchanged, but now warns when a replacement would drop group or legend state the incoming definition does not carry.

  • The project-creation vignette’s rebuild section is corrected: map themes are now restorable; layer-tree placement and order are not.

rfp 0.30.1

  • Documented what a manifest rebuild does not restore. rfp_project_create(layer_config = <manifest>) reconstructs the layer set — and from that, the new project’s manifest, rfp_project.json and a trimmed .qgs. It does not reconstruct map themes, changes to what a theme shows, or layer-tree placement and order for anything added after creation; those live only in the source project’s .qgs. Stated on the reference page and in the project-creation vignette. Seeding a new project is unaffected — there is no arrangement to lose. See #178 for making themes portable.

rfp 0.30.0

  • The templates’ vector styles are now a committed style store. inst/extdata/styles/vector/ holds 50 styles plus 3 per-template overrides, extracted from the shipped templates by data-raw/styles/export_template_styles.R. Rasters and services already had theirs; forms stay owned by rfp_form_build(). Guarded by test-rfp_qgs_style_store.R: no source binding, every block starts at flags, and every template vector layer resolves to a style.
  • Styles are keyed by layer name, not table — bcfishpass.streams_vw backs four differently-styled layers, so a table key would keep only one of them.
  • data-raw/qgs/extract_roster.R derives each template’s layer roster, which is also the layer-key to display-name bridge. Staged under data-raw/; not yet shipped.
  • The project-comparison canonicalizer now recurses into escaped XML held in attribute values. QGIS serialises a label’s symbol that way, so two identical styles could compare unequal on attribute order alone — 21 of 45 shared layers appeared to differ where 3 actually do.

rfp 0.29.0

  • Remote services are data. rfp_qgs_service_add(), rfp_qgs_service_rm() and rfp_qgs_services() write xyz tile basemaps, WMS and ArcGIS FeatureServer layers from inst/lookups/rfp_qgs_services.csv. Six were baked into the shipped templates with no way to add a seventh or move one without QGIS Desktop — the last layer category in that position, after rasters, forms and plain vectors. rfp_project_create() gains services =, defaulting to all six so nothing changes for existing callers.
  • #162: the basemap no longer fetches tiles on every theme switch. ESRI World Topo is now visible="0" in every map theme of both templates. Present-and-off rather than removed, so a crew can turn it on for one lookup without the next theme switch discarding that; the layer tree’s checked state is deliberately untouched.
  • A <srs> block can now be resolved for a CRS no layer in the project uses yet — rfp ships the blocks, since the srsid inside is QGIS-internal and cannot be synthesised. rfp_qgs_raster_add() benefits from the same escape.

rfp 0.28.0

  • rfp_qgs_vector_add() writes a background vector layer and its tree entry into a project, so a vector layer added after creation no longer needs a QGIS Desktop round trip. Rasters have been writable headlessly since v0.19.0 and forms since v0.24.0; this was the last kind still in that position.
  • Both templates ship a styled Trails layer. osm.trail has had no symbology since it arrived in v0.27.0, so it drew as a default thin line everywhere. It is now categorized on highway — the only tag populated on every feature — with classes distinguished by colour, pattern and width so the map reads in printed greyscale and on a phone in sun, and labels from name limited to 1:25000 and closer since only 15.3% of features carry one. Verified in a pinned QGIS container against a real 9,106-feature layer.
  • The style is generated by lifting a QGIS-authored renderer (data-raw/styles/build_trail_qml.R) rather than hand-written, and lives in the templates because rfp_project_create() styles a project from its own .qgs — so every project created from now on gets it, and gq extracts the same style into its registry for tmap and mapgl.

rfp 0.27.1

  • osm.trail no longer captures sidewalks and road crossings. OSM classes road furniture as highway=footway, so the v0.27.0 selector pulled it in alongside trails — measured over one project AOI, 8091 of 13,245 features were footways at a 23 m median length and 5.7% named, about a third of the layer syncing to field phones as town geometry. Verified against the live service: the old selector returns 777 ways over a town-centre bbox of which 688 are furniture, the new one 89 with none. footway joins the declared tags so what the exclusion leaves behind is inspectable; ways with no footway subtag are kept deliberately, because a paved accessible trail in a regional park is a footway with surface=asphalt.

rfp 0.27.0

  • osm is a refreshable source_type. Trails reach no project any other way: measured on the Hornby AOI, FTEN_RECREATION_LINES_SVW, FTEN_REC_TRAILS_SVW and the heritage trail register each return zero — they cover forest recreation tenure, and park, regional and community trails fall outside it. OpenStreetMap returns 167 ways carrying 67 distinct names plus the bicycle/foot tags that separate a bike network from a hiking one. Adding the tier needed no per-type branch anywhere downstream, which is the first exercise of the extension point rfp_manifest_types() has always advertised.
  • The column set is declared, because OSM’s is not. The same selector returns 17 distinct tag keys over one project area and 45 over another, and neither contains the other — so a response-derived schema would make a layer’s shape depend on where the project is, which geodiff cannot diff and a two-column manifest cannot reconstruct. inst/lookups/rfp_source_osm.csv declares the tags: an absent tag is an all-NA column, never a missing one. That lookup, keyed by source_layer, is also where an osm layer’s selector and geometry live, since a manifest row is two columns and the validator refuses a third.
  • An Overpass outage costs trails, not the refresh. The API rate-limits at 2 concurrent slots per IP, so requests are paced as well as retried and fall back across endpoints. A total failure warns rather than raising: a refresh during an outage leaves the previous layer exactly as it was on disk and lets rfp_project_update() finish, where raising would abort it after the other passes had run.
  • ODbL is recorded where each consumer meets it — the lookup, the layer’s gpkg_contents.description (what QGIS shows in Layer Properties), and the rfp_tracking provenance row. OSM is the one source type carrying downstream conditions; bcdata, fwa and aws are OGL-BC.
  • rfp_source_test()’s source_types is now derived from rfp_manifest_types() rather than hardcoded, so a future tier is covered without a second edit.
  • rfp_source() no longer reports success when every layer failed.

rfp 0.26.0

  • pscis and fiss_site are schema-built. Both came from an upstream RISC/MoTi spreadsheet, so their GeoPackage and their field configuration had no common source and had drifted: PSCIS styled 89 fields against a 100-column table, FISS Site 145 against 150, and both named fields that do not exist. QGIS drops a form element naming an absent field silently, so four FISS photo widgets, one PSCIS notes field and a GENERAL tab entry were configured and never drawn — in every project created. Both now generate from a schema CSV like the other six, which makes that drift unrepresentable.
  • The GeoPackages did not move. fpr and the provincial PSCIS submission format depend on their column names, order and types, so the schemas were derived from the shipped files rather than written, and a rebuild leaves both byte-identical. A test asserts it, and asserts the build actually ran.
  • A form’s label is a registry column. label_expression in rfp_form_types.csv, rendered through inst/extdata/forms/labels/simple.qml and its {{expression}} placeholder — the same shape as symbols/ and {{color}}. Labelling needed to move with the forms because the builder always rewrites the QML, so a hand-added block would not survive a rebuild.
  • The schema vocabulary grew to cover what those two forms use: a boolean column type, checkbox and range widgets, nested Tab > Subgroup tabs, an order column for layout position independent of column order, a blank group for a field that is deliberately not on the form, and constraint / constraint_desc. Existing schemas read unchanged.
  • QGIS in a pinned container confirms both forms open valid, in CRS, with their tabs, every photo slot an ExternalResource, and labelling on with its expression.

rfp 0.25.1

  • The shipped form GeoPackages now ship empty. Six of the twelve carried a single seed point at hardcoded UTM coordinates inherited from the dff-2022 build scripts, often hundreds of km from any project AOI — so zooming to a form layer landed nowhere useful and the first row in the attribute table was one nobody entered. What makes a GeoPackage layer spatial is its gpkg_geometry_columns entry, not a feature; QGIS confirms an emptied form loads valid, in CRS, with its tabs and widgets.
  • Fixed: the data-raw/ scripts read the installed rfp rather than the source tree they operate on. The installed one here was v0.9.0 — sixteen releases behind — whose form registry was missing a form and whose source-tag list was missing three tags. Shipped artifacts were verified unaffected.

rfp 0.25.0

  • Inspect and fetch from Mergin without cloning. rfp_mergin_workspaces(), rfp_mergin_list(), rfp_mergin_files() and rfp_mergin_file_get() read the server directly. newgraph/hornby_2026 is 327 MB across 53 files and its .qgs is 2.2 MB, so reading a project file out of a clone costs about 150x what the task needs.
  • The Mergin workspace is now discoverable. rfp_mergin_workspaces() reports the role held in each, so the default is the one this account owns — no local project required. Reading it out of a cloned project’s .mergin/mergin.json was circular for the case that mattered.
  • A fetch verifies the file’s checksum, not merely that something arrived. The download treats a short read as end-of-file, so a truncated transfer would otherwise write a plausible file; a mismatch now deletes the partial and errors.
  • rfp_mergin_list() reports has_conflict, which report harvests have been inferring by substring-matching "conflicted" in filenames.
  • Fixed: download-file checked whether a path existed at HEAD regardless of the version requested, so fetching a since-deleted file failed with an error about rfp_mergin_diff. It resolves against the requested version and names what is present there. --version now defaults to HEAD.
  • Internal: the regex over mergin list-projects tabular output is gone. It discarded the size and version the output already carried; rfp_mergin_collaborators() now uses the API path, so there is one project lister rather than two.

rfp 0.24.1

  • noData is a source element, not a style one — settled by asking QGIS rather than inferring from rfp’s own exports. Each reference node in inst/testdata/nodes/ now ships beside the .qml QGIS itself wrote for the same layer, so maplayer − sidecar is the source block as the application draws it. A raster’s sidecar carries no noData and begins at flags, exactly as a vector’s does.
  • The boundary therefore falls in the same place for every layer type, one positional list covers all of them, and rasters join the source-tag assertion they had been excluded from. rfp_qgs_style_export() is ready for #135 to lift its vector-only gate without leaking a nodata binding into every exported raster style.

rfp 0.24.0

  • A field form no longer needs QGIS Desktop to become collectable. rfp_qgs_form_add() writes the form’s <maplayer> into a project with surgical xml2 edits, leaving the project at whatever version QGIS stamped it, and creates the GeoPackage and its style beside the project when they are not already there.
  • rfp_project_create(forms = ) now places every form it creates. It used to mean “create these GeoPackages” while the .qgs declared only the two the templates ship, so asking for any other type produced a file no field crew could see, with nothing reported.
  • Form layers are named " Form <label>", leading space included, which sorts the blank forms to the top of every layer list and dropdown — the convention every field project already follows. The display name comes from a new label column in the form registry.
  • edna and monitoring now ship a .qml, harvested from field projects with rfp_qgs_style_export() and verified field-for-field against the GeoPackage they style. fhap and fish_sample still have none and are added bare with a warning.
  • Fixed: .qgs_maplayer_attrs("vector") returned 10 of the 20 attributes QGIS writes for a vector layer. The oracle’s attribute assertions ran over the raster reference only, so nothing could see it.
  • Fixed: a .qml’s root attributes are now read back onto the maplayer. labelsEnabled lives there and defaults to off, so a layer whose style carried a full <labeling> block drew no labels.
  • QGIS spells a line layer geometry="Line" with wkbType="LineString", and minScale as 1e+08 on a raster but 100000000 on a vector. Both are now matched rather than guessed.
  • Known issue: the templates’ own PSCIS and FISS Site styles have drifted from the form GeoPackages and name fields that no longer exist, so some photo widgets are silently dropped by QGIS. See #159.

rfp 0.23.0

  • QGIS now checks rfp’s XML. data-raw/qgs/author_nodes.R runs a digest-pinned QGIS 4.2.1 and commits what it writes for a layer to inst/testdata/nodes/; the suite compares rfp’s hand-authored <maplayer> against it. Docker regenerates those references when the pin moves and gates no test, so the suite still runs without it.
  • A raster added by rfp_qgs_raster_add() now carries noData, map-layer-style-manager, layerType, minScale, maxScale and legendPlaceholderImage, and the current spelling of the auto-refresh attribute. None of those gaps were visible from rfp’s own tests.
  • Fixed: the layer-tree entry declared providerKey="gdal" for every layer, including vectors. It now reads the layer’s own provider.
  • The never-re-save rule is corrected: what it forbids is re-saving at an unpinned version. A digest-pinned container is a legitimate tool, and reading the rule as a blanket prohibition kept this approach off the table.

rfp 0.22.1

  • Reading a project’s mergin-config.json no longer fails when the file holds a key whose value is not a scalar. Mergin owns that file’s vocabulary, and an array or nested object is ordinary JSON — rfp_project_audit() swallowed the failure and reported the whole settings section as unreadable, losing the QGIS half as well.

rfp 0.22.0

  • Project settings are now data, applied at creation and checked by the audit. inst/lookups/rfp_project_settings.csv records each setting with the evidence for its value; rfp_project_settings_apply() writes them into a project’s .qgs and mergin-config.json, and rfp_project_audit() reports drift. Changing a convention is a one-cell edit. Photos now capture at Mergin’s low quality (~0.5 MB) rather than original size — measured at 11.3 MB a photo in the field — and the measurement ellipsoid is set, so $length and $area expressions are ellipsoidal.
  • ignore_mobile/ is held back from field devices on every new project (#92). rfp_project_create() created the directory but shipped nothing excluding it, so it synced to every phone — 21 MB on a fresh project and hundreds of MB once photos land in it — and rfp_project_slim() refused to write its office GeoPackage until the config was hand-written.
  • Applying settings is idempotent to the byte, so an already-correct project costs nothing to sync; the writers touch only the keys the lookup names and leave the rest of both files exactly as found.
  • Writing project settings requires QGIS 4.0 or newer. Reading is unchanged and still tolerant across 3.30–4.2, so the audit reports on a project it cannot repair.

rfp 0.21.0

  • Projects are now created at QGIS 4.2.1, and creation refuses to regress. The shipped templates were stamped 3.30.1 (Aug 2023), so every project inherited that stamp — rfp_project_create() now errors on a template below QGIS 4.0. Reading is unchanged and stays tolerant across 3.30–4.2; the pin governs creation only.

    Two consequences worth knowing before you upgrade a project:

    • A new project’s settings are invisible to QGIS 3.x. QGIS 4.0 changed how project properties are stored, and the change is not backward-readable: a 3.x QGIS opening a 4.x project sees no project properties at all — no ellipsoid, no Mergin photo quality, no position tracking — and writes that emptiness back if it saves. This is not new to rfp (it already applies to the 4.0.0 projects in the field), but it now applies to newly created ones. Use QGIS 4.x on the desktop.
    • New projects no longer carry the legacy <legend> block, which QGIS 4.2 dropped. Nothing in rfp requires it; the functions that read it all check for its presence first.

    Re-stamping is reproducible: data-raw/qgs/restamp_templates.R runs a digest-pinned qgis/qgis:4.2 container and refuses to write unless a comparator confirms every layer, theme and project property survived.

  • rfp_project_templates() gains a qgis_version column.

rfp 0.20.0 (2026-08-15)

  • Fixes “From gallery” on every photo field. The shipped photo widget set DefaultRoot to an expression (@project_folder + '/…'). That option takes a literal path, so QGIS read it as a directory name that does not exist, which breaks the gallery picker in Mergin Maps while the camera keeps working — the camera resolves through the data-defined propertyRootPath, and that one does evaluate expressions.

    Affects all six schema-built forms, not just the new one. Found by comparing against the dff-derived forms used in the field for years: across Form PSCIS and Form FISS Site, 19 of 20 photo widgets set no DefaultRoot at all.

    Regenerate deployed forms to pick this up — or, for a form that already holds data, copy the new .qml beside its GeoPackage.

  • New trail_feature form — point observations along a route: what is here, its condition, whether it is passable, exposure, action needed, and five photos with a caption each (photo_N / photo_tagN).

    A Mergin position track records where someone walked and when, and nothing else. The app never opens a form for the tracking layer, and a GeoPackage LineString vertex carries only X/Y/Z/M — already spent on the timestamp — so per-vertex attributes are not expressible. Observations are separate points, joined to a track on time.

    Scoped to observations rather than trail classification: condition and exposure describe the spot you are standing on, not the trail. Segment-level vocabulary belongs on a derived centreline that does not exist yet.

  • Two time fields, on purpose. date_time_start defaults to now() for humans. time_epoch defaults to round(epoch(now())/1000) — UTC seconds, the same clock as a Mergin track’s M ordinate — so the two join with no timezone reasoning. What Mergin writes for now() on a device is not pinned down anywhere, which is the ambiguity #10 records.

    The round() wrapper is load-bearing: the form builder leaves a default unquoted only when it ends in ), so the unwrapped expression would warn and pass through rather than apply cleanly.

    No code changed to add this form — a schema CSV, a registry row and two generated artifacts, which is the standard rfp_form_* contract.

rfp 0.19.0 (2026-08-14)

  • A raster style and its companion in one call. rfp_qgs_raster_add(companion = TRUE) adds the style the registry already names — dem_turbo names dem_hillshade — as a second layer over the same raster, drawn above the first. A DEM reads as terrain only with a hillshade over it, and QGIS reads one .qml per .tif, so a pair can exist only where a project is written. Opt-in, because asking for a DEM should not silently produce two layers.

    The companion is named "<label> - <name>" from a new label column, or by name_companion =. Its stretch comes from its own registry row: inheriting the caller’s would run an exact -stats pass a hillshade discards, and could fail the hillshade on a nodata guard that only concerns the elevation.

  • Fixes a writer that refused to run on real projects. Print layouts carry layer trees naming layers a project no longer has — 7 in one project here, 10 in another, before anything is inserted — and QGIS tolerates them. The reference check read that absolutely and reported the insert had left references it never touched, so adding a raster to either project was impossible. It now compares across the insert.

  • The two ordering lists stay in step. <layerorder> and <custom-order> are one list in two shapes, and writing one alone is how a project ends up with layers in the first and not the second. Both are now written together, the path is anchored to the project’s block rather than a print layout’s, and a pair is anchored so the layer tree, <layerorder> and <custom-order> all agree — measured in the QGIS container, an unanchored pair inverts the moment anyone enables custom layer order.

    Draw order itself comes from the layer tree whenever custom-order is disabled, which is every project tested across QGIS 3.30–4.0. See ?rfp_qgs_raster_add.

rfp 0.18.0 (2026-08-15)

  • A raster’s colour stretch is computed and written consistently. rfp_qgs_raster_add() gains style = (a [rfp_raster_styles()] key, which carries the stretch rule the bare path discarded) and stretch =, taking "from_data", "none", or an explicit c(min, max).

    Two DEMs that must agree need the same explicit range: "from_data" gives each its own, which is exactly the seam this fixes. A 1 m detail tile inside a 2 m context tile was rendering discontinuously because one was stretched 0-280.6 and the other 0-300.9.

  • Fixes a rendering bug in the shipped dem_turbo style. A stretch lives in five places — two renderer attributes, two shader attributes, and the value and label of every one of 255 colour stops. The shipped file declared 0-300.9 while its stops ended at 280.6, so everything above 280.6 had no colour and clamped to the last stop. An invariant test now asserts all five agree for every shipped style, and it was confirmed failing before the fix.

    A style with no shader is left alone: a paletted renderer’s palette entries are class codes, and rescaling them would be corruption rather than restyling.

  • Statistics are read exactly (-approx_stats varies by GDAL version and would make the written QML non-reproducible), with GDAL_PAM_ENABLED=NO so no .aux.xml lands beside the raster, and a guard that refuses a range whose minimum looks like an undeclared nodata fill — the flat-render failure this work exists to prevent, arriving through the fix.

rfp 0.17.0 (2026-08-14)

  • A raster can be added to a project without a desktop round trip. rfp_qgs_raster_add() inserts a <maplayer> and the other surfaces a layer id must appear on, using surgical xml2 edits that leave the project’s version stamp alone. That matters because no available headless QGIS matches the version the field projects are saved at, so project.write() either downgrades or upgrades the file.

    Every test in it is a failure a hand-rolled prototype produced on a real project, two of which reached the Mergin server: a CRS taken positionally (the layer loaded valid, with the right renderer, and would have rendered in the Gulf of Guinea), a layer group created rather than found (two groups of the same name, duplicated in the tree but not the legend), a layer added outside every map theme (hidden whenever a theme was applied), and a non-idempotent write. Adding the same raster twice is now a no-op that leaves the file byte-identical.

  • Raster styles are data. rfp_raster_styles() is a registry of shipped .qml files following the same contract as rfp_form_types() - a style is a row plus a file, and registering it is what makes it reachable. Three ship: dem_turbo, dem_hillshade, and habitat_lateral, the last lifted out of the templates where it was already styled.

    Only the knobs that vary per project are columns; everything that decides the look stays in the .qml. #17 recorded why - a registry that models ~20 symbology properties against the 73 QGIS carries loses the difference.

  • Styles can now be written as sidecars beside .tif/.vrt rasters. The sidecar path was already generic over file type; an extension whitelist was all that kept rasters out.

  • Fixes a source-binding leak in every exported style (#130): the positional style-block boundary needs the source-tag list to be complete, and keywordList/attribution/metadataUrls were missing, so QMLs exported from either template carried layername, srs and provider.

rfp 0.16.0 (2026-08-14)

Breaking. The map-theme API collapses to read / set / rm, and the QWC2 config function moves out of it. Nothing outside rfp called the deleted pair; rtj’s four call sites for the rename are tracked in NewGraphEnvironment/rtj#204.

Was Now
rfp_qgs_theme_export() + rfp_qgs_theme_apply() rfp_qgs_theme_set()
(no exported way to remove a theme) rfp_qgs_theme_rm()
rfp_qgs_themes_config() rfp_qwc_config_create()
  • A theme can now be built from nothing but layer names — a data frame of theme and layer. The groups holding its visible layers are checked, ancestors included, because QGIS hides a layer when any group above it is unchecked. Setting a theme that already exists replaces it, which is what makes the function usable as an edit.

  • rfp_qgs_theme_rm() gives removing a theme an exported path. Trimming a real project from five themes to two previously went through an unexported internal, and theme_apply()’s own docs sent you to QGIS Desktop mid-workflow. Takes themes (drop these) or keep (drop everything else), validates names against the project first, and does not touch the file when nothing matches.

  • Porting a theme between projects now preserves what it meant. The pair this replaces copied a theme’s group paths verbatim, but those paths are rooted at a project-specific top-level group name, so a theme ported into a differently-named project carried paths matching nothing; QGIS ignored them silently and the group state was lost. Paths are resolved against the destination: exact match, else re-rooted, else dropped and reported.

    Beyond that, a preset carries more than layers and groups, and rfp_qgs_theme_set() rebuilds it from scratch — so anything unread was destroyed on write. Per-class legend visibility (checked-legend-nodes), legend expansion state, and the has-expanded-info / has-checked-group-info flags that decide whether QGIS applies a theme’s group state or ignores it are all carried now. The full element vocabulary was taken from QGIS’s own binary rather than inferred from the projects to hand, since one of these elements appears in none of them; CLAUDE.md records the extraction command and what it found.

  • rfp_qgs_themes() returns four data frames — layers, groups, legend and themes — rather than counts, in the shape theme_set() accepts, so porting is read-then-set with no intermediate file. rfp_qgs_theme_names() is the name-only read. Pass the whole list: subset(), df[, cols] and merge() would drop per-theme metadata if it were carried as attributes, which is why it is a table.

  • The qwc_config.csv schema is unchanged; rfp_qwc_config_create() is a rename only.

rfp 0.15.0 (2026-08-13)

  • A project records the template it was created from, in rfp_project.json at the project root. rfp_project_update(), rfp_project_audit() and rfp_manifest_create() resolve it from there instead of defaulting to bcfishpass_mobile, so they stop working from the wrong registry when you do not pass template =.

    The cost of that default was concrete: on a restoration project rfp_manifest_create() recorded whse_basemapping.cwb_floodplains_bc_area_svw as frozen — excluded from every refresh, a hard error later if it ever went missing, and unrepairable because the manifest refuses to regenerate. The audit reported the same layer as one to consider deleting.

    Resolution is explicit argument, then the record, then the previous default, so an explicit template = always wins and a project created before this behaves exactly as it did. Nothing is inferred — the two templates are not distinguishable from a project.

  • A layer added to a project after creation now arrives styled. This closes the gap left open in 0.13.0. The project .qgs is trimmed to the layers it held at creation, so it cannot supply a style for a layer added later; the recorded template can. That second style source is scoped to layers the project file does not cover, which is what keeps it from overwriting symbology restyled in QGIS Desktop, and leaves an unchanged project byte-identical across runs.

rfp 0.14.0 (2026-08-13)

  • rfp_project_create() writes the project manifest — rfp_manifest.csv at the project root — so a project starts inside the lifecycle rfp_project_update() and rfp_project_audit() depend on, instead of running in registry mode until someone backfills one with rfp_manifest_create(). Registry mode refreshes against a template that merely defaults to bcfishpass_mobile, cannot add a layer declaratively, and offers no frozen-layer protection.

    The manifest records intent, so it is derived from layer_config rather than from what landed on disk. A layer requested but not present — empty in this area, or a download that failed — stays listed deliberately: the audit reports it as informational and the next update adds it, which is the same reasoning that keeps such a layer in the project .qgs.

    It is also a valid layer_config, so a project can be rebuilt from one: rfp_project_create(layer_config = rfp_manifest_read(path)).

  • rfp_project_create() now rejects a layer_config missing source_layer or source_type before creating anything. Such a config previously downloaded nothing and still reported success.

  • The column keep-list sidecar is not written. Nothing can seed one — layer_config carries no column-level information — and rfp_project_slim() errors on an empty sidecar exactly as it does on an absent one, so a stub would be the same failure plus a misleading file. See the note on #90.

rfp 0.13.0 (2026-08-12)

  • The background GeoPackages rfp_project_create() and rfp_project_update() write now carry their own layer_styles, taken from the project’s .qgs. QGIS reads that table on Add Layer and never on project open, so this is what makes a layer arrive styled when it is added to another project or re-added after a refresh. Previously every GeoPackage rfp wrote was unstyled and had to be styled by hand.

    Styles are lifted after the layer set is reconciled, so a project carries styles only for layers it holds, and a table styled several ways keeps all of them — bcfishpass.streams_vw carries four, opening as Streams - all.

    A layer the project .qgs does not reference gets no style: it has to be added in QGIS Desktop to display at all, and the next update styles it. The shipped template is deliberately not used as a second source, because nothing in a project records which template it came from.

    Re-styling an unchanged layer leaves its file byte-identical, so this does not dirty a project for the next Mergin push. Styling failure never aborts a create; the project is written unstyled with a warning.

  • A datasource naming a remote service no longer resolves to a table name. rfp_qgs_style_export() read an ArcGIS FeatureServer URL ending /0 as the table 0', exporting a style for a layer that can never own a layer_styles row.

rfp 0.12.0 (2026-08-12)

  • rfp_project_create() now reconciles the project .qgs to the data it actually downloaded, so a narrowed layer_config yields a project that opens clean instead of one reporting dozens of missing layers. Measured in the qgis/qgis container on bcrestoration_mobile with four tables present: 58 declared layers of which QGIS could not load 45, against 13 declared, 13 loaded and 0 bad after. This also fixes the default full create, where nine template layers across the two templates point at tables the gq registry never downloads (NewGraphEnvironment/gq#40).

    A layer is dropped only when its datasource resolves to a file inside the project that is not there. Anything rfp cannot resolve locally — basemaps, WMS, and any source added to gq later, such as a STAC floodplain or orthophoto collection — is kept without a change to rfp. A layer that was requested but failed to download is kept and reported rather than trimmed, so a transient outage stays recoverable by a refresh.

    Because layer_config selects tables, a table backing several styled layers keeps all of them; use rfp_project_subset(layers = ) to choose among them.

  • rfp_project_subset() no longer leaves dangling layer references behind. It pruned references one element type at a time and handled five of the eight places bcrestoration_mobile.qgs cites a layer, so custom-order, per-layer snapping settings and the GPS destination layer survived every subset. These are now swept generically, and the tests assert the property — no reference to a removed layer anywhere in the document — against both shipped templates.

rfp 0.11.0 (2026-08-05)

  • Ships the current provincial Fish Data Submission template, FDS_Template2026-03-11.xlsx. The version previously carried, FDS_Template2023-05-03.xls, was roughly three years stale and in the legacy .xls format that openxlsx cannot open — loadWorkbook() fails on it with “subscript out of bounds” but opens the .xlsx, which is what makes programmatic population of the submission possible at all. Column sets are identical across all four step sheets (16 / 40 / 20 / 93, same names and order, compared at absolute header rows with tidyxl), so adoption is a drop-in for callers such as fpr::fpr_import_hab_con(). The 2023 template is retained — past submissions were built on it (NewGraphEnvironment/dff-2022#94).

rfp 0.10.0 (2026-08-03)

  • A project’s styling can now be put inside the data losslessly, so a layer arrives correctly styled wherever it is added or read without a project — QGIS’s Add Layer, QGIS Server/QWC2, rfp_map_render() (#17, #15). Note this is a provisioning mechanism, not a rendering one: QGIS queries layer_styles when a layer is added and then stores the style in the .qgs, so a saved project renders from its own style block and never re-queries the data. Verified on desktop and on Mergin Maps mobile. New rfp_qgs_style_export() lifts per-layer QML out of a project, and rfp_styles_apply(path, qgs = ) writes it to the layer_styles table. A .qml turns out to be a project’s <maplayer> node minus its source binding, so this is an XML copy rather than a translation — verified byte-for-byte against a QGIS-authored sidecar. It runs in pure R, needs neither Docker nor qgisprocess, and never writes the .qgs.
  • Styling QGIS from the gq registry is demoted to a fallback for layers no project covers. The registry is extracted from QGIS to drive tmap and mapgl, so feeding it back is a lossy round-trip: on a real project it dropped per-class dash on 56 of 163 classes, labelling on all 24 layers carrying it, and every symbol layer past the first. That path now says it is approximate, reports each field it drops, and skips renderers it cannot rebuild instead of overwriting them with a default symbol. gq remains the source of truth for tmap and mapgl rendering.
  • rfp_styles_apply() handles the one-GeoPackage-per-layer layout recursively, and keeps exactly one default style per table when a table is styled more than once.

rfp 0.9.0 (2026-08-01)

  • New rfp_form_build() promotes the prototype form generator to an exported function, and moves the decisions it used to hardcode into data (#76). A form’s fields stay in its schema CSV; the form itself — schema path, geometry, symbol, colour — is now a row in the registry rfp_form_types() reads. Adding a form, or restyling one, no longer touches R code.
  • The five schema-built forms (vri_qa, transition_qa, exceedance, viewscape, monitoring_fish_passage) are registered, so rfp_form_create("vri_qa", …) works — previously it failed with “Unknown form type” even though the GeoPackage shipped (#81).
  • Form symbology is a per-form choice that survives regeneration (#84). Symbols are QML fragments in inst/extdata/forms/symbols/, seeded with the NGE form marker captured from a field-styled project; the registry picks the symbol and a hex colour. Restyling a form is a one-cell edit.
  • Rebuilding is idempotent. A GeoPackage records its creation time, so regenerating an unchanged form used to churn a ~100 KB binary in git; the builder now compares columns, geometry type and CRS, and rewrites only on a real change. A GeoPackage in the diff means the schema really changed. Since the rewrite replaces the whole file, it refuses when the target also holds another layer or a layer_styles table.
  • rfp_form_create() now copies a form’s .qml sidecar alongside its GeoPackage. For schema-built forms that file is the form — aliases, widgets, tabs, symbology — so the GeoPackage alone deployed a bare table. It refuses rather than overwrite a QML you have restyled in QGIS.
  • test-rfp_form_build.R rebuilds every shipped schema and byte-compares the QML, so a hand-edited generated artifact fails the suite instead of reaching the field.
  • Regenerate all forms with Rscript data-raw/forms/build_forms.R (replaces data-raw/forms/build_form_from_csv.R).

rfp 0.8.1 (2026-08-01)

  • The form builder quotes a literal default for you (#102). QGIS reads a field’s default as an expression, so a bare value-map member like Not required parsed as one, failed, and the default silently never applied — the field just came up empty. A bare default that matches one of the field’s own options is now emitted quoted; expressions (now()), variables (@mergin_username) and already-quoted literals are left alone, so every shipped form rebuilds byte-identical. Options ending in )Early (<40 yr), Sparse (<25%) — are matched before the function-call heuristic, and an embedded apostrophe is doubled (Don't know'Don''t know'). A default matching no option is passed through with a warning rather than guessed at.

rfp 0.8.0 (2026-08-01)

  • Three field forms built with the CSV form builder. form_transition_qa QAs drift IO-LULC land-cover transition polygons — is a mapped change (e.g. Trees -> Rangeland) real, or a classification artifact? (#77). form_monitoring_fish_passage covers effectiveness monitoring at fish-passage remediation sites: the standard crossing photo set plus one written statement per metric, and no numeric ratings — the Peace 2024/2025 data showed the 1-5 scores were applied inconsistently and the reporting pipeline consumed only the statements. Its field names match what tab_monitoring already selects, so it feeds existing reporting unchanged.

  • form_vri_qa grows from 27 to 52 fields: pre-loaded VRI reference values so the claim under test travels with the record and the audit is reproducible against a named extract, sampling provenance (a stratified sample is not an opportunistic stop, and an ocular call at 200 m is not a walked polygon), obs_seral_class in the LRMP’s own age classes, and a Composition tab with species composition and a 3x3 per-species DBH block. VRI has no per-species diameter: pooled these compare to quad_diam_125, and per species they detect the big-cottonwood-over-small-spruce structure a stand-level mean averages away.

  • Fix: a literal default in a schema CSV was written into QGIS’s default-value expression, so a bare value like Not required parsed as an invalid expression and silently never applied. Quoted in all affected schemas; the contract is documented in inst/extdata/forms/schema/README.md.

rfp 0.7.2 (2026-07-31)

  • R CMD check is clean: 0 errors, 0 warnings (#66, #97). Non-ASCII characters removed from every R source, and rfp_source()’s dir_layers / file_gpkg / layout arguments - added in 0.7.0 - are documented.

  • comms/ and research/ no longer ship in the built package. They hold internal cross-repo notes and were reaching the library of anyone installing from GitHub.

rfp 0.7.1 (2026-07-31)

  • Python-backed functions work in an installed package (#93). Every one of them used to resolve its binary from inst/python/.venv, which .Rbuildignore (correctly) keeps out of a built package - so rfp_mergin_*() failed for anyone who installed rfp rather than cloning it, and so did rfp_project_create() / rfp_project_update() on any project with aws layers. Each tool is now resolved independently: getOption("rfp.python_venv"), then the package venv, then a uv tool install, then PATH.

  • uv tool install mergin-client is now the recommended setup. It survives reinstalling rfp, which uv sync into a package library directory does not.

  • rfp_check_deps() reports what rfp will actually call, with the path and the tier it resolved from, instead of probing PATH for tools the package reached by absolute path. Its list is now what the package really invokes: bcdata and fio are gone (nothing called them), wget and unzip added (the aws script has always called both). rfp_mergin_healthcheck() reports the same provenance - useful when something works in the terminal but not in RStudio, where PATH excludes ~/.local/bin.

  • The aws pass no longer puts a venv on PATH or exports PROJ_LIB for the whole shell script. rio and its own PROJ data are passed in and scoped to the single raster call, so ogr2ogr keeps using its own. A missing rio now fails just that one layer instead of the entire pass.

rfp 0.7.0 (2026-07-31)

  • One GeoPackage per layer (#89). A project can now keep each background layer in its own GeoPackage under a background_layers/ directory, rather than all of them in a single background_layers.gpkg. A change then costs one file instead of the whole project: measured on a real Mergin project, a refresh including a 113 MB layer uploaded 784 bytes, and adding a layer uploaded that layer’s file rather than the 135 MB a single-GeoPackage project re-pushed for 28 rows.

  • rfp_project_create() now creates split projects by default. Pass layout = "monolith" for the previous behaviour. Existing projects are untouched and every function reads either layout, so nothing needs migrating.

  • New rfp_project_split() converts an existing project. It deletes the original only after every layer passes row-count, column-schema and style-row verification, and restores the project — project files included — if anything fails.

  • Layout names are configurable at three levels, resolved argument first, then session option, then package default: rfp.dir_layers, rfp.file_gpkg, and rfp.fn_layer_file. The layer-to-filename mapping is a function, so <schema>.<table>.gpkg is a default convention rather than a fixed rule.

  • Fixes: the pre-refresh backup listed the project root without recursing and then verified only what it had found, so a project whose layers sat in a subdirectory would have produced an empty backup and reported success. Splitting a project also dropped any styling stored in layer_styles; those rows now travel with their layer.

rfp 0.6.0 (2026-07-29)

  • Stable-fid refresh (#12 phase 4). Refreshed layers that carry a natural key (shipped in inst/lookups/rfp_natural_keys.csvbcfishpass.streams_vw and bcfishpass.crossings_vw) keep the feature id each row had before the refresh, so a re-download that reassigns ids no longer churns every row. Mergin pushes shrink from the whole GeoPackage to a changeset: measured live at 122 KB against a 387 MB file, with the fat layers contributing nothing at all. rfp_project_update() gains per-table content fingerprints in its return value.

  • The reidentify runs through GDAL end to end. An sf round-trip silently re-declares a layer’s schema against the ogr2ogr-written original (INTEGER ids become REAL, GEOMETRY becomes LINESTRING), which both makes geodiff refuse to diff and writes a type regression into the project.

  • Scope worth knowing: the changeset win applies to refresh — row-level change within a fixed set of tables. Adding or removing a layer changes the table set, which geodiff cannot diff, so it pushes the whole file. Batch structural changes, and fold them into the one-time transition push described below. Projects built with earlier versions carry shell-era schemas; their first refresh on this version also pushes whole-file, and every refresh after it ships a delta. Slim large projects before that first refresh.

rfp 0.5.0 (2026-07-28)

  • Pure-R data-source layer (#12 phases 2-3). rfp_source(type = "bcdata") now runs entirely in R: no Python CLI, no temp files in the project directory, per-layer failure isolation, retry-with-backoff on transient WFS errors, server-side column keep-lists, and deterministic row order. The fwa/aws shell scripts keep running but the AOI is generated in R and passed via a tempdir GeoJSON, so they write nothing to the working directory; aws side effects (habitat_lateral.tif, parameter tables, named streams) are now list-gated rather than unconditional. rfp_tracking is written honestly by the pure-R pass (#13).

  • Correctness fix: the retired shell path built the feature-service lat/lon bounding box by transforming only the two projected corner points, producing an envelope too short north-south. Freshwater Atlas layers and named streams inside the area of interest but near its north/south edges were silently never fetched. The pure-R pass bounds the full polygon and fixes it. Projects built with earlier versions should be re-refreshed to recover the missing features.

  • A local delete+insert refresh probe confirmed that a stable-key changeset pushes to Mergin as kilobytes rather than the whole GeoPackage, motivating the row-identity work above (dev/probe_dml_refresh.R).

rfp 0.4.0 (2026-07-27)

rfp 0.3.0 (2026-07-23)

  • Two Morice LRMP §4.2.8 zero-target field forms built with the CSV form builder: form_exceedance (one dated record per observed feature, with position relative to the floodplain and post-Feb-2007 dating evidence) and form_viewscape (repeat photo-point station for the no-net-change recreation access and zero new viewscape infrastructure objectives). Documents the single-quote-literal-defaults contract for the schema CSVs in inst/extdata/forms/schema/README.md. (#79)

rfp 0.2.0 (2026-07-21)

  • CSV-driven field-form builder: a schema CSV in inst/extdata/forms/schema/ is the git-diffable source of truth, and data-raw/forms/build_form_from_csv.R generates an empty typed GPKG plus a QGIS QML sidecar (field widgets, tabbed attribute form, point symbology). First form built this way: form_vri_qa, for rapid field QA of VRI polygons over river floodplains. Adds research/ notes on VRI reliability and floodplain forest ecology. (#74)

rfp 0.1.9 (2026-07-03)

  • rfp_source() gains an operation argument ("generate" default, "update") exposing the source scripts’ existing update mode: refresh layers in place in an existing background_layers.gpkg (#12 groundwork). Update mode requires path_gpkg to exist — guarded R-side before the scripts run, because rfp_source_bcdata.sh clears *.geojson in the working directory ahead of its own check. Behaviour change for type = "fwa": operation = "update" with a missing GeoPackage now fails instead of silently generating.
  • rfp_source_bcdata.sh now writes the AOI watershed-groups layer as whse_basemapping.fwa_watershed_groups_poly, matching the fwa/aws scripts and the QGIS templates. Previously it wrote a second, unprefixed fwa_watershed_groups_poly copy into every project GeoPackage (#71).

rfp 0.1.8 (2026-05-11)

  • rfp_project_subset() — derive a focused child project directory from a heavy parent by one of three mutually-exclusive selectors (themes, groups, layers). Emits a trimmed .qgs plus table-trimmed copies of each referenced .gpkg (rtree + metadata cleanup, VACUUM), copies in-tree raster sidecars verbatim, and warns on externally-rooted (..-rooted or absolute) datasource refs. Optionally seeds new form GPKGs via rfp_form_create(). Returns a manifest tibble with content-deterministic sha256_content for the daily-cron skip-push contract; dry_run = TRUE returns the manifest without writing (#45).

rfp 0.1.7 (2026-05-08)

  • rfp_qgs_theme_export() and rfp_qgs_theme_apply() — xml2-based theme transfer between .qgs files. Export emits a portable <rfp-themes> document with layer ids rewritten to layer names; apply resolves names back to the destination’s ids. Group nodes pass through unchanged. Format-aware — handles pre-3.32 <visibility-presets> and 3.32+ <map-themes> schemas, renaming child elements as needed on cross-format transfer. Backup .qgs.bak by default; on_missing_layer = c("warn","error","skip") dispatch. End-to-end round-trip live-tested against a real restoration project (#43).

rfp 0.1.6 (2026-05-07)

  • rfp_mergin_create() no longer claims success when the underlying mergin CLI silently fails. Pre-flight refuses if the path already has a .mergin/ scaffold; post-create requires both the Created project <name> line in CLI output and the .mergin/ directory to exist locally. Either failure raises with the captured CLI output for diagnosis (#60).

rfp 0.1.5 (2026-04-14)

Breaking changes

  • rfp_qgs_themes_config() param rename into families: keeptheme_keep, map_themetheme_initial, titleproject_title. CSV field names follow the new R arg names (rtj consumers must update, see NewGraphEnvironment/rtj#61). No deprecation shim — call sites must be updated (#58).

New features

  • rfp_qgs_themes_config() gains bbox_override (explicit "xmin,ymin,xmax,ymax,EPSG:code" string; always emitted as a CSV row with empty value when unset so the schema is stable) and theme_locked (single theme name to lock as active visibility preset on VM-side deploy; no .qgs mutation) (#58, #44).

rfp 0.1.4 (2026-04-14)

  • rfp_mergin_healthcheck() — one call pre-flight check on venv, mergin-client, pygeodiff, auth env vars, and server reachability. CLAUDE.md note on the mergin subsystem boundary (#57).

rfp 0.1.3 (2026-04-14)

  • rfp_mergin_diff() — row-level diff of a GPKG layer between two project versions, date-addressable via from/to. Takes an optional key arg to match rows on a stable business key instead of sqlite’s volatile fid (#55).

rfp 0.1.2 (2026-04-13)

rfp 0.1.1 (2026-04-13)

  • rfp_mergin_collaborators() — build a tibble of (project, user, permissions) for one project, a vector of projects, or every project in a namespace (#11).

rfp 0.1.0 (2026-04-12)

Major expansion: rfp is now the single package for the full field project lifecycle in BC — from data sourcing through QGIS project assembly, Mergin Maps deployment, layer styling, and project maintenance.

New functions

Data sourcing

  • rfp_source() — unified download entry point for BC spatial data (bcdata, fwa, aws) with new layers parameter for registry-driven layer selection (#20)
  • rfp_check_deps() — verify external CLI tools and Docker are available (#19)

Project assembly

Form lifecycle

Mergin Maps integration

Layer styling (Docker-based)

  • rfp_styles_apply() — apply gq registry styles to spatial data via headless PyQGIS in Docker. Format-agnostic (GPKG, GeoJSON, Shapefile) (#23)
  • rfp_map_render() — render styled layers to PNG for vignettes, testing, and previews (#23)

QGIS project maintenance

  • rfp_qgs_rename() — fix duplicate and invalid layer/group names in .qgs files for QWC2 compatibility. Global namespace dedup, comma removal, three-location sync (#39)
  • rfp_qgs_themes() — list map themes in a .qgs project with layer/group counts (#44)
  • rfp_qgs_themes_config() — generate QWC2-compatible config CSV for web viewer deployment (#44)

Infrastructure

  • Migrated Python dependency management from conda to uv (inst/python/pyproject.toml) (#19)
  • Ship QGIS project templates, methods PDFs, provincial form templates, and pre-built form GPKGs (#22)
  • 5 vignettes + pkgdown site (#25)
  • 80 unit tests