Internal engineering plan · R1 of the provider driver programme

maptara_san_provider_base rework

Take the 4 658-line "vendor-neutral" module that contains 106 lines of one vendor's vocabulary and one vendor's cron, and turn it into an actual base — by moving the eKV domain out of it, not by renaming things until the grep is clean.

Drafted 2026-09-18 · module-level companion to the programme report
Evidence: survey A · survey C · module at maptara_san_provider_base/
Revised 2026-09-19 — read this before the rest of the document

Three things changed after the first draft, and together they flip the recommendation.

1. The estimates were padded. They carried buffer for review cycles and migration verification and then presented that buffer as coding time. Re-cut in §5: W1 ≈ 2 h, W2 ≈ 1 d, W6 ≈ 2 h. Phase 1 is 1.5–2 days, not 4. R3 is the only figure in the programme that does not compress — §7 says why.

2. A production-read database is available (confirmed 2026-09-19). The upgrade hazard in §6 is now verifiable instead of theoretical. R1-d is closed.

3. Option B is deferred, possibly permanently. R2 — the driver contract — goes first. The SWOT in §3a settles it in one line: R2 needs no migration at all; B is nothing but migration.

Contents

  1. What the module is today
  2. Model-by-model verdict
  3. The one real decision: split or scrub
  4. SWOT — B against R2
  5. Target layout
  6. Work packages
  7. Migration hazards
  8. The test harness is the plan
  9. Risk register
  10. Deliberately not done

01 · What the module is today

Python
4 658

lines, 23 files

Models / fields
9 / 70

class definitions and field definitions

Tests
112

methods, ~182 asserts, 8 files

Transport code
0

lines. Not a typo.

Four files carry 84% of the weight:

models/maptara_provider.py
819
wizard/maptara_send_ekv_wizard.py
784
models/sale_order.py
508
models/maptara_provider_ref_burn.py
121
hooks.py
74
4 migrations
265
tests (8 files)
1 997
0400800 lines

Red bars are the eKV domain. That is 1 366 of the 2 232 non-test, non-migration Python lines — 61% of the module doing something that has nothing to do with being a provider base.

The leakage, in one table

169 lines match egeko|eGeKo|Egekonummer|optadata|X01|A01|Sonstige; 106 outside tests/, in 10 of the 14 non-test Python files. Only four non-test files are clean: two __init__.py, the confirm wizard, and one migration.

KindExamplesCost to fix
Structural — renaming Egeko breaks it post_init_hook_egeko (__manifest__.py:18, __init__.py:2, hooks.py:11); _ensure_egeko_cron (hooks.py:25); the cron record itself (hooks.py:54); _auto_init re-creating it every -u (sale_order.py:328-330); action_new_egeko_code bound from XML (wizard:498 + views:44) Needs a migration — the cron is noupdate
Vocabulary — cosmetic but user-visible Labels 'eGeKo KV-Nr', 'Let eGeKo assign the KV-Nr'; chatter "eKV NOT accepted — no Egekonummer returned" (maptara_provider.py:739); the eGeKo web-viewer footer (:761-763); "sent as X01 (Sonstige)" / "A01 for a prescription" (wizard:177-180); 8 XML column labels Cheap, plus i18n/de.po churn
Assumptions — no vendor name, all vendor The poll-then-fetch-documents sequencing is Egeko's three-call protocol, in base, with 17 "Egeko cron:" log lines (sale_order.py:418-502); the activity document_type selection is Egeko's operation list (maptara_provider.py:499-507); _get_ref_family is built around opta data's German refusal string and their base/base-1/base-2 collision scheme (wizard:434-496); EKV_APPENDIX_MIMETYPES is a capability declaration wearing a constant's clothes (:22-24) This is the actual work

And the one that should settle the argument: views/sale_order_views.xml:25 is a comment in base's XML citing maptara_san_provider_egeko/models/maptara_provider.py:211 by file and line. The base documents itself against its own plugin. 🫠

02 · Model-by-model verdict

