Skip to content

build: pay the Scala 3 debt on 2.13 (flip sequenced after Doobie) - #91

Draft
hongwei1 wants to merge 385 commits into
build/scala-2.13-migrationfrom
build/scala-3-migration
Draft

build: pay the Scala 3 debt on 2.13 (flip sequenced after Doobie)#91
hongwei1 wants to merge 385 commits into
build/scala-2.13-migrationfrom
build/scala-3-migration

Conversation

@hongwei1

@hongwei1 hongwei1 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Everything Scala 3 needs that can be done on 2.13, delivered and verified. The flip itself is
not here: it is blocked, and the blocker is documented rather than worked around. One commit per
verified step, same structure as #90.

Based on the head of #90 (build/scala-2.13-migration); retarget to develop-obp once #90 merges.

What is in it

build hygiene single-Scala-suffix dependency audit; Central searched before JitPack (dependency confusion); target/lib pruned so a removed dependency actually leaves the runtime classpath
dependency work json4s 3.6.12 → 4.1.0-M8; scalapb 0.9.0 → 0.11.17 with reproducible regeneration; scala-nameof 2.0.0 → 4.1.0; scalacache replaced with an in-house memoize layer; CacheKeyFromArguments macro replaced with explicit keys; the dead avro stack dropped
source obp-api compiles under -Xsource:3 (307 files)
seam run-time Scala compilation moved behind a DynamicScalaCompiler interface
tests scalatest 3.0.8 → 3.2.20

Three plan premises that measurement overturned

  1. json4s cannot use for3Use2_13. Only _3 4.1.0-M8 with scala3-staging extracts Scala 3
    case classes.
  2. The dynamic-code "2.13 island" does not exist. DynamicUtil.importStatements puts
    obp-api's own classes in scope, so the ToolBox cannot be isolated into a separate module. It
    became a compiler seam instead.
  3. scalatest had to move first. 3.0.8 publishes no _3, and 3.2.x removed the legacy style
    traits — an unplanned prerequisite.

Verification

Every commit: full local suite, consumer-contract surface diff against a same-source baseline,
single-Scala-suffix audit. Milestone gates:

  • suite 3494 / 0
  • contract A/B, all four layers: 0 RED
  • P-1 1757 ms (baseline 1925) · banks p95 13.4 (16.0) · users/current p95 85.9 (threshold 88.3)

Three review rounds over the full diff, each fix reproduced by a failing test first: a sub-second
Redis TTL rounded up to 1 s by SETEX (round 1), a protoc shim with an absolute path baked in
(round 2), zero findings (round 3).

Not covered, deliberately: run_probes was withheld — its reset_env() mutates the shared
database and another session's server holds 8080. perm_matrix and two_tpp produce no verdict
on a non-8080 port; running the identical script against an unmodified base build produced the
same failures, which is what shows they are not attributable to this branch.

Also fixed in passing: target/lib never pruned removed dependencies, so avro
(CVE-2024-47561, CVSS 9.8) stayed on the runtime classpath after being dropped. The detector was
shown failing before it passed.

Why the flip is not here

Scala 3 cannot compile against Lift's KeyedMapper / KeyedMetaMapper hierarchy, which roughly
140 entity classes extend. Full evidence in docs/scala3-lift-mapper-blocker.md; the short version:

  • Plain Mapper[A] is fine — the failure is confined to the F-bounded keyed half.
  • Not caused by IdPK, and not by the object X extends class X idiom — so rewriting how
    the entities are spelled cannot fix it. That is the expensive route somebody would try first.
  • Cross-building the fork to _3 does not escape it: compiling Lift's own sources turns the
    assertion failure into 42 cyclic errors in the same construct. Two symptoms, one problem.
  • TypeTag looked like a blocking API change and is not — the tag is only stored, never
    introspected, and no consumer reads it, so ClassTag is a drop-in (95 → 79 errors). This shares
    a root cause with the plan's F-1 item.

The document also records what was tried and failed, so it is not retried: four synthetic
models that all compile clean, and two direct fixes on the fork that moved nothing.

Decided: Doobie first. Of the remaining routes — patching Lift's core type structure in our
fork, keeping the entity layer on 2.13, or migrating persistence off Lift — the one taken is to
remove Lift Mapper rather than work around it. The flip is not abandoned, it is sequenced after
the persistence migration, because that migration deletes the blocker instead of containing it.
That work is already underway on lift-mapper-remove in the OBP-API-I copy, with ATMs the
first table fully off Lift.

Nothing in this PR depends on that sequencing: it pays the 2.13-side debt the flip will need
whenever it happens, and each item stands on its own merits today.

Known CI state

SonarCloud's quality gate fails: new-code duplication 14.8% against a 3% threshold. It is not
a code defect and it is not pre-existing drift — it is the scalatest rename touching 5041 lines
across 358 test suites that were already heavily duplicated.

An earlier commit here (639133d1c) added exclusions to sonar-project.properties and its
message says it addressed this. It did not, and the gate failed on that commit too. SonarCloud
runs this project in Automatic Analysis mode, which does not read sonar.cpd.exclusions — proven
by obp-api/src/test/**/*.scala, listed there long before this branch, while API1_2_1Test.scala
still reports 13.3% duplication. The file now carries a warning to that effect.

Making exclusions effective needs either SonarCloud project settings (Administration → Analysis
Scope) or a scanner step in CI. Both are outside this PR.

@hongwei1 hongwei1 changed the title build: Scala 3 migration build: pay the Scala 3 debt on 2.13 (the flip itself is blocked) Aug 16, 2026
@hongwei1 hongwei1 changed the title build: pay the Scala 3 debt on 2.13 (the flip itself is blocked) build: pay the Scala 3 debt on 2.13 (flip sequenced after Doobie) Aug 16, 2026
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
8.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

hongwei1 and others added 12 commits August 17, 2026 14:06
Three tables replaced with Doobie row case classes and a V079 migration
reproducing the probed DDL.

RoutingSchemeValidation shares the file but is pure logic — scheme-name
regexes, the global allow-list, and the country-prefix rule for the
CARDANO/ETHEREUM settlement rails. Only the entity and provider above it
are rewritten; the validation object is spliced back byte for byte so a
storage swap cannot perturb payment-address validation.

Two constraints carry behaviour and the migration says so at each:
ROUTINGSCHEME(scheme) unique is what makes scheme usable as the handle
for every read, the update and the soft delete, and
BANKSUPPORTEDROUTINGSCHEME(bankid, scheme) unique is what makes
putBankSupportedRoutingScheme an upsert rather than an append.

deleteRoutingScheme stays a soft delete: status goes to RETIRED and the
row remains so historical addresses still resolve. Nothing removes these
rows, which is easy to mistake for an oversight, so it is stated.

getAbacRulesByPolicy keeps filtering in memory. policy is a comma-joined
tag list in one column, so a SQL LIKE would also match a policy name that
is a substring of another; pushing the filter into SQL would read as an
optimisation and be a correctness regression.

ABACRULE has three plain indexes and no unique one, though abacRuleId is
the handle the update and delete key off and getAbacRuleByName reads by
rulename, so two rules may share a name. Pre-existing; reproduced with
id ASC pinning the lookup.
… Mapper

Two tables replaced with Doobie row case classes and a V080 migration
reproducing the probed DDL.

endpointmapping.operationid is unique globally, not per bank. The
provider looks rows up by (operationId, bankId), which reads as though
the pair is the key, so the constraint is stated in the migration: a
bank-level and a system-level mapping cannot share an operation id, and
bankId only narrows a read. bankid itself genuinely holds NULL for
system-level rows and is bound as an Option so SQL NULL survives.

DYNAMICENTITYINDEX keeps its five backfill-bookkeeping columns in the
DDL, since deployed databases have them, but leaves them out of the row
model: no code path reads or writes backfillcheckpoint,
rowcountexpected, coercionerrors, lasterror or provisionerversion, and
carrying them as always-default fields would suggest the provisioner
maintains state it does not.

