diff --git a/.gitignore b/.gitignore
index 9d8d1b78d..ada768be0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ pom.xml.versionsBackup
.project
.settings/
*.classpath
+bin/
diff --git a/.gitmodules b/.gitmodules
index dc531300e..431dac830 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,7 @@
[submodule "pipeline.javascriptbuilder/src/main/resources/fiftyone/pipeline/javascriptbuilder/templates"]
path = pipeline.javascriptbuilder/src/main/resources/fiftyone/pipeline/javascriptbuilder/templates
url = ../javascript-templates.git
+[submodule "owid-java"]
+ path = owid-java
+ url = ../owid-java.git
+ branch = main
diff --git a/owid-java b/owid-java
new file mode 160000
index 000000000..cb03973c7
--- /dev/null
+++ b/owid-java
@@ -0,0 +1 @@
+Subproject commit cb03973c7005cce1a028299e0ee7d630c0d2e783
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml b/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml
new file mode 100644
index 000000000..466b3d5ec
--- /dev/null
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml
@@ -0,0 +1,49 @@
+
+
+
+
+ * The 51Degrees Cloud service issues real 51Dids. To keep this example + * self-contained and offline, it builds a sample 51Did in process - generate + * an ECDSA P-256 key pair, sign a canonical 37-byte payload - then parses it + * back with {@link FodId} and prints the three payload fields. + *
+ * It also demonstrates the headline use case: a 51Did is re-issued fresh on
+ * every call (the envelope, hence the base64, changes), but the value (the
+ * Hash) is stable. Compare values, never envelopes.
+ */
+public class Main {
+
+ private static final String DOMAIN = "51degrees.com";
+
+ public static class Example {
+
+ public void run() throws Exception {
+ // Generate a key pair and a signer entirely in process.
+ Crypto crypto = Crypto.generate();
+ Creator creator = Creator.create(DOMAIN, crypto);
+
+ byte[] payload = samplePayload();
+
+ // Issue a 51Did over the payload and parse it back.
+ FodId fodId = FodId.fromBase64(issue(creator, payload));
+
+ System.out.println("51Did parsed from base64:");
+ System.out.println(" Domain : " + fodId.getDomain());
+ System.out.println(" Type : " + fodId.getType());
+ System.out.println(" Flags : 0x"
+ + Integer.toHexString(fodId.getFlags()));
+ System.out.println(" LicenseId : " + fodId.getLicenseId());
+ System.out.println(" Hash : " + toHex(fodId.getHash()));
+ System.out.println(" Verifies : "
+ + fodId.verify(crypto.publicKeyPem()));
+
+ // Issue the SAME payload again: a separate envelope, same value.
+ FodId reissued = FodId.fromBase64(issue(creator, payload));
+ boolean sameEnvelope =
+ fodId.asBase64().equals(reissued.asBase64());
+ boolean sameValue =
+ Arrays.equals(fodId.getHash(), reissued.getHash());
+
+ System.out.println();
+ System.out.println("Same payload, re-issued:");
+ System.out.println(" Same envelope (base64) : " + sameEnvelope);
+ System.out.println(" Same value (Hash) : " + sameValue);
+
+ // The reader's whole purpose: the value is the stable, comparable
+ // part while the envelope is not.
+ if (sameEnvelope || !sameValue) {
+ throw new IllegalStateException(
+ "Expected a different envelope but the same value "
+ + "across reissues.");
+ }
+ }
+
+ /** Issues (signs) a 51Did over the payload and returns it as base64. */
+ private String issue(Creator creator, byte[] payload)
+ throws Exception {
+ Owid owid = new Owid(DOMAIN, Instant.now(), payload);
+ creator.sign(owid);
+ return owid.asBase64();
+ }
+
+ /**
+ * A canonical 37-byte Probabilistic payload: flags 0x00, License Id
+ * 0x12345678 (little-endian) and a 32-byte value 0x20..0x3F.
+ */
+ private byte[] samplePayload() {
+ byte[] payload = new byte[FodId.PAYLOAD_LENGTH];
+ payload[FodId.FLAGS_OFFSET] = 0x00;
+ payload[FodId.LICENSE_ID_OFFSET] = 0x78;
+ payload[FodId.LICENSE_ID_OFFSET + 1] = 0x56;
+ payload[FodId.LICENSE_ID_OFFSET + 2] = 0x34;
+ payload[FodId.LICENSE_ID_OFFSET + 3] = 0x12;
+ for (int i = 0; i < FodId.HASH_LENGTH; i++) {
+ payload[FodId.HASH_OFFSET + i] = (byte) (0x20 + i);
+ }
+ return payload;
+ }
+
+ private static String toHex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ builder.append(String.format("%02x", b & 0xFF));
+ }
+ return builder.toString();
+ }
+ }
+
+ public static void main(String[] args) throws Exception {
+ new Example().run();
+ }
+}
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java
new file mode 100644
index 000000000..8a7d2c226
--- /dev/null
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java
@@ -0,0 +1,39 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package pipeline.developerexamples.fodid;
+
+import org.junit.jupiter.api.Test;
+
+public class ExampleTests {
+
+ /**
+ * The 51Did example is fully offline, so unlike the cloud examples it must
+ * complete without throwing. {@code run()} also self-checks the
+ * value-stable / envelope-changes invariant and throws if it does not
+ * hold.
+ */
+ @Test
+ public void FodId_Example_Test() throws Exception {
+ new Main.Example().run();
+ }
+}
diff --git a/pipeline.developer-examples/pom.xml b/pipeline.developer-examples/pom.xml
index 7a85db684..a1c51b55d 100644
--- a/pipeline.developer-examples/pom.xml
+++ b/pipeline.developer-examples/pom.xml
@@ -43,6 +43,7 @@
+ * A 51Did is described at three levels, and the wording here is deliberate.
+ * The 51Did is the identifier as a whole. The envelope is the
+ * signed {@link Owid} that carries it (version, domain, date, payload,
+ * signature), re-issued fresh on every call. The value is the stable,
+ * comparable part of the payload after the Flags and License Id, exposed as
+ * {@link #getHash()}. Two 51Dids for the same inputs share the same value even
+ * though their envelopes differ on every issue. Compare values, never
+ * envelopes.
+ *
+ * Payload layout. The header (offsets 0-4) is shared by every identifier type;
+ * bits 6-7 of Flags select the {@link IdType} and the length of the value that
+ * follows:
+ *
+ * Java's {@link Owid} is {@code final}, so this type composes an OWID
+ * rather than inheriting from it: it holds the wrapped envelope and delegates
+ * OWID-level concerns (domain, date, payload, signature, base64 round-trip,
+ * verification) to it, adding the strongly typed 51Did accessors on top.
+ *
+ * Constructing a {@code FodId} does not verify the OWID signature. Call
+ * {@link #verify(String)} explicitly when cryptographic verification is needed.
+ */
+public final class FodId {
+
+ /** Byte offset of the Flags field within the payload. */
+ public static final int FLAGS_OFFSET = 0;
+
+ /** Byte offset of the License Id field within the payload. */
+ public static final int LICENSE_ID_OFFSET = 1;
+
+ /** Byte length of the License Id field. */
+ public static final int LICENSE_ID_LENGTH = 4;
+
+ /** Byte offset of the value (Hash) field within the payload. */
+ public static final int HASH_OFFSET = 5;
+
+ /** Byte length of the SHA-256 value. */
+ public static final int HASH_LENGTH = 32;
+
+ /**
+ * Byte length of the payload header (Flags + License Id) common to every
+ * identifier type.
+ */
+ public static final int HEADER_LENGTH = HASH_OFFSET;
+
+ /** Byte length of the GUID value carried by Random identifiers. */
+ public static final int GUID_LENGTH = 16;
+
+ /**
+ * Minimum byte length of a Random 51Did payload
+ * (Flags + License Id + GUID).
+ */
+ public static final int RANDOM_PAYLOAD_LENGTH = HEADER_LENGTH + GUID_LENGTH;
+
+ /**
+ * Minimum byte length of a Probabilistic or HashedEmail 51Did payload
+ * (Flags + License Id + Hash). Random payloads are shorter - see
+ * {@link #RANDOM_PAYLOAD_LENGTH}.
+ */
+ public static final int PAYLOAD_LENGTH = HASH_OFFSET + HASH_LENGTH;
+
+ private final Owid owid;
+ private final int flags;
+ private final long licenseId;
+ private final byte[] hash;
+
+ private FodId(Owid owid, String paramName) {
+ this.owid = owid;
+ byte[] payload = owid.getPayload();
+ if (payload == null || payload.length < HEADER_LENGTH) {
+ throw new IllegalArgumentException(
+ "51Did payload must be at least " + HEADER_LENGTH
+ + " bytes; got " + (payload == null ? 0 : payload.length)
+ + " (" + paramName + ").");
+ }
+ this.flags = payload[FLAGS_OFFSET] & 0xFF;
+ // Little-endian uint32, kept unsigned in a long so the high bit does
+ // not sign-extend into a negative value.
+ this.licenseId =
+ (payload[LICENSE_ID_OFFSET] & 0xFFL)
+ | ((payload[LICENSE_ID_OFFSET + 1] & 0xFFL) << 8)
+ | ((payload[LICENSE_ID_OFFSET + 2] & 0xFFL) << 16)
+ | ((payload[LICENSE_ID_OFFSET + 3] & 0xFFL) << 24);
+ int valueLength;
+ switch (IdType.fromFlags(flags)) {
+ case RANDOM:
+ valueLength = GUID_LENGTH;
+ break;
+ case RESERVED:
+ valueLength = payload.length - HEADER_LENGTH;
+ break;
+ default:
+ valueLength = HASH_LENGTH;
+ break;
+ }
+ if (payload.length < HEADER_LENGTH + valueLength) {
+ throw new IllegalArgumentException(
+ "51Did payload for the " + IdType.fromFlags(flags)
+ + " type must be at least " + (HEADER_LENGTH + valueLength)
+ + " bytes; got " + payload.length + " (" + paramName + ").");
+ }
+ // Defensive copy: mutating the returned hash must not change the
+ // underlying OWID payload bytes.
+ this.hash = Arrays.copyOfRange(
+ payload, HASH_OFFSET, HASH_OFFSET + valueLength);
+ }
+
+ /**
+ * Parses a 51Did from its base64-encoded OWID string.
+ *
+ * @param base64 base64 of the full OWID envelope
+ * @return the parsed 51Did
+ * @throws NullPointerException if {@code base64} is null
+ * @throws OwidException if the string is not valid base64 or not a
+ * valid OWID
+ * @throws IllegalArgumentException if the payload is shorter than the
+ * minimum for its identifier type
+ */
+ public static FodId fromBase64(String base64) throws OwidException {
+ Objects.requireNonNull(base64, "base64");
+ return new FodId(Owid.fromBase64(base64), "base64");
+ }
+
+ /**
+ * Parses a 51Did from the raw bytes of an OWID envelope.
+ *
+ * @param buffer the OWID envelope bytes
+ * @return the parsed 51Did
+ * @throws NullPointerException if {@code buffer} is null
+ * @throws OwidException if the bytes are not a valid OWID
+ * @throws IllegalArgumentException if the payload is shorter than the
+ * minimum for its identifier type
+ */
+ public static FodId fromByteArray(byte[] buffer) throws OwidException {
+ Objects.requireNonNull(buffer, "buffer");
+ return new FodId(Owid.fromByteArray(buffer), "buffer");
+ }
+
+ /**
+ * Promotes an already-parsed OWID into a 51Did by unpacking its payload.
+ * The OWID is copied (round-tripped through its byte form), not
+ * aliased, so that a {@code FodId} can never desync from its envelope if
+ * the caller later mutates the OWID it passed in. The supplied OWID must
+ * therefore be signed (serializable).
+ *
+ * @param owid the already-parsed OWID envelope
+ * @return a 51Did wrapping an independent copy of {@code owid}
+ * @throws NullPointerException if {@code owid} is null
+ * @throws OwidException if {@code owid} cannot be serialized (e.g.
+ * it has not been signed)
+ * @throws IllegalArgumentException if the payload is shorter than the
+ * minimum for its identifier type
+ */
+ public static FodId fromOwid(Owid owid) throws OwidException {
+ Objects.requireNonNull(owid, "owid");
+ return new FodId(Owid.fromByteArray(owid.asByteArray()), "owid");
+ }
+
+ /**
+ * @return the 1-byte usage flags bit-mask from the payload (0-255)
+ */
+ public int getFlags() {
+ return flags;
+ }
+
+ /**
+ * @return the identifier type carried in bits 6-7 of {@link #getFlags()}
+ */
+ public IdType getType() {
+ return IdType.fromFlags(flags);
+ }
+
+ /**
+ * @return the 4-byte little-endian License Id (0 to 4294967295)
+ */
+ public long getLicenseId() {
+ return licenseId;
+ }
+
+ /**
+ * Returns the value bytes from the payload (a 32-byte SHA-256 for
+ * Probabilistic and HashedEmail identifiers, or 16 GUID bytes for Random).
+ * This is the stable, comparable part of the envelope - use it as the
+ * cache / dedup key.
+ *
+ * @return a defensive copy of the value bytes
+ */
+ public byte[] getHash() {
+ return hash.clone();
+ }
+
+ /** @return the OWID version. */
+ public Version getVersion() {
+ return owid.getVersion();
+ }
+
+ /** @return the domain of the OWID creator. */
+ public String getDomain() {
+ return owid.getDomain();
+ }
+
+ /** @return the OWID creation date. */
+ public Instant getDate() {
+ return owid.getDate();
+ }
+
+ /** @return a copy of the OWID payload bytes. */
+ public byte[] getPayload() {
+ return owid.getPayload();
+ }
+
+ /** @return a copy of the 64-byte OWID signature. */
+ public byte[] getSignature() {
+ return owid.getSignature();
+ }
+
+ /**
+ * @return the OWID as a base64 string
+ * @throws OwidException if the OWID has not been signed or cannot be encoded
+ */
+ public String asBase64() throws OwidException {
+ return owid.asBase64();
+ }
+
+ /**
+ * @return the OWID as a byte array including the signature
+ * @throws OwidException if the OWID has not been signed or cannot be encoded
+ */
+ public byte[] asByteArray() throws OwidException {
+ return owid.asByteArray();
+ }
+
+ /**
+ * Verifies the OWID signature against the supplied public key. This is an
+ * explicit, separate step - construction never verifies.
+ *
+ * @param publicPem the creator's public key in SPKI PEM form
+ * @return true if the signature verifies, false otherwise
+ * @throws OwidException if the PEM is not a valid public key or a field
+ * cannot be encoded
+ */
+ public boolean verify(String publicPem) throws OwidException {
+ return owid.verifyWithPublicKey(publicPem, Collections.
+ * Existing identifiers were issued with these bits zeroed, so they decode as
+ * {@link #PROBABILISTIC}. The type selects the length of the value that
+ * follows the header in the payload.
+ */
+public enum IdType {
+
+ /**
+ * Derived from the device fingerprint and IP address. The payload carries
+ * a 32-byte SHA-256 value.
+ */
+ PROBABILISTIC,
+
+ /**
+ * A server-generated random GUID. The payload carries 16 GUID bytes.
+ */
+ RANDOM,
+
+ /**
+ * Derived from the caller-supplied email and salt. The payload carries a
+ * 32-byte SHA-256 value.
+ */
+ HASHED_EMAIL,
+
+ /**
+ * Not yet assigned. Parsed best-effort: the header fields are unpacked and
+ * the remaining payload bytes are exposed as-is.
+ */
+ RESERVED;
+
+ /**
+ * Decodes the identifier type from the top two bits (6-7) of a flags byte.
+ *
+ * @param flags the 1-byte flags value (0-255)
+ * @return the identifier type
+ */
+ public static IdType fromFlags(int flags) {
+ return values()[(flags >> 6) & 0b11];
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java
new file mode 100644
index 000000000..9d13d6cdf
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java
@@ -0,0 +1,32 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+/**
+ * Strongly typed reader for the 51Did (51Degrees Identifier) value.
+ *
+ * {@link fiftyone.pipeline.did.FodId} parses a 51Did from its base64 OWID
+ * form, exposes the three payload fields (Flags, License Id and the value
+ * Hash) and the identifier {@link fiftyone.pipeline.did.IdType}, and delegates
+ * OWID-level concerns to the wrapped envelope. Compare 51Dids by their value
+ * ({@code getHash()}), never by their envelopes.
+ */
+package fiftyone.pipeline.did;
diff --git a/pipeline.did/src/main/resources/META-INF/LICENSE-owid.txt b/pipeline.did/src/main/resources/META-INF/LICENSE-owid.txt
new file mode 100644
index 000000000..261eeb9e9
--- /dev/null
+++ b/pipeline.did/src/main/resources/META-INF/LICENSE-owid.txt
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/pipeline.did/src/main/resources/META-INF/NOTICE.txt b/pipeline.did/src/main/resources/META-INF/NOTICE.txt
new file mode 100644
index 000000000..3dd0f8e4a
--- /dev/null
+++ b/pipeline.did/src/main/resources/META-INF/NOTICE.txt
@@ -0,0 +1,19 @@
+FiftyOne.Did (fiftyone.pipeline.did)
+Copyright 2026 51 Degrees Mobile Experts Limited
+Licensed under the European Union Public Licence (EUPL) v.1.2.
+
+------------------------------------------------------------------------------
+This artifact bundles third-party code:
+
+OWID (Open Web Id) - com.swancommunity.owid.*
+ Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com)
+ Source: https://github.com/SWAN-community/owid-java
+ Licensed under the Apache License, Version 2.0.
+ The full licence text is provided alongside this notice in
+ META-INF/LICENSE-owid.txt.
+
+The OWID source files retain their original Apache-2.0 licence headers. This
+package compiles that source into its jar (rather than depending on a separate
+artifact); the Apache-2.0 licence text and this attribution travel with it as
+required by the licence.
+------------------------------------------------------------------------------
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
new file mode 100644
index 000000000..1bfc1c104
--- /dev/null
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
@@ -0,0 +1,124 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import com.swancommunity.owid.Creator;
+import com.swancommunity.owid.Crypto;
+import com.swancommunity.owid.Owid;
+import com.swancommunity.owid.OwidException;
+
+import java.time.Instant;
+
+/**
+ * Shared test helper for the 51Did tests. Generates a fresh ECDSA P-256 key
+ * pair per instance and signs real OWID envelopes with it, and builds the
+ * canonical payloads the tests assert against.
+ */
+final class FodIdTestFactory {
+
+ /** The domain stamped into every signed test OWID. */
+ static final String TEST_DOMAIN = "51degrees.com";
+
+ /**
+ * The canonical flags byte (0xA5): usage bits plus the HashedEmail type
+ * tag in bits 6-7, so the 37-byte payload minimum applies.
+ */
+ static final int CANONICAL_FLAGS = 0xA5;
+
+ /** The canonical little-endian License Id, 0x12345678. */
+ static final long CANONICAL_LICENSE_ID = 0x12345678L;
+
+ /** The canonical 32-byte hash value, bytes 0x20..0x3F. */
+ static final byte[] CANONICAL_HASH = canonicalHash();
+
+ private final Creator creator;
+
+ /** The PEM-encoded public key matching the signing key. */
+ final String publicPem;
+
+ FodIdTestFactory() throws OwidException {
+ Crypto crypto = Crypto.generate();
+ this.publicPem = crypto.publicKeyPem();
+ this.creator = Creator.create(TEST_DOMAIN, crypto);
+ }
+
+ private static byte[] canonicalHash() {
+ byte[] hash = new byte[FodId.HASH_LENGTH];
+ for (int i = 0; i < hash.length; i++) {
+ hash[i] = (byte) (0x20 + i);
+ }
+ return hash;
+ }
+
+ /**
+ * A canonical 37-byte 51Did payload: {@link #CANONICAL_FLAGS},
+ * {@link #CANONICAL_LICENSE_ID} (little-endian) and {@link #CANONICAL_HASH}.
+ */
+ static byte[] canonicalPayload() {
+ byte[] payload = new byte[FodId.PAYLOAD_LENGTH];
+ payload[FodId.FLAGS_OFFSET] = (byte) CANONICAL_FLAGS;
+ writeCanonicalLicenseId(payload);
+ System.arraycopy(
+ CANONICAL_HASH, 0, payload, FodId.HASH_OFFSET, FodId.HASH_LENGTH);
+ return payload;
+ }
+
+ /**
+ * A canonical 21-byte Random payload: the Random type tag in bits 6-7 plus
+ * usage bits 0b001, {@link #CANONICAL_LICENSE_ID}, and a stable 16-byte
+ * GUID block (0x40..0x4F).
+ */
+ static byte[] canonicalRandomPayload() {
+ byte[] payload = new byte[FodId.RANDOM_PAYLOAD_LENGTH];
+ payload[FodId.FLAGS_OFFSET] = (byte) ((1 << 6) | 0b001);
+ writeCanonicalLicenseId(payload);
+ for (int i = 0; i < FodId.GUID_LENGTH; i++) {
+ payload[FodId.HASH_OFFSET + i] = (byte) (0x40 + i);
+ }
+ return payload;
+ }
+
+ private static void writeCanonicalLicenseId(byte[] payload) {
+ // Little-endian: low byte first.
+ payload[FodId.LICENSE_ID_OFFSET] = 0x78;
+ payload[FodId.LICENSE_ID_OFFSET + 1] = 0x56;
+ payload[FodId.LICENSE_ID_OFFSET + 2] = 0x34;
+ payload[FodId.LICENSE_ID_OFFSET + 3] = 0x12;
+ }
+
+ /**
+ * Creates and signs a real OWID with the given payload. Note that
+ * {@code Creator.sign} stamps the date itself (current time, to the
+ * minute), so callers that need distinct dates set them after signing.
+ */
+ Owid signedOwid(byte[] payload) throws OwidException {
+ Owid owid = new Owid(TEST_DOMAIN, Instant.now(), payload);
+ creator.sign(owid);
+ return owid;
+ }
+
+ /** Signs the given payload and returns the OWID as base64. */
+ String signedOwidBase64(byte[] payload) throws OwidException {
+ return signedOwid(payload).asBase64();
+ }
+}
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
new file mode 100644
index 000000000..54a92b2cf
--- /dev/null
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
@@ -0,0 +1,412 @@
+/* *********************************************************************
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
+ *
+ * This Original Work is licensed under the European Union Public Licence
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
+ *
+ * If a copy of the EUPL was not distributed with this file, You can obtain
+ * one at https://opensource.org/licenses/EUPL-1.2.
+ *
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
+ * amended by the European Commission) shall be deemed incompatible for
+ * the purposes of the Work and the provisions of the compatibility
+ * clause in Article 5 of the EUPL shall not apply.
+ *
+ * If using the Work as, or as part of, a network application, by
+ * including the attribution notice(s) required under Article 5 of the EUPL
+ * in the end user terms of the application under an appropriate heading,
+ * such notice(s) shall fulfill the requirements of that article.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import com.swancommunity.owid.Crypto;
+import com.swancommunity.owid.Owid;
+import com.swancommunity.owid.OwidException;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+
+import static fiftyone.pipeline.did.FodIdTestFactory.CANONICAL_FLAGS;
+import static fiftyone.pipeline.did.FodIdTestFactory.CANONICAL_HASH;
+import static fiftyone.pipeline.did.FodIdTestFactory.CANONICAL_LICENSE_ID;
+import static fiftyone.pipeline.did.FodIdTestFactory.TEST_DOMAIN;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayload;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalRandomPayload;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for {@link FodId}. Ports the live .NET suite (including the Type-model
+ * cases) and adds the runbook's gap tests.
+ */
+public class FodIdTests {
+
+ private FodIdTestFactory factory;
+
+ @Before
+ public void init() throws OwidException {
+ factory = new FodIdTestFactory();
+ }
+
+ // ----- Current .NET coverage -----
+
+ @Test
+ public void constants_AreInternallyConsistent() {
+ assertEquals(FodId.PAYLOAD_LENGTH, FodId.HASH_OFFSET + FodId.HASH_LENGTH);
+ assertEquals(FodId.HASH_OFFSET, FodId.LICENSE_ID_OFFSET + FodId.LICENSE_ID_LENGTH);
+ assertEquals(FodId.RANDOM_PAYLOAD_LENGTH, FodId.HASH_OFFSET + FodId.GUID_LENGTH);
+ }
+
+ @Test
+ public void exposesOwidLevelFields() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+ // OWID-level concerns are delegated to the wrapped envelope.
+ assertEquals(TEST_DOMAIN, fodId.getDomain());
+ assertNotNull(fodId.getVersion());
+ }
+
+ @Test
+ public void constructor_FromBase64_UnpacksAllThreeFields() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertEquals(TEST_DOMAIN, fodId.getDomain());
+ }
+
+ @Test
+ public void constructor_FromBytes_UnpacksAllThreeFields() throws Exception {
+ String base64 = factory.signedOwidBase64(canonicalPayload());
+ byte[] bytes = Base64.getDecoder().decode(base64);
+
+ FodId fodId = FodId.fromByteArray(bytes);
+
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertEquals(TEST_DOMAIN, fodId.getDomain());
+ }
+
+ @Test
+ public void constructor_FromOwid_UnpacksAllThreeFields() throws Exception {
+ Owid owid = factory.signedOwid(canonicalPayload());
+
+ FodId fodId = FodId.fromOwid(owid);
+
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertEquals(owid.getDomain(), fodId.getDomain());
+ assertEquals(owid.getDate(), fodId.getDate());
+ assertEquals(owid.getVersion(), fodId.getVersion());
+ // owid-java getters clone, so assert content equality, not identity.
+ assertArrayEquals(owid.getPayload(), fodId.getPayload());
+ assertArrayEquals(owid.getSignature(), fodId.getSignature());
+ }
+
+ @Test
+ public void constructor_NullOwid_Throws() {
+ assertThrows(NullPointerException.class, () -> FodId.fromOwid(null));
+ }
+
+ @Test
+ public void licenseId_IsLittleEndian() throws Exception {
+ byte[] payload = canonicalPayload();
+ payload[FodId.LICENSE_ID_OFFSET] = 0x01;
+ payload[FodId.LICENSE_ID_OFFSET + 1] = 0x00;
+ payload[FodId.LICENSE_ID_OFFSET + 2] = 0x00;
+ payload[FodId.LICENSE_ID_OFFSET + 3] = 0x00;
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(1L, fodId.getLicenseId());
+ }
+
+ @Test
+ public void licenseId_MaxValue_IsLittleEndian() throws Exception {
+ byte[] payload = canonicalPayload();
+ payload[FodId.LICENSE_ID_OFFSET] = (byte) 0xFF;
+ payload[FodId.LICENSE_ID_OFFSET + 1] = (byte) 0xFF;
+ payload[FodId.LICENSE_ID_OFFSET + 2] = (byte) 0xFF;
+ payload[FodId.LICENSE_ID_OFFSET + 3] = (byte) 0xFF;
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(4294967295L, fodId.getLicenseId());
+ }
+
+ @Test
+ public void licenseId_HighBitSet_StaysUnsigned() throws Exception {
+ byte[] payload = canonicalPayload();
+ // 0x80000000 little-endian: 00 00 00 80
+ payload[FodId.LICENSE_ID_OFFSET] = 0x00;
+ payload[FodId.LICENSE_ID_OFFSET + 1] = 0x00;
+ payload[FodId.LICENSE_ID_OFFSET + 2] = 0x00;
+ payload[FodId.LICENSE_ID_OFFSET + 3] = (byte) 0x80;
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(0x80000000L, fodId.getLicenseId());
+ }
+
+ @Test
+ public void flags_ZeroValue_Exposed() throws Exception {
+ byte[] payload = canonicalPayload();
+ payload[FodId.FLAGS_OFFSET] = 0x00;
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(0, fodId.getFlags());
+ }
+
+ @Test
+ public void flags_AllBitsSet_Exposed() throws Exception {
+ byte[] payload = canonicalPayload();
+ payload[FodId.FLAGS_OFFSET] = (byte) 0xFF;
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(255, fodId.getFlags());
+ }
+
+ @Test
+ public void hash_IsDefensiveCopy() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+
+ byte[] hash = fodId.getHash();
+ hash[0] = 0x00;
+ hash[FodId.HASH_LENGTH - 1] = 0x00;
+
+ // Neither the underlying payload nor a fresh getHash() is affected.
+ assertEquals(CANONICAL_HASH[0], fodId.getPayload()[FodId.HASH_OFFSET]);
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ }
+
+ @Test
+ public void constructor_PayloadOneByteShort_Throws() throws Exception {
+ // 36 bytes - one short of the minimum 37 (flags 0 -> Probabilistic).
+ String base64 = factory.signedOwidBase64(new byte[FodId.PAYLOAD_LENGTH - 1]);
+ assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64));
+ }
+
+ @Test
+ public void constructor_PayloadEmpty_Throws() throws Exception {
+ String base64 = factory.signedOwidBase64(new byte[0]);
+ assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64));
+ }
+
+ @Test
+ public void constructor_NullBase64_Throws() {
+ assertThrows(NullPointerException.class, () -> FodId.fromBase64(null));
+ }
+
+ @Test
+ public void constructor_NullBuffer_Throws() {
+ assertThrows(NullPointerException.class, () -> FodId.fromByteArray(null));
+ }
+
+ @Test
+ public void constructor_InvalidBase64_Throws() {
+ assertThrows(OwidException.class,
+ () -> FodId.fromBase64("This is not valid Base64!@#$"));
+ }
+
+ @Test
+ public void constructor_PayloadLargerThanSpec_UsesFirst37Bytes() throws Exception {
+ byte[] payload = new byte[64];
+ System.arraycopy(canonicalPayload(), 0, payload, 0, FodId.PAYLOAD_LENGTH);
+ for (int i = FodId.PAYLOAD_LENGTH; i < payload.length; i++) {
+ payload[i] = (byte) 0xCC;
+ }
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertEquals(FodId.HASH_LENGTH, fodId.getHash().length);
+ }
+
+ @Test
+ public void fodId_IsCryptographicallyVerifiable() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+ assertTrue(fodId.verify(factory.publicPem));
+ }
+
+ @Test
+ public void base64Roundtrip_PreservesAllFields() throws Exception {
+ FodId fodId1 = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+ FodId fodId2 = FodId.fromBase64(fodId1.asBase64());
+
+ assertEquals(fodId1.getFlags(), fodId2.getFlags());
+ assertEquals(fodId1.getLicenseId(), fodId2.getLicenseId());
+ assertArrayEquals(fodId1.getHash(), fodId2.getHash());
+ assertEquals(fodId1.getDomain(), fodId2.getDomain());
+ }
+
+ // ----- Type model -----
+
+ @Test
+ public void type_DecodedFromTopTwoFlagBits() throws Exception {
+ assertEquals(IdType.PROBABILISTIC, typeForFlags((byte) 0b0000_0101));
+ assertEquals(IdType.HASHED_EMAIL, typeForFlags((byte) 0b1000_0101));
+ assertEquals(IdType.RESERVED, typeForFlags((byte) 0b1100_0101));
+ }
+
+ private IdType typeForFlags(byte flags) throws Exception {
+ byte[] payload = canonicalPayload();
+ payload[FodId.FLAGS_OFFSET] = flags;
+ return FodId.fromBase64(factory.signedOwidBase64(payload)).getType();
+ }
+
+ @Test
+ public void type_RandomWhenBits01() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalRandomPayload()));
+ assertEquals(IdType.RANDOM, fodId.getType());
+ }
+
+ @Test
+ public void constructor_RandomPayload21Bytes_Parses() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalRandomPayload()));
+
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertEquals(FodId.GUID_LENGTH, fodId.getHash().length);
+ byte[] expected = new byte[FodId.GUID_LENGTH];
+ for (int i = 0; i < expected.length; i++) {
+ expected[i] = (byte) (0x40 + i);
+ }
+ assertArrayEquals(expected, fodId.getHash());
+ }
+
+ @Test
+ public void constructor_RandomPayloadOneByteShort_Throws() throws Exception {
+ byte[] payload = Arrays.copyOf(
+ canonicalRandomPayload(), FodId.RANDOM_PAYLOAD_LENGTH - 1);
+ String base64 = factory.signedOwidBase64(payload);
+ assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64));
+ }
+
+ @Test
+ public void constructor_RandomPayloadLargerThanSpec_UsesFirst16ValueBytes()
+ throws Exception {
+ byte[] payload = new byte[FodId.PAYLOAD_LENGTH];
+ System.arraycopy(
+ canonicalRandomPayload(), 0, payload, 0, FodId.RANDOM_PAYLOAD_LENGTH);
+ for (int i = FodId.RANDOM_PAYLOAD_LENGTH; i < payload.length; i++) {
+ payload[i] = (byte) 0xCC;
+ }
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(IdType.RANDOM, fodId.getType());
+ assertEquals(FodId.GUID_LENGTH, fodId.getHash().length);
+ }
+
+ @Test
+ public void constructor_HashedEmailPayloadOneByteShort_Throws() throws Exception {
+ // CANONICAL_FLAGS (0xA5) carries the HashedEmail tag, so the 37-byte
+ // minimum still applies.
+ byte[] payload = Arrays.copyOf(canonicalPayload(), FodId.PAYLOAD_LENGTH - 1);
+ String base64 = factory.signedOwidBase64(payload);
+ assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64));
+ }
+
+ @Test
+ public void constructor_ReservedHeaderOnly_Parses() throws Exception {
+ byte[] payload = new byte[FodId.HASH_OFFSET];
+ payload[FodId.FLAGS_OFFSET] = (byte) 0b1100_0000;
+
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
+
+ assertEquals(IdType.RESERVED, fodId.getType());
+ assertEquals(0, fodId.getHash().length);
+ }
+
+ // ----- Gap tests (runbook section 6b) -----
+
+ @Test
+ public void compareTwo51Dids_SamePayload_SameValueDifferentEnvelopes()
+ throws Exception {
+ byte[] payload = canonicalPayload();
+ Owid a = factory.signedOwid(payload);
+ Owid b = factory.signedOwid(payload);
+ // sign() stamps "now" to the minute, so set distinct dates to
+ // represent two reissues at different times.
+ a.setDate(Instant.parse("2026-01-01T00:00:00Z"));
+ b.setDate(Instant.parse("2026-01-01T00:05:00Z"));
+
+ FodId fodA = FodId.fromBase64(a.asBase64());
+ FodId fodB = FodId.fromBase64(b.asBase64());
+
+ // The value is stable across reissues...
+ assertArrayEquals(fodA.getHash(), fodB.getHash());
+ // ...while the envelope differs.
+ assertNotEquals(fodA.getDate(), fodB.getDate());
+ assertFalse(Arrays.equals(fodA.getSignature(), fodB.getSignature()));
+ assertNotEquals(a.asBase64(), b.asBase64());
+ }
+
+ @Test
+ public void construction_DoesNotVerify() throws Exception {
+ // An OWID with a present but tampered (invalid) signature still
+ // constructs and exposes all three fields - construction must not
+ // verify.
+ byte[] bytes = Base64.getDecoder().decode(
+ factory.signedOwidBase64(canonicalPayload()));
+ bytes[bytes.length - 1] ^= 0xFF; // corrupt the signature
+ Owid tampered = Owid.fromByteArray(bytes);
+
+ FodId fodId = FodId.fromOwid(tampered);
+
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ }
+
+ @Test
+ public void fromOwid_IsDecoupledFromSourceOwid() throws Exception {
+ // Mutating the source OWID after construction must not affect the
+ // FodId (it holds an independent copy).
+ Owid owid = factory.signedOwid(canonicalPayload());
+ FodId fodId = FodId.fromOwid(owid);
+
+ owid.setPayload(new byte[FodId.PAYLOAD_LENGTH]);
+
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertEquals(CANONICAL_HASH[0], fodId.getPayload()[FodId.HASH_OFFSET]);
+ }
+
+ @Test
+ public void verify_WithWrongKey_ReturnsFalse() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+ String otherPublicPem = Crypto.generate().publicKeyPem();
+
+ assertFalse(fodId.verify(otherPublicPem));
+ }
+
+ @Test
+ public void roundtrip_ThroughBytesConstructor_PreservesAllFields()
+ throws Exception {
+ FodId fodId1 = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+
+ FodId fodId2 = FodId.fromByteArray(fodId1.asByteArray());
+
+ assertEquals(fodId1.getFlags(), fodId2.getFlags());
+ assertEquals(fodId1.getLicenseId(), fodId2.getLicenseId());
+ assertArrayEquals(fodId1.getHash(), fodId2.getHash());
+ assertEquals(fodId1.getDomain(), fodId2.getDomain());
+ }
+}
diff --git a/pom.xml b/pom.xml
index 043e3bbd5..38eb3c851 100644
--- a/pom.xml
+++ b/pom.xml
@@ -48,6 +48,7 @@
+ *
+ *