Java Core Technical Specification
On this page
Date: 2026-09-22 · Status: Draft 1, the tech spec the Java core is built against, updated as built; all eight stages of §8 are built: scaffold and CI, capability audit, envelope codec, crypto pipeline and client, blind indexes, the testing artifact with the conformance report, the cross job, and (S8) this document as built with the core’s divergence report, docs/28-java-core-report.md · Purpose: the Java/JVM binding of docs/09-core-architecture.md, in the shape of docs/10 and docs/11. It is the first Phase 2 core (WS-I, docs/26-phase-2-plan.md §2) and the third implementation of the format. It is built under the docs/17 isolation protocol, against the vector inputs and the specification, never against another core.
Where it came from. This document is the JVM core design drafted and reviewed on 2026-09-19, made into a repository document when Phase 2 opened (docs/26 §1 item 3). On the way in, it lost what was true only on the day it was drafted:
- its premise that
docs/09§4’s buffer-maxima flag waited on this core (#167 and #168 turned that flag into a per-binding obligation, which §6 here discharges); - the report-contract gap it proposed to fix, which G26 has since closed;
- its references to working files outside the repository.
Its review record is kept as §0, because the corrections listed there are the evidence the design was checked.
Library-fact caveat: as in docs/10 and docs/11, a claim about a platform or library API that has not been run on this core’s toolchain is marked [VERIFY]. Each is resolved at the capability-audit stage (S2, §8), as confirmed or corrected, with a dated docs/07 §7 entry (docs/07 §4). S2 resolved all of them on 2026-09-24 (docs/07 §7, that date). Where it corrected a claim, the text below now states what was measured, and names the correction.
Reading path for the implementer (normative for this workstream):
docs/02-spec-v0.1.md(the authority);- this document;
docs/08-test-vector-spec.md§4–§6;docs/09-core-architecture.md;docs/14-conformance-ci.md§4;docs/17-m2-implementer-brief.md;vectors/itself.
Do not open, read, grep or list core/python/**, core/typescript/**, core/dotnet/** or tools/vector-gen/**. The independence rule (docs/17 §1) applies to the third core exactly as it did to the second, and a sibling binding is an implementation, not a specification (docs/26 §2.2).
This document is a read input, and its authors read more than the implementer may. The 2026-09-19 reviewer read the TypeScript harness (core/typescript/tests/harness/run.ts) for §0. The session that wrote this document had read small ranges of that harness, of core/typescript/src/registry.ts and of core/python/tests/run_vectors.py while implementing G26. Nothing here about the shipped cores comes from those reads where a public document says it: such facts are cited to docs/11, docs/14, docs/18 or the G26 record. The implementer’s isolation statement names this document. Where it is wrong, that is a bug in this document, and it goes in the divergence log (docs/17 §5 item 4). The session that wrote it does not implement the core (docs/26 §2.2).
0. Review record, 2026-09-19
A second agent reviewed the first draft of the design against the repository and against primary platform sources. What it changed, and why, follows. Two items it left open have been settled since, and §0.3 says how.
0.1 Corrections of fact
| Draft said | Correct | Evidence |
|---|---|---|
| “Phase 1-plus”, “not in the PRD Phase 1 window” | The JVM core is PRD Phase 2 | docs/01-prd.md §8, Phase 2 |
SOFT_MAX_ARRAY_LENGTH = 2³¹−8 = 2,147,483,639 | Integer.MAX_VALUE - 8 = 2,147,483,639 = 2³¹−9 | openjdk/jdk master, jdk/internal/util/ArraysSupport.java, fetched 2026-09-19 |
“public java.util.ArraysSupport since JDK 22” | No such public class exists; ArraysSupport is jdk.internal.util only | java/util/ArraysSupport.java returns 404 on openjdk/jdk master |
| A 2³¹−1 plaintext gives an envelope of “~2³¹+103” bytes | 2³¹−1 + 111 = 2³¹+110 | spec §3.1: overhead 1+2+16+32+12+16+32 = 111 |
JCA GCM: update returns ct, doFinal returns the tag, so the core needs a “split-tag dance” | Encrypt doFinal returns the final ciphertext bytes followed by the tag, and decrypt takes ct‖tag as one input. Spec §3.1 places ciphertext and tag contiguously, so there is no split to get wrong (§5.1) | spec §3.1 layout; the JCA Cipher AEAD contract |
HKDFParameterSpec.ExtractExpand, deriveData(..., 32) | HKDFParameterSpec.ofExtract().addIKM(..).addSalt(..).thenExpand(info, 32) yields HKDFParameterSpec.ExtractThenExpand, and KDF.deriveData(spec) takes only the spec | javax/crypto/spec/HKDFParameterSpec.java, javax/crypto/KDF.java, fetched 2026-09-19. Moot at the JDK 21 floor (§1), which does not use this API |
| On JDK 21, HKDF “must fall back to BouncyCastle” | RFC 5869 over javax.crypto.Mac (HmacSHA512) is about 20 lines, is available on every JDK, and the kdf/ family checks it. A JDK 21 floor costs no dependency | RFC 5869 §2 |
| Argon2id salt: “32-byte salt handling” in one place, “16 bytes” in another | 16 bytes, derived by HKDF-SHA-512 with info = "fieldseal-argon2-salt-v1" | spec §7.3, the Argon2id invocation block |
| bcprov is “the mainstream FIPS-listed pure-Java crypto provider” | bcprov-jdk18on is not FIPS-validated; the validated module is the separate bc-fips artifact. The claim is removed | BC distribution naming. Resolved at S2: bc-fips carries no Argon2. Its current release, 2.1.3, has no class whose name contains “argon” (0 of 7,004 jar entries; it ships scrypt). A FIPS-validated BouncyCastle build therefore offers no Argon2id path, which matters only if CL-9 FIPS conversations ever reach this core |
| CSPRNG “seeded via the strongest available source” | SecureRandom.getInstanceStrong() can block on Linux. Use new SecureRandom(), the platform default, which does not block. The JVM does not fork() a running VM, so docs/09 §10’s prefork guidance does not apply | JDK SecureRandom documentation |
Node’s MAX_LENGTH “≈ 2⁵³” on “Node ≥ 22”; a 32-bit Node figure | docs/18 measured buffer.constants.MAX_LENGTH = 2⁵³−1 on Node 24 x64, and the TypeScript core targets Node ≥ 24.7. The 32-bit figure had no source and is dropped | docs/18 §4 |
0.2 Structural changes
- The binding doc is written first, not last.
docs/17§2 and §5 item 1 have the implementer build “per the module layout and API shape in the language binding doc”, anddocs/10anddocs/11existed as tech specs before their cores did. This document is that tech spec; stage S8 (§8) updates it as built. - The draft’s “reachability audit by reflection” is replaced (§6.2). Reflection can list methods but cannot see the order of statements inside one. The replacement is a behavioural test through an internal operand seam: a synthetic 2³¹-long operand whose content accessors throw, and a spy provider that must record zero calls.
docs/09§4 has named this seam as architecture since G26. - The platform-maximum probe is informational, not a gate (§6.4). The unrepresentability argument rests on the type (
byte[].lengthisint, so no operand reaches 2³¹), not on any measurement. The probe earns its place by naming the real ceiling in this document. - An out-of-band entry the draft had missed is added:
docs/09/7.1/lone-surrogate-refusal.java.lang.Stringis UTF-16 and can hold an unpaired surrogate, so this core runs the entry and passes it directly (§6.5). - Memory claims include JCA’s own buffering (§6.3). SunJCE’s GCM decryption holds back plaintext until the tag verifies, so it buffers about the size of the operand internally. The draft’s “the only allocations on the decrypt path are…” was not true of the platform cipher. (S2 corrected this review item in turn: the buffering happens only when ciphertext arrives through
update(). A singledoFinal, the call §5.1 prescribes, allocates no operand-sized buffer; §6.3 has the figures.) max-usesis along. Spec §5.5 allows max-uses up to 2³², which does not fit in anint: the same int/long hazard as the buffer bound, in a place the draft did not look (§4, §7).- The module layout uses JPMS properly (§3). A module exports packages, not classes, so the draft’s flat class list could hide nothing.
testingbecomes a separate Gradle subproject and artifact, which is how “never exported from the main module” (docs/09§1) reads on the JVM.
0.3 What the review left open, and how it closed
- The JDK floor. Decided 21 by the maintainer on 2026-09-22 (
docs/26§5 item 1,docs/07§7). The ground is that the Hibernate adapter is in Phase 2’s scope and is the reason this core exists. The draft’s claim that Hibernate deployments skew toward 17 and 21 is unsourced and is not part of that ground. - The report-contract gap. Applied literally,
docs/14§4 gave this corenot-runon both length-bound entries forever. G26 (#176, closed 2026-09-22) added thebasisfield and the seam route, so this core can recordpasswithbasis: "seam"under the conditions in §6.5. - Still open: the Maven coordinates. A group id such as
dev.fieldsealneeds its namespace verified on Maven Central before anything is published under it, and nothing is published under this document outside PRD §8’s five experimental-release conditions (§10).
1. Package identity and toolchain
| Item | Decision | Notes |
|---|---|---|
| Location | core/java/ | Stage S1 since 2026-09-23: the Gradle scaffold, the module skeleton and the java-core job. Stage S2 since 2026-09-24: CapabilitiesTest and the java-memory-probe job. Stage S3 since 2026-09-24: the codec, the registry and the error taxonomy. Stage S4a since 2026-09-25: the crypto primitives (context, kdf, aead, commitment). Stage S4b since 2026-09-25: the key providers, the DekCache and the client. Stage S5 since 2026-09-27: blind indexes, the normalizers and the vendored Unicode tables. Stage S6 since 2026-09-27: encrypt_with_materials, the conformance report, and java in cross-core-result-ids. Stage S7 since 2026-09-28: java in cross-produce, CROSS_PRODUCERS and cross-consume. Stage S8 since 2026-09-29: this document as built, LargeContextTest (§5.2), and the divergence report, docs/28 |
| Build | Gradle 9.x, foojay-resolver-convention toolchains | A pinned JDK patch; nightly legs float the latest patch (docs/14 §5) |
| JDK floor | 21 (LTS) | §0.3. HKDF is written over Mac (§5.2); JEP 510’s javax.crypto.KDF arrives with JDK 25 and is not used |
| Module | dev.fieldseal.core, plus dev.fieldseal.core.testing as a separate artifact | Final names follow the governance decision on coordinates (§0.3) |
| CI | a java-core job in .github/workflows/conformance.yml | Mirrors the Python and TypeScript jobs; uploads conformance-java.json |
Joining the shared jobs is the list WS-L left in the workflow (the comment above cross-produce, and docs/14 §2–§3):
- a
needsentry, a named download andjavain--coresforcross-core-result-ids; - a
javaentry incross-produce’s matrix, with its produce steps; javain the workflow’sCROSS_PRODUCERS;- a
javaconsumer incross-consume, with its consume steps.
No change to the comparison or to the existing consumers is needed.
2. Dependencies
| Need | Choice | Notes |
|---|---|---|
| AES-256-GCM, HMAC-SHA-512, constant-time compare, CSPRNG | the JDK (SunJCE, SecureRandom) | No third-party crypto for suite 0xFF01 apart from Argon2id |
| HKDF-SHA-512 | written in the core over Mac.getInstance("HmacSHA512") | RFC 5869; about 20 lines; checked by kdf/ (§5.2) |
| Argon2id (spec §7.3) | org.bouncycastle:bcprov-jdk18on 1.86, for Argon2id only, the core’s one runtime dependency since S5 (requires org.bouncycastle.provider) | SunJCE has no Argon2. BouncyCastle is pure JVM, while argon2-jvm goes through JNA to native code. Confirmed at S2: the class names (org.bouncycastle.crypto.generators.Argon2BytesGenerator, org.bouncycastle.crypto.params.Argon2Parameters.Builder), and that withVersion(ARGON2_VERSION_13), withParallelism(1) and a 16-byte salt reproduce all 12 raw values in blind-index/argon2id.json, at both cost points it pins (t = 3 and t = 4, m = 32768). The salt, answered: the builder copies it (withSalt), build() copies it again, and getSalt() returns a copy. Builder.clear() and Argon2Parameters.clear() erase the two copies they hold, and the core is to call both (S5). A third copy, taken through getSalt() inside every generateBytes call, is never erased; that is read from the 1.86 bytecode, since no test can observe it, and §5.4 counts it. The builder also caps m at 2²⁴ KiB (16 GiB) through the system property org.bouncycastle.argon2.max_memory_exp, far above any cost spec §7.3 contemplates |
Unicode 17.0.0 tables (docs/09 §7.1) | vendored: a text resource tools/ucd-gen generates into internal/blindindex, never the platform’s | JDK 21’s character tables are at Unicode 15.0, below the pin. (This row said 15.1 until S6. Measured on Temurin 21.0.12: U+31350, added in 15.0, is defined; U+2FFC, U+31EF and U+2EBF0, added in 15.1, are not.). The resource rather than generated source because a Java string constant is capped at 65,535 bytes and the folding table is larger; one copy per core rather than one shared, hashed resource, so that each is checked by the same --check (decided at S5, docs/07 §7, 2026-09-27) |
| Tests only | JUnit 5, jqwik (property and fuzz testing), Jackson (vector JSON), ArchUnit, ICU4J 78.3 (the Unicode 17.0.0 oracle, since S5) | None of these ships in the published artifact |
3. Module layout
Package root dev.fieldseal.core. The packages mirror docs/09 §1’s modules, so that JPMS can hide them:
core/java/
fieldseal-core/ module dev.fieldseal.core
dev/fieldseal/core/ api: Fieldseal, FieldContext, KeyProviders, IndexDeclaration, ValidatedIndex, Unassigned, CachePolicy, ReadMode (exported)
dev/fieldseal/core/errors/ FieldsealError + one subclass per §9 code (exported)
dev/fieldseal/core/keyprovider/ KeyProvider SPI, KeyRequest, EnvelopeHeader, KeyMaterial, Wrapper, WrappedKeyStore (exported: callers implement the SPI)
dev/fieldseal/core/internal/envelope/ header, parse, serialize, isCiphertext, BufferLimits, Operand (the §6.2 seam)
dev/fieldseal/core/internal/registry/ frozen suite table + allow-list
dev/fieldseal/core/internal/context/ canonical_context, AAD
dev/fieldseal/core/internal/kdf/ HKDF, record_key, index_key
dev/fieldseal/core/internal/aead/ 0xFF01 over javax.crypto.Cipher
dev/fieldseal/core/internal/commitment/ §4.6 compute/verify (provisional, G1)
dev/fieldseal/core/internal/blindindex/ IDFs, truncation, normalizers, UCD tables (resource ucd-17.0.0.txt)
dev/fieldseal/core/internal/cache/ DekCache
dev/fieldseal/core/internal/config/ (empty since S4b: the builder validates, in api)
dev/fieldseal/core/internal/testing/ MaterialsSeam: the arming gate, and the hook the api installs (S6)
fieldseal-core-testing/ module dev.fieldseal.core.testing: encrypt_with_materials, armed by FIELDSEAL_TEST_MODE=1 (docs/08 §6)
module-info.javaexports the three public packages, plus one qualified export (exports dev.fieldseal.core.internal.testing to dev.fieldseal.core.testing) for the test seam, and nothing else. The seam, as built at S6: docs/09 §1 lets no module importapi, sointernal.testingholds a gate and a one-shot hook,MaterialsSeam, and the api’s static initializer installs itsencryptWithMaterialsthere. The testing module’sFieldsealTesting.encryptWithMaterials(client, plaintext, ctx, msgSeed, nonce)asks the seam for it. That isencryptin full, API-boundary order included, with the CSPRNG’s two draws replaced by the caller’s values. The gate is checked in the core, twice, not only in the testing module: a qualified export binds on the module path, and on the class path every public class is reachable. The seam refuses to hand out the encryptor unlessFIELDSEAL_TEST_MODE=1, and the api checks again before it encrypts. The second check cannot be reached around the first, so its bite entry is stated as unobservable.docs/09§1’s dependency rule is enforced by an ArchUnit test in CI, not by prose:internal/*may depend only onregistryanderrors, andkeyproviderandcacheonly onerrors.internal/testing(S6) may depend onerrorsonly, a rule this core adds: docs/09 §1 states none fortesting, and this one keeps pipeline steps out of the seam, where a vector would test a parallel path.- The construction lives behind the
commitmentandaeadmodule boundaries, so a Gate 0b change to G1 or ADR-0002 touches one module (docs/26§6). - The three shipped providers are built in the api package, not in
keyprovider(decided 2026-09-25,docs/07§7). The derived provider needskdfand the envelope provider needscache, andkeyprovidermay reacherrorsonly, sokeyproviderholds the SPI andKeyProvidersin the api package assembles the three.docs/09§1 and the ArchUnit rules are unchanged. The builder’s validation lives in the api package for the same reason, which leavesinternal/config/empty. The envelope provider is bound to a cache of its own when it is made (#192,docs/07§7, revising the 2026-09-25 decision that a client binds it):KeyProviders.envelopetakes theCachePolicy, and an optional warmExecutor, and returns a provider that works whether a client or the caller calls it. Every client built with one provider shares its cache, which is whatdocs/09§8.3 describes: the cache is keyed by provider scope, not by client, so the per-client cache this replaces was the deviation. Sharing has three consequences: each key is unwrapped once; a DEK’s max-uses budget counts every client’s encryptions (an index key has no use budget: #212, below); and capacity is shared, so one client’s warms can evict another client’s keys, which then fail closed until warmed again. Clients that share a provider but serve different tenants need acapacitysized for all of those tenants together;docs/09§10’s readonly-analytics-beside-strict-serving case is the likely one. A client that needs its own cache is built with a provider of its own. The refusal for an evicted key says that eviction is one of its causes.
4. Public API shape
Bytes in, bytes out:
KeyProvider keys = KeyProviders.envelope(wrapper, store, // or staticKeys / derived / your own
new CachePolicy( // required: the provider's own DEK cache
Duration.ofMinutes(10), // max age
1L << 20, // max uses: long, since spec §5.5 allows up to 2^32
10_000)); // capacity
// optional 4th argument: the Executor warm runs on; default: 4 shared daemon threads
Fieldseal fs = Fieldseal.builder()
.keyProvider(keys) // required; every client built with keys shares its cache
.allowedSuites(Set.of(0xFF01)) // required, non-empty
.writeSuite(0xFF01) // required, member of allowedSuites
.readMode(ReadMode.STRICT) // STRICT | PERMISSIVE | READONLY
.armProvisionalSuites(false) // spec §4.8; also env FIELDSEAL_ARM_PROVISIONAL_SUITES=1
.onWarning(log::warn) // default: System.Logger at WARNING
.indexes(List.of(IndexDeclaration.builder(table, column)...build()))
.build(); // validates everything; immutable afterwards
FieldContext ctx = FieldContext.of(tableUuid, columnUuid).withTenant(tenantId); // .withRow(rowId); .forIndex(id)
byte[] ct = fs.encrypt(plaintext, ctx);
byte[] pt = fs.decrypt(envelope, ctx);
byte[] ix = fs.blindIndex(value, ctx.forIndex("email-eq")); // String (preferred) or byte[] (strict UTF-8)
byte[] mk = fs.unindexableMarker(ctx.forIndex("email-eq"));
boolean ok = fs.isCiphertext(bytes);
byte[] ct2 = fs.rotate(envelope, ctx); // ciphertext to ciphertext in every mode (spec §11.1)
CompletableFuture<Void> w = fs.warm(List.of(ctx)); // docs/09 §3.6: async where the language has it
// docs/09 §2 configuration reflection: validated, resolved, not mutable
fs.readMode(); fs.writeSuite(); fs.allowedSuites(); fs.provisionalArmed();
Map<String, ValidatedIndex> fs.indexes(); // keyed by indexRegistryKey(...)
// + static on Fieldseal: validateIndexDeclaration, indexRegistryKey,
// firstUnassigned -> Optional<Unassigned>, UNICODE_VERSION (docs/09 §12, G18/G22)
// + IndexDeclaration.Normalizer.normalize(String | byte[]): spec §7.5's comparison (docs/09 §7)
The cache values above are examples, not defaults: CachePolicy has none, and every limit is required (decided 2026-09-25, docs/07 §7). Its documentation and the README describe the limits as security parameters, not performance tuning (spec §5.5).
Decisions:
byte[]is the byte type (docs/09§12, “idiomatic byte type”). The public value path has noByteBuffer.blindIndexacceptsStringandbyte[](docs/09§7.1, “where the refusal has to live”). Thebyte[]form decodes with aCharsetDecoderset toCodingErrorAction.REPORT, and a decoding failure isINVALID_ARGUMENT, which theblind-index/refusevectors pin (suite0.8.0-provisional).new String(bytes, UTF_8)replaces malformed input silently and MUST NOT appear in a value path; CI greps for it.firstUnassignedoffsets count code points, not UTF-16 units (docs/09§12). This is the JVM-specific way to get it wrong, and it has an astral-plane test. It reports a lone surrogate as well as an unassigned code point, sincenfc-casefold-v1refuses both.- A blind index is selected through the context, and its purpose is the core’s (S5).
FieldContext.forIndex(id)names the index; the core looks the declaration up by(table_uuid, column_uuid, id)and derives under"index:" + idfrom that declaration, so a derivation string is never one a caller passed (spec §6.1). An id outside the §6.1 grammar is refused byforIndexitself.encrypt,decryptandrotaterefuse a context that names an index (INVALID_ARGUMENT).indexRegistryKeyis the table and column in lowercase hex and the id, joined by/<wbr>: a language-local key, not a portable one. - Order on
blindIndex, as built (S5,BlindIndexClientTest): the value (null) → the context (null, or naming no index:INVALID_ARGUMENT) → the declaration (none:ConfigurationError, never a default IDF;docs/09§3.3 step 2) → key acquisition (KEY_UNAVAILABLE) → normalization. Neither the read mode nor spec §4.8’s arming gates it (spec §10.3). The index-key request carries the purpose and norow_id, since the index key is not per row (spec §7.2). - What
on_unindexable = bucketcatches (S5): every refusalnfc-casefold-v1makes, which is an unassigned code point, a lone surrogate in aString, and malformed UTF-8 in abyte[].docs/09§7.2 names the first; the other two are refused on the same terms (§7.1 clauses 1 and 5), so they take the same route. No vector distinguishes the readings.identityover aStringholding a lone surrogate is refused too, because the text has no UTF-8 encoding to hand on;bucketis not allowed underidentity, so that refusal always raises. That isdocs/09§7.2’s gate, whose predicate is “refuses well-formed text” (clarified there after #208’s review): spec §3.6 has an adapter refuse to store such a string at all, so a marker would keep no row findable. - The index key follows the write suite (S5; #208 review, #213).
canonical_contextcarries asuite_id, and on the index path the only suite a client has iswriteSuite. So a client’s index bytes depend onwriteSuite()as well as onindexes(), and a registry comparison must compare both. Changing the write suite re-keys every index. No document states which suite the index context carries; #213 asks the spec to decide. Unobservable while0xFF01is the only suite that can be built. - Declaration validation (S5,
IndexValidation): spec §7.4’s band in integers,2^(b+1) ≤ P < 2^(2b)withP ≥ 16; spec §7.6’s gate atP < 2^10or a column declaredskewed, lifted only by acardinalityOverridewith a non-blank reason, approver and a date;docs/09§7.2’s rules forbucket; the Argon2id cost against spec §7.3’s minima and against BouncyCastle’s own memory ceiling (a system property, 2²⁴ KiB by default), so that a cost it would refuse is refused at declaration, not at the first derivation; and one declaration per registry key. Every override is logged throughonWarningwhen the client is built, with its reason, approver and date: spec §7.6 requires the override to be logged.skewedis spec §7.6’s second half, a boolean gated exactly as a smallPis. It was missing from the first push, becausedocs/09§7’s sketch did not show it; the other two cores have it, which a review comment on #211 reported (an isolation exposure,docs/28§1, X-4), anddocs/09§7 now shows it. - The five operations are synchronous and I/O-free (spec §11.1). This binding ships no async companions: Hibernate cannot await in the value path, and a companion would still pay Argon2id’s CPU cost on some thread. This is the binding’s G9 decision (
docs/09§11), and the report saysasync_companions: false. - Errors:
FieldsealErrorsubclasses whose.code()returns the exact §9 string (the base class issealedover exactly these, since S3), plus the local configuration code (docs/09§9) andINVALID_ARGUMENT(docs/09§7.1). Mappings:AEADBadTagException→TAG_INVALID, and only after the commitment has verified (docs/09§3.2 step 6);- provider exceptions →
KEY_UNAVAILABLE; - malformed UTF-8 →
INVALID_ARGUMENT.
- What an internal module throws (since S4a): a
FieldsealErroronly for a caller’s input it is the first to see (ContextFieldsrefuses a wrongly sized UUID withINVALID_ARGUMENT), and otherwiseIllegalArgumentExceptionorIllegalStateException, which mean a bug in the core. The AEAD checks its offsets before calling the JDK, so a bad one there is anIllegalArgumentExceptionrather than a JDK array or buffer exception; the codec relies on recognition having bounded every offset first (§6.3). The client does not map these two to a §9 code: a core bug must not read as a verdict on the ciphertext. api-boundary-orderis this core’s own pinned decision (docs/14§4). It is declared in the report and tested.docs/09§3.1 notes that the order of steps 1 and 1b cannot be observed through vectors. As built (S4b,ApiBoundaryOrderTest): onencrypt,MODE_VIOLATION→SUITE_PROVISIONAL→ the operand (null:INVALID_ARGUMENT; thenLENGTH_EXCEEDED) → the context (INVALID_ARGUMENT) → key acquisition. Onrotate, the same first two, then the operand asdecryptreads it, except that a non-envelope isNOT_CIPHERTEXTin every mode.decrypt-order, as built: the operand (null) → recognition (UNKNOWN_FORMAT_VERSION; a non-envelope isNOT_CIPHERTEXTinstrict, returned as-is otherwise) →LENGTH_EXCEEDED→SUITE_NOT_ALLOWED→ the context →KEY_UNAVAILABLE→ per candidate, the commitment and then the tag (TAG_INVALID) →COMMITMENT_INVALID.AAD_MISMATCHis never raised (aad-mismatch): under spec §6.3 a wrong context and a wrong key are indistinguishable.unimplemented-registered-suite(decided 2026-09-25): naming0xFF02inallowedSuitesor aswriteSuiteis aConfigurationErrorthat names G7. A0xFF02envelope is still recognized (isCiphertextis true), and decrypting one isSUITE_NOT_ALLOWED.FieldContexthas nosuite_idand nopurpose. The core fills both (docs/09§12):suite_idfromwriteSuiteon a write and from the envelope’s header on a read (docs/09§3.2 step 4); the purpose is"encrypt"for values and an index’s own at S5, never a string a caller passed.decryptionKeysreceives the call’s context as well as the header (decided 2026-09-25). Spec §8 passes the header alone, and a derived provider cannot find a tenant in an opaquekey_id.EnvelopeHeadercarriessuite_id,key_idand aKeyRequestfor the call.- What the core checks in what a provider returns: a non-empty key and a 16-byte
key_id, and a non-empty candidate list of non-empty keys. Anything else, and any exception, isKEY_UNAVAILABLE, with the provider’s exception as its cause. The DEK’s length is not checked: the spec does not fix it. - Warnings go through
onWarning, by defaultSystem.LoggeratWARNING(no logging framework,docs/09§11): at construction, for a permissive or readonly client and for the static provider outsideFIELDSEAL_TEST_MODE=1. The metrics hook ofdocs/09§2 is not built yet. - The envelope provider fails closed.
warmis the only place it calls the key store or the KMS. A key that was never warmed, or has aged out or, for a DEK, used up its budget, isKEY_UNAVAILABLEuntil the nextwarm; there is no background refresh. The index role has no use budget (#212, #221;docs/09§8.3 as amended by #218):DekCache.takeForEncryptcounts a use only for a key whose slot’s role isDEK, reading the role from the key rather than from a caller’s flag, so no path can reintroduce the count by omitting one. Max-age and the capacity LRU still apply to an index key. Until this fix, everyblindIndex, on write and on query, spent one use of the index key’s budget, and atmaxUsesthe index path answeredKEY_UNAVAILABLEuntil the nextwarm(tested byDekCacheTestandEnvelopeProviderTest.theIndexRoleHasNoUseBudget, red before the fix). Eachwarmof a slot re-unwraps every version the store lists (restarting its age and use budget, so a schedule shorter than max-age never lapses), evicts the versions the store no longer lists, and then makes the first listed version active. The slot changes only once its whole list has loaded, so a failed warm never changes which key writes go out under (#190 review).
5. Security-relevant implementation notes
5.1 AES-256-GCM through JCA
Cipher.getInstance("AES/GCM/NoPadding")withGCMParameterSpec(128, nonce), andupdateAAD(aad)before any data.- Encrypt:
doFinal(plaintext, 0, n, envelope, 63)writes ct‖tag straight into the pre-sized output envelope at offset 51 + 12. That is one allocation: the envelope itself. - Decrypt:
doFinal(envelope, 63, ctLen + 16, out, 0)consumes ct‖tag in place. Spec §3.1 makes the two fields contiguous, so no split and no copy are needed. - A new
Cipherfor every call. Instances are not thread-safe, and SunJCE refuses a repeated key and IV on an encrypting instance anyway. - Confirmed at S2 by the
envelope/family: with the vector’s record key, nonce and AAD, the encrypt call above rebuilds all nine envelopes byte for byte, and the decrypt call reads each back in place. A flipped tag bit, ciphertext bit or AAD bit each raises exactlyAEADBadTagException, not a subclass or sibling. The repeated key-and-IV refusal is confirmed too (InvalidAlgorithmParameterException). - Found at S2, and binding on the core:
- Decrypt is one
doFinal, neverupdate(). Throughupdate(), SunJCE buffers the ciphertext and allocates about 3× the operand; onedoFinalallocates no operand-sized buffer (§6.3). - After a tag failure the output range holds no plaintext, and is not left as it was either: SunJCE on Temurin 21 zero-fills it. The core discards that array anyway; it must not assume its earlier contents survive. The audit asserts only the absence of plaintext, which is the property the core relies on, and prints the observed fill, since what the provider writes there is its own business and may change under a JDK.
- Decrypt is one
5.2 HKDF-SHA-512 at the JDK 21 floor
- The construction: RFC 5869 extract-then-expand over
Mac.getInstance("HmacSHA512"), with the PRK erased after expand. - One JVM-specific trap. Wherever the spec’s HKDF salt is empty (the commitment in spec §4.6, and the Argon2id salt in §7.3;
record_keyin §5.3 is salted withkey_id ‖ msg_seed), RFC 5869 §2.2 substitutes HashLen (64) zero bytes, and spec §4.6 says so in its own comment.new SecretKeySpec(new byte[0], "HmacSHA512")throws on an empty key, so the core passes 64 zero bytes explicitly. HMAC pads its key to the 128-byte block with zeros, so the two are the same key. Confirmed at S2:SecretKeySpecthrowsIllegalArgumentExceptionon the empty key, and 64 zero bytes reproduce all three commitment values incommitment/and the Argon2id salt carried by each of the 23 vectors inblind-index/argon2id.json. Every all-zero key of 1 to 128 bytes gives the same HMAC, and 129 bytes does not, which is the padding argument itself. Thekdf/value vectors (four record keys, five index keys) pass over the sameMacconstruction. Their twodistinctvectors give a context object rather thaninfo, so they waited forcanonical_context, and run since S4a (KdfVectorsTest). - G14: what this core accepts, recorded at S8 so that G14’s resolution (#43) can be checked against it. Spec §6.1 leaves
tenant_idandrow_idunbounded, so the canonicalinfois unbounded too.Macdoes not capinfo, so this core derives over anyinfoit can hold.canonical_contextand the AAD are refused only pastInteger.MAX_VALUEbytes, as anOutOfMemoryErrorraised byCanonicalContextwith a message naming G14, by the codec’s rule (§6.1): a total nointholds is refused, never wrapped, and it is anError, not a §9 code. That limit is by construction and not tested, since a test would need arrays of 2 GiB.LargeContextTestruns 70,000-bytetenant_idandrow_id, past the Node (1,024 bytes) and OpenSSL 3.0–3.5 (32 KiB) caps spec §6.1 names:encrypt,decryptandrotateround-trip, the last byte oftenant_idchanges the index key (which never carriesrow_id, spec §7.2), and the last byte of each field changes the record key. The tests vary only the last byte, so they show that the end of a 70,000-byte field reaches the derivation, not that every byte before it does. So this core can derive keys for any context a core on a capped platform HKDF can write, and for some it cannot. A bite entry capsinfoat 1,024 bytes: the two last-byte tests go red, and the round trip stays green, because the AAD carries the whole context.
5.3 The rest of the crypto
- Constant-time compare:
MessageDigest.isEqual. Tags and commitments have equal lengths by construction; check the lengths first anyway, with a comment explaining why. A mismatch is the core’s bug, so it throws before any derivation rather than answering “no match”, which would surface asCOMMITMENT_INVALIDand blame the ciphertext (since S4a). - Argon2id (spec §7.3):
- version 0x13, p = 1, output 64 bytes;
tandmfrom the declaration, defaulting to the §7.3 minima;- construction refuses a declaration below either minimum, and one carrying
tormon anhmac-sha512index (docs/09§12); - the salt is the 16-byte HKDF-derived value, erased after the call, with both BouncyCastle copies this code can reach (
Builder.clear(),Argon2Parameters.clear()); - built at S5 in
internal/blindindex/Idf, over an injected HKDF, ascommitmentis (§8, S4a).
5.4 Zeroization and the memory model (this binding’s G17 half)
byte[]is mutable, soArrays.fill(x, (byte) 0)in afinallyperformsdocs/09§3’s erasure steps on the buffers the core owns:record_keyon both paths, the index key, the normalized value, the untruncated IDF output, the Argon2id salt, the HKDF PRK and expand blocks, the commitment recomputed on decrypt, and the AEAD output on every exit that does not return it. The normalized value is always the core’s own copy,identityincluded, so erasing it never touches a caller’s array. What aStringholds cannot be erased, and nor can the intermediate code point arrays’ copies insideStringconstruction.- The core never zeroizes provider-owned material (
docs/09§8.1, G17). It validates what a provider returns (key length,key_idlength) and maps exceptions toKEY_UNAVAILABLE. A test with a provider that keeps and inspects its own buffer proves the core never writes to it. - What the core cannot promise:
SecretKeySpeccopies the key it is given;CipherandMacinternals, JIT register spills and GC compaction can leave copies the core cannot reach;- BouncyCastle’s Argon2 takes one more copy of the salt on every call and never erases it (found at S2, §2);
- the DEK copies a provider returns on every call: one per
encryptionKey, and one per cached version on everydecryptionKeys. They are the provider’s (docs/09§8.1), so the core may not erase them, and the envelope provider’s are fresh copies that nothing erases. Their fate is the garbage collector’s (S4b). #192’s slot index does not narrow them: it stops a decrypt from walking other tenants’ entries, but a decrypt still receives one copy per fresh version in its own slot, as before. Narrowing them is #200.
pinned_decisions.key-material-ownershiplists the steps performed, the provider carve-out, and a clause saying none of this is guaranteed (spec §5.5).- No
mlockand no swap protection: a documented deviation, worded asdocs/10anddocs/11word theirs.
5.5 Concurrency and CSPRNG
- The client is immutable after construction and the
DekCacheis thread-safe, so all five operations are re-entrant (docs/09§10). - CSPRNG: one
new SecureRandom()per client (it is thread-safe), nevergetInstanceStrong(). - Fork-safety does not apply: a JVM is not
fork()ed while it is running. warm: single-flight refresh, so N concurrent misses cause one unwrap per key (docs/09§8.3). Built at S4b asConcurrentHashMap.putIfAbsentof a future that concurrent loads join, with the unwrap outside the cache’s lock; a failed load leaves nothing behind.- The value path’s lock. The
DekCachehas one lock, and its entries are indexed by slot, soencryptionKeyanddecryptionKeyshold it for their own slot’s versions only, never for a walk of the whole cache (#192). The lock is still one for the whole cache, so tenants take turns for it: this is short ofdocs/09§8.3’s “lock-free or fine-grained-locked reads”, a recorded deviation until a striped or lock-free cache replaces it. Unwraps already run outside it, so no read waits on a KMS call. A read counts no use, and neither does an index-role fetch (#212), but both mark the keys they return recently used, so a key that only decrypts is not the first evicted at capacity. Before #192 a read walked every entry in order, which left the order unchanged. That lengthens how long a key that is only read stays cached, which is the direction §5.4 is about: it now leaves at max-age, or when colder keys are gone, rather than first. Max-age still bounds it, because a read checks age before it returns a key. - Where
warmblocks. The envelope provider’swarmcalls the key store and the KMS, which block, so it runs them on the executor passed toKeyProviders.envelope(#192). By default that is a pool of four daemon threads namedfieldseal-warm-N, each gone after a minute idle. Four is fixed rather than the common pool’s CPU count minus one: awarmwaits on the KMS, not the CPU, and a documented bound should not vary by machine. That is more concurrency than before on one or two CPUs and less on many. Awarmbeyond the four waits its turn and is never refused. The threads do not inherit the calling thread’s inheritable thread-locals, so a request’s context is not handed to aWrapper. It is never the ForkJoin common pool: that pool’s threads belong to the application’s other async work, and on two CPUs or fewerCompletableFutureuses a new thread per task instead of it. The pool is shared by every envelope provider in the process, the one process-wide object in this core whose contents change.docs/09§10’s “no global mutable state” is there so that clients with different configurations coexist, and the pool holds no provider’s or client’s state or configuration, so they still do. What they now share is one thread budget: one provider’s burst of warms delays another’s. A caller who needs a bound of their own, or isolation, passes an executor toKeyProviders.envelope; a same-thread executor makeswarmblock its caller. Awarmstill loads its slots one after another; loading them in parallel is a separate change. An executor that rejects the task failswarm’s future.
6. Buffer limits and the report contract
This section discharges the per-binding obligation in docs/09 §4: each core’s binding doc states its platform’s largest byte buffer, and whether that ceiling or spec §3.5’s bound binds first.
6.1 Where the JVM binds relative to the spec bound
| Limit | Value | Source / status |
|---|---|---|
Java language (int array length) | 2³¹−1 = 2,147,483,647 | JLS §10: array length is int. A length computed past Integer.MAX_VALUE wraps to a negative number, and new byte[negative] throws NegativeArraySizeException |
JDK soft limit SOFT_MAX_ARRAY_LENGTH (internal) | Integer.MAX_VALUE - 8 = 2,147,483,639 = 2³¹−9 | jdk.internal.util.ArraysSupport; used by the JDK’s growth policies, not a VM limit |
HotSpot allocatable byte[] | 2³¹−3 = 2,147,483,645 on Temurin 21.0.12 (HotSpot, G1, -Xmx6g), the same on Windows x64 and on CI’s ubuntu-24.04 runner; one byte more fails with “Requested array size exceeds VM limit”, the VM’s limit, not the heap’s | Measured by §6.4 on 2026-09-24, not cited: locally, and in the java-memory-probe job on PR #186 |
| 32-bit JVM | far lower, bound by address space | spec §3.5 names the case. Documented only, never run in CI |
The platform binds first, by construction. No Java array has a length of 2³¹ or more, so a 2³¹-byte plaintext cannot be an operand, and an over-bound envelope (at least 2³¹+111 bytes) cannot be received either. This holds on every JVM, whatever heap or flags it runs with. Spec §3.5 already says it: “the JVM cannot reliably allocate a byte[] of exactly Integer.MAX_VALUE”.
An asymmetry, recorded in harness_notes: Python and Node can produce a valid envelope larger than any JVM array. The largest is 2³¹+110 bytes, for a 2³¹−1 plaintext, and a JVM cannot receive it at all. The spec’s ceiling-not-a-guarantee clause covers the case.
The same limit on encrypt. An envelope is its plaintext plus 111 bytes, so on HotSpot 21 the largest plaintext this core can encrypt is (2³¹−3) − 111 = 2,147,483,534 bytes, 113 short of the bound. A plaintext between that and 2³¹−1 passes the LENGTH_EXCEEDED guard and then fails to allocate its envelope. That surfaces as an OutOfMemoryError, not as LENGTH_EXCEEDED: the VM’s own for a total it can attempt, and the codec’s, raised with a message that says so, for a total no int holds, where the alternative is a wrapped cast. It stays an Error so that a converter’s catch (Exception) cannot turn the value into a silent failure. The plaintext is within the bound, and spec §3.5 makes a platform failure below the bound conformant (built at S3).
6.2 The seam, and why an int-typed guard would be wrong
if (plaintext.length > MAX_PLAINTEXT) against an int constant of 2³¹−1 is dead code by construction, and a test that calls it only with real arrays proves nothing. The guard is made testable by docs/09 §4’s length seam, which for this core is part of the core, not an option (docs/26 §4):
- An internal
Operandinterface exposeslong length()and bounded read and copy methods. Each public entry point (encrypt,decrypt,rotate) builds it exactly once from the caller’sbyte[](Operand.of(p)) and passes it to the internal pipeline. Every later step reads through it. - The guard takes a
longlength (2_147_483_647L), so 2³¹, 2³² andLong.MAX_VALUEall reach the refusal. - Positions:
encrypt: step 1 isreadonly → MODE_VIOLATION, and step 1b is the length guard. Both sit at the API boundary, before context validation, key acquisition or any copy (docs/09§3.1).decrypt: spec §3.5 leaves the position open. This core applies the guard right after structural recognition (docs/09§3.2 step 2), before the allow-list, key lookup and output allocation, and declares that choice indecrypt-order.rotate: both checks apply.blindIndexandunindexableMarker: no length guard applies.
- The wiring test:
- Encrypt case: a synthetic
Operandreportinglength() = 2^31that throws on every content access, driven through the internal pipeline with a spyKeyProvider. It assertsLENGTH_EXCEEDED, zero provider calls and zero content accesses. - Decrypt case: a synthetic operand whose implied plaintext length (received length minus the suite’s fixed overhead) is at least 2³¹. It serves a valid
0xFF01header from a small backing array and throws on any other offset. Same assertions, except that recognition reads bytes 0–2 first: the test asserts no access past offset 2. - Bite check: move the guard one statement later and confirm the test fails, before merging.
- Built: the codec half at S3 (
BufferLimitsWiringTest), the provider half at S4b (SeamWiringTest), through the package-private pipeline each publicencrypt,decryptandrotateenters.
- Encrypt case: a synthetic
BufferLimits (in internal/envelope):
static final long MAX_PLAINTEXT = 2_147_483_647L; // 2^31 - 1, spec §3.5
static final int HEADER_LEN = 51; // 1 + 2 + 16 + 32
static long fixedOverhead(Suite s) { return HEADER_LEN + s.nonceLen() + s.tagLen() + s.commitLen(); } // 0xFF01: 111
static long impliedPlaintextLen(long received, Suite s) { return received - fixedOverhead(s); }
static boolean plaintextWithinBound(long len) { return len >= 0 && len <= MAX_PLAINTEXT; }
static boolean impliedWithinBound(long received, Suite s) { return impliedPlaintextLen(received, s) <= MAX_PLAINTEXT; }
Three rules, each with a test:
- 64-bit arithmetic wherever a length is computed, including in every test that builds a near-bound length.
111 + 2_147_483_648computed asintwraps negative. - A negative implied length belongs to recognition, not to the length check. It means the envelope is too short for its suite:
NOT_CIPHERTEXTinstrict, pass-through in the other read modes (docs/09§3.2 step 2). - Over-bound is
LENGTH_EXCEEDED, never the platform’s error. No clamping and no wrapping.
6.3 No-copy recognition, and what the platform copies anyway
Recognition is index arithmetic on the caller’s array: 1 B version, 2 B suite, 16 B
key_id, 32 Bmsg_seed. The operand is never copied withArrays.copyOfRange.key_idandmsg_seedare copied, because the KDF and the provider need arrays, and they are small and fixed-size.What the core allocates. On decrypt: the header object, the small fixed fields, and the output plaintext, which comes after the guard. On encrypt:
msg_seed, the nonce and the output envelope.What the platform allocates. This said SunJCE’s GCM decryption buffers the ciphertext until the tag verifies, putting peak decrypt memory at about 2× the operand plus the output. Corrected at S2, measured with
getCurrentThreadAllocatedByteson Temurin 21.0.12 over a 64 MiB operand, after a warm-up (CapabilitiesTest):Call Allocated beyond the caller’s arrays encrypt, one doFinalinto a pre-sized output22,280 B decrypt, one doFinalover ct‖tag into a separate output22,320 B decrypt, the same bytes through update()thendoFinal201,349,032 B (3.0×) The claim was true of the
update()path only. Given the whole of ct‖tag in one call, SunJCE does not buffer, and it releases no plaintext on a failed tag (§5.1). The core decrypts in onedoFinal(§5.1), so its decrypt peak is the envelope plus the plaintext it returns. The test first checks that its counter registers a 64 MiB array, then fails if either one-shot figure reaches 1 MiB, so a JDK that starts buffering is noticed. This document still does not promise zero-copy crypto: the figures are one JDK’s.Large but conformant operands. A positive round trip near the ceiling is not a conformance requirement; only the refusal is. At the measured figures, decrypting a 1 GiB envelope needs about 2 GiB of heap, the envelope and its plaintext, and spec §3.5 makes an out-of-memory failure there conformant. The core adds no heap pre-checks.
6.4 The platform-maximum probe (informational)
BufferMaxProbe is a JUnit test tagged @Tag("memory"), run by ./gradlew memoryProbe (never by build) in the java-memory-probe CI job with -Xmx6g. Runner memory, resolved at S2: GitHub’s runner reference gives standard Linux runners 16 GB for public repositories and 8 GB for private ones (fetched 2026-09-24), and this repository is public.
- Bisect for the largest
new byte[n]that succeeds, between 1 GiB andInteger.MAX_VALUE, to the exact byte. - Record which failure appears just above it: “Requested array size exceeds VM limit” (the VM ceiling) or “Java heap space” (the heap). Only the first names the platform ceiling.
- Record the JVM version, vendor, GC and flags.
The result is a fact for §6.1 (“on JDK X / HotSpot, the largest byte[] is 2³¹−K”). It is not a conformance input and not a gate. If the runner cannot allocate near 2 GiB, the probe reports the heap limit it hit, and §6.1 says the ceiling was not observed.
6.5 Conformance-report entries (docs/14 §4, as amended by G26)
spec/3.5/length-boundandspec/3.5/length-bound#decrypt:status: "pass",basis: "seam", underdocs/14§4’s conditions, all met by §6.2’s wiring test. Themethodstates:- that the route is the seam;
- the public surface it carries (
encrypt,decrypt,rotateoverbyte[]); - that the guard is unreachable from the public byte API on this runtime.
Proposed text, which the S6 report uses with the test names completed: “seam:
byte[]length isint, so a 2³¹-byte operand is unrepresentable here and the guard is unreachable from the publicbyte[]API. The refusal is proven on synthetic operands (length 2³¹ for encrypt; implied plaintext length ≥ 2³¹ for decrypt) through the internalOperandpipeline thatencrypt,decryptandrotateall enter, with zero key-provider calls and zero operand reads (BufferLimitsWiringTest).”docs/09/7.1/lone-surrogate-refusal:status: "pass",basis: "direct". Two distinct unpaired surrogates ("a\uD800b","a\uDC00b") must be refused, and refused distinguishably (docs/08§5 item 9).The
blind-index/refusevectors are ordinary results, not out-of-band. They pass throughblindIndex(byte[])(§4).Every out-of-band entry carries
basis. A level claim quoted outside the report names the twoseamentries (docs/14§4).pinned_decisions: all six mandatory keys (docs/14§4). This core may add aplatform-byte-buffer-maxkey carrying the probe’s figure.A backed pinned decision (#225):
index-role-use-budgetstatesdocs/09§8.3’s “the index role has no use budget”, a rule with no bytes to compare and so no vector (docs/08§8). The key’s text citesEnvelopeProviderTest.theIndexRoleHasNoUseBudget, which the report runs through the launcher. That test derives a blind indexmaxUses + 1times undermaxUses = 2, then use-evicts the DEK beside it as the positive control. A backing test that is missing, or does not pass, is a report problem: the L0 claim is withdrawn and the run exits 1. The key is this core’s own untildocs/14§4 makes it mandatory, which needs the other cores’ halves.harness_notes: the near-maximum envelope asymmetry from §6.1.
7. Testing plan
- The vector harness. It reads
MANIFEST.filesonly, neverheld_out, and verifies the hashes first. It takesvector_suite_versionfrom the manifest rather than a constant, and runs both directions ofenvelope/. It emits thedocs/14§4 report withbasison every out-of-band entry. An assertion shape it does not know is a recorded failure, never a skip. - jqwik properties (the
docs/09§4 fuzzing mandate):parse ∘ serializeround trips;isCiphertextis total over arbitrary bytes;- recognition over the length edges, computed as
long: 0, 1, overhead−1, overhead, overhead+1, 2³¹−1, 2³¹, 2³²,Long.MAX_VALUE; - guard totality: only typed errors ever escape;
CachePolicy.maxUsesat its edges: 2³² accepted; 2³²+1, 0 and negatives refused.
- Targeted tests:
- the §6.2 wiring test, with its bite check;
- the provider-ownership test (§5.4);
- the ArchUnit dependency test, with a bite check on an injected violation;
- “production
encryptaccepts no caller-supplied nonce or seed, in any form”; - the strict-UTF-8 grep (§4);
firstUnassignedon astral-plane input.
8. Build stages
Relative sizing only; docs/07 §3 rejects invented week numbers. These are stages inside WS-I, not the P2-M milestones of docs/26 §3. Each ends in an observable gate.
S0 — this document. Merged to dev, and the implementer reads it rather than the draft design.
S1 — Scaffold and CI.
- The Gradle multi-project (
fieldseal-core,fieldseal-core-testing), toolchains,module-info.javaand the package skeleton. - The ArchUnit dependency test.
- The
java-corejob, with an empty harness that iteratesMANIFEST.files. - Exit: CI green, and the ArchUnit test fails on an injected violation.
S2 — Capability audit (this document’s [VERIFY] sweep).
- GCM offsets and the tag-failure exception (§5.1).
- HKDF over
Macagainstkdf/, including the empty-salt trap (§5.2). - The strict UTF-8 decoder.
- BouncyCastle’s Argon2id against
blind-index/argon2id.json, and its salt-copy behaviour. - The decrypt allocation multiplier (§6.3), and the probe (§6.4).
- Exit:
CapabilitiesTestgreen. Every [VERIFY] is confirmed or corrected here, with adocs/07§7 entry, and deviations are noted (docs/17§5 item 5). - Built 2026-09-24.
CapabilitiesTest(in the testing module’s test sources) andBufferMaxProbe; the results are in the sections above and indocs/07§7. The strict decoder is checked against the fourrefusepreimages and an RFC 3629 table of malformed and boundary inputs.
S3 — Envelope codec, registry, errors, BufferLimits.
- Recognition, serialization and
isCiphertext. - The registry:
0xFF01in full;0xFF02registered and unbuilt. - The §9 error taxonomy.
- The
Operandseam and its wiring test (§6.2). - Codec fuzzing.
- Exit:
envelope/anderrors/green through the harness, and the wiring test passes and bites. - Built 2026-09-24, with the exit read as far as a codec reaches it. There is no client or key provider before S4, so:
envelope/: all nine envelopes parse to their vectors’ fields and re-serialize byte for byte (CodecVectorsTest).errors/: the recognition half runs to its expected outcome. That is 40 of 41formatvectors and 4 of 16policyvectors: everyis_ciphertextcase, and every decrypt or rotate outcome that recognition and the read mode decide. Every other decrypt vector is asserted to be recognized and within the bound, and its outcome waits for S4. A pinned count per file keeps the split from moving silently, and both families are enumerated fromMANIFEST.files, so a new file in either fails until it is pinned. The read-mode mapping the test applies is spec §3.4 and §10.3’s tables; in the core it belongs to the client.- The wiring test (
BufferLimitsWiringTest) is the codec half: the guard refuses synthetic operands of 2³¹, 2³² and near-Long.MAX_VALUElengths before any byte past the three recognition bytes is read, and passes exactly the bound. The zero-provider-calls half needs a provider, and S4 adds it by driving the same operands throughencrypt,decryptandrotate. - The decrypt front (
EnvelopeCodec.frontOfDecrypt) fixes the order recognition, then the guard, then the parse, so the guard reads the length only and runs before any field is copied. - Codec fuzzing: jqwik 1.10.1 properties for
parse ∘ serialize,isCiphertextagainst spec §3.4’s first row, totality of the decrypt front, and the §7 length edges. - Bite checks: each of these turns the build red: moving the guard after the parse, the suite minimum off by one, the reserved-version floor off by one,
0xFF02unregistered, and the encrypt bound off by one.
S4 — Crypto pipeline.
- KDF, AEAD, commitment, context and AAD.
- The three key providers, with a
Wrapperseam. DekCache: max-age, max-uses as along, capacity LRU, single-flight, erase on eviction.- Config validation and the reflection accessors.
- Exit:
kdf/,context/andcommitment/green; thekey-material-ownershipandapi-boundary-ordertests green. - S4a built 2026-09-25: the primitives, in their own PR. The providers, the
DekCache, config and the client are S4b’s, with the two exit tests.context:canonical_contextand the AAD (spec §6.2), and the spec §6.1 purpose grammar.encodeForIndexKeydropsrow_id, as spec §7.2 requires. Lengths are summed aslong; a total past any Java array is anOutOfMemoryError, as in the codec (§6.1).kdf: HKDF-SHA-512 overMac(§5.2), with the PRK and every expand block erased;record_key(spec §5.3) andindex_key(spec §7.2).aead:0xFF01in place (§5.1). A tag failure is returned as an outcome, not thrown, so that the client maps it toTAG_INVALIDonly after the commitment has verified.commitment: spec §4.6 over an injected KDF.docs/09§1 forbidscommitment→kdf, and the maintainer chose injection over amendingdocs/09(docs/07§7, 2026-09-25);blindindexdoes the same at S5.- Vectors:
kdf/,context/andcommitment/green, with per-file counts pinned fromMANIFEST.files;envelope/green in both directions composed from the primitives (EnvelopeCryptoVectorsTest). Every value passed on the first run: the mismatch list is empty. - Still deferred, to S4b: the
errors/outcomes S3 deferred (all 12 ofcrypto.json, 12 ofpolicy.json, 1 offormat.json). The primitives can now produceTAG_INVALIDandCOMMITMENT_INVALID, but which code a vector expects depends on the read mode, the allow-list and the key lookup, which are the client’s;CodecVectorsTest’s pinned counts are unchanged until S4b runs them through it. - Bite checks: twelve mutations each turn their tests red, among them an unsubstituted empty HKDF salt, a dropped length prefix, an absent
tenant_idencoded as empty,row_idkept in the index-keyinfo(caught byCanonicalContextTestonly while the pinnedrow-id-droppedvector carried norow_id; since suite 0.9.0KdfVectorsTestcatches it too, #191), decryption throughupdate(), whichopen’s allocation test catches at about 4× a 16 MiB operand, the commitment length check removed, and the AEAD’s range check removed. Re-run at S4b with a working launcher and a green baseline (docs/07§7, 2026-09-25, S4b entry).
- S4b built 2026-09-25: the providers, the
DekCache, config and the client.keyprovider: the SPI (KeyProvider,KeyRequest,EnvelopeHeader,KeyMaterial,Wrapper,WrappedKeyStore). api:Fieldsealwith its builder,FieldContext,CachePolicy, andKeyProviderswith the static, derived and envelope providers (§3, §4).cache:DekCache, with max-age, max-uses as along(DEK role only since #212), capacity LRU, erasure on eviction and single-flight loads.- Exit tests:
KeyMaterialOwnershipTest(provider arrays byte-identical after every operation and failure path; every derivedrecord_keyzeroed) andApiBoundaryOrderTest(each pair’s precedence observed, and whether the provider was reached).SeamWiringTestis the wiring test’s provider half (§6.2).PublicSurfaceTestpins the client’s public methods, so a nonce or seed parameter cannot appear unnoticed (§7). - Vectors through the client (
ClientVectorsTest): everyerrors/vector but the twoblind_indexones (S5), that is 41, 12 and 14 offormat,cryptoandpolicy, andenvelope/decrypted.CodecVectorsTestkeeps its restatement until S6. - Bite checks:
core/java/scripts/bite_checks.pyholds every S4a, S4b and review mutation. It runs each target test green first as a control, counts a mutation as biting only when Gradle reports failing tests, and restores every file. On the final S4b head, 37 mutations bite and the one below changes nothing, as stated. Later changes add their own entries, and each PR states the count on its head; the script’s output is the live count, oneRED (bites)line per mutation and test task, so a mutation that names two tasks prints two. - Not testable yet: that
decrypttakes the context’ssuite_idfrom the header and not fromwriteSuite(docs/09§3.2 step 4). With0xFF01the only suite this core can be configured with, the two are always equal; the mutation was run and changes no outcome. It becomes testable when a second suite is built.
S5 — Blind indexes and normalizers.
A Java emitter for
tools/ucd-gen, so that CI’s--checkcovers the Java tables. Decide it jointly with WS-J; one shared, hashed resource is the alternative.nfc-casefold-v1on Unicode 17.0.0 (docs/09§7.1), refusing unassigned code points and lone surrogates, withfirstUnassignedoffsets in code points andUNICODE_VERSION.identityanddigits-only-v1.IndexDeclarationvalidation, the §7.4 band, the §7.6 cardinality gate andon_unindexable.Exit:
blind-index/green, includingargon2id.jsonat every cost point it pins and the fourrefusevectors; the lone-surrogate entry passes.Built 2026-09-27.
docs/07§7 has the entry.tools/ucd-genhas a Java target:core/java/fieldseal-core/src/main/resources/dev/fieldseal/core/internal/blindindex/ucd-17.0.0.txt, the same parse as the other three targets in a line-oriented text form, so theunicode-tablesjob’s--checkcovers it. The loader checks the version line and the table counts the generator writes.internal/blindindex:UnicodeTables(the loader),Nfc(this core’s own transcription of UAX #15 decomposition, ordering and composition, with Hangul by arithmetic, and full case folding),Normalizers(the three normalizers,firstUnassigned, the strict decode and the reserved preimage) andIdf(HMAC-SHA-512, Argon2id, truncation).- api:
IndexDeclaration(with its builder and the nestedIdf,Normalizer,OnUnindexable,Argon2ParamsandReviewedOverride),ValidatedIndex,Unassigned,FieldContext.forIndex, and onFieldseal:blindIndex(text and bytes),unindexableMarker,indexes(), the builder’sindexes(...), and the staticvalidateIndexDeclaration,indexRegistryKey,firstUnassignedandUNICODE_VERSION.warmnow warms, for each context, the index key of every index declared on its column, and sends the provider each distinct request once (#208 review: N contexts on one column had been N identical unwraps). - Vectors:
blind-index/in full (BlindIndexVectorsTest): 44 vectors, every shape, and each primitive vector a second time as#pipelinethrough the public client, over text and bytes, with arow_idon the caller’s context; 68 results. Both cost pointsargon2id.jsonpins (t = 3 and t = 4) pass, as do the fourrefusevectors.ClientVectorsTestruns the twoerrors/policyblind_indexvectors it deferred. Every value passed on the first run: the mismatch list is empty. - The Unicode tables against an oracle:
UnicodeAgreementTestcompares the assigned set, NFD, NFC, full folding and the whole ofnfc-casefold-v1with ICU4J 78.3 (Unicode 17.0.0, test-only): the assigned set over all 1,114,112 code points, the rest over every assigned one, then over 200,000 seeded random strings drawn from combining marks, Hangul and decomposable letters. It fails, rather than skips, if ICU4J’s Unicode version is not the pin. No disagreement. - The lone-surrogate entry (§6.5):
"a\uD800b"and"a\uDC00b"are both refused, with messages naming U+D800 and U+DC00, throughNormalizersand throughblindIndex(NormalizersTest,BlindIndexClientTest). S6 records it in the report. - The strict-UTF-8 grep (§4) is a test,
StrictUtf8GrepTest: it scans every main source file for a lossy decode (new String(…, UTF_8), a charset-namednew String,UTF_8.decode), so it runs in thejava-corejob. - Bite checks: 26 S5 entries in
bite_checks.py, among them truncation unmasked and LSB-first, the second NFC dropped, unassigned code points or lone surrogates accepted, malformed UTF-8 replaced, offsets in UTF-16 units, canonical ordering skipped, composition exclusions ignored, a Hangul composition off by one, the Argon2id cost taken from the minima rather than the declaration, each band edge, the gate at 2⁹,bucketwithout its override, an index-key request carrying the row, the index role not warmed, an unlogged override, and a lossy decode added to a value path. The count on the PR’s head is in the PR. - The
declarationvectors (suite0.10.0-provisional, #210 and #211; after S5, before S6): five incontext/for spec §6.1’sindex-idgrammar, six inblind-index/hmac-sha512.jsonfor spec §7.6’s gate.DeclarationVectorsbuilds each declaration field by field throughIndexDeclaration.builder, with nothing defaulted, and hands it to a client’s construction.refusedpasses only asConfigurationError, which is whereIndexValidationraises every refusal; any other exception fails.acceptedpasses only if the client builds and reports the index back throughindexes(). All eleven passed on the first run. Eight bite checks, each breaking one rule, redden all eleven vectors, as read from the JUnit XML under each mutation: the grammar reduced to non-empty (index-id-uppercase,-non-ascii,-33-bytes), an empty id accepted (index-id-empty), the grammar stopping at 31 (index-id-32-bytes-accepted), a low population not gated (gate-low-cardinality-refused), the gate at<=(gate-at-threshold-accepted),skewednot gated (gate-skewed-refused, a second task on the S5 entry), an override without an approver (gate-override-incomplete-refused), and a recorded override ignored (both-override-accepted). The harness also refuses a malformed declaration rather than default it:argon2idwithouttime_costandmemory_kib,hmac-sha512with parameters, a pinned §7.3 field that is not §7.3’s, or a fractional number (DeclarationVectorsTest, on synthetic vectors, three more bite entries; #222 review). - Not testable: that the core erases the index key, the normalized value and the salt. A wiped buffer and a live one derive the same bytes, and none of the three leaves the core; the steps are declared in
key-material-ownershipat S6.
S6 — Testing artifact, full harness, report.
encrypt_with_materialsin the testing artifact, armed only byFIELDSEAL_TEST_MODE=1.- The full harness and the report.
javaadded tocross-core-result-ids(§1).- Exit:
summary.fail == 0, the report validates, andcompare_result_ids.py --cores python,typescript,javareports identical ids. - Built 2026-09-27.
docs/07§7 has the entry. The exit is met, at suite0.10.0-provisional: 193 results, 193 pass, the three out-of-band entries pass, the report validates, andcompare_result_ids.py --cores python,typescript,javafinds 193 synchronous and 3 out-of-band ids identical across the three cores, with every manifest file reached by each. It was run locally against the Python and TypeScript reports fromdev’s CI run atc1804d5, after rebasing onto #222, and in CI on the PR.- The testing artifact (§3):
FieldsealTesting.encryptWithMaterialsover the core’sinternal.testingseam.FieldsealTestingTest(armed) shows the materials land in the envelope, that productionencrypton the same client still draws fresh ones, that the API-boundary order isencrypt’s, and that materials of the wrong length are refused.UnarmedTestruns in its own Gradle task,unarmedTest, with the variable removed from the process environment, andcheckdepends on it: that is docs/08 §6’s negative test.freshJvmTestis the armed counterpart in a process of its own that has never initializedFieldseal, where a null client must still get the api’sInvalidArgumentError(#219 review).MaterialsSeamTestpins the byte-exact1and the one-shot install. - The report (
./gradlew -q vectors,ConformanceReport) is not a second harness. It runs the build’s own vector tests again through the JUnit launcher, in one process, and reads each dynamic test’s outcome under its name, which is the result id:KdfVectorsTest,ContextVectorsTest,CommitmentVectorsTest,EnvelopeVectorsTest(new:envelope/encrypt throughencrypt_with_materials, and#decryptthrough the publicdecrypt),ClientVectorsTestandBlindIndexVectorsTest. The ids required are computed fromMANIFEST.files, not from the run, so a vector no test ran is a failed result that says so. A vector-named test the suite does not require, a duplicated one, a failed container or a failed harness check is a problem that fails the run. The out-of-band entries are named tests offieldseal-core, run in the same process:SeamWiringTestandBufferLimitsWiringTestfor the length-bound pair (basis: "seam", §6.5’s text), andNormalizersTestandBlindIndexClientTestfor the lone-surrogate entry (basis: "direct").pinned_decisionscarries the six mandatory keys andplatform-byte-buffer-max;harness_notescarries §6.1’s asymmetry. The report is validated against docs/14 §4 and §9’s gate D before it is printed (ConformanceReportTestbreaks each rule once), and the task exits 1 on a failed result, a problem or an invalid report. WithCommitment’s label changed by one character, the run reports 26 failures, claims no L0, and exits 1. stored.hexis a harness note, not a skipped result. docs/08 §4.4 has a binary-only harness “report it skipped”, but it is an assertion inside a result, not a vector id, and askippedresult is a finding forcross-core-result-ids.- Deferred items closed:
CodecVectorsTestloses its restatement of the recognition half oferrors/and keepsenvelope/parsing (the client has run everyerrors/vector since S4b, and the report is built from that run);ClientVectorsTest’senvelope/decrypt moved toEnvelopeVectorsTestas#decrypt; the lone-surrogate entry is in the report;key-material-ownershipdeclares the steps S5 could not observe. - Bite checks: 19 S6 entries in
bite_checks.py: the gate opened and loosened, the install repeatable, the seam’s type check and its export removed, the caller’s materials ignored or leaked into productionencrypt, each material’s length check, and eight report and validation guards. 18 bite; the api’s second gate changes nothing, as stated above. The count on the PR’s head is in the PR. - Isolation exposures (in
docs/28§1 as X-6 to X-8). Three in this stage, all through GitHub rather than a checkout: the #212 thread, read for its Java half, cites the Python and TypeScript cache and provider code (both key the cache by role, count a use on every cacheget, and peek without counting on the read path; the TypeScript config refuses a client-level cache); and the three-core comparison printed the other reports’ counts (Python 193 results and 3 out-of-band entries, TypeScript 386 and 4, with its async flag; 182, 364 and 4 before suite0.10.0-provisional). The Python and TypeScript reports were passed tocompare_result_ids.pyonly, never opened. Andgh pr diffof #218, read for itsdocs/09§8.3 wording, printed the start of that PR’s Pythoncache.pychange before the output was narrowed todocs/09: acount_usekeyword on the cache’sget, its docstring, and the line that increments the counter. No Java code was written from any of the three.
- The testing artifact (§3):
S7 — Cross-implementation CI.
- A producer leg that emits
cross/output through the real production path (CSPRNG, no injection). - A consumer leg that decrypts every producer’s file, its own included.
- Both joined per §1.
- Exit: the matrix is green in both directions, self-pair included.
- Built 2026-09-28.
docs/07§7 has the entry. The exit is met: in CI on #232, the Java consumer decrypts and re-derives every case of all five producers’ documents, its own included, and the Python and TypeScript consumers decrypt and re-derive the Java document. The job is five producers by three consumers; its three-core part is the 3×3 ofdocs/26§3’s P2-M1.- The producer (
CrossProduce,./gradlew -q crossProduce --args="--out <file>", in the testing module’s test sources): the 16casesofcross/corpus.jsonthrough productionencrypt, and the 11index_casesthrough a client built with each case’s declaration, into across/v2document whose ids arecross/java/<case>andcross/java/index/<case>. The static provider holds eachkey_ref’s keys fromkeys/test-keys.json. The Gradle task removesFIELDSEAL_TEST_MODEandFIELDSEAL_ARM_PROVISIONAL_SUITESfrom the process, and the producer refuses to run ifFIELDSEAL_TEST_MODE=1reaches it anyway. Before it writes, it checks that no two of its envelopes share amsg_seedor a nonce. - The consumer (
CrossConsume,./gradlew -q crossConsume --args="<cross-*.json …> --verdict <file>"): a strict client decrypts every envelope case and the plaintext is compared byte for byte; every index case is re-derived through a client built with its declaration and compared byte for byte. The verdict has one pair per producer and half (envelope,index). Beyond a wrong byte, a case or document fails on: aschemaother thancross/v1orcross/v2; a v1 document carryingindex_cases, or a v2 one without any;purposeother than"index:" + index_id; not exactly one ofvalue_text,value_bytesandvalue_marker,value_bytesunder a normalizer other thanidentity, or avalue_markerthat is nottrue(docs/08 §4.7); an unknown field in a context or a declaration, since either can change the bytes; and an envelope repeating another’smsg_seedor nonce in the same document. A0xFF02document is skipped and counted in the verdict (docs/14§3); no producer writes one. An unknown field elsewhere in a case is read past and named in the verdict’s notes; none appeared in CI. - The shared inputs.
cross/corpus.jsonandkeys/test-keys.jsonare read only once their length and SHA-256 matchMANIFEST.support, and only if theirvector_suite_versionis the manifest’s.VectorHarness’s walk coversMANIFEST.filesonly, so until S7 nothing in this core checked them. - Tests:
CrossTest, 26 tests: the producer’s document against the corpus, and againstdocs/08§4.7’s case set for a core producer (with the check itself shown to see each rule’s shortfall), the self-pair, a second production sharing no envelope with the first, the armed-process refusal, a repeated seed refused, a support file failing its hash, and one test per consumer guard, each against a document broken in exactly one way. - Bite checks: 25 S7 entries in
bite_checks.py, one per guard above and per rule of the case-set check, and one pinning the byte the tag-flip test flips (#232 review); 24 bite. The twenty-fifth removesproduce’s own call to the entropy check, and changes nothing, as stated: productionencryptcannot repeat, so the check is covered by its direct test and by every consumer’s.main’s call to the armed-process refusal has no test; the Gradle task removes the variable before it. - Isolation. Both legs were written from
docs/08§4.7,docs/14§3 and the two support files. In CI the Java consumer read the other producers’ documents, and its log printed their pair counts and the Django and Prismaproducer.limitations(the adapters’ own data). The Python and TypeScript consumers’ verdicts were not opened; their logs were read only for the lines namingcross-java.json.
- The producer (
S8 — This document as built, and the divergence report.
- The S2 results, the probe figure and the measured decrypt multiplier recorded here.
- The divergence and ambiguity report (
docs/17§5 item 4), even if empty. Its isolation statement names this document, and its single-implementer statement takes the form ofdocs/18§1 (docs/26§2.2). - The
AGENTS.mdlayout and build section, and thecore/java/README.mdhonest limitations (PRD §8 condition 4). - Exit:
docs/07§4’s definition of done, including a no-overclaim read by someone other than the author, anddocs/26§4’s additions for a core. - Built 2026-09-29.
docs/07§7 has the entry.- The divergence report is
docs/28-java-core-report.md: the isolation statement, which names this document,docs/18and the other two binding documents as read inputs and lists fourteen disclosures (two of them first recorded there: the #227 entry and closing comments, and S8’s own research summary); the single-implementer statement indocs/18§1’s form; the result, with an empty mismatch list; twenty divergences and ambiguities (J-01 to J-20), six of them still open and one more with an open follow-up (#225); the dependency deviations; and the conformance claim. - This document as built. The S2 results, the probe figure (§6.1) and the decrypt multiplier (§6.3) were recorded at S2, and gate A.4 of §9 reads them. At S8: the status line, §1’s location row, §4’s pointer to the
skeweddisclosure, and §5.2’s G14 line, which had promised a record it now gives. No [VERIFY] remains. - G14 (§5.2):
LargeContextTest, three tests, with one bite entry in the S4a section ofbite_checks.py. It is S8’s only code change, and it is a test. - Elsewhere:
core/java/README.md’s status and its limitations, which gain the threat model spec §2.2 requires in substance (N2 to N4, and the degraded T2 and T3 cases) and spec §7.5’s pagination rule (PRD §8 condition 4); andAGENTS.md’s layout, build section and independence rule. - The exit’s no-overclaim read is a reviewer’s, on the PR; P2-M1 (
docs/26§3) is recorded as met once it is answered.
- The divergence report is
9. Verification gates
A. Buffer bound (this core’s share of docs/09 §4’s obligation).
- The §6.2 wiring test proves
LENGTH_EXCEEDEDon synthetic operands of length 2³¹ and of implied length ≥ 2³¹, with zero provider calls and zero operand reads, and fails when the guard is moved. - The
BufferLimitsvalue table and the long-typed fuzz pass are green. - Both length-bound entries record
pass,basis: "seam", with §6.5’s method text. - §6.1 states the type argument, the probe figure (or that the ceiling was not observed), and §6.3 the decrypt multiplier.
B. L0. A report with fail: 0, and every out-of-band entry pass with its basis. Every errors/ case yields its typed §9 error, and every refuse vector yields INVALID_ARGUMENT. The fuzz pass is green.
C. Cross-language. The S7 matrix is green, self-pair included, compared byte for byte.
D. Report integrity. The report validates against docs/14 §4:
held_outmirrors the manifest;provisional_suites: trueandasync_companions: false;- all six
pinned_decisionskeys are present; - a vector-suite version bump fails loudly.
10. Risks and non-goals
| Risk | Mitigation |
|---|---|
| The JDK 21 floor still excludes part of the Hibernate audience | A recorded decision (§0.3). A JDK 17 floor would change nothing in the crypto either, since the HKDF is written over Mac |
The empty-salt trap (§5.2) mismatches kdf/ | Caught at S2 by the KAT; the fix is the RFC 5869 substitution, which the spec already states |
| BouncyCastle’s Argon2 differs from the vectors, or copies and keeps its salt | Caught by the S2 KAT; the salt behaviour is documented, not claimed |
| SunJCE’s GCM buffering makes large decrypts memory-heavy | Measured at S2 (§6.3): it buffers through update() only, so the core decrypts in one doFinal. Spec §3.5 makes an out-of-memory failure conformant |
tools/ucd-gen has no Java emitter | Built at S5: a Java target, checked by the same --check. WS-J adds its own target rather than reading this one |
| One implementer writes the JVM and .NET cores | docs/17’s rule: say so in both reports. The claim is weakened, not invalidated. The cores are sequenced, never interleaved (docs/26 §2.2) |
| This document leaks reference-core facts to the implementer | Facts are cited to documents, not code, and the isolation statement names this document (header) |
| The memory probe destabilises CI | A separate job and tagged tests; no gate depends on it |
Non-goals:
0xFF02(registered and unbuilt; theunimplemented-registered-suitepin);- async companions;
- BouncyCastle beyond Argon2id;
- PKCS#11;
- a streaming API;
- 32-bit claims;
- the Hibernate adapter, which is WS-N, built right after this core (
docs/26§5 item 3); - publication outside PRD §8’s experimental-release conditions.
References: spec §3.1, §3.5, §4.6, §5.3, §5.5, §6.1, §7.3, §8, §9, §11.1–§11.2; docs/08 §4.4, §4.6, §5, §6; docs/09 §1–§4, §7.1, §8.3, §9–§12; docs/14 §2–§4; docs/17 §1–§6; docs/18 §1, §4; docs/26 §2.2, §4, §5; PRD §8 (phases, experimental releases); G17, G18, G22, G26; JEP 510; openjdk/jdk ArraysSupport.java, HKDFParameterSpec.java, KDF.java (fetched 2026-09-19); RFC 5869; RFC 9106.