Its index on (entityname, bankid, fieldname) is plain rather than unique
though markReady does a find-then-insert on that triple, so a concurrent
double-provision would leave two rows. Pre-existing; reproduced with
id ASC pinning the lookup.
Two tables replaced with Doobie row case classes and a V081 migration
reproducing the probed DDL.

This OneToMany was live, unlike the counterparty-bespoke one: the
invitees accessor read mInvitees. It is replaced with an explicit query
preserving id ASC, which is the order the invitees were supplied in.

createMeeting still accepts staffUser and still does not store it —
Mapper's .mStaffUserId line is commented out, so the column has always
been NULL and present.staffUserId has always been "". Preserved with a
note; writing it would change what every existing meeting reports.

mcustomeruserid and mstaffuserid hold RESOURCEUSER's numeric primary key
rather than the public user_id, so present resolves them through joins
and an unresolved key yields "" as before.
… off Lift Mapper

Two tables replaced with Doobie row case classes and a V082 migration
reproducing the probed DDL.

MAPPEDCUSTOMERMESSAGE has two owner columns and they are not
interchangeable: user_c holds RESOURCEUSER's numeric key and is written
only by the deprecated addMessage path, while customer holds
MAPPEDCUSTOMER's numeric key and is written only by
createCustomerMessage. Each read filters on exactly one of them, so a
message created one way is invisible to the other reader. That is what
the deprecation note on the user field is about; the split is reproduced
and documented rather than unified, since merging them is a data-model
change and not a storage swap.

MAPPEDTRANSACTIONREQUESTTYPECHARGE has no index beyond its primary key
though its only read filters on (mbankid, mtransactionrequesttypeid) and
expects at most one row. Pre-existing; reproduced with id ASC pinning
which row a lookup sees.

MappedCustomerMessagesTest's bulkDelete_!! calls become deleteAll on the
new store.
Two tables replaced with Doobie row case classes and a V083 migration
reproducing the probed DDL.

The Lift entity CardAction is deleted rather than migrated: it was never
registered for schema creation, so its table has never existed in any
database and no code path read or wrote it. The probe returned zero
columns for it.

networks and allows are both comma-joined lists in one column but are
parsed differently — allows filters empties, networks does not, so an
empty networks column yields List("") rather than Nil. Preserved; the
difference is visible to every caller.

The two write paths represent an absent replacement differently and both
are reproduced: update writes the literal string "null" for the reason,
because Mapper called toString on a null reasonRequested, while create
leaves the columns genuinely NULL.

cvv and brand stay create-only. Mapper's update never set them, which
matters because the CVV column holds a SHA-256 hash and
updatePhysicalCard is never handed a plaintext to re-hash.

PinReset's upsert still looks an existing row up by replacement date
alone, ignoring which card it belongs to, so a reset requested on the
same instant for another card is updated instead of a row being
inserted. Preserved verbatim — narrowing the lookup would change which
rows exist.

The only constraint is (mbankid, mbankcardnumber, missuenumber). It does
not cover mcardid, which three provider methods key off, nor the
(bank, serial, cardNumber) triple createPhysicalCard checks before
inserting. Both are assumed unique without the database enforcing it;
recorded in the migration.
One table replaced with a Doobie row case class and a V084 migration
reproducing the probed DDL.

Both unique indexes are load-bearing rather than protective, and the
migration says so: each leg — (bank, account, transaction) on the debit
side and on the credit side — may appear in the book at most once, which
is what stops a transaction being booked into two different double-entry
pairs. The connector wraps the insert in tryo, so a duplicate booking
surfaces as a Failure.

The two find(...).or(find(...)) call sites in LocalMappedConnector become
findByLeg and findByTransactionId on the store, keeping the
debit-then-credit precedence in one place instead of spread across the
connector.

The transactionrequest* columns hold "" rather than NULL for a movement
that did not originate from a transaction request, so the accessors map
empty to None as before.
One table replaced with a Doobie row case class and a V085 migration
reproducing the probed DDL.

The optional bank id every read and the delete accept only narrows the
match — it is not part of the key, and only dynamicendpointid is unique.
A system-level lookup will therefore find a bank-level row with the same
id, and nothing but the id being generated prevents that. The semantics
are pulled into a single idCondition helper instead of being re-spelled
at each of the five call sites, and recorded in the migration.

bankid genuinely holds NULL for system-level endpoints, so it is bound as
an Option and the reader turns null-or-empty back into None as before.
One table replaced with a Doobie row case class and a V086 migration
reproducing the probed DDL. Writes already went through Doobie —
ConnectorMetricBatchWriter batches the inserts — so only the reads
changed.

bulkDeleteConnectorMetrics deletes MappedMetric, not
MappedConnectorMetric: it empties the API-metric table and leaves
connector metrics untouched, so the method does the opposite of its name.
That is a pre-existing defect, left verbatim with the reasoning at the
call site. Any caller invoking it today relies on the API metrics being
cleared, so silently redirecting it under a storage swap would change
what the call destroys. Worth its own change with a caller audit.

date is stored as date_c because DATE collides with a SQL reserved word.
Five plain indexes and no unique one is correct here: a connector may
call the same function under the same correlation id more than once and
each call is its own row.
One table replaced with a Doobie row case class and a V087 migration
reproducing the probed DDL.

The unique index on (mbankid, muserid, mrolename) is load-bearing for
authorisation, not an optimisation, and the migration says so:
addEntitlement deliberately lets a concurrent duplicate grant hit the
constraint, then re-reads and returns the committed row rather than
failing or creating a second grant. Without it a role could be held twice
and one revoke would leave the other behind.

Four columns break the m-prefix convention because the entity overrode
dbColumnName — group_id, process, granted_by_user_id and
entitlement_request_id. The migration pins the exact names.

entitlement_request_id is the one optional column defaulting to NULL
rather than "", and its reader also rejects the all-zero UUID since only
request-born grants set it. The other three use the empty-string
convention and are read through an empty check.

Empty ByList kept its "no rows" meaning on both the read and the two
delete paths: an empty user-id or role-name list matches nothing rather
than everything, which on the delete side is the difference between a
no-op and emptying the table.
One table replaced with a Doobie row case class and a V088 migration
reproducing the probed DDL.

The three optional scope columns hold SQL NULL, not "", when the scope is
broader, and that is load-bearing: getByConsumerId resolves a limit by
trying four increasingly general scopes and each tier matches the columns
it is not scoping on with IS NULL. A row storing "" would be invisible to
every tier. The four-tier fallback is kept intact and the IS NULL
semantics routed through one `scoped` helper, so "None means the column
must be NULL" cannot drift into "None means do not filter".

The readers stay laxer than the queries — apiName, apiVersion and bankId
map both NULL and "" to None while the lookups accept only NULL. That
asymmetry is Lift's and is preserved.

The six call-limit columns carry no database default. Their defaults come
from props (rate_limiting_per_second and friends, -1 when unset) and are
resolved in application code at insert time, which is where Lift's field
defaults came from. On update an omitted limit keeps its stored value, as
Mapper's per-field foreach did.

createOrUpdateConsumerCallLimits still does not invalidate the rate-limit
cache while createConsumerCallLimits and updateConsumerCallLimits both
do. Preserved with a note rather than corrected.

createdAt and updatedAt are exposed on the row because the v5.1.0 and
v6.0.0 JSON factories report them on the rate-limit resource.
One table replaced with a Doobie row case class and a V089 migration
reproducing the probed DDL.

mparentproductcode models the hierarchy by value, not by foreign key:
getProductTree walks it by repeatedly looking up (bankId,
parentProductCode) and an empty string terminates the walk. A product
with no parent must therefore store "" and never NULL, so that column
stays non-nullable while the free-text ones do not. createOrUpdate also
reads the existing parent before writing, because the connector only
supplies parentProductCode when the caller did — an update that omits it
must not reset it.

The first attempt failed 19 tests across three shards. Http4s310's
createProduct passes termsAndConditionsUrl = null as a literal; Lift's
MappedString stored that as SQL NULL, while a bare String binding throws
at bind time. The throw was swallowed by the surrounding tryo and
surfaced as 404 instead of 201, with nothing in the message pointing at a
null. Every free-text column is now bound as Option and read back with
orNull, reproducing Lift's round trip. CLAUDE.md's null-binding note
gains the write-side case, which is easier to miss than the query-side
one because the null is a literal in the caller rather than data.

