diff --git a/README.md b/README.md
index 57fed125..62200bfb 100644
--- a/README.md
+++ b/README.md
@@ -132,25 +132,39 @@ Install an application-owned key provider at the runtime boundary:
```java
byte[] activeKey = loadThirtyTwoByteKeyFromSecretManager();
-EntityReferenceCodec codec = new AeadEntityReferenceCodec(
- 2, Map.of(2, activeKey));
-userContext.withEntityReferenceCodec(codec);
-
-String token = userContext.encodeEntityReference(
- "OrderItem", 42L, 7L, "edit-order", Duration.ofMinutes(15));
-EntityReferenceClaims claims = userContext.decodeEntityReference(
- token, "OrderItem", "edit-order");
+var provider = AeadRoundTripReferenceProvider.fromProcessEnvironment(
+ DeploymentProfile.PRODUCTION,
+ "order-service",
+ "production",
+ new StaticRoundTripReferenceKeyProvider(
+ new RoundTripReferenceKey("k2", activeKey)),
+ currentAuthorizationPolicy);
+var runtime = TeaQLRuntime.builder()
+ .roundTripReferenceProvider(provider)
+ .build();
+userContext.withTrustedReferencePrincipal(
+ new TrustedReferencePrincipal("oidc", "alice", "Platform", 7));
+var scope = new ReferenceDocumentScope(
+ "order-editor-100", "edit-order", "Order", 100, 9);
+
+Object wire = userContext.referenceFor(orderItem, scope, Duration.ofMinutes(15));
+ResolvedRoundTripReference resolved = userContext.resolveReference(
+ wire, scope, "OrderItem");
```
-The token uses AES-256-GCM, carries a key version for rotation, expires, and is
-bound to both entity type and purpose. Invalid, expired, substituted, or
-tampered tokens all fail with `ENTITY_REFERENCE_INVALID`; a missing codec fails
-with `ENTITY_REFERENCE_CODEC_REQUIRED`. Raw `tqr0.` references are available
-only after setting the deliberately long local-development acknowledgement
-documented in the canonical
-[opaque entity reference contract](https://github.com/teaql/teaql-conformance/blob/main/design/opaque-entity-references.md).
-Tokens complement authorization; they do not replace tenant, ownership, role,
-or optimistic-lock checks.
+The `tqr1` token uses AES-256-GCM with HKDF-SHA-256, carries a key ID for
+rotation, expires, and binds Actor, Domain Root, service, environment, purpose,
+document, Aggregate identity/revision, entity type/ID/version. Current
+authorization runs both when issuing and consuming the reference. Java and
+Rust retain the same deterministic golden vector.
+
+For local diagnosis only, the exact
+`TEAQL_UNSAFE_EXPOSE_RAW_ENTITY_IDS=I_UNDERSTAND_THIS_EXPOSES_INTERNAL_ENTITY_IDS_FOR_LOCAL_DEBUGGING_ONLY`
+acknowledgement changes the startup-selected wire shape to `{id, version}` in
+development/test. Production fails closed; raw mode does not bypass current
+authorization, type, version, projection, Checker/Fix, audit, or Mutation
+Ledger enforcement. See the canonical
+[context-bound reference contract](https://github.com/teaql/teaql-conformance/blob/main/design/context-bound-round-trip-references.md).
## Choose Modules
diff --git a/examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md b/examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md
new file mode 100644
index 00000000..30b690d1
--- /dev/null
+++ b/examples/conformance/evidence/2026-09-27-context-bound-round-trip-reference.md
@@ -0,0 +1,68 @@
+# Java context-bound Round-Trip Reference evidence
+
+Date: 2026-09-27
+
+Tracking: [teaql-java#175](https://github.com/teaql/teaql-java/issues/175)
+
+Baseline: `origin/main` at `9ca9e261a7b8c5f39292374ac7eab2c7cab303d6`.
+
+Scope: the bounded `tqr1` independent-reference core. This evidence does not
+claim a complete encrypted document manifest or generated `openDocument` /
+`acceptDocument` adapters.
+
+## Implemented boundary
+
+- high-level `UserContext::referenceFor` / `resolveReference` boundary;
+- trusted authentication realm, subject and Domain Root identity supplied by
+ runtime assembly, never accepted from the wire;
+- service/environment, purpose, document, Aggregate type/ID/revision and
+ entity type/ID/version binding;
+- AES-256-GCM, 96-bit CSPRNG nonce, HKDF-SHA-256 and unpadded Base64URL;
+- current encryption key plus decode-only previous keys;
+- expiry, service/environment isolation, tamper rejection, fresh nonces and
+ restart/rotation continuity;
+- current authorization at issue and consume time;
+- one runtime-selected diagnostic raw mode with an exact long acknowledgement,
+ production failure, explicit ERROR notice, telemetry and response metadata;
+- removal of the earlier weaker codec/raw-fallback path so there is one public
+ Round-Trip Reference contract.
+
+## Cross-language vector
+
+The fixed inputs used by Rust and Java are identical: service `order-service`,
+environment `test-a`, current key `k2` filled with `0x22`, timestamp
+`2026-09-27T00:00:00Z`, nonce filled with `0x33`, principal
+`oidc/alice/Platform#7`, scope `doc-100/edit-order/Order#100@9`, identity
+`OrderItem#42@3`, and lifetime 600 seconds.
+
+Both runtimes assert this exact token:
+
+```text
+tqr1.k2.MzMzMzMzMzMzMzMzmhCET15vBdSc1BbFJcrvMMnRP1wAyjunShYlFPnDZL7Gk5V1LuptWyrbiQ8LQSDHFUmN3o6vb-MEgXZuh3N-YkeO55CYLeVgiyBBYslzuSu-fvWC2KMZcnZ5D8BRmWpGYA2Ok5sWe1Fg1aIyadTjVoKB-H2vPW_0okj-
+```
+
+## RAW-01 through RAW-09
+
+`ContextBoundRoundTripReferenceTest` contains individually named deterministic
+tests for all nine cases: default governed mode, exact development/test opt-in,
+near-match rejection, production failure, current authorization after actor
+change, mode/wire-shape mismatch, identity/version preservation, and safe
+downgrade metadata.
+
+## Commands
+
+```text
+mvn -pl teaql-runtime -am \
+ -Dtest=ContextBoundRoundTripReferenceTest \
+ -Dsurefire.failIfNoSpecifiedTests=false test
+
+mvn -pl examples/round-trip-reference-runtime -am test
+mvn clean verify
+```
+
+The focused runtime suite reports 12 passed tests. The full clean reactor
+reports 39/39 modules successful, including 197 core tests, 48 runtime tests,
+the migrated security-foundations example, the standalone Round-Trip Reference
+example, all database dialect modules, TFP, telemetry, and generated
+conformance workspaces. One external OTLP smoke test is skipped because its
+external endpoint is not configured. No release is produced by this work.
diff --git a/examples/round-trip-reference-runtime/README.md b/examples/round-trip-reference-runtime/README.md
new file mode 100644
index 00000000..2fd40a23
--- /dev/null
+++ b/examples/round-trip-reference-runtime/README.md
@@ -0,0 +1,21 @@
+# Round-Trip Reference runtime example
+
+This example proves that the reusable runtime serialization boundary can be
+called by any Web adapter without depending on Spring, JAX-RS, Servlet, or a
+specific JSON library.
+
+It covers governed issue/resolve through `UserContext`, trusted Actor and
+Domain Root binding, purpose/document/Aggregate scope, tamper rejection,
+expected-type validation, expiry, key rotation, current authorization at issue
+and consume time, and the explicit development/test-only raw diagnostic shape.
+
+The core deliberately does not define tenant or role semantics. Runtime
+assembly installs the trusted principal and application-owned authorization
+policy. The focused runtime tests retain the same deterministic `tqr1` golden
+vector as TeaQL Rust and enumerate RAW-01 through RAW-09.
+
+Run locally against repository sources:
+
+```bash
+mvn -pl examples/round-trip-reference-runtime -am test
+```
diff --git a/examples/round-trip-reference-runtime/pom.xml b/examples/round-trip-reference-runtime/pom.xml
new file mode 100644
index 00000000..a46f4920
--- /dev/null
+++ b/examples/round-trip-reference-runtime/pom.xml
@@ -0,0 +1,25 @@
+
+
+ 4.0.0
+
+ io.teaql
+ teaql-java-parent
+ 1.550-RELEASE
+ ../../pom.xml
+
+ teaql-round-trip-reference-example
+ TeaQL Round-Trip Reference focused example
+
+
+ io.teaql
+ teaql-runtime
+
+
+ junit
+ junit
+ test
+
+
+
diff --git a/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java
new file mode 100644
index 00000000..4b393546
--- /dev/null
+++ b/examples/round-trip-reference-runtime/src/test/java/io/teaql/examples/reference/RoundTripReferenceRuntimeExampleTest.java
@@ -0,0 +1,73 @@
+package io.teaql.examples.reference;
+
+import io.teaql.core.BaseEntity;
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.reference.ReferenceDocumentScope;
+import io.teaql.core.reference.RoundTripReferenceErrorCode;
+import io.teaql.core.reference.RoundTripReferenceException;
+import io.teaql.core.reference.TrustedReferencePrincipal;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+import io.teaql.runtime.reference.AeadRoundTripReferenceProvider;
+import io.teaql.runtime.reference.DeploymentProfile;
+import io.teaql.runtime.reference.RoundTripReferenceKey;
+import io.teaql.runtime.reference.StaticRoundTripReferenceKeyProvider;
+import java.time.Duration;
+import java.util.Arrays;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RoundTripReferenceRuntimeExampleTest {
+ private static final class OrderItem extends BaseEntity {
+ OrderItem(long id, long version) {
+ __internalSet("id", id);
+ __internalSet("version", version);
+ }
+
+ @Override public String typeName() { return "OrderItem"; }
+ }
+
+ @Test
+ public void contextApiIssuesAndConsumesAnOpaqueReference() {
+ byte[] current = new byte[32];
+ byte[] previous = new byte[32];
+ Arrays.fill(current, (byte) 0x22);
+ Arrays.fill(previous, (byte) 0x11);
+ AeadRoundTripReferenceProvider provider = AeadRoundTripReferenceProvider.governed(
+ DeploymentProfile.DEVELOPMENT,
+ "order-service",
+ "local",
+ new StaticRoundTripReferenceKeyProvider(
+ new RoundTripReferenceKey("k2", current),
+ new RoundTripReferenceKey("k1", previous)),
+ (context, principal, scope, identity) -> {
+ if (!"alice".equals(principal.subject())
+ || !"OrderItem".equals(identity.entityType())) {
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED,
+ "current authorization rejected the entity reference");
+ }
+ });
+ TeaQLRuntime runtime = TeaQLRuntime.builder()
+ .metadata(new SimpleEntityMetaFactory())
+ .roundTripReferenceProvider(provider)
+ .build();
+ UserContext context = new DefaultUserContext(runtime).withTrustedReferencePrincipal(
+ new TrustedReferencePrincipal("oidc", "alice", "Platform", 7));
+ ReferenceDocumentScope scope = new ReferenceDocumentScope(
+ "order-editor-100", "edit-order", "Order", 100, 9);
+
+ Object wire = context.referenceFor(new OrderItem(42, 3), scope, Duration.ofMinutes(15));
+ Assert.assertTrue(wire instanceof String);
+ Assert.assertTrue(wire.toString().startsWith("tqr1.k2."));
+ Assert.assertFalse(wire.toString().contains("OrderItem"));
+ Assert.assertEquals(42, context.resolveReference(wire, scope, "OrderItem").id());
+
+ ReferenceDocumentScope anotherDocument = new ReferenceDocumentScope(
+ "order-editor-101", "edit-order", "Order", 100, 9);
+ Assert.assertThrows(
+ RoundTripReferenceException.class,
+ () -> context.resolveReference(wire, anotherDocument, "OrderItem"));
+ }
+}
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
index a3ab2722..49e5628a 100644
--- 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
@@ -3,33 +3,58 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
-import io.teaql.core.EntityReferenceTokenException;
+import io.teaql.core.BaseEntity;
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.reference.ReferenceDocumentScope;
+import io.teaql.core.reference.RoundTripReferenceException;
+import io.teaql.core.reference.TrustedReferencePrincipal;
import io.teaql.runtime.DefaultUserContext;
-import io.teaql.runtime.security.AeadEntityReferenceCodec;
-import java.time.Clock;
+import io.teaql.runtime.TeaQLRuntime;
+import io.teaql.runtime.reference.AeadRoundTripReferenceProvider;
+import io.teaql.runtime.reference.DeploymentProfile;
+import io.teaql.runtime.reference.RoundTripReferenceKey;
+import io.teaql.runtime.reference.StaticRoundTripReferenceKeyProvider;
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 {
+ private static final class OrderItem extends BaseEntity {
+ OrderItem(long id, long version) {
+ __internalSet("id", id);
+ __internalSet("version", version);
+ }
+
+ @Override public String typeName() { return "OrderItem"; }
+ }
+
@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");
+ AeadRoundTripReferenceProvider provider = AeadRoundTripReferenceProvider.governed(
+ DeploymentProfile.TEST,
+ "order-service",
+ "test-a",
+ new StaticRoundTripReferenceKeyProvider(new RoundTripReferenceKey("k2", key)),
+ (context, principal, scope, identity) -> {});
+ TeaQLRuntime runtime = TeaQLRuntime.builder()
+ .metadata(new SimpleEntityMetaFactory())
+ .roundTripReferenceProvider(provider)
+ .build();
+ UserContext context = new DefaultUserContext(runtime).withTrustedReferencePrincipal(
+ new TrustedReferencePrincipal("oidc", "alice", "Platform", 7));
+ ReferenceDocumentScope scope = new ReferenceDocumentScope(
+ "doc-100", "edit-order", "Order", 100, 9);
+
+ Object token = context.referenceFor(new OrderItem(42, 7), scope, Duration.ofHours(1));
+ assertEquals(42, context.resolveReference(token, scope, "OrderItem").id());
+ assertThrows(RoundTripReferenceException.class,
+ () -> context.resolveReference(
+ token,
+ new ReferenceDocumentScope("doc-100", "view-order", "Order", 100, 9),
+ "OrderItem"));
+ System.out.println("PASS Java security foundations: context-bound reference is opaque and purpose-bound");
}
}
diff --git a/pom.xml b/pom.xml
index 6b8066b6..cc74ff52 100644
--- a/pom.xml
+++ b/pom.xml
@@ -78,6 +78,7 @@
teaql-business-id-jdbc
examples/business-id-runtime
examples/security-foundations
+ examples/round-trip-reference-runtime
teaql-tfp-endpoint
teaql-opentelemetry
examples/conformance/lib
diff --git a/scripts/verify-examples.sh b/scripts/verify-examples.sh
index 1e2bf541..2c8d4e63 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 security-foundations)
+expected=(business-id-runtime conformance order-management round-trip-reference-runtime 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
@@ -17,6 +17,8 @@ mvn -q -pl examples/business-id-runtime \
-Dtest=BusinessIdRuntimeExampleTest test
mvn -q -pl examples/security-foundations \
-Dtest=SecurityFoundationsExampleTest test
+mvn -q -pl examples/round-trip-reference-runtime \
+ -Dtest=RoundTripReferenceRuntimeExampleTest 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
deleted file mode 100644
index 90828277..00000000
--- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceClaims.java
+++ /dev/null
@@ -1,13 +0,0 @@
-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
deleted file mode 100644
index 9ea944e2..00000000
--- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceCodec.java
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index d5999197..00000000
--- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceTokenException.java
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 77b05690..00000000
--- a/teaql-core/src/main/java/io/teaql/core/EntityReferenceWire.java
+++ /dev/null
@@ -1,96 +0,0 @@
-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 67072fe7..b5751454 100644
--- a/teaql-core/src/main/java/io/teaql/core/UserContext.java
+++ b/teaql-core/src/main/java/io/teaql/core/UserContext.java
@@ -11,8 +11,13 @@
import io.teaql.core.businessid.BusinessClock;
import io.teaql.core.businessid.BusinessIdSchemaContributor;
import io.teaql.core.businessid.BusinessIdService;
+import io.teaql.core.reference.ResolvedRoundTripReference;
+import io.teaql.core.reference.ReferenceDocumentScope;
+import io.teaql.core.reference.RoundTripReferenceCodec;
+import io.teaql.core.reference.RoundTripReferenceErrorCode;
+import io.teaql.core.reference.RoundTripReferenceException;
+import io.teaql.core.reference.TrustedReferencePrincipal;
import java.time.Duration;
-import java.time.Instant;
public interface UserContext extends OptNullBasicTypeFromObjectGetter {
@@ -21,50 +26,7 @@ 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;
- }
+ String TEAQL_TRUSTED_REFERENCE_PRINCIPAL = TrustedReferencePrincipal.class.getName();
default void beginFixEvidence() {
putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList());
@@ -279,6 +241,45 @@ default java.time.LocalDate businessDate() {
return clock.businessDate(this);
}
+ default RoundTripReferenceCodec roundTripReferences() {
+ RoundTripReferenceCodec codec = capability(RoundTripReferenceCodec.class);
+ if (codec == null) {
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.PROVIDER_NOT_CONFIGURED,
+ "RoundTripReferenceCodec capability is not registered");
+ }
+ return codec;
+ }
+
+ default UserContext withTrustedReferencePrincipal(TrustedReferencePrincipal principal) {
+ if (principal == null) throw new IllegalArgumentException("principal must not be null");
+ putAttribute(TEAQL_TRUSTED_REFERENCE_PRINCIPAL, principal);
+ return this;
+ }
+
+ default TrustedReferencePrincipal trustedReferencePrincipal() {
+ TrustedReferencePrincipal principal = getAttribute(
+ TEAQL_TRUSTED_REFERENCE_PRINCIPAL, TrustedReferencePrincipal.class);
+ if (principal == null) {
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.CONTEXT_BINDING_REQUIRED,
+ "Trusted reference principal is not installed in this UserContext");
+ }
+ return principal;
+ }
+
+ default Object referenceFor(
+ Entity entity, ReferenceDocumentScope scope, Duration lifetime) {
+ return roundTripReferences().serialize(
+ this, trustedReferencePrincipal(), scope, entity, lifetime);
+ }
+
+ default ResolvedRoundTripReference resolveReference(
+ Object reference, ReferenceDocumentScope scope, String expectedEntityType) {
+ return roundTripReferences().deserialize(
+ this, trustedReferencePrincipal(), scope, reference, expectedEntityType);
+ }
+
/**
* Returns the Dynamic Fields facade for reading/writing dynamic field values.
* The facade is resolved via {@link #capability(Class)} and must be registered
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java b/teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java
new file mode 100644
index 00000000..805f0215
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/ExternalEntityReference.java
@@ -0,0 +1,16 @@
+package io.teaql.core.reference;
+
+/** Strongly typed internal representation; Web adapters use {@link ReferenceWireCodec}. */
+public sealed interface ExternalEntityReference {
+ record Governed(String token) implements ExternalEntityReference {
+ public Governed {
+ if (token == null || token.isBlank()) throw new IllegalArgumentException("token required");
+ }
+ }
+
+ record Raw(long id, long version) implements ExternalEntityReference {
+ public Raw {
+ if (id <= 0 || version < 0) throw new IllegalArgumentException("invalid raw identity");
+ }
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java
new file mode 100644
index 00000000..0224695f
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/InternalEntityIdentity.java
@@ -0,0 +1,19 @@
+package io.teaql.core.reference;
+
+import java.nio.charset.StandardCharsets;
+
+/** Runtime identity carried inside a protected round-trip reference. */
+public record InternalEntityIdentity(String entityType, long id, long version) {
+ public InternalEntityIdentity {
+ if (entityType == null || entityType.isBlank() || !entityType.equals(entityType.trim())
+ || entityType.getBytes(StandardCharsets.UTF_8).length > 4096) {
+ throw new IllegalArgumentException("entityType must be non-blank, trimmed, and bounded");
+ }
+ if (id <= 0) {
+ throw new IllegalArgumentException("id must be positive");
+ }
+ if (version < 0) {
+ throw new IllegalArgumentException("version must not be negative");
+ }
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java
new file mode 100644
index 00000000..45987ace
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceDocumentScope.java
@@ -0,0 +1,28 @@
+package io.teaql.core.reference;
+
+import java.nio.charset.StandardCharsets;
+
+/** Purpose and Aggregate boundary for one UI/API document round trip. */
+public record ReferenceDocumentScope(
+ String documentId,
+ String purpose,
+ String aggregateType,
+ long aggregateId,
+ long aggregateRevision) {
+ public ReferenceDocumentScope {
+ requireText(documentId, "documentId");
+ requireText(purpose, "purpose");
+ requireText(aggregateType, "aggregateType");
+ if (aggregateId <= 0) throw new IllegalArgumentException("aggregateId must be positive");
+ if (aggregateRevision < 0) {
+ throw new IllegalArgumentException("aggregateRevision must not be negative");
+ }
+ }
+
+ private static void requireText(String value, String name) {
+ if (value == null || value.isBlank() || !value.equals(value.trim())
+ || value.getBytes(StandardCharsets.UTF_8).length > 4096) {
+ throw new IllegalArgumentException(name + " must be non-blank and trimmed");
+ }
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java
new file mode 100644
index 00000000..c9429669
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceMode.java
@@ -0,0 +1,6 @@
+package io.teaql.core.reference;
+
+public enum ReferenceMode {
+ GOVERNED,
+ RAW
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java
new file mode 100644
index 00000000..886a887c
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/ReferenceWireCodec.java
@@ -0,0 +1,49 @@
+package io.teaql.core.reference;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.Map;
+
+/** Exact boundary wire shapes: a governed token string or raw diagnostic {id, version}. */
+public final class ReferenceWireCodec {
+ private ReferenceWireCodec() {}
+
+ public static Object serialize(ExternalEntityReference reference) {
+ if (reference instanceof ExternalEntityReference.Governed governed) {
+ return governed.token();
+ }
+ ExternalEntityReference.Raw raw = (ExternalEntityReference.Raw) reference;
+ return Map.of("id", raw.id(), "version", raw.version());
+ }
+
+ public static ExternalEntityReference deserialize(Object wire, ReferenceMode mode) {
+ if (mode == ReferenceMode.GOVERNED && wire instanceof String token && token.startsWith("tqr1.")) {
+ return new ExternalEntityReference.Governed(token);
+ }
+ if (mode == ReferenceMode.RAW && wire instanceof Map, ?> map && map.size() == 2) {
+ Object id = map.get("id");
+ Object version = map.get("version");
+ Long exactId = exactLong(id);
+ Long exactVersion = exactLong(version);
+ if (exactId != null && exactVersion != null) {
+ return new ExternalEntityReference.Raw(exactId, exactVersion);
+ }
+ }
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.INVALID_REFERENCE, "ROUND_TRIP_REFERENCE_INVALID");
+ }
+
+ private static Long exactLong(Object value) {
+ try {
+ if (value instanceof Byte || value instanceof Short
+ || value instanceof Integer || value instanceof Long) {
+ return ((Number) value).longValue();
+ }
+ if (value instanceof BigInteger integer) return integer.longValueExact();
+ if (value instanceof BigDecimal decimal) return decimal.longValueExact();
+ return null;
+ } catch (ArithmeticException ignored) {
+ return null;
+ }
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java
new file mode 100644
index 00000000..a9c202ff
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/ResolvedRoundTripReference.java
@@ -0,0 +1,15 @@
+package io.teaql.core.reference;
+
+import java.time.Instant;
+
+/** Decoded identity after scope binding and current authorization have both succeeded. */
+public record ResolvedRoundTripReference(
+ InternalEntityIdentity identity,
+ ReferenceMode mode,
+ String keyId,
+ Instant issuedAt,
+ Instant expiresAt) {
+ public String entityType() { return identity.entityType(); }
+ public long id() { return identity.id(); }
+ public long version() { return identity.version(); }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java
new file mode 100644
index 00000000..55a5695b
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceCodec.java
@@ -0,0 +1,53 @@
+package io.teaql.core.reference;
+
+import io.teaql.core.Entity;
+import io.teaql.core.UserContext;
+import java.time.Duration;
+
+/**
+ * Framework-neutral serialization boundary. Spring, JAX-RS and other Web
+ * adapters call this codec; cryptographic behavior does not live in a Web layer.
+ */
+public final class RoundTripReferenceCodec {
+ private final RoundTripReferenceProvider provider;
+
+ public RoundTripReferenceCodec(RoundTripReferenceProvider provider) {
+ if (provider == null) throw new IllegalArgumentException("provider must not be null");
+ this.provider = provider;
+ }
+
+ public Object serialize(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ Entity entity,
+ Duration lifetime) {
+ if (entity == null || entity.getId() == null || entity.getVersion() == null) {
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.INVALID_ENTITY_IDENTITY,
+ "A round-trip reference requires a persisted entity with id and version");
+ }
+ return ReferenceWireCodec.serialize(provider.issue(
+ context,
+ principal,
+ scope,
+ new InternalEntityIdentity(entity.typeName(), entity.getId(), entity.getVersion()),
+ lifetime));
+ }
+
+ public ResolvedRoundTripReference deserialize(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ Object reference,
+ String expectedEntityType) {
+ return provider.resolve(
+ context,
+ principal,
+ scope,
+ ReferenceWireCodec.deserialize(reference, provider.mode()),
+ expectedEntityType);
+ }
+
+ public ReferenceMode mode() { return provider.mode(); }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java
new file mode 100644
index 00000000..8d5f4d62
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceErrorCode.java
@@ -0,0 +1,14 @@
+package io.teaql.core.reference;
+
+public enum RoundTripReferenceErrorCode {
+ PROVIDER_NOT_CONFIGURED,
+ CONTEXT_BINDING_REQUIRED,
+ INVALID_REFERENCE,
+ EXPIRED,
+ SCOPE_MISMATCH,
+ AUTHORIZATION_REQUIRED,
+ CONFIGURATION_INVALID,
+ CONTEXT_MISMATCH,
+ TYPE_MISMATCH,
+ INVALID_ENTITY_IDENTITY
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java
new file mode 100644
index 00000000..64e9735a
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceException.java
@@ -0,0 +1,20 @@
+package io.teaql.core.reference;
+
+public class RoundTripReferenceException extends RuntimeException {
+ private final RoundTripReferenceErrorCode code;
+
+ public RoundTripReferenceException(RoundTripReferenceErrorCode code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ public RoundTripReferenceException(
+ RoundTripReferenceErrorCode code, String message, Throwable cause) {
+ super(message, cause);
+ this.code = code;
+ }
+
+ public RoundTripReferenceErrorCode getCode() {
+ return code;
+ }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java
new file mode 100644
index 00000000..df243a3c
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/RoundTripReferenceProvider.java
@@ -0,0 +1,23 @@
+package io.teaql.core.reference;
+
+import io.teaql.core.UserContext;
+import java.time.Duration;
+
+/** Provider SPI; implementations must authenticate, not merely obscure, identities. */
+public interface RoundTripReferenceProvider {
+ ExternalEntityReference issue(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ InternalEntityIdentity identity,
+ Duration lifetime);
+
+ ResolvedRoundTripReference resolve(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ ExternalEntityReference reference,
+ String expectedEntityType);
+
+ ReferenceMode mode();
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java b/teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java
new file mode 100644
index 00000000..a1295504
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/reference/TrustedReferencePrincipal.java
@@ -0,0 +1,24 @@
+package io.teaql.core.reference;
+
+import java.nio.charset.StandardCharsets;
+
+/** Trusted server-side identity used to bind an external reference to its current actor. */
+public record TrustedReferencePrincipal(
+ String authenticationRealm,
+ String subject,
+ String domainRootType,
+ long domainRootId) {
+ public TrustedReferencePrincipal {
+ requireText(authenticationRealm, "authenticationRealm");
+ requireText(subject, "subject");
+ requireText(domainRootType, "domainRootType");
+ if (domainRootId <= 0) throw new IllegalArgumentException("domainRootId must be positive");
+ }
+
+ private static void requireText(String value, String name) {
+ if (value == null || value.isBlank() || !value.equals(value.trim())
+ || value.getBytes(StandardCharsets.UTF_8).length > 4096) {
+ throw new IllegalArgumentException(name + " must be non-blank and trimmed");
+ }
+ }
+}
diff --git a/teaql-core/src/main/java/module-info.java b/teaql-core/src/main/java/module-info.java
index 8f95aca6..e4f8640d 100644
--- a/teaql-core/src/main/java/module-info.java
+++ b/teaql-core/src/main/java/module-info.java
@@ -13,6 +13,7 @@
exports io.teaql.core.value;
exports io.teaql.core.spi;
exports io.teaql.core.businessid;
+ exports io.teaql.core.reference;
uses io.teaql.core.spi.ContextAssembler;
uses io.teaql.core.spi.RemoteCacheProvider;
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java
index c05e308e..d0cae95d 100644
--- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java
@@ -19,6 +19,7 @@
import io.teaql.core.businessid.BusinessIdService;
import io.teaql.runtime.businessid.DefaultBusinessIdProfileFactory;
import io.teaql.runtime.businessid.SystemBusinessClock;
+import io.teaql.core.reference.RoundTripReferenceCodec;
public class DefaultUserContext implements UserContext, OptNullBasicTypeFromObjectGetter {
@@ -179,6 +180,9 @@ public T capability(Class capabilityType) {
if (value == null && capabilityType == BusinessIdSchemaContributor.class) {
value = runtime.getBusinessIdSchemaContributor();
}
+ if (value == null && capabilityType == RoundTripReferenceCodec.class) {
+ value = runtime.getRoundTripReferenceCodec();
+ }
if (value == null) {
return null;
}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java
index c2bf5748..528302ef 100644
--- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java
@@ -11,6 +11,8 @@
import io.teaql.core.businessid.BusinessIdSchemaContributor;
import io.teaql.core.businessid.BusinessIdService;
import io.teaql.runtime.businessid.DefaultBusinessIdService;
+import io.teaql.core.reference.RoundTripReferenceCodec;
+import io.teaql.core.reference.RoundTripReferenceProvider;
import java.util.*;
public class TeaQLRuntime {
@@ -25,6 +27,7 @@ public class TeaQLRuntime {
private final SchemaExecutor schemaExecutor;
private final BusinessIdService businessIdService;
private final BusinessIdSchemaContributor businessIdSchemaContributor;
+ private final RoundTripReferenceCodec roundTripReferenceCodec;
private final Map> checkers = new java.util.concurrent.ConcurrentHashMap<>();
private final List generatedBootstraps =
new java.util.concurrent.CopyOnWriteArrayList<>();
@@ -41,6 +44,9 @@ private TeaQLRuntime(Builder builder) {
this.schemaExecutor = builder.schemaExecutor;
this.businessIdService = builder.businessIdService;
this.businessIdSchemaContributor = builder.businessIdSchemaContributor;
+ this.roundTripReferenceCodec = builder.roundTripReferenceProvider == null
+ ? null
+ : new RoundTripReferenceCodec(builder.roundTripReferenceProvider);
}
public static Builder builder() {
@@ -95,6 +101,10 @@ BusinessIdSchemaContributor getBusinessIdSchemaContributor() {
return businessIdSchemaContributor;
}
+ RoundTripReferenceCodec getRoundTripReferenceCodec() {
+ return roundTripReferenceCodec;
+ }
+
/** Installs a passive generated manifest. Database schemas remain unchanged. */
public TeaQLRuntime install(RuntimeModule module) {
module.install(metadata);
@@ -921,6 +931,7 @@ public static class Builder {
private SchemaExecutor schemaExecutor;
private BusinessIdService businessIdService;
private BusinessIdSchemaContributor businessIdSchemaContributor;
+ private RoundTripReferenceProvider roundTripReferenceProvider;
public Builder metadata(EntityMetaFactory metadata) {
this.metadata = metadata;
@@ -971,6 +982,12 @@ Builder businessIdInfrastructure(A allocator) {
return this;
}
+ /** Installs context-bound identity serialization without coupling it to a Web framework. */
+ public Builder roundTripReferenceProvider(RoundTripReferenceProvider provider) {
+ this.roundTripReferenceProvider = java.util.Objects.requireNonNull(provider, "provider");
+ return this;
+ }
+
public Builder logSink(RuntimeLogSink logSink) {
this.logSink = logSink;
return this;
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java
new file mode 100644
index 00000000..1abff135
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/AeadRoundTripReferenceProvider.java
@@ -0,0 +1,387 @@
+package io.teaql.runtime.reference;
+
+import io.teaql.core.UserContext;
+import io.teaql.core.reference.ExternalEntityReference;
+import io.teaql.core.reference.InternalEntityIdentity;
+import io.teaql.core.reference.ReferenceDocumentScope;
+import io.teaql.core.reference.ReferenceMode;
+import io.teaql.core.reference.ResolvedRoundTripReference;
+import io.teaql.core.reference.RoundTripReferenceErrorCode;
+import io.teaql.core.reference.RoundTripReferenceException;
+import io.teaql.core.reference.RoundTripReferenceProvider;
+import io.teaql.core.reference.TrustedReferencePrincipal;
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Supplier;
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Context-bound AES-256-GCM implementation shared with TeaQL Rust's tqr1 format. */
+public final class AeadRoundTripReferenceProvider implements RoundTripReferenceProvider {
+ private static final String PREFIX = "tqr1";
+ private static final byte[] HKDF_SALT = "teaql.round-trip-reference.v1".getBytes(StandardCharsets.US_ASCII);
+ private static final SecureRandom RANDOM = new SecureRandom();
+ private static final int MAX_TEXT_BYTES = 4096;
+ private static final int MAX_TOKEN_LENGTH = 24000;
+
+ private final DeploymentProfile profile;
+ private final ReferenceMode mode;
+ private final String service;
+ private final String environment;
+ private final RoundTripReferenceKeyProvider keys;
+ private final RoundTripReferenceAuthorizationPolicy authorization;
+ private Clock clock;
+ private Supplier nonceSource;
+
+ private AeadRoundTripReferenceProvider(
+ DeploymentProfile profile,
+ ReferenceMode mode,
+ String service,
+ String environment,
+ RoundTripReferenceKeyProvider keys,
+ RoundTripReferenceAuthorizationPolicy authorization) {
+ this.profile = Objects.requireNonNull(profile, "profile");
+ this.mode = Objects.requireNonNull(mode, "mode");
+ this.service = requireText(service);
+ this.environment = requireText(environment);
+ this.keys = Objects.requireNonNull(keys, "keys");
+ this.authorization = Objects.requireNonNull(authorization, "authorization");
+ requireKey(keys.currentKey());
+ this.clock = Clock.systemUTC();
+ this.nonceSource = () -> {
+ byte[] nonce = new byte[12];
+ RANDOM.nextBytes(nonce);
+ return nonce;
+ };
+ }
+
+ public static AeadRoundTripReferenceProvider governed(
+ DeploymentProfile profile,
+ String service,
+ String environment,
+ RoundTripReferenceKeyProvider keys,
+ RoundTripReferenceAuthorizationPolicy authorization) {
+ return create(profile, service, environment, keys, authorization, null);
+ }
+
+ public static AeadRoundTripReferenceProvider fromProcessEnvironment(
+ DeploymentProfile profile,
+ String service,
+ String environment,
+ RoundTripReferenceKeyProvider keys,
+ RoundTripReferenceAuthorizationPolicy authorization) {
+ AeadRoundTripReferenceProvider provider = create(
+ profile, service, environment, keys, authorization,
+ System.getenv(RoundTripReferenceConfiguration.RAW_ID_ENV));
+ provider.startupNotice().ifPresent(notice -> System.err.printf(
+ "%s: %s; deployment_profile=%s; %s=%s%n",
+ notice.level(), notice.message(), notice.deploymentProfile(),
+ notice.telemetryKey(), notice.telemetryValue()));
+ return provider;
+ }
+
+ static AeadRoundTripReferenceProvider create(
+ DeploymentProfile profile,
+ String service,
+ String environment,
+ RoundTripReferenceKeyProvider keys,
+ RoundTripReferenceAuthorizationPolicy authorization,
+ String acknowledgement) {
+ boolean raw = RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT.equals(acknowledgement);
+ if (raw && profile == DeploymentProfile.PRODUCTION) {
+ throw configuration("raw entity references cannot be enabled in production", null);
+ }
+ return new AeadRoundTripReferenceProvider(
+ profile, raw ? ReferenceMode.RAW : ReferenceMode.GOVERNED,
+ service, environment, keys, authorization);
+ }
+
+ AeadRoundTripReferenceProvider withClock(Clock clock) {
+ this.clock = Objects.requireNonNull(clock, "clock");
+ return this;
+ }
+
+ AeadRoundTripReferenceProvider withNonceSource(Supplier nonceSource) {
+ this.nonceSource = Objects.requireNonNull(nonceSource, "nonceSource");
+ return this;
+ }
+
+ public Optional startupNotice() {
+ if (mode != ReferenceMode.RAW) return Optional.empty();
+ return Optional.of(new ReferenceStartupNotice(
+ "ERROR",
+ "TeaQL raw internal entity reference mode is active for local diagnostics",
+ profile.label(),
+ "teaql.reference.mode",
+ "raw",
+ Map.of("TeaQL-Reference-Mode", "raw-internal-id", "Cache-Control", "no-store")));
+ }
+
+ @Override
+ public ReferenceMode mode() { return mode; }
+
+ @Override
+ public ExternalEntityReference issue(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ InternalEntityIdentity identity,
+ Duration lifetime) {
+ validate(principal, scope, identity);
+ if (lifetime == null || lifetime.isZero() || lifetime.isNegative()) throw invalid(null);
+ authorization.authorize(context, principal, scope, identity);
+ if (mode == ReferenceMode.RAW) {
+ return new ExternalEntityReference.Raw(identity.id(), identity.version());
+ }
+ try {
+ RoundTripReferenceKey key = requireKey(keys.currentKey());
+ Instant issuedAt = clock.instant();
+ Instant expiresAt = issuedAt.plus(lifetime);
+ byte[] fingerprint = actorFingerprint(key, principal);
+ byte[] plaintext = encodePayload(identity, scope, fingerprint, issuedAt, expiresAt);
+ byte[] nonce = nonceSource.get();
+ if (nonce == null || nonce.length != 12) throw configuration("nonce must contain 12 bytes", null);
+ Cipher cipher = cipher(Cipher.ENCRYPT_MODE, key, nonce, scope.purpose());
+ byte[] encrypted = cipher.doFinal(plaintext);
+ byte[] envelope = ByteBuffer.allocate(nonce.length + encrypted.length)
+ .put(nonce).put(encrypted).array();
+ return new ExternalEntityReference.Governed(
+ PREFIX + "." + key.keyId() + "."
+ + Base64.getUrlEncoder().withoutPadding().encodeToString(envelope));
+ } catch (RoundTripReferenceException error) {
+ throw error;
+ } catch (Exception error) {
+ throw invalid(error);
+ }
+ }
+
+ @Override
+ public ResolvedRoundTripReference resolve(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ ExternalEntityReference reference,
+ String expectedEntityType) {
+ requireText(expectedEntityType);
+ ResolvedRoundTripReference resolved;
+ if (mode == ReferenceMode.RAW && reference instanceof ExternalEntityReference.Raw raw) {
+ resolved = new ResolvedRoundTripReference(
+ new InternalEntityIdentity(expectedEntityType, raw.id(), raw.version()),
+ ReferenceMode.RAW, null, null, null);
+ } else if (mode == ReferenceMode.GOVERNED
+ && reference instanceof ExternalEntityReference.Governed governed) {
+ resolved = resolveGoverned(principal, scope, governed.token(), expectedEntityType);
+ } else {
+ throw invalid(null);
+ }
+ authorization.authorize(context, principal, scope, resolved.identity());
+ return resolved;
+ }
+
+ private ResolvedRoundTripReference resolveGoverned(
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ String token,
+ String expectedEntityType) {
+ try {
+ String[] parts = token.split("\\.", -1);
+ if (token.length() > MAX_TOKEN_LENGTH || parts.length != 3 || !PREFIX.equals(parts[0])) {
+ throw invalid(null);
+ }
+ RoundTripReferenceKey key = keys.keyById(parts[1]);
+ if (key == null) throw invalid(null);
+ byte[] envelope = Base64.getUrlDecoder().decode(parts[2]);
+ if (envelope.length < 28) throw invalid(null);
+ byte[] nonce = Arrays.copyOfRange(envelope, 0, 12);
+ byte[] encrypted = Arrays.copyOfRange(envelope, 12, envelope.length);
+ DecodedPayload payload = decodePayload(
+ cipher(Cipher.DECRYPT_MODE, key, nonce, scope.purpose()).doFinal(encrypted));
+ Instant now = clock.instant();
+ if (!payload.expiresAt().isAfter(now)) {
+ throw new RoundTripReferenceException(RoundTripReferenceErrorCode.EXPIRED,
+ "ROUND_TRIP_REFERENCE_EXPIRED");
+ }
+ if (payload.issuedAt().isAfter(now.plusSeconds(60))) throw invalid(null);
+ byte[] expectedActor = actorFingerprint(key, principal);
+ if (!MessageDigest.isEqual(payload.actorFingerprint(), expectedActor)
+ || !payload.documentId().equals(scope.documentId())
+ || !payload.aggregateType().equals(scope.aggregateType())
+ || payload.aggregateId() != scope.aggregateId()
+ || payload.aggregateRevision() != scope.aggregateRevision()
+ || !payload.identity().entityType().equals(expectedEntityType)) {
+ throw new RoundTripReferenceException(RoundTripReferenceErrorCode.SCOPE_MISMATCH,
+ "ROUND_TRIP_REFERENCE_SCOPE_MISMATCH");
+ }
+ return new ResolvedRoundTripReference(
+ payload.identity(), ReferenceMode.GOVERNED, key.keyId(),
+ payload.issuedAt(), payload.expiresAt());
+ } catch (RoundTripReferenceException error) {
+ throw error;
+ } catch (Exception error) {
+ throw invalid(error);
+ }
+ }
+
+ private Cipher cipher(int mode, RoundTripReferenceKey key, byte[] nonce, String purpose)
+ throws GeneralSecurityException {
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(mode, new SecretKeySpec(deriveKey(key, "entity-reference-encryption"), "AES"),
+ new GCMParameterSpec(128, nonce));
+ cipher.updateAAD(encodeTexts(PREFIX, key.keyId(), service, environment, purpose));
+ return cipher;
+ }
+
+ private byte[] actorFingerprint(
+ RoundTripReferenceKey key, TrustedReferencePrincipal principal)
+ throws GeneralSecurityException {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(deriveKey(key, "actor-fingerprint"), "HmacSHA256"));
+ ByteArrayOutputStream canonical = new ByteArrayOutputStream();
+ writeText(canonical, principal.authenticationRealm());
+ writeText(canonical, principal.subject());
+ writeText(canonical, principal.domainRootType());
+ canonical.writeBytes(ByteBuffer.allocate(8).putLong(principal.domainRootId()).array());
+ return mac.doFinal(canonical.toByteArray());
+ }
+
+ private byte[] deriveKey(RoundTripReferenceKey key, String label)
+ throws GeneralSecurityException {
+ byte[] info = encodeTexts(label, service, environment, key.keyId());
+ Mac extract = Mac.getInstance("HmacSHA256");
+ extract.init(new SecretKeySpec(HKDF_SALT, "HmacSHA256"));
+ byte[] pseudoRandomKey = extract.doFinal(key.keyBytes());
+ Mac expand = Mac.getInstance("HmacSHA256");
+ expand.init(new SecretKeySpec(pseudoRandomKey, "HmacSHA256"));
+ expand.update(info);
+ expand.update((byte) 1);
+ return expand.doFinal();
+ }
+
+ private static byte[] encodePayload(
+ InternalEntityIdentity identity,
+ ReferenceDocumentScope scope,
+ byte[] fingerprint,
+ Instant issuedAt,
+ Instant expiresAt) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ output.writeBytes(fingerprint);
+ writeText(output, identity.entityType());
+ output.writeBytes(ByteBuffer.allocate(16).putLong(identity.id()).putLong(identity.version()).array());
+ writeText(output, scope.documentId());
+ writeText(output, scope.aggregateType());
+ output.writeBytes(ByteBuffer.allocate(32)
+ .putLong(scope.aggregateId()).putLong(scope.aggregateRevision())
+ .putLong(issuedAt.getEpochSecond()).putLong(expiresAt.getEpochSecond()).array());
+ return output.toByteArray();
+ }
+
+ private static DecodedPayload decodePayload(byte[] bytes) {
+ try {
+ ByteBuffer input = ByteBuffer.wrap(bytes);
+ byte[] fingerprint = new byte[32];
+ input.get(fingerprint);
+ String entityType = readText(input);
+ long id = input.getLong();
+ long version = input.getLong();
+ String documentId = readText(input);
+ String aggregateType = readText(input);
+ long aggregateId = input.getLong();
+ long revision = input.getLong();
+ Instant issuedAt = Instant.ofEpochSecond(input.getLong());
+ Instant expiresAt = Instant.ofEpochSecond(input.getLong());
+ if (input.hasRemaining()) throw invalid(null);
+ return new DecodedPayload(
+ fingerprint, new InternalEntityIdentity(entityType, id, version), documentId,
+ aggregateType, aggregateId, revision, issuedAt, expiresAt);
+ } catch (RoundTripReferenceException error) {
+ throw error;
+ } catch (Exception error) {
+ throw invalid(error);
+ }
+ }
+
+ private static byte[] encodeTexts(String... values) {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ for (String value : values) writeText(output, value);
+ return output.toByteArray();
+ }
+
+ private static void writeText(ByteArrayOutputStream output, String value) {
+ byte[] bytes = requireText(value).getBytes(StandardCharsets.UTF_8);
+ if (bytes.length > 0xffff) throw invalid(null);
+ 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(null);
+ int length = Short.toUnsignedInt(input.getShort());
+ if (input.remaining() < length) throw invalid(null);
+ byte[] bytes = new byte[length];
+ input.get(bytes);
+ return requireText(new String(bytes, StandardCharsets.UTF_8));
+ }
+
+ private static String requireText(String value) {
+ if (value == null || value.isEmpty() || !value.equals(value.trim())
+ || value.getBytes(StandardCharsets.UTF_8).length > MAX_TEXT_BYTES) {
+ throw invalid(null);
+ }
+ return value;
+ }
+
+ private static RoundTripReferenceKey requireKey(RoundTripReferenceKey key) {
+ if (key == null) throw configuration("current reference key is missing", null);
+ return key;
+ }
+
+ private static void validate(
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ InternalEntityIdentity identity) {
+ Objects.requireNonNull(principal, "principal");
+ Objects.requireNonNull(scope, "scope");
+ Objects.requireNonNull(identity, "identity");
+ }
+
+ private static RoundTripReferenceException invalid(Throwable cause) {
+ return cause == null
+ ? new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.INVALID_REFERENCE, "ROUND_TRIP_REFERENCE_INVALID")
+ : new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.INVALID_REFERENCE,
+ "ROUND_TRIP_REFERENCE_INVALID", cause);
+ }
+
+ private static RoundTripReferenceException configuration(String message, Throwable cause) {
+ return cause == null
+ ? new RoundTripReferenceException(RoundTripReferenceErrorCode.CONFIGURATION_INVALID, message)
+ : new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.CONFIGURATION_INVALID, message, cause);
+ }
+
+ private record DecodedPayload(
+ byte[] actorFingerprint,
+ InternalEntityIdentity identity,
+ String documentId,
+ String aggregateType,
+ long aggregateId,
+ long aggregateRevision,
+ Instant issuedAt,
+ Instant expiresAt) {}
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java
new file mode 100644
index 00000000..3a426ae2
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/DeploymentProfile.java
@@ -0,0 +1,11 @@
+package io.teaql.runtime.reference;
+
+public enum DeploymentProfile {
+ DEVELOPMENT("development"), TEST("test"), PRODUCTION("production");
+
+ private final String label;
+
+ DeploymentProfile(String label) { this.label = label; }
+
+ public String label() { return label; }
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java
new file mode 100644
index 00000000..39b922d8
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/ReferenceStartupNotice.java
@@ -0,0 +1,11 @@
+package io.teaql.runtime.reference;
+
+import java.util.Map;
+
+public record ReferenceStartupNotice(
+ String level,
+ String message,
+ String deploymentProfile,
+ String telemetryKey,
+ String telemetryValue,
+ Map responseHeaders) {}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java
new file mode 100644
index 00000000..0727fa9b
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceAuthorizationPolicy.java
@@ -0,0 +1,16 @@
+package io.teaql.runtime.reference;
+
+import io.teaql.core.UserContext;
+import io.teaql.core.reference.InternalEntityIdentity;
+import io.teaql.core.reference.ReferenceDocumentScope;
+import io.teaql.core.reference.TrustedReferencePrincipal;
+
+/** Re-evaluated at issue and consume time; a valid token is never an authorization grant. */
+@FunctionalInterface
+public interface RoundTripReferenceAuthorizationPolicy {
+ void authorize(
+ UserContext context,
+ TrustedReferencePrincipal principal,
+ ReferenceDocumentScope scope,
+ InternalEntityIdentity identity);
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java
new file mode 100644
index 00000000..3d594107
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceConfiguration.java
@@ -0,0 +1,10 @@
+package io.teaql.runtime.reference;
+
+public final class RoundTripReferenceConfiguration {
+ public static final String RAW_ID_ENV = "TEAQL_UNSAFE_EXPOSE_RAW_ENTITY_IDS";
+ public static final String RAW_ID_ACKNOWLEDGEMENT =
+ "I_UNDERSTAND_THIS_EXPOSES_INTERNAL_ENTITY_IDS_FOR_LOCAL_DEBUGGING_ONLY";
+
+ private RoundTripReferenceConfiguration() {}
+
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java
new file mode 100644
index 00000000..b516e6e3
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKey.java
@@ -0,0 +1,28 @@
+package io.teaql.runtime.reference;
+
+import java.util.Arrays;
+
+/** AES key material used only by the runtime's default reference provider. */
+public final class RoundTripReferenceKey {
+ private final String keyId;
+ private final byte[] keyBytes;
+
+ public RoundTripReferenceKey(String keyId, byte[] keyBytes) {
+ if (keyId == null || !keyId.matches("[A-Za-z0-9_-]{1,64}")) {
+ throw new IllegalArgumentException("keyId must be 1-64 URL-safe characters");
+ }
+ if (keyBytes == null || keyBytes.length != 32) {
+ throw new IllegalArgumentException("AES-256 key must contain exactly 32 bytes");
+ }
+ this.keyId = keyId;
+ this.keyBytes = Arrays.copyOf(keyBytes, keyBytes.length);
+ }
+
+ public String keyId() {
+ return keyId;
+ }
+
+ public byte[] keyBytes() {
+ return Arrays.copyOf(keyBytes, keyBytes.length);
+ }
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java
new file mode 100644
index 00000000..0227265e
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/RoundTripReferenceKeyProvider.java
@@ -0,0 +1,8 @@
+package io.teaql.runtime.reference;
+
+/** Supplies one current encryption key and retained decode-only rotation keys. */
+public interface RoundTripReferenceKeyProvider {
+ RoundTripReferenceKey currentKey();
+
+ RoundTripReferenceKey keyById(String keyId);
+}
diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java
new file mode 100644
index 00000000..2f7ee7a7
--- /dev/null
+++ b/teaql-runtime/src/main/java/io/teaql/runtime/reference/StaticRoundTripReferenceKeyProvider.java
@@ -0,0 +1,32 @@
+package io.teaql.runtime.reference;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** In-memory key ring for configuration adapters, tests, and examples. */
+public final class StaticRoundTripReferenceKeyProvider implements RoundTripReferenceKeyProvider {
+ private final RoundTripReferenceKey current;
+ private final Map keys;
+
+ public StaticRoundTripReferenceKeyProvider(
+ RoundTripReferenceKey current, RoundTripReferenceKey... decodeOnlyKeys) {
+ if (current == null) throw new IllegalArgumentException("current key must not be null");
+ this.current = current;
+ Map configured = new LinkedHashMap<>();
+ configured.put(current.keyId(), current);
+ if (decodeOnlyKeys != null) {
+ for (RoundTripReferenceKey key : decodeOnlyKeys) {
+ if (key == null || configured.put(key.keyId(), key) != null) {
+ throw new IllegalArgumentException("duplicate or null reference key");
+ }
+ }
+ }
+ this.keys = Map.copyOf(configured);
+ }
+
+ @Override
+ public RoundTripReferenceKey currentKey() { return current; }
+
+ @Override
+ public RoundTripReferenceKey keyById(String keyId) { return keys.get(keyId); }
+}
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
deleted file mode 100644
index c6854a1a..00000000
--- a/teaql-runtime/src/main/java/io/teaql/runtime/security/AeadEntityReferenceCodec.java
+++ /dev/null
@@ -1,120 +0,0 @@
-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/main/java/module-info.java b/teaql-runtime/src/main/java/module-info.java
index 0838c869..0acd8b13 100644
--- a/teaql-runtime/src/main/java/module-info.java
+++ b/teaql-runtime/src/main/java/module-info.java
@@ -6,6 +6,7 @@
exports io.teaql.runtime.memory;
exports io.teaql.runtime.boot;
exports io.teaql.runtime.businessid;
+ exports io.teaql.runtime.reference;
provides io.teaql.core.spi.ContextAssembler with io.teaql.runtime.boot.CoreAssembler;
}
diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java
new file mode 100644
index 00000000..ae29d80b
--- /dev/null
+++ b/teaql-runtime/src/test/java/io/teaql/runtime/reference/ContextBoundRoundTripReferenceTest.java
@@ -0,0 +1,257 @@
+package io.teaql.runtime.reference;
+
+import io.teaql.core.BaseEntity;
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.reference.ReferenceDocumentScope;
+import io.teaql.core.reference.ReferenceMode;
+import io.teaql.core.reference.RoundTripReferenceErrorCode;
+import io.teaql.core.reference.RoundTripReferenceException;
+import io.teaql.core.reference.TrustedReferencePrincipal;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+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 java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ContextBoundRoundTripReferenceTest {
+ private static final Instant NOW = Instant.parse("2026-09-27T00:00:00Z");
+ private static final String GOLDEN = "tqr1.k2.MzMzMzMzMzMzMzMzmhCET15vBdSc1BbFJcrvMMnRP1wAyjunShYlFPnDZL7Gk5V1LuptWyrbiQ8LQSDHFUmN3o6vb-MEgXZuh3N-YkeO55CYLeVgiyBBYslzuSu-fvWC2KMZcnZ5D8BRmWpGYA2Ok5sWe1Fg1aIyadTjVoKB-H2vPW_0okj-";
+
+ private static final class OrderItem extends BaseEntity {
+ OrderItem(long id, long version) {
+ __internalSet("id", id);
+ __internalSet("version", version);
+ }
+
+ @Override public String typeName() { return "OrderItem"; }
+ }
+
+ @Test
+ public void governedVectorMatchesRustAndBindsActorDocumentAggregateAndType() {
+ AeadRoundTripReferenceProvider provider = provider(DeploymentProfile.TEST, "test-a", null, null);
+ UserContext alice = context(provider, "alice");
+ Object reference = alice.referenceFor(new OrderItem(42, 3), scope(), Duration.ofSeconds(600));
+
+ Assert.assertEquals(GOLDEN, reference);
+ Assert.assertFalse(reference.toString().contains("OrderItem"));
+ Assert.assertEquals(42, alice.resolveReference(reference, scope(), "OrderItem").id());
+ Assert.assertEquals(ReferenceMode.GOVERNED,
+ alice.resolveReference(reference, scope(), "OrderItem").mode());
+
+ assertCode(RoundTripReferenceErrorCode.SCOPE_MISMATCH,
+ () -> context(provider, "bob").resolveReference(reference, scope(), "OrderItem"));
+ assertCode(RoundTripReferenceErrorCode.SCOPE_MISMATCH,
+ () -> alice.resolveReference(reference,
+ new ReferenceDocumentScope("doc-101", "edit-order", "Order", 100, 9),
+ "OrderItem"));
+ assertCode(RoundTripReferenceErrorCode.SCOPE_MISMATCH,
+ () -> alice.resolveReference(reference, scope(), "InvoiceItem"));
+ }
+
+ @Test
+ public void expiryEnvironmentRotationTamperingAndCurrentAuthorizationFailClosed() {
+ AtomicInteger authorizations = new AtomicInteger();
+ AeadRoundTripReferenceProvider provider = provider(
+ DeploymentProfile.TEST, "test-a", null,
+ (context, principal, scope, identity) -> {
+ authorizations.incrementAndGet();
+ if (!"alice".equals(principal.subject())) {
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED,
+ "ROUND_TRIP_REFERENCE_AUTHORIZATION_REQUIRED");
+ }
+ });
+ UserContext alice = context(provider, "alice");
+ Object reference = alice.referenceFor(new OrderItem(42, 3), scope(), Duration.ofSeconds(600));
+ alice.resolveReference(reference, scope(), "OrderItem");
+ Assert.assertEquals("authorization must run at issue and consume", 2, authorizations.get());
+
+ String token = reference.toString();
+ String tampered = token.substring(0, token.length() - 1)
+ + (token.endsWith("A") ? "B" : "A");
+ assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE,
+ () -> alice.resolveReference(tampered, scope(), "OrderItem"));
+ assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE,
+ () -> context(provider(DeploymentProfile.TEST, "test-b", null, null), "alice")
+ .resolveReference(reference, scope(), "OrderItem"));
+
+ provider.withClock(Clock.fixed(NOW.plusSeconds(601), ZoneOffset.UTC));
+ assertCode(RoundTripReferenceErrorCode.EXPIRED,
+ () -> alice.resolveReference(reference, scope(), "OrderItem"));
+
+ AeadRoundTripReferenceProvider old = provider(DeploymentProfile.TEST, "test-a", "k1", null);
+ UserContext beforeRotation = context(old, "alice");
+ Object oldReference = beforeRotation.referenceFor(
+ new OrderItem(42, 3), scope(), Duration.ofSeconds(600));
+ Assert.assertEquals(42,
+ context(provider(DeploymentProfile.TEST, "test-a", null, null), "alice")
+ .resolveReference(oldReference, scope(), "OrderItem").id());
+ }
+
+ @Test
+ public void freshNoncesMakeRepeatedReferencesUnlinkable() {
+ AtomicInteger nonce = new AtomicInteger(1);
+ AeadRoundTripReferenceProvider provider = provider(
+ DeploymentProfile.TEST, "test-a", null, null)
+ .withNonceSource(() -> filledNonce(nonce.getAndIncrement()));
+ UserContext context = context(provider, "alice");
+ Object first = context.referenceFor(new OrderItem(42, 3), scope(), Duration.ofMinutes(10));
+ Object second = context.referenceFor(new OrderItem(42, 3), scope(), Duration.ofMinutes(10));
+ Assert.assertNotEquals(first, second);
+ Assert.assertEquals(42, context.resolveReference(first, scope(), "OrderItem").id());
+ Assert.assertEquals(42, context.resolveReference(second, scope(), "OrderItem").id());
+ }
+
+ @Test
+ public void raw01UnsetUsesGovernedReferences() {
+ Assert.assertEquals(ReferenceMode.GOVERNED,
+ provider(DeploymentProfile.TEST, "test-a", null, null).mode());
+ }
+
+ @Test
+ public void raw02ExactAcknowledgementEnablesDevelopmentRawMode() {
+ Assert.assertEquals(ReferenceMode.RAW, raw(DeploymentProfile.DEVELOPMENT, null).mode());
+ }
+
+ @Test
+ public void raw03ExactAcknowledgementEnablesTestRawMode() {
+ Assert.assertEquals(ReferenceMode.RAW, raw(DeploymentProfile.TEST, null).mode());
+ }
+
+ @Test
+ public void raw04NearMatchDoesNotEnableRawMode() {
+ Assert.assertEquals(ReferenceMode.GOVERNED,
+ provider(DeploymentProfile.TEST, "test-a", "near-match", null).mode());
+ }
+
+ @Test
+ public void raw05ProductionFailsClosed() {
+ assertCode(RoundTripReferenceErrorCode.CONFIGURATION_INVALID,
+ () -> raw(DeploymentProfile.PRODUCTION, null));
+ }
+
+ @Test
+ public void raw06AnotherActorIsRejectedByCurrentAuthorization() {
+ AeadRoundTripReferenceProvider raw = raw(DeploymentProfile.TEST,
+ (context, principal, scope, identity) -> {
+ if (!"alice".equals(principal.subject())) {
+ throw new RoundTripReferenceException(
+ RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED,
+ "ROUND_TRIP_REFERENCE_AUTHORIZATION_REQUIRED");
+ }
+ });
+ Object wire = context(raw, "alice").referenceFor(
+ new OrderItem(42, 3), scope(), Duration.ofMinutes(10));
+ assertCode(RoundTripReferenceErrorCode.AUTHORIZATION_REQUIRED,
+ () -> context(raw, "bob").resolveReference(wire, scope(), "OrderItem"));
+ }
+
+ @Test
+ public void raw07ModeChangeRejectsOldWireShape() {
+ AeadRoundTripReferenceProvider raw = raw(DeploymentProfile.TEST, null);
+ assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE,
+ () -> context(raw, "alice").resolveReference(GOLDEN, scope(), "OrderItem"));
+ Object rawWire = context(raw, "alice").referenceFor(
+ new OrderItem(42, 3), scope(), Duration.ofMinutes(10));
+ assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE,
+ () -> context(provider(DeploymentProfile.TEST, "test-a", null, null), "alice")
+ .resolveReference(rawWire, scope(), "OrderItem"));
+ }
+
+ @Test
+ public void raw08RetainsVersionTypeAndAuthorizationGuards() {
+
+ AtomicInteger authorizations = new AtomicInteger();
+ AeadRoundTripReferenceProvider raw = raw(
+ DeploymentProfile.DEVELOPMENT,
+ (context, principal, scope, identity) -> authorizations.incrementAndGet());
+ UserContext context = context(raw, "alice");
+ Object wire = context.referenceFor(new OrderItem(42, 3), scope(), Duration.ofMinutes(10));
+ Assert.assertEquals(Map.of("id", 42L, "version", 3L), wire);
+ Assert.assertEquals(3, context.resolveReference(wire, scope(), "OrderItem").version());
+ Assert.assertEquals("OrderItem",
+ context.resolveReference(wire, scope(), "OrderItem").entityType());
+ Assert.assertEquals(3, authorizations.get());
+ assertCode(RoundTripReferenceErrorCode.INVALID_REFERENCE,
+ () -> context.resolveReference(
+ Map.of("id", 42.5, "version", 3), scope(), "OrderItem"));
+ }
+
+ @Test
+ public void raw09ExposesSafeDowngradeMetadataWithoutSecretMaterial() {
+ AeadRoundTripReferenceProvider raw = raw(DeploymentProfile.DEVELOPMENT, null);
+ ReferenceStartupNotice notice = raw.startupNotice().orElseThrow();
+ Assert.assertEquals("ERROR", notice.level());
+ Assert.assertEquals("raw", notice.telemetryValue());
+ Assert.assertEquals("raw-internal-id",
+ notice.responseHeaders().get("TeaQL-Reference-Mode"));
+ Assert.assertEquals("no-store", notice.responseHeaders().get("Cache-Control"));
+ Assert.assertFalse(notice.toString().contains("22".repeat(32)));
+ }
+
+ private static AeadRoundTripReferenceProvider raw(
+ DeploymentProfile profile, RoundTripReferenceAuthorizationPolicy policy) {
+ AeadRoundTripReferenceProvider raw = provider(
+ profile, "test-a", RoundTripReferenceConfiguration.RAW_ID_ACKNOWLEDGEMENT, policy);
+ Assert.assertEquals(ReferenceMode.RAW, raw.mode());
+ return raw;
+ }
+
+ private static AeadRoundTripReferenceProvider provider(
+ DeploymentProfile profile,
+ String environment,
+ String acknowledgement,
+ RoundTripReferenceAuthorizationPolicy policy) {
+ RoundTripReferenceKey current = new RoundTripReferenceKey(
+ "k1".equals(acknowledgement) ? "k1" : "k2",
+ filled("k1".equals(acknowledgement) ? 0x11 : 0x22));
+ StaticRoundTripReferenceKeyProvider keys = "k1".equals(acknowledgement)
+ ? new StaticRoundTripReferenceKeyProvider(current)
+ : new StaticRoundTripReferenceKeyProvider(
+ current, new RoundTripReferenceKey("k1", filled(0x11)));
+ return AeadRoundTripReferenceProvider.create(
+ profile, "order-service", environment, keys,
+ policy == null ? (context, principal, scope, identity) -> {} : policy,
+ "k1".equals(acknowledgement) ? null : acknowledgement)
+ .withClock(Clock.fixed(NOW, ZoneOffset.UTC))
+ .withNonceSource(() -> filledNonce(0x33));
+ }
+
+ private static UserContext context(AeadRoundTripReferenceProvider provider, String subject) {
+ TeaQLRuntime runtime = TeaQLRuntime.builder()
+ .metadata(new SimpleEntityMetaFactory())
+ .roundTripReferenceProvider(provider)
+ .build();
+ return new DefaultUserContext(runtime).withTrustedReferencePrincipal(
+ new TrustedReferencePrincipal("oidc", subject, "Platform", 7));
+ }
+
+ private static ReferenceDocumentScope scope() {
+ return new ReferenceDocumentScope("doc-100", "edit-order", "Order", 100, 9);
+ }
+
+ private static byte[] filled(int value) {
+ byte[] bytes = new byte[32];
+ Arrays.fill(bytes, (byte) value);
+ return bytes;
+ }
+
+ private static byte[] filledNonce(int value) {
+ byte[] bytes = new byte[12];
+ Arrays.fill(bytes, (byte) value);
+ return bytes;
+ }
+
+ private static void assertCode(RoundTripReferenceErrorCode code, Runnable action) {
+ RoundTripReferenceException error = Assert.assertThrows(
+ RoundTripReferenceException.class, action::run);
+ Assert.assertEquals(code, error.getCode());
+ }
+}
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
deleted file mode 100644
index 94ec2909..00000000
--- a/teaql-runtime/src/test/java/io/teaql/runtime/security/AeadEntityReferenceCodecTest.java
+++ /dev/null
@@ -1,63 +0,0 @@
-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());
- }
-}