ModelFieldsVerdictReasoning
maptara.provider.document.type
maptara_provider.py:64
4 keep in core "A document type maps to a provider code" is precisely the driver-layer concept, already generic. Zero changes.
maptara.provider.uom
maptara_provider.py:133
3 delete Defined, ACL'd (ir.model.access.csv:12-13), shown in the form (views:93-97), and uom_ids is read by nothing in either module. A model whose entire job is appearing in a form. Free deletion.
maptara.service.provider
maptara_provider.py:157
+11 keep move 2 down split later Credentials, branches and routing are genuinely generic. Move name and active down into maptara_base — that alone frees 15 of the 22 modules in the closure from caring about this layer. The deeper dual-purpose split is §9.
maptara.provider.activity
maptara_provider.py:461
17 keep shell move the state machine "An exchange happened, here is the audit record" is generic and must stay. But document_type (:499) is a five-value eKV state machine, and request_data/response_data are labelled "XML File" (:531, :538) — an SFTP driver ships a fixed-width text file and a REST driver ships JSON. Generalise the labels; move the selection values to the eKV module via selection_add.
maptara.provider.ref.burn
ref_burn.py:37
5 rename keep mechanism An idempotency-token ledger written on its own cursor so a spent token survives a rollback. Excellent, reusable, and its field label is literally 'eGeKo KV-Nr' (:63). Rename the labels, touch nothing else. 23 tests guard the behaviour.
sale.order
sale_order.py:13
+6 move to eKV module cost_estimate_completed (:27) hardcodes the three-activity Egeko lifecycle at :212-216. The warning centre and the poll cron are eKV, not "provider".
maptara.send.ekv.wizard
wizard:13
13 move to eKV module 784 lines. It is called send.ekv. There is no reading under which this belongs in a provider base.
maptara.send.ekv.line.wizard
wizard:667
10 move Actually generic — a two-store file picker with an _exactly_one_source SQL CHECK. Moves with its parent; promote to core only if a second consumer appears.
maptara.confirm.send.ekv.wizard
confirm:9
1 move fix bug Moves with its parent. While it is open: action_confirm (:17-20) calls _send_ekv directly, bypassing _materialise_uploads, so a file in upload_attachment_ids on that path silently never becomes a document.

03 · The one real decision: split or scrub

Two coherent options. I recommend the second, and the argument is not aesthetic.

Option A — scrub in place
4–6 d

Rename the strings, rename the hook, de-brand the cron, delete maptara.provider.uom, fix the ACL. One module, one dependency graph, no migration beyond the cron XML-ID.

What you get: a base module whose grep is clean.

What you still have: 1 366 lines of eKV domain inside the "provider base", and azhDirekt inheriting all of it.

Option B — extract the eKV domain (recommended)
9–14 d

Everything in A, plus: maptara_san_provider_base keeps its name and its three dependents and loses its eKV half to a new maptara_san_ekv.

What you get: a base that a §302 billing driver can depend on without inheriting a cost-estimate wizard.

What it costs: an ir_model_data reassignment migration (§6), which is the genuinely fiddly part.

The argument for B, and it is a one-liner

azhDirekt is not an eKV integration. §302 SGB V is billing — claims submitted after delivery, which already has maptara.claim.batch as its home. It never sends a Kostenvoranschlag, never opens the send-eKV wizard, never touches a KV-Nr, and has no use for a 15-minute cost-estimate poll cron.

Under Option A, CR #1197 ships a module that depends on 784 lines of eKV wizard and a warning centre keyed on LEGS positions, neither of which it calls.

