Java Core Report — the third core, built in isolation
On this page
Date: 2026-09-29 · Status: Draft 1 · Purpose: the deliverable docs/17-m2-implementer-brief.md §5 asks of every core, for the Java core (docs/27-core-java.md, workstream WS-I): the isolation statement with its single-implementer statement (docs/26 §2.2), the result, the divergence list (delivered even though it is empty), every place the specification or a design document was ambiguous, silent or wrong even where the guess was right, the dependency deviations from docs/27 §2, and the resolution of its [VERIFY] flags. It is written in the shape of docs/18-m2-report.md, the TypeScript core’s report, and styled after docs/06-verification-log.md.
The code: core/java/. The report: ./gradlew -q vectors, emitting the docs/14 §4 report. The record, stage by stage: docs/27 §8, and the WS-I entries of docs/07 §7 dated 2026-09-23 to 2026-09-29.
1. Isolation statement
The prohibition of docs/17 §1, extended by docs/26 §2.2 to the Phase 2 cores, was followed: core/python/**, core/typescript/**, core/dotnet/** and tools/vector-gen/** were not opened, read, grepped or listed by any session that wrote this core. Each stage’s docs/07 §7 entry says so. The implementer’s reading path is the one at the top of docs/27: docs/02 (the authority), docs/27, docs/08 §4–§6, docs/09, docs/14 §4, docs/17, and vectors/ itself.
The read inputs are not only the specification. Three of them carry more than the spec, and each is named here, as docs/26 §2.2 requires:
docs/27itself. Its header says what its authors read: the 2026-09-19 design reviewer read the TypeScript harness (core/typescript/tests/harness/run.ts), and the session that wrote the document had read small ranges of that harness, ofcore/typescript/src/registry.tsand ofcore/python/tests/run_vectors.pywhile implementing G26. Wheredocs/27states a fact about the shipped cores, it cites a document for it, but the facts reached this core through a document whose authors had read code.docs/18, the TypeScript core’s report, is cited fromdocs/27and was within reach. Several of its pins are the pins this core declares:decrypt-orderandaad-mismatch(D-02),api-boundary-order(D-04), and the refusal of a configured0xFF02(D-12). Agreement between the two cores on those pins is therefore not independent evidence, and should not be read as two implementers reaching one answer.docs/10§4–§7 anddocs/11§4–§7, the other two binding documents, were read at S5 for the shape of the blind-index API (docs/07§7, 2026-09-27, the S5 entry). Binding documents are specifications, not implementations, but the API shape they describe was the other cores’ before it was this one’s.
Disclosures. Everything below reached a WS-I session by a route other than a checkout of the forbidden trees. None of it is a value, a label, an encoding or an order that a vector checks. Each is recorded, so the record is complete rather than merely clean:
| # | Stage | Route | What was seen |
|---|---|---|---|
| X-1 | S3 | At the maintainer’s request, a separate agent read the two shipped cores’ READMEs | Their section outline only, so this core’s README follows the same shape. Nothing about their implementation reached the implementer |
| X-2 | S4a | A review comment on #189 | How the Python and TypeScript cores lay out their commitment modules: which module imports which, with no values and no logic. Read after the S4a code was committed |
| X-3 | S4b | A review comment on #190 | One quoted line of the Python core (context.py:50, where the index context drops row_id), and which cores have a test for that drop. The S4b code was written before it |
| X-4 | S5 | A comment on #211 | That the other two cores declare a boolean skewed, gated as P < 2^10 or skewed and relieved by cardinality_override. This core’s skewed field took its shape from that comment (J-06); its file citations were not followed |
| X-5 | S5 | Running tools/ucd-gen/generate.py | It wrote the Python, TypeScript and generator Unicode tables, byte-identical to what was committed, without their being read |
| X-6 | S6 | The #212 thread, read for its Java half | It cites the Python and TypeScript cache and provider code: both key the cache by role, count a use on every cache get, and peek without counting on the read path; the TypeScript config refuses a client-level cache |
| X-7 | S6 | compare_result_ids.py --cores python,typescript,java | The other reports’ counts, which the comparison prints (Python 193 results and 3 out-of-band entries, TypeScript 386 and 4, with its async flag). The reports were passed to the script only, never opened |
| X-8 | S6 | gh pr diff of #218, read for its docs/09 §8.3 wording | The start of that PR’s Python cache.py change, before the output was narrowed: a count_use keyword on the cache’s get, its docstring, and the line that increments the counter |
| X-9 | #212, Java half | #218’s description, read for its Java section | How the Python and TypeScript cores decide the use budget: once, when an entry is stored. The Java fix reads the role at the count instead, from the key |
| X-10 | #212, Java half | A CI log on #219 | The name of one Python test, test_index_key_is_still_evicted_on_max_age, which failed once. Its source was not read |
| X-11 | #212 and #225 | #225’s body, read and later re-read | It cites the Python and TypeScript cache files by line and names their counted flag. It adds nothing to X-9 |
| X-12 | after #225 | The docs/07 §7 entry of 2026-09-28 for #227, and the closing comments on #227 and #212 | Not recorded until this report. How the Python index max-age test started its injected clock (at time.monotonic(), advanced by 100.0, so that (x + 100.0) - x could round below 100), and that its fix starts the clock at 1000.0. That work was done by a separate session that read core/python. It concerns a Python test, not the core |
| X-13 | S7 | CI logs of the cross job | The Java consumer’s log printed the other producers’ pair counts and the Django and Prisma adapters’ producer.limitations, their own data. The Python and TypeScript consumers’ logs were read only for the lines naming cross-java.json; their verdicts were not opened |
| X-14 | S8 | A research agent’s summary of docs/07 §7, used to plan this report | Two quotations the log carries: the 2026-09-27 entry for #202, which cites Python cache.py:134-137 and quotes it evicting next(iter(self._entries)), and X-12’s clock line. S8 changed no core code in response; its one code change is a test (§2) |
No Java code was written from any of them. X-4 is the one that shaped this core: the skewed field would otherwise have waited for a vector or a doc fix, and J-06 says what the gap was.
Single-implementer statement, in docs/18 §1’s form. This core was written by an AI assistant (Claude), in a sequence of sessions directed by the project’s maintainer, each without the earlier sessions’ context beyond the repository and the tracker. The Python core, the TypeScript core and the vector generator were, per the repository’s log, written by the same maintainer with the same class of assistant in earlier sessions, and docs/18 §1 says the same of the TypeScript core. The isolation is therefore at the level of sessions, not authors: no session that wrote this core had the others’ contents in memory, and a hard prohibition kept it from reading them. docs/25 §6 records that this weakens the independence claim, and docs/26 §2.2 that Phase 2 does not change the arrangement. What the protocol can still find is an ambiguity in the specification, because two sessions reading an unclear clause can resolve it differently, and §3 lists every place that happened or could have. What it cannot find is a misreading the assistant makes the same way every time: the same reader cannot find their own misreading, and three cores agreeing on it would look exactly like three cores agreeing on the truth. §3’s entries are the clauses a human reviewer should check with that risk in mind.
The order of work docs/17 §3 requires was followed: each module was written from the specification first, the vectors were run once the code compiled, and no constant, label, order or encoding was adjusted to make a vector pass. §2 is the result.
2. Result
Every vector in every file listed in MANIFEST.files passes, in both directions where the family requires it. The mismatch list is empty: at each stage that ran vectors, every value passed on the first run (docs/07 §7: S4a and S4b, 2026-09-25; S5 and S6, 2026-09-27; the declaration vectors, 2026-09-27; S7, 2026-09-28). S4b’s first run had seven failures, all the harness’s own: it read a context from the is_ciphertext vectors, which carry none.
On the head that adds this report, ./gradlew -q vectors prints vector suite 0.10.0-provisional: 193 results, 193 pass, 0 fail, 0 skipped; 3 out-of-band; L0 true, and exits 0.
| Report field | Value |
|---|---|
summary | { "pass": 193, "fail": 0, "skipped": 0, "held_out": 0 } |
out_of_band | spec/3.5/length-bound and spec/3.5/length-bound#decrypt: pass, basis: "seam"; docs/09/7.1/lone-surrogate-refusal: pass, basis: "direct" |
pinned_decisions | the six mandatory keys (decrypt-order, aad-mismatch, api-boundary-order, unimplemented-registered-suite, commitment-construction, key-material-ownership), and two of this core’s own: platform-byte-buffer-max and the test-backed index-role-use-budget (#225) |
provisional_suites · suites_supported · async_companions | true · ["0xFF01"] · false |
claimed_levels | { "L0": true } |
environment | Eclipse Adoptium OpenJDK 21.0.12.1+1 on Windows 11 x64 (CI: ubuntu-24.04); SunJCE for AES-256-GCM and HMAC-SHA-512, with HKDF written over Mac; BouncyCastle 1.86 for Argon2id only; vendored UCD 17.0.0 tables |
compare_result_ids.py --cores python,typescript,java finds the 193 synchronous and 3 out-of-band result ids identical across the three cores (cross-core-result-ids, in CI since S6). In the cross job (docs/14 §3), the Java consumer decrypts and re-derives every case of all five producers’ documents, its own included (16 envelope and 11 index cases from each core, 6 and 6 from Django, 11 and 7 from Prisma), and the Python and TypeScript consumers decrypt and re-derive the Java document: the three-core 3×3 that docs/26 §3 asks of P2-M1, inside the five-by-three job (docs/07 §7, 2026-09-28, the S7 entry).
Beyond the vectors, ./gradlew build runs 535 tests (172 in fieldseal-core, 361 in the testing module, and one each in unarmedTest and freshJvmTest, which run in JVMs of their own), none failing. They cover what vectors cannot express (docs/17 §5 item 3): the §4.8 arming gate and what it deliberately does not gate, the §10.3 read modes, the docs/08 §6 arming gate of the testing artifact and the rule that production encrypt takes no caller-supplied nonce or seed (PublicSurfaceTest), the order of refusals at the API boundary, the length seam with zero provider calls, key-material ownership, the cache and providers, the docs/09 §1 dependency rules, and the vendored Unicode tables against ICU4J over every code point. core/java/scripts/bite_checks.py holds one mutation per guard; each must turn its tests red after a green control run. The count on this report’s final head is in its PR and its docs/07 §7 entry.
What this does and does not establish. A third implementation, written without reading the other two, agrees with them byte for byte on every published expected value of the pinned suite, and decrypts what they write and they what it writes. That is L0 conformance to the provisional suite 0xFF01 at 0.10.0-provisional, not to any frozen format: none exists until Gate 0b (PRD §8). Its two length-bound entries are passes through a seam, not direct: byte[] lengths are int, so a 2³¹-byte operand does not exist on this platform and the refusal is proven on synthetic operands through the pipeline every public entry point enters (docs/27 §6.2, §6.5). Any level claim quoted outside the report names them (docs/14 §4). And §1 says what kind of independence the agreement has.
Also added at S8, for G14. docs/27 §5.2 had promised to record what this core accepts as a KDF info, so that G14’s resolution (#43) can be checked against it. It accepts any length: HKDF is written over Mac, which caps nothing, and canonical_context and the AAD are refused only past Integer.MAX_VALUE bytes, as an OutOfMemoryError (CanonicalContext, by the same rule as the codec’s, docs/27 §6.1). That last clause is by construction and not tested, since a test would need arrays of 2 GiB. What is tested is LargeContextTest: 70,000-byte tenant_id and row_id, past the 1,024-byte and 32 KiB caps spec §6.1 names, round-trip through encrypt, decrypt and rotate; the last byte of tenant_id changes the index key, which never carries row_id (spec §7.2); and the last byte of each field changes the record key. Only the last byte is varied, so the tests show that the end of a 70,000-byte field reaches the derivation, past both caps, not that every byte before it does. A bite entry that caps info at 1,024 bytes turns the last two red and leaves the round trip green, because the AAD still carries the whole context.
3. Divergences and ambiguities
The brief’s list (docs/17 §3): for every mismatch, the vector id, the computed and expected values, the clause, and the cause. There were no mismatches. The table is the other half of the deliverable: every place the specification, the vectors or a design document was ambiguous, silent, contradictory or wrong, including where the guess was right. The kinds are those of docs/18 §3, with one added: spec gap (the spec should say it), vector or doc drift (the vectors or a document disagree with the spec or with each other), binding-doc bug (docs/27 was wrong; its header says such a bug belongs here), and pin (a choice the spec leaves open, declared or tested).
| # | Clause | Finding | What this core does | Kind | Status |
|---|---|---|---|---|---|
| J-01 | docs/09 §1; spec §4.6, §7.3 | docs/09 §1 lets commitment and blindindex depend on registry and errors only, but spec §4.6’s commitment is computed with the suite’s KDF and §7.3’s Argon2id salt is an HKDF output, and HKDF lives in kdf. Found at S1, where the ArchUnit rule encodes §1 literally | commitment and blindindex declare their own functional interface for the KDF, and api passes in kdf’s (maintainer, 2026-09-25) | Doc gap | Resolved in this binding; docs/09 §1 unchanged, so each later core meets the same rule and must choose again |
| J-02 | spec §8 | decryption_keys receives the envelope header alone, and a provider that derives a key per tenant cannot find the tenant in an opaque key_id | decryptionKeys receives the call’s context as well as the header (maintainer, 2026-09-25; docs/27 §4) | Spec gap | Pinned here; the spec is unchanged |
| J-03 | docs/08 §4.2; spec §7.2 | The pinned vector kdf/index-key/row-id-dropped carried row_id: null, although its description said the caller’s context carried one, so no core was checked for dropping row_id from the index-key info. Found at S4b, when the bite check for keeping it was caught only by a unit test | A unit test (CanonicalContextTest) pinned the drop | Vector drift | Fixed at suite 0.9.0-provisional (#191, #196); the vector now catches it in every core |
| J-04 | spec §3.4, §9 | No vector sits on the reserved-version floor: a fmt_ver 0x02 envelope of exactly 111 bytes, the smallest that is UNKNOWN_FORMAT_VERSION rather than NOT_CIPHERTEXT. The errors/format vectors put it at 120 bytes and at 110, so a floor off by one passes both | A unit test pins the floor at 111 (S3) | Vector gap | Open; not filed |
| J-05 | docs/08 §4.3; spec §6.1 | docs/08 §4.3 said context/ carries four negative index declarations (index:Exact, index:é, empty, 33 bytes); the suite held none, so a core that accepted index:Exact passed the whole suite. Found at S5 | The core tested the four refusals itself | Vector drift | Fixed at suite 0.10.0-provisional (#210, #217) |
| J-06 | spec §7.6; docs/09 §7 | Spec §7.6 gates a column “declared as heavily skewed”, and docs/09 §7’s IndexDeclaration sketch had no field to declare it. The finding at S5 was that the field did not exist anywhere; that premise was wrong: the other two cores had it (X-4). §7.6’s gate had no vector at all | IndexDeclaration.skewed, gated exactly as a small P is, relieved only by a reviewed override | Doc drift; vector gap | Fixed: docs/09 §7 shows the field (#208); six gate vectors at 0.10.0-provisional (#211, #217) |
| J-07 | docs/09 §8.3; spec §5.5 | docs/09 §8.3 counted a use on every encryption_key return, so every blind index spent the index key’s max-uses budget, a limit spec §5.5 states for encryptions, and a busy index key failed closed. Found at S5 | The cache counts uses for the DEK role only, reading the role from the key (#221, #224); the report carries it as the test-backed pin index-role-use-budget | Doc error | The rule was changed for every core (#212, #218); a cross-core check of it is open (#225) |
| J-08 | docs/09 §1, §2, §10 vs §8.3 | docs/09 §1, §2 and §10 put the DEK cache in client configuration while §8.3 keyed it by provider. Raised in review of the Java provider change | The envelope provider owns its cache from construction; every client built with one provider shares it (docs/27 §3) | Doc contradiction | Fixed (#202, #216) |
| J-09 | spec §6.2, §7.2; docs/09 §3.3, §12 | Which suite_id the index-key context carries is stated nowhere. On the index path the only suite a client has is its write suite, so a change of write suite re-keys every blind index | The write suite; a registry comparison must include writeSuite() as well as indexes() (docs/27 §4) | Spec gap | Open (#213); unobservable while 0xFF01 is the only suite that can be built |
| J-10 | docs/09 §7.2 | §7.2 allowed on_unindexable = bucket only for a normalizer that “can never refuse”, which identity over text holding a lone surrogate does. Found in review of S5 | bucket under identity stays a ConfigurationError; spec §3.6 has an adapter refuse such a string anyway, so a marker would keep no row findable | Doc error | Fixed: §7.2 says “refuses no well-formed text” (#208) |
| J-11 | docs/09 §7.2; spec §7.1 | Which refusals bucket catches. docs/09 §7.2 names an unassigned code point; a lone surrogate in a string and malformed UTF-8 in bytes are refused by the same normalizer on the same terms, and no vector distinguishes the readings | All three take the marker (docs/27 §4) | Pin | Could split two cores’ adapters on real data: one stores a marker where another raises |
| J-12 | docs/08 §4.4 | A binary-only harness is to “report stored.hex skipped”, but stored.hex is an assertion inside a result, not a result id, and cross-core-result-ids treats a skipped result as lost coverage | Says it in harness_notes; stored.binary and stored.octets are asserted | Doc drift | Recorded (docs/07 §7, 2026-09-27, S6); docs/08 §4.4 unchanged |
| J-13 | docs/14 §4 | docs/14 §4’s example report tolerates a skipped result beside a level claim | This report withdraws L0 on any skipped result | Pin (stricter) | Recorded, not changed |
| J-14 | docs/09 §1; docs/08 §6 | docs/09 §1 lets no module import api, and the encrypt pipeline is the api’s, so the testing artifact’s seam has nowhere to live under the stated rules, which say nothing about a testing module | A package of its own, internal.testing, holding a gate and a one-shot hook the api installs; an ArchUnit rule this core adds holds it to errors only | Doc gap | Resolved in this binding (docs/27 §3); docs/09 §1 unchanged |
| J-15 | docs/08 §2, §5 item 2 | vectors/schema/ does not exist, so the harness contract’s schema validation cannot be performed. docs/18 D-09 found the same | Each file’s length and SHA-256 are checked against the manifest before any vector runs, and each vector’s shape is checked by the harness; stated in harness_notes | Doc drift | Open, carried from D-09 |
| J-16 | docs/27 §6.3, §2, §0 | docs/27 was wrong three times. §6.3 said SunJCE’s GCM decryption buffers about the operand; measured at S2, one doFinal allocates about 22 KB beyond the caller’s arrays, and only the update() path buffers (3.0×). §2 said JDK 21’s Unicode tables are at 15.1; measured at S6, they are at 15.0. And §0 lists the corrections the design review made before the build | Decrypt is one doFinal, a stated rule of the binding. Nothing depended on the Unicode claim: the core never uses the platform tables | Binding-doc bug | Corrected in place, each with a dated docs/07 §7 entry |
| J-17 | docs/09 §7.1, Pin currency | Unicode 18.0.0 is published, so the nfc-casefold-v1 pin is due to move before the freeze. ICU4J’s newest release on 2026-09-27, 78.3, is Unicode 17, so this core’s oracle test would fail at an 18.0.0 pin | Pinned to 17.0.0 with vendored tables, checked against ICU4J 78.3 | Spec maintenance | Open (#209); touches every core and the generator |
| J-18 | spec §6.1 (G14) | tenant_id and row_id are unbounded, so the KDF info is too, and platform HKDFs cap it | Accepts any info up to the Java array limit; past it, an OutOfMemoryError, not a typed §9 error (§2) | Spec gap | Open (#43); this core’s record is §2’s |
| J-19 | spec §6.3, §6.4, §9 | Spec §6.4 requires an implementation that supports row binding to distinguish AAD_MISMATCH from TAG_INVALID, while under §6.3’s dual-layer binding a wrong context and a wrong key are indistinguishable on the 0xFF01 path. docs/18 D-02 met the same tension | AAD_MISMATCH is never raised; declared in the aad-mismatch pin | Spec tension | Open, under G5 (#5) |
| J-20 | docs/09 §3.2 step 4 | That decrypt takes the context’s suite_id from the envelope’s header, not from the write suite, cannot be observed while one suite can be built: the mutation was run and changes no outcome | As docs/09 says | Untestable today | Becomes testable with a second suite |
Nothing above changed a byte of the envelope. The two that could split cores on real data without any vector noticing are J-11 and J-18, and J-09 joins them the day a second suite can be written. The one that most weakens the protocol’s evidence is not in the table: it is §1’s note that several pins were within reach in docs/18.
4. Dependency deviations and the [VERIFY] sweep
docs/27 §2’s dependency choices were kept: the JDK for AES-256-GCM, HMAC-SHA-512, constant-time comparison and the CSPRNG; HKDF written over Mac; BouncyCastle bcprov-jdk18on 1.86 for Argon2id only, the core’s one runtime dependency since S5; JUnit, jqwik, Jackson, ArchUnit and ICU4J 78.3 for tests only. The deviations, each with its reason:
| Stage | Deviation | Reason |
|---|---|---|
| S1 | FieldsealError is unchecked | An ORM converter method cannot declare a checked exception |
| S1 | ArchUnit is used as a library inside JUnit 5 tests, not through its engine | One test runner; each rule has a fixture that violates it, and the test asserts the rule reports that fixture and nothing else |
| S1 | CI uses setup-gradle with cache-provider: basic | The default, enhanced, is a commercial caching service |
| S2 | BouncyCastle was a test dependency of the testing module, and the audit’s HKDF a test-local one | The core held no cryptographic code until S4 and S5; both moved into the core then |
| S5 | The Unicode tables are a tools/ucd-gen target of this core’s own, as a text resource, rather than one hashed resource shared by the Phase 2 cores | A shared file would be read by one core from another’s tree; a Java string constant is capped at 65,535 bytes, and the folding table is larger |
Every [VERIFY] in docs/27 was resolved at S2 (docs/07 §7, 2026-09-24): GCM through JCA at docs/27 §5.1’s offsets, the empty-salt trap of SecretKeySpec, the strict UTF-8 decoder, BouncyCastle’s Argon2id against all 12 raw values at both pinned cost points, and the runners’ memory, confirmed; SunJCE’s decrypt buffering, corrected (J-16); BouncyCastle’s salt copies and bc-fips’s lack of Argon2, answered. A second library fact was corrected at S6 (J-16). Found without being asked: SunJCE zero-fills a failed tag’s output range; BouncyCastle caps Argon2 memory at 2²⁴ KiB through a system property; and on Temurin 21.0.12 the largest byte[] is 2³¹−3, both locally and on CI’s runner (docs/27 §6.1). No [VERIFY] flag remains in docs/27.
5. Conformance claim
L0 against provisional suite 0xFF01 at vector_suite_version 0.10.0-provisional, from a report with fail: 0, skipped: 0, provisional_suites: true, every out-of-band entry pass with its basis (the length-bound pair through the seam, the lone-surrogate entry directly), and the six mandatory pinned_decisions keys declared. Per spec §4.8 the claim names the provisional identifier and is not restated against a registered one; per PRD §8 it is not a claim against a frozen format, because none exists. No adapter level is claimed. async_companions: false.
6. What was not done
- Suite
0xFF02. Registered and unbuilt while G7 is open; configuring it is aConfigurationErrornaming G7. - The metrics hook of
docs/09§2, and background refresh for the envelope provider, which fails closed and refreshes only throughwarm. - A lock-free or fine-grained cache. The
DekCachehas one lock for the whole cache, short ofdocs/09§8.3’s “lock-free or fine-grained-locked reads”; a recorded deviation (docs/27§5.5). - Narrowing the per-decrypt DEK copies the envelope provider hands out: #200.
- No
mlockand no swap protection, as in the other cores (docs/27§5.4). - The other halves of #225 (the Python and TypeScript backing tests), which are separate sessions under the isolation rule.
- Publication. Nothing is on Maven Central, and the group id needs its namespace verified before anything is published under it (
docs/27§0.3). A release would be an experimental pre-1.0 one under PRD §8’s five conditions. - A test of the
OutOfMemoryErrorpast the array limit for an oversized context (J-18): it would need arrays of 2 GiB.
7. Recommended follow-ups, in order of how much they protect the central claim
- A second human implementer, or Gate 0b. §1’s single-implementer statement is the report’s largest limit, and
docs/26§6 says that only these two fix it. - Decide #213 before a second suite or the Hibernate adapter’s registry check, since the answer decides whether a write-suite change re-keys every index (J-09).
- Pin J-11 and J-04 in vectors: a lone-surrogate and a malformed-UTF-8 value under
bucket, and a0x02envelope of exactly 111 bytes. - Settle G14 (J-18) and G5’s
AAD_MISMATCHquestion (J-19) at Gate 0b; this report gives the Java core’s side of each. - Write J-01, J-02 and J-14 into
docs/09, so that the .NET and Go cores meet them as rules rather than as failing builds. - Fix the two drifts in
docs/08(J-12, J-15), and move the Unicode pin (#209) once ICU4J and Node ICU carry Unicode 18.