High
L3 checkout fulfilments are never constraint-checked
TW-R10-01
ConstraintChecker.check has exactly one call site in the whole repository, and it sits inside the if (l3Payment != null) branch. When an agent presents only an L3 checkout, verifyL3 runs its signature, sd_hash, pair-identity and temporal checks — and then no constraint from the L2 checkout mandate is evaluated at all. That includes mandate.checkout.line_items, the constraint that bounds what the agent may buy.
The chain verifier only checks that a line_items constraint is present (open_checkout_contains_line_items), never that the fulfilment satisfies it. This also routes around ConstraintChecker's deliberate fail-closed handling: that class refuses an open mandate whose line_items it cannot evaluate, and the checkout path never asks it. Structurally this is the same shape as the omit-payment-mandate bypass closed earlier — an L3 accepted with its authority unbounded.
ChainVerifier.kt:236 only ConstraintChecker.check call site, inside `if (l3Payment != null)`
ChainVerifier.kt:279 `if (l3Checkout != null) { verifyL3(...); l3CheckoutResolved = l3Checkout.resolve() }`
ConstraintChecker.kt:71 line_items → fails closed for open mandates, if it is ever reached
Fix: lift the constraint block into a helper taking (mandate, fulfilment, requiredVct) and call it from both L3 branches. The checkout side should fail closed on unevaluable line_items exactly as the payment side does today.
High
Replay protection is opt-in and silently skipped
TW-R10-02
verifyChain takes expectedL2Aud and expectedL2Nonce defaulted to null. A verifier that omits them gets valid = true with a note in checksSkipped — a field nothing forces the caller to read. A captured presentation replays against a different verifier.
The contrast inside the same signature makes the case: now carries a comment explaining that it defaults to the host clock precisely so a caller who omits it cannot accidentally pin verification to a time of the presenter's choosing. Audience and nonce were not given the same treatment, and they are the two parameters a presentation verifier most needs.
ChainVerifier.kt:55-56 expectedL2Aud: String? = null, expectedL2Nonce: String? = null
ChainVerifier.kt:83,466 audNonce() records "skipped" and returns null → chain still valid
VerifiableIntent.kt:38-39 same defaults on the public facade
Fix: make both required for presentation verification, or add requireReplayProtection: Boolean = true that fails the chain when either expected value is absent. Leave an explicit opt-out for the offline/audit case.
Medium
A missing exp means never expires
TW-R10-03
expired() and future() both return null — pass — when the claim is absent. An L1, L2 or L3 with no exp clears every temporal check. The one-hour L3 lifetime cap is likewise conditional on both iat and exp being present, so omitting exp removes the cap rather than tripping it.
ChainVerifier.kt expired(): `val v = (exp as? JsonPrimitive)?.longOrNull ?: return null`
ChainVerifier.kt `if (iat != null && exp != null && exp - iat > MAX_L3_LIFETIME_SECONDS)`
Fix: require exp and iat on L3 at minimum, and treat their absence at L1/L2 as a policy decision the caller states rather than a silent pass.
Medium
Payment-instrument cross-check passes when the L2 side is absent
TW-R10-04
paymentInstrumentCrossCheck returns null (pass) when the L2 payment mandate carries no payment_instrument. The L3 side is required to have one by paymentRequiredFields; the L2 open mandate is not. So an open mandate that never named an instrument authorizes an L3 that names any instrument, and the check reports nothing.
ChainVerifier.kt `val l2Pi = (l2Payment?.get("payment_instrument") as? JsonObject) ?: return null`
Fix: require payment_instrument on the open payment mandate, or record the skip in checksSkipped so it is at least visible to the caller.
Medium
7,354 lines of production code with no tests
TW-R10-05
Twenty-one modules have a src/main and no test source set at all. Nine are DID method plugins the SDK advertises as supported methods. The repository has 3,975 tests overall, so this is not thin testing — it is a cliff at the plugin edge.
932 credentials/plugins/status-list/database 501 did/plugins/plc
768 did/plugins/sol 478 did/registrar-server-ktor
725 did/plugins/ion 430 did/plugins/ens
548 did/plugins/cheqd 309 credentials/plugins/anchor
541 did/plugins/polygon 277 wallet/wallet-services
514 credentials/plugins/chapi 182 credentials/plugins/status-list/server
511 did/registrar-server-spring 161 anchors/plugins/starknet
+ btcr, tezos, threebox, venafi, salesforce, servicenow
Fix: the DID plugins share a resolution/creation contract — one parameterised conformance suite in testkit would cover nine of these at once, the way InMemoryKmsContractConformanceTest already does for KMS.
Medium
No API-surface control on 106 published modules
TW-R10-06
Neither explicitApi() nor the binary-compatibility validator is applied anywhere in the build (the only matches in the tree are inside node_modules). Every declaration is public by default, and nothing mechanically catches an ABI break before it ships to consumers. For a library at 0.7.0 that has already had to write release notes for three signature-format changes, this is the gap that keeps producing them.
build.gradle.kts freeCompilerArgs = ["-Xjsr305=strict"] only; no explicitApi, no apiValidation
grep explicitApi|binary-compatibility-validator → matches only under reference-wallet/expo/node_modules
Fix: apply binary-compatibility-validator and commit .api dumps — that alone turns an ABI break into a failed PR. Then adopt explicitApi() module by module, starting with the five -core modules.
Medium
108 exceptions swallowed without a log line
TW-R10-07
Of 536 broad catch (e: Exception|Throwable) blocks in main source, 35 have an empty body and 73 return a bare null/false with nothing recorded. I traced the ones in the verification path — PresentationVerification.kt, SdJwtProofEngine.kt, ProofEngineUtils.kt — and they are all genuinely fail-closed, so this is not a correctness finding. It is an operability one: a verification that failed because a JWT would not parse is indistinguishable, from the outside, from one that failed because a signature was wrong.
PresentationVerification.kt:284,321,527,605 SdJwtProofEngine.kt:463,643,744
ProofEngineUtils.kt:506,677 BlockchainAnchor.kt:145
AbstractEvmAnchorClient.kt:258,362,408,491,498 ProviderChain.kt:63
Fix: a debug-level log at each swallow point, carrying the exception. Worth doing before the anchor plugins go to production, where these are network failures being reported as clean negatives.
Low
Coverage measured on 3 of 106 modules
TW-R10-08
Kover is applied in credentials/credential-api and kms/kms-core only, and CI runs no coverage task and enforces no threshold. There is no repo-wide number, which is how TW-R10-05 stayed invisible.
Fix: apply Kover in the root subprojects {} block next to ktlint and add a merged report to CI. A threshold can come later; the number is the useful part now.
Low
32,692 lint violations permanently baselined
TW-R10-09
Across 123 config/ktlint/baseline.xml files. The gate on new code works and is the right call — but a baseline this size is indefinite debt, and it grows every time a module is legitimately re-baselined.
Fix: a scheduled ktlintFormat sweep, one domain per release, shrinking the baseline on a known cadence rather than never.
Low
No signing, SBOM, or provenance on published artifacts
TW-R10-10
The build publishes to Maven with no GPG signature, no CycloneDX SBOM, and no build attestation. Dependabot is configured for both Gradle and Actions (good), but there is no CodeQL or other SAST, and CI is a single OS on a single JDK. For a library whose value proposition is verifiable trust, artifact provenance is close to table stakes.
Fix: GPG signing is required for Maven Central anyway — do it now. Add cyclonedx-gradle-plugin and GitHub artifact attestation to the release workflow.
Low
Test dependencies invert the module layering
TW-R10-11
credentials/credential-api declares a testImplementation on :trust, the facade that sits above it, and did/did-core does the same on :did:registrar. Production layering is clean and acyclic; the test configurations quietly are not, which constrains how these modules can be split or published independently.
credentials/credential-api/build.gradle.kts:42 testImplementation(project(":trust"))
did/did-core/build.gradle.kts:23 testImplementation(project(":did:registrar"))
Fix: move those tests up into :trust, or push the shared fixtures down into :testkit where the rest of the doubles already live.