diff --git a/examples/security-foundations/pom.xml b/examples/security-foundations/pom.xml
new file mode 100644
index 00000000..14a72b17
--- /dev/null
+++ b/examples/security-foundations/pom.xml
@@ -0,0 +1,25 @@
+
+
+ 4.0.0
+
+ io.teaql
+ teaql-java-parent
+ 1.550-RELEASE
+ ../../pom.xml
+
+ teaql-security-foundations-example
+ TeaQL security foundations focused example
+
+
+ io.teaql
+ teaql-runtime
+
+
+ junit
+ junit
+ test
+
+
+
diff --git a/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java b/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java
new file mode 100644
index 00000000..a3ab2722
--- /dev/null
+++ b/examples/security-foundations/src/test/java/io/teaql/examples/security/SecurityFoundationsExampleTest.java
@@ -0,0 +1,35 @@
+package io.teaql.examples.security;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import io.teaql.core.EntityReferenceTokenException;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.security.AeadEntityReferenceCodec;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Map;
+import org.junit.Test;
+
+public class SecurityFoundationsExampleTest {
+ @Test
+ public void userContextProtectsBoundaryReferences() {
+ byte[] key = new byte[32];
+ byte[] nonce = new byte[12];
+ Arrays.fill(key, (byte) 0x22);
+ Arrays.fill(nonce, (byte) 0x33);
+ AeadEntityReferenceCodec codec = new AeadEntityReferenceCodec(2, Map.of(2, key))
+ .withClock(Clock.fixed(Instant.parse("2026-09-26T12:00:00Z"), ZoneOffset.UTC))
+ .withNonceSource(() -> nonce.clone());
+ DefaultUserContext context = new DefaultUserContext(null);
+ context.withEntityReferenceCodec(codec);
+ String token = context.encodeEntityReference("OrderItem", 42, 7, "edit-order", Duration.ofHours(1));
+ assertEquals(42, context.decodeEntityReference(token, "OrderItem", "edit-order").id());
+ assertThrows(EntityReferenceTokenException.class,
+ () -> context.decodeEntityReference(token, "OrderItem", "view-order"));
+ System.out.println("PASS Java security foundations: opaque reference is portable and purpose-bound");
+ }
+}
diff --git a/pom.xml b/pom.xml
index e224bc43..6b8066b6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -77,6 +77,7 @@
teaql-android
teaql-business-id-jdbc
examples/business-id-runtime
+ examples/security-foundations
teaql-tfp-endpoint
teaql-opentelemetry
examples/conformance/lib
diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh
index 5755153a..1e2bf541 100755
--- a/scripts/verify-examples.sh
+++ b/scripts/verify-examples.sh
@@ -4,7 +4,7 @@ set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
verification_dir="$(mktemp -d)"
trap 'rm -rf -- "$verification_dir"' EXIT
-expected=(business-id-runtime conformance order-management school-management)
+expected=(business-id-runtime conformance order-management school-management security-foundations)
mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
if [[ "${actual[*]}" != "${expected[*]}" ]]; then
echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2
@@ -15,6 +15,8 @@ cd "$repo"
mvn -q -DskipTests install
mvn -q -pl examples/business-id-runtime \
-Dtest=BusinessIdRuntimeExampleTest test
+mvn -q -pl examples/security-foundations \
+ -Dtest=SecurityFoundationsExampleTest test
mvn -q -f examples/conformance/lib/pom.xml install -DskipTests
mvn -q -f examples/conformance/pom.xml spring-boot:run \
-Dspring-boot.run.arguments="--spring.main.web-application-type=none --spring.datasource.url=jdbc:sqlite:$verification_dir/conformance.db"
diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java
new file mode 100644
index 00000000..90828277
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java
@@ -0,0 +1,13 @@
+package io.teaql.core;
+
+import java.time.Instant;
+
+/** Verified, boundary-safe identity recovered from an opaque entity reference. */
+public record EntityReferenceClaims(
+ String entityType,
+ long id,
+ long version,
+ Instant issuedAt,
+ Instant expiresAt,
+ String purpose,
+ int keyVersion) {}
diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java
new file mode 100644
index 00000000..9ea944e2
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java
@@ -0,0 +1,10 @@
+package io.teaql.core;
+
+import java.time.Duration;
+
+/** Provider SPI for boundary-safe entity references. */
+public interface EntityReferenceCodec {
+ String encode(String entityType, long id, long version, String purpose, Duration lifetime);
+
+ EntityReferenceClaims decode(String token, String expectedEntityType, String purpose);
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java
new file mode 100644
index 00000000..d5999197
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java
@@ -0,0 +1,15 @@
+package io.teaql.core;
+
+/** Stable fail-closed error; deliberately does not reveal why validation failed. */
+public final class EntityReferenceTokenException extends TeaQLRuntimeException {
+ private final String code;
+
+ public EntityReferenceTokenException(String code) {
+ super(code);
+ this.code = code;
+ }
+
+ public String code() {
+ return code;
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java b/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java
new file mode 100644
index 00000000..77b05690
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java
@@ -0,0 +1,96 @@
+package io.teaql.core;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Base64;
+
+/** Portable wire helpers shared by the runtime codec and the explicit development bypass. */
+public final class EntityReferenceWire {
+ public static final String ENCRYPTED_PREFIX = "tqr1.";
+ public static final String RAW_PREFIX = "tqr0.";
+ public static final String ASSOCIATED_DATA = "teaql.entity-reference.v1";
+ public static final String UNSAFE_ENVIRONMENT = "TEAQL_UNSAFE_RAW_ENTITY_REFERENCES";
+ public static final String UNSAFE_ACKNOWLEDGEMENT =
+ "I_UNDERSTAND_RAW_ENTITY_IDS_ARE_VISIBLE_FOR_LOCAL_DEVELOPMENT_ONLY";
+
+ private EntityReferenceWire() {}
+
+ public static byte[] encodeClaims(EntityReferenceClaims claims) {
+ try {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ writeText(output, claims.entityType());
+ output.write(ByteBuffer.allocate(32)
+ .putLong(claims.id())
+ .putLong(claims.version())
+ .putLong(claims.issuedAt().getEpochSecond())
+ .putLong(claims.expiresAt().getEpochSecond())
+ .array());
+ writeText(output, claims.purpose());
+ return output.toByteArray();
+ } catch (EntityReferenceTokenException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw invalid();
+ }
+ }
+
+ public static EntityReferenceClaims decodeClaims(byte[] data) {
+ try {
+ ByteBuffer input = ByteBuffer.wrap(data);
+ String entityType = readText(input);
+ if (input.remaining() < 32) throw invalid();
+ long id = input.getLong();
+ long version = input.getLong();
+ long issuedAt = input.getLong();
+ long expiresAt = input.getLong();
+ String purpose = readText(input);
+ if (input.hasRemaining() || entityType.isEmpty() || id <= 0) throw invalid();
+ return new EntityReferenceClaims(
+ entityType, id, version, Instant.ofEpochSecond(issuedAt),
+ Instant.ofEpochSecond(expiresAt), purpose, 0);
+ } catch (EntityReferenceTokenException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw invalid();
+ }
+ }
+
+ public static String base64Url(byte[] data) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
+ }
+
+ public static byte[] base64UrlDecode(String text) {
+ try {
+ return Base64.getUrlDecoder().decode(text);
+ } catch (IllegalArgumentException exception) {
+ throw invalid();
+ }
+ }
+
+ public static boolean unsafeRawReferencesEnabled() {
+ return UNSAFE_ACKNOWLEDGEMENT.equals(System.getenv(UNSAFE_ENVIRONMENT));
+ }
+
+ public static EntityReferenceTokenException invalid() {
+ return new EntityReferenceTokenException("ENTITY_REFERENCE_INVALID");
+ }
+
+ private static void writeText(ByteArrayOutputStream output, String value) {
+ byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length > 0xffff) throw invalid();
+ output.write((bytes.length >>> 8) & 0xff);
+ output.write(bytes.length & 0xff);
+ output.writeBytes(bytes);
+ }
+
+ private static String readText(ByteBuffer input) {
+ if (input.remaining() < 2) throw invalid();
+ int length = Short.toUnsignedInt(input.getShort());
+ if (input.remaining() < length) throw invalid();
+ byte[] bytes = new byte[length];
+ input.get(bytes);
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/UserContext.java b/teaql-core/src/main/java/io/teaql/core/UserContext.java
index 36b8be45..67072fe7 100644
--- a/teaql-core/src/main/java/io/teaql/core/UserContext.java
+++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java
@@ -11,6 +11,8 @@
import io.teaql.core.businessid.BusinessClock;
import io.teaql.core.businessid.BusinessIdSchemaContributor;
import io.teaql.core.businessid.BusinessIdService;
+import java.time.Duration;
+import java.time.Instant;
public interface UserContext extends OptNullBasicTypeFromObjectGetter {
@@ -19,6 +21,50 @@ public interface UserContext extends OptNullBasicTypeFromObjectGetter {
String TEAQL_ACTIVE_ROOT = "teaql.active.root";
String TEAQL_FIX_EVIDENCE_CURRENT = "teaql.fix.evidence.current";
String TEAQL_FIX_EVIDENCE_LAST = "teaql.fix.evidence.last";
+ String TEAQL_ENTITY_REFERENCE_CODEC = EntityReferenceCodec.class.getName();
+
+ default UserContext withEntityReferenceCodec(EntityReferenceCodec codec) {
+ if (codec == null) throw new IllegalArgumentException("codec must not be null");
+ putAttribute(TEAQL_ENTITY_REFERENCE_CODEC, codec);
+ return this;
+ }
+
+ default String encodeEntityReference(
+ String entityType, long id, long version, String purpose, Duration lifetime) {
+ EntityReferenceCodec codec = getAttribute(TEAQL_ENTITY_REFERENCE_CODEC, EntityReferenceCodec.class);
+ if (codec == null) codec = capability(EntityReferenceCodec.class);
+ if (codec != null) return codec.encode(entityType, id, version, purpose, lifetime);
+ if (!EntityReferenceWire.unsafeRawReferencesEnabled()) {
+ throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED");
+ }
+ if (entityType == null || entityType.isBlank() || id <= 0 || lifetime == null
+ || lifetime.isZero() || lifetime.isNegative()) throw EntityReferenceWire.invalid();
+ Instant now = Instant.now();
+ EntityReferenceClaims claims = new EntityReferenceClaims(
+ entityType, id, version, now, now.plus(lifetime), purpose == null ? "" : purpose, 0);
+ return EntityReferenceWire.RAW_PREFIX
+ + EntityReferenceWire.base64Url(EntityReferenceWire.encodeClaims(claims));
+ }
+
+ default EntityReferenceClaims decodeEntityReference(
+ String token, String expectedEntityType, String purpose) {
+ EntityReferenceCodec codec = getAttribute(TEAQL_ENTITY_REFERENCE_CODEC, EntityReferenceCodec.class);
+ if (codec == null) codec = capability(EntityReferenceCodec.class);
+ if (codec != null) return codec.decode(token, expectedEntityType, purpose);
+ if (!EntityReferenceWire.unsafeRawReferencesEnabled()) {
+ throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED");
+ }
+ if (token == null || !token.startsWith(EntityReferenceWire.RAW_PREFIX)) {
+ throw new EntityReferenceTokenException("ENTITY_REFERENCE_CODEC_REQUIRED");
+ }
+ EntityReferenceClaims claims = EntityReferenceWire.decodeClaims(EntityReferenceWire.base64UrlDecode(
+ token.substring(EntityReferenceWire.RAW_PREFIX.length())));
+ Instant now = Instant.now();
+ if (!claims.expiresAt().isAfter(now) || claims.issuedAt().isAfter(now.plusSeconds(60))
+ || !claims.entityType().equals(expectedEntityType)
+ || !claims.purpose().equals(purpose)) throw EntityReferenceWire.invalid();
+ return claims;
+ }
default void beginFixEvidence() {
putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList());
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java b/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java
new file mode 100644
index 00000000..c6854a1a
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java
@@ -0,0 +1,120 @@
+package io.teaql.runtime.security;
+
+import io.teaql.core.EntityReferenceClaims;
+import io.teaql.core.EntityReferenceCodec;
+import io.teaql.core.EntityReferenceTokenException;
+import io.teaql.core.EntityReferenceWire;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Portable AES-256-GCM implementation of TeaQL opaque entity references. */
+public final class AeadEntityReferenceCodec implements EntityReferenceCodec {
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ private final int activeKeyVersion;
+ private final Map keys;
+ private Clock clock = Clock.systemUTC();
+ private Supplier nonceSource = () -> {
+ byte[] nonce = new byte[12];
+ SECURE_RANDOM.nextBytes(nonce);
+ return nonce;
+ };
+
+ public AeadEntityReferenceCodec(int activeKeyVersion, Map keys) {
+ if (keys == null || !keys.containsKey(activeKeyVersion)) {
+ throw new IllegalArgumentException("Active entity reference key is missing");
+ }
+ this.keys = new HashMap<>();
+ keys.forEach((version, key) -> {
+ if (key == null || key.length != 32) {
+ throw new IllegalArgumentException("Every entity reference key must contain 32 bytes");
+ }
+ this.keys.put(version, Arrays.copyOf(key, key.length));
+ });
+ this.activeKeyVersion = activeKeyVersion;
+ }
+
+ public AeadEntityReferenceCodec withClock(Clock clock) {
+ this.clock = java.util.Objects.requireNonNull(clock);
+ return this;
+ }
+
+ public AeadEntityReferenceCodec withNonceSource(Supplier nonceSource) {
+ this.nonceSource = java.util.Objects.requireNonNull(nonceSource);
+ return this;
+ }
+
+ @Override
+ public String encode(String entityType, long id, long version, String purpose, Duration lifetime) {
+ if (entityType == null || entityType.isBlank() || id <= 0 || lifetime == null
+ || lifetime.isZero() || lifetime.isNegative()) throw EntityReferenceWire.invalid();
+ try {
+ Instant now = clock.instant();
+ byte[] plaintext = EntityReferenceWire.encodeClaims(new EntityReferenceClaims(
+ entityType, id, version, now, now.plus(lifetime), purpose == null ? "" : purpose, 0));
+ byte[] nonce = nonceSource.get();
+ if (nonce == null || nonce.length != 12) throw new IllegalArgumentException("AES-GCM nonce must contain 12 bytes");
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keys.get(activeKeyVersion), "AES"),
+ new GCMParameterSpec(128, nonce));
+ cipher.updateAAD(EntityReferenceWire.ASSOCIATED_DATA.getBytes(StandardCharsets.UTF_8));
+ byte[] ciphertextAndTag = cipher.doFinal(plaintext);
+ byte[] envelope = ByteBuffer.allocate(4 + nonce.length + ciphertextAndTag.length)
+ .putInt(activeKeyVersion).put(nonce).put(ciphertextAndTag).array();
+ return EntityReferenceWire.ENCRYPTED_PREFIX + EntityReferenceWire.base64Url(envelope);
+ } catch (EntityReferenceTokenException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw new TeaqlEntityReferenceConfigurationException("Unable to encode entity reference", exception);
+ }
+ }
+
+ @Override
+ public EntityReferenceClaims decode(String token, String expectedEntityType, String purpose) {
+ try {
+ if (token == null || !token.startsWith(EntityReferenceWire.ENCRYPTED_PREFIX)) throw EntityReferenceWire.invalid();
+ byte[] envelope = EntityReferenceWire.base64UrlDecode(token.substring(EntityReferenceWire.ENCRYPTED_PREFIX.length()));
+ if (envelope.length < 4 + 12 + 16) throw EntityReferenceWire.invalid();
+ ByteBuffer input = ByteBuffer.wrap(envelope);
+ int keyVersion = input.getInt();
+ byte[] key = keys.get(keyVersion);
+ if (key == null) throw EntityReferenceWire.invalid();
+ byte[] nonce = new byte[12];
+ input.get(nonce);
+ byte[] ciphertextAndTag = new byte[input.remaining()];
+ input.get(ciphertextAndTag);
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce));
+ cipher.updateAAD(EntityReferenceWire.ASSOCIATED_DATA.getBytes(StandardCharsets.UTF_8));
+ EntityReferenceClaims decoded = EntityReferenceWire.decodeClaims(cipher.doFinal(ciphertextAndTag));
+ EntityReferenceClaims claims = new EntityReferenceClaims(
+ decoded.entityType(), decoded.id(), decoded.version(), decoded.issuedAt(),
+ decoded.expiresAt(), decoded.purpose(), keyVersion);
+ Instant now = clock.instant();
+ if (!claims.expiresAt().isAfter(now) || claims.issuedAt().isAfter(now.plusSeconds(60))
+ || !claims.entityType().equals(expectedEntityType)
+ || !claims.purpose().equals(purpose)) throw EntityReferenceWire.invalid();
+ return claims;
+ } catch (EntityReferenceTokenException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw EntityReferenceWire.invalid();
+ }
+ }
+
+ public static final class TeaqlEntityReferenceConfigurationException extends RuntimeException {
+ public TeaqlEntityReferenceConfigurationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java
new file mode 100644
index 00000000..94ec2909
--- /dev/null
+++ b/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java
@@ -0,0 +1,63 @@
+package io.teaql.runtime.security;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+
+import io.teaql.core.EntityReferenceClaims;
+import io.teaql.core.EntityReferenceTokenException;
+import io.teaql.runtime.DefaultUserContext;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Map;
+import org.junit.Test;
+
+public class AeadEntityReferenceCodecTest {
+ private static final String GOLDEN = "tqr1.AAAAAjMzMzMzMzMzMzMzM3bKiZgRSQQhfIj2cBXRDZIloUGHWLBp8QrXL_aejwIXPFtvV_E71O7wbOXy3cvYo_SwxvuS-89x572T9CO_pDAY4tbjWCNv";
+ private static final Instant NOW = Instant.parse("2026-09-26T12:00:00Z");
+
+ @Test
+ public void referenceIsPortableOpaqueBoundRotatableAndExpiring() {
+ byte[] key = new byte[32];
+ byte[] nonce = new byte[12];
+ Arrays.fill(key, (byte) 0x22);
+ Arrays.fill(nonce, (byte) 0x33);
+ AeadEntityReferenceCodec codec = new AeadEntityReferenceCodec(2, Map.of(2, key))
+ .withClock(Clock.fixed(NOW, ZoneOffset.UTC))
+ .withNonceSource(() -> nonce.clone());
+ DefaultUserContext context = new DefaultUserContext(null);
+ context.withEntityReferenceCodec(codec);
+
+ String token = context.encodeEntityReference(
+ "OrderItem", 42, 7, "edit-order", Duration.ofHours(1));
+ assertEquals(GOLDEN, token);
+ assertFalse(token.contains("OrderItem"));
+ assertFalse(token.contains("42"));
+ EntityReferenceClaims claims = context.decodeEntityReference(token, "OrderItem", "edit-order");
+ assertEquals(42, claims.id());
+ assertEquals(7, claims.version());
+ assertEquals(2, claims.keyVersion());
+
+ assertThrows(EntityReferenceTokenException.class,
+ () -> context.decodeEntityReference(token, "InvoiceItem", "edit-order"));
+ assertThrows(EntityReferenceTokenException.class,
+ () -> context.decodeEntityReference(token, "OrderItem", "other-purpose"));
+ assertThrows(EntityReferenceTokenException.class,
+ () -> context.decodeEntityReference(token.substring(0, token.length() - 1) + "A", "OrderItem", "edit-order"));
+
+ codec.withClock(Clock.fixed(NOW.plus(Duration.ofHours(2)), ZoneOffset.UTC));
+ assertThrows(EntityReferenceTokenException.class,
+ () -> context.decodeEntityReference(token, "OrderItem", "edit-order"));
+ }
+
+ @Test
+ public void missingCodecFailsClosedWithoutExactDevelopmentAcknowledgement() {
+ DefaultUserContext context = new DefaultUserContext(null);
+ EntityReferenceTokenException error = assertThrows(EntityReferenceTokenException.class,
+ () -> context.encodeEntityReference("Order", 1, 1, "edit", Duration.ofMinutes(1)));
+ assertEquals("ENTITY_REFERENCE_CODEC_REQUIRED", error.code());
+ }
+}