Skip to content

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

Closed
hongwei1 wants to merge 476 commits into
build/scala-2.13-migrationfrom
build/scala-3-migration
Closed

build: pay the Scala 3 debt on 2.13 (flip sequenced after Doobie)#91
hongwei1 wants to merge 476 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

@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

@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

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

Comment thread scripts/h2_to_postgres_migrations.py Fixed
Comment thread scripts/h2_to_postgres_migrations.py Fixed
Comment thread scripts/h2_to_postgres_migrations.py Fixed
Comment thread scripts/normalise_generated_changelog.py Fixed
Comment thread scripts/normalise_generated_changelog.py Fixed
Every _3 dependency pulls in its own scala3-library_3 transitively, each at
whatever version it happened to build against (observed range 3.0.0-3.3.6
across the dependency tree), and Maven's mediation was resolving that down
to 3.3.4 - older than the 3.3.8 compiler actually compiling this module's
sources.

The mismatch is not cosmetic: the first full-suite run under the flipped
compiler hung every shard indefinitely at startup with no output past
classloading, silently, well before any test could log anything. The only
clue was a JVM warning naming scala.runtime.LazyVals - the class Scala 3
uses to implement `lazy val` initialization - loaded from
scala3-library_3-3.3.4.jar. That machinery changed its locking
implementation more than once across the 3.3.x line; running bytecode
compiled for 3.3.8 against 3.3.4's runtime support class is exactly the
kind of mismatch that would deadlock lazy val initialization rather than
fail cleanly. This migration's own changes made that failure mode likely:
OBPRestHelper.version/versionStatus and Saveable.value both went from
strict to lazy val in the previous commit, and both sit on the CallContext/
Connector initialization path every request and every test touches.

A direct dependency in obp-api's own pom overrides the transitive version
regardless of tree depth, pinned to ${scala.compiler} so the two can never
drift apart again. Verified: `mvn -pl obp-api dependency:tree` now resolves
a single scala3-library_3:3.3.8 with no other version anywhere in the tree,
and `mvn -pl obp-api -am test-compile` stays clean.
sensitivePatterns' initializer calls APIUtil.getPropsAsBoolValue, the first
touch of APIUtil$ on that thread, which triggers APIUtil$'s own class init -
eagerly evaluating publicAppUrlDefaults, which logs a debug message, which
(every log call in MdcLoggable routes through maskSensitive) calls back into
maskSensitive -> sensitivePatterns, on the same thread, before the first call
returns. Scala 2's lazy val used a reentrant `synchronized` block, so this
recursive call silently passed through; Scala 3's LazyVals uses a
CountDownLatch, which is not reentrant, so the thread deadlocks waiting on a
latch only it could count down - every test shard hung identically at
startup.

A ThreadLocal flag detects that one-time computation window and returns the
message unmasked rather than recursing; everything logged in that window is
APIUtil/SecureLogging's own prop-driven startup, not request data.
…sLazy miss

getFieldValues filters candidate members by isLazy/isVal/isVar, which read
from Scala's own declaration metadata (ScalaSig for Scala 2, TASTy for Scala
3). scala.reflect.runtime.universe - the Scala 2.13 reflection library
obp-commons is pinned to - has no TASTy reader, so all three come back false
for every member of a Scala-3-compiled class; only the bytecode-level shape
(a zero-arg method with a return type) survives. ExampleValue.exampleNameToValue
(a Scala-3-compiled obp-api object, scanned via this function) resolved to an
empty map as a result, and any downstream lookup against it threw at
initialization time.

The added clause recovers that shape, but it also matches a case class's own
synthetic zero-arg methods (toString, hashCode, productArity, ...), so it is
only safe on a target whose zero-arg methods are all real fields - true for
the plain objects getFieldsNameToValue scans, not for an arbitrary case-class
instance. JSONFactory1_4_0's generic field-extraction branch reads a case
class instance's fields for JSON schema output, so it is switched to
scala.Product's productElementNames/productIterator instead, which needs no
reflection metadata at all and can't pick up synthetic methods by construction.
…companion

values ran eagerly, in OBPEnumerationBase's own constructor - while the
concrete companion object (e.g. AuthenticationType$) is still mid-<clinit>,
before the JVM marks the class initialized. Scala's runtime reflection,
asked to inspect that same not-yet-initialized class from inside its own
construction, silently returned every member symbol with isModule false, so
decls.filter(_.isModule) found nothing and the assertion threw at <clinit>
time - reproduced identically for a Scala-2-compiled companion once isolated,
so this is a self-reflection-during-own-<clinit> problem, not a Scala-3/TASTy
one. Deferring to first external access (lazy val), after the class is fully
initialized, gets decls.filter(_.isModule) back to finding the right symbols.