Add MIP and ZHP X3 (per CR #1174) and the question stops being "is one more module worth it" and becomes "how many integrations inherit a cost-estimate wizard before someone objects".

Correction — the first draft of this section overstated the penalty

It said azh would inherit "a cron it must actively suppress". It would not. Base routes background operations through _log_unroutable (maptara_provider.py:644), which is explicitly the non-raising variant: an azh order with no _get_state_cost_estimate_azh produces a log line, not an error.

So Option A's real penalty is clutter and log noise, not breakage. That makes A materially more defensible than this section originally implied, and it is part of why the recommendation below moved.

Where I would push back on myself

Splitting an Odoo module that already has four migration scripts (19.0.0.3, .7, .8, .14) and 112 tests is not free, and the failure mode is not a test going red — it is Odoo deleting records on upgrade because their ir_model_data.module still names the old module. §6 is the whole reason W4 is the expensive package.

"Do MIP and ZHP X3 have committed dates?" is D4 in the programme report and it is still unanswered. If the answer is no, and #1197 slips past this quarter, Option A is not merely defensible — it is correct.

03a · SWOT — B against R2

These are not alternatives on the same axis, which is exactly why they kept getting compared badly. B is a filing question — where does the eKV wizard live. R2 is the interface question — how does base call a provider without knowing who it is. You can do either without the other. Only one of them has a deadline.

Option B — extract maptara_san_ekv · 4–6 d
SBase becomes an honest base: azh depends on ~870 lines, not 4 658. Test suites split along a real seam. The 61%-of-the-module problem simply stops existing.
W4–6 days for zero behaviour change. Carries the single riskiest migration in the programme. Touches four existing migration scripts.
ONatural moment to also fix the dual-purpose maptara.service.provider. Sets a domain-per-module precedent before MIP and ZHP X3 arrive.
TStale ir_model_data silently deletes views, menus and the cron on upgrade. A "move-only" diff drifting into a rewrite. If MIP/ZHP never land, it is one extra module serving one consumer.
R2 — the driver contract · ~2 d
S~200 lines. No migration whatsoever — purely additive. The only change that stops provider #5 editing shared code. Builds on an AbstractModel pattern already proven 12× in this repo.
WYou are designing an interface against 2 known protocols and 3 unknowns (MIP, ZHP X3, azh in detail). Get it wrong and you version it.
OMakes azh a plugin instead of integration #3. Forces the undocumented context-key smuggling (selected_document_ids, selected_mdm_attachment_ids) into explicit arguments. Makes probe's stage usable by the UI instead of a log line nobody reads.
TA wrong abstraction means churn across every plugin. MIP/ZHP turn out to need something unmodelled. Scope creep into R3.
Verdict — R2 first, and B may never be worth doing

R2's worst case is revising an interface nothing outside our own code depends on. B's worst case is a production database missing its cron. That is the entire argument and it does not need a tiebreaker.

The one genuine weakness in R2 — designing against three unknowns — is an argument for keeping it thin, not for delaying it: ship the six operations we can prove plus capability flags, and nothing speculative. No GraphQL driver (no provider in scope uses it), no async, no batch semantics until azh tells us what §302 actually needs.

And the cost asymmetry over time: R2 gets more expensive with every provider added; B costs the same next quarter as it does today. Nobody's pager goes off because a wizard sits in a slightly wrong folder.

04 · Target layout

BEFORE — one module, two domains maptara_san_provider_base · 4 658 provider · credentials · branches · activity log dispatch registry · ref ledger · doc-type map — 866 lines eKV domain — 1 366 lines, 61% send wizard 784 · sale.order eKV 508 · confirm 19 + the "Egeko: Poll…" cron created on install AFTER — same name, half the job maptara_san_provider_base · ~870 provider · credentials · branches · generic activity log driver contract (R2) · ref ledger · doc-type map · no cron maptara_san_ekv · ~1 370 ← NEW send wizard · sale.order eKV fields · warning centre poll cron (vendor-neutral name) · EkvDocument DTO (R2) WHO DEPENDS ON WHAT, AFTER provider_base the socket codexa_api + 15 behind it needs `name`,`active` → freed maptara_san_ekv cost estimates provider_egeko (R3) base + ekv + SoapDriver MIP · ZHP X3 base + ekv + a driver azh §302 (CR #1197) base + SFTP — NOT ekv
Fig 1 — Before and after. The dashed amber line is the whole argument: azh reaches the base without passing through the eKV module. Today that line does not exist, because there is nowhere for it to go.

05 · Work packages

Dev-day figures are judgement, not measurement — and they were revised down on 2026-09-19
Estimates, honestly re-cut
PackageFirst draftActual typingWhat the difference was
W1 baseline1 d~2 hRunning a suite and writing one portal test. Most of it is waiting for Odoo to boot.
W2 de-brand2 d~1 dFind-and-replace over ~106 lines. 80% of the remaining day is the cron migration.
W6 ACL + sweep1 d~2 hOne CSV line, two groups=, delete a model nothing reads.
Phase 1 total4 d1.5–2 dThe first figure was buffer for review cycles and migration verification, dressed up as coding time.
W3, W4, W56–10 dunchangedW4 is dominated by migration verification, which the prod-read DB makes possible but not fast.

What will not compress, and being fast does not help: the Egeko rewrite in R3. 1 448 lines, one 263-line method mixing ORM reads, VAT arithmetic, MDM downloads and wire vocabulary, with zero tests on payload assembly. Speed is no use when nothing tells you the bytes changed.

W1 Baseline the harness
2 h
W2 De-brand strings + cron
1 d
W6 ACL + dead-weight sweep
2 h
R2 driver contract (separate doc)
~2 d
W3 Push name/active down anytime
1–2 d
W5 Generalise the activity log anytime
1–2 d
W4 Extract maptara_san_ekv deferred
4–6 d
day 0481214

Grey bars are deferred or schedule-free. The committed sequence is the three green bars plus R2 — about four days, after which azh is unblocked on everything except the SFTP driver itself.

W1 — Baseline the harness 1 d

Run all 112 tests, record the pass count and the wall time, commit the output. Nothing else in this document is safe without a known-good starting line — and the suite is unusually good insurance, because its docstrings cite real incident dates and row counts.

Add the one test that does not exist and that W3 needs: assert maptara_patient_connect_hub's two search() calls on maptara.service.provider still exclude archived rows. Today they inherit that filter by accident.

Exit: ./odoo test --tags /maptara_san_provider_base green with a recorded count, plus one new red-then-green portal test.

W2 — De-brand strings and the cron 2 d

Rename post_init_hook_egeko → a neutral name across __manifest__.py:18, __init__.py:2, hooks.py:11; _ensure_egeko_cron → neutral (hooks.py:25); the cron record's display name (hooks.py:54); action_new_egeko_code → neutral in both the wizard (:498) and the XML that binds it (:44). Strip vendor vocabulary from the 8 XML column labels, the chatter block (maptara_provider.py:739-776) and the X01/A01 warning text (wizard:177-180).

The cron is noupdate, so a rename is a migration, not an edit — see §6.

Exit: grep -rn 'egeko\|eGeKo\|Egekonummer\|optadata\|X01\|A01' --include='*.py' --include='*.xml' over non-test base files returns 0; suite still green; i18n/de.po updated rather than left pointing at dead msgids.

W3 — Push name and active down to maptara_base 1–2 d

The entire Codexa tree — 15 modules — depends on this layer through exactly two column names. Move them and those 15 stop caring which provider module is installed.

Exit: the portal test from W1 still green; maptara_codexa_api installs with maptara_san_provider_base absent.

W4 — Extract maptara_san_ekv 4–6 d skip for Option A

Move, unchanged where possible: both send wizards and the confirm wizard (784 + 19 lines + 173 lines of XML), sale.order's 6 eKV fields and the warning centre (508), the poll cron body (sale_order.py:333-502), and the four eKV-reasoning migrations. The `document_type` selection values move here and re-enter the activity model via selection_add.

Resist rewriting while the files are open. The point of this package is that the diff is moves, so a reviewer can verify it by reading paths rather than logic. Behaviour changes go in R2/R3 where they have their own tests.

Exit: all 112 tests green, redistributed across the two modules, with zero assertions edited; a fresh DB installs base alone with no cron and no eKV menu; an upgrade from the current version preserves every existing activity, ref-burn row and stored warning (verify by row count, before and after).

W5 — Generalise the activity log 1–2 d

Relabel request_data / response_data from "XML File" (maptara_provider.py:531, :538) to something a fixed-width §302 file and a JSON body can also live in. Decide whether url (:518) and data (:520) are used by the plugin or are dead — survey A flagged them as unread by base and did not check the plugin.

Exit: a driver can write an audit record for a non-XML payload without the form lying about what is in it.

W6 — ACL and dead-weight sweep 1 d

Fix security/ir.model.access.csv:9base.group_user currently holds 1,1,1,1 on the model storing password and client_key as plain Char. Every internal user can read, edit and delete every ECE credential.

Do not remove read — it breaks Codexa

maptara_codexa_api/models/provider_site.py:20 declares a Many2one to maptara.service.provider and is not sudo'd. Strip read from base.group_user and every Codexa user opening a provider-site form gets an access error. The create path at :63-67 is sudo'd, so it would survive — the form would not.

base.group_userAction
readkeep — Codexa's FK needs it
write / create / unlinkremove
password, client_keyadd groups= so the fields are invisible regardless of the model ACL

That removes the credential exposure without touching anyone's foreign key. Field-level groups= is what actually protects the secret; the model ACL just stops casual editing and deletion.

Delete maptara.provider.uom (model, 2 ACL rows, form section). Flip the manifest off baotnp/OPL-1 — though see the programme report's D6 before claiming clean-room authorship.

Exit: a non-manager internal user cannot read password or client_key, and can still open a Codexa provider-site form; no model in the module is unreferenced.

06 · Migration hazards

Which migration goes first — and the asymmetry that answers it

R2 needs no migration at all. It is purely additive: a new AbstractModel, new methods, and a selection_add registry that already exists. Nothing moves, no ir_model_data is touched, no data is at risk. B is nothing but migration — the code moves are trivial and the entire cost sits in the reassignment.

So the ordering question answers itself. When you do start moving things, go cheapest-and-safest first, so the harness gets debugged on something that cannot hurt you:

#MigrationRiskVerify on the prod copy by
1Delete maptara.provider.uom (W6)lowest SELECT count(*) on the table first. Odoo leaves the orphan table rather than dropping it, so the risk is low — but if the count is non-zero, know that you are abandoning rows before you decide, not after.
2Cron rename (W2)contained Count ir.cron rows matching the poll job, before and after. Must be exactly 1 — not 0, not 2.
3name/activemaptara_base (W3)medium The portal test from W1, plus maptara_codexa_api installing with provider_base absent.
4B — the module extraction (W4)highest Row counts on maptara.provider.activity, maptara.provider.ref.burn and the stored warning fields, before and after.

A production-read database is available (confirmed 2026-09-19), so every row in that last column is actually runnable. Before it existed, none of them were.

The one that actually bites — and it does not show up as a red test

When a model or XML record moves between modules, its ir_model_data row still names the old module. On the next upgrade Odoo sees records belonging to maptara_san_provider_base that the new maptara_san_provider_base no longer declares, and does the reasonable thing: deletes them. Views, actions, menus, ACL rows, and — worst — the cron.

A test suite on a fresh database is perfectly happy with this, because a fresh DB has no stale ir_model_data. The failure only appears on an upgrade of an existing database, which is to say: in production.

Mitigation: a pre-migration on the new version that reassigns ir_model_data.module for every moved XML ID before the registry loads. And a test that upgrades a populated database, not just a fresh one.

HazardWhyHandling
The poll cron is noupdate: True hooks.py:70-71. Renaming the record does not update installed databases; the old one survives under its old name and the new one is created beside it. Two crons, both firing. Migration that renames in place by XML ID, plus a guard against the _auto_init at sale_order.py:328-330 re-creating the old one.
_auto_init re-creates the cron on every -u models/sale_order.py:328-330. Deleting the cron is not a supported state — it comes back. Move this with the cron in W4, or the base module keeps resurrecting a job it no longer owns.
Four existing migrations reason about Egeko and MIP by name 19.0.0.3/pre-migrate.py:7,19, 19.0.0.7/post-migrate.py:8, 19.0.0.14/post-migrate.py:20 They are historical; do not rewrite them. They move with the eKV module, and any DB that has already passed them stays passed.
Manifest version numbers after a rebase A "keep mine" conflict resolution can land a version lower than the base — and the migration then simply never runs. Silent. Check the version number is monotonic after every rebase in this programme, not just at the end.
Field renames on maptara.provider.ref.burn Label-only changes are free; renaming the column is not, and the ledger is the one table where losing a row means reissuing a spent KV-Nr. Rename labels only. Leave column names alone unless someone wants to fund the migration and the risk.

07 · The test harness is the plan

112 tests, ~182 asserts, all @tagged('post_install', '-at_install'). Every one of them must stay green, and none of them should be edited beyond import paths and module assignment. Survey A's instruction was blunt and correct: do not rewrite the tests as part of the split.

FiletestsGuardsLands in
test_ekv_internal_ref.py23KV-Nr never reissued: family scoping not order scoping, numeric not lexicographic suffix sort, burn ledger survives rollback, empty-ref escape hatchsplit — ledger to base, allocation to ekv
test_ekv_media_type.py20media_type_code returns False not 'X01' when unmapped; per-provider isolation; re-tick on provider changebase
test_provider_company.py18Group-wide vs branch-locked credentials, _for_company precedence, both @api.constrains, test_a_removed_branch_stays_removedbase
test_provider_routing.py17Unroutable activities raise on foreground paths and log-not-raise in background; ondelete='restrict'; the nine dead stage-test rowsbase
test_ekv_warnings.py13LEGS warning fires, counts correctly, never blocks, survives empty recordsetsekv
test_send_ekv_mdm_source.py11MDM attachments reach wizard lines without being copied; the _exactly_one_source SQL CHECKekv
test_send_ekv_image_warning.py6Both empty-appendix states produce distinct banners; the negative caseekv
test_send_chatter.py4Chatter on success, failure and exception; the senterror downgrade when no external_ref came backbase
What the harness does not cover, and W4 must not assume it does

Every one of these runs on a fresh database. Nothing in the suite exercises an upgrade — which is precisely the axis W4 puts at risk (§6). Add one upgrade check against a populated DB and compare row counts on maptara.provider.activity, maptara.provider.ref.burn and the stored warning fields before and after. Real numbers, not a green tick.

08 · Risk register

RiskBlast radiusWho finds out, and whenMitigation
Stale ir_model_data deletes moved records on upgrade Views, menus, ACLs and the poll cron vanish on an existing DB Nobody, until an eKV stops being polled. Feedback loop: days. Reassignment pre-migration + a populated-DB upgrade test. Non-negotiable for W4.
Two crons after the rename Every order polled twice; duplicate chatter; doubled load on opta data Fast — the chatter is visible. Unless egeko_state_code dedupe hides it, which it might. Rename by XML ID in a migration; move _auto_init with the cron.
Archived providers appear in the patient portal after W3 A public-facing surface lists providers we deliberately retired Possibly a customer. No error is raised, no test fails today. The W1 portal test, written before W3 starts.
A spent KV-Nr gets reissued opta data rejects the eKV with the German collision message; at worst two Vorgänge collide Fast and loud, but customer-visible. Do not touch ref-burn column names. 23 tests already guard the behaviour; keep them green through every package.
Scope drift into R2/R3 A "move-only" diff becomes a rewrite and stops being reviewable At review, by which point it is expensive to unwind W4's exit criterion is explicit: zero edited assertions. If a behaviour change is needed, it belongs to R2 or R3.
Option B is built and MIP/ZHP X3 never arrive One extra module for one consumer Months Honestly: survivable. azh alone justifies it. But answer D4 before committing the 4–6 days of W4.

09 · Deliberately not done

Left aloneWhyWhat breaks if nobody ever does it
Splitting maptara.service.provider's dual purpose — it is both the Leistungserbringer (defined in maptara_base, 25 lines, 3 fields) and the ECE credential holder The constraint at maptara_provider.py:311-351 is scoped to "rows carrying a secret" purely to route around this, and its own docstring says "The real fix is to stop the model being dual-purpose". Doing it here would double R1 and touch all 22 modules. Every future constraint on this model needs the same "does this row have a secret" dance. Annoying, not dangerous. Revisit when CR #1174's one-config-vs-per-location question is answered.
The _unique_provider_type constraint (one configuration per provider type, system-wide) Flagged by CR #1174, not independently re-verified in this survey. If real, it blocks per-Betriebsstätte credentials. azh may need one Kundennummer per location. That is an open question in CR #1197, so this gets answered there, not here.
The confirm-wizard upload bypass Real bug, medium confidence, no reproducer traced. It is a behaviour fix, and W4 is a move-only package. A file attached on the resend-confirm path silently never becomes a document. Fix in R3, or as its own one-line ticket.
Rewriting any test They are the only thing making this refactor verifiable, and their docstrings carry incident history that would be lost. Nothing. This is the correct call.

Decisions needed before W4 starts

#QuestionBlocksRecommendation
R1-aOption A (scrub) or Option B (split)?W4 entirely A now, B deferred — revised 2026-09-19. B is still the right end state; it is simply the only item on the list whose cost does not rise with delay, and it is the only one carrying production-data risk. See §3a.
R1-bName for the extracted module. maptara_san_ekv follows the existing maptara_san_* convention; maptara_ekv matches the newer tree.W4 file layout maptara_san_ekv — consistency with its neighbours beats consistency with the newer naming. Trivial to change now, painful later.
R1-cDoes the ACL fix (W6) ship in R1 or wait for a security ticket?Nothing — it is independent Ship it in R1. It is a one-line CSV change plus groups=, the file is already open, and "every user can delete every credential" does not improve with age.
R1-dIs there a populated non-production database we can upgrade against? CLOSED Yes — a production-read copy is available (2026-09-19). Every verification step in §6 is now runnable. This was the one hard blocker on the whole migration story.

Programme: report-provider-driver-refactor.html — R1 is this document; R2 (driver contract), R3 (Egeko rewrite), R4 (SFTP), R5 (REST) and R6 (licence) are still to be written up at this level of detail.

Evidence: A · B · B2 · C

Internal engineering planning document. No code has been modified; this describes work that has not started.