ADR-0003: Byte-identical twin code lives in legatium-common, inlined by Shade¶
- Status: accepted
- Date: 2026-09-03
- Context: The sibling project limesium started with two deliberately duplicated twins, then learned (its ADR-0003 of 2026-08-30, with two amendments) that every shared-layer change became a synchronized multi-file port in both directions, and extracted the byte-identical set into an internal module inlined by Shade - under the constraint that consumers keep adding exactly ONE artifact. Legatium is built as limesium's twin project and inherits that lesson from day one instead of re-learning it.
Decision¶
The byte-identical shared code lives in the legatium-common module;
each twin inlines it into its own jar with the Maven Shade plugin;
legatium-common itself is never published.
- What is shared:
Traceparent(with unit, conformance-fixture and Jazzer fuzz tests),NanoTimeSource,CorrelationIdGenerator(with the counting default, ADR-0004),reportQuietly/failOpen,Mdc.kt(MdcKeys/TraceMdcKeys/MdcScope),HeaderLogProperties(selection semantics, with unit test and fuzz target) andHeaderValueMasker(the injectable masking, with the fingerprint default),BodyReadState/decodeTruncated,Timeouts(the classification that makesadapter_outcome=timeoutmean the same thing on both stacks), and - since the amendments below - theClientLogFieldenum with its builder extensions and theClientLoggingPropertiesbinding. Package:eu.inqudium.legatium.common. - What deliberately stays duplicated: everything whose twin copies
genuinely differ - the metrics (per-stack outcome vocabulary and meter
descriptions), the emitters, exchanges,
interceptor/filter, and
BoundedBodyCapture(two different concurrency designs: volatile single-writer on the blocking stack, lock and freeze on the reactive one). For those the accepted cost is a conscious port in both directions; the lockstep tests (each twin'sTwinContractTestfor the message text and the stack's own outcome vocabulary;SharedContractTest,ClientLogFieldTestandClientLoggingReferenceConfigTestinlegatium-commonfor the shared literals) catch named contract drift, not behavioural drift. - Shading: an
artifactSetrestricted toeu.inqudium: legatium-common, NO relocation (relocating rewrites bytecode but not Kotlin metadata),keepDependenciesWithProvidedScope=falseso the dependency-reduced POM drops the dependency entirely, and the module'sMETA-INF/mavenfiltered out of the shaded jar. spring-boot-starter-parent pre-configures an unnamed uber-jar shade execution; it is unbound (phase=none) so declaring the plugin does not swallow the compile classpath. - Visibility: the twins compile with
-Xfriend-paths(own output dir, common's classes dir AND jar - the reactor resolves the dependency as a directory before packaging and as a jar afterwards), so the shared classes stayinternal. Consequence: a twin builds only through the reactor (from the root, or with-am) - a lone-pl <twin>build resolves the dependency from the local repository, which is not a friend path, and fails with "internal in file". - Not published:
maven.deploy.skip=trueplusskipPublishing=truefor the Central Portal bundle. The published twin POMs mention nolegatium-common. - Documentation: each twin's Dokka run includes the common sources
as an additional source root - the API reference documents what the
shaded jar actually contains, and cross-module KDoc links resolve
under
failOnWarning. The Docs workflow installs (not merely verifies) before the per-module Dokka runs, so the dependency resolves.
Consequences¶
- A shared-layer change is made ONCE; the both-directions port and its drift risk exist only for the deliberately duplicated remainder.
- Consumers are unaffected in shape: one artifact, no new transitive dependency, internals stay internal.
- Both twin jars carry byte-identical copies of the common classes. An
application with BOTH twins on the classpath (a servlet host that also
uses
WebClient- a supported deployment here, unlike limesium's twins) sees benign duplication at equal versions and classpath-order-dependent classes at skewed versions: keep the two versions equal. - The common classes carry the
eu.inqudium.legatium.commonpackage, distinct from limesium'seu.inqudium.limesium.common, so a host that runs limesium AND legatium (the intended pairing) never sees two copies of one class name. -Xfriend-pathsis a-Xcompiler flag: stable in practice and used widely for test friendship, but not a documented contract; a Kotlin upgrade that changes it surfaces as a loud compile error, never as silent misbehaviour.
Amendment (2026-09-03)¶
The ClientLogField enum (wire names, per-field type guarantee, the
addKeyValue/addKeyValueIfPresent/setCauseIfPresent builder
extensions) was listed above as deliberately duplicated, by analogy with
limesium, whose twin enums genuinely differ (endpoint_async exists on
one stack only). Here the two copies were byte-identical apart from KDoc
prose: the field family is one cross-stack contract, and the only
stack-specific fact - the reactive cancelled outcome - is a VALUE of
adapter_outcome, not a field. The enum now lives in legatium-common,
documented stack-neutrally, and ClientLogFieldTest binds the ELK
component template against it ONCE there (the template is declared as a
test resource of legatium-common); the twins no longer carry the test or
the template resource. The ELK template's authority points at the new
location.
Amendment (2026-09-03, second)¶
The same review found ClientLoggingProperties byte-identical apart from
KDoc wording (interceptor vs. filter) - the adapter-logging.* namespace is
one cross-stack contract by design, key for key and default for default,
and unlike limesium there is no stack-only key (variant) to justify two
classes. The class now lives in legatium-common (which therefore depends
on spring-boot for the @ConfigurationProperties annotation - no
autoconfigure, no starter), documented stack-neutrally; both twins'
auto-configurations enable the same class, and a host carrying both twins
gets one properties bean (Boot derives the bean name from prefix and class).
ClientLoggingPropertiesTest and ClientLoggingReferenceConfigTest moved
along, so the reference YAML is bound ONCE in legatium-common and the
twins declare no shared-docs test resources any more. NOTE - source-facing
for hosts that import the class for a custom interceptor/filter bean: the
package is eu.inqudium.legatium.common, decided before the first release.
The two twins' copies of the test helper MdcAdapterSwap were unused (only
legatium-common's MdcScopeTest swaps the adapter) and were deleted; the
"copies are cheaper than a test-jar" rule applies to helpers a module
actually uses.
Amendment (2026-09-04): not relocated - the consequences, stated¶
The inlined classes keep their package (eu.inqudium.legatium.common); they
are NOT relocated per twin. Relocation would break the one thing the shared
layer is for: a host bean of HeaderValueMasker, NanoTimeSource or
CorrelationIdGenerator masks or clocks BOTH twins, and a host that imports
ClientLoggingProperties for a custom interceptor or filter bean writes one
import - with relocation each twin would carry its own type and none of that
holds. The price is explicit:
- Both twins in one application carry the same classes twice, under the same names, and the first jar on the classpath wins. That is safe only while the two versions are byte-identical - hence "keep the two versions equal" in both READMEs; a version skew between the twins is a misconfiguration.
- The JPMS module path is unsupported for a host carrying both twins: two
automatic modules exporting the same package are a split-package error and
the application does not start. A single twin on the module path is fine.
(The build itself runs Surefire with
useModulePath=falsefor the same reason.)
Amendment (2026-09-04, second): the metrics owner and the activation move; a threshold; the 1.0 decision¶
The architecture review of 2026-09-04 measured the "deliberately duplicated
remainder" with stack names neutralised: the two ClientLoggingMetrics
classes were 95 % identical (the differences were the outcome list, one tag
value and two descriptions - data, not design) and the activation logic 96 %
(identical). Both now live in legatium-common: ClientLoggingMetrics
parameterised by a ClientStack (the client tag, the outcome vocabulary,
the gauge's wording; the per-registry cache is keyed by registry and stack)
and ClientActivation built from the properties. The emitters (72 %), the
exchanges and the two BoundedBodyCaptures remain duplicated - they differ in
design, not in data.
Threshold, so this is a rule rather than a review: a twin-paired file
that reaches 90 % line similarity after neutralising the stack names is
byte-identical enough to move to legatium-common, parameterised where it
must differ. Below that, a copy stays a copy and the both-directions port is
the accepted cost.
The inlining stays through 1.0 (review finding 4, decided). Publishing
legatium-common as a regular artifact would remove the friend-path build
coupling, the duplicate classes and the JPMS split-package exclusion, at the
price of one more artifact in every consumer's tree and a public API surface
for the shared types. The one-artifact shape and internal shared types are
kept for the first release; the documented costs (above) are accepted. The
decision is re-opened by the first of: a consumer that needs the module path
with both twins, a third twin, or a shared-type API change that would break
consumers of both jars.
Amendment (2026-09-05): the proof moves to the consumer's side of the boundary¶
The architecture review of 2026-09-05 (finding 3) found the evidence for
this decision sitting on the wrong side of it. Surefire runs the twins'
tests in the test phase against the legatium-common module; Shade inlines
the classes and writes the dependency-reduced POM afterwards, in package,
and nothing ever loaded the jars a consumer receives. Meanwhile ten of the
twelve methods in the two TwinContractTest files pinned literals of types
that, since the amendments above, exist only once in legatium-common - a
drift "between the twins" they were written against had become impossible.
- Shared literals are pinned once.
SharedContractTestinlegatium-commonpins the meter names and fallback tag values, the read states, the MDC keys, the outcome vocabulary, the fail-open stages and the request-id sources; the masking fingerprint was already pinned inHeaderValueMaskerTest. Each twin'sTwinContractTestkeeps the two facts the twin owns: itsClientStack(client tag, pre-registered outcomes) and the message text its emitter renders. - The shared owner is tested once. The registration behaviour of
ClientLoggingMetrics(fallback registry, gauge collision, guarded updates, tag folding, the no-op composite) moved from both twins' metrics tests intoClientLoggingMetricsTestinlegatium-common(ADR-0008). - The packaging is verified where it matters. The standalone project
consumer-smoke/(no reactor child, like limesium'sbenchmarks/) depends on both twins exactly as an application does and starts a Boot context on the installed jars: the inlined common classes must resolve from exactly the two twin jars and from nolegatium-commonartifact, both auto-configurations must wire up through the jars' own imports files, and one call per client must end in one exchange line against a real local peer. The CI jobconsumer-smokeinstalls the reactor, DELETESlegatium-commonfrom the local repository and only then builds the consumer - a dependency-reduced POM that still named the unpublished module fails there, not at the first consumer. The both-twins-on-one- classpath case this ADR documents is thereby exercised on every push.