The sandbox importer gains a SaveableProduct that writes through the
store, following the SaveableAtm precedent: the import must not write
with Mapper when every read comes back through the store. Mapper's field
validation there is dropped rather than reimplemented — no validator was
ever declared on the product entity, so it always passed.

MappedProductsProviderTest's fixtures move from MappedProduct.create to
createOrUpdate; its assertions are unchanged.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
7.0% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

One 53-column table replaced with a Doobie row case class and a V090
migration reproducing the probed DDL. The row is split across three
tuples because Scala tuples stop at 22 elements.

The connector writes a dozen columns through orNull — mcounty, both
branch-routing columns, all fourteen drive-up times, mbranchtype,
mmoreinfo and mphonenumber — so those are bound as Option and read back
as null, reproducing Lift's round trip. The lobby times are the
exception: the connector defaults them to "00:00" and they are never
null.

Two guards that have never fired are preserved rather than repaired.
branchRouting's fallback to "BRANCH_ID" compares the FIELD OBJECT to null
and to "" instead of its value, and a MappedString object is neither, so
callers have always seen the stored value including null.
getBranchLocal's defaulting to "OBP" compares an Option[String] to null,
which is likewise always false. Correcting either would change what every
caller of an unrouted branch receives.

The first attempt failed CreateBranchTest: the generated UPDATE excluded
the two key columns from its SET list by filtering chunks of four rather
than individual columns, so mname and mline1 were never written and an
update silently kept the old name. Only mname had a test watching it.
The generator now asserts that every non-key column appears exactly once
in the SET body.

The sandbox importer gains a SaveableBranch writing through the store,
following SaveableAtm and SaveableProduct. Mapper's field validation is
dropped rather than reimplemented — no validator was ever declared on the
branch entity.

MappedBranchesProviderTest's fixtures set a handful of fields and relied
on MappedString's "" default for the rest; a local helper now passes the
unset columns explicitly.
One table replaced with a Doobie row case class and a V091 migration
reproducing the probed DDL. The table keeps its MAPPER prefix, which is
unlike every other table here and is now stated in the migration.

The unique index on (user_c, accountbankpermalink, accountpermalink) is
load-bearing: getOrCreateAccountHolder is a check-then-insert that relies
on the database rejecting a concurrent duplicate so the loser can re-read
the committed row. Without it a user could be recorded twice as holder of
one account and one revoke would leave the other behind.

source genuinely holds NULL and getAccountsHeldByUser branches three ways
on it — no filter, IS NULL, or an exact match — so the column stays
nullable and all three branches are preserved.

The first attempt failed two API1_2_1Test revoke scenarios with a 500:
canRevokeOwnerAccess looks holders up by a ViewDefinition's bankId and
accountId, and a SYSTEM view has neither, so both arrive as null. Lift
rendered that as `= NULL` and returned no rows; a bare String binding
throws instead. Every string binding in the file is now Option, with the
reasoning at find.

CLAUDE.md's null-binding note gains the rule this keeps violating: audit
the callers for literal nulls and for identifiers that are optional in
the domain BEFORE writing the store. Three migrations in a row compiled,
passed their targeted suites, and failed the full run on a null arriving
from a call site that had not been read.
One table replaced with a Doobie row case class and a V092 migration
reproducing the probed DDL.

First table done under the caller-audit rule added to CLAUDE.md: grepping
the callers before writing the store turned up
.BankId(bankId.getOrElse(null)) immediately, so bankid was bound as
Option from the start rather than after a failing full run. Green first
time.

process is unique globally rather than per bank, the same shape as
endpointmapping.operationid: a bank-level and a system-level doc cannot
share a process name, and the optional bank id narrows a read without
being part of the key.

The reads are inconsistent with the write and stay that way: a supplied
bank id filters on bankid, while an absent one does not constrain it at
all, so a system-level lookup also matches bank-level rows. That is
expressed once in a bankFilter helper instead of being re-derived at each
of the five call sites, and stated in the migration.

bankId is not written on update — Mapper did not set it either, so a doc
cannot move between system and bank scope after creation.
One table replaced with a Doobie row case class and a V093 migration
reproducing the probed DDL. Green first time; the caller audit found all
three nulls up front — bankid, examplerequestbody and successresponsebody
— and they are bound as Option before anything was written.

The two optional JSON bodies matter beyond not throwing: the reader
filters blank before parsing, so an absent body has to stay NULL rather
than become "", or the column's meaning would depend on the reader
instead of the data.

This provider differs from the near-identical dynamicmessagedoc one that
landed just before it, and both are preserved as they were: this update
DOES write bankId, and its lookup deliberately ignores bankId so an
update addressed by id finds the doc whatever its scope and then rescopes
it. Migrating the two back to back makes them easy to harmonise by
accident.

The unique index on (requesturl, requestverb) is global rather than per
bank, so a bank-level and a system-level doc cannot claim the same route.
roles is stored as roles_c because ROLES is a SQL reserved word.
One table replaced with a Doobie row case class and a V094 migration
reproducing the probed DDL. Green first time.

ProjectionStore was reading this table's name and three column names off
Lift's metadata to build raw SQL for its user-scoped EXISTS joins — a
dependency on the ORM rather than on the data. Those four names are now
constants on the companion so the DDL and that hand-built SQL cannot
drift apart.

bankid is the NULL-vs-empty case again: both scoped queries use IS NULL
when no bank is supplied, matching Lift's NullRef rather than "no
filter", so a row storing "" would be invisible to them. Routed through
one scopedBank helper.

The revoke walk is preserved intact — it follows GrantedBy edges to
remove the target user and everyone they granted downstream, with a
visited set that terminates re-share cycles and absorbs the owner row's
self-edge. The (dynamicdataid, grantedby) index exists to serve that
walk, which the migration now says so it does not read as redundant
beside the unique index.

The unique index on (dynamicdataid, userid) is what makes grant an upsert
rather than an append and lets allows answer with a single lookup.
Brings in the nine commits upstream added after this branch was cut - chat
message constraints and email digest, the password-policy endpoint, signal
channel sanitizing, and the Sonar annotations - so the branch is tested as it
will merge rather than as it stands alone.

They were written on 2.12. Compiling and testing them under 2.13 is the point of
merging here rather than leaving it to the merge button: a long-lived branch
being green on its own says nothing about the merge, which is what the
pull_request build actually compiles.
One table replaced with a Doobie row case class and a V095 migration
reproducing the probed DDL. Green first time.

All three dynamic-* providers scope differently, and each keeps its own
behaviour. dynamicmessagedoc and dynamicresourcedoc leave bankid
unconstrained when no bank id is supplied, so a system-level lookup also
sees bank-level rows; this one uses IS NULL, so it does not. Having
migrated the other two immediately before, the difference is easy to
harmonise by accident, so it is stated in the migration — it is only
visible by reading all three.

getDynamicEntities keeps its third mode: returnBothBankAndSystemLevel
ignores scope entirely and returns every row.

delete keeps its two branches. A row we loaded is deleted by its own id;
anything that merely names an entity deletes every row with that name.
Those are materially different blast radii, so they stay separate rather
than being unified on the name.

Only dynamicentityid is unique — nothing constrains (bankid, entityname)
even though getByEntityName treats that pair as a key, so two entities in
one scope may share a name. Recorded with id ASC pinning the lookup.
One table replaced with a Doobie row case class and a V096 migration
reproducing the probed DDL. This clears the whole code/dynamicEntity
package.

bankid and userid both hold NULL but are read differently, and the
difference is load-bearing. bankid uses IS NULL for the system-level
case, so a system-level query excludes bank-level rows. userid is
compared with `= ?` even when the caller passes None, because the
provider wrote By(UserId, userId.getOrElse(null)) — Lift rendered that as
`= NULL`, which matches nothing, so a personal-entity query with no user
id has always returned zero rows rather than every row. That reads like a
bug but callers depend on the empty result as an access check; writing it
"correctly" as IS NULL would start returning every ownerless personal
record. Both behaviours are preserved literally and spelled out at the
two helpers and in the migration, since neither is visible without
reading the other.