Symbol -> runtime instance still can't go through
mirror.reflectModule(sym).instance though: for a Scala-3-compiled nested
module, ModuleMirror's own name resolution reconstructs the wrong binary
name and reflectModule throws ClassNotFoundException. That step is done by
hand instead - a nested object's binary name is always
"<outer's binary name><simple name>$" regardless of which Scala version
compiled it, and loading it plus reading its MODULE$ field is reliable
either way.

Order is a narrower, separate caveat: decls preserves source declaration
order for a Scala-2-compiled companion (asserted on by OBPEnumerationTest),
but not for a Scala-3-compiled one, where it comes back in some other
deterministic order instead. AuthenticationType, the only Scala-3-compiled
subclass today, uses values only as an unordered set, so this doesn't bite
in practice; documented inline for whoever adds the next one.
…Sig poisoning

Four layered problems, found and fixed in sequence, all on the path to
letting json4s actually extract/decompose a Scala-3-compiled case class:

1. org.json4s:json4s-native_3:4.1.0-M8 (Maven Central's last publish there,
   2024-11-07) still throws "Can't find ScalaSig for class ..." extracting a
   Scala-3-compiled case class. The project moved to io.github.json4s after
   M8; 4.1.1 there is the first stable release with a working Scala-3
   quotes-based reflector (core/src/main/scala-3/.../ScalaSigReader.scala
   upstream). Root pom, obp-commons and obp-api all repoint to the new
   groupId.

2. obp-commons (permanently Scala 2.13) and obp-api (now Scala 3) end up
   needing both json4s-native_2.13 and json4s-native_3 transitively, and
   both publish org.json4s.reflect.ScalaSigReader under the identical class
   name with a different implementation - the _2.13 one has no notion of
   the Scala-3 quotes-based reader. Whichever jar the classloader resolves
   first wins for every caller, and it consistently picked _2.13, silently
   reintroducing the ScalaSig error for Scala-3-compiled classes even with
   4.1.1 on the classpath. obp-api excludes json4s-native_2.13 transitively
   from obp-commons, forcing the whole reactor onto _3 - the "verify binary
   compatibility" step the json4s-scala3-matrix memory flagged as the
   fallback if json4s's own upgrade wasn't enough on its own (it wasn't).

3. That exclusion breaks obp-commons's own json4s code in turn:
   JsonSerializers.scala's CustomFormats (extends DefaultFormats) and its
   Serializer[T] implementations are Scala-2.13-compiled trait mixins:
   Scala 2 compiles a trait's field initialization through a $init$
   forwarder method that Scala 3 does not generate, so a $init$ call
   against the _3 jar's Formats/Serializer throws NoSuchMethodError at
   runtime. JsonSerializers.scala and JsonBoxSerializer.scala move into
   obp-api (code.api.util) so they get compiled by Scala 3 against the
   Scala-3 json4s jar they now depend on; JsonAble - a plain interface with
   no trait-field-init concerns, mixed into several obp-commons case
   classes that must stay in obp-commons - splits out to its own file and
   stays behind, and the two Scala-3-only TypeTag call sites the move
   exposed (ru.typeOf[optional], ru.typeOf[Mapper[_]], plus a handful of
   ru.typeOf[Long/Double/...]) go through ReflectUtils.forType instead.

4. Even with the right reflector selected, a ScalaSig-pickled override
   whose signature names org.json4s.JsonAST.JValue directly is unreadable
   from a Scala-2.13-compiled class once json4s-native_2.13 is off the
   classpath - reflecting anywhere near it throws "unsafe symbol JValue
   (child of class JsonAST) in runtime reflection universe". Every
   obp-commons toJValue override (ApiVersion, CommonModel's
   CounterpartyLimit, Enumerations' SimpleEnum, RequiredFieldValidation's
   three implementors) is repointed at the json.Formats/json.JValue
   aliases in JsonAliases.scala instead of importing org.json4s directly -
   those aliases are themselves Scala-2.13-compiled symbols in the same
   package, so they stay resolvable, and the method bodies are unaffected
   since json.JValue =:= org.json4s.JValue.

Left over after all four: json4s's Scala-3 ScalaSigReader.readField (used
to recover a generic field's erased type argument, e.g. an Option[String])
throws NoSuchElementException: None.get introspecting a Scala-2.13-compiled
DTO - a genuine upstream limitation, unchanged between json4s 4.1.1 and
4.2.0-M4. RabbitMQConnector_vOct2024's two ResourceDoc examples that hit it
(Extraction.decompose on InBoundOpenCorridorCreditNotificationData /
InBoundOpenCorridorSettlementData, both obp-commons DTOs with Option
fields) are hand-built with the JsonDSL ~ operator instead, since they are
fixed example literals, not runtime data. ConnectorUtils.scala and
MSsqlStoredProcedureBuilder.scala have generic Extraction.decompose(Any)
call sites that can hit the same bug on arbitrary connector messages and
still need a real fix, not a one-off literal rewrite.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
5.1% Duplication on New Code (required ≤ 3%)
E Security Rating on New Code (required ≥ A)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Its own docstring promised "call by name methods OR val values", but the
code only ever handled the method branch: it asserted methodSymbol.isMethod
unconditionally, which was true for every Lift Mapper entity this was
written against (Mapper exposes every column as a call-by-name accessor
def). Post-Mapper-to-Doobie migration, an entity like MappedBankAccount is a
plain case class, so its fields (e.g. accountPrimaryKey) are ordinary
constructor vals - isMethod is correctly false for them, and the assertion
threw "accountPrimaryKey is not method in Object MappedBankAccount(...)"
for every one, breaking findBankIdIn and cascading into a 500 on every
connector call that routes through it (surfaced here via
saveHistoricalTransactionViaEndpoint, but not specific to it).

Not a Scala-3-reflection gap like the isVal/isVar/isLazy ones elsewhere in
this file: confirmed via an isolated diagnostic that isVal/isMethod are
both read correctly here, they just don't match what this function assumed.
Added the missing val/var branch, reading the field directly instead of
searching for a zero-arg method alternative.
…" field

Copying a same-named parameter from a source object to a target constructor
worked by reflecting it as a method; when the source symbol wasn't a method
it fell back to reflecting a completely unrelated hardcoded field named
"attributes" instead of the actual parameter. That fallback firing at all
depended on the source field genuinely not being a call-by-name method - true
for whatever original case needed it, but also true for any ordinary case
class constructor val, which is what most source objects going through
ConverterWithType.toCommons actually are. Every plain val parameter hit the
"attributes" fallback, and once no source object actually had an "attributes"
member either, that threw ScalaReflectionException: <none> is not a method -
surfaced here via createDynamicEntityImpl's use of ConverterWithType, 500ing
customer/bank dynamic entity creation across almost every DynamicEntityTest
scenario.

Same shape as the getCallByNameValues fix: added the missing val/var branch,
reading the field directly instead of assuming every source member is a
method. The "attributes" fallback is kept as the last resort, unchanged, for
whatever case actually relies on it.
…f converting it

NewStyle.function.getDynamicEntities returns List[DynamicEntityT] - the
provider trait - not List[DynamicEntityCommons]. Seven call sites across
Http4s400/Http4s600 (system/bank/my dynamic entity listing endpoints) took
that as List[DynamicEntityCommons] via a blind asInstanceOf, relying on the
runtime instances happening to already be DynamicEntityCommons. They threw
ClassCastException as soon as that stopped being true, 500ing every affected
DynamicEntityTest scenario.

DynamicEntityCommons's own companion is a ConverterWithType[DynamicEntityT,
DynamicEntityCommons] - it exists specifically to do this conversion via
reflection (the same ReflectUtils.toOther machinery fixed for Scala 3
case-class-val sources earlier this session), so the cast is replaced with
DynamicEntityCommons.toCommonsList(...) instead of asInstanceOf.
…tching, getEnumContainer

Three more reflection gaps the full suite surfaced beyond the 5 originally-crashing classes
(all previously masked by the boot crash never letting these code paths run):

1. SwaggerTypes.scala used typeOf[JObject] et al. directly - needs TypeTag synthesis for
   org.json4s's own type symbol, which scala.reflect.runtime.universe can't resolve once
   json4s-native_2.13 is off obp-api's classpath (same ScalaSig-poisoning shape as the earlier
   json.* alias fixes, but via typeOf instead of a plain import). Rewritten to build every
   json4s-involving Type from ReflectUtils.forType + ru.appliedType instead of typeOf, which
   needs no TypeTag synthesis. Also fixed the class names themselves: org.json4s.JsonAST.JObject
   doesn't exist as a loadable class in json4s 4.x - JsonAST is a legacy compatibility object,
   the classes live directly under org.json4s.

2. getFieldValues' widened zero-arg-method filter (from the isVal/isVar/isLazy fix earlier this
   session) matched too much: java.lang.Object's notify()/wait() (reflectMethod on those outside
   a synchronized block throws IllegalMonitorStateException), scala.Any's asInstanceOf/
   isInstanceOf (reflectMethod refuses to invoke a generic method at all), and even an unrelated
   JDK-internal interface method on whatever object JSONFactory1_4_0's unfiltered fallback branch
   happened to be looking at. Restricted the clause to it.owner == tp.typeSymbol - a member
   actually declared on the target's own class, never one it merely inherits - which excludes
   all of the above at once instead of naming bad owners one at a time.

3. OBPEnumeration.getEnumContainer (both overloads) used knownDirectSubclasses.head to find any
   one enum value and walk back up to its companion. knownDirectSubclasses reads Scala's own
   declaration metadata the same way isVal/isVar/isLazy and OBPEnumerationBase.modules do, and a
   Scala-3-compiled sealed trait reports zero known subclasses to it - .head then threw
   NoSuchElementException. The function never needed a subclass in the first place, only the
   companion - and a companion's binary name is always "<trait's name>$" regardless of compiler,
   the same technique OBPEnumerationBase.modules already uses. getValuesByInstance's own
   getInterfaces().headOption lookup (used to find which trait an enum value implements before
   calling getEnumContainer) got a matching fix: Scala 3 doesn't always list an EnumValue-derived
   trait before EnumValue itself, so headOption sometimes returned EnumValue - now finds the
   interface that extends EnumValue without being it.

Combined verification (the 5 originally-crashing classes + SwaggerFactoryUnitTest +
ResourceDocsTechnologyTest): 375/377, no regressions from any of these three changes.
ResourceDocsTechnologyTest's remaining 2 failures are a separate, still-open issue - see
project_scala3_json4s_extraction_saga memory.
…tEnumContainer cast bug

Two more root causes on the same "resource docs generation" path, found chasing the same 2
scenarios through successive fixes:

1. JSONFactory1_4_0's unfiltered getFieldValues fallback branch reflected over whatever
   non-Product AnyRef it was given, with no check that the value was ever meant to be
   schema-relevant OBP data. Somewhere in the v5.0.0/v6.0.0 resource doc tree this reached a
   genuine java.util.stream.ReferencePipeline object, and getFieldValues' isMethod-shaped filter
   (declared directly on the target's own class, so not excluded by the owner check from the
   previous commit) picked up its internal opIsStateful() method, which reflectMethod can't
   invoke without an InaccessibleObjectException. Restricted the branch to
   ReflectUtils.isObpObject(entity) - a JDK type was never meant to reach this code path at all.

2. Underneath that: OBPEnumeration.getEnumContainer's return type was hardcoded to
   OBPEnumeration[T]. AuthenticationType (obp-api, Scala 3) extends OBPEnumerationWithType[T],
   a sibling of OBPEnumeration[T] under the shared OBPEnumerationBase[T] - not a subtype of it -
   so the function's own .asInstanceOf[OBPEnumeration[T]] threw ClassCastException the moment it
   was ever asked to resolve AuthenticationType specifically (a live bug independent of this
   session's knownDirectSubclasses fix, exposed by it rather than caused by it, since
   OBPEnumerationWithType didn't exist until AuthenticationType was introduced). Every caller
   only uses values/withNameOption/withIndexOption/example, all declared on the shared
   OBPEnumerationBase, so widening the return type to it is a pure fix with no narrowing loss.

SwaggerFactoryUnitTest + ResourceDocsTechnologyTest: 13/13, both fully green.
… frozen test

RestConnector_vMar2019_FrozenTest aborted its whole shard with
Symbols$CyclicReference: illegal cyclic reference involving class BodyPartParser.
Bisection showed this is not one bad member among many: any attempt to resolve
RestConnector_vMar2019's own scala-reflect Type - decls, baseClasses, or asking
any of its members whether they override something - forces scala.reflect.runtime
to complete an unrelated symbol reachable from its inheritance chain, and that
completion fails inside the JVM-wide shared symbol table, not inside the specific
symbol being asked about. Per-symbol try/catch around the caller's own loop
cannot catch it, no matter how granular.

Route around it instead of trying to catch it: get the override-eligible method
names from Connector's type, which is a plain OBP domain trait and resolves
cleanly, then cross-reference against RestConnector_vMar2019's own declared
methods via java.lang.Class reflection, which never touches
scala.reflect.runtime.universe and so cannot hit this. Verified byte-for-byte
equivalent to the old list against the persisted frozen metadata file.
SwaggerScalarFieldTypeTest and SwaggerOptionFieldTypeTest declared their fixture
case classes nested inside the AnyFlatSpec test class. SwaggerJSONFactory.
translateEntity reflects on a fixture value's runtime type via
scala.reflect.runtime.universe, and for a nested case class that also means
resolving the enclosing class - here a ScalaTest suite mixing in Assertions.
Walking that unrelated third-party hierarchy throws (AssertionError: no symbol
could be loaded from class org.scalatest.Assertions$UseDefaultAssertions$, and
separately illegal cyclic inheritance once the enclosing class's own type
becomes unresolvable).

Moving the fixtures to file scope removes the enclosing class scala-reflect
would otherwise have to walk. No production code changed.
…la 3

ToolBox is Scala 2.13-only and cannot read TASTy, so it fails on any
dynamically-compiled snippet that references obp-api's own Scala-3-compiled
classes (OBPReturnType, HttpCode, and friends injected via
DynamicUtil.importStatements). This was the largest remaining source of test
failures after the Scala 3 flip.

DotcScalaCompiler drives dotty.tools.dotc.Driver directly: each distinct
source is wrapped as a synthetic top-level object compiled to a temp
directory against the running JVM's own classpath, then loaded with a
URLClassLoader parented on the current thread's context classloader.

Two things the wrapping has to get right, both found by running the actual
test suite rather than assumed up front:

- top-level type definitions (case class/class/object/trait/enum) are
  hoisted out as direct siblings of the result method rather than nested
  inside its body, because json4s's Reflector refuses to extract into a case
  class "defined in function bodies" - exactly what a naive whole-snippet
  block wrap would produce for DynamicUtil.toCaseObject's generated case
  classes.
- the result accessor is a def, not a val: a runtime-compiled dynamic
  endpoint body can contain a `return` inside a nested lambda (recovered
  later as a NonLocalReturnControl by Sandbox.runInSandboxIO), and that only
  compiles when there is a real enclosing method to target. A val compiles
  to a bare field initializer with no such method.

Compiled classes are kept on disk for the JVM's lifetime (registered for
delete-on-exit rather than deleted immediately after evaluation): a class
referenced only inside a returned closure's body is linked lazily, on first
invocation of that closure, which can happen long after compilation
returns - deleting the output directory right after evaluate() intermittently
broke exactly that case.

Verified: DynamicUtilTest, DynamicCompilerKillSwitchTest,
DynamicCompilerFourChainPocTest, ConnectorMethodTest, EndpointMappingTest,
EndpointMappingBankLevelTest, DynamicMessageDocTest, DynamicResourceDocTest,
DynamicCodeKillSwitchTest, MethodRoutingTest, ConnectorProxyObjectMethodsTest.
Four remaining failures (EndpointMappingTest, EndpointMappingBankLevelTest,
MethodRoutingTest, ConnectorProxyObjectMethodsTest) are unrelated
pre-existing issues - confirmed by ClassCastExceptions and reflection
mismatches that occur entirely within the same classloader, with no
DynamicScalaCompiler involvement in their stack traces.
…able by name

InternalConnector.methodNameToSymbols filters Connector's decls with
!t.isVal && !t.isVar to decide which members dynamic code may implement.
Connector is now Scala 3-compiled and read via Scala 2.13's
scala.reflect.runtime.universe, under which isVal/isVar report false for it - a
val getter and a zero-arg def compile to the identical JVM shape, so the
distinction is source-level information a non-TASTy reader cannot recover from
any flag. messageDocs leaked through as if it were a genuine connector method,
throwing IllegalStateException instead of falling through to the stub path.

Connector's trait body declares exactly two public vals directly (formats,
messageDocs; everything else at that scope is protected and already excluded
by the isPublic check, which reflects a JVM access modifier and reads
correctly across the compiler boundary). Excluded both by name.
Four response-serialization sites in Http4s310/Http4s400 blindly cast a
provider-returned List[XT] (the trait) to List[XCommons], on the assumption
that the provider only ever constructs XCommons. That assumption no longer
holds where a Doobie-migrated provider (MappedMethodRoutingProvider,
DoobieCardAttributeProvider) returns its own row type implementing the same
trait: management/method_routings, management/endpoint-mappings,
management/banks/BANK_ID/cards/CARD_ID, and management/webui_props all threw
ClassCastException instead of serving a 200/201.

Each affected XCommons companion already extends Converter/ConverterWithType
for exactly this purpose - the singular per-item getters in the same files
already used its implicit toCommons conversion correctly. Switched the four
list sites to the same companion's toCommonsList instead of casting.
… gap

json4s's default Reflector-based decompose calls ScalaSigReader.readField
(a Scala 3 compiler staging run) whenever a field's generic type argument is
erased to java.lang.Object on the classfile - the case for an Option[T] field
where T is a primitive value type. readField only knows how to recover that
argument from TASTy, so it throws NoSuchElementException: None.get for any
Scala-2.13-compiled class, since none of them carry TASTy. This hits any
obp-commons DTO with such a field, directly or nested arbitrarily deep inside
another obp-commons value being decomposed (e.g. every OutBound message embeds
OutboundAdapterCallContext -> User, and User.isDeleted: Option[Boolean] is
exactly this shape), and was crashing code.api.v3_0_0.ViewsTests (via the
UpdateViewJSON body built on the test side) and code.api.v2_2_0.API2_2_0Test's
Get Message Docs scenarios (via connector example messages) with a server-side
500.

Add ObpCommonsProductSerializer, registered last in JsonSerializers.serializers
so every more specific existing serializer still gets first refusal. It builds
the JSON for any com.openbankproject.commons case class from
ReflectUtils.getConstructorArgs instead of the default Reflector -
ReflectUtils reads Scala-2.13-compiled classes through their own compiler's
runtime reflection, which has no TASTy dependency. Because it is registered in
the shared Formats chain rather than invoked at one call site, nested
obp-commons values get the same treatment automatically at any depth.
Deliberately scoped to the com.openbankproject.commons package rather than
also matching obp-api's own classes, which are Scala 3-compiled and reflecting
those via scala.reflect.runtime.universe has its own, unrelated set of gaps.
… method enumeration

Connector.connectorMethods/implementedMethods enumerate Connector's own decls
to decide which members are genuine dynamic-dispatch connector methods. Two
kinds of Scala 3-compiled trait member leaked through and got miscounted:

- synthetic cross-module setters for Connector's own protected vals
  (bankTTL, formats, ...), named "_setter_$xyz_=" or "_setter_@xyz_=" -
  isVal/isVar report false for them under Scala 2.13's
  scala.reflect.runtime.universe, the same gap fixed for
  InternalConnector.messageDocs, but connectorMethods needed the same
  exclusion independently since implementedMethods unions it in;
- the eight protected implicit def adapter conversions (boxToTuple,
  tupleToBoxTuple, OBPReturnTypeToBox, ...) - both isPublic and isImplicit
  are equally unreadable cross-compiler for these, so both were tried and
  both failed; named explicitly instead.

ConnectorTest's own WrongOutBoundType.unapply needed a third, narrower fix:
Option[AnyVal] connector method parameters (dependents: Option[Int],
isActive: Option[Boolean]) read back as Option[Object] - the JVM parameter
signature boxes/erases the value type with no TASTy to recover it from. A
wildcard-tolerant type comparison is now used only for the final by-name
field check, not for the CallContext/query-params shape detection earlier in
the same method - using it there deleted the erased-looking fields from the
comparison entirely instead of tolerating their type.
Every diff FrozenClassTest reported was one of three benign, already-
understood renderings from the migration, not a real API structure change:
json4s's package rename (org.json4s.JsonAST.JValue -> org.json4s.JValue,
already normalised in RestConnector_vMar2019_FrozenTest), an Option[AnyVal]
connector/JSON field reading back as Option[Object] under Scala 2.13's
scala.reflect.runtime.universe (a JVM erasure gap with no TASTy to recover
the original argument from - same shape already fixed in Connector.scala and
ConnectorTest.scala), and BigDecimal now rendering as its fully-qualified
scala.math.BigDecimal. Regenerated via FrozenClassUtil.getFrozenApiInfo, per
the test's own stated remediation.
…readField gap

json4s's Scala 3 ScalaSigReader.readField throws NoSuchElementException
extracting an Option[AnyVal] field (e.g. User.isDeleted: Option[Boolean])
declared on a Scala-2.13-compiled obp-commons type, because it can only
recover the erased type argument by reading TASTy and those classes have
none. This is the extract-direction mirror of the earlier
ObpCommonsProductSerializer decompose fix.

Add ObpCommonsProductDeserializer, which builds any concrete obp-commons
case class directly from its constructor parameter names/types (read via
scala.reflect.runtime.universe, which resolves the real type argument
correctly) instead of letting json4s's default Reflector walk the class.

Along the way, fix two pre-existing bugs this newly-exercised code path
uncovered: ReflectUtils.invokeConstructor threw for any class with an
auxiliary constructor (e.g. BankCommons), and a missing @optional field
with no Option wrapper had no tolerated default.
hongwei1 and others added 27 commits September 5, 2026 14:28
1. The mappedconsent join had no guard against the empty-string sentinel.
   opt() in MappedConsent.scala stores an empty consent_reference_id as ''
   rather than NULL, and every non-consent metric row also defaults to
   consent_reference_id = ''. An unguarded
   `ON m.consent_reference_id = c.consent_reference_id` therefore joined
   ALL non-consent rows to a single legacy/blanked consent row whenever
   one existed, and COALESCE(c.muserid, ...) attributed the estate's
   entire non-consent traffic to that one unrelated user. Add
   `AND m.consent_reference_id <> ''` to the join condition in
   buildAggregateMetricsQuery and buildTopUsersQuery - the same fix the
   NULLIF(..., '') calls already apply on the read side.

2. buildFilterConditions' user_id filter always bound to the raw
   metric.userid column, even in the two queries whose SELECT/GROUP BY
   attributes a consent-borne call to the granting human via
   COALESCE(c.muserid, m.userid). Filtering by a human's user_id excluded
   exactly the consent-borne calls the endpoint claims to attribute to
   them (their metric.userid is the consent's own shadow user), while
   filtering by the shadow user's id returned rows displayed under a
   different (the human's) identity. Add a resolvedUserIdExpr parameter,
   defaulting to the previous behaviour for callers with no consent
   resolution in play, and pass the COALESCE expression from
   buildAggregateMetricsQuery and buildTopUsersQuery.
Two independent gaps in updateMyMobilePhoneNumber and the mobile number on
POST /users:

- The regex character class is a union, not a required sequence, so
  "     " (five spaces), "((.))" and "-.-.-" all matched despite the
  ResourceDoc promising "5 to 50 digits, spaces, dashes, dots or
  parentheses". A digit-free string would be stored as the user's mobile
  number with nothing for the later validation/SMS flow to send to.
  Require at least five actual digits alongside the shape check.

- updateMyMobilePhoneNumber wrote straight to the authenticated principal
  with no check for a consent user. Under a Consent, cc.user is the
  consent's own shadow ResourceUser by default; letting that identity
  overwrite the mobile number - an authentication channel used for
  validation codes and SMS OTP - would let an agent repoint the granting
  human's second factor. Refuse it outright rather than silently
  redirecting to the resolved human, since a silent redirect here would
  let the agent change a security-relevant field the caller has no reason
  to believe they don't have permission to change.
getTopUsers and getTopConsumers called createQueriesByHttpParamsFuture
directly on the raw request params instead of going through
APIMetrics.applyMetricsFromDateDefault the way every other metrics-reading
endpoint does. With no from_date, APIUtil.getFromDate substitutes the
epoch, which makes MappedMetrics.determineMetricsCacheTTL classify the
query as "only stable data" and pick the 24-hour TTL - so the default,
no-parameter call an operator dashboard would make froze for a day while
traffic kept arriving, and the first miss of that day scanned the whole
metric table since 1970. Corrected the two ResourceDoc descriptions to
match (they claimed "defaults to one year ago" / "the current date",
which was never the actual range).
createAccountJSON's Links.Self does list.head.AccountId unconditionally.
getAccount builds that list by filtering the caller's own private accounts
down to the requested accountId, which is legitimately empty for an id
that does not exist (or belongs to someone else) - the same shape a real
TPP integration hits on a typo or a stale id. That empty list reached
list.head and threw NoSuchElementException, answering 500 instead of the
404 UK Open Banking's spec calls for.

Found by extending the endpoint auth/crash sweep to cover Berlin Group and
UK Open Banking (previously OBP-standard only) - FailureSweepTest calls
every endpoint with a nonexistent id and asserts none of them 5xx.
…n Banking

EndpointCatalog.all was Http4s700.allResourceDocs - the OBP-standard
aggregation only. AuthSweepTest, SuccessSweepTest and FailureSweepTest all
read their coverage from it, so every Berlin Group and UK Open Banking
endpoint was silently outside the anonymous-401/crash sweep: a doc in
those standards missing AuthenticatedUserIsRequired with empty roles would
let anonymous callers reach account data and nothing would catch it.

Switching to ResourceDocRegistry.allStaticResourceDocs (the same
cross-standard union APIUtil.getAllResourceDocs already exposes) needed
three follow-on fixes, all specific to a catalog that now spans multiple
independent route trees rather than one:

- EndpointCatalog.concretePath hard-coded "/obp/" + apiShortVersion.
  Berlin Group and UK Open Banking routes match on Root / urlPrefix /
  apiShortVersion with no "/obp" segment at all (see e.g.
  Http4sBGv13AIS.bgV13Prefix) - urlPrefix is "obp" for the OBP standard by
  construction (ApiVersion.setUrlPrefix patches it to the configured
  apiPathZero at boot), so using implementedInApiVersion.urlPrefix
  uniformly reproduces the old OBP behaviour while giving BG/UK their own
  real prefix instead of a path that 404s before reaching any route.

- AuthSweepTest.messageOf only read the top-level "message" field. Berlin
  Group requests get a PSD2-mandated {"tppMessages": [{"text": ...}]}
  envelope instead (ErrorResponseConverter.toBgErrorBody) - the endpoint
  was correctly answering 401, the sweep just could not see the message
  text to compare it against. Fall back to tppMessages[0].text, which
  carries the identical string the OBP envelope would have.

- SweepCoverageTest's "deduplicated by (url, verb)" check assumed one
  route shape maps to one operation, true for OBP but not for Berlin
  Group: several SCA sub-steps (e.g. updatePsuAuthentication /
  selectPsuAuthenticationMethod / transactionAuthorisation) legitimately
  share one URL and verb, disambiguated by request body rather than path.
  Replaced with a check on operationId uniqueness, which is the union's
  actual by-construction guarantee and still catches a genuine duplicate
  (e.g. two ResourceDoc objects registered under the same operation id).

Two categories of endpoint answer non-2xx to SuccessSweepTest's
fully-entitled-but-consentless caller and are documented in
expectedNon2xx rather than treated as failures: Berlin Group AIS and UK
Open Banking account-read endpoints both require an established,
standard-tagged consent regardless of role, which the sweep's generic
fixture (grants every role, creates no consent) does not provide. The
403 in both cases is the endpoint correctly refusing, not a defect - the
anonymous case is what AuthSweepTest already covers independently.
- Updates to signal channels with added gRPC service.
…tired-lift-standards

refactor: remove retired API standards and superseded Lift stubs
# Conflicts:
#	obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala
Also glossary item to explain the three rate limiters.
activity. Ability to change dynamic entity e.g. indexing if change
doesn't change structure / field names.
apiTagSignalChannel + dyanmic code Props
…ie stores

develop's 73 commits here are four features and one large teardown.

The features: maker/checker for runtime-supplied code (a Dynamic Change Request holds a
proposed create/update/delete of a dynamic resource doc, connector method, message doc or ABAC
rule until a second person approves its payload hash, and the runtime executes only rows whose
body hash equals the approved one); API Product Subscriptions (one Consumer holds one product
for a period, with a status machine and the Scope and RateLimiting rows it created); Dynamic
Glossary Items, which an operator adds at runtime and which may shadow a static item; and the
on-behalf-of attribution framework, which renames accountableUserId to onBehalfOfUserId, pulls
the resolver into Users.onBehalfOfUserIdOf, and states in UserReference.scala, per column,
whether a write by a consent user is recorded against the consent user or the human it acts for.

The teardown: the 12 OBPAPIx_y_z aggregator objects and the VersionedOBPApis trait are gone,
along with the APIMethodsXYZ.scala shims. Version enumeration is now explicit in
Http4sResourceDocAggregation.allVersions, and the Lift ResourceDoc text those files carried as
comments was exported first to scripts/resource_doc_baseline/*.json, which is what the parity
audit now reads.

All four features were written against Lift Mapper entities and created their tables through
Schemifier. This branch has neither: ToSchemify.models is empty, so a Mapper column here would
compile and then not exist. The resolution carries the behaviour across instead of restoring the
entities - DynamicChangeRequest, DynamicGlossaryItem, ApiProductSubscription, its Scope join
table and its Attribute table are case classes over Doobie stores, MakerChecker's 33 Mapper call
sites read through the Doobie providers, and the five tables plus the approvedhash/isactive
columns on the four target types and dynamicentity.authmode come from a new
db.changelog-develop-merge-2.yaml.

Three of develop's entries in the new attribution policy name entities this branch does not
have, and one names a column it does not have:

  - 16 of the 65 classes UserReference.scala names are Lift Mapper entities this branch already
    moved to Doobie. They are only ever read to build the log line in Users.attributionOf, but
    AgentDelegationTest resolves each one, so they are repointed at the class that actually owns
    the write here, with the real column names beside them.
  - PemUsageLastUser and UserScopeUser are dropped: 6bfcb4b deleted the PemUsage scaffold (its
    table was never created in any environment) and ac72b13 deleted MappedUserScope (no
    callers). A policy entry for a table that does not exist is not a policy.
  - abacrule already carries isactive from the baseline, unlike the three dynamic-code tables, so
    its maker/checker changeset adds only approvedhash. Liquibase aborted the boot on the
    duplicate column, which reads as a test-infrastructure failure rather than a schema one.

Two defects were introduced while resolving conflicts and are fixed here rather than left for CI:

The attribution policy was not actually applied to dynamic data. MapppedDynamicDataProvider
defines ownerOf and never calls it - save, update, get, getAll and existsData all passed the raw
caller id - and MapppedDynamicEntityProvider.createOrUpdate stored dynamicEntity.userId
unresolved. Taking our side of those conflicts kept the Doobie method bodies and dropped the
wrapping develop had put around them, so a consent user's rows and definitions were owned by the
agent rather than by the human it acts for. That is the whole point of the feature, and it is
silent: nothing fails, the row is simply attributed to the wrong user.

Four resource-doc examples published a dangling $ref to Object. A field of an erased type takes
its published type from its example value, so ProvenanceJsonV700.is_active and three of
RateLimiterLimitJsonV700's windows, left as None by develop's examples, had no type to recover.
DynamicChangeRequestJsonV700.current_payload is a separate case: it is a bare JValue and is
genuinely JNothing for a CREATE, which json4s omits from the body entirely - that now publishes
as {"type":"object"}, which is what the null branch already answered for the same field, while
every other declared type with an empty example still fails. buildSwaggerSchema's own failures
named neither the class nor the field, so translateEntity now adds that; it is how the offending
field was found at all.

Alongside: the four table-count assertions move from 147 to 152 for the five new tables, and
run_tests_parallel.sh's per-shard hard kill becomes SHARD_TIMEOUT_SECONDS, defaulting to 2400s
on Postgres. That kill exists to stop a JVM whose Pekko threads hang after the tests finish, not
to bound how long tests may take, and at 1200s three of the four Postgres shards were killed
mid-run - each still emitting test output at the moment it died. A killed shard reports zero
failures while dropping ~800 tests from the audit line, so the run reads as a pass unless the
per-shard durations are checked.

Suite: 4233 tests, 0 failures on H2 and the same 4233 on Postgres, where the three shards now
finish in 20:43, 21:58 and 22:39 - which is what the old 20-minute kill was cutting off.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

@hongwei1

Copy link
Copy Markdown
Owner Author

Superseded by OpenBankProject#2899, which reviews the same branch at the same commit (build/scala-3-migration @ ba37e67) against upstream develop.

This PR was opened on 2026-08-15, when the branch only carried the 2.13-side preparation; the title and description still describe that scope. The branch has since done the Scala 3 flip, the Doobie persistence migration and the Liquibase changeover, and OpenBankProject#2899 describes the result. Its base, build/scala-2.13-migration, merged via #90 on 2026-08-16, so the retarget this description asked for is moot.

Closing to keep one review surface. Nothing is lost: the branch and every commit stay, and OpenBankProject#2899 is open and green.

@hongwei1 hongwei1 closed this Sep 11, 2026
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