The four get/getAll scopes collapse to a personal/impersonal choice over
two scoping helpers rather than four hand-written branches, and the
community reads keep their own helper — they deliberately ignore owner
and personal flag.

ProjectionStore was again reading this table's name and six column names
off Lift metadata; those are now constants on the companion, as the ACL
table's already are.

Http4s600's orphaned-record cleanup and the useRowLevelAccess warning
both counted rows with hand-built scope filters; both now go through
findAllCommunity, which is the scoping they actually wanted.
One table replaced with a Doobie row case class and a V097 migration
reproducing the probed DDL.

The first attempt failed 12 tests on `oops, null` even though bank_id and
account_id were already bound as Option. The failing value was Some(null),
not None: a system view loaded from the database carries BankId(null), so
Some(view.bankId.value) wraps a null, and Doobie unwraps the Some and
hands the non-nullable Put that null. Lift's By(field, null) rendered
`= NULL`. The scoping helper now collapses Some(null) to None with
flatMap(Option(_)), and CLAUDE.md's null note gains this case — binding as
Option is necessary but not sufficient when the Option itself can wrap a
null.

The unique index on (bank_id, account_id, view_id, permission) is what
makes a permission single-valued per view, and resetViewPermissions
depends on it: it deletes the view's rows then re-inserts each permission
inside a Try so a concurrent reset is absorbed by the constraint. That
holds for CUSTOM views only — H2 and Postgres treat NULLs in a unique
index as distinct, so for SYSTEM views, where both id columns are NULL,
the constraint never fires and two concurrent resets can both insert.
Pre-existing; recorded in the migration.

bulkDeleteAllAccountAccessAndViews scopes its view and access deletes to
one account and then deletes EVERY view permission in the system. That
over-reach is pre-existing and is marked at the call site rather than
narrowed, since narrowing changes what a caller's cleanup destroys.
The authorisation link between a user and a view, replaced with a Doobie
row case class and a V098 migration reproducing the probed DDL. 56 call
sites across 17 main files and eight test files.

All five columns of the unique index are load-bearing, and the migration
says so: revokeAccess matches on (bank, account, view, user) and cannot
tell two applications' grants apart, while the per-consumer revokes match
on (bank, account, view, consumer) and cannot tell two users apart — on a
joint account they would delete whichever row came back first. Undoing
one consent's grant needs the whole tuple, which is why deleteRow
addresses a row by all five.

The table carries a SECOND, dead consumer_id column in deployed
databases. Helper.addColumnIfNotExists emits ADD COLUMN IF NOT EXISTS
"consumer_id" — quoted, so lowercase and distinct from the CONSUMER_ID
Schemifier created. The existence check never matched and
MigrationOfAccountAccessAddedConsumerId added a duplicate nothing reads.
This migration builds only the live column and explains the twin.

Also fixes a latent build breakage introduced at the eleventh table:
DoobieTransactionTypeProvider.scala declared package code.transactiontypes
beside a file declaring code.TransactionTypes. Those are distinct packages
to scalac but the same directory on a case-insensitive filesystem, so the
class files overwrite each other and any from-scratch compile fails with
"location not matching its contents". Every build since survived only
because Zinc's incremental analysis never rescanned that directory;
clearing it exposed the collision. The new file now matches the package
the rest of the directory uses.

MigrationOfSystemViewsToCustomViews keyed off view_fk, the deprecated
numeric link no row has carried since. It is left as the no-op it already
was rather than rewritten against a column it was never about.
Mandate, MandateProvision and SignatoryPanel become plain row case classes
with SQL stores, and their DDL moves from Schemifier to a Flyway script.

The three tables had no direct coverage and neither do the v6.0.0 endpoints
above them, so MandateProviderTest is added first and was confirmed green
against the Mapper implementation before the rewrite. It pins what the API
actually depends on: store-generated ids, the three listing orders (mandates
newest-updated first, provisions by sortOrder, panels by name), that an update
restamps the row and so reorders the listing, and that a miss is Empty rather
than a failure.

Free-text columns are bound as Option and read back with orNull so a null
stays a SQL NULL instead of throwing at bind time, as MappedString and
MappedText behaved. The endpoints fill every optional field with "" before
calling, so a null is not expected here - but a store that throws on one turns
a tolerated input into a 500.

The update paths look the row up before writing so an unknown id stays Empty
rather than becoming a no-op that reports success.
MappedSigningBasket and its two join tables become plain row case classes
with SQL stores, and their DDL moves from Schemifier to a Flyway script.
Covered by SigningBasketServiceSBSApiTest.

Membership stays as unconstrained as it was: neither join table has a unique
index, so the same payment can be listed in a basket twice, and BASKETID
itself is only indexed rather than unique - reads take the first match by
insertion order, which is what Mapper's find did.

Cancelling a basket remains a status change rather than a delete, so an
authorisation that referenced the basket can still be explained afterwards.

Mapper ran entity.validate before saving a new basket and threw on a
violation. The only validated field was Status against MappedString(50) and
the only status written on create is the constant RCVD, so that branch could
not fire; the column length still enforces it.
@hongwei1
hongwei1 force-pushed the build/scala-3-migration branch from 074957c to f4b79eb Compare August 17, 2026 17:07
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
6.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

simonredfern and others added 29 commits August 31, 2026 12:43
fix: widen DynamicResourceDoc example/response body columns to text- #94
…registry

The resource-docs dispatcher serves the BG v1.3 alias (active only when
berlin_group_v1_3_alias_path is set) through its ScannedApis registration,
but APIUtil.allStaticResourceDocs never included it. Its docs carry their
own operation ids, re-derived from the alias version string, so alias
operation ids failed the getAllResourceDocs membership check used by
api-collection-endpoint creation and other operation-id lookups -- the
same gap BGv2 had before it was added to this union. Reproduced against a
running instance with the alias prop set (OBP-40048 on a valid alias
operation id) and confirmed the fix resolves it.
The alias surface is gated by berlin_group_v1_3_alias_path, which is
unset in the default test environment, so its operation-id list is
legitimately empty there -- skip the non-empty assertion for it while
still running the membership check against getAllResourceDocs.
…ocs dispatcher

Every one of its ~19 arms was `case X => resourceDocs`, unchanged --
a leftover from the pre-http4s Lift route-filter era that stopped doing
any filtering once the corresponding version moved fully onto http4s.
getResourceDocsList now feeds resourceDocs directly into
activePlusLocalResourceDocs, with identical output.
…ion from one registry

Introduce ResourceDocRegistry as the single source of truth for "which
resource docs does version X serve", replacing two independently
hand-maintained registries: ResourceDocsAPIMethods.getResourceDocsList
(the per-version dispatcher used by /resource-docs/{VERSION}/... and API
Explorer) and APIUtil.allStaticResourceDocs (the union used wherever an
operation id must be resolved). These drifted three times by hand --
Berlin Group v2, v7-only operation ids, and the Berlin Group v1.3 alias
all had to be independently added to both places, and were each missed
at least once. Deriving both from one registry map makes that class of
drift structurally impossible going forward.

Http4sBGv2 becomes a ScannedApis registrant (its apiVersion is
ConstantsBG.berlinGroupVersion2), so it is now fully convention-driven
like the other Berlin Group / UK Open Banking standards and needs no
hand-maintained entry in the registry or a special case in
ApiVersionUtils.valueOf. The global union is now deduped by operationId
-- the underlying per-version buffers legitimately overlap (each
OBP-standard aggregation repeats every older version's docs), and
consumers only ever .find or build a lookup map from the result.

ResourceDocRegistryParityTest is rewritten to iterate the registry
itself rather than a hand-typed list of standards, so a future standard
reachable by the dispatcher is covered by construction and the test's
job narrows to catching an accidental regression back to two
independently maintained registries.

Verified live against a running instance, before and after: BGv2 and
Berlin Group v1.3 alias operation ids both still resolve through
POST /my/api-collections/{name}/api-collection-endpoints. Full local
suite: 3582 tests, 0 failures.
…int operation id

Adds an HTTP-level regression test for the sandbox bug report this
branch started from: creating an API collection endpoint with
operation_id=BGv2-getAccountDetails now returns 201, alongside the
existing coverage for OBPv6.0.0, UK Open Banking, and Berlin Group v1.3
operation ids in the same scenario. Previously the only regression
guard for this exact operation id was the unit-level membership check
in ResourceDocRegistryParityTest; this exercises the actual endpoint.
…evel coverage

berlin_group_v1_3_alias_path could not be toggled per-test at runtime:
its ScannedApiVersion identity is captured once by ScannedApis.
versionMapScannedApis' process-wide classpath scan (a lazy val, shared
across the whole JVM/shard), which gets forced by the first unrelated
request that falls through Http4sApp's route chain -- almost always
long before any test-specific setPropsValues call. The only way to
exercise a real alias operation id end to end is to have the prop
already set before the JVM boots.

Set berlin_group_v1_3_alias_path=0.6/v1 in test.default.props (local)
and both CI workflows' generated test.default.props (build_pull_request.
yml, build_container.yml). Add a regression test in
ApiCollectionEndpointTest mirroring the existing per-standard coverage
(OBPv6.0.0/UK Open Banking/Berlin Group v1.3 canonical) for the alias's
BGv1-getPaymentInitiationStatus operation id, and pin the same
operation id in ResourceDocRegistryParityTest alongside the existing
BGv2-getAccountDetails pin.
…ift instance

The global operation-id union used to be built from the v6.0.0
aggregation, so operation ids belonging to endpoints that exist only in
v7.0.0 were absent from it and could not be added to an API collection.
That drift instance had no regression test: the OBPv6.0.0-* cases in
ApiCollectionEndpointTest pass under both the old v6-based union and the
current v7-based one, so they cannot detect it.

Pin OBPv7.0.0-getMyMetrics (v7-only -- not part of
Http4sResourceDocAggregation.v600) as a real api-collection-endpoint
request, and add the matching named pin in ResourceDocRegistryParityTest
alongside the BGv2 and Berlin Group v1.3 alias ones, so all three
historical drift instances now have both HTTP-level and registry-level
coverage.
…efined order

Two defects from the registry refactor, both in how allStaticResourceDocs
was assembled.

Folding every per-version aggregation into the union added 287 operation
ids it never carried (the older aggregations are not subsets of the v7
one -- an endpoint dropped after v4 keeps its operation id there), and 234
of those collide on partialFunctionName with an entry already present.
Http4s600's top-apis and popular-apis and JSONFactory6.0.0's metrics all
build `partialFunctionName -> operationId` with `.toMap`, where the last
entry wins, so with v1.2.1 sorting last the reported operation_id flipped
to the oldest id: getBanks became OBPv1.2.1-getBanks, root became
OBPv1.2.1-root. Restrict the union to obpUnionVersion (the current OBP
aggregation) plus every non-OBP standard. Consequence, deliberate and
documented at the constant: an operation id living only in a superseded
aggregation stays unresolvable, exactly as before the refactor.

The scanned half of the registry was a plain Map, so the same `.toMap`
consumers resolved a partialFunctionName shared by two scanned standards
according to hash iteration order -- undefined, and free to shift when a
standard is added or removed. The Berlin Group v1.3 alias re-stamps the
canonical BG v1.3 docs and so collides with BG v2 on getAccountDetails
and four other names, and test.default.props now activates that alias for
every test run. Sort it by fullyQualifiedVersion into a ListMap; BG v2
then wins those names, matching the behaviour before this branch.

ResourceDocRegistryParityTest follows the narrowed union and regains the
per-surface non-empty assertion, without which a standard whose docs stop
being registered passes as a trivial subset. A new scenario pins
obpUnionVersion as the newest OBP-standard version in the registry, so
adding a v8 aggregation without moving it fails instead of silently
dropping v8-only operation ids.

Verified against a running instance: OBPv1.2.1-getBanks and
OBPv3.0.0-getAggregateMetrics are rejected with OBP-40048 again, while
BGv2-getAccountDetails, BGv1-getPaymentInitiationStatus and
OBPv7.0.0-getMyMetrics still resolve. Full local suite 3573/0.
"".split("/") returns Array(""), not an empty array, so berlinGroupV13AliasPath
was List("") on a default instance -- nonEmpty. Every downstream
`if (berlinGroupV13AliasPath.nonEmpty)` guard therefore took its ACTIVE branch
with an empty prefix: Http4sBGv13Alias published 55 docs stamped with the
degenerate ScannedApiVersion("", "", ""), whose operation ids came out as
`BG-<name>`, and its route bridge matched the prefix "/" (every request) only
to fall through again.

That was invisible while the alias sat outside the global operation-id union.
Now that this branch folds it in, those 55 junk ids became resolvable: verified
against a running default instance that api-collection-endpoint creation
accepted BG-getAccountDetails and BG-getPaymentInitiationStatus with 201,
naming endpoints no route serves. Filtering empty segments makes "unset" mean
"inactive" again -- both now return 400, while BGv1.3, BGv2, UK and OBP ids are
unaffected and /resource-docs/BGv1.3/obp still serves its 55 docs.

OBP_BERLIN_GROUP_1_3_Alias.apiVersion has to guard .head/.last against the now
genuinely empty list: the ScannedApis classpath scan catches a throwing
companion and only logs a warning, so an unguarded NoSuchElementException would
drop the alias silently. Inactive registrations keep the empty-string version,
which deliberately does not equal ConstantsBG.berlinGroupVersion1 -- colliding
there would let this doc-less object win ScannedApis' .toMap and blank out the
canonical BG v1.3 resource docs.

The alias assertions in both tests no longer depend on a prop that only exists
in a gitignored file. test.default.props is excluded by .gitignore:21, so the
CI workflows carried berlin_group_v1_3_alias_path while a fresh clone or an IDE
runner did not: deleting the line locally reproduced two failures whose
messages gave no hint a prop was missing. They now cancel with an explanatory
message when the alias is inactive, and read the expected operation id back
from the alias's own docs instead of hard-coding the BGv1- prefix, which is
derived from the configured path. Verified both ways: with the prop set 13/13
pass, without it 11 pass and 2 cancel. Full local suite 3573/0.
…passed

Two follow-ups from reviewing the registry work itself.

The scanned half was sorted by fullyQualifiedVersion, which concatenates
apiStandard.toUpperCase and apiShortVersion and can therefore collide across
distinct keys -- ("BG", "v1.3") and ("BGV", "1.3") both render "BGV1.3", and
berlin_group_v1_3_alias_path lets a deployment choose the alias's half of such
a pair. sortBy is only stable with respect to its input, and the input is the
unordered ScannedApis.versionMapScannedApis, so a tie would hand the order back
to hash iteration and with it the `.toMap` winner for a shared
partialFunctionName. Sort by (apiStandard, apiShortVersion) instead: that pair
is exactly ScannedApiVersion's equals/hashCode key, so two distinct keys of
that Map always differ in it and the order is total. The resulting sequence is
unchanged -- alias, BG v1.3, BG v2, UK 2.0/3.1/4.0.1 -- so BG v2 keeps winning
the names it shares with the alias.

The obpUnionVersion guard ranked versions with ApiVersionUtils.versions.indexOf,
which returns -1 for anything absent from that equally hand-maintained list. A
-1 loses every maxBy comparison, so adding a v8.0.0 aggregation to the registry
while forgetting ApiVersionUtils.versions left v7 as the maximum and the
scenario green -- precisely the two-places-to-edit slip it was written to catch.
Assert first that every OBP version in the registry can be ranked at all.
Verified by injecting an unregistered OBPv8.0.0: the guard now fails with
"OBP versions in the registry but missing from ApiVersionUtils.versions:
OBPv8.0.0", where before it passed. Full local suite 3573/0.
any grant targeting a consent user redirects to its granting human with
a warn-log; sole exemption is createdByProcess == consent_user.
ConsentUtil.addEntitlements tags its writes accordingly.
  - process column retired: parameter and accessor removed from the
    Entilement trait and implementation
    group queries now key on group_id; GroupEntitlementJsonV600 exposes
    created_by_process instead of process.
  - Explicit-target guards (400, "…names a consent user…"):
    addEntitlement v2.0.0 + v7.0.0; addUserToGroup v6;
    grantUserAccessToViewById v5.1; createAccountAccessRequest v6
    (reject at creation) plus repeated check at its approval;
    createAccount endpoints v2.0.0, v3.1.0, v4.0.0 (regular +
    settlement), v5.0.0, v7.0.0.
  - Implicit-target resolution to the accountable user (currently a
    human): bank-creator grants (v2.2, v5, v6, v7 incl. the
    generated-bank endpoint), v6
    dynamic-entity creator roles, v3.0.0 entitlement-request requester,
    createAccount owner fallbacks, and the connector-internal
    HOLDING-account holder.
  - Rename: effectiveHumanUserId → accountableUserId
…I version

Two defects found reviewing the registry against the union it replaced.

Berlin Group and UK Open Banking both publish getBalances, getAccountList and
getAccountBalances. Http4s600's top-apis/popular-apis and JSONFactory6.0.0's
metrics resolve a partialFunctionName with `.toMap`, which keeps the LAST
matching entry, so registry order decides the operation_id they report. The
hand-written union listed UK before BG, giving Berlin Group all three; sorting
the scanned standards alphabetically put UK last and silently flipped them to
UKv4.0.1-getBalances, UKv2.0-getAccountList and UKv2.0-getAccountBalances.
Replace the alphabetical sort with an explicit standardPrecedence (UK Open
Banking, then Berlin Group) and move Berlin Group v1.3 out of the explicit
block so it is ordered by that precedence rather than pinned ahead of it. A
standard absent from the list -- including the alias, whose apiStandard is
whatever berlin_group_v1_3_alias_path names -- ranks below all of them and can
never override a first-class standard. Verified against a running instance:
the three names resolve to BGv1.3-getBalances, BGv2-getAccountList and
BGv2-getAccountBalances again, matching the values measured before this branch.

A configuration-gated standard that is switched off reports
ScannedApiVersion("", "", ""), whose fullyQualifiedVersion is "" as well. While
ScannedApis kept that registration, ApiVersionUtils.valueOf("") resolved
successfully and, because the resource-docs route tolerates an empty path
segment, GET /obp/v7.0.0/resource-docs//obp answered 200 with an empty document
list where any other unknown version string gets 400 InvalidApiVersionString.
Drop unaddressable registrations in ScannedApis.versionMapScannedApis, which
fixes ApiVersionUtils, ResourceDocRegistry and Boot's version enablement in one
place. Verified: that request now returns 400, and BG v1.3, BG v2, UK 4.0.1 and
OBP v7.0.0 still serve 55, 22, 89 and 1031 docs. With the alias no longer
registered while inactive it is not a registry surface at all, so the parity
test's now-unreachable "cancel when unconfigured" branch is removed.

Both defects reached a green CI because nothing asserted either value; two
scenarios now pin them. Full local suite 3575/0.
…red name

standardPrecedence ranked a version by its apiStandard string, and the alias
takes that string from the first segment of berlin_group_v1_3_alias_path. A
deployment may point it at a name an existing standard already uses:
configured as "BG/v9" the alias reports ScannedApiVersion("BG", "BG", "v9"),
ranks alongside Berlin Group, and -- sorting after "v2" on the tie-breaker --
comes last, so its re-stamped copies won getBalances, getAccountList and
getAccountBalances away from the canonical docs it had copied. Metrics,
top-apis and popular-apis would then report BGv9-getBalances instead of
BGv1.3-getBalances. The comment on standardPrecedence claimed the opposite,
that the alias "can never override a first-class standard no matter how a
deployment configures it".

Match the alias by identity instead and rank it below every listed standard,
which makes that claim true for any configuration. sortKey takes the derived
alias version as a curried parameter and is package-private so the guarantee
can be tested against a synthetic alias, rather than only under whichever
berlin_group_v1_3_alias_path the JVM happens to have booted with.

Verified both directions with the new scenario: reverting to the string-based
rank fails it with "(1,BG,v9) was not less than (1,BG,v2)" -- the mechanism
itself -- and it passes with the fix. Full local suite 3576/0.
…alias-resource-doc-registry

refactor: single source of truth for resource-doc registry (closes BG v1.3 alias gap)
…weep-and-cache-contracts

test: endpoint sweeps and serialization contracts, and the defects they found
…ie stores

develop's work here is mostly one theme: a consent user must not accumulate durable roles or own
things in its own right. It landed as a guard inside Entitlement.addEntitlement plus explicit
per-endpoint checks, and it retired the entitlement `process` column in favour of group_id and
created_by_process. It also added mobile-phone fields to ResourceUser, an auth_type column and
activity-dashboard indexes to the metrics tables, GET /my/metrics with Top Users and Top
Consumers, a serialization namespace on every Redis memoize key, and a resource-doc registry that
gives the Berlin Group v1.3 alias a tie-free order.

All of that was written against Lift Mapper entities. This branch had already moved the
entitlement, resource-user and metrics tables to Doobie, so the resolution carries the behaviour
across rather than restoring the entities. The on-behalf-of guard reads createdByConsentId from
the Doobie row (an Option, so the null/empty dance is gone) and is otherwise unchanged. The
columns develop added by Schemifier come from db.changelog-develop-merge.yaml instead, since
ToSchemify.models is empty on this branch and Schemifier creates nothing: a Mapper column here
would compile and then not exist.

Redis: this branch replaced scalacache with its own memoize layer, so develop's namespace is
applied in redisMemoKey rather than through CacheConfig. The two key-format tests move from
asserting the sampled envelope as the whole key to asserting it as the suffix - equality against
the bare sample would now be asserting the absence of the namespace, which is the opposite of
what develop added it for.

Three defects were introduced while resolving conflicts and are fixed here rather than left for
CI, all three invisible except through a symptom somewhere else:

The master changelog gained two `- include:` entries folded into one YAML mapping. Duplicate keys
are not an error - the last wins - so db.changelog-provenance.yaml was silently dropped and the
tables it creates were never made. It surfaced as `Table "CHAT_EMAIL_DIGEST_STATE" not found`
from a DELETE in the per-class test reset, with every shard aborting before it ran a test. No
existing check could see it: they each read a changelog on its own and none asked whether master
still referenced it. check_changelog_preconditions.py now verifies that every schema changelog is
included exactly once, and that the `- include:` and `file:` counts agree.

The entitlement INSERT kept `process` in its column list with a value of `""`. In SQL that is a
quoted identifier, not an empty string, so the statement never parsed - and addEntitlement wraps
the write in tryo, so the grant silently did not happen. 642 scenarios failed with 403 across
suites that never mention entitlements. The column is nullable and the field is retired, so it is
simply absent from the statement now.

MetricQuery collected OBPUserId but not OBPUserIds, so the server-locked user set behind
GET /my/metrics was dropped and the endpoint returned every user's rows - a data leak, not just a
failing test. It is now rendered as `userid IN (...)`, with an empty set matching nothing rather
than removing the clause: no visible users is not the same as no restriction.

Each of the three has a test that fails on the defect and names it, rather than leaving the next
person to work back from a 403 or a missing table.

4073 scenarios pass on H2 and on Postgres.
develop widens dynamicresourcedoc.examplerequestbody, .successresponsebody and
.errorresponsebodies with MigrationOfDynamicResourceDocBodyFieldsLength, whose own comment says a
response example "routinely exceeds varchar(255)". That migration reads Mapper metadata which does
not exist on this branch and was deleted in the merge along with two others; the other two got
changesets, this one did not, so the three columns stayed at the baseline's VARCHAR(255) and any
body over 255 characters failed the INSERT outright. The endpoints wrap the write, so the caller
saw a generic error rather than a length complaint.

${text.type} is the per-vendor spelling the baseline already uses for methodbody, so the columns
end up where the migration intended: text on Postgres, wide enough not to be a limit on H2.

The precondition is a sqlCheck rather than the columnExists the other changesets use, because the
columns are present either way and the question is their width. It reads
character_maximum_length, which is NULL once the type is unbounded, so a database already widened
by the upstream migration counts zero and marks this run instead of re-applying it.

check_changelog_preconditions.py rejected the changeset until it was taught modifyDataType - by
design, since it refuses to pass a change type it does not know the right precondition for. It now
requires a sqlCheck reading information_schema for these, and says why tableExists/columnExists
cannot serve.
serializationNamespace exists so two builds whose Kryo encodings differ cannot address each
other's entries. It derived its discriminator from scala.util.Properties.versionNumberString,
which reads the STANDARD LIBRARY - and Scala 3 compiles against the 2.13 one, so it answered
"2.13" here too. This branch therefore produced byte-identical keys to develop, on exactly the
upgrade the namespace was added to protect: measured as the prefix "obpser1-scala2.13" in this
branch's own golden-key test output, on a build whose scala.compiler is 3.3.8.

The failure that follows is the one already documented above the value: an entry written by one
chill/Scala combination decodes under the other into a different collection type, the decode
succeeds, and the call site whose signature says List gets a ClassCastException - a 500 for the
whole TTL, since a read that throws does not evict.

The compiler generation is not in any version string the runtime exposes, so it is a class probe:
scala.runtime.Scala3RunTime ships in scala3-library and does not exist in scala-library 2.13. Both
halves are kept ("3-lib2.13"), because the encoding depends on the compiler that produced the
classes and on the library they were compiled against. A 2.13 build keeps develop's spelling
exactly, so only this side moves and no one else cold-starts a cache.

The test probes with a different Scala-3-only class (scala.runtime.LazyVals$) than the
implementation uses: repeating the production probe would make the test agree with it however
wrong both were.
`CurrentNamespace should include("3")` was meant to say the namespace names the compiler
generation. It says nothing: "obpser1-scala2.13" contains a '3' as well, so the assertion held in
precisely the state the test exists to reject. The real work was being done by the line above it,
and this one only added false confidence. It now looks for "scala3".

Two comments corrected alongside it, both wrong in ways a reader would act on:

Redis.scala - the block explaining what the namespace is for had a second doc comment placed
between it and `serializationNamespace`, so it documented nothing and the value it explains was
left bare. The probe moves above it.

db.changelog-develop-merge.yaml - the precondition's comment said character_maximum_length is NULL
for an unbounded type. That is Postgres and MySQL; H2 reports 1000000000 and SQL Server -1. The
changeset is correct either way because it counts columns still at exactly 255, which is what the
comment now says.
getDistinctParentIds and getParentIdWithAttributes were written for
AttributeQueryTrait.getParentIdByParams and NewAttributeQueryTrait.getParentIdByParams.
Both traits are dead code with zero mixers anywhere in the tree, removed in the next
commit as part of the net.liftweb.mapper cleanup - and neither ever called into these
two methods either, so their removal leaves this file's only remaining function,
getDistinctProviders, unaffected.

Found while auditing the mapper cleanup's blast radius: the doc comments on both
methods asserted a caller that had not existed since the traits were deleted, which
is worse than no comment at all.
First step of unbundling lift-persistence: the fork's mapper package is 46.5% of its
lines, has no upstream Scala 3 port (Lift itself deleted persistence rather than
porting it), and OBP-API has had zero live Mapper entities since the Doobie migration
completed (ToSchemify.models = Nil). This removes the last obp-api references to it,
without touching the dependency itself - obp-api still pulls in lift-persistence for
common/util/db, same as before.

Deleted outright (all confirmed zero external references, not just zero imports):
AttributeQueryTrait/NewAttributeQueryTrait (self: BaseMetaMapper, no mixers anywhere),
CommonFunctions (validUri/validUrl, zero call sites), MappedAccountNumber/
DefaultStringField/MappedUUID/UUIDString (MappedString subclasses with no entity left
to use them), and MappedClassNameTest - which asserted over classOf[Mapper[_]]
subtypes, a set that has been permanently empty since the last entity was moved to
Doobie. It is the same "assertion that could not fail" shape as the CacheKeyFormatTest
fix earlier on this branch.

Two deletions needed care because nothing importing net.liftweb.mapper pointed at
them - they are reachable only through a class-name string, so removing the jar
without removing these would compile clean and then fail at runtime:
  - JsonSerializers.MapperSerializer: ReflectUtils.forType("net.liftweb.mapper.Mapper")
    inside an eager val, wired into the json4s Formats chain. Deleting the object
    without also dropping it from the `serializers ::` list would leave a reference
    to a name that no longer exists.
  - ClassScanUtils.getMappers: Class.forName("net.liftweb.mapper.LongKeyedMapper")
    inside a try/catch that logs and returns Nil on any Exception - the failure mode
    a `net.liftweb.mapper`-string grep cannot see and a deleted jar would hit silently.
    Zero callers, confirmed before deletion.

LocalMappedConnectorDataImport.MappedSaveable (zero instantiations) is deleted the
same way, with the historical comments at its three call-alike sites updated to say
"the now-removed MappedSaveable" rather than describing a type that no longer exists.
Same treatment for two comments in DoobieQueries.scala that credited
AttributeQueryTrait/NewAttributeQueryTrait as callers of getDistinctParentIds/
getParentIdWithAttributes - untrue even before this commit, since those two methods
already had zero callers (deleted separately, previous commit).

Remaining touches are narrowing, not removal: 18 migration scripts had a dead `DB`
import alongside the `Schemifier` one they actually use (`Schemifier.infoF` as a
logging callback - handled in the next commit), and three files had a dead
`import code.util.{MappedUUID, UUIDString}` left over from before those types moved to
Doobie-native construction.

Verification: mvn -Pprod -DskipTests clean install clean on first pass (deletions are
self-checking - the compiler is the reachability proof). H2 Surefire audit: 4073/0/0
(4075 - the 2 MappedClassNameTest scenarios, the only test-file change here).

Postgres was flakier to pin down and worth recording. Three concurrent 4-shard runs
and one 6-shard run all failed, but never on a real assertion:
  - Shard 2 hit `run_tests_parallel.sh`'s 1200s per-shard timeout in every attempt,
    once at 19m51s - a hair under the cap. The JVM's own shutdown hooks fired cleanly
    mid-scenario each time, with zero exceptions, zero OOM/jetsam events, zero
    Postgres connection errors in any log. This machine had a second worktree's
    orphaned scalatest fork alive for >40h during every attempt (a stray
    forkMode=once JVM this repo's own comments already document as a known
    reparenting hazard) plus this session's own earlier background work, pushing
    load average past 7 - not something to kill blindly (not owned by this session),
    so shard 2's package set was instead run standalone (own Postgres database, own
    ports, 1800s budget, no sibling shards competing for CPU): 1197 succeeded, 0
    failed, 0 exceptions.
  - Shard 3 failed once, on ResourceDocsTest's v4.0.0 scenarios, with a
    scala.xml.XML.loadString error on a literal "<random-string>" placeholder inside
    an existing (untouched by this commit) v4.0.0 endpoint description -
    `resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description))` only
    XML-validates the first three docs returned, so whether this fires depends on
    resource-doc ordering, not on anything this commit changed. The suite passed
    standalone (63/63) and again as part of shard 3's full package set run the same
    isolated way as shard 2: 1008 succeeded, 0 failed.

Both isolated runs together cover every package the 4-shard split runs; shards 1 and 4
were clean across all three concurrent attempts. That is full Postgres coverage, green,
just not all four shards inside one concurrent invocation this particular machine could
sustain today.
…local ones

Second step of unbundling lift-persistence, following the mapper-surface deletions in
the previous commit. Four symbols were still genuinely called (not just imported) from
obp-api, none of them mapper-specific in behaviour - Schemifier's logging callback and
schema-name lookup both operate purely on net.liftweb.db types, and DB/
DefaultConnectionIdentifier under the mapper package are forwarders to the db/util
originals, not distinct implementations. Decompiled the shipped jar (javap) to copy
each one exactly rather than guess:

  Schemifier.infoF(msg: => AnyRef): Unit = logger.info(msg)     - unwrapped, verbatim
  Schemifier.getDefaultSchemaName(conn: SuperConnection): String =
    conn.schemaName.or(conn.driverType.defaultSchemaName).or(DB.globalDefaultSchemaName)
      .openOr(conn.getMetaData.getUserName)                     - unwrapped, verbatim

Both now live on Migration.DbFunction, next to the tableExistsByName/
makeBackUpOfTableByName helpers that already carried the "copied from
net.liftweb.mapper.Schemifier" comment for the same reason. 62 call sites across 41
migration scripts and StoredProcedureUtils.scala move from `Schemifier.infoF _` to
`DbFunction.infoF _` - a mechanical substitution, verified uniform first: every one of
those 41 files used Schemifier for infoF and nothing else, and every one already
imported DbFunction unqualified for other Migration helpers, so the now-dead
`import net.liftweb.mapper.Schemifier` line comes out alongside each substitution.
`net.liftweb.mapper.DB` becomes `net.liftweb.db.DB` in Migration.scala (11 call sites)
and `net.liftweb.mapper.DefaultConnectionIdentifier` becomes
`net.liftweb.util.DefaultConnectionIdentifier` in DBUtil.scala - both confirmed
identical singletons by decompiling: `mapper.DB` is `object DB extends db.DB1`, and
`mapper.DefaultConnectionIdentifier` is a one-line forwarder to `util.DefaultConnectionIdentifier`.

Migration.DbFunction.tableExists(BaseMetaMapper, ...) and makeBackUpOfTable(BaseMetaMapper)
are deleted outright: both were the last two consumers of BaseMetaMapper, both had zero
callers (confirmed by grep before deletion - the only remaining hits are doc comments in
other migration scripts that already say the entity behind them is gone), and both have
had *ByName successors in active use for a while.

Two call sites intentionally untouched: Boot.scala:540 and
MockedRabbitMqAdapter.scala:3322 still call Schemifier.schemify(true, Schemifier.infoF _,
ToSchemify.models: _*) on an empty list - a no-op, but the whole call and its
ToSchemify.models plumbing come out in the next commit along with Boot's remaining
Schemifier-adjacent setup, rather than half-migrating a call this commit does not also
delete.

net.liftweb.mapper now has zero live references from obp-api (grep -rn
"net\.liftweb\.mapper" obp-api/src/main | grep -v '^\s*//' turns up only the two
Boot.scala/MockedRabbitMqAdapter.scala schemify calls and pre-existing commented-out
Lift-era files this refactor does not touch).

Verification: clean compile in one pass. H2 Surefire audit: 4073/0/0, unchanged from
the previous commit (no test files touched here).

Postgres: given the previous commit's documented machine-load flakiness on concurrent
shards, went straight to isolating each of the 4-shard split's package sets against its
own database and ports rather than re-running the concurrent layout first - two pairs
run concurrently (shard 1 with shard 4, then shard 2 with shard 3) for a bounded total
runtime without reintroducing the contention that caused the earlier timeouts. All four
green: shard 1 535/0, shard 2 1197/0, shard 3 1008/0, shard 4 1285/0 - full coverage of
the 4-shard layout, all BUILD SUCCESS, zero FAILED markers anywhere.
Third and final step of removing net.liftweb.mapper from obp-api. The previous two
commits took every reference down to two schemify calls, both already no-ops
(Schemifier.schemify(true, Schemifier.infoF _, ToSchemify.models: _*) on an empty
list), plus a MapperRules setting and a MetaMapper-typed field that fed them. All
four come out here, along with the last wildcard mapper import.

  - Boot.scala:173's MapperRules.createForeignKeys_? assignment: the only reader was
    Schemifier, and Schemifier's argument was always Nil, so this configured a
    foreign-key policy for a schema-creation pass that never created anything. The
    mapper_rules.create_foreign_keys prop it read is retired (release_notes.md, both
    props templates).
  - Boot.scala:539's schemifyAll(), renamed createDefaultChatRoom() with the
    Schemifier.schemify line removed - it kept exactly one live side effect
    (getOrCreateDefaultRoom()) and the name should say so, not describe schema work
    that stopped happening once ToSchemify.models went to Nil.
  - MockedRabbitMqAdapter.scala:3322's identical schemify call, and its now-dead
    net.liftweb.mapper.Schemifier / bootstrap.liftweb.ToSchemify imports.
  - ToSchemify.models itself: not just emptied, deleted. The object stays (it also
    starts the optional gRPC server and registers a JVM shutdown hook, unrelated to
    schema). Its four remaining "importers" - ServerSetup, LocalMappedConnectorTestSetup,
    TestConnectorSetupWithStandardPermissions, SandboxDataLoadingTest - never actually
    read the field; each import was dead weight left over from when their reset loops
    iterated it. Removing them is confirmed safe by the same evidence that made the
    field safe to delete: obp-api has had zero live Mapper entities since the Doobie
    migration finished.
  - Boot.scala:64's `import net.liftweb.mapper.{DefaultConnectionIdentifier => _, _}` -
    the wildcard that supplied MapperRules, Schemifier and MetaMapper to this file.
    Nothing else in it needed anything from that package.

LiquibaseSchemaSetupTest asserted `ToSchemify.models shouldBe empty` as half of pinning
"liquibase.enabled defaults to true because nothing else creates a table." That
assertion doesn't compile once the field is gone, and doesn't need to: the invariant it
protected is now enforced by the compiler rather than by a runtime check, since there
is no Schemifier.schemify call left anywhere in obp-api to accidentally un-empty a list
that no longer exists. Rewrote the test and the doc comments in LiquibaseSchemaSetup.scala
and LiquibaseOnExistingSchemaTest.scala that described the old mechanism, so none of them
keep pointing at a symbol that isn't there.

One more comment turned out to be stale independently of this refactor, caught only
because it was about to become more obviously wrong: AtmTableResetIsolationTest.scala's
doc comment said MappedAtm was "still in Boot.ToSchemify.models" and reset "happens for
free" via that list's bulkDelete_!! loop - checked, and all four reset paths it lists
already carry an explicit `DELETE FROM mappedatm` (ServerSetup:150 and the same line
number pattern in the other three). MappedAtm moved to Doobie a while ago; the comment
was never updated to say so. Corrected to describe the current mechanism instead of a
superseded one.

obp-api/pom.xml's comment on the lift-persistence dependency said Scala 3 doesn't exist
"see docs/scala3-lift-mapper-blocker.md" as if obp-api's own code were still blocked by
it. It isn't, any more - grep -rn "net\.liftweb\.mapper" across obp-api and obp-commons
main sources now turns up only comments and the pre-existing entirely-commented-out
Lift-era files this refactor doesn't touch. What is still pinned to _2.13 is the
ARTIFACT: lift-persistence bundles common+db+mapper+proto+util as one jar, and no
Scala 3 build of the bundle exists because mapper can't compile under Scala 3. Reworded
to say that rather than implying obp-api's own mapper usage is the blocker.

Verification: clean compile in one pass. H2 Surefire audit: 4073/0/0, unchanged (the
4 dead-import deletions and the LiquibaseSchemaSetupTest rewrite add or remove no
scenarios). Postgres: same isolated-per-shard-pair strategy as the previous commit,
same numbers - shard 1 535/0, shard 2 1197/0, shard 3 1008/0, shard 4 1285/0, all
BUILD SUCCESS, zero FAILED anywhere.

Also did the one check the test suite cannot: a real production-mode boot
(flushall_build_and_run.sh, backed by an isolated in-memory H2 rather than any
suite's shared setup) reached `Ember-Server service bound to address: 127.0.0.1:8080`
with no ExceptionInInitializerError and no Schemifier line anywhere in the log, then
served two live requests against it - GET /obp/v5.1.0/root (200) and
GET /obp/v5.1.0/resource-docs/v5.1.0/obp (200, 3.5MB, 599 resource_docs entries) - the
second one specifically to drive the json4s Formats chain end to end now that
MapperSerializer is gone from it (removed two commits ago), on a real multi-megabyte
payload rather than a test fixture.

End state: grep -rn "net\.liftweb\.mapper" obp-api/src obp-commons/src, filtered to
non-comment lines, returns nothing. obp-api's dependency on net.liftweb.mapper is zero.
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants