diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedEventStore.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedEventStore.java
new file mode 100644
index 00000000..fb86b2d0
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedEventStore.java
@@ -0,0 +1,292 @@
+package io.teaql.core.sqlite;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.LongFunction;
+
+/**
+ * SQLite durability boundary for already-committed logical mutation facts.
+ *
+ * The caller must install the schema before use. Append and apply require the
+ * same active SQL transaction as business writes. This class deliberately does
+ * not infer mutations from a ledger, replay business logic, or expose a remote
+ * replication endpoint. Projection writers are trusted infrastructure code.
+ */
+public final class SqliteCommittedEventStore {
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private static final java.util.Set ENVELOPE_FIELDS = java.util.Set.of(
+ "protocol_version", "change_set_id", "source_runtime_id", "source_sequence",
+ "source_model_hash", "source_transaction_id", "committed_at", "audit", "mutations");
+ private final SqlExecutionAdapter sql;
+
+ public SqliteCommittedEventStore(SqlExecutionAdapter sql) {
+ this.sql = Objects.requireNonNull(sql, "sql");
+ }
+
+ public record OutboxRecord(String sourceRuntimeId, long sourceSequence,
+ String changeSetId, String modelHash, String payloadSha256, String payloadJson) {
+ @Override public String toString() {
+ return "OutboxRecord[sourceRuntimeId=" + sourceRuntimeId
+ + ", sourceSequence=" + sourceSequence + ", payload=]";
+ }
+ }
+
+ public record InboxOutcome(boolean duplicate, long checkpoint) {
+ public static InboxOutcome applied(long checkpoint) { return new InboxOutcome(false, checkpoint); }
+ public static InboxOutcome duplicate(long checkpoint) { return new InboxOutcome(true, checkpoint); }
+ }
+
+ @FunctionalInterface
+ interface TrustedProjectionWriter {
+ void apply(SqlExecutionAdapter transaction, JsonNode committedFacts);
+ }
+
+ public static final class CommittedEventException extends RuntimeException {
+ private final String code;
+
+ CommittedEventException(String code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ public String code() { return code; }
+ }
+
+ public void ensureSchema() {
+ SqliteCommittedObservation.ensureSchema(sql);
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_sequence ("
+ + "source_runtime_id TEXT PRIMARY KEY, "
+ + "current_sequence INTEGER NOT NULL CHECK(current_sequence >= 0))");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_outbox ("
+ + "source_runtime_id TEXT NOT NULL, "
+ + "source_sequence INTEGER NOT NULL CHECK(source_sequence > 0), "
+ + "change_set_id TEXT NOT NULL, model_hash TEXT NOT NULL, "
+ + "payload_sha256 TEXT NOT NULL, payload_json TEXT NOT NULL, "
+ + "PRIMARY KEY(source_runtime_id, source_sequence), "
+ + "UNIQUE(source_runtime_id, change_set_id))");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_inbox ("
+ + "source_runtime_id TEXT NOT NULL, change_set_id TEXT NOT NULL, "
+ + "source_sequence INTEGER NOT NULL CHECK(source_sequence > 0), "
+ + "payload_sha256 TEXT NOT NULL, PRIMARY KEY(source_runtime_id, change_set_id))");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_applied_payload ("
+ + "source_runtime_id TEXT NOT NULL, source_sequence INTEGER NOT NULL CHECK(source_sequence > 0), "
+ + "change_set_id TEXT NOT NULL, payload_json TEXT NOT NULL, "
+ + "PRIMARY KEY(source_runtime_id, source_sequence), "
+ + "UNIQUE(source_runtime_id, change_set_id))");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_checkpoint ("
+ + "source_runtime_id TEXT PRIMARY KEY, "
+ + "source_sequence INTEGER NOT NULL CHECK(source_sequence >= 0), "
+ + "model_hash TEXT NOT NULL)");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_delivery_cursor ("
+ + "source_runtime_id TEXT NOT NULL, target_id TEXT NOT NULL, "
+ + "acked_sequence INTEGER NOT NULL CHECK(acked_sequence >= 0), "
+ + "model_hash TEXT NOT NULL, PRIMARY KEY(source_runtime_id, target_id))");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_snapshot ("
+ + "source_runtime_id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, "
+ + "source_sequence INTEGER NOT NULL CHECK(source_sequence >= 0), model_hash TEXT NOT NULL, "
+ + "payload_sha256 TEXT NOT NULL, payload_json TEXT NOT NULL)");
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_snapshot_publication ("
+ + "source_runtime_id TEXT NOT NULL, target_id TEXT NOT NULL, snapshot_id TEXT NOT NULL, "
+ + "source_sequence INTEGER NOT NULL CHECK(source_sequence >= 0), model_hash TEXT NOT NULL, "
+ + "payload_sha256 TEXT NOT NULL, payload_json TEXT NOT NULL, "
+ + "acknowledged INTEGER NOT NULL CHECK(acknowledged IN (0,1)), "
+ + "PRIMARY KEY(source_runtime_id,target_id))");
+ }
+
+ /** Bounded publisher read of one durable, ordered source stream. */
+ public List outboxAfter(String sourceRuntimeId, long after, int limit) {
+ if (blank(sourceRuntimeId) || after < 0 || limit < 1 || limit > 1000) {
+ throw invalid("invalid source, position, or read limit");
+ }
+ return sql.queryForList("SELECT source_runtime_id, source_sequence, change_set_id, "
+ + "model_hash, payload_sha256, payload_json FROM teaql_event_outbox "
+ + "WHERE source_runtime_id = ? AND source_sequence > ? "
+ + "ORDER BY source_sequence ASC LIMIT ?", new Object[] {sourceRuntimeId, after, limit})
+ .stream().map(row -> new OutboxRecord((String) row.get("source_runtime_id"),
+ number(row.get("source_sequence")), (String) row.get("change_set_id"),
+ (String) row.get("model_hash"), (String) row.get("payload_sha256"),
+ (String) row.get("payload_json"))).toList();
+ }
+
+ /** Allocate and append within the caller's already-open business transaction. */
+ public OutboxRecord append(String sourceRuntimeId, String changeSetId, String modelHash,
+ LongFunction payloadBuilder) {
+ requireTransaction();
+ if (blank(sourceRuntimeId) || blank(changeSetId) || blank(modelHash) || payloadBuilder == null) {
+ throw invalid("missing committed-event identity or payload builder");
+ }
+ return savepoint("teaql_event_append", () -> {
+ sql.update("INSERT INTO teaql_event_sequence(source_runtime_id, current_sequence) "
+ + "VALUES (?, 0) ON CONFLICT(source_runtime_id) DO NOTHING",
+ new Object[] {sourceRuntimeId});
+ int updated = sql.update("UPDATE teaql_event_sequence SET current_sequence = current_sequence + 1 "
+ + "WHERE source_runtime_id = ? AND current_sequence < 9223372036854775807",
+ new Object[] {sourceRuntimeId});
+ if (updated != 1) throw new CommittedEventException("SEQUENCE_EXHAUSTED", "source sequence exhausted");
+ long sequence = number(sql.queryForList("SELECT current_sequence FROM teaql_event_sequence "
+ + "WHERE source_runtime_id = ?", new Object[] {sourceRuntimeId}).get(0).get("current_sequence"));
+ String payloadJson = payloadBuilder.apply(sequence);
+ JsonNode payload = parse(payloadJson);
+ validateHeader(payload, sourceRuntimeId, sequence, changeSetId, modelHash);
+ String digest = digest(payloadJson);
+ sql.update("INSERT INTO teaql_event_outbox "
+ + "(source_runtime_id, source_sequence, change_set_id, model_hash, payload_sha256, payload_json) "
+ + "VALUES (?, ?, ?, ?, ?, ?)",
+ new Object[] {sourceRuntimeId, sequence, changeSetId, modelHash, digest, payloadJson});
+ return new OutboxRecord(sourceRuntimeId, sequence, changeSetId, modelHash, digest, payloadJson);
+ });
+ }
+
+ /**
+ * Apply one set through a restricted projection writer. The writer must not
+ * call normal entity save, Checker/Fix, ID generation or business hooks.
+ */
+ InboxOutcome apply(OutboxRecord record, String expectedModelHash,
+ TrustedProjectionWriter writer) {
+ requireTransaction();
+ if (record == null || blank(expectedModelHash) || writer == null) {
+ throw invalid("missing committed event, model hash, or projection writer");
+ }
+ if (!expectedModelHash.equals(record.modelHash())) {
+ throw new CommittedEventException("MODEL_MISMATCH", "committed-event model hash mismatch");
+ }
+ JsonNode payload = parse(record.payloadJson());
+ validateHeader(payload, record.sourceRuntimeId(), record.sourceSequence(),
+ record.changeSetId(), record.modelHash());
+ if (!digest(record.payloadJson()).equals(record.payloadSha256())) {
+ throw invalid("payload digest mismatch");
+ }
+ List> checkpoints = sql.queryForList(
+ "SELECT source_sequence, model_hash FROM teaql_event_checkpoint WHERE source_runtime_id = ?",
+ new Object[] {record.sourceRuntimeId()});
+ long last = checkpoints.isEmpty() ? 0 : number(checkpoints.get(0).get("source_sequence"));
+ if (!checkpoints.isEmpty() && !expectedModelHash.equals(checkpoints.get(0).get("model_hash"))) {
+ throw new CommittedEventException("MODEL_MISMATCH", "committed-event checkpoint model mismatch");
+ }
+ List> prior = sql.queryForList(
+ "SELECT source_sequence, payload_sha256 FROM teaql_event_inbox "
+ + "WHERE source_runtime_id = ? AND change_set_id = ?",
+ new Object[] {record.sourceRuntimeId(), record.changeSetId()});
+ if (!prior.isEmpty()) {
+ Map seen = prior.get(0);
+ if (number(seen.get("source_sequence")) == record.sourceSequence()
+ && record.payloadSha256().equals(seen.get("payload_sha256"))) {
+ List> retained = sql.queryForList(
+ "SELECT source_sequence, payload_json FROM teaql_event_applied_payload "
+ + "WHERE source_runtime_id = ? AND change_set_id = ?",
+ new Object[] {record.sourceRuntimeId(), record.changeSetId()});
+ if (retained.size() != 1
+ || number(retained.get(0).get("source_sequence")) != record.sourceSequence()
+ || !record.payloadSha256().equals(digest((String) retained.get(0).get("payload_json")))) {
+ throw invalid("prior Inbox identity lacks retained source evidence");
+ }
+ if (last < record.sourceSequence()) {
+ throw invalid("prior Inbox identity is ahead of target checkpoint");
+ }
+ return InboxOutcome.duplicate(record.sourceSequence());
+ }
+ throw new CommittedEventException("DUPLICATE_CONFLICT", "committed-event identity has conflicting content");
+ }
+ if (last == Long.MAX_VALUE) {
+ throw new CommittedEventException("SEQUENCE_EXHAUSTED", "source sequence exhausted");
+ }
+ if (record.sourceSequence() != last + 1) {
+ throw new CommittedEventException("SEQUENCE_GAP",
+ "committed-event sequence gap: expected " + (last + 1) + ", received " + record.sourceSequence());
+ }
+ return savepoint("teaql_event_apply", () -> {
+ writer.apply(sql, payload);
+ sql.update("INSERT INTO teaql_event_applied_payload "
+ + "(source_runtime_id, source_sequence, change_set_id, payload_json) VALUES (?, ?, ?, ?)",
+ new Object[] {record.sourceRuntimeId(), record.sourceSequence(),
+ record.changeSetId(), record.payloadJson()});
+ sql.update("INSERT INTO teaql_event_inbox "
+ + "(source_runtime_id, change_set_id, source_sequence, payload_sha256) VALUES (?, ?, ?, ?)",
+ new Object[] {record.sourceRuntimeId(), record.changeSetId(),
+ record.sourceSequence(), record.payloadSha256()});
+ sql.update("INSERT INTO teaql_event_checkpoint (source_runtime_id, source_sequence, model_hash) "
+ + "VALUES (?, ?, ?) ON CONFLICT(source_runtime_id) DO UPDATE SET "
+ + "source_sequence = excluded.source_sequence, model_hash = excluded.model_hash",
+ new Object[] {record.sourceRuntimeId(), record.sourceSequence(), record.modelHash()});
+ return InboxOutcome.applied(record.sourceSequence());
+ });
+ }
+
+ private static JsonNode parse(String payloadJson) {
+ if (payloadJson == null) throw invalid("missing payload");
+ try {
+ return JSON.readTree(payloadJson);
+ } catch (Exception ignored) {
+ throw invalid("invalid payload JSON");
+ }
+ }
+
+ private static void validateHeader(JsonNode payload, String source, long sequence,
+ String changeSetId, String modelHash) {
+ if (blank(source) || sequence < 1 || blank(changeSetId) || blank(modelHash)
+ || payload == null || payload.path("protocol_version").asInt(-1) != 1
+ || !source.equals(payload.path("source_runtime_id").asText())
+ || !Long.toString(sequence).equals(payload.path("source_sequence").asText())
+ || !changeSetId.equals(payload.path("change_set_id").asText())
+ || !modelHash.equals(payload.path("source_model_hash").asText())
+ || !payload.path("mutations").isArray() || payload.path("mutations").isEmpty()) {
+ throw invalid("required v1 header or mutations mismatch");
+ }
+ rejectUnknownControls(payload, ENVELOPE_FIELDS);
+ }
+
+ /** Check protocol control frames, never recursively scan modeled values or audit text. */
+ static void rejectUnknownControls(JsonNode object, java.util.Set allowed) {
+ if (!object.isObject()) throw invalid("noncanonical protocol object");
+ object.fieldNames().forEachRemaining(key -> {
+ if (!allowed.contains(key)) throw invalid("noncanonical protocol control field");
+ });
+ }
+
+ private T savepoint(String name, java.util.function.Supplier action) {
+ sql.execute("SAVEPOINT " + name);
+ try {
+ T value = action.get();
+ sql.execute("RELEASE SAVEPOINT " + name);
+ return value;
+ } catch (RuntimeException | Error failure) {
+ try {
+ sql.execute("ROLLBACK TO SAVEPOINT " + name);
+ sql.execute("RELEASE SAVEPOINT " + name);
+ } catch (RuntimeException rollbackFailure) {
+ // A failed savepoint rollback must not leave partial rows committable.
+ try { sql.execute("ROLLBACK"); } catch (RuntimeException ignored) { }
+ throw new CommittedEventException("ROLLBACK_FAILED", "committed-event rollback failed");
+ }
+ throw failure;
+ }
+ }
+
+ private void requireTransaction() {
+ if (!sql.hasActiveTransaction()) throw invalid("an active SQL transaction is required");
+ }
+
+ private static long number(Object value) { return ((Number) value).longValue(); }
+ private static boolean blank(String value) { return value == null || value.isBlank(); }
+ private static CommittedEventException invalid(String reason) {
+ return new CommittedEventException("INVALID_ENVELOPE", "invalid committed-event envelope: " + reason);
+ }
+ private static String digest(String payload) {
+ try {
+ byte[] hash = MessageDigest.getInstance("SHA-256")
+ .digest(payload.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new AssertionError("JDK SHA-256 is unavailable", impossible);
+ }
+ }
+}
diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedMutationRecorder.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedMutationRecorder.java
new file mode 100644
index 00000000..1e45db2f
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedMutationRecorder.java
@@ -0,0 +1,111 @@
+package io.teaql.core.sqlite;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.teaql.core.GeneratedSchemaBootstrap;
+import io.teaql.core.CommittedEventSchemaContributor;
+import io.teaql.core.MutationIntent;
+import io.teaql.core.SchemaExecutor;
+import io.teaql.core.TraceNode;
+import io.teaql.core.UserContext;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+import io.teaql.runtime.CommittedMutationFact;
+import io.teaql.runtime.CommittedMutationRecorder;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+/** SQLite source adapter that records one graph-save result inside its write transaction. */
+public final class SqliteCommittedMutationRecorder
+ implements CommittedMutationRecorder, CommittedEventSchemaContributor {
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private final SqlExecutionAdapter adapter;
+ private final SqliteCommittedEventStore events;
+ private final String sourceRuntimeId;
+ private final String modelHash;
+
+ public SqliteCommittedMutationRecorder(SqlExecutionAdapter adapter,
+ String sourceRuntimeId, String modelHash) {
+ this.adapter = Objects.requireNonNull(adapter, "adapter");
+ this.events = new SqliteCommittedEventStore(adapter);
+ if (sourceRuntimeId == null || sourceRuntimeId.isBlank()
+ || modelHash == null || !modelHash.matches("sha256:[0-9a-f]{64}")) {
+ throw new IllegalArgumentException("A source runtime ID and accepted SHA-256 model hash are required");
+ }
+ this.sourceRuntimeId = sourceRuntimeId;
+ this.modelHash = modelHash;
+ }
+
+ @Override
+ public void ensureSchema(UserContext context, SchemaExecutor.Invocation invocation) {
+ SchemaExecutor.Invocation.requireContextOwned(invocation);
+ Objects.requireNonNull(context, "context");
+ events.ensureSchema();
+ }
+ public SqliteCommittedEventStore eventStore() { return events; }
+
+ @Override
+ public void record(UserContext context, Object transactionResource,
+ MutationIntent intent, List facts) {
+ Objects.requireNonNull(context, "context");
+ Objects.requireNonNull(intent, "intent");
+ if (transactionResource != adapter || !adapter.hasActiveTransaction()) {
+ throw new IllegalStateException("Committed graph capture requires the source provider transaction");
+ }
+ if (facts == null || facts.isEmpty()) {
+ throw new IllegalArgumentException("Committed graph capture requires at least one fact");
+ }
+ String changeSetId = UUID.randomUUID().toString();
+ String committedAt = Instant.now().toString();
+ String actor = context.getAttribute(GeneratedSchemaBootstrap.AUDIT_ACTOR_ATTRIBUTE, String.class);
+ events.append(sourceRuntimeId, changeSetId, modelHash, sequence -> {
+ Map envelope = new LinkedHashMap<>();
+ envelope.put("protocol_version", 1);
+ envelope.put("change_set_id", changeSetId);
+ envelope.put("source_runtime_id", sourceRuntimeId);
+ envelope.put("source_sequence", Long.toString(sequence));
+ envelope.put("source_model_hash", modelHash);
+ envelope.put("source_transaction_id", changeSetId);
+ envelope.put("committed_at", committedAt);
+ Map audit = new LinkedHashMap<>();
+ audit.put("actor_ref", actor);
+ audit.put("comment", intent.comment());
+ // MutationIntent currently has no separate business purpose field.
+ audit.put("purpose", "replicate committed graph");
+ envelope.put("audit", audit);
+ List> mutations = new ArrayList<>(facts.size());
+ for (CommittedMutationFact fact : facts) {
+ Map mutation = new LinkedHashMap<>();
+ mutation.put("entity_type", fact.entityType());
+ mutation.put("entity_id", fact.entityId());
+ mutation.put("operation", fact.operation());
+ mutation.put("base_version", fact.baseVersion());
+ mutation.put("committed_version", fact.committedVersion());
+ mutation.put("fields", fact.fields());
+ List> chain = new ArrayList<>();
+ for (TraceNode node : fact.traceChain()) {
+ Map step = new LinkedHashMap<>();
+ step.put("kind", node.getKind().name());
+ step.put("name", Objects.toString(node.getName(), ""));
+ step.put("entity_id", node.getEntityId() == null ? null : node.getEntityId().toString());
+ step.put("comment", Objects.toString(node.getComment(), ""));
+ chain.add(step);
+ }
+ mutation.put("trace_chain", chain);
+ mutations.add(mutation);
+ }
+ envelope.put("mutations", mutations);
+ try {
+ return JSON.writeValueAsString(envelope);
+ } catch (JsonProcessingException failure) {
+ // Do not attach values or serializer causes to public errors.
+ throw new IllegalStateException("Committed mutation payload encoding failed");
+ }
+ });
+ }
+}
diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedObservation.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedObservation.java
new file mode 100644
index 00000000..95e066f5
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedObservation.java
@@ -0,0 +1,115 @@
+package io.teaql.core.sqlite;
+
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.EntityMetaFactory;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+import io.teaql.runtime.RuntimeTelemetry;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+/** Durable infrastructure observations. Source ACK is never labeled target watermark. */
+public final class SqliteCommittedObservation {
+ public record SourceObservation(long sourceHighWatermark,long ackedSequence,long sequenceLag,
+ long backlog,long pendingSnapshots,long attempts,long retries,long failures,
+ long rejections,long deadLetters,long unresolvedAttempts) { }
+ public record TargetObservation(long checkpoint,long snapshotBaseline,long inboxRows,
+ OptionalLong sequenceLag,long rejections) { }
+ public record DeadLetter(String kind,long sequence,String identityFingerprint,
+ String payloadFingerprint,long attempts,String reason) { }
+ record Attempt(String kind,long sequence,long ordinal) { }
+ private final SqlExecutionAdapter sql;
+ private final String source,target,model;
+ private static final Set PERMANENT=Set.of("UNAUTHORIZED_SOURCE","UNAUTHORIZED_TARGET",
+ "UNKNOWN_KEY","INVALID_SIGNATURE","INVALID_ENVELOPE","MODEL_MISMATCH",
+ "SEQUENCE_GAP","DUPLICATE_CONFLICT","VERSION_CONFLICT","SEQUENCE_EXHAUSTED",
+ "INVALID_SNAPSHOT","SNAPSHOT_CONFLICT","TARGET_NOT_EMPTY");
+ public SqliteCommittedObservation(SqlExecutionAdapter sql,String source,String target,String model){
+ this.sql=Objects.requireNonNull(sql,"sql");
+ if(source==null||source.isBlank()||target==null||target.isBlank()||model==null||!model.matches("sha256:[0-9a-f]{64}"))throw unavailable();
+ this.source=source;this.target=target;this.model=model;
+ }
+ static void ensureSchema(SqlExecutionAdapter sql){
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_delivery_journal (source_runtime_id TEXT NOT NULL,target_id TEXT NOT NULL,kind TEXT NOT NULL CHECK(kind IN ('event','snapshot')),source_sequence INTEGER NOT NULL CHECK(source_sequence>=0),model_hash TEXT NOT NULL,identity_fingerprint TEXT NOT NULL,payload_fingerprint TEXT NOT NULL,attempts INTEGER NOT NULL CHECK(typeof(attempts)='integer' AND attempts>0),failures INTEGER NOT NULL CHECK(typeof(failures)='integer' AND failures>=0),rejections INTEGER NOT NULL CHECK(typeof(rejections)='integer' AND rejections>=0),dead_letter INTEGER NOT NULL CHECK(dead_letter IN (0,1)),last_outcome TEXT NOT NULL CHECK(last_outcome IN ('STARTED','ACKNOWLEDGED','FAILED')),last_reason TEXT NOT NULL,dead_reason TEXT NOT NULL,PRIMARY KEY(source_runtime_id,target_id,kind,source_sequence))");
+ }
+ Attempt begin(String kind,long sequence,String identity,String payload){
+ if(!Set.of("event","snapshot").contains(kind)||sequence<0||identity==null||payload==null||!payload.matches("[0-9a-f]{64}"))throw unavailable();
+ return transaction(()->{
+ var rows=sql.queryForList("SELECT * FROM teaql_event_delivery_journal WHERE source_runtime_id=? AND target_id=? AND kind=? AND source_sequence=?",new Object[]{source,target,kind,sequence});
+ long ordinal=1;
+ if(rows.isEmpty())sql.update("INSERT INTO teaql_event_delivery_journal VALUES(?,?,?,?,?,?,?,1,0,0,0,'STARTED','NONE','NONE')",new Object[]{source,target,kind,sequence,model,fingerprint(identity),payload});
+ else{
+ var row=rows.get(0);long prior=number(row.get("attempts"));
+ if(!model.equals(row.get("model_hash"))||!fingerprint(identity).equals(row.get("identity_fingerprint"))||!payload.equals(row.get("payload_fingerprint"))||"ACKNOWLEDGED".equals(row.get("last_outcome")))throw conflict();
+ ordinal=Math.addExact(prior,1);
+ if(sql.update("UPDATE teaql_event_delivery_journal SET attempts=?,last_outcome='STARTED',last_reason='NONE' WHERE source_runtime_id=? AND target_id=? AND kind=? AND source_sequence=? AND attempts=?",new Object[]{ordinal,source,target,kind,sequence,prior})!=1)throw conflict();
+ }
+ return new Attempt(kind,sequence,ordinal);
+ });
+ }
+ void failed(Attempt attempt,RuntimeException error){
+ String code=error instanceof SqliteCommittedReceiver.ReceiverException r?r.code():error instanceof SqliteCommittedEventStore.CommittedEventException e?e.code():"TRANSPORT_UNAVAILABLE";
+ String normalized=code.startsWith("SNAPSHOT_")&&PERMANENT.contains(code.substring(9))?code.substring(9):code;
+ boolean permanent=PERMANENT.contains(normalized);
+ String safe=permanent?normalized:Set.of("ACK_MISMATCH","AUDIT_UNAVAILABLE","CURSOR_CONFLICT").contains(code)?code:"TRANSPORT_UNAVAILABLE";
+ transaction(()->{if(sql.update("UPDATE teaql_event_delivery_journal SET failures=failures+1,rejections=rejections+?,dead_letter=CASE WHEN ?=1 THEN 1 ELSE dead_letter END,dead_reason=CASE WHEN ?=1 THEN ? ELSE dead_reason END,last_outcome='FAILED',last_reason=? WHERE source_runtime_id=? AND target_id=? AND kind=? AND source_sequence=? AND attempts=? AND last_outcome='STARTED'",new Object[]{permanent?1:0,permanent?1:0,permanent?1:0,safe,safe,source,target,attempt.kind(),attempt.sequence(),attempt.ordinal()})!=1)throw conflict();return null;});
+ }
+ T acknowledged(Attempt attempt,Supplier cursorUpdate){
+ return transaction(()->{
+ T value=cursorUpdate.get();
+ if(sql.update("UPDATE teaql_event_delivery_journal SET last_outcome='ACKNOWLEDGED',last_reason='NONE',dead_letter=0,dead_reason='NONE' WHERE source_runtime_id=? AND target_id=? AND kind=? AND source_sequence=? AND attempts=? AND last_outcome='STARTED'",new Object[]{source,target,attempt.kind(),attempt.sequence(),attempt.ordinal()})!=1)throw conflict();
+ return value;
+ });
+ }
+ public SourceObservation source(UserContext context){
+ require(context);
+ SourceObservation value=transaction(()->{
+ if(count("SELECT count(*) n FROM teaql_event_delivery_journal WHERE source_runtime_id=? AND target_id=? AND model_hash<>?",new Object[]{source,target,model})!=0)throw unavailable();
+ var cursor=sql.queryForList("SELECT acked_sequence,model_hash FROM teaql_event_delivery_cursor WHERE source_runtime_id=? AND target_id=?",new Object[]{source,target});
+ long ack=cursor.isEmpty()?0:number(cursor.get(0).get("acked_sequence"));
+ if(!cursor.isEmpty()&&!model.equals(cursor.get(0).get("model_hash")))throw unavailable();
+ var highRows=sql.queryForList("SELECT current_sequence FROM teaql_event_sequence WHERE source_runtime_id=?",new Object[]{source});long high=highRows.isEmpty()?0:number(highRows.get(0).get("current_sequence"));
+ if(high?",new Object[]{source,ack});
+ long pending=count("SELECT count(*) n FROM teaql_event_snapshot_publication WHERE source_runtime_id=? AND target_id=? AND model_hash=? AND acknowledged=0",new Object[]{source,target,model});
+ return new SourceObservation(high,ack,high-ack,backlog,pending,number(sums.get("attempts")),number(sums.get("retries")),number(sums.get("failures")),number(sums.get("rejections")),number(sums.get("dead")),number(sums.get("unresolved")));
+ });
+ emit(context,"source",Map.of("source_watermark",value.sourceHighWatermark(),"source_ack",value.ackedSequence(),"sequence_lag",value.sequenceLag(),"backlog",value.backlog(),"pending_snapshots",value.pendingSnapshots(),"attempts",value.attempts(),"retries",value.retries(),"failures",value.failures(),"rejections",value.rejections(),"dead_letters",value.deadLetters()),Map.of("unresolved_attempts",value.unresolvedAttempts()));
+ return value;
+ }
+ public TargetObservation target(UserContext context,OptionalLong observedSourceHigh){
+ require(context);Objects.requireNonNull(observedSourceHigh,"observedSourceHigh");
+ TargetObservation value=transaction(()->{
+ var rows=sql.queryForList("SELECT source_sequence,model_hash FROM teaql_event_checkpoint WHERE source_runtime_id=?",new Object[]{source});
+ long checkpoint=rows.isEmpty()?0:number(rows.get(0).get("source_sequence"));
+ if(!rows.isEmpty()&&!model.equals(rows.get(0).get("model_hash")))throw unavailable();
+ if(observedSourceHigh.isPresent()&&observedSourceHigh.getAsLong()checkpoint)throw unavailable();
+ return new TargetObservation(checkpoint,n,count("SELECT count(*) n FROM teaql_event_inbox WHERE source_runtime_id=?",new Object[]{source}),observedSourceHigh.isPresent()?OptionalLong.of(observedSourceHigh.getAsLong()-checkpoint):OptionalLong.empty(),count("SELECT count(*) n FROM teaql_event_rejection WHERE target_fingerprint=?",new Object[]{fingerprint(target)}));
+ });
+ var attrs=new LinkedHashMap();attrs.put("target_watermark",value.checkpoint());attrs.put("snapshot_baseline",value.snapshotBaseline());attrs.put("inbox_rows",value.inboxRows());attrs.put("rejections",value.rejections());if(value.sequenceLag().isPresent())attrs.put("sequence_lag",value.sequenceLag().getAsLong());emit(context,"target",attrs,Map.of());return value;
+ }
+ public List deadLetters(UserContext context,int limit){
+ require(context);if(limit<1||limit>1000)throw unavailable();
+ return transaction(()->sql.queryForList("SELECT kind,source_sequence,identity_fingerprint,payload_fingerprint,attempts,dead_reason FROM teaql_event_delivery_journal WHERE source_runtime_id=? AND target_id=? AND model_hash=? AND dead_letter=1 ORDER BY source_sequence,kind LIMIT ?",new Object[]{source,target,model,limit}).stream().map(r->new DeadLetter((String)r.get("kind"),number(r.get("source_sequence")),(String)r.get("identity_fingerprint"),(String)r.get("payload_fingerprint"),number(r.get("attempts")),(String)r.get("dead_reason"))).toList());
+ }
+ private void emit(UserContext context,String name,Map values,Map extra){
+ var attributes=new LinkedHashMap();attributes.put("teaql.replication.source_fingerprint",fingerprint(source));attributes.put("teaql.replication.target_fingerprint",fingerprint(target));values.forEach((k,v)->attributes.put("teaql.replication."+k,v));extra.forEach((k,v)->attributes.put("teaql.replication."+k,v));
+ RuntimeTelemetry.startSafely(context.capability(RuntimeTelemetry.class),new RuntimeTelemetry.Operation("replication",name,attributes)).success();
+ }
+ private long count(String query,Object[] args){return number(sql.queryForList(query,args).get(0).get("n"));}
+ private void require(UserContext context){Objects.requireNonNull(context,"context");EntityMetaFactory.requireFrom(context);if(sql.hasActiveTransaction())throw unavailable();}
+ private T transaction(Supplier action){
+ if(sql.hasActiveTransaction())throw unavailable();
+ var value=new AtomicReference();sql.executeInTransaction(()->{if(!sql.hasActiveTransaction())throw unavailable();value.set(action.get());});return value.get();
+ }
+ private static long number(Object value){if(!(value instanceof Number n)||n instanceof Double||n instanceof Float||n.longValue()<0)throw unavailable();return n.longValue();}
+ static String fingerprint(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);}}
+ private static SqliteCommittedEventStore.CommittedEventException unavailable(){return new SqliteCommittedEventStore.CommittedEventException("OBSERVATION_UNAVAILABLE","committed delivery observation is unavailable");}
+ private static SqliteCommittedEventStore.CommittedEventException conflict(){return new SqliteCommittedEventStore.CommittedEventException("CURSOR_CONFLICT","committed delivery attempt changed concurrently");}
+}
diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedOutboxPublisher.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedOutboxPublisher.java
new file mode 100644
index 00000000..9607ca6f
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedOutboxPublisher.java
@@ -0,0 +1,143 @@
+package io.teaql.core.sqlite;
+
+import io.teaql.core.sqlite.SqliteCommittedEventStore.CommittedEventException;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.InboxOutcome;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.OutboxRecord;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Bounded ordered publication from one durable SQLite Outbox stream. The
+ * supplied sink must own its authentication and network retry budget; this
+ * publisher never treats a callback as proof of remote identity.
+ */
+public final class SqliteCommittedOutboxPublisher {
+ @FunctionalInterface
+ public interface DeliverySink {
+ InboxOutcome deliver(OutboxRecord record);
+ }
+
+ public record DeliveryStatus(long ackedSequence, long sourceHighWatermark, long backlog) { }
+ public record BatchOutcome(int acknowledged, DeliveryStatus status) { }
+
+ private final SqlExecutionAdapter sql;
+ private final SqliteCommittedEventStore events;
+ private final String sourceRuntimeId;
+ private final String targetId;
+ private final String modelHash;
+
+ public SqliteCommittedOutboxPublisher(SqlExecutionAdapter sql, String sourceRuntimeId,
+ String targetId, String modelHash) {
+ this.sql = Objects.requireNonNull(sql, "sql");
+ if (sourceRuntimeId == null || sourceRuntimeId.isBlank()
+ || targetId == null || targetId.isBlank()
+ || modelHash == null || !modelHash.matches("sha256:[0-9a-f]{64}")) {
+ throw failure("INVALID_ENVELOPE", "delivery source, target or model identity is invalid");
+ }
+ this.sourceRuntimeId = sourceRuntimeId;
+ this.targetId = targetId;
+ this.modelHash = modelHash;
+ this.events = new SqliteCommittedEventStore(sql);
+ }
+
+ public DeliveryStatus status() {
+ List> rows = sql.queryForList(
+ "SELECT acked_sequence, model_hash FROM teaql_event_delivery_cursor "
+ + "WHERE source_runtime_id = ? AND target_id = ?",
+ new Object[] {sourceRuntimeId, targetId});
+ long acked = 0;
+ if (!rows.isEmpty()) {
+ if (!modelHash.equals(rows.get(0).get("model_hash"))) {
+ throw failure("MODEL_MISMATCH", "delivery cursor model hash mismatch");
+ }
+ acked = number(rows.get(0).get("acked_sequence"));
+ }
+ List> sourceRows = sql.queryForList(
+ "SELECT current_sequence FROM teaql_event_sequence WHERE source_runtime_id = ?",
+ new Object[] {sourceRuntimeId});
+ long highWatermark = sourceRows.isEmpty() ? 0
+ : number(sourceRows.get(0).get("current_sequence"));
+ if (highWatermark < acked) {
+ throw failure("INVALID_ENVELOPE", "delivery cursor is ahead of source sequence");
+ }
+ return new DeliveryStatus(acked, highWatermark, highWatermark - acked);
+ }
+
+ /**
+ * One attempt per event, at most {@code limit} events. The source cursor
+ * advances only after an acknowledgement for the exact sequence. If the
+ * target applied an event but the response is lost, a later call/restart
+ * re-delivers it and the target Inbox supplies a duplicate acknowledgement.
+ */
+ public BatchOutcome publishBatch(int limit, DeliverySink sink) {
+ Objects.requireNonNull(sink, "sink");
+ if (limit < 1 || limit > 1000) throw failure("INVALID_ENVELOPE", "invalid delivery read limit");
+ claimCursor();
+ DeliveryStatus before = status();
+ List records = events.outboxAfter(sourceRuntimeId, before.ackedSequence(), limit);
+ if (records.isEmpty() && before.backlog() > 0) {
+ throw failure("SEQUENCE_GAP", "source Outbox has an unacknowledged gap");
+ }
+ long acked = before.ackedSequence();
+ int count = 0;
+ for (OutboxRecord record : records) {
+ if (acked == Long.MAX_VALUE) {
+ throw failure("SEQUENCE_EXHAUSTED", "source sequence exhausted");
+ }
+ if (record.sourceSequence() != acked + 1) {
+ throw failure("SEQUENCE_GAP", "source Outbox sequence is not contiguous");
+ }
+ if (!modelHash.equals(record.modelHash())) {
+ throw failure("MODEL_MISMATCH", "source Outbox model hash mismatch");
+ }
+ var journal = new SqliteCommittedObservation(sql,sourceRuntimeId,targetId,modelHash);
+ var attempt = journal.begin("event",record.sourceSequence(),record.changeSetId(),record.payloadSha256());
+ try {
+ InboxOutcome outcome = sink.deliver(record);
+ if (outcome == null || outcome.checkpoint() != record.sourceSequence())
+ throw failure("ACK_MISMATCH", "target acknowledgement does not match delivered sequence");
+ } catch (RuntimeException error) { journal.failed(attempt,error); throw error; }
+ long prior = acked;
+ journal.acknowledged(attempt, () -> {
+ int changed = sql.update("UPDATE teaql_event_delivery_cursor SET acked_sequence=? "
+ + "WHERE source_runtime_id=? AND target_id=? AND acked_sequence=? AND model_hash=?",
+ new Object[] {record.sourceSequence(),sourceRuntimeId,targetId,prior,modelHash});
+ if (changed != 1) {
+ throw failure("CURSOR_CONFLICT", "delivery cursor changed concurrently");
+ }
+ return null;
+ });
+ acked = record.sourceSequence();
+ count++;
+ }
+ return new BatchOutcome(count, status());
+ }
+
+ private void claimCursor() {
+ if (sql.hasActiveTransaction()) throw failure("INVALID_ENVELOPE", "publisher requires a standalone transaction");
+ sql.executeInTransaction(() -> {
+ if (!sql.hasActiveTransaction()) throw failure("INVALID_ENVELOPE", "publisher adapter must provide an active SQL transaction");
+ var prepared = sql.queryForList("SELECT source_sequence,model_hash,acknowledged FROM teaql_event_snapshot_publication WHERE source_runtime_id=? AND target_id=?",
+ new Object[]{sourceRuntimeId,targetId});
+ if (!prepared.isEmpty()) {
+ var row = prepared.get(0);
+ if (number(row.get("acknowledged")) == 0) throw failure("SNAPSHOT_PENDING", "snapshot bootstrap must be acknowledged before incremental delivery");
+ var cursor = sql.queryForList("SELECT acked_sequence,model_hash FROM teaql_event_delivery_cursor WHERE source_runtime_id=? AND target_id=?",new Object[]{sourceRuntimeId,targetId});
+ if (number(row.get("acknowledged")) != 1 || !modelHash.equals(row.get("model_hash")) || cursor.size()!=1
+ || !modelHash.equals(cursor.get(0).get("model_hash")) || number(cursor.get(0).get("acked_sequence")) REJECTIONS=Set.of("INVALID_ENVELOPE","MODEL_MISMATCH",
+ "SEQUENCE_GAP","DUPLICATE_CONFLICT","VERSION_CONFLICT","SEQUENCE_EXHAUSTED");
+ private final SqlExecutionAdapter sql;
+ private final String source,target,model;
+ private final Map keys;
+ private final SqliteModelCheckedCommittedTarget delegate;
+ private final SqliteCommittedSnapshot snapshots;
+ private final SqliteCommittedSnapshot.Limits snapshotLimits;
+ private final SqliteCommittedSnapshot.ImportMode snapshotMode;
+
+ public record Delivery(OutboxRecord event,String keyId,String targetId,byte[] signature) {
+ public Delivery {signature=signature==null?new byte[0]:signature.clone();}
+ @Override public byte[] signature(){return signature.clone();}
+ @Override public String toString(){return "CommittedDelivery[content=]";}
+ }
+ public record SnapshotDelivery(SqliteCommittedSnapshot.SnapshotRecord snapshot,
+ String keyId,String targetId,byte[] signature) {
+ public SnapshotDelivery {signature=signature==null?new byte[0]:signature.clone();}
+ @Override public byte[] signature(){return signature.clone();}
+ @Override public String toString(){return "CommittedSnapshotDelivery[content=]";}
+ }
+ public static final class ReceiverException extends RuntimeException {
+ private final String code;
+ private ReceiverException(String code){super("committed receiver: "+code);this.code=code;}
+ public String code(){return code;}
+ }
+ public SqliteCommittedReceiver(SqlExecutionAdapter sql,String source,String target,
+ String model,Map keys) {
+ this(sql,source,target,model,keys,SqliteCommittedSnapshot.Limits.defaults());
+ }
+ /** Limits are trusted startup policy, never caller-controlled wire options. */
+ public SqliteCommittedReceiver(SqlExecutionAdapter sql,String source,String target,
+ String model,Map keys,SqliteCommittedSnapshot.Limits snapshotLimits) {
+ this(sql,source,target,model,keys,snapshotLimits,SqliteCommittedSnapshot.ImportMode.REQUIRE_EMPTY);
+ }
+ /** Explicit local policy for bootstrap-populated targets, never a wire flag. */
+ public SqliteCommittedReceiver(SqlExecutionAdapter sql,String source,String target,
+ String model,Map keys,SqliteCommittedSnapshot.Limits snapshotLimits,
+ SqliteCommittedSnapshot.ImportMode snapshotMode) {
+ this.sql=Objects.requireNonNull(sql,"sql");
+ if(snapshotLimits==null||snapshotLimits.maxPayloadBytes()>MAX_SNAPSHOT_PAYLOAD||snapshotMode==null)
+ throw new IllegalArgumentException("invalid receiver snapshot limits");
+ this.snapshotLimits=snapshotLimits;
+ this.snapshotMode=snapshotMode;
+ try {
+ field(source,MAX_ID);field(target,MAX_ID);
+ if(model==null||!model.matches("sha256:[0-9a-f]{64}")||keys==null||keys.isEmpty()||keys.size()>16)throw rejected("INVALID_ENVELOPE");
+ Map copy=new HashMap<>();
+ for(var entry:keys.entrySet()){field(entry.getKey(),MAX_ID);key(entry.getValue());copy.put(entry.getKey(),entry.getValue().clone());}
+ this.keys=Map.copyOf(copy);
+ }catch(ReceiverException failure){throw new IllegalArgumentException("invalid committed receiver configuration");}
+ this.source=source;this.target=target;this.model=model;
+ this.delegate=new SqliteModelCheckedCommittedTarget(sql,source,model);
+ this.snapshots=new SqliteCommittedSnapshot(sql,source,model);
+ }
+ /** Explicit provider-infrastructure installation; no domain-row seeding. */
+ public void ensureSchema(UserContext context) {
+ EntityMetaFactory.requireFrom(Objects.requireNonNull(context,"context"));
+ if(sql.hasActiveTransaction())throw rejected("AMBIENT_TRANSACTION");
+ delegate.ensureSchema();
+ sql.execute("CREATE TABLE IF NOT EXISTS teaql_event_rejection (audit_id INTEGER PRIMARY KEY, "
+ +"reason_code TEXT NOT NULL, source_sequence INTEGER NOT NULL, source_fingerprint TEXT NOT NULL, "
+ +"change_fingerprint TEXT NOT NULL,target_fingerprint TEXT NOT NULL,payload_fingerprint TEXT NOT NULL)");
+ }
+ /** Original model facts remain unchanged; authenticated duplicate ACKs stay stable. */
+ public SqliteCommittedEventStore.InboxOutcome receive(UserContext context,Delivery delivery) {
+ Objects.requireNonNull(context,"context");
+ // Infrastructure wiring error: never join/commit a caller's transaction.
+ if(sql.hasActiveTransaction())throw rejected("AMBIENT_TRANSACTION");
+ try {
+ validate(delivery);
+ OutboxRecord event=delivery.event();
+ if(!source.equals(event.sourceRuntimeId()))throw rejected("UNAUTHORIZED_SOURCE");
+ if(!target.equals(delivery.targetId()))throw rejected("UNAUTHORIZED_TARGET");
+ if(!model.equals(event.modelHash()))throw rejected("MODEL_MISMATCH");
+ byte[] secret=keys.get(delivery.keyId());
+ if(secret==null)throw rejected("UNKNOWN_KEY");
+ if(!MessageDigest.isEqual(tag(delivery.keyId(),delivery.targetId(),secret,event),delivery.signature))throw rejected("INVALID_SIGNATURE");
+ return delegate.apply(context,event);
+ }catch(ReceiverException failure){
+ audit(delivery,failure.code());throw failure;
+ }catch(SqliteCommittedEventStore.CommittedEventException failure){
+ // Unknown/failed transaction outcomes are not security rejections.
+ if(!REJECTIONS.contains(failure.code()))throw failure;
+ audit(delivery,failure.code());throw rejected(failure.code());
+ }
+ }
+ /** Source-side signing of original bytes; requires a deployment-owned secret. */
+ public static Delivery sign(String keyId,String target,byte[] secret,OutboxRecord event) {
+ field(keyId,MAX_ID);field(target,MAX_ID);key(secret);record(event);
+ return new Delivery(event,keyId,target,tag(keyId,target,secret,event));
+ }
+ /** Separate type domain; zero is a valid snapshot baseline, never an event sequence. */
+ public static SnapshotDelivery signSnapshot(String keyId,String target,byte[] secret,
+ SqliteCommittedSnapshot.SnapshotRecord snapshot) {
+ field(keyId,MAX_ID);field(target,MAX_ID);key(secret);snapshot(snapshot,MAX_SNAPSHOT_PAYLOAD);
+ return new SnapshotDelivery(snapshot,keyId,target,snapshotTag(keyId,target,secret,snapshot));
+ }
+ /** Authenticate before restore. Deliveries cannot select or relax startup restore policy. */
+ public SqliteCommittedSnapshotPublisher.Receipt receiveSnapshot(UserContext context,SnapshotDelivery delivery) {
+ Objects.requireNonNull(context,"context");
+ if(sql.hasActiveTransaction())throw rejected("AMBIENT_TRANSACTION");
+ try {
+ if(delivery==null)throw rejected("INVALID_ENVELOPE");
+ field(delivery.keyId(),MAX_ID);field(delivery.targetId(),MAX_ID);
+ snapshot(delivery.snapshot(),snapshotLimits.maxPayloadBytes());
+ if(delivery.signature.length!=32)throw rejected("INVALID_ENVELOPE");
+ var record=delivery.snapshot();
+ if(!source.equals(record.sourceRuntimeId()))throw rejected("UNAUTHORIZED_SOURCE");
+ if(!target.equals(delivery.targetId()))throw rejected("UNAUTHORIZED_TARGET");
+ if(!model.equals(record.modelHash()))throw rejected("MODEL_MISMATCH");
+ byte[] secret=keys.get(delivery.keyId());if(secret==null)throw rejected("UNKNOWN_KEY");
+ if(!MessageDigest.isEqual(snapshotTag(delivery.keyId(),delivery.targetId(),secret,record),delivery.signature))throw rejected("INVALID_SIGNATURE");
+ return SqliteCommittedSnapshotPublisher.initializeAndAcknowledge(context,snapshots,record,target,snapshotMode,snapshotLimits);
+ }catch(ReceiverException failure){audit(delivery,failure.code());throw failure;}
+ catch(SqliteCommittedEventStore.CommittedEventException failure){
+ String code="INVALID_ENVELOPE".equals(failure.code())?"INVALID_SNAPSHOT":failure.code();
+ if(!REJECTIONS.contains(code)&&!Set.of("INVALID_SNAPSHOT","SNAPSHOT_CONFLICT").contains(code))throw failure;
+ audit(delivery,code);throw rejected(code);
+ }
+ }
+ private static void validate(Delivery delivery){
+ if(delivery==null)throw rejected("INVALID_ENVELOPE");
+ field(delivery.keyId(),MAX_ID);field(delivery.targetId(),MAX_ID);record(delivery.event());
+ if(delivery.signature.length!=32)throw rejected("INVALID_ENVELOPE");
+ }
+ private static void record(OutboxRecord e){
+ if(e==null||e.sourceSequence()<=0)throw rejected("INVALID_ENVELOPE");
+ field(e.sourceRuntimeId(),MAX_ID);field(e.changeSetId(),MAX_ID);field(e.modelHash(),MAX_ID);field(e.payloadSha256(),MAX_ID);field(e.payloadJson(),MAX_PAYLOAD);
+ }
+ private static void snapshot(SqliteCommittedSnapshot.SnapshotRecord s,int byteLimit){
+ if(s==null||s.sourceSequence()<0)throw rejected("INVALID_ENVELOPE");
+ field(s.sourceRuntimeId(),MAX_ID);field(s.snapshotId(),MAX_ID);field(s.modelHash(),MAX_ID);field(s.payloadSha256(),MAX_ID);field(s.payloadJson(),byteLimit);
+ }
+ private static byte[] field(String text,int limit){
+ if(text==null||text.isEmpty()||text.length()>limit||!StandardCharsets.UTF_8.newEncoder().canEncode(text))throw rejected("INVALID_ENVELOPE");
+ byte[] bytes=text.getBytes(StandardCharsets.UTF_8);if(bytes.length>limit)throw rejected("INVALID_ENVELOPE");return bytes;
+ }
+ private static void key(byte[] secret){if(secret==null||secret.length<32||secret.length>128)throw rejected("INVALID_ENVELOPE");}
+ private static byte[] tag(String keyId,String target,byte[] secret,OutboxRecord event){
+ return hmac(secret,"teaql.committed.receiver.v1\0",keyId,target,event.sourceRuntimeId(),Long.toString(event.sourceSequence()),event.changeSetId(),event.modelHash(),event.payloadSha256(),event.payloadJson());
+ }
+ private static byte[] snapshotTag(String keyId,String target,byte[] secret,SqliteCommittedSnapshot.SnapshotRecord s){
+ return hmac(secret,"teaql.committed.snapshot.receiver.v1\0",keyId,target,s.sourceRuntimeId(),Long.toString(s.sourceSequence()),s.snapshotId(),s.modelHash(),s.payloadSha256(),s.payloadJson());
+ }
+ private static byte[] hmac(byte[] secret,String domain,String... fields){
+ try {
+ Mac mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(secret,"HmacSHA256"));
+ mac.update(domain.getBytes(StandardCharsets.UTF_8));
+ for(String value:fields){byte[] bytes=value.getBytes(StandardCharsets.UTF_8);mac.update(ByteBuffer.allocate(4).putInt(bytes.length).array());mac.update(bytes);}
+ return mac.doFinal();
+ }catch(GeneralSecurityException failure){throw new IllegalStateException("standard HMAC-SHA256 unavailable");}
+ }
+ private void audit(Delivery delivery,String code){
+ OutboxRecord e=delivery==null?null:delivery.event();
+ audit(code,e==null?0:e.sourceSequence(),e==null?null:e.sourceRuntimeId(),e==null?null:e.changeSetId(),e==null?null:e.payloadJson(),MAX_PAYLOAD);
+ }
+ private void audit(SnapshotDelivery delivery,String code){
+ var s=delivery==null?null:delivery.snapshot();
+ audit("SNAPSHOT_"+code,s==null?0:s.sourceSequence(),s==null?null:s.sourceRuntimeId(),s==null?null:s.snapshotId(),s==null?null:s.payloadJson(),MAX_SNAPSHOT_PAYLOAD);
+ }
+ private void audit(String code,long sequence,String sourceId,String identity,String payload,int maxPayload){
+ try {
+ if(sql.hasActiveTransaction())throw new IllegalStateException("audit transaction unavailable");
+ Object[] values={code,Math.max(0,sequence),fingerprint(sourceId,MAX_ID),fingerprint(identity,MAX_ID),fingerprint(target,MAX_ID),fingerprint(payload,maxPayload)};
+ sql.executeInTransaction(()->{if(sql.update("INSERT INTO teaql_event_rejection(reason_code,source_sequence,source_fingerprint,change_fingerprint,target_fingerprint,payload_fingerprint) VALUES (?,?,?,?,?,?)",values)!=1)throw new IllegalStateException("audit row missing");});
+ }catch(RuntimeException failure){throw rejected("AUDIT_UNAVAILABLE");}
+ }
+ private static String fingerprint(String text,int limit){
+ if(text==null)return "missing";
+ byte[] bytes;try{bytes=field(text,limit);}catch(ReceiverException invalid){return "invalid_or_oversize";}
+ try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
+ catch(GeneralSecurityException impossible){throw new IllegalStateException("standard SHA-256 unavailable");}
+ }
+ private static ReceiverException rejected(String code){return new ReceiverException(code);}
+ @Override public String toString(){return "SqliteCommittedReceiver[authority=]";}
+}
diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedSnapshot.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedSnapshot.java
new file mode 100644
index 00000000..8647210d
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedSnapshot.java
@@ -0,0 +1,227 @@
+package io.teaql.core.sqlite;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.meta.EntityMetaFactory;
+import io.teaql.core.meta.PropertyDescriptor;
+import io.teaql.core.meta.Relation;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
+import static io.teaql.core.sqlite.SqliteModelCheckedCommittedTarget.*;
+
+/** Trusted startup infrastructure. No ordinary business save or remote request routing. */
+public final class SqliteCommittedSnapshot {
+ private static final Set SNAPSHOT_FIELDS = Set.of("snapshot_version", "snapshot_id",
+ "source_runtime_id", "source_sequence", "source_model_hash", "audit", "entity_types", "rows");
+ private static final Set AUDIT_FIELDS = Set.of("actor_ref", "comment", "purpose", "trace_id");
+ private static final ObjectMapper JSON = new ObjectMapper()
+ .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
+ .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ public record Limits(int maxRows, int maxPayloadBytes) {
+ public Limits { if (maxRows < 1 || maxRows == Integer.MAX_VALUE || maxPayloadBytes < 1)
+ throw new IllegalArgumentException("positive bounded snapshot limits are required"); }
+ public static Limits defaults() { return new Limits(100_000, 16 * 1024 * 1024); }
+ }
+ public enum ImportMode { REQUIRE_EMPTY, REPLACE_UNINITIALIZED }
+ public record SnapshotRecord(String sourceRuntimeId, long sourceSequence, String snapshotId,
+ String modelHash, String payloadSha256, String payloadJson) {
+ @Override public String toString() { return "SnapshotRecord[sourceSequence=" + sourceSequence + ", payload=]"; }
+ }
+ private record Table(EntityDescriptor entity, List fields,
+ String table, String id, String version) { }
+ private final SqlExecutionAdapter sql;
+ private final String source;
+ private final String model;
+
+ public SqliteCommittedSnapshot(SqlExecutionAdapter sql, String source, String model) {
+ this.sql = Objects.requireNonNull(sql, "sql");
+ if (blank(source) || model == null || !model.matches("sha256:[0-9a-f]{64}")) throw invalid("source/model is invalid");
+ this.source = source; this.model = model;
+ }
+
+ /** N and physical rows share one read transaction. Never export a caller's uncommitted writes. */
+ public SnapshotRecord exportSnapshot(UserContext context, String snapshotId,
+ String comment, String purpose, Limits limits) {
+ Objects.requireNonNull(context, "context"); Objects.requireNonNull(limits, "limits");
+ if (blank(snapshotId) || blank(comment) || blank(purpose)) throw invalid("snapshot identity and intent are required");
+ standalone();
+ EntityMetaFactory metadata = EntityMetaFactory.requireFrom(context);
+ AtomicReference result = new AtomicReference<>();
+ sql.executeInTransaction(() -> {
+ requireTransaction();
+ // The first SELECT establishes SQLite's read snapshot, including when N=0.
+ long n = position();
+ List tables = tables(metadata);
+ ObjectNode body = JSON.createObjectNode();
+ body.put("snapshot_version", 1); body.put("snapshot_id", snapshotId);
+ body.put("source_runtime_id", source); body.put("source_sequence", Long.toString(n));
+ body.put("source_model_hash", model);
+ body.putObject("audit").put("comment", comment).put("purpose", purpose);
+ var catalog = body.putArray("entity_types"); var rows = body.putArray("rows");
+ long usedBytes = 0;
+ for (Table table : tables) {
+ catalog.add(table.entity().getType());
+ List columns = new ArrayList<>(List.of(quoted(table.id()) + " AS snapshot_id", quoted(table.version()) + " AS snapshot_version"));
+ for (int i = 0; i < table.fields().size(); i++) columns.add(quoted(mapping(table.fields().get(i)).column()) + " AS snapshot_field_" + i);
+ int remaining = limits.maxRows() - rows.size();
+ for (Map values : sql.queryForList("SELECT " + String.join(",", columns)
+ + " FROM " + quoted(table.table()) + " ORDER BY " + quoted(table.id()) + " LIMIT ?",
+ new Object[] {remaining + 1})) {
+ if (rows.size() == limits.maxRows()) throw invalid("snapshot row budget exceeded");
+ ObjectNode row = JSON.createObjectNode().put("entity_type", table.entity().getType())
+ .put("entity_id", integer(values.get("snapshot_id")).toString())
+ .put("version", integer(values.get("snapshot_version")).toString());
+ ObjectNode fields = row.putObject("fields");
+ for (int i = 0; i < table.fields().size(); i++) fields.set(table.fields().get(i).getName(), encode(table.fields().get(i), values.get("snapshot_field_" + i)));
+ // Same structural decoder/representability checks as the target, not Checker/Fix.
+ decodeSnapshotRow(metadata, row);
+ usedBytes += bytes(row.toString()); if (usedBytes > limits.maxPayloadBytes()) throw invalid("snapshot byte budget exceeded");
+ rows.add(row);
+ }
+ }
+ String text = body.toString(); bounded(text, limits);
+ result.set(new SnapshotRecord(source, n, snapshotId, model, digest(text), text));
+ });
+ return result.get();
+ }
+
+ /** Explicit destructive replacement is allowed only before this target has any stream state. */
+ public SqliteCommittedEventStore.InboxOutcome initializeSnapshot(UserContext context, SnapshotRecord record, Limits limits) {
+ return initializeSnapshot(context, record, ImportMode.REQUIRE_EMPTY, limits);
+ }
+
+ /** Explicit destructive replacement is allowed only before this target has any stream state. */
+ public SqliteCommittedEventStore.InboxOutcome initializeSnapshot(UserContext context,
+ SnapshotRecord record, ImportMode mode, Limits limits) {
+ Objects.requireNonNull(context, "context"); Objects.requireNonNull(mode, "mode"); Objects.requireNonNull(limits, "limits");
+ sql.restrictOrdinaryMutations();
+ standalone(); JsonNode body = validate(record, limits);
+ EntityMetaFactory metadata = EntityMetaFactory.requireFrom(context);
+ AtomicReference result = new AtomicReference<>();
+ sql.executeInTransaction(() -> {
+ requireTransaction();
+ List> prior = sql.queryForList("SELECT snapshot_id,source_sequence,model_hash,payload_sha256,payload_json "
+ + "FROM teaql_event_snapshot WHERE source_runtime_id=?", new Object[] {source});
+ if (!prior.isEmpty()) {
+ Map old = prior.get(0);
+ if (!record.snapshotId().equals(old.get("snapshot_id")) || record.sourceSequence() != integer(old.get("source_sequence"))
+ || !model.equals(old.get("model_hash")) || !record.payloadSha256().equals(old.get("payload_sha256"))
+ || !record.payloadSha256().equals(digest((String) old.get("payload_json")))) throw conflict("snapshot identity/content conflicts");
+ var checkpoint = sql.queryForList("SELECT source_sequence,model_hash FROM teaql_event_checkpoint WHERE source_runtime_id=?", new Object[] {source});
+ if (checkpoint.size() != 1 || integer(checkpoint.get(0).get("source_sequence")) < record.sourceSequence()
+ || !model.equals(checkpoint.get(0).get("model_hash"))) throw invalid("snapshot checkpoint is inconsistent");
+ result.set(SqliteCommittedEventStore.InboxOutcome.duplicate(record.sourceSequence())); return;
+ }
+ for (String table : List.of("teaql_event_snapshot", "teaql_event_checkpoint", "teaql_event_inbox", "teaql_event_applied_payload", "teaql_event_outbox", "teaql_event_sequence", "teaql_event_snapshot_publication", "teaql_event_delivery_journal"))
+ if (count(table) != 0) throw conflict("target already contains stream state");
+ List tables = tables(metadata);
+ Set catalog = new TreeSet<>();
+ if (!body.path("entity_types").isArray()) throw invalid("snapshot catalog is missing");
+ for (JsonNode type : body.path("entity_types")) if (!type.isTextual() || !catalog.add(type.textValue())) throw invalid("snapshot catalog is invalid");
+ if (!catalog.equals(new TreeSet<>(tables.stream().map(t -> t.entity().getType()).toList()))) throw invalid("snapshot catalog differs from model");
+ List mutations = new ArrayList<>(); Set identities = new HashSet<>();
+ for (JsonNode row : body.path("rows")) {
+ Mutation mutation = decodeSnapshotRow(metadata, row);
+ if (!identities.add(mutation.table() + ":" + mutation.id())) throw invalid("duplicate snapshot row");
+ mutations.add(mutation);
+ }
+ sql.execute("PRAGMA defer_foreign_keys=ON");
+ for (Table table : tables) {
+ if (mode == ImportMode.REQUIRE_EMPTY && count(table.table()) != 0) throw conflict("target domain is not empty; explicit replacement required");
+ if (mode == ImportMode.REPLACE_UNINITIALIZED) sql.execute("DELETE FROM " + quoted(table.table()));
+ }
+ for (Mutation mutation : mutations) applyOne(sql, mutation);
+ // Later triggers/rows must not rewrite earlier authoritative facts.
+ for (Mutation mutation : mutations) verifyReadback(sql, mutation);
+ for (Table table : tables) {
+ long expected = mutations.stream().filter(m -> m.table().equals(table.table())).count();
+ if (count(table.table()) != expected) throw invalid("target snapshot row count differs");
+ if (!sql.queryForList("PRAGMA foreign_key_check(" + quoted(table.table()) + ")", new Object[0]).isEmpty()) throw invalid("snapshot has a dangling reference");
+ }
+ sql.update("INSERT INTO teaql_event_snapshot(source_runtime_id,snapshot_id,source_sequence,model_hash,payload_sha256,payload_json) VALUES (?,?,?,?,?,?)",
+ new Object[] {source, record.snapshotId(), record.sourceSequence(), model, record.payloadSha256(), record.payloadJson()});
+ sql.update("INSERT INTO teaql_event_checkpoint(source_runtime_id,source_sequence,model_hash) VALUES (?,?,?)", new Object[] {source, record.sourceSequence(), model});
+ result.set(SqliteCommittedEventStore.InboxOutcome.applied(record.sourceSequence()));
+ });
+ return result.get();
+ }
+
+ JsonNode validate(SnapshotRecord record, Limits limits) {
+ if (record == null || !source.equals(record.sourceRuntimeId())) throw invalid("snapshot source is not authorized");
+ if (!model.equals(record.modelHash())) throw invalid("snapshot model mismatch");
+ if (blank(record.snapshotId()) || record.sourceSequence() < 0) throw invalid("snapshot identity/position is invalid");
+ bounded(record.payloadJson(), limits);
+ if (!digest(record.payloadJson()).equals(record.payloadSha256())) throw invalid("snapshot digest mismatch");
+ JsonNode body;
+ try { body = JSON.readTree(record.payloadJson()); } catch (Exception failure) { throw invalid("snapshot JSON is invalid"); }
+ if (!body.isObject() || body.size() != 8 || !body.path("snapshot_version").isInt() || body.path("snapshot_version").intValue() != 1
+ || !record.snapshotId().equals(body.path("snapshot_id").textValue()) || !source.equals(body.path("source_runtime_id").textValue())
+ || !Long.toString(record.sourceSequence()).equals(body.path("source_sequence").textValue()) || !model.equals(body.path("source_model_hash").textValue())
+ || !body.path("rows").isArray() || body.path("rows").size() > limits.maxRows()
+ || blank(body.path("audit").path("comment").asText()) || blank(body.path("audit").path("purpose").asText())) throw invalid("snapshot header or rows are invalid");
+ SqliteCommittedEventStore.rejectUnknownControls(body, SNAPSHOT_FIELDS);
+ SqliteCommittedEventStore.rejectUnknownControls(body.path("audit"), AUDIT_FIELDS);
+ return body;
+ }
+ private long position() {
+ var positions = sql.queryForList("SELECT current_sequence FROM teaql_event_sequence WHERE source_runtime_id=?", new Object[] {source});
+ long n = positions.isEmpty() ? 0 : integer(positions.get(0).get("current_sequence"));
+ var latest = sql.queryForList("SELECT source_sequence,model_hash FROM teaql_event_outbox WHERE source_runtime_id=? ORDER BY source_sequence DESC LIMIT 1", new Object[] {source});
+ if (n < 0 || n == 0 && !latest.isEmpty() || n > 0 && (latest.size() != 1 || integer(latest.get(0).get("source_sequence")) != n || !model.equals(latest.get(0).get("model_hash")))) throw invalid("source sequence/model is inconsistent");
+ return n;
+ }
+ private static List tables(EntityMetaFactory metadata) {
+ List result = new ArrayList<>(); Set names = new HashSet<>(), physical = new HashSet<>();
+ for (EntityDescriptor entity : metadata.allEntityDescriptors()) {
+ if (entity.getParent() != null || entity.hasChildren() || entity.findIdProperty() == null || entity.findVersionProperty() == null) throw invalid("unsupported snapshot entity mapping");
+ Mapping id = mapping(entity.findIdProperty()), version = mapping(entity.findVersionProperty());
+ String physicalName = id.table().toLowerCase(Locale.ROOT);
+ if (!names.add(entity.getType()) || !physical.add(physicalName) || physicalName.startsWith("teaql_event_") || !id.table().equals(version.table())) throw invalid("ambiguous/reserved snapshot table");
+ var fields = entity.getOwnProperties().stream().filter(p -> !p.isId() && !p.isVersion()).toList();
+ Set columns = new HashSet<>(List.of(id.column(), version.column()));
+ for (PropertyDescriptor field : fields) if (!id.table().equals(mapping(field).table()) || !columns.add(mapping(field).column())) throw invalid("unsupported snapshot field mapping");
+ result.add(new Table(entity, fields, id.table(), id.column(), version.column()));
+ }
+ if (result.isEmpty()) throw invalid("snapshot requires an installed model");
+ result.sort(Comparator.comparing(t -> t.entity().getType())); return result;
+ }
+ private static JsonNode encode(PropertyDescriptor property, Object value) {
+ JsonNode wire;
+ Class> type = property.getType() == null ? null : property.getType().javaType();
+ if (value == null) wire = JSON.nullNode();
+ else if (property instanceof Relation relation) wire = JSON.createObjectNode()
+ .put("entity_type", relation.getReverseProperty().getOwner().getType()).put("entity_id", integer(value).toString());
+ else if (type == Boolean.class || type == boolean.class) {
+ long number = integer(value); if (number != 0 && number != 1) throw invalid("source Boolean storage is invalid"); wire = JSON.valueToTree(number == 1);
+ } else if (type == BigDecimal.class || type == java.math.BigInteger.class) {
+ if (value instanceof Double || value instanceof Float) throw invalid("source Decimal is approximate REAL"); wire = JSON.valueToTree(value.toString());
+ } else if (type == Float.class || type == float.class || type == Double.class || type == double.class) {
+ if (!(value instanceof Number number) || !Double.isFinite(number.doubleValue())) throw invalid("source floating storage is invalid"); wire = JSON.valueToTree(number.doubleValue());
+ } else if (type == byte[].class && value instanceof byte[] bytes) wire = JSON.createObjectNode().put("$binary_base64", Base64.getEncoder().encodeToString(bytes));
+ else if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) wire = JSON.valueToTree(integer(value).toString());
+ else if (value instanceof String text) wire = JSON.valueToTree(text);
+ else throw invalid("source storage representation is unsupported");
+ Object decoded = decodeValue(property, wire);
+ if (!sameValue(new Field(mapping(property).column(), decoded, type == BigDecimal.class || type == java.math.BigInteger.class), value)) throw invalid("source field cannot be represented losslessly");
+ return wire;
+ }
+ private long count(String table) { return integer(sql.queryForList("SELECT COUNT(*) AS n FROM " + quoted(table), new Object[0]).get(0).get("n")); }
+ private void standalone() { if (sql.hasActiveTransaction()) throw invalid("snapshot requires a standalone transaction"); }
+ private void requireTransaction() { if (!sql.hasActiveTransaction()) throw invalid("snapshot adapter must provide an active SQL transaction"); }
+ private static Long integer(Object value) { if (!(value instanceof Long || value instanceof Integer || value instanceof Short || value instanceof Byte)) throw invalid("source identity/integer storage is invalid"); return ((Number) value).longValue(); }
+ private static int bytes(String text) { return text.getBytes(StandardCharsets.UTF_8).length; }
+ private static void bounded(String text, Limits limits) { if (text == null || text.length() > limits.maxPayloadBytes() || bytes(text) > limits.maxPayloadBytes()) throw invalid("snapshot byte budget exceeded"); }
+ private static String digest(String text) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))); } catch (java.security.NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } }
+ private static boolean blank(String text) { return text == null || text.isBlank(); }
+ private static SqliteCommittedEventStore.CommittedEventException invalid(String message) { return new SqliteCommittedEventStore.CommittedEventException("INVALID_SNAPSHOT", message); }
+ private static SqliteCommittedEventStore.CommittedEventException conflict(String message) { return new SqliteCommittedEventStore.CommittedEventException("SNAPSHOT_CONFLICT", message); }
+}
diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedSnapshotPublisher.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedSnapshotPublisher.java
new file mode 100644
index 00000000..040d9e23
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteCommittedSnapshotPublisher.java
@@ -0,0 +1,125 @@
+package io.teaql.core.sqlite;
+
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.EntityMetaFactory;
+import io.teaql.core.sqlite.SqliteCommittedSnapshot.Limits;
+import io.teaql.core.sqlite.SqliteCommittedSnapshot.SnapshotRecord;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+/** Durable startup bootstrap for one trusted source/target slot, not a remote API. */
+public final class SqliteCommittedSnapshotPublisher {
+ public record PreparedSnapshot(SnapshotRecord snapshot, boolean acknowledged) { }
+ /** Correlation, not authentication: the transport must authenticate its peer. */
+ public record Receipt(String sourceRuntimeId, String targetId, String snapshotId,
+ long sourceSequence, String modelHash, String payloadSha256, boolean duplicate) { }
+ @FunctionalInterface public interface SnapshotSink { Receipt deliver(SnapshotRecord record); }
+
+ private final SqlExecutionAdapter sql;
+ private final String source, target, model;
+ private final SqliteCommittedSnapshot snapshots;
+ public SqliteCommittedSnapshotPublisher(SqlExecutionAdapter sql, String source, String target, String model) {
+ this.sql = Objects.requireNonNull(sql, "sql");
+ if (target == null || target.isBlank()) throw failure("INVALID_SNAPSHOT", "target identity is required");
+ this.source = source; this.target = target; this.model = model;
+ this.snapshots = new SqliteCommittedSnapshot(sql, source, model);
+ }
+
+ /** Retain original bytes before delivery; retries never re-export a published identity. */
+ public PreparedSnapshot prepare(UserContext context, String snapshotId, String comment, String purpose, Limits limits) {
+ require(context, snapshotId, limits);
+ if (comment == null || comment.isBlank() || purpose == null || purpose.isBlank())
+ throw failure("INVALID_SNAPSHOT", "snapshot intent is required");
+ PreparedSnapshot prior = transaction(() -> existing(snapshotId, limits));
+ if (prior != null) return prior;
+ SnapshotRecord captured = snapshots.exportSnapshot(context, snapshotId, comment, purpose, limits);
+ return transaction(() -> {
+ PreparedSnapshot raced = existing(snapshotId, limits);
+ if (raced != null) return raced; // First durable record wins, never replace with a newer view.
+ if (!cursor().isEmpty()) throw failure("CURSOR_CONFLICT", "target stream has already started");
+ sql.update("INSERT INTO teaql_event_snapshot_publication "
+ + "(source_runtime_id,target_id,snapshot_id,source_sequence,model_hash,payload_sha256,payload_json,acknowledged) "
+ + "VALUES (?,?,?,?,?,?,?,0)", new Object[] {source,target,captured.snapshotId(),captured.sourceSequence(),model,captured.payloadSha256(),captured.payloadJson()});
+ return new PreparedSnapshot(captured, false);
+ });
+ }
+
+ /** One attempt. Lost or mismatching ACK leaves the retained job/cursor untouched. */
+ public PreparedSnapshot publish(UserContext context, String snapshotId, Limits limits, SnapshotSink sink) {
+ require(context, snapshotId, limits); Objects.requireNonNull(sink, "sink");
+ PreparedSnapshot prepared = transaction(() -> existing(snapshotId, limits));
+ if (prepared == null) throw failure("INVALID_SNAPSHOT", "snapshot has not been durably prepared");
+ if (prepared.acknowledged()) return prepared;
+ SnapshotRecord record = prepared.snapshot();
+ var journal = new SqliteCommittedObservation(sql,source,target,model);
+ var attempt = journal.begin("snapshot",record.sourceSequence(),record.snapshotId(),record.payloadSha256());
+ Receipt receipt;
+ try {
+ receipt = sink.deliver(record); // No source SQL transaction/connection held across transport.
+ if (receipt == null || !source.equals(receipt.sourceRuntimeId()) || !target.equals(receipt.targetId())
+ || !record.snapshotId().equals(receipt.snapshotId()) || record.sourceSequence() != receipt.sourceSequence()
+ || !model.equals(receipt.modelHash()) || !record.payloadSha256().equals(receipt.payloadSha256()))
+ throw failure("ACK_MISMATCH", "snapshot receipt does not match this source/target record");
+ } catch (RuntimeException error) { journal.failed(attempt,error); throw error; }
+ return journal.acknowledged(attempt, () -> {
+ PreparedSnapshot current = existing(snapshotId, limits);
+ if (current == null || !current.snapshot().equals(record)) throw failure("SNAPSHOT_CONFLICT", "prepared snapshot changed");
+ if (current.acknowledged()) return current; // Tail may already have advanced; never rewind.
+ if (!cursor().isEmpty()) throw failure("CURSOR_CONFLICT", "bootstrap cursor changed concurrently");
+ var status = new SqliteCommittedOutboxPublisher(sql,source,target,model).status();
+ if (status.sourceHighWatermark() < record.sourceSequence()) throw failure("INVALID_SNAPSHOT", "snapshot is ahead of source");
+ sql.update("INSERT INTO teaql_event_delivery_cursor(source_runtime_id,target_id,acked_sequence,model_hash) VALUES (?,?,?,?)",
+ new Object[] {source,target,record.sourceSequence(),model});
+ if (sql.update("UPDATE teaql_event_snapshot_publication SET acknowledged=1 WHERE source_runtime_id=? AND target_id=? "
+ + "AND snapshot_id=? AND payload_sha256=? AND acknowledged=0", new Object[] {source,target,snapshotId,record.payloadSha256()}) != 1)
+ throw failure("CURSOR_CONFLICT", "bootstrap acknowledgement changed concurrently");
+ return new PreparedSnapshot(record, true);
+ });
+ }
+
+ /** Target helper: construct a receipt only after the authoritative target transaction commits. */
+ public static Receipt initializeAndAcknowledge(UserContext context, SqliteCommittedSnapshot target,
+ SnapshotRecord record, String targetId, Limits limits) {
+ return initializeAndAcknowledge(context,target,record,targetId,SqliteCommittedSnapshot.ImportMode.REQUIRE_EMPTY,limits);
+ }
+ public static Receipt initializeAndAcknowledge(UserContext context, SqliteCommittedSnapshot target,
+ SnapshotRecord record, String targetId, SqliteCommittedSnapshot.ImportMode mode, Limits limits) {
+ if (targetId == null || targetId.isBlank()) throw failure("INVALID_SNAPSHOT", "target identity is required");
+ var outcome = target.initializeSnapshot(context,record,mode,limits);
+ if (outcome.checkpoint() != record.sourceSequence()) throw failure("ACK_MISMATCH", "target snapshot position differs");
+ return new Receipt(record.sourceRuntimeId(),targetId,record.snapshotId(),record.sourceSequence(),record.modelHash(),record.payloadSha256(),outcome.duplicate());
+ }
+
+ private PreparedSnapshot existing(String snapshotId, Limits limits) {
+ var rows = sql.queryForList("SELECT snapshot_id,source_sequence,model_hash,payload_sha256,payload_json,acknowledged "
+ + "FROM teaql_event_snapshot_publication WHERE source_runtime_id=? AND target_id=?", new Object[] {source,target});
+ if (rows.isEmpty()) return null;
+ var row = rows.get(0);
+ if (!snapshotId.equals(row.get("snapshot_id"))) throw failure("SNAPSHOT_CONFLICT", "target slot already has a different snapshot");
+ var record = new SnapshotRecord(source,((Number)row.get("source_sequence")).longValue(),snapshotId,
+ (String)row.get("model_hash"),(String)row.get("payload_sha256"),(String)row.get("payload_json"));
+ snapshots.validate(record,limits);
+ int flag = ((Number)row.get("acknowledged")).intValue();
+ if (flag != 0 && flag != 1) throw failure("INVALID_SNAPSHOT", "invalid acknowledgement state");
+ boolean acked = flag == 1;
+ var cursors = cursor();
+ if (!acked && !cursors.isEmpty() || acked && (cursors.size()!=1 || !model.equals(cursors.get(0).get("model_hash"))
+ || ((Number)cursors.get(0).get("acked_sequence")).longValue()> cursor() { return sql.queryForList(
+ "SELECT acked_sequence,model_hash FROM teaql_event_delivery_cursor WHERE source_runtime_id=? AND target_id=?",new Object[]{source,target}); }
+ private void require(UserContext context, String snapshotId, Limits limits) {
+ Objects.requireNonNull(context,"context"); Objects.requireNonNull(limits,"limits");
+ EntityMetaFactory.requireFrom(context);
+ if (snapshotId == null || snapshotId.isBlank()) throw failure("INVALID_SNAPSHOT","snapshot identity is required");
+ if (sql.hasActiveTransaction()) throw failure("INVALID_SNAPSHOT","bootstrap requires a standalone transaction");
+ }
+ private T transaction(Supplier action) { AtomicReference value=new AtomicReference<>();sql.executeInTransaction(()->{
+ if (!sql.hasActiveTransaction()) throw failure("INVALID_SNAPSHOT","bootstrap adapter must provide an active SQL transaction");
+ value.set(action.get());});return value.get(); }
+ private static SqliteCommittedEventStore.CommittedEventException failure(String code,String message) { return new SqliteCommittedEventStore.CommittedEventException(code,message); }
+}
diff --git a/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteModelCheckedCommittedTarget.java b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteModelCheckedCommittedTarget.java
new file mode 100644
index 00000000..7abb3316
--- /dev/null
+++ b/teaql-sqlite/src/main/java/io/teaql/core/sqlite/SqliteModelCheckedCommittedTarget.java
@@ -0,0 +1,445 @@
+package io.teaql.core.sqlite;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.teaql.core.UserContext;
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.meta.EntityMetaFactory;
+import io.teaql.core.meta.PropertyDescriptor;
+import io.teaql.core.meta.Relation;
+import io.teaql.core.sql.SQLColumn;
+import io.teaql.core.sql.portable.SQLPropertyUtil;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Startup-owned, one-source SQLite target for committed model facts. This is
+ * infrastructure, not a business-facing Mutation or remote query endpoint.
+ */
+public final class SqliteModelCheckedCommittedTarget {
+ private static final Set AUDIT_FIELDS = Set.of("actor_ref", "comment", "purpose", "trace_id");
+ private static final Set MUTATION_FIELDS = Set.of("entity_type", "entity_id", "operation",
+ "base_version", "committed_version", "fields", "trace_chain");
+ private static final Set TRACE_KINDS = Set.of(
+ "OPERATION", "REQUEST", "RELATION", "ENTITY", "PROVIDER", "SQL",
+ "COMMENT", "PURPOSE", "AUDIT_REASON");
+ private static final Pattern DECLARED_DECIMAL = Pattern.compile(
+ "(?i)\\s*(?:NUMERIC|DECIMAL)\\s*\\(\\s*([0-9]+)\\s*,\\s*([0-9]+)\\s*\\)\\s*");
+ private final SqlExecutionAdapter sql;
+ private final SqliteCommittedEventStore events;
+ private final String sourceRuntimeId;
+ private final String modelHash;
+
+ public SqliteModelCheckedCommittedTarget(SqlExecutionAdapter sql,
+ String sourceRuntimeId, String modelHash) {
+ this.sql = Objects.requireNonNull(sql, "sql");
+ if (sourceRuntimeId == null || sourceRuntimeId.isBlank()
+ || modelHash == null || !modelHash.matches("sha256:[0-9a-f]{64}")) {
+ throw invalid("target source identity or SHA-256 model hash is invalid");
+ }
+ this.sourceRuntimeId = sourceRuntimeId;
+ this.modelHash = modelHash;
+ sql.restrictOrdinaryMutations();
+ this.events = new SqliteCommittedEventStore(sql);
+ }
+
+ public void ensureSchema() { events.ensureSchema(); }
+
+ /** One graph, its Inbox identity, original audit payload and checkpoint commit together. */
+ public SqliteCommittedEventStore.InboxOutcome apply(
+ UserContext context, SqliteCommittedEventStore.OutboxRecord record) {
+ Objects.requireNonNull(context, "context");
+ if (record == null) throw invalid("committed event is missing");
+ if (!sourceRuntimeId.equals(record.sourceRuntimeId())) {
+ throw new SqliteCommittedEventStore.CommittedEventException(
+ "UNAUTHORIZED_SOURCE", "committed-event source is not authorized");
+ }
+ // Resolve from this Context, never from the legacy process-global fallback.
+ EntityMetaFactory metadata = EntityMetaFactory.requireFrom(context);
+ AtomicReference result = new AtomicReference<>();
+ sql.executeInTransaction(() -> result.set(events.apply(record, modelHash,
+ (transaction, payload) -> applyGraph(transaction, metadata, payload))));
+ return result.get();
+ }
+
+ private static void applyGraph(SqlExecutionAdapter sql, EntityMetaFactory metadata, JsonNode payload) {
+ JsonNode audit = payload.path("audit");
+ SqliteCommittedEventStore.rejectUnknownControls(audit, AUDIT_FIELDS);
+ if (!audit.isObject() || !nonblank(audit.path("comment"))
+ || !nonblank(audit.path("purpose"))
+ || !nonblank(payload.path("source_transaction_id"))) {
+ throw invalid("source transaction or audit intent is missing");
+ }
+ try { OffsetDateTime.parse(payload.path("committed_at").asText()); }
+ catch (RuntimeException failure) { throw invalid("commit time is invalid"); }
+ List mutations = new ArrayList<>();
+ Set seen = new HashSet<>();
+ for (JsonNode fact : payload.path("mutations")) {
+ Mutation mutation = decode(metadata, fact);
+ if (!seen.add(mutation.entityType() + ":" + mutation.id())) {
+ throw invalid("one set mutates the same entity twice");
+ }
+ mutations.add(mutation);
+ }
+ // Decode every fact before touching domain rows. The surrounding
+ // savepoint also rolls back a later FK or readback failure.
+ for (Mutation mutation : mutations) applyOne(sql, mutation);
+ }
+
+ private static Mutation decode(EntityMetaFactory metadata, JsonNode fact) {
+ return decode(metadata, fact, false);
+ }
+
+ /** Package-private physical restore mode; never accepted by ordinary event apply. */
+ static Mutation decodeSnapshotRow(EntityMetaFactory metadata, JsonNode row) {
+ if (!row.isObject() || row.size() != 4 || !row.has("entity_type")
+ || !row.has("entity_id") || !row.has("version") || !row.has("fields")) {
+ throw invalid("snapshot row has wrong shape");
+ }
+ long id = positiveId(row.path("entity_id"));
+ long version = signedVersion(row.path("version"));
+ if (!Long.toString(id).equals(row.path("entity_id").asText())
+ || !Long.toString(version).equals(row.path("version").asText())
+ || version == 0 || version == Long.MIN_VALUE) throw invalid("snapshot identity/version is invalid");
+ var fact = row.deepCopy();
+ var object = (com.fasterxml.jackson.databind.node.ObjectNode) fact;
+ object.remove("version"); object.put("committed_version", Long.toString(version));
+ object.put("operation", "create"); object.putNull("base_version"); object.putArray("trace_chain");
+ return decode(metadata, object, true);
+ }
+
+ private static Mutation decode(EntityMetaFactory metadata, JsonNode fact, boolean snapshot) {
+ if (!fact.isObject() || !nonblank(fact.path("entity_type"))) {
+ throw invalid("mutation entity type is missing");
+ }
+ SqliteCommittedEventStore.rejectUnknownControls(fact, MUTATION_FIELDS);
+ validateTraceChain(fact.path("trace_chain"));
+ String entityType = fact.path("entity_type").textValue();
+ EntityDescriptor entity = metadata.allEntityDescriptors().stream()
+ .filter(candidate -> entityType.equals(candidate.getType())).findFirst()
+ .orElseThrow(() -> invalid("unknown entity type"));
+ // Multi-table inheritance needs an explicit target mapping. Reject it
+ // instead of silently writing only the leaf table.
+ if (entity.getParent() != null || entity.hasChildren()) {
+ throw invalid("multi-table entity target mapping is not supported");
+ }
+ PropertyDescriptor idProperty = entity.findIdProperty();
+ PropertyDescriptor versionProperty = entity.findVersionProperty();
+ if (idProperty == null || versionProperty == null) {
+ throw invalid("entity has no ID or version property");
+ }
+ Mapping idMapping = mapping(idProperty);
+ Mapping versionMapping = mapping(versionProperty);
+ if (!idMapping.table().equals(versionMapping.table())) {
+ throw invalid("identity and version span multiple tables");
+ }
+ long id = positiveId(fact.path("entity_id"));
+ String operation = fact.path("operation").asText("");
+ if (!List.of("create", "update", "delete", "recover").contains(operation)) {
+ throw invalid("unknown mutation operation");
+ }
+ JsonNode baseNode = fact.path("base_version");
+ Long base = "create".equals(operation) && baseNode.isNull()
+ ? null : signedVersion(baseNode);
+ long committed = signedVersion(fact.path("committed_version"));
+ boolean valid = switch (operation) {
+ case "create" -> base == null && (snapshot ? committed != 0 && committed != Long.MIN_VALUE : committed == 1);
+ case "update" -> base != null && base > 0 && base < Long.MAX_VALUE
+ && committed == base + 1;
+ case "delete" -> base != null && base > 0 && base < Long.MAX_VALUE
+ && committed == -(base + 1);
+ case "recover" -> base != null && base < 0 && base != Long.MIN_VALUE
+ && -base < Long.MAX_VALUE && committed == -base + 1;
+ default -> false;
+ };
+ if (!valid) throw invalid("invalid version transition");
+ JsonNode supplied = fact.path("fields");
+ if (!supplied.isObject()) throw invalid("mutation fields are missing");
+ if ("delete".equals(operation) && !supplied.isEmpty()) {
+ throw invalid("delete must be a tombstone");
+ }
+ List ownProperties = entity.getOwnProperties();
+ supplied.fieldNames().forEachRemaining(name -> {
+ PropertyDescriptor property = entity.findProperty(name);
+ if (property == null || !ownProperties.contains(property)
+ || property.isId() || property.isVersion()) {
+ throw invalid("unknown or reserved model field");
+ }
+ });
+ List fields = new ArrayList<>();
+ for (PropertyDescriptor property : ownProperties) {
+ if (property.isId() || property.isVersion()) continue;
+ JsonNode value = supplied.get(property.getName());
+ if (value == null) {
+ if ("create".equals(operation)) throw invalid("create omitted a modeled field");
+ continue;
+ }
+ Mapping column = mapping(property);
+ if (!idMapping.table().equals(column.table())) {
+ throw invalid("mutation field spans multiple tables");
+ }
+ Class> type = property.getType() == null ? null : property.getType().javaType();
+ fields.add(new Field(column.column(), decodeValue(property, value),
+ type == BigDecimal.class || type == java.math.BigInteger.class));
+ }
+ return new Mutation(entityType, idMapping.table(), idMapping.column(),
+ versionMapping.column(), id, base, committed, operation, fields);
+ }
+
+ private static void validateTraceChain(JsonNode chain) {
+ if (!chain.isArray()) throw invalid("mutation trace chain is missing");
+ for (JsonNode node : chain) {
+ JsonNode id = node.path("entity_id");
+ if (!node.isObject() || node.size() != 4
+ || !TRACE_KINDS.contains(node.path("kind").asText(""))
+ || !node.path("name").isTextual()
+ || !node.path("comment").isTextual()
+ || !(id.isNull() || id.isTextual() && id.textValue().matches("[1-9][0-9]*"))) {
+ throw invalid("trace node has wrong shape");
+ }
+ }
+ }
+
+ static Object decodeValue(PropertyDescriptor property, JsonNode value) {
+ if (value.isNull()) {
+ if (property.getBoolean("required", false)) throw invalid("non-null field is null");
+ return null;
+ }
+ if (property instanceof Relation relation) {
+ if (!value.isObject() || value.size() != 2 || relation.getReverseProperty() == null
+ || relation.getReverseProperty().getOwner() == null
+ || !relation.getReverseProperty().getOwner().getType()
+ .equals(value.path("entity_type").asText())) {
+ throw invalid("reference target does not match model");
+ }
+ return positiveId(value.path("entity_id"));
+ }
+ Class> type = property.getType() == null ? null : property.getType().javaType();
+ if (type == java.time.LocalDate.class || type == java.sql.Date.class) {
+ if (!value.isTextual()) throw invalid("Date field has wrong type");
+ String text = value.textValue();
+ try {
+ if (!java.time.LocalDate.parse(text).toString().equals(text)) {
+ throw invalid("Date field has wrong type");
+ }
+ } catch (java.time.format.DateTimeParseException failure) {
+ throw invalid("Date field has wrong type");
+ }
+ return text;
+ }
+ if (type == java.time.Instant.class || type == java.util.Date.class
+ || type == java.sql.Timestamp.class) {
+ if (!value.isTextual() || !value.textValue().matches("-?(0|[1-9][0-9]*)")
+ || value.textValue().equals("-0")) {
+ throw invalid("Timestamp field has wrong type");
+ }
+ try { return Long.parseLong(value.textValue()); }
+ catch (NumberFormatException failure) { throw invalid("Timestamp field has wrong type"); }
+ }
+ if (type == java.time.LocalDateTime.class) {
+ throw invalid("zone-free LocalDateTime has no committed instant mapping");
+ }
+ if (type == String.class || type == java.util.UUID.class || type != null && type.isEnum()) {
+ if (!value.isTextual()) throw invalid("text field has wrong type");
+ return value.textValue();
+ }
+ if (type == Boolean.class || type == boolean.class) {
+ if (!value.isBoolean()) throw invalid("Boolean field has wrong type");
+ return value.booleanValue() ? 1 : 0;
+ }
+ if (type == Long.class || type == long.class || type == Integer.class || type == int.class
+ || type == Short.class || type == short.class || type == Byte.class || type == byte.class) {
+ if (!value.isTextual() || !value.textValue().matches("-?(0|[1-9][0-9]*)")
+ || value.textValue().equals("-0")) {
+ throw invalid("integer field has wrong type");
+ }
+ long number;
+ try { number = Long.parseLong(value.textValue()); }
+ catch (NumberFormatException failure) { throw invalid("integer field overflows"); }
+ if (type == Integer.class || type == int.class) {
+ if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) throw invalid("integer overflows field");
+ } else if (type == Short.class || type == short.class) {
+ if (number < Short.MIN_VALUE || number > Short.MAX_VALUE) throw invalid("integer overflows field");
+ } else if (type == Byte.class || type == byte.class) {
+ if (number < Byte.MIN_VALUE || number > Byte.MAX_VALUE) throw invalid("integer overflows field");
+ }
+ return number;
+ }
+ if (type == BigDecimal.class || type == java.math.BigInteger.class) {
+ if (!value.isTextual()) throw invalid("Decimal field has wrong type");
+ try {
+ if (type == java.math.BigInteger.class) {
+ if (!value.textValue().matches("-?(0|[1-9][0-9]*)")
+ || value.textValue().equals("-0")) {
+ throw invalid("integer field has wrong type");
+ }
+ return new java.math.BigInteger(value.textValue()).toString();
+ }
+ BigDecimal decimal = new BigDecimal(value.textValue());
+ if (!fitsDeclaredDecimal(property, decimal)) {
+ throw invalid("Decimal lacks or exceeds modeled precision/scale");
+ }
+ return decimal.toPlainString();
+ }
+ catch (NumberFormatException failure) { throw invalid("Decimal field is invalid"); }
+ }
+ if (type == Double.class || type == double.class || type == Float.class || type == float.class) {
+ if (!value.isNumber() || !Double.isFinite(value.doubleValue())) {
+ throw invalid("floating field has wrong type");
+ }
+ return value.doubleValue();
+ }
+ if (type == byte[].class && value.isObject() && value.size() == 1
+ && value.path("$binary_base64").isTextual()) {
+ try { return java.util.Base64.getDecoder().decode(value.path("$binary_base64").textValue()); }
+ catch (IllegalArgumentException failure) { throw invalid("binary field is invalid"); }
+ }
+ throw invalid("unsupported modeled field type");
+ }
+
+ static void applyOne(SqlExecutionAdapter sql, Mutation mutation) {
+ String table = quoted(mutation.table());
+ String idColumn = quoted(mutation.idColumn());
+ String versionColumn = quoted(mutation.versionColumn());
+ List> current = sql.queryForList("SELECT " + versionColumn
+ + " AS committed_version FROM " + table + " WHERE " + idColumn + " = ?",
+ new Object[] {mutation.id()});
+ Long actualVersion = current.isEmpty() ? null : ((Number) current.get(0).get("committed_version")).longValue();
+ if (!Objects.equals(actualVersion, mutation.baseVersion())) {
+ throw new SqliteCommittedEventStore.CommittedEventException("VERSION_CONFLICT",
+ "committed-event predecessor version mismatch");
+ }
+ List args = new ArrayList<>();
+ if ("create".equals(mutation.operation())) {
+ List columns = new ArrayList<>(List.of(idColumn, versionColumn));
+ args.add(mutation.id());
+ args.add(mutation.committedVersion());
+ for (Field field : mutation.fields()) {
+ columns.add(quoted(field.column()));
+ args.add(field.value());
+ }
+ sql.update("INSERT INTO " + table + " (" + String.join(", ", columns) + ") VALUES ("
+ + String.join(", ", java.util.Collections.nCopies(args.size(), "?")) + ")",
+ args.toArray());
+ } else {
+ List assignments = new ArrayList<>(List.of(versionColumn + " = ?"));
+ args.add(mutation.committedVersion());
+ for (Field field : mutation.fields()) {
+ assignments.add(quoted(field.column()) + " = ?");
+ args.add(field.value());
+ }
+ args.add(mutation.id());
+ args.add(mutation.baseVersion());
+ int changed = sql.update("UPDATE " + table + " SET " + String.join(", ", assignments)
+ + " WHERE " + idColumn + " = ? AND " + versionColumn + " = ?", args.toArray());
+ if (changed != 1) {
+ throw new SqliteCommittedEventStore.CommittedEventException("VERSION_CONFLICT",
+ "committed-event predecessor version mismatch");
+ }
+ }
+ verifyReadback(sql, mutation);
+ }
+
+ static void verifyReadback(SqlExecutionAdapter sql, Mutation mutation) {
+ List projections = new ArrayList<>(List.of(quoted(mutation.versionColumn()) + " AS committed_version"));
+ for (int i = 0; i < mutation.fields().size(); i++) {
+ projections.add(quoted(mutation.fields().get(i).column()) + " AS field_" + i);
+ }
+ List> rows = sql.queryForList("SELECT " + String.join(", ", projections)
+ + " FROM " + quoted(mutation.table()) + " WHERE " + quoted(mutation.idColumn()) + " = ?",
+ new Object[] {mutation.id()});
+ if (rows.size() != 1 || !Objects.equals(mutation.committedVersion(),
+ ((Number) rows.get(0).get("committed_version")).longValue())) {
+ throw invalid("target did not retain committed version");
+ }
+ for (int i = 0; i < mutation.fields().size(); i++) {
+ if (!sameValue(mutation.fields().get(i), rows.get(0).get("field_" + i))) {
+ throw invalid("target did not retain committed field value");
+ }
+ }
+ }
+
+ static boolean sameValue(Field field, Object actual) {
+ Object expected = field.value();
+ if (expected instanceof byte[] bytes) return actual instanceof byte[] other && Arrays.equals(bytes, other);
+ if (expected == null || actual == null) return expected == actual;
+ // SQLite REAL is approximate even when Double.toString() happens to
+ // match the exact source decimal (for example, 0.1).
+ if (field.decimal() && (actual instanceof Double || actual instanceof Float)) return false;
+ if (field.decimal() || expected instanceof Number && actual instanceof Number) {
+ try { return new BigDecimal(expected.toString()).compareTo(new BigDecimal(actual.toString())) == 0; }
+ catch (NumberFormatException failure) { return false; }
+ }
+ return Objects.equals(expected, actual);
+ }
+
+ static Mapping mapping(PropertyDescriptor property) {
+ List columns = SQLPropertyUtil.getColumns(property);
+ if (columns.size() != 1) throw invalid("property has no single SQLite column");
+ return new Mapping(columns.get(0).getTableName(), columns.get(0).getColumnName());
+ }
+
+ private static boolean fitsDeclaredDecimal(PropertyDescriptor property, BigDecimal value) {
+ List columns = SQLPropertyUtil.getColumns(property);
+ if (columns.size() != 1) return false;
+ String declaredType = columns.get(0).getType();
+ if (declaredType == null) return false;
+ Matcher declaration = DECLARED_DECIMAL.matcher(declaredType);
+ if (!declaration.matches()) return false;
+ try {
+ int precision = Integer.parseInt(declaration.group(1));
+ int scale = Integer.parseInt(declaration.group(2));
+ if (precision < 1 || scale > precision) return false;
+ BigDecimal normalized = value.stripTrailingZeros();
+ long integerDigits = value.signum() == 0 ? 0
+ : Math.max(0L, (long) normalized.precision() - normalized.scale());
+ long fractionalDigits = Math.max(0L, normalized.scale());
+ return integerDigits <= precision - scale && fractionalDigits <= scale;
+ } catch (NumberFormatException invalidDeclaration) {
+ return false;
+ }
+ }
+
+ static String quoted(String identifier) {
+ if (identifier == null || identifier.isBlank()) throw invalid("model has an empty SQL identifier");
+ return "\"" + identifier.replace("\"", "\"\"") + "\"";
+ }
+
+ private static long positiveId(JsonNode value) {
+ if (!value.isTextual()) throw invalid("identity must be a decimal string");
+ try {
+ long id = Long.parseLong(value.textValue());
+ if (id > 0) return id;
+ } catch (NumberFormatException ignored) { }
+ throw invalid("identity is outside the SQLite ID range");
+ }
+
+ private static long signedVersion(JsonNode value) {
+ if (!value.isTextual()) throw invalid("version must be a decimal string");
+ try { return Long.parseLong(value.textValue()); }
+ catch (NumberFormatException failure) { throw invalid("version must be a decimal string"); }
+ }
+
+ private static boolean nonblank(JsonNode value) { return value.isTextual() && !value.textValue().isBlank(); }
+ private static SqliteCommittedEventStore.CommittedEventException invalid(String reason) {
+ return new SqliteCommittedEventStore.CommittedEventException(
+ "INVALID_ENVELOPE", "invalid committed-event envelope: " + reason);
+ }
+
+ record Mapping(String table, String column) { }
+ record Field(String column, Object value, boolean decimal) { }
+ record Mutation(String entityType, String table, String idColumn,
+ String versionColumn, long id, Long baseVersion, long committedVersion,
+ String operation, List fields) { }
+}
diff --git a/teaql-sqlite/src/main/java/module-info.java b/teaql-sqlite/src/main/java/module-info.java
index 98b73c4b..82c0d1ef 100644
--- a/teaql-sqlite/src/main/java/module-info.java
+++ b/teaql-sqlite/src/main/java/module-info.java
@@ -4,6 +4,8 @@
requires transitive io.teaql.utils;
requires transitive io.teaql.dataservice.sql;
requires io.teaql.provider.jdbc;
+ requires io.teaql.runtime;
+ requires com.fasterxml.jackson.databind;
requires org.xerial.sqlitejdbc;
requires java.sql;
exports io.teaql.core.sqlite;
diff --git a/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteCommittedEventStoreTest.java b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteCommittedEventStoreTest.java
new file mode 100644
index 00000000..5eb5c4f0
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/core/sqlite/SqliteCommittedEventStoreTest.java
@@ -0,0 +1,301 @@
+package io.teaql.core.sqlite;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import io.teaql.core.sqlite.SqliteCommittedEventStore;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.CommittedEventException;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.OutboxRecord;
+import io.teaql.dataservice.sql.SqlExecutionAdapter;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.*;
+
+/** Small SQLite graph fixture shared in meaning with CMR v1; no generated ERP model. */
+public class SqliteCommittedEventStoreTest {
+ private static final String MODEL_HASH = "sha256:" + "0".repeat(64);
+ private static final String SOURCE = "sqlite-order-source";
+ private static final long ORDER_ID = 9_007_199_254_740_993L;
+ private static final long ITEM_ID = ORDER_ID + 1;
+
+ private record Database(JdbcSqlExecutor driver, SqliteCommittedEventStore events) {}
+
+ private static Database database(Path path) {
+ SQLiteDataSource source = new SQLiteDataSource();
+ source.setUrl("jdbc:sqlite:" + path);
+ JdbcSqlExecutor driver = new JdbcSqlExecutor(source);
+ return new Database(driver, new SqliteCommittedEventStore(driver));
+ }
+
+ private static void graphSchema(SqlExecutionAdapter sql) {
+ sql.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT NOT NULL, version INTEGER NOT NULL)");
+ sql.execute("CREATE TABLE order_items (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, "
+ + "quantity INTEGER NOT NULL, version INTEGER NOT NULL)");
+ }
+
+ private static long count(SqlExecutionAdapter sql, String table) {
+ // This helper only receives the two fixed table names from this test.
+ return ((Number) sql.queryForList("SELECT count(*) AS total FROM " + table,
+ new Object[0]).get(0).get("total")).longValue();
+ }
+
+ private static String payload(long sequence, String change, boolean submitted) {
+ String operation = submitted ? "update" : "create";
+ String itemOperation = submitted ? "delete" : "create";
+ String status = submitted ? "SUBMITTED" : "DRAFT";
+ String version = submitted ? "2" : "1";
+ String itemVersion = submitted ? "-2" : "1";
+ String baseVersion = submitted ? "\"1\"" : "null";
+ return ("""
+ {"protocol_version":1,"source_runtime_id":"%s","source_sequence":"%d",
+ "change_set_id":"%s","source_model_hash":"%s","mutations":[
+ {"entity_type":"order","entity_id":"%d","operation":"%s",
+ "base_version":%s,"committed_version":"%s","fields":{"status":"%s"}},
+ {"entity_type":"order_item","entity_id":"%d","operation":"%s",
+ "base_version":%s,"committed_version":"%s",
+ "fields":{"order":{"entity_type":"order","entity_id":"%d"},"quantity":"2"}}
+ ]}
+ """).formatted(SOURCE, sequence, change, MODEL_HASH, ORDER_ID, operation,
+ baseVersion, version, status, ITEM_ID, itemOperation, baseVersion,
+ itemVersion, ORDER_ID);
+ }
+
+ private static void applyGraph(SqlExecutionAdapter sql, JsonNode facts) {
+ for (JsonNode mutation : facts.path("mutations")) {
+ long id = Long.parseLong(mutation.path("entity_id").asText());
+ long version = Long.parseLong(mutation.path("committed_version").asText());
+ String operation = mutation.path("operation").asText();
+ if (mutation.path("entity_type").asText().equals("order")) {
+ String status = mutation.path("fields").path("status").asText();
+ if (operation.equals("create")) {
+ sql.update("INSERT INTO orders (id, status, version) VALUES (?, ?, ?)",
+ new Object[] {id, status, version});
+ } else {
+ sql.update("UPDATE orders SET status = ?, version = ? WHERE id = ?",
+ new Object[] {status, version, id});
+ }
+ } else if (operation.equals("create")) {
+ long parent = Long.parseLong(mutation.path("fields").path("order").path("entity_id").asText());
+ int quantity = mutation.path("fields").path("quantity").asInt();
+ sql.update("INSERT INTO order_items (id, order_id, quantity, version) VALUES (?, ?, ?, ?)",
+ new Object[] {id, parent, quantity, version});
+ } else {
+ sql.update("UPDATE order_items SET version = ? WHERE id = ?", new Object[] {version, id});
+ }
+ }
+ }
+
+ private static OutboxRecord append(Database database, String change, boolean submitted) {
+ AtomicReference result = new AtomicReference<>();
+ database.driver().executeInTransaction(() -> {
+ if (submitted) {
+ database.driver().update("UPDATE orders SET status = 'SUBMITTED', version = 2 WHERE id = ?",
+ new Object[] {ORDER_ID});
+ database.driver().update("UPDATE order_items SET version = -2 WHERE id = ?",
+ new Object[] {ITEM_ID});
+ } else {
+ database.driver().update("INSERT INTO orders (id, status, version) VALUES (?, 'DRAFT', 1)",
+ new Object[] {ORDER_ID});
+ database.driver().update("INSERT INTO order_items (id, order_id, quantity, version) "
+ + "VALUES (?, ?, 2, 1)", new Object[] {ITEM_ID, ORDER_ID});
+ }
+ result.set(database.events().append(SOURCE, change, MODEL_HASH,
+ sequence -> payload(sequence, change, submitted)));
+ });
+ return result.get();
+ }
+
+ private static CommittedEventException expectCode(String code, Runnable action) {
+ try {
+ action.run();
+ fail("expected " + code);
+ throw new AssertionError("unreachable");
+ } catch (CommittedEventException failure) {
+ assertEquals(code, failure.code());
+ return failure;
+ }
+ }
+
+ @Test public void sourceRollbackAndRestartPreserveAtomicOrderedFacts() throws Exception {
+ Path path = Files.createTempFile("teaql-java-cmr-source-", ".sqlite");
+ try {
+ Database source = database(path);
+ source.events().ensureSchema();
+ graphSchema(source.driver());
+ try {
+ source.driver().executeInTransaction(() -> {
+ source.driver().update("INSERT INTO orders (id, status, version) VALUES (?, 'DRAFT', 1)",
+ new Object[] {ORDER_ID});
+ source.driver().update("INSERT INTO order_items (id, order_id, quantity, version) "
+ + "VALUES (?, ?, 2, 1)", new Object[] {ITEM_ID, ORDER_ID});
+ source.events().append(SOURCE, "change-a", MODEL_HASH,
+ sequence -> payload(sequence, "change-a", false));
+ throw new IllegalStateException("roll back complete graph");
+ });
+ fail("expected source rollback");
+ } catch (IllegalStateException expected) {
+ assertEquals("roll back complete graph", expected.getMessage());
+ }
+ assertEquals(0, count(source.driver(), "orders"));
+ assertEquals(0, count(source.driver(), "order_items"));
+ assertTrue(source.events().outboxAfter(SOURCE, 0, 10).isEmpty());
+
+ OutboxRecord first = append(source, "change-a", false);
+ OutboxRecord second = append(source, "change-b", true);
+ assertFalse("raw facts must not appear in ordinary formatting", first.toString().contains("DRAFT"));
+ assertFalse(first.toString().contains("order_item"));
+ assertEquals(1, first.sourceSequence());
+ assertEquals(2, second.sourceSequence());
+
+ Database reopened = database(path);
+ reopened.events().ensureSchema();
+ List rows = reopened.events().outboxAfter(SOURCE, 0, 10);
+ assertEquals(List.of(first, second), rows);
+ assertEquals(List.of(second), reopened.events().outboxAfter(SOURCE, 1, 1));
+ assertEquals(1, count(reopened.driver(), "orders"));
+ assertEquals(1, count(reopened.driver(), "order_items"));
+ Map item = reopened.driver().queryForList(
+ "SELECT version FROM order_items WHERE id = ?", new Object[] {ITEM_ID}).get(0);
+ assertEquals(-2, ((Number) item.get("version")).longValue());
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ @Test public void targetApplyDeduplicatesAndRollsBackWholeGraphOnFailure() throws Exception {
+ Path path = Files.createTempFile("teaql-java-cmr-target-", ".sqlite");
+ try {
+ Database target = database(path);
+ target.events().ensureSchema();
+ graphSchema(target.driver());
+ OutboxRecord first = record(1, "change-a", false);
+ OutboxRecord second = record(2, "change-b", true);
+
+ target.driver().executeInTransaction(() -> expectCode("SEQUENCE_GAP",
+ () -> target.events().apply(second, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph)));
+ assertEquals(0, count(target.driver(), "orders"));
+ assertEquals(0, count(target.driver(), "teaql_event_inbox"));
+
+ target.driver().executeInTransaction(() -> {
+ var outcome = target.events().apply(first, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph);
+ assertFalse(outcome.duplicate());
+ assertEquals(1, outcome.checkpoint());
+ });
+ target.driver().executeInTransaction(() -> {
+ expectCode("MODEL_MISMATCH", () -> target.events().apply(second,
+ "sha256:" + "1".repeat(64), SqliteCommittedEventStoreTest::applyGraph));
+ try {
+ target.events().apply(second, MODEL_HASH, (sql, facts) -> {
+ sql.update("UPDATE orders SET status = 'PARTIAL' WHERE id = ?", new Object[] {ORDER_ID});
+ sql.update("UPDATE order_items SET version = -99 WHERE id = ?", new Object[] {ITEM_ID});
+ throw new IllegalStateException("injected second-table failure");
+ });
+ fail("expected injected failure");
+ } catch (IllegalStateException expected) {
+ assertEquals("injected second-table failure", expected.getMessage());
+ }
+ });
+ assertEquals("DRAFT", target.driver().queryForList("SELECT status FROM orders", new Object[0])
+ .get(0).get("status"));
+ assertEquals(1L, ((Number) target.driver().queryForList("SELECT version FROM order_items",
+ new Object[0]).get(0).get("version")).longValue());
+ assertEquals(1, count(target.driver(), "teaql_event_inbox"));
+ assertEquals(1, count(target.driver(), "teaql_event_applied_payload"));
+
+ Database reopened = database(path);
+ reopened.driver().executeInTransaction(() -> {
+ var outcome = reopened.events().apply(second, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph);
+ assertFalse(outcome.duplicate());
+ assertEquals(2, outcome.checkpoint());
+ var duplicate = reopened.events().apply(second, MODEL_HASH, (sql, facts) -> {
+ throw new AssertionError("duplicate must not rematerialize");
+ });
+ assertTrue(duplicate.duplicate());
+ assertEquals(2, duplicate.checkpoint());
+ var olderDuplicate = reopened.events().apply(first, MODEL_HASH, (sql, facts) -> {
+ throw new AssertionError("an older duplicate must not rematerialize");
+ });
+ assertTrue(olderDuplicate.duplicate());
+ assertEquals(1, olderDuplicate.checkpoint());
+ });
+ assertEquals("SUBMITTED", reopened.driver().queryForList("SELECT status FROM orders", new Object[0])
+ .get(0).get("status"));
+ assertEquals(-2L, ((Number) reopened.driver().queryForList(
+ "SELECT version FROM order_items", new Object[0]).get(0).get("version")).longValue());
+ assertEquals(2, count(reopened.driver(), "teaql_event_inbox"));
+ assertEquals(2, count(reopened.driver(), "teaql_event_applied_payload"));
+ assertEquals(2L, ((Number) reopened.driver().queryForList(
+ "SELECT source_sequence FROM teaql_event_checkpoint", new Object[0])
+ .get(0).get("source_sequence")).longValue());
+
+ OutboxRecord conflicted = record(2, "change-b", false);
+ reopened.driver().executeInTransaction(() -> expectCode("DUPLICATE_CONFLICT",
+ () -> reopened.events().apply(conflicted, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph)));
+ OutboxRecord invalidDigest = new OutboxRecord(second.sourceRuntimeId(), second.sourceSequence(),
+ second.changeSetId(), second.modelHash(), "f".repeat(64), second.payloadJson());
+ reopened.driver().executeInTransaction(() -> expectCode("INVALID_ENVELOPE",
+ () -> reopened.events().apply(invalidDigest, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph)));
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ @Test public void appendAndApplyRequireTheCallerTransaction() throws Exception {
+ Path path = Files.createTempFile("teaql-java-cmr-boundary-", ".sqlite");
+ try {
+ Database database = database(path);
+ database.events().ensureSchema();
+ OutboxRecord first = record(1, "change-a", false);
+ expectCode("INVALID_ENVELOPE", () -> database.events().append(SOURCE, "change-a",
+ MODEL_HASH, sequence -> payload(sequence, "change-a", false)));
+ expectCode("INVALID_ENVELOPE", () -> database.events().apply(first,
+ MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph));
+ assertEquals(0, count(database.driver(), "teaql_event_outbox"));
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ @Test public void duplicateRequiresCheckpointCoveringRetainedInbox() throws Exception {
+ Path path = Files.createTempFile("teaql-java-cmr-checkpoint-", ".sqlite");
+ try {
+ Database target = database(path);
+ target.events().ensureSchema();
+ graphSchema(target.driver());
+ OutboxRecord first = record(1, "change-a", false);
+ OutboxRecord second = record(2, "change-b", true);
+ target.driver().executeInTransaction(() -> {
+ target.events().apply(first, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph);
+ target.events().apply(second, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph);
+ });
+
+ target.driver().update("UPDATE teaql_event_checkpoint SET source_sequence = 1 "
+ + "WHERE source_runtime_id = ?", new Object[] {SOURCE});
+ target.driver().executeInTransaction(() -> expectCode("INVALID_ENVELOPE",
+ () -> target.events().apply(second, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph)));
+
+ target.driver().update("DELETE FROM teaql_event_checkpoint WHERE source_runtime_id = ?",
+ new Object[] {SOURCE});
+ target.driver().executeInTransaction(() -> expectCode("INVALID_ENVELOPE",
+ () -> target.events().apply(first, MODEL_HASH, SqliteCommittedEventStoreTest::applyGraph)));
+ assertEquals(2, count(target.driver(), "teaql_event_inbox"));
+ assertEquals(2, count(target.driver(), "teaql_event_applied_payload"));
+ } finally {
+ Files.deleteIfExists(path);
+ }
+ }
+
+ private static OutboxRecord record(long sequence, String change, boolean submitted) throws Exception {
+ String json = payload(sequence, change, submitted);
+ byte[] bytes = java.security.MessageDigest.getInstance("SHA-256")
+ .digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ return new OutboxRecord(SOURCE, sequence, change, MODEL_HASH,
+ java.util.HexFormat.of().formatHex(bytes), json);
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java
index c0159efb..ad981399 100644
--- a/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java
@@ -5,15 +5,29 @@
import io.teaql.core.sql.GenericSQLProperty;
import io.teaql.core.sql.SQLEntityDescriptor;
import io.teaql.core.sqlite.SqliteDataServiceExecutor;
+import io.teaql.core.sqlite.SqliteCommittedMutationRecorder;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.teaql.provider.jdbc.JdbcSqlExecutor;
import io.teaql.runtime.*;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
import java.nio.file.Files;
+import java.sql.Connection;
+import java.sql.SQLException;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.logging.Handler;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import javax.sql.DataSource;
import io.teaql.core.checker.Checker;
import io.teaql.core.checker.CheckException;
import io.teaql.core.checker.FixEvidence;
@@ -27,13 +41,24 @@ public class GraphTraceSqliteTest {
public static final class GraphEntity extends BaseEntity {
private final String type;
private final Map values = new HashMap<>();
+ boolean failPostCommitReconcile;
public GraphEntity(String type) { this.type = type; }
@Override public String typeName() { return type; }
@Override public Object __internalGet(String field) {
- return field.equals("name") || field.equals("memo") || field.equals("children") ? values.get(field) : super.__internalGet(field);
+ return field.equals("name") || field.equals("memo") || field.equals("children")
+ || field.equals("business_counter") || field.equals("planned_date")
+ || field.equals("active") || field.equals("ratio") || field.equals("optional_note")
+ || field.equals("occurred_at") || field.equals("wall_time")
+ ? values.get(field) : super.__internalGet(field);
}
@Override public void __internalSet(String field, Object value) {
- if (field.equals("name") || field.equals("memo") || field.equals("children")) values.put(field, value);
+ if (failPostCommitReconcile && field.equals("version")) {
+ throw new IllegalStateException("diagnostic reconciliation failure");
+ }
+ if (field.equals("name") || field.equals("memo") || field.equals("children")
+ || field.equals("business_counter") || field.equals("planned_date")
+ || field.equals("active") || field.equals("ratio") || field.equals("optional_note")
+ || field.equals("occurred_at") || field.equals("wall_time")) values.put(field, value);
else super.__internalSet(field, value);
}
}
@@ -41,10 +66,12 @@ public static final class GraphEntity extends BaseEntity {
static final class Fixture {
final List sql = new CopyOnWriteArrayList<>();
final List audit = new CopyOnWriteArrayList<>();
+ final List rawAudit = new CopyOnWriteArrayList<>();
final List commands = new CopyOnWriteArrayList<>();
final List results = new CopyOnWriteArrayList<>();
boolean queryLogging = true;
boolean mutationLogging = true;
+ boolean failStandardAudit;
final List itemInsertBatchSizes = new CopyOnWriteArrayList<>();
final List itemUpdateBatchSizes = new CopyOnWriteArrayList<>();
final List itemDeleteBatchSizes = new CopyOnWriteArrayList<>();
@@ -52,14 +79,24 @@ static final class Fixture {
final JdbcSqlExecutor driver;
final SQLiteDataSource dataSource;
final DefaultUserContext context;
+ final SqliteCommittedMutationRecorder committedRecorder;
volatile boolean failReadback;
+ volatile boolean errorReadback;
+ volatile boolean errorReadbackWithoutSuppression;
volatile boolean unknownUpdateCounts;
- Fixture() throws Exception {
+ Fixture() throws Exception { this(false); }
+
+ Fixture(boolean captureCommittedMutations) throws Exception {
+ this(captureCommittedMutations, source -> source);
+ }
+
+ Fixture(boolean captureCommittedMutations,
+ Function transactionSource) throws Exception {
var ds = new SQLiteDataSource();
dataSource = ds;
ds.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-graph-trace-", ".db"));
- driver = new JdbcSqlExecutor(ds) {
+ driver = new JdbcSqlExecutor(transactionSource.apply(ds)) {
@Override public int[] batchUpdate(String text, List rows) {
if (text.startsWith("INSERT INTO order_item_data")) {
itemInsertBatchSizes.add(rows.size());
@@ -79,6 +116,13 @@ static final class Fixture {
return counts;
}
@Override public List> queryForList(String text, Object[] args) {
+ if (errorReadbackWithoutSuppression && text.startsWith("SELECT * FROM")
+ && text.contains("customer_order_data")) {
+ throw new NoSuppressionError();
+ }
+ if (errorReadback && text.startsWith("SELECT * FROM") && text.contains("customer_order_data")) {
+ throw new AssertionError("synthetic Error after business write");
+ }
if (failReadback && text.startsWith("SELECT * FROM") && text.contains("customer_order_data")) {
// A real driver failure after a successful write, inside the same transaction.
execute("DROP TABLE customer_order_data");
@@ -86,6 +130,9 @@ static final class Fixture {
return super.queryForList(text, args);
}
};
+ committedRecorder = captureCommittedMutations
+ ? new SqliteCommittedMutationRecorder(driver, "java-graph-source", "sha256:" + "0".repeat(64))
+ : null;
var metadata = new SimpleEntityMetaFactory();
for (String type : List.of("CustomerOrder", "OrderItem", "Payment", "PaymentAttempt", "Shipment")) {
var descriptor = new SQLEntityDescriptor();
@@ -100,6 +147,26 @@ static final class Fixture {
textField ? String.class : Long.class);
property.setColumnType(textField ? "VARCHAR(255)" : "BIGINT");
}
+ if (captureCommittedMutations && type.equals("CustomerOrder")) {
+ var counter = (GenericSQLProperty) descriptor.addSimpleProperty("business_counter", Long.class);
+ counter.setColumnType("BIGINT");
+ var plannedDate = (GenericSQLProperty) descriptor.addSimpleProperty(
+ "planned_date", java.time.LocalDate.class);
+ plannedDate.setColumnType("DATE");
+ var active = (GenericSQLProperty) descriptor.addSimpleProperty("active", Boolean.class);
+ active.setColumnType("BOOLEAN");
+ var ratio = (GenericSQLProperty) descriptor.addSimpleProperty("ratio", Double.class);
+ ratio.setColumnType("DOUBLE");
+ var optionalNote = (GenericSQLProperty) descriptor.addSimpleProperty(
+ "optional_note", String.class);
+ optionalNote.setColumnType("VARCHAR(255)");
+ var occurredAt = (GenericSQLProperty) descriptor.addSimpleProperty(
+ "occurred_at", java.time.Instant.class);
+ occurredAt.setColumnType("BIGINT");
+ var wallTime = (GenericSQLProperty) descriptor.addSimpleProperty(
+ "wall_time", java.time.LocalDateTime.class);
+ wallTime.setColumnType("TIMESTAMP");
+ }
var children = new Relation();
children.setName("children");
children.setOwner(descriptor);
@@ -124,9 +191,20 @@ static final class Fixture {
}
};
var ids = new AtomicLong(1000);
- var runtime = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite", provider)
+ var builder = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite", provider)
.idGenerationService((caller, entity) -> ids.getAndIncrement())
- .logSink((caller, entry) -> sql.add(entry)).build();
+ .logSink(new RuntimeLogSink() {
+ @Override public void writeExecutionLog(UserContext caller, ExecutionMetadata entry) {
+ sql.add(entry);
+ }
+
+ @Override public void writeAuditEvent(UserContext caller, RawAuditEvent event) {
+ if (failStandardAudit) throw new IllegalStateException("standard audit sink failure");
+ rawAudit.add(event);
+ }
+ });
+ if (committedRecorder != null) builder.committedMutationRecorder(committedRecorder);
+ var runtime = builder.build();
context = new DefaultUserContext(runtime) {
@Override public boolean isQueryExecutionLoggingEnabled() { return queryLogging; }
@Override public boolean isMutationExecutionLoggingEnabled() { return mutationLogging; }
@@ -137,7 +215,7 @@ static final class Fixture {
}
void clear() {
- sql.clear(); audit.clear(); commands.clear(); results.clear(); itemInsertBatchSizes.clear();
+ sql.clear(); audit.clear(); rawAudit.clear(); commands.clear(); results.clear(); itemInsertBatchSizes.clear();
itemUpdateBatchSizes.clear(); itemDeleteBatchSizes.clear(); itemRecoverBatchSizes.clear();
}
@@ -150,6 +228,12 @@ GraphEntity create(String type, long id, String name) {
}
}
+ private static final class NoSuppressionError extends Error {
+ private NoSuppressionError() {
+ super("synthetic Error without suppressed diagnostics", null, false, false);
+ }
+ }
+
private static void await(CountDownLatch latch) {
try {
assertTrue("overlapping execution must reach its checkpoint", latch.await(10, TimeUnit.SECONDS));
@@ -445,6 +529,648 @@ private static CheckedSave checkedSave(Fixture fixture, GraphEntity entity, Stri
assertEquals(Long.valueOf(-2), removed.getVersion());
}
+ @Test public void graphSaveCapturesAuthoritativeRowsAndOutboxInTheSameTransaction() throws Exception {
+ var fixture = new Fixture(true);
+ assertThrows(SecurityException.class, () -> fixture.committedRecorder.ensureSchema(fixture.context, null));
+ fixture.driver.execute("CREATE TRIGGER order_db_default AFTER INSERT ON customer_order_data "
+ + "BEGIN UPDATE customer_order_data SET memo = 'database trigger value' WHERE id = NEW.id; END");
+ var order = fixture.create("CustomerOrder", 100, "root value");
+ order.updateProperty("business_counter", 9_007_199_254_740_993L);
+ order.updateProperty("planned_date", java.time.LocalDate.of(2026, 10, 16));
+ order.updateProperty("active", true);
+ order.updateProperty("ratio", 0.5);
+ order.updateProperty("optional_note", null);
+ order.updateProperty("occurred_at", java.time.Instant.parse("1960-01-01T00:00:00.123Z"));
+ var item = fixture.create("OrderItem", 201, "first item");
+ item.setComment("create item");
+ var payment = fixture.create("Payment", 300, "payment value");
+ order.__internalSet("children", List.of(item, payment));
+ order.auditAs("create complete order graph").save(fixture.context);
+
+ var events = fixture.committedRecorder.eventStore();
+ var first = events.outboxAfter("java-graph-source", 0, 10);
+ assertEquals("one graph save must produce one durable set", 1, first.size());
+ JsonNode created = new ObjectMapper().readTree(first.get(0).payloadJson());
+ assertEquals("create complete order graph", created.path("audit").path("comment").asText());
+ assertEquals(3, created.path("mutations").size());
+ JsonNode orderFact = null;
+ for (JsonNode fact : created.path("mutations")) {
+ assertEquals("create", fact.path("operation").asText());
+ assertTrue(fact.path("base_version").isNull());
+ assertEquals("1", fact.path("committed_version").asText());
+ assertTrue(fact.path("trace_chain").isArray());
+ for (JsonNode node : fact.path("trace_chain")) {
+ assertEquals(4, node.size());
+ assertTrue(node.has("kind"));
+ assertTrue(node.has("name"));
+ assertTrue(node.has("entity_id"));
+ assertTrue(node.has("comment"));
+ assertTrue(node.path("kind").asText().matches("[A-Z_]+"));
+ }
+ if (fact.path("entity_type").asText().equals("CustomerOrder")) orderFact = fact;
+ }
+ assertNotNull(orderFact);
+ assertEquals("database trigger value", orderFact.path("fields").path("memo").asText());
+ assertTrue(orderFact.path("fields").path("business_counter").isTextual());
+ assertEquals("9007199254740993", orderFact.path("fields").path("business_counter").asText());
+ assertEquals("2026-10-16", orderFact.path("fields").path("planned_date").asText());
+ assertTrue(orderFact.path("fields").path("active").isBoolean());
+ assertTrue(orderFact.path("fields").path("active").booleanValue());
+ assertEquals(0.5, orderFact.path("fields").path("ratio").doubleValue(), 0.0);
+ assertTrue(orderFact.path("fields").path("optional_note").isNull());
+ assertEquals("-315619199877", orderFact.path("fields").path("occurred_at").asText());
+ assertFalse(new CommittedMutationFact("CustomerOrder", "100", "create", null, "1",
+ Map.of("memo", "database trigger value"), List.of()).toString().contains("database trigger value"));
+ assertEquals("database trigger value", fixture.driver.queryForList(
+ "SELECT memo FROM customer_order_data WHERE id = ?", new Object[] {100L})
+ .get(0).get("memo"));
+ Map instant = fixture.driver.queryForList(
+ "SELECT typeof(occurred_at) AS storage_type, occurred_at "
+ + "FROM customer_order_data WHERE id = ?", new Object[] {100L}).get(0);
+ assertEquals("integer", instant.get("storage_type"));
+ assertEquals(-315_619_199_877L, ((Number) instant.get("occurred_at")).longValue());
+
+ item.updateProperty("name", "revised item");
+ item.setComment("revise item");
+ order.auditAs("revise order graph").save(fixture.context);
+ var second = events.outboxAfter("java-graph-source", 1, 10);
+ assertEquals(1, second.size());
+ JsonNode changed = new ObjectMapper().readTree(second.get(0).payloadJson());
+ assertEquals(1, changed.path("mutations").size());
+ JsonNode changedItem = changed.path("mutations").get(0);
+ assertEquals("update", changedItem.path("operation").asText());
+ assertEquals("1", changedItem.path("base_version").asText());
+ assertEquals("2", changedItem.path("committed_version").asText());
+ assertEquals("revised item", changedItem.path("fields").path("name").asText());
+ assertEquals(1, changedItem.path("fields").size());
+ assertFalse(changedItem.path("fields").has("memo"));
+
+ payment.markForDeletion();
+ payment.setComment("remove payment");
+ order.auditAs("remove payment from order").save(fixture.context);
+ var third = events.outboxAfter("java-graph-source", 2, 10);
+ assertEquals(1, third.size());
+ JsonNode tombstone = new ObjectMapper().readTree(third.get(0).payloadJson()).path("mutations").get(0);
+ assertEquals("delete", tombstone.path("operation").asText());
+ assertEquals("1", tombstone.path("base_version").asText());
+ assertEquals("-2", tombstone.path("committed_version").asText());
+ assertEquals(0, tombstone.path("fields").size());
+
+ fixture.driver.execute("CREATE UNIQUE INDEX payment_name_unique_for_outbox ON payment_data(name)");
+ var duplicate = fixture.create("Payment", 301, "payment value");
+ order.__internalSet("children", List.of(item, payment, duplicate));
+ assertThrows(RuntimeException.class,
+ () -> order.auditAs("reject duplicate payment graph").save(fixture.context));
+ assertEquals("failed save must leave no publishable set", 3,
+ events.outboxAfter("java-graph-source", 0, 10).size());
+ assertTrue(fixture.driver.queryForList("SELECT id FROM payment_data WHERE id = ?",
+ new Object[] {301L}).isEmpty());
+ }
+
+ @Test public void committedUpdateIncludesDatabaseTriggerChangeButOmitsUnchangedFields() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 909, "before");
+ order.auditAs("create trigger comparison order").save(fixture.context);
+ fixture.driver.execute("CREATE TRIGGER order_update_memo AFTER UPDATE OF name ON customer_order_data "
+ + "WHEN NEW.name = 'after' BEGIN UPDATE customer_order_data "
+ + "SET memo = 'database revised memo' WHERE id = NEW.id; END");
+
+ order.updateProperty("name", "after");
+ order.auditAs("update trigger comparison order").save(fixture.context);
+ var outbox = fixture.committedRecorder.eventStore().outboxAfter("java-graph-source", 1, 10);
+ assertEquals(1, outbox.size());
+ JsonNode fact = new ObjectMapper().readTree(outbox.get(0).payloadJson())
+ .path("mutations").get(0);
+ assertEquals("update", fact.path("operation").asText());
+ assertEquals("after", fact.path("fields").path("name").asText());
+ assertEquals("database revised memo", fact.path("fields").path("memo").asText());
+ assertEquals(2, fact.path("fields").size());
+ }
+
+ @Test public void committedUpdateDistinguishesNullTransitionsFromUnchangedFields() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 911, "nullable order");
+ order.updateProperty("optional_note", null);
+ order.auditAs("create nullable order").save(fixture.context);
+
+ order.updateProperty("optional_note", "submitted note");
+ order.auditAs("set optional note").save(fixture.context);
+ var outbox = fixture.committedRecorder.eventStore();
+ JsonNode set = new ObjectMapper().readTree(
+ outbox.outboxAfter("java-graph-source", 1, 10).get(0).payloadJson());
+ JsonNode fields = set.path("mutations").get(0).path("fields");
+ assertEquals("submitted note", fields.path("optional_note").asText());
+ assertEquals(1, fields.size());
+
+ order.updateProperty("optional_note", null);
+ order.auditAs("clear optional note").save(fixture.context);
+ JsonNode cleared = new ObjectMapper().readTree(
+ outbox.outboxAfter("java-graph-source", 2, 10).get(0).payloadJson())
+ .path("mutations").get(0).path("fields");
+ assertTrue(cleared.has("optional_note"));
+ assertTrue(cleared.path("optional_note").isNull());
+ assertEquals(1, cleared.size());
+ assertNull(fixture.driver.queryForList(
+ "SELECT optional_note FROM customer_order_data WHERE id = ?", new Object[] {911L})
+ .get(0).get("optional_note"));
+ }
+
+ @Test public void committedLocalDateTimeFailsClosedWithoutAnInstantMapping() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 912, "wall-clock order");
+ order.updateProperty("wall_time", java.time.LocalDateTime.of(2026, 10, 16, 8, 0));
+ var failure = assertThrows(RuntimeException.class,
+ () -> order.auditAs("reject ambiguous wall time").save(fixture.context));
+ assertTrue(failure.getMessage().contains("LocalDateTime"));
+ assertTrue(fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {912L}).isEmpty());
+ assertTrue(fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).isEmpty());
+ }
+
+ @Test public void committedInstantRejectsSubMillisecondPrecision() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 913, "precise order");
+ order.updateProperty("occurred_at", java.time.Instant.parse("2026-10-16T00:00:00.123456Z"));
+ assertThrows(RuntimeException.class,
+ () -> order.auditAs("reject submillisecond instant").save(fixture.context));
+ assertTrue(fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {913L}).isEmpty());
+ assertTrue(fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).isEmpty());
+ }
+
+ @Test public void committedRecoveryKeepsPredecessorAndNeedsNoChangedScalarField() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 910, "recoverable");
+ order.auditAs("create recoverable order").save(fixture.context);
+ order.markForDeletion();
+ order.auditAs("delete recoverable order").save(fixture.context);
+ order.markAsRecover();
+ order.auditAs("recover order").save(fixture.context);
+
+ var outbox = fixture.committedRecorder.eventStore().outboxAfter("java-graph-source", 2, 10);
+ assertEquals(1, outbox.size());
+ JsonNode fact = new ObjectMapper().readTree(outbox.get(0).payloadJson())
+ .path("mutations").get(0);
+ assertEquals("recover", fact.path("operation").asText());
+ assertEquals("-2", fact.path("base_version").asText());
+ assertEquals("3", fact.path("committed_version").asText());
+ assertEquals(0, fact.path("fields").size());
+ }
+
+ @Test public void brokenOutboxRollsBackNormalGraphSaveAndDoesNotDeliverAudit() throws Exception {
+ var fixture = new Fixture(true);
+ fixture.driver.execute("DROP TABLE teaql_event_outbox");
+ var order = fixture.create("CustomerOrder", 100, "must roll back");
+ var item = fixture.create("OrderItem", 201, "must roll back too");
+ order.__internalSet("children", List.of(item));
+ assertThrows(RuntimeException.class,
+ () -> order.auditAs("fail together when outbox unavailable").save(fixture.context));
+ assertTrue(fixture.driver.queryForList("SELECT id FROM customer_order_data WHERE id = ?",
+ new Object[] {100L}).isEmpty());
+ assertTrue(fixture.driver.queryForList("SELECT id FROM order_item_data WHERE id = ?",
+ new Object[] {201L}).isEmpty());
+ assertTrue("no post-commit audit on rolled back save", fixture.audit.isEmpty());
+ }
+
+ @Test public void lostCommitAcknowledgementAfterDurableWriteRequiresReload() throws Exception {
+ assertCommitAcknowledgementUnknown(true);
+ }
+
+ @Test public void failedCommitBeforeWriteRequiresTheSameConservativeReload() throws Exception {
+ assertCommitAcknowledgementUnknown(false);
+ }
+
+ @Test public void commitErrorAfterDurableWriteRequiresReload() throws Exception {
+ var armed = new AtomicBoolean();
+ var injected = new AtomicBoolean();
+ var fixture = new Fixture(true,
+ source -> commitErrorAfterWrite(source, armed, injected));
+ var order = fixture.create("CustomerOrder", 193, "commit Error after durable write");
+ armed.set(true);
+
+ assertThrows(NoSuppressionError.class,
+ () -> order.auditAs("probe uncertain commit Error").save(fixture.context));
+ assertTrue("the commit Error must be injected", injected.get());
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {193L}).size());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject replay after commit Error").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ }
+
+ @Test public void commitFailureAndRollbackErrorMustNotRestoreAutoCommit() throws Exception {
+ var armed = new AtomicBoolean();
+ var rollbackInjected = new AtomicBoolean();
+ var restoredAutoCommit = new AtomicBoolean();
+ var fixture = new Fixture(true, source -> commitRollbackErrorFault(
+ source, armed, rollbackInjected, restoredAutoCommit));
+ var order = fixture.create("CustomerOrder", 195, "commit and rollback uncertain");
+ armed.set(true);
+
+ var failure = assertThrows(TransactionOutcomeUnknownException.class,
+ () -> order.auditAs("probe commit and rollback Error").save(fixture.context));
+ assertTrue(failure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN"));
+ assertTrue("rollback Error must be injected", rollbackInjected.get());
+ assertFalse("unconfirmed rollback must not restore autoCommit", restoredAutoCommit.get());
+ assertTrue("failed commit must not materialize the business row", fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {195L}).isEmpty());
+ assertTrue("failed commit must not materialize Outbox", fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).isEmpty());
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject replay after commit and rollback Error").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ }
+
+ @Test public void failedRollbackMustNotImplicitlyCommitOnAutoCommitRestoration() throws Exception {
+ var armed = new AtomicBoolean();
+ var injected = new AtomicBoolean();
+ var restoredAutoCommit = new AtomicBoolean();
+ var fixture = new Fixture(true,
+ source -> rollbackFault(source, armed, injected, restoredAutoCommit));
+ fixture.driver.execute("DROP TABLE teaql_event_outbox");
+ armed.set(true);
+ var order = fixture.create("CustomerOrder", 187, "rollback failed after Outbox error");
+
+ var failure = assertThrows(RuntimeException.class,
+ () -> order.auditAs("probe uncertain rollback outcome").save(fixture.context));
+ assertTrue("the rollback failure must be injected", injected.get());
+ assertFalse("restoring autoCommit after failed rollback can commit pending writes",
+ restoredAutoCommit.get());
+ assertEquals(0, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {187L}).size());
+ assertTrue(failure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN"));
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject replay after failed rollback").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ }
+
+ @Test public void rollbackErrorMustNotRestoreAutoCommitOrPermitReplay() throws Exception {
+ var armed = new AtomicBoolean();
+ var injected = new AtomicBoolean();
+ var restoredAutoCommit = new AtomicBoolean();
+ var fixture = new Fixture(true,
+ source -> rollbackFault(source, armed, injected, restoredAutoCommit, true));
+ fixture.driver.execute("DROP TABLE teaql_event_outbox");
+ var order = fixture.create("CustomerOrder", 194, "rollback Error after Outbox failure");
+ armed.set(true);
+
+ assertThrows(NoSuppressionError.class,
+ () -> order.auditAs("probe rollback Error").save(fixture.context));
+ assertTrue("the rollback Error must be injected", injected.get());
+ assertFalse("unconfirmed rollback must not restore autoCommit", restoredAutoCommit.get());
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject replay after rollback Error").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ }
+
+ @Test public void failedRollbackAfterErrorRequiresReloadBeforeRetry() throws Exception {
+ var armed = new AtomicBoolean();
+ var injected = new AtomicBoolean();
+ var restoredAutoCommit = new AtomicBoolean();
+ var fixture = new Fixture(false,
+ source -> rollbackFault(source, armed, injected, restoredAutoCommit));
+ var order = fixture.create("CustomerOrder", 190, "error before rollback");
+ fixture.errorReadback = true;
+ armed.set(true);
+
+ var failure = assertThrows(AssertionError.class,
+ () -> order.auditAs("probe rollback failure after Error").save(fixture.context));
+ assertEquals("synthetic Error after business write", failure.getMessage());
+ assertTrue("rollback fault must be injected", injected.get());
+ assertFalse("failed rollback must not restore autoCommit", restoredAutoCommit.get());
+ fixture.errorReadback = false;
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject retry after unknown Error outcome").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ var independent = fixture.create("Payment", 191, "independent graph after Error");
+ independent.auditAs("save independent graph after unknown Error outcome")
+ .save(fixture.context);
+ assertEquals(Long.valueOf(1), independent.getVersion());
+ }
+
+ @Test public void failedRollbackAfterUnsuppressibleErrorRequiresReloadBeforeRetry() throws Exception {
+ var armed = new AtomicBoolean();
+ var injected = new AtomicBoolean();
+ var restoredAutoCommit = new AtomicBoolean();
+ var fixture = new Fixture(false,
+ source -> rollbackFault(source, armed, injected, restoredAutoCommit));
+ var order = fixture.create("CustomerOrder", 192, "unsuppressible error before rollback");
+ fixture.errorReadbackWithoutSuppression = true;
+ armed.set(true);
+
+ assertThrows(NoSuppressionError.class,
+ () -> order.auditAs("probe unsuppressible Error rollback failure").save(fixture.context));
+ assertTrue("rollback fault must be injected", injected.get());
+ assertFalse("failed rollback must not restore autoCommit", restoredAutoCommit.get());
+ fixture.errorReadbackWithoutSuppression = false;
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject retry after unsuppressible Error").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ }
+
+ private static void assertCommitAcknowledgementUnknown(boolean afterActualCommit) throws Exception {
+ var armed = new AtomicBoolean();
+ var injected = new AtomicBoolean();
+ var fixture = new Fixture(true,
+ source -> commitAcknowledgementFault(source, armed, injected, afterActualCommit));
+ armed.set(true);
+ var order = fixture.create("CustomerOrder", afterActualCommit ? 189 : 188,
+ "commit acknowledgement may be lost");
+ var item = fixture.create("OrderItem", afterActualCommit ? 289 : 288,
+ "same uncertain transaction");
+ order.__internalSet("children", List.of(item));
+
+ var warnings = new ArrayList();
+ var logger = Logger.getLogger(JdbcSqlExecutor.class.getName());
+ Handler capture = new Handler() {
+ @Override public void publish(LogRecord record) { warnings.add(record.getMessage()); }
+ @Override public void flush() { }
+ @Override public void close() { }
+ };
+ logger.addHandler(capture);
+ RuntimeException failure;
+ try {
+ failure = assertThrows(RuntimeException.class,
+ () -> order.auditAs("probe uncertain commit outcome").save(fixture.context));
+ } finally {
+ logger.removeHandler(capture);
+ }
+ assertTrue("fault must reach the actual commit boundary", injected.get());
+ assertEquals(afterActualCommit ? 1 : 0, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {order.getId()}).size());
+ assertEquals(afterActualCommit ? 1 : 0, fixture.driver.queryForList(
+ "SELECT id FROM order_item_data WHERE id = ?", new Object[] {item.getId()}).size());
+ assertEquals(afterActualCommit ? 1 : 0, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ assertTrue("the outcome must not be reported as a known rollback",
+ failure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN"));
+ assertFalse("the public message must not contain the JDBC diagnostic",
+ failure.getMessage().contains("COMMIT-ACK-SECRET-CANARY"));
+ assertTrue(warnings.stream().anyMatch(message -> message.contains("outcome unknown")));
+ assertTrue("the operational warning must not contain the JDBC diagnostic",
+ warnings.stream().noneMatch(message -> message.contains("COMMIT-ACK-SECRET-CANARY")));
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject replay before reload").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ var childRetryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> item.auditAs("reject child replay before reload").save(fixture.context));
+ assertTrue(childRetryFailure.getMessage().contains("TRANSACTION_OUTCOME_UNKNOWN_RELOAD_REQUIRED"));
+ assertThrows(TeaQLRuntimeException.class,
+ () -> order.setEntityMutationLedger(new EntityMutationLedger()));
+ }
+
+ private static DataSource commitAcknowledgementFault(SQLiteDataSource source,
+ AtomicBoolean armed, AtomicBoolean injected, boolean afterActualCommit) {
+ return (DataSource) Proxy.newProxyInstance(DataSource.class.getClassLoader(),
+ new Class>[] {DataSource.class}, (proxy, method, args) -> {
+ Object result = invoke(source, method, args);
+ if (!method.getName().equals("getConnection")) return result;
+ Connection actual = (Connection) result;
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("commit") && armed.get()
+ && injected.compareAndSet(false, true)) {
+ if (afterActualCommit) invoke(actual, operation, parameters);
+ throw new SQLException("COMMIT-ACK-SECRET-CANARY");
+ }
+ return invoke(actual, operation, parameters);
+ });
+ });
+ }
+
+ private static DataSource commitErrorAfterWrite(SQLiteDataSource source,
+ AtomicBoolean armed, AtomicBoolean injected) {
+ return (DataSource) Proxy.newProxyInstance(DataSource.class.getClassLoader(),
+ new Class>[] {DataSource.class}, (proxy, method, args) -> {
+ Object result = invoke(source, method, args);
+ if (!method.getName().equals("getConnection")) return result;
+ Connection actual = (Connection) result;
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("commit") && armed.get()
+ && injected.compareAndSet(false, true)) {
+ invoke(actual, operation, parameters);
+ throw new NoSuppressionError();
+ }
+ return invoke(actual, operation, parameters);
+ });
+ });
+ }
+
+ private static DataSource commitRollbackErrorFault(SQLiteDataSource source,
+ AtomicBoolean armed, AtomicBoolean rollbackInjected, AtomicBoolean restoredAutoCommit) {
+ return (DataSource) Proxy.newProxyInstance(DataSource.class.getClassLoader(),
+ new Class>[] {DataSource.class}, (proxy, method, args) -> {
+ Object result = invoke(source, method, args);
+ if (!method.getName().equals("getConnection")) return result;
+ Connection actual = (Connection) result;
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("commit") && armed.get()) {
+ throw new SQLException("COMMIT-ACK-SECRET-CANARY");
+ }
+ if (operation.getName().equals("rollback") && armed.get()
+ && rollbackInjected.compareAndSet(false, true)) {
+ throw new NoSuppressionError();
+ }
+ if (operation.getName().equals("setAutoCommit")
+ && rollbackInjected.get() && Boolean.TRUE.equals(parameters[0])) {
+ restoredAutoCommit.set(true);
+ }
+ return invoke(actual, operation, parameters);
+ });
+ });
+ }
+
+ private static DataSource rollbackFault(SQLiteDataSource source,
+ AtomicBoolean armed, AtomicBoolean injected, AtomicBoolean restoredAutoCommit) {
+ return rollbackFault(source, armed, injected, restoredAutoCommit, false);
+ }
+
+ private static DataSource rollbackFault(SQLiteDataSource source,
+ AtomicBoolean armed, AtomicBoolean injected, AtomicBoolean restoredAutoCommit,
+ boolean throwError) {
+ return (DataSource) Proxy.newProxyInstance(DataSource.class.getClassLoader(),
+ new Class>[] {DataSource.class}, (proxy, method, args) -> {
+ Object result = invoke(source, method, args);
+ if (!method.getName().equals("getConnection")) return result;
+ Connection actual = (Connection) result;
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("rollback") && armed.get()
+ && injected.compareAndSet(false, true)) {
+ if (throwError) throw new NoSuppressionError();
+ throw new SQLException("ROLLBACK-SECRET-CANARY");
+ }
+ if (operation.getName().equals("setAutoCommit") && injected.get()
+ && Boolean.TRUE.equals(parameters[0])) {
+ restoredAutoCommit.set(true);
+ }
+ return invoke(actual, operation, parameters);
+ });
+ });
+ }
+
+ private static Object invoke(Object target, Method method, Object[] args) throws Throwable {
+ try {
+ return method.invoke(target, args);
+ } catch (InvocationTargetException failure) {
+ throw failure.getCause();
+ }
+ }
+
+ @Test public void postCommitAuditFailureReportsCommittedOutcomeWithoutReplayingLedger() throws Exception {
+ var fixture = new Fixture(true);
+ fixture.context.putAttribute(AppAuditEventSink.class.getName(),
+ (AppAuditEventSink) (caller, event) -> {
+ throw new IllegalStateException("diagnostic audit sink failure");
+ });
+ var order = fixture.create("CustomerOrder", 199, "committed before audit delivery");
+
+ var failure = assertThrows(AuditDeliveryAfterCommitException.class,
+ () -> order.auditAs("diagnose post-commit audit failure").save(fixture.context));
+ assertTrue(failure.getMessage().contains("AUDIT_DELIVERY_AFTER_COMMIT"));
+ assertEquals(1, failure.failureCount());
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {199L}).size());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+
+ fixture.context.putAttribute(AppAuditEventSink.class.getName(),
+ (AppAuditEventSink) (caller, event) -> fixture.audit.add(event));
+ order.auditAs("retry after audit delivery failure").save(fixture.context);
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ }
+
+ @Test public void postCommitReconciliationFailureMustReportCommittedOutcome() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 196, "reconcile after commit");
+ order.failPostCommitReconcile = true;
+
+ var failure = assertThrows(CommittedStateReconciliationException.class,
+ () -> order.auditAs("diagnose committed reconciliation failure").save(fixture.context));
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {196L}).size());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ assertEquals("committed mutations still require application audit delivery", 1, fixture.audit.size());
+ assertEquals(1, fixture.rawAudit.size());
+ assertEquals(Long.valueOf(1), fixture.rawAudit.get(0).resultingVersion());
+ assertTrue("committed outcome must be explicit to prevent blind retry",
+ failure.getMessage().contains("provider write completed"));
+ order.failPostCommitReconcile = false;
+ var retryFailure = assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("retry committed reconciliation failure").save(fixture.context));
+ assertTrue(retryFailure.getMessage().contains("COMMITTED_STATE_RELOAD_REQUIRED"));
+ assertThrows(TeaQLRuntimeException.class,
+ () -> order.setEntityMutationLedger(new EntityMutationLedger()));
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ assertEquals(1, fixture.audit.size());
+ var independent = fixture.create("Payment", 195, "unrelated graph may still save");
+ independent.auditAs("save independent graph after failure").save(fixture.context);
+ assertEquals(2, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ }
+
+ @Test public void postCommitChildReconciliationFailurePoisonsOnlyItsSaveGraph() throws Exception {
+ var fixture = new Fixture(true);
+ var order = fixture.create("CustomerOrder", 194, "root already committed");
+ var item = fixture.create("OrderItem", 294, "child needs reload");
+ item.failPostCommitReconcile = true;
+ order.__internalSet("children", List.of(item));
+
+ assertThrows(CommittedStateReconciliationException.class,
+ () -> order.auditAs("commit graph before child reconciliation").save(fixture.context));
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {194L}).size());
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM order_item_data WHERE id = ?", new Object[] {294L}).size());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+
+ item.failPostCommitReconcile = false;
+ assertThrows(TeaQLRuntimeException.class,
+ () -> item.auditAs("unsafe child retry").save(fixture.context));
+ assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("unsafe root retry").save(fixture.context));
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ assertEquals("both committed graph members must still be audited", 2, fixture.audit.size());
+ assertEquals(2, fixture.rawAudit.size());
+ }
+
+ @Test public void postCommitReconciliationAndAuditFailureRetainBothOutcomes() throws Exception {
+ var fixture = new Fixture(true);
+ var attempts = new java.util.concurrent.atomic.AtomicInteger();
+ fixture.context.putAttribute(AppAuditEventSink.class.getName(),
+ (AppAuditEventSink) (caller, event) -> {
+ attempts.incrementAndGet();
+ throw new IllegalStateException("diagnostic audit sink failure");
+ });
+ var order = fixture.create("CustomerOrder", 193, "committed despite two post-commit failures");
+ order.failPostCommitReconcile = true;
+
+ var failure = assertThrows(CommittedStateReconciliationException.class,
+ () -> order.auditAs("commit before reconciliation and audit failures").save(fixture.context));
+ assertEquals(1, attempts.get());
+ assertEquals(1, failure.getSuppressed().length);
+ assertTrue(failure.getSuppressed()[0] instanceof AuditDeliveryAfterCommitException);
+ assertEquals(1, fixture.rawAudit.size());
+ assertEquals(Long.valueOf(1), fixture.rawAudit.get(0).resultingVersion());
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {193L}).size());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ order.failPostCommitReconcile = false;
+ assertThrows(TeaQLRuntimeException.class,
+ () -> order.auditAs("reject retry after two post-commit failures").save(fixture.context));
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ }
+
+ @Test public void postCommitAuditFailureStillAttemptsEveryGraphMember() throws Exception {
+ var fixture = new Fixture(true);
+ var attempts = new java.util.concurrent.atomic.AtomicInteger();
+ fixture.context.putAttribute(AppAuditEventSink.class.getName(),
+ (AppAuditEventSink) (caller, event) -> {
+ attempts.incrementAndGet();
+ throw new IllegalStateException("diagnostic audit sink failure");
+ });
+ var order = fixture.create("CustomerOrder", 198, "root audit fails");
+ var item = fixture.create("OrderItem", 298, "child audit fails");
+ order.__internalSet("children", List.of(item));
+
+ var failure = assertThrows(AuditDeliveryAfterCommitException.class,
+ () -> order.auditAs("commit graph before audit delivery").save(fixture.context));
+ assertEquals(2, failure.failureCount());
+ assertEquals(2, attempts.get());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM customer_order_data WHERE id = ?", new Object[] {198L}).size());
+ assertEquals(1, fixture.driver.queryForList(
+ "SELECT id FROM order_item_data WHERE id = ?", new Object[] {298L}).size());
+ }
+
+ @Test public void standardAuditSinkFailureDoesNotSuppressApplicationAuditDelivery() throws Exception {
+ var fixture = new Fixture(true);
+ fixture.failStandardAudit = true;
+ var order = fixture.create("CustomerOrder", 197, "both audit sinks are attempted");
+
+ var failure = assertThrows(AuditDeliveryAfterCommitException.class,
+ () -> order.auditAs("commit before standard audit delivery").save(fixture.context));
+ assertEquals(1, failure.failureCount());
+ assertEquals(1, fixture.audit.size());
+ assertEquals(1, fixture.committedRecorder.eventStore()
+ .outboxAfter("java-graph-source", 0, 10).size());
+ }
+
@Test public void realSqliteProviderFailureKeepsAttemptedLineageWithoutCommittedAudit() throws Exception {
var fixture = new Fixture();
fixture.driver.execute("CREATE UNIQUE INDEX payment_name_unique ON payment_data(name)");
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/JdbcCommittedCleanupSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/JdbcCommittedCleanupSqliteTest.java
new file mode 100644
index 00000000..6c471370
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/JdbcCommittedCleanupSqliteTest.java
@@ -0,0 +1,147 @@
+package io.teaql.sqlite;
+
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.nio.file.Files;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Handler;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import javax.sql.DataSource;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/** A committed JDBC write must not look like a rolled-back, retryable save. */
+public class JdbcCommittedCleanupSqliteTest {
+ private static final String DIAGNOSTIC_CANARY = "POST-COMMIT-CLEANUP-SECRET-CANARY";
+ private enum Fault { AUTO_COMMIT_RESTORE, CLOSE, AUTO_COMMIT_RESTORE_ERROR, CLOSE_ERROR }
+
+ private static final class NoSuppressionError extends Error {
+ private NoSuppressionError() {
+ super(DIAGNOSTIC_CANARY, null, false, false);
+ }
+ }
+
+ @Test public void failedAutoCommitRestoreAfterCommitKeepsCommittedOutcome() throws Exception {
+ verifyCommittedCleanupFailure(Fault.AUTO_COMMIT_RESTORE);
+ }
+
+ @Test public void failedCloseAfterCommitKeepsCommittedOutcome() throws Exception {
+ verifyCommittedCleanupFailure(Fault.CLOSE);
+ }
+
+ @Test public void failedWarningHandlerAfterCommitKeepsCommittedOutcome() throws Exception {
+ verifyCommittedCleanupFailure(Fault.AUTO_COMMIT_RESTORE, true);
+ }
+
+ @Test public void autoCommitRestoreErrorAfterCommitKeepsCommittedOutcome() throws Exception {
+ verifyCommittedCleanupFailure(Fault.AUTO_COMMIT_RESTORE_ERROR);
+ }
+
+ @Test public void closeErrorAfterCommitKeepsCommittedOutcome() throws Exception {
+ verifyCommittedCleanupFailure(Fault.CLOSE_ERROR);
+ }
+
+ @Test public void warningHandlerErrorAfterCommitKeepsCommittedOutcome() throws Exception {
+ verifyCommittedCleanupFailure(Fault.AUTO_COMMIT_RESTORE, false, true);
+ }
+
+ private static void verifyCommittedCleanupFailure(Fault fault) throws Exception {
+ verifyCommittedCleanupFailure(fault, false);
+ }
+
+ private static void verifyCommittedCleanupFailure(Fault fault, boolean warningHandlerFails)
+ throws Exception {
+ verifyCommittedCleanupFailure(fault, warningHandlerFails, false);
+ }
+
+ private static void verifyCommittedCleanupFailure(Fault fault, boolean warningHandlerFails,
+ boolean warningHandlerThrowsError) throws Exception {
+ var backing = new SQLiteDataSource();
+ backing.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-committed-cleanup-", ".db"));
+ try (var connection = backing.getConnection(); var statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE committed_cleanup_probe (id INTEGER PRIMARY KEY)");
+ }
+ var committed = new AtomicBoolean();
+ DataSource injecting = (DataSource) Proxy.newProxyInstance(
+ DataSource.class.getClassLoader(), new Class>[] {DataSource.class},
+ (proxy, method, args) -> {
+ Object value = invoke(backing, method, args);
+ if (!method.getName().equals("getConnection")) return value;
+ Connection actual = (Connection) value;
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("setAutoCommit")
+ && Boolean.TRUE.equals(parameters[0]) && committed.get()
+ && (fault == Fault.AUTO_COMMIT_RESTORE
+ || fault == Fault.AUTO_COMMIT_RESTORE_ERROR)) {
+ if (fault == Fault.AUTO_COMMIT_RESTORE_ERROR) throw new NoSuppressionError();
+ throw new SQLException(DIAGNOSTIC_CANARY);
+ }
+ Object result = invoke(actual, operation, parameters);
+ if (operation.getName().equals("commit")) committed.set(true);
+ if (operation.getName().equals("close") && committed.get()
+ && (fault == Fault.CLOSE || fault == Fault.CLOSE_ERROR)) {
+ if (fault == Fault.CLOSE_ERROR) throw new NoSuppressionError();
+ throw new SQLException(DIAGNOSTIC_CANARY);
+ }
+ return result;
+ });
+ });
+ var executor = new JdbcSqlExecutor(injecting);
+ List warnings = new ArrayList<>();
+ Logger logger = Logger.getLogger(JdbcSqlExecutor.class.getName());
+ Handler capture = new Handler() {
+ @Override public void publish(LogRecord record) {
+ warnings.add(record.getMessage());
+ if (warningHandlerThrowsError) throw new NoSuppressionError();
+ if (warningHandlerFails) throw new IllegalStateException(DIAGNOSTIC_CANARY);
+ }
+ @Override public void flush() { }
+ @Override public void close() { }
+ };
+ logger.addHandler(capture);
+
+ Throwable reported = null;
+ try {
+ executor.executeInTransaction(() -> executor.update(
+ "INSERT INTO committed_cleanup_probe (id) VALUES (?)", new Object[] {1L}));
+ } catch (RuntimeException | Error failure) {
+ reported = failure;
+ } finally {
+ logger.removeHandler(capture);
+ }
+
+ assertTrue("the injected failure must occur after the actual commit", committed.get());
+ try (var connection = backing.getConnection();
+ var statement = connection.prepareStatement("SELECT COUNT(*) FROM committed_cleanup_probe");
+ var rows = statement.executeQuery()) {
+ assertTrue(rows.next());
+ assertEquals(1, rows.getInt(1));
+ }
+ assertTrue("cleanup failure needs an operational warning", warnings.stream()
+ .anyMatch(message -> message.contains("JDBC transaction committed")));
+ assertTrue("the warning must not reveal the JDBC exception", warnings.stream()
+ .noneMatch(message -> message.contains(DIAGNOSTIC_CANARY)));
+ if (reported != null) {
+ throw new AssertionError("committed row was reported as a failed transaction", reported);
+ }
+ }
+
+ private static Object invoke(Object target, Method method, Object[] args) throws Throwable {
+ try {
+ return method.invoke(target, args);
+ } catch (InvocationTargetException failure) {
+ throw failure.getCause();
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/JdbcRollbackCloseSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/JdbcRollbackCloseSqliteTest.java
new file mode 100644
index 00000000..315fc085
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/JdbcRollbackCloseSqliteTest.java
@@ -0,0 +1,189 @@
+package io.teaql.sqlite;
+
+import io.teaql.core.TransactionOutcomeUnknownException;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.nio.file.Files;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.sql.DataSource;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/** An unconfirmed rollback must not be followed by a close that can commit pending writes. */
+public class JdbcRollbackCloseSqliteTest {
+ private enum AbortBehavior { CLOSE, THROW, NO_OP }
+
+ @Test public void failedRollbackDoesNotCommitPendingWriteOnClose() throws Exception {
+ verifyFailedRollback(AbortBehavior.CLOSE);
+ }
+
+ @Test public void failedAbortDoesNotFallBackToUnsafeClose() throws Exception {
+ verifyFailedRollback(AbortBehavior.THROW);
+ }
+
+ @Test public void noopAbortDoesNotFallBackToUnsafeClose() throws Exception {
+ verifyFailedRollback(AbortBehavior.NO_OP);
+ }
+
+ @Test public void confirmedRollbackRetryAllowsSafeClose() throws Exception {
+ var backing = new SQLiteDataSource();
+ backing.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-rollback-retry-", ".db"));
+ try (var connection = backing.getConnection(); var statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE rollback_close_probe (id INTEGER PRIMARY KEY)");
+ }
+
+ var rollbackAttempts = new AtomicInteger();
+ var rollbackConfirmed = new AtomicBoolean();
+ var closedAfterRollback = new AtomicBoolean();
+ var committedOnUnsafeClose = new AtomicBoolean();
+ var aborted = new AtomicBoolean();
+ var opened = new AtomicReference();
+ DataSource injecting = (DataSource) Proxy.newProxyInstance(
+ DataSource.class.getClassLoader(), new Class>[] {DataSource.class},
+ (proxy, method, args) -> {
+ Object value = invoke(backing, method, args);
+ if (!method.getName().equals("getConnection")) return value;
+ Connection actual = (Connection) value;
+ opened.set(actual);
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("rollback")) {
+ if (rollbackAttempts.incrementAndGet() == 1) {
+ throw new SQLException("one-shot injected rollback failure");
+ }
+ Object result = invoke(actual, operation, parameters);
+ rollbackConfirmed.set(true);
+ return result;
+ }
+ if (operation.getName().equals("close")) {
+ if (rollbackConfirmed.get()) {
+ closedAfterRollback.set(true);
+ } else {
+ // Model a pool that commits a still-active transaction on close.
+ actual.commit();
+ committedOnUnsafeClose.set(true);
+ }
+ }
+ if (operation.getName().equals("abort")) aborted.set(true);
+ return invoke(actual, operation, parameters);
+ });
+ });
+ var executor = new JdbcSqlExecutor(injecting);
+
+ try {
+ assertThrows(TransactionOutcomeUnknownException.class, () ->
+ executor.executeInTransaction(() -> {
+ executor.update("INSERT INTO rollback_close_probe (id) VALUES (?)", new Object[] {1L});
+ throw new IllegalStateException("abort this transaction");
+ }));
+ assertEquals("rollback must be retried exactly once", 2, rollbackAttempts.get());
+ assertTrue("the retry must confirm rollback", rollbackConfirmed.get());
+ assertTrue("close is safe after confirmed rollback", closedAfterRollback.get());
+ assertFalse("abort is unnecessary after confirmed rollback", aborted.get());
+ assertFalse("close must not commit pending writes", committedOnUnsafeClose.get());
+ } finally {
+ Connection actual = opened.get();
+ if (actual != null && !actual.isClosed()) {
+ actual.rollback();
+ actual.close();
+ }
+ }
+ try (var connection = backing.getConnection();
+ var statement = connection.prepareStatement("SELECT COUNT(*) FROM rollback_close_probe");
+ var rows = statement.executeQuery()) {
+ assertTrue(rows.next());
+ assertEquals(0, rows.getInt(1));
+ }
+ }
+
+ private static void verifyFailedRollback(AbortBehavior abortBehavior) throws Exception {
+ var backing = new SQLiteDataSource();
+ backing.setUrl("jdbc:sqlite:" + Files.createTempFile("teaql-rollback-close-", ".db"));
+ try (var connection = backing.getConnection(); var statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE rollback_close_probe (id INTEGER PRIMARY KEY)");
+ }
+
+ var rollbackFailed = new AtomicBoolean();
+ var committedOnClose = new AtomicBoolean();
+ var aborted = new AtomicBoolean();
+ var opened = new AtomicReference();
+ DataSource injecting = (DataSource) Proxy.newProxyInstance(
+ DataSource.class.getClassLoader(), new Class>[] {DataSource.class},
+ (proxy, method, args) -> {
+ Object value = invoke(backing, method, args);
+ if (!method.getName().equals("getConnection")) return value;
+ Connection actual = (Connection) value;
+ opened.set(actual);
+ return Proxy.newProxyInstance(Connection.class.getClassLoader(),
+ new Class>[] {Connection.class}, (connectionProxy, operation, parameters) -> {
+ if (operation.getName().equals("rollback")) {
+ rollbackFailed.set(true);
+ throw new SQLException("injected rollback failure");
+ }
+ if (operation.getName().equals("abort")) {
+ aborted.set(true);
+ if (abortBehavior == AbortBehavior.THROW) {
+ throw new SQLException("injected abort failure");
+ }
+ if (abortBehavior == AbortBehavior.NO_OP) return null;
+ actual.rollback();
+ actual.close();
+ return null;
+ }
+ if (operation.getName().equals("close") && rollbackFailed.get()) {
+ // JDBC leaves active-transaction close behavior implementation-defined.
+ // Model a driver/pool that commits when close returns the connection.
+ actual.commit();
+ committedOnClose.set(true);
+ }
+ return invoke(actual, operation, parameters);
+ });
+ });
+ var executor = new JdbcSqlExecutor(injecting);
+
+ try {
+ assertThrows(TransactionOutcomeUnknownException.class, () ->
+ executor.executeInTransaction(() -> {
+ executor.update("INSERT INTO rollback_close_probe (id) VALUES (?)", new Object[] {1L});
+ throw new IllegalStateException("abort this transaction");
+ }));
+
+ assertTrue("the rollback failure must be injected", rollbackFailed.get());
+ assertFalse("an unsafe close must not commit pending writes", committedOnClose.get());
+ assertTrue("unconfirmed transactions must invalidate the connection", aborted.get());
+ } finally {
+ // A failed/no-op abort leaves the backing connection open; release it
+ // explicitly so the test does not strand SQLite's write lock.
+ Connection actual = opened.get();
+ if (actual != null && !actual.isClosed()) {
+ actual.rollback();
+ actual.close();
+ }
+ }
+ try (var connection = backing.getConnection();
+ var statement = connection.prepareStatement("SELECT COUNT(*) FROM rollback_close_probe");
+ var rows = statement.executeQuery()) {
+ assertTrue(rows.next());
+ assertEquals(0, rows.getInt(1));
+ }
+ }
+
+ private static Object invoke(Object target, Method method, Object[] args) throws Throwable {
+ try {
+ return method.invoke(target, args);
+ } catch (InvocationTargetException failure) {
+ throw failure.getCause();
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedBoundaryTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedBoundaryTest.java
new file mode 100644
index 00000000..8e59ca28
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedBoundaryTest.java
@@ -0,0 +1,100 @@
+package io.teaql.sqlite;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.OutboxRecord;
+import io.teaql.core.sqlite.SqliteCommittedReceiver;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.security.MessageDigest;
+import java.util.*;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+import static org.junit.Assert.*;
+
+/** Small signed protocol fixture, not a high-level business save/example. */
+public class SqliteCommittedBoundaryTest {
+ static final ObjectMapper JSON=new ObjectMapper();
+ static final String SOURCE="boundary-source",TARGET="boundary-target",MODEL="sha256:"+"0".repeat(64);
+ static final String SQL="INSERT INTO school_data(id,version,name) VALUES(99,1,'SQL-CANARY')";
+ static final byte[] KEY="0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.US_ASCII);
+ static ObjectNode body()throws Exception{
+ return (ObjectNode)JSON.readTree("{\"protocol_version\":1,\"change_set_id\":\"fact\",\"source_runtime_id\":\"boundary-source\",\"source_sequence\":\"1\",\"source_model_hash\":\""+MODEL+"\",\"source_transaction_id\":\"tx\",\"committed_at\":\"2026-10-22T00:00:00Z\",\"audit\":{\"comment\":\"receive school\",\"purpose\":\"check protocol boundary\"},\"mutations\":[{\"entity_type\":\"school\",\"entity_id\":\"7\",\"operation\":\"create\",\"base_version\":null,\"committed_version\":\"1\",\"fields\":{\"name\":\"original\"},\"trace_chain\":[]}]}");
+ }
+ static ObjectNode snapshotBody()throws Exception{
+ return (ObjectNode)JSON.readTree("{\"snapshot_version\":1,\"snapshot_id\":\"baseline\",\"source_runtime_id\":\"boundary-source\",\"source_sequence\":\"0\",\"source_model_hash\":\""+MODEL+"\",\"audit\":{\"comment\":\"receive baseline\",\"purpose\":\"check snapshot boundary\"},\"entity_types\":[\"school\"],\"rows\":[{\"entity_type\":\"school\",\"entity_id\":\"7\",\"version\":\"1\",\"fields\":{\"name\":\"original\"}}]}");
+ }
+ static final class Fixture implements AutoCloseable{
+ final Path owner=Files.createTempDirectory("cmr15-java-");final JdbcSqlExecutor sql;final DefaultUserContext context;final SqliteCommittedReceiver receiver;
+ Fixture()throws Exception{
+ var ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+owner.resolve("target.sqlite"));sql=new JdbcSqlExecutor(ds);
+ var school=new EntityDescriptor();school.setType("school");school.addSimpleProperty("id",Long.class);school.addSimpleProperty("version",Long.class);school.addSimpleProperty("name",String.class);
+ var metadata=new SimpleEntityMetaFactory();metadata.register(school);context=new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).executionLogging(false).build());
+ sql.execute("CREATE TABLE school_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,name TEXT NOT NULL)");
+ receiver=new SqliteCommittedReceiver(sql,SOURCE,TARGET,MODEL,Map.of("current",KEY));receiver.ensureSchema(context);
+ }
+ long count(String table){return ((Number)sql.queryForList("SELECT count(*) n FROM "+table,new Object[0]).get(0).get("n")).longValue();}
+ io.teaql.core.sqlite.SqliteCommittedEventStore.InboxOutcome receive(ObjectNode body)throws Exception{
+ String text=JSON.writeValueAsString(body),hash=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)));
+ var event=new OutboxRecord(SOURCE,1,"fact",MODEL,hash,text);
+ return receiver.receive(context,SqliteCommittedReceiver.sign("current",TARGET,KEY,event));
+ }
+ void reject(ObjectNode body)throws Exception{
+ try{receive(body);fail("noncanonical controls must be rejected");}catch(SqliteCommittedReceiver.ReceiverException error){assertEquals("INVALID_ENVELOPE",error.code());assertFalse(error.toString().contains(SQL));}
+ assertEquals(0,count("school_data"));assertEquals(0,count("teaql_event_inbox"));assertEquals(0,count("teaql_event_checkpoint"));assertEquals(1,count("teaql_event_rejection"));
+ assertFalse(receive(body()).duplicate());assertEquals(1,count("school_data"));assertEquals(1,count("teaql_event_inbox"));
+ }
+ void receiveSnapshot(ObjectNode body)throws Exception{
+ String text=JSON.writeValueAsString(body),hash=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)));
+ var record=new io.teaql.core.sqlite.SqliteCommittedSnapshot.SnapshotRecord(SOURCE,0,"baseline",MODEL,hash,text);
+ receiver.receiveSnapshot(context,SqliteCommittedReceiver.signSnapshot("current",TARGET,KEY,record));
+ }
+ void rejectSnapshot(ObjectNode body)throws Exception{
+ try{receiveSnapshot(body);fail("noncanonical snapshot controls must be rejected");}catch(SqliteCommittedReceiver.ReceiverException error){assertEquals("INVALID_SNAPSHOT",error.code());assertFalse(error.toString().contains(SQL));}
+ for(String table:List.of("school_data","teaql_event_snapshot","teaql_event_inbox","teaql_event_checkpoint"))assertEquals(table,0,count(table));
+ assertEquals(1,count("teaql_event_rejection"));receiveSnapshot(snapshotBody());assertEquals(1,count("school_data"));assertEquals(1,count("teaql_event_snapshot"));
+ }
+ @Override public void close()throws Exception{try(var all=Files.walk(owner)){for(var p:all.sorted(Comparator.reverseOrder()).toList())Files.delete(p);}}
+ }
+ @Test public void envelopeControlsCannotSmuggleSql()throws Exception{
+ for(String key:List.of("sql","rawSql","raw_sql","statement","statements","provider","mutation_plan","replace"))try(var f=new Fixture()){var b=body();b.put(key,SQL);f.reject(b);}
+ System.out.println("CMR15_CONTROL_FRAMES_PASS language=java scope=envelope rejected=8");
+ }
+ @Test public void mutationControlsCannotSmuggleSql()throws Exception{
+ for(String key:List.of("sql","rawSql","raw_sql","statement","table","provider","physical_table","command"))try(var f=new Fixture()){var b=body();((ObjectNode)b.path("mutations").get(0)).put(key,SQL);f.reject(b);}
+ }
+ @Test public void auditControlsCannotSmuggleSql()throws Exception{
+ for(String key:List.of("sql","rawSql","statement","provider","class","command","tenantId"))try(var f=new Fixture()){var b=body();((ObjectNode)b.path("audit")).put(key,SQL);f.reject(b);}
+ }
+ @Test public void traceControlsStayStrict()throws Exception{
+ try(var f=new Fixture()){var b=body();((com.fasterxml.jackson.databind.node.ArrayNode)b.path("mutations").get(0).path("trace_chain")).addObject().put("kind","SQL").put("name","diagnostic").putNull("entity_id").put("comment","trace metadata").put("statement",SQL);f.reject(b);}
+ }
+ @Test public void sqlLookingBusinessAndAuditTextRemainBoundData()throws Exception{
+ try(var f=new Fixture()){var b=body();((ObjectNode)b.path("mutations").get(0).path("fields")).put("name",SQL);((ObjectNode)b.path("audit")).put("comment",SQL).put("actor_ref","user:7").put("trace_id","trace:1");
+ assertFalse(f.receive(b).duplicate());assertEquals(1,f.count("school_data"));assertEquals(SQL,f.sql.queryForList("SELECT name FROM school_data WHERE id=7",new Object[0]).get(0).get("name"));assertEquals(0,f.count("teaql_event_rejection"));
+ }
+ }
+ @Test public void ordinaryRequestsAreNotCommittedEnvelopes()throws Exception{
+ for(String input:List.of("{\"sql\":\"SELECT 1\"}","{\"entity\":\"school\",\"limitValue\":1,\"commentText\":\"read\",\"purposeText\":\"inspect\"}","{\"entity\":\"school\",\"action\":\"Create\",\"payload\":{\"name\":\"business\"},\"comment\":\"create school\"}"))try(var f=new Fixture()){f.reject((ObjectNode)JSON.readTree(input));}
+ }
+ @Test public void snapshotContainerControlsStayStrict()throws Exception{
+ for(String key:List.of("sql","rawSql","statement","provider","replace"))try(var f=new Fixture()){var b=snapshotBody();b.put(key,SQL);f.rejectSnapshot(b);}
+ }
+ @Test public void snapshotRowControlsStayStrict()throws Exception{
+ for(String key:List.of("sql","statement","table","command","operation"))try(var f=new Fixture()){var b=snapshotBody();((ObjectNode)b.path("rows").get(0)).put(key,SQL);f.rejectSnapshot(b);}
+ }
+ @Test public void snapshotAuditControlsCannotSmuggleSql()throws Exception{
+ for(String key:List.of("sql","rawSql","statement","provider","class","command","tenantId"))try(var f=new Fixture()){var b=snapshotBody();((ObjectNode)b.path("audit")).put(key,SQL);f.rejectSnapshot(b);}
+ System.out.println("CMR15_SNAPSHOT_CONTROLS_PASS language=java container=5 row=5 audit=7");
+ }
+ @Test public void snapshotSqlLookingBusinessAndAuditTextRemainBoundData()throws Exception{
+ try(var f=new Fixture()){var b=snapshotBody();((ObjectNode)b.path("rows").get(0).path("fields")).put("name",SQL);((ObjectNode)b.path("audit")).put("comment",SQL).put("actor_ref","user:7").put("trace_id","trace:1");
+ f.receiveSnapshot(b);assertEquals(1,f.count("school_data"));assertEquals(SQL,f.sql.queryForList("SELECT name FROM school_data WHERE id=7",new Object[0]).get(0).get("name"));assertEquals(0,f.count("teaql_event_rejection"));
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedObservationTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedObservationTest.java
new file mode 100644
index 00000000..f119e7c5
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedObservationTest.java
@@ -0,0 +1,162 @@
+package io.teaql.sqlite;
+
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.sqlite.SqliteCommittedEventStore;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.InboxOutcome;
+import io.teaql.core.sqlite.SqliteCommittedOutboxPublisher;
+import io.teaql.core.sqlite.SqliteCommittedObservation;
+import io.teaql.core.sqlite.SqliteCommittedReceiver;
+import io.teaql.core.sqlite.SqliteCommittedSnapshot;
+import io.teaql.core.sqlite.SqliteCommittedSnapshotPublisher;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.RuntimeTelemetry;
+import io.teaql.runtime.TeaQLRuntime;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+import static org.junit.Assert.*;
+
+/** Small infrastructure fixture; not an audited business-save/example claim. */
+public class SqliteCommittedObservationTest {
+ static final String SOURCE="source-CANARY", TARGET="target-CANARY", MODEL="sha256:"+"0".repeat(64);
+ static final class Fixture implements AutoCloseable {
+ final Path directory=Files.createTempDirectory("cmr14-java-");
+ final JdbcSqlExecutor sql; final DefaultUserContext context;
+ final SqliteCommittedOutboxPublisher publisher;
+ final SqliteCommittedObservation observations;
+ final List> telemetry=new ArrayList<>();
+ Fixture() throws Exception {
+ var ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+directory.resolve("source.sqlite"));
+ sql=new JdbcSqlExecutor(ds);new SqliteCommittedEventStore(sql).ensureSchema();
+ RuntimeTelemetry t=op->{telemetry.add(op.attributes());return new RuntimeTelemetry.Scope(){
+ public void success(Map attrs){telemetry.add(attrs);}
+ public void failure(Throwable ignored){fail("observation should succeed");}};};
+ var school=new EntityDescriptor();school.setType("school");school.addSimpleProperty("id",Long.class);school.addSimpleProperty("version",Long.class);school.addSimpleProperty("name",String.class);
+ var metadata=new SimpleEntityMetaFactory();metadata.register(school);
+ context=new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).telemetry(t).build());
+ sql.execute("CREATE TABLE school_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,name TEXT)");
+ publisher=new SqliteCommittedOutboxPublisher(sql,SOURCE,TARGET,MODEL);
+ observations=new SqliteCommittedObservation(sql,SOURCE,TARGET,MODEL);
+ sql.update("INSERT INTO teaql_event_sequence VALUES(?,2)",new Object[]{SOURCE});
+ for(int n=1;n<=2;n++)sql.update("INSERT INTO teaql_event_outbox VALUES(?,?,?,?,?,?)",
+ new Object[]{SOURCE,n,"fact-CANARY-"+n,MODEL,"a".repeat(64),"payload-CANARY"});
+ }
+ @Override public void close()throws Exception{try(var paths=Files.walk(directory)){for(var p:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(p);}}
+ }
+ @Test public void lostResponseRetryAndReopenKeepDurableFacts()throws Exception {
+ try(var f=new Fixture()){
+ try{f.publisher.publishBatch(1,event->{throw new IllegalStateException("transport-CANARY");});fail();}catch(IllegalStateException expected){}
+ var a=f.observations.source(f.context);assertEquals(2,a.sourceHighWatermark());assertEquals(0,a.ackedSequence());
+ assertEquals(2,a.sequenceLag());assertEquals(2,a.backlog());assertEquals(1,a.attempts());assertEquals(1,a.failures());assertEquals(0,a.retries());assertEquals(0,a.deadLetters());
+ assertEquals(1,f.publisher.publishBatch(1,event->new InboxOutcome(false,1)).acknowledged());
+ var reopened=new SQLiteDataSource();reopened.setUrl("jdbc:sqlite:"+f.directory.resolve("source.sqlite"));
+ var b=new SqliteCommittedObservation(new JdbcSqlExecutor(reopened),SOURCE,TARGET,MODEL).source(f.context);
+ assertEquals(1,b.ackedSequence());assertEquals(1,b.backlog());assertEquals(2,b.attempts());assertEquals(1,b.retries());assertEquals(1,b.failures());
+ assertEquals(2,((Number)f.sql.queryForList("SELECT count(*) n FROM teaql_event_outbox",new Object[0]).get(0).get("n")).intValue());
+ assertFalse(f.telemetry.toString().contains("CANARY"));assertTrue(f.telemetry.toString().contains("teaql.replication.retries"));
+ System.out.println("CMR14_SOURCE_PASS language=java retry=1 backlog=1");
+ }
+ }
+ @Test public void pendingRowsAreNotSequenceDistance()throws Exception{
+ try(var f=new Fixture()){
+ f.sql.update("DELETE FROM teaql_event_outbox WHERE source_sequence=1",new Object[0]);
+ var s=f.observations.source(f.context);assertEquals(2,s.sequenceLag());assertEquals(1,s.backlog());
+ try{f.publisher.publishBatch(2,e->new InboxOutcome(false,e.sourceSequence()));fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("SEQUENCE_GAP",expected.code());}
+ assertEquals(0,f.observations.source(f.context).attempts());
+ }
+ }
+ @Test public void badAckDoesNotAcknowledgeOrHideFailure()throws Exception{
+ try(var f=new Fixture()){
+ try{f.publisher.publishBatch(1,e->new InboxOutcome(false,2));fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("ACK_MISMATCH",expected.code());}
+ var s=f.observations.source(f.context);assertEquals(0,s.ackedSequence());assertEquals(1,s.failures());assertEquals(0,s.deadLetters());
+ }
+ }
+ @Test public void journalFailureRollsBackSourceAck()throws Exception{
+ try(var f=new Fixture()){
+ f.sql.execute("CREATE TRIGGER refuse_ack BEFORE UPDATE ON teaql_event_delivery_journal WHEN NEW.last_outcome='ACKNOWLEDGED' BEGIN SELECT RAISE(ABORT,'journal-CANARY'); END");
+ try{f.publisher.publishBatch(1,e->new InboxOutcome(false,1));fail();}catch(RuntimeException expected){}
+ var s=f.observations.source(f.context);assertEquals(0,s.ackedSequence());assertEquals(1,s.unresolvedAttempts());assertEquals(0,s.failures());
+ }
+ }
+ @Test public void observationsRejectAmbientAndInconsistentPositions()throws Exception{
+ try(var f=new Fixture()){
+ f.sql.executeInTransaction(()->{try{f.observations.source(f.context);fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("OBSERVATION_UNAVAILABLE",expected.code());}});
+ f.sql.update("INSERT INTO teaql_event_delivery_cursor VALUES(?,?,3,?)",new Object[]{SOURCE,TARGET,MODEL});
+ try{f.observations.source(f.context);fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("OBSERVATION_UNAVAILABLE",expected.code());}
+ }
+ }
+ @Test public void permanentRejectionIsInspectableAndResolvingDoesNotEraseHistory()throws Exception{
+ try(var f=new Fixture()){
+ var ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+f.directory.resolve("target.sqlite"));var target=new JdbcSqlExecutor(ds);
+ var receiver=new SqliteCommittedReceiver(target,SOURCE,TARGET,MODEL,Map.of("current",new byte[32]));receiver.ensureSchema(f.context);
+ try{f.publisher.publishBatch(1,e->receiver.receive(f.context,new SqliteCommittedReceiver.Delivery(e,"current",TARGET,new byte[32])));fail();}catch(SqliteCommittedReceiver.ReceiverException expected){assertEquals("INVALID_SIGNATURE",expected.code());}
+ var s=f.observations.source(f.context);assertEquals(1,s.rejections());assertEquals(1,s.deadLetters());assertEquals(0,s.ackedSequence());
+ var failures=f.observations.deadLetters(f.context,10);assertEquals(1,failures.size());assertEquals("INVALID_SIGNATURE",failures.get(0).reason());assertFalse(failures.toString().contains("CANARY"));
+ for(int limit:new int[]{0,1001})try{f.observations.deadLetters(f.context,limit);fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){}
+ try{f.publisher.publishBatch(1,e->{throw new IllegalStateException("network temporarily down");});fail();}catch(IllegalStateException expected){}
+ assertEquals("INVALID_SIGNATURE",f.observations.deadLetters(f.context,10).get(0).reason());
+ // This deliberately fake callback ACK is not evidence of target application.
+ f.publisher.publishBatch(1,e->new InboxOutcome(false,1));
+ var after=f.observations.source(f.context);assertEquals(0,after.deadLetters());assertEquals(1,after.rejections());assertEquals(2,after.retries());
+ var targetView=new SqliteCommittedObservation(target,SOURCE,TARGET,MODEL).target(f.context,OptionalLong.of(2));assertEquals(0,targetView.checkpoint());assertEquals(2,targetView.sequenceLag().getAsLong());assertEquals(1,targetView.rejections());
+ }
+ }
+ @Test public void unauthorizedSourceAuditIsNotFilteredOutAndMissingLagStaysAbsent()throws Exception{
+ try(var f=new Fixture()){
+ var receiver=new SqliteCommittedReceiver(f.sql,SOURCE,TARGET,MODEL,Map.of("current",new byte[32]));receiver.ensureSchema(f.context);
+ var e=new SqliteCommittedEventStore.OutboxRecord("intruder",1,"x",MODEL,"a".repeat(64),"payload-CANARY");
+ try{receiver.receive(f.context,new SqliteCommittedReceiver.Delivery(e,"current",TARGET,new byte[32]));fail();}catch(SqliteCommittedReceiver.ReceiverException expected){assertEquals("UNAUTHORIZED_SOURCE",expected.code());}
+ var s=f.observations.target(f.context,OptionalLong.empty());assertFalse(s.sequenceLag().isPresent());assertEquals(1,s.rejections());
+ f.sql.update("INSERT INTO teaql_event_checkpoint VALUES(?,1,?)",new Object[]{SOURCE,MODEL});
+ try{f.observations.target(f.context,OptionalLong.of(0));fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("OBSERVATION_UNAVAILABLE",expected.code());}
+ }
+ }
+ @Test public void staleAttemptCannotOverwriteNewerSuccessfulAttempt()throws Exception{
+ try(var f=new Fixture()){
+ try{f.publisher.publishBatch(1,e->{f.publisher.publishBatch(1,inner->new InboxOutcome(false,1));return new InboxOutcome(false,1);});fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("CURSOR_CONFLICT",expected.code());}
+ var s=f.observations.source(f.context);assertEquals(1,s.ackedSequence());assertEquals(2,s.attempts());assertEquals(0,s.failures());assertEquals(0,s.unresolvedAttempts());
+ }
+ }
+ @Test public void abortedCallbackIsUnresolvedNotFailed()throws Exception{
+ try(var f=new Fixture()){
+ try{f.publisher.publishBatch(1,e->{throw new AssertionError("aborted-CANARY");});fail();}catch(AssertionError expected){}
+ var s=f.observations.source(f.context);assertEquals(1,s.unresolvedAttempts());assertEquals(0,s.failures());assertEquals(0,s.ackedSequence());
+ f.publisher.publishBatch(1,e->new InboxOutcome(false,1));assertEquals(1,f.observations.source(f.context).retries());
+ }
+ }
+ @Test public void snapshotZeroHasIndependentDurableAttempts()throws Exception{
+ try(var f=new Fixture()){
+ f.sql.update("DELETE FROM teaql_event_outbox",new Object[0]);f.sql.update("DELETE FROM teaql_event_sequence",new Object[0]);
+ var publisher=new SqliteCommittedSnapshotPublisher(f.sql,SOURCE,TARGET,MODEL);var limits=SqliteCommittedSnapshot.Limits.defaults();
+ publisher.prepare(f.context,"baseline","capture empty baseline","verify observations",limits);
+ try{publisher.publish(f.context,"baseline",limits,e->{throw new IllegalStateException("lost response-CANARY");});fail();}catch(IllegalStateException expected){}
+ var before=f.observations.source(f.context);assertEquals(1,before.pendingSnapshots());assertEquals(1,before.attempts());assertEquals(1,before.failures());
+ publisher.publish(f.context,"baseline",limits,e->new SqliteCommittedSnapshotPublisher.Receipt(SOURCE,TARGET,e.snapshotId(),e.sourceSequence(),MODEL,e.payloadSha256(),false));
+ var after=f.observations.source(f.context);assertEquals(0,after.pendingSnapshots());assertEquals(0,after.sourceHighWatermark());assertEquals(0,after.ackedSequence());assertEquals(2,after.attempts());assertEquals(1,after.retries());
+ publisher.publish(f.context,"baseline",limits,e->{throw new AssertionError("already acked");});assertEquals(2,f.observations.source(f.context).attempts());
+ }
+ }
+ @Test public void counterOverflowAndWrongModelFailClosed()throws Exception{
+ try(var f=new Fixture()){
+ try{f.publisher.publishBatch(1,e->{throw new IllegalStateException();});fail();}catch(IllegalStateException expected){}
+ f.sql.update("UPDATE teaql_event_delivery_journal SET attempts=?",new Object[]{Long.MAX_VALUE});
+ try{f.publisher.publishBatch(1,e->{fail("overflow must not invoke transport");return null;});fail();}catch(ArithmeticException expected){}
+ assertEquals(Long.MAX_VALUE,f.observations.source(f.context).attempts());
+ f.sql.update("UPDATE teaql_event_delivery_journal SET model_hash=?",new Object[]{"sha256:"+"1".repeat(64)});
+ try{f.observations.source(f.context);fail();}catch(SqliteCommittedEventStore.CommittedEventException expected){assertEquals("OBSERVATION_UNAVAILABLE",expected.code());}
+ }
+ }
+ @Test public void telemetryExporterCannotBreakObservation()throws Exception{
+ try(var f=new Fixture()){
+ RuntimeTelemetry broken=op->{throw new IllegalStateException("exporter unavailable");};
+ var context=new DefaultUserContext(TeaQLRuntime.builder().metadata(new SimpleEntityMetaFactory()).telemetry(broken).build());
+ assertEquals(2,f.observations.source(context).backlog());
+ RuntimeTelemetry brokenScope=op->new RuntimeTelemetry.Scope(){public void success(Map a){throw new IllegalStateException("exporter unavailable");}public void failure(Throwable t){throw new AssertionError();}};
+ context=new DefaultUserContext(TeaQLRuntime.builder().metadata(new SimpleEntityMetaFactory()).telemetry(brokenScope).build());assertEquals(2,f.observations.source(context).backlog());
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReadOnlyTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReadOnlyTest.java
new file mode 100644
index 00000000..8f8313d0
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReadOnlyTest.java
@@ -0,0 +1,62 @@
+package io.teaql.sqlite;
+
+import io.teaql.core.*;
+import io.teaql.core.meta.EntityMetaFactory;
+import io.teaql.core.sqlite.SqliteDataServiceExecutor;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.*;
+import java.util.List;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+import static org.junit.Assert.*;
+
+/** A real ordinary executor and the signed receiver deliberately share storage. */
+public class SqliteCommittedReadOnlyTest {
+ public static final class School extends BaseEntity {
+ private String name;
+ @Override public String typeName(){return "school";}
+ @Override public Object __internalGet(String field){return "name".equals(field)?name:super.__internalGet(field);}
+ @Override public void __internalSet(String field,Object value){if("name".equals(field))name=(String)value;else super.__internalSet(field,value);}
+ }
+ private record Application(DefaultUserContext context,SqliteDataServiceExecutor provider){}
+ private static Application application(JdbcSqlExecutor sql,io.teaql.core.meta.EntityMetaFactory metadata){
+ var descriptor=metadata.resolveEntityDescriptor("school");descriptor.setTargetType(School.class);descriptor.withEntitySupplier(School::new);
+ var provider=new SqliteDataServiceExecutor("default",sql,null);
+ var context=new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).dataService("default",provider)
+ .idGenerationService((c,e)->99L).executionLogging(false).build());
+ return new Application(context,provider);
+ }
+ private static School school(){var s=new School();s.updateProperty("name","ordinary write");s.setComment("verify default analytical target write boundary");return s;}
+ private static void readonly(Runnable action){var error=assertThrows("analytical target ordinary mutation must be read-only",RuntimeException.class,()->action.run());assertTrue(error.toString(),error.toString().contains("READ_ONLY_TARGET"));}
+ @Test public void graphSaveCannotWriteThroughReceiverAuthority()throws Exception{
+ try(var f=new SqliteCommittedBoundaryTest.Fixture()){
+ var app=application(f.sql,EntityMetaFactory.requireFrom(f.context));
+ readonly(()->app.context().saveGraph(school()));assertEquals(0,f.count("school_data"));
+ assertFalse(f.receive(SqliteCommittedBoundaryTest.body()).duplicate());assertEquals(1,f.count("school_data"));
+ assertEquals(1,f.count("teaql_event_inbox"));assertEquals(0,f.count("teaql_event_rejection"));
+ var query=new BaseRequest.TempRequest(EntityMetaFactory.requireFrom(app.context()).resolveEntityDescriptor("school"));
+ query.selectProperty("name");query.getSlice().setSize(1);
+ var result=(DefaultQueryResult)app.provider().query(app.context(),new DefaultQueryRequest(query,QueryIntent.of("read analytical target","verify reads remain available")));
+ assertEquals(1,result.getResult().size());assertEquals("original",((Entity)result.getResult().getData().get(0)).getProperty("name"));
+ }
+ }
+ @Test public void directAndBatchMutationCannotBypassReadOnlyTarget()throws Exception{
+ for(boolean batch:List.of(false,true))try(var f=new SqliteCommittedBoundaryTest.Fixture()){
+ var app=application(f.sql,EntityMetaFactory.requireFrom(f.context));var s=school();s.setProperty("id",99L);
+ var request=new EntityPersistenceMutation(s,EntityPersistenceMutation.Action.SAVE);
+ if(batch)readonly(()->app.provider().mutateBatch(app.context(),new MutationBatchRequest(request.intent(),List.of(request))));
+ else readonly(()->app.provider().mutate(app.context(),request));
+ assertEquals(0,f.count("school_data"));assertEquals(0,f.count("teaql_event_inbox"));
+ }
+ }
+ @Test public void ordinarySourceIsNotMadeReadOnlyByAnotherTargetsReceiver()throws Exception{
+ try(var f=new SqliteCommittedBoundaryTest.Fixture()){
+ var ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+f.owner.resolve("ordinary.sqlite"));var sql=new JdbcSqlExecutor(ds);
+ sql.execute("CREATE TABLE school_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,name TEXT NOT NULL)");
+ var app=application(sql,EntityMetaFactory.requireFrom(f.context));app.context().saveGraph(school());
+ assertEquals(1,((Number)sql.queryForList("SELECT count(*) n FROM school_data",new Object[0]).get(0).get("n")).intValue());
+ assertEquals(0,f.count("school_data"));assertFalse(f.receive(SqliteCommittedBoundaryTest.body()).duplicate());
+ System.out.println("CMR15_READ_ONLY_SOURCE_PASS language=java graph=1 direct=1 batch=1 source_isolated=1 replication=1");
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReceiverSnapshotTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReceiverSnapshotTest.java
new file mode 100644
index 00000000..ffdee8da
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReceiverSnapshotTest.java
@@ -0,0 +1,131 @@
+package io.teaql.sqlite;
+
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.OutboxRecord;
+import io.teaql.core.sqlite.SqliteCommittedReceiver;
+import io.teaql.core.sqlite.SqliteCommittedReceiver.Delivery;
+import io.teaql.core.sqlite.SqliteCommittedReceiver.SnapshotDelivery;
+import io.teaql.core.sqlite.SqliteCommittedReceiver.ReceiverException;
+import io.teaql.core.sqlite.SqliteCommittedSnapshot.Limits;
+import io.teaql.core.sqlite.SqliteCommittedSnapshot.SnapshotRecord;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.Map;
+import static org.junit.Assert.*;
+
+public class SqliteCommittedReceiverSnapshotTest {
+ private static final String SOURCE="snapshot-source",TARGET="snapshot-target",MODEL="sha256:"+"0".repeat(64);
+ private static final byte[] KEY="0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
+ private static final String CANARY="SNAPSHOT_SECRET_never_in_rejection_audit";
+ private static String digest(String s)throws Exception{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}
+ private static SnapshotRecord snapshot(long n,String id)throws Exception{
+ String body="{\"snapshot_version\":1,\"snapshot_id\":\""+id+"\",\"source_runtime_id\":\""+SOURCE+"\",\"source_sequence\":\""+n+"\",\"source_model_hash\":\""+MODEL+"\",\"audit\":{\"comment\":\"load baseline\",\"purpose\":\"verify authenticated recovery\"},\"entity_types\":[\"school\"],\"rows\":[{\"entity_type\":\"school\",\"entity_id\":\"7\",\"version\":\"1\",\"fields\":{\"name\":\""+CANARY+"\"}}]}";
+ return new SnapshotRecord(SOURCE,n,id,MODEL,digest(body),body);
+ }
+ private static OutboxRecord tail(long n)throws Exception{
+ String body="{\"protocol_version\":1,\"change_set_id\":\"tail\",\"source_runtime_id\":\""+SOURCE+"\",\"source_sequence\":\""+n+"\",\"source_model_hash\":\""+MODEL+"\",\"source_transaction_id\":\"tx\",\"committed_at\":\"2026-10-22T00:00:00Z\",\"audit\":{\"comment\":\"apply tail\",\"purpose\":\"verify recovery\"},\"mutations\":[{\"entity_type\":\"school\",\"entity_id\":\"7\",\"operation\":\"update\",\"base_version\":\"1\",\"committed_version\":\"2\",\"fields\":{\"name\":\"updated\"},\"trace_chain\":[]}]}";
+ return new OutboxRecord(SOURCE,n,"tail",MODEL,digest(body),body);
+ }
+ private static SnapshotDelivery signed(SnapshotRecord s){return SqliteCommittedReceiver.signSnapshot("current",TARGET,KEY,s);}
+ private static SnapshotDelivery replace(SnapshotDelivery d,SnapshotRecord s){return new SnapshotDelivery(s,d.keyId(),d.targetId(),d.signature());}
+ private static final class Fixture implements AutoCloseable{
+ final Path dir=Files.createTempDirectory("cmr13-snapshot-java-");final JdbcSqlExecutor sql;final DefaultUserContext context;final SqliteCommittedReceiver receiver;
+ Fixture()throws Exception{
+ SQLiteDataSource ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+dir.resolve("target.sqlite"));sql=new JdbcSqlExecutor(ds);
+ EntityDescriptor e=new EntityDescriptor();e.setType("school");e.addSimpleProperty("id",Long.class);e.addSimpleProperty("version",Long.class);e.addSimpleProperty("name",String.class);
+ SimpleEntityMetaFactory meta=new SimpleEntityMetaFactory();meta.register(e);context=new DefaultUserContext(TeaQLRuntime.builder().metadata(meta).build());
+ sql.execute("CREATE TABLE school_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,name TEXT NOT NULL)");receiver=new SqliteCommittedReceiver(sql,SOURCE,TARGET,MODEL,Map.of("current",KEY));receiver.ensureSchema(context);
+ }
+ long count(String table){return ((Number)sql.queryForList("SELECT count(*) AS n FROM "+table,new Object[0]).get(0).get("n")).longValue();}
+ void empty(long audits){for(String t:new String[]{"school_data","teaql_event_snapshot","teaql_event_checkpoint","teaql_event_inbox"})assertEquals(t,0,count(t));assertEquals(audits,count("teaql_event_rejection"));}
+ void reject(String code,SnapshotDelivery d){try{receiver.receiveSnapshot(context,d);fail();}catch(ReceiverException e){assertEquals(code,e.code());assertNull(e.getCause());assertFalse(e.toString().contains(CANARY));}}
+ @Override public void close()throws Exception{try(var paths=Files.walk(dir)){for(Path p:paths.sorted(java.util.Comparator.reverseOrder()).toList())Files.delete(p);}}
+ }
+ @Test public void zeroAndNonzeroSnapshotTailAndDuplicateNeverRewind()throws Exception{
+ for(long n:new long[]{0,5})try(var f=new Fixture()){
+ var d=signed(snapshot(n,"baseline"));var ack=f.receiver.receiveSnapshot(f.context,d);assertEquals(n,ack.sourceSequence());assertFalse(ack.duplicate());assertEquals(TARGET,ack.targetId());assertEquals(d.snapshot().payloadSha256(),ack.payloadSha256());
+ f.receiver.receive(f.context,SqliteCommittedReceiver.sign("current",TARGET,KEY,tail(n+1)));var duplicate=f.receiver.receiveSnapshot(f.context,d);assertTrue(duplicate.duplicate());assertEquals(ack.sourceSequence(),duplicate.sourceSequence());
+ assertEquals("updated",f.sql.queryForList("SELECT name FROM school_data",new Object[0]).get(0).get("name"));assertEquals(n+1,((Number)f.sql.queryForList("SELECT source_sequence FROM teaql_event_checkpoint",new Object[0]).get(0).get("source_sequence")).longValue());assertEquals(0,f.count("teaql_event_rejection"));
+ System.out.println("CMR13_SNAPSHOT_SOURCE_PASS language=java baseline="+n+" tail="+(n+1)+" duplicate_no_rewind=1");
+ }
+ }
+ @Test public void sameEightFieldsCannotCrossEventSnapshotSignatureDomains()throws Exception{
+ try(var f=new Fixture()){
+ var s=snapshot(1,"same");var e=new OutboxRecord(s.sourceRuntimeId(),1,s.snapshotId(),s.modelHash(),s.payloadSha256(),s.payloadJson());var eventTag=SqliteCommittedReceiver.sign("current",TARGET,KEY,e).signature();
+ f.reject("INVALID_SIGNATURE",new SnapshotDelivery(s,"current",TARGET,eventTag));
+ try{f.receiver.receive(f.context,new Delivery(e,"current",TARGET,signed(s).signature()));fail();}catch(ReceiverException error){assertEquals("INVALID_SIGNATURE",error.code());}
+ f.empty(2);
+ }
+ }
+ @Test public void snapshotTamperRoutingAndEveryIdentityFieldReject()throws Exception{
+ try(var f=new Fixture()){
+ var s=snapshot(0,"baseline");var d=signed(s);String altered=s.payloadJson().replace(CANARY,"attacker");
+ for(String hash:new String[]{s.payloadSha256(),digest(altered)})f.reject("INVALID_SIGNATURE",replace(d,new SnapshotRecord(SOURCE,0,s.snapshotId(),MODEL,hash,altered)));
+ f.reject("INVALID_SIGNATURE",replace(d,new SnapshotRecord(SOURCE,1,s.snapshotId(),MODEL,s.payloadSha256(),s.payloadJson())));
+ f.reject("INVALID_SIGNATURE",replace(d,new SnapshotRecord(SOURCE,0,"other",MODEL,s.payloadSha256(),s.payloadJson())));
+ f.reject("UNAUTHORIZED_SOURCE",replace(d,new SnapshotRecord("other",0,s.snapshotId(),MODEL,s.payloadSha256(),s.payloadJson())));
+ f.reject("UNAUTHORIZED_TARGET",new SnapshotDelivery(s,"current","other",d.signature()));f.reject("UNKNOWN_KEY",new SnapshotDelivery(s,"retired",TARGET,d.signature()));
+ f.reject("MODEL_MISMATCH",replace(d,new SnapshotRecord(SOURCE,0,s.snapshotId(),"sha256:"+"1".repeat(64),s.payloadSha256(),s.payloadJson())));f.empty(8);
+ }
+ }
+ @Test public void authenticatedMalformedSnapshotAndConflictingBaselineAreAudited()throws Exception{
+ try(var f=new Fixture()){
+ var s=snapshot(0,"baseline");String invalid=s.payloadJson().replace("\"school\"","\"unknown\"");f.reject("INVALID_SNAPSHOT",signed(new SnapshotRecord(SOURCE,0,s.snapshotId(),MODEL,digest(invalid),invalid)));f.empty(1);
+ f.receiver.receiveSnapshot(f.context,signed(s));f.reject("SNAPSHOT_CONFLICT",signed(snapshot(1,"different")));assertEquals(1,f.count("school_data"));assertEquals(2,f.count("teaql_event_rejection"));
+ assertEquals(CANARY,f.sql.queryForList("SELECT name FROM school_data",new Object[0]).get(0).get("name"));
+ }
+ }
+ @Test public void snapshotAuditReopensAndAuditFailureNeverAcknowledges()throws Exception{
+ try(var f=new Fixture()){
+ var d=new SnapshotDelivery(snapshot(0,CANARY),"current",TARGET,new byte[32]);f.reject("INVALID_SIGNATURE",d);f.empty(1);
+ SQLiteDataSource ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+f.dir.resolve("target.sqlite"));var rows=new JdbcSqlExecutor(ds).queryForList("SELECT * FROM teaql_event_rejection",new Object[0]);assertEquals(1,rows.size());assertFalse(rows.toString().contains(CANARY));assertFalse(rows.toString().contains(new String(KEY,StandardCharsets.UTF_8)));
+ f.sql.execute("CREATE TRIGGER reject_audit BEFORE INSERT ON teaql_event_rejection BEGIN SELECT RAISE(ABORT,'"+CANARY+"'); END");f.reject("AUDIT_UNAVAILABLE",d);f.empty(1);
+ }
+ }
+ @Test public void receiverOwnedLimitsAndAmbientTransactionsFailClosed()throws Exception{
+ try(var f=new Fixture()){
+ var d=signed(snapshot(0,"baseline"));var strict=new SqliteCommittedReceiver(f.sql,SOURCE,TARGET,MODEL,Map.of("current",KEY),new Limits(1,16));
+ try{strict.receiveSnapshot(f.context,d);fail();}catch(ReceiverException e){assertEquals("INVALID_ENVELOPE",e.code());}f.empty(1);
+ f.reject("INVALID_ENVELOPE",replace(d,new SnapshotRecord(SOURCE,-1,"baseline",MODEL,d.snapshot().payloadSha256(),d.snapshot().payloadJson())));
+ f.reject("INVALID_ENVELOPE",new SnapshotDelivery(d.snapshot(),"current",TARGET,new byte[0]));f.empty(3);
+ f.sql.executeInTransaction(()->f.reject("AMBIENT_TRANSACTION",d));f.empty(3);
+ }
+ }
+ @Test public void knownKeyAliasDestinationAndLengthFramingAreBound()throws Exception{
+ try(var f=new Fixture()){
+ var s=snapshot(0,"baseline");var d=signed(s);var aliases=new SqliteCommittedReceiver(f.sql,SOURCE,TARGET,MODEL,Map.of("current",KEY,"alias",KEY));
+ try{aliases.receiveSnapshot(f.context,new SnapshotDelivery(s,"alias",TARGET,d.signature()));fail();}catch(ReceiverException e){assertEquals("INVALID_SIGNATURE",e.code());}
+ var destination=new SqliteCommittedReceiver(f.sql,SOURCE,"other",MODEL,Map.of("current",KEY));try{destination.receiveSnapshot(f.context,new SnapshotDelivery(s,"current","other",d.signature()));fail();}catch(ReceiverException e){assertEquals("INVALID_SIGNATURE",e.code());}
+ assertFalse(Arrays.equals(SqliteCommittedReceiver.signSnapshot("a","bc",KEY,s).signature(),SqliteCommittedReceiver.signSnapshot("ab","c",KEY,s).signature()));f.empty(2);
+ }
+ }
+ @Test public void independentSnapshotGoldenAndDefensiveTagCopies()throws Exception{
+ var s=new SnapshotRecord("source",0,"baseline",MODEL,"a".repeat(64),"{\"x\":\"雪\"}");var d=SqliteCommittedReceiver.signSnapshot("key","target",KEY,s);
+ assertEquals("21dd4226dec5f6513b30b82b9bc4b8a340421a5663e4635658c54291226fe8d9",HexFormat.of().formatHex(d.signature()));byte[] first=d.signature();Arrays.fill(first,(byte)0);assertFalse(Arrays.equals(first,d.signature()));assertFalse(d.toString().contains(s.payloadJson()));
+ }
+ @Test public void trustedStartupReplacementCannotResetAnActiveStream()throws Exception{
+ try(var f=new Fixture()){
+ // Pre-stream fixture, representing existing bootstrap/legacy rows;
+ // not a production seeding API or a wire-selectable restore mode.
+ f.sql.update("INSERT INTO school_data(id,version,name) VALUES(99,1,'pre-stream fixture')",new Object[0]);
+ var original=signed(snapshot(0,"baseline"));f.reject("SNAPSHOT_CONFLICT",original);
+ assertEquals(99L,((Number)f.sql.queryForList("SELECT id FROM school_data",new Object[0]).get(0).get("id")).longValue());
+ var configured=new SqliteCommittedReceiver(f.sql,SOURCE,TARGET,MODEL,Map.of("current",KEY),new Limits(10,65536),io.teaql.core.sqlite.SqliteCommittedSnapshot.ImportMode.REPLACE_UNINITIALIZED);
+ assertFalse(configured.receiveSnapshot(f.context,original).duplicate());
+ configured.receive(f.context,SqliteCommittedReceiver.sign("current",TARGET,KEY,tail(1)));
+ try{configured.receiveSnapshot(f.context,signed(snapshot(0,"different")));fail();}catch(ReceiverException e){assertEquals("SNAPSHOT_CONFLICT",e.code());}
+ assertEquals("updated",f.sql.queryForList("SELECT name FROM school_data",new Object[0]).get(0).get("name"));assertEquals(1,f.count("school_data"));assertEquals(1,f.count("teaql_event_inbox"));assertEquals(2,f.count("teaql_event_rejection"));
+ System.out.println("CMR13_STARTUP_POLICY_PASS language=java default_reject=1 explicit_replace=1 active_stream_fenced=1");
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReceiverTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReceiverTest.java
new file mode 100644
index 00000000..b45adc14
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedReceiverTest.java
@@ -0,0 +1,127 @@
+package io.teaql.sqlite;
+
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.OutboxRecord;
+import io.teaql.core.sqlite.SqliteCommittedReceiver;
+import io.teaql.core.sqlite.SqliteCommittedReceiver.Delivery;
+import io.teaql.core.sqlite.SqliteCommittedReceiver.ReceiverException;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.Map;
+import static org.junit.Assert.*;
+
+/** Public receiver APIs only: reusable by isolated installed consumers. */
+public class SqliteCommittedReceiverTest {
+ private static final String SOURCE="receiver-source", TARGET="receiver-target";
+ private static final String MODEL="sha256:"+"0".repeat(64);
+ private static final byte[] KEY="0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
+ private static final String CANARY="BUSINESS_SECRET_must_not_appear_in_audit";
+
+ private static String digest(String text) throws Exception {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)));}
+ private static OutboxRecord event(long sequence,String change,String source,boolean update) throws Exception {
+ String body="{\"protocol_version\":1,\"change_set_id\":\""+change+"\",\"source_runtime_id\":\""+source+"\",\"source_sequence\":\""+sequence+"\",\"source_model_hash\":\""+MODEL+"\",\"source_transaction_id\":\"tx\",\"committed_at\":\"2026-10-22T00:00:00Z\",\"audit\":{\"comment\":\"receive school\",\"purpose\":\"verify signed delivery\"},\"mutations\":[{\"entity_type\":\"school\",\"entity_id\":\"7\",\"operation\":\""+(update?"update":"create")+"\",\"base_version\":"+(update?"\"1\"":"null")+",\"committed_version\":\""+(update?"2":"1")+"\",\"fields\":{\"name\":\""+(update?"updated":CANARY)+"\"},\"trace_chain\":[]}]}";
+ return new OutboxRecord(source,sequence,change,MODEL,digest(body),body);
+ }
+ private static Delivery sign(OutboxRecord event){return SqliteCommittedReceiver.sign("current",TARGET,KEY,event);}
+ private static Delivery replace(Delivery original,OutboxRecord event){return new Delivery(event,original.keyId(),original.targetId(),original.signature());}
+ private static final class Fixture implements AutoCloseable {
+ final Path dir=Files.createTempDirectory("cmr13-java-");
+ final JdbcSqlExecutor sql; final DefaultUserContext context; final SqliteCommittedReceiver receiver;
+ Fixture() throws Exception {
+ SQLiteDataSource ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+dir.resolve("target.sqlite"));sql=new JdbcSqlExecutor(ds);
+ EntityDescriptor school=new EntityDescriptor();school.setType("school");school.addSimpleProperty("id",Long.class);school.addSimpleProperty("version",Long.class);school.addSimpleProperty("name",String.class);
+ SimpleEntityMetaFactory metadata=new SimpleEntityMetaFactory();metadata.register(school);context=new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).build());
+ sql.execute("CREATE TABLE school_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,name TEXT NOT NULL)");
+ receiver=new SqliteCommittedReceiver(sql,SOURCE,TARGET,MODEL,Map.of("current",KEY));receiver.ensureSchema(context);
+ }
+ long count(String table){return ((Number)sql.queryForList("SELECT count(*) AS n FROM "+table,new Object[0]).get(0).get("n")).longValue();}
+ void unchanged(long rows,long checkpoint,long audits){assertEquals(rows,count("school_data"));assertEquals(checkpoint,count("teaql_event_inbox"));assertEquals(audits,count("teaql_event_rejection"));}
+ void rejected(String code,Delivery delivery) {try{receiver.receive(context,delivery);fail("receiver must reject");}catch(ReceiverException error){assertEquals(code,error.code());assertFalse(error.toString().contains(CANARY));assertNull(error.getCause());}}
+ @Override public void close()throws Exception{try(var paths=Files.walk(dir)){for(Path p:paths.sorted(java.util.Comparator.reverseOrder()).toList())Files.delete(p);}}
+ }
+
+ @Test public void validSignedApplyUpdateAndRetainedDuplicate()throws Exception{
+ try(var f=new Fixture()){
+ var a=sign(event(1,"create",SOURCE,false));var b=sign(event(2,"update",SOURCE,true));
+ assertFalse(f.receiver.receive(f.context,a).duplicate());assertFalse(f.receiver.receive(f.context,b).duplicate());assertTrue(f.receiver.receive(f.context,a).duplicate());assertTrue(f.receiver.receive(f.context,b).duplicate());f.unchanged(1,2,0);
+ assertEquals("updated",f.sql.queryForList("SELECT name FROM school_data",new Object[0]).get(0).get("name"));
+ String origin=SqliteCommittedReceiver.class.getProtectionDomain().getCodeSource().getLocation().getPath();
+ if(System.getProperty("cmr13.source.root")!=null)assertTrue(origin,origin.startsWith(System.getProperty("cmr13.source.root")+"/teaql-sqlite/target/classes"));
+ System.out.println("CMR13_SOURCE_PASS language=java origin="+origin+" valid=2 duplicates=2");
+ }
+ }
+ @Test public void tamperingEvenWithRecomputedChecksumRejectsWithoutWrites()throws Exception{
+ try(var f=new Fixture()){
+ var record=event(1,"a",SOURCE,false);var signed=sign(record);String changed=record.payloadJson().replace(CANARY,"attacker-value");
+ for(String hash:new String[]{record.payloadSha256(),digest(changed)})f.rejected("INVALID_SIGNATURE",replace(signed,new OutboxRecord(SOURCE,1,"a",MODEL,hash,changed)));
+ f.unchanged(0,0,2);
+ }
+ }
+ @Test public void routingAndKeyChangesRejectAndAudit()throws Exception{
+ try(var f=new Fixture()){
+ var e=event(1,"a",SOURCE,false);var d=sign(e);
+ f.rejected("UNAUTHORIZED_SOURCE",sign(event(1,"a","other-source",false)));
+ f.rejected("UNAUTHORIZED_TARGET",new Delivery(e,d.keyId(),"other-target",d.signature()));
+ f.rejected("UNKNOWN_KEY",new Delivery(e,"retired",TARGET,d.signature()));
+ f.rejected("INVALID_SIGNATURE",new Delivery(e,d.keyId(),TARGET,new byte[32]));
+ f.rejected("INVALID_ENVELOPE",new Delivery(e,d.keyId(),TARGET,new byte[0]));
+ f.rejected("INVALID_SIGNATURE",replace(d,new OutboxRecord(SOURCE,2,"a",MODEL,e.payloadSha256(),e.payloadJson())));
+ f.rejected("INVALID_SIGNATURE",replace(d,new OutboxRecord(SOURCE,1,"b",MODEL,e.payloadSha256(),e.payloadJson())));
+ f.rejected("MODEL_MISMATCH",replace(d,new OutboxRecord(SOURCE,1,"a","sha256:"+"1".repeat(64),e.payloadSha256(),e.payloadJson())));
+ f.unchanged(0,0,8);
+ }
+ }
+ @Test public void signedOutOfStreamReplayAndConflictAreAudited()throws Exception{
+ try(var f=new Fixture()){
+ var e=event(1,"a",SOURCE,false);f.receiver.receive(f.context,sign(e));f.receiver.receive(f.context,sign(event(2,"b",SOURCE,true)));
+ f.rejected("SEQUENCE_GAP",sign(event(1,"old-unseen",SOURCE,false)));
+ f.rejected("SEQUENCE_GAP",sign(event(4,"gap",SOURCE,true)));
+ String body=e.payloadJson().replace(CANARY,"changed");f.rejected("DUPLICATE_CONFLICT",sign(new OutboxRecord(SOURCE,1,"a",MODEL,digest(body),body)));
+ f.unchanged(1,2,3);
+ assertEquals(2L,((Number)f.sql.queryForList("SELECT source_sequence FROM teaql_event_checkpoint",new Object[0]).get(0).get("source_sequence")).longValue());
+ }
+ }
+ @Test public void auditIsDurableHashOnlyAndUnavailableIsDistinct()throws Exception{
+ try(var f=new Fixture()){
+ var e=event(1,CANARY,SOURCE,false);f.rejected("INVALID_SIGNATURE",new Delivery(e,"current",TARGET,new byte[32]));
+ SQLiteDataSource ds=new SQLiteDataSource();ds.setUrl("jdbc:sqlite:"+f.dir.resolve("target.sqlite"));var reopened=new JdbcSqlExecutor(ds);
+ var audit=reopened.queryForList("SELECT * FROM teaql_event_rejection",new Object[0]);assertEquals(1,audit.size());assertFalse(audit.toString().contains(CANARY));assertFalse(audit.toString().contains(new String(KEY,StandardCharsets.UTF_8)));assertEquals("INVALID_SIGNATURE",audit.get(0).get("reason_code"));
+ f.sql.execute("CREATE TRIGGER reject_audit BEFORE INSERT ON teaql_event_rejection BEGIN SELECT RAISE(ABORT,'"+CANARY+"'); END");
+ f.rejected("AUDIT_UNAVAILABLE",new Delivery(e,"current",TARGET,new byte[32]));f.unchanged(0,0,1);
+ }
+ }
+ @Test public void immutableKeyAndTagCopiesAndRotation()throws Exception{
+ try(var f=new Fixture()){
+ byte[] original=KEY.clone();var receiver=new SqliteCommittedReceiver(f.sql,SOURCE,TARGET,MODEL,Map.of("current",original,"previous",KEY));Arrays.fill(original,(byte)0);
+ var d=sign(event(1,"a",SOURCE,false));Arrays.fill(d.signature(),(byte)0);assertFalse(receiver.receive(f.context,d).duplicate());
+ var old=SqliteCommittedReceiver.sign("previous",TARGET,KEY,event(2,"b",SOURCE,true));assertFalse(receiver.receive(f.context,old).duplicate());
+ f.rejected("UNKNOWN_KEY",old);f.unchanged(1,2,1);assertFalse(d.toString().contains(CANARY));assertFalse(receiver.toString().contains(new String(KEY,StandardCharsets.UTF_8)));
+ }
+ }
+ @Test public void limitsAndAmbientTransactionFailClosed()throws Exception{
+ try(var f=new Fixture()){
+ var e=event(1,"a",SOURCE,false);var d=sign(e);
+ f.rejected("INVALID_ENVELOPE",replace(d,new OutboxRecord(SOURCE,0,"a",MODEL,e.payloadSha256(),e.payloadJson())));
+ f.rejected("INVALID_ENVELOPE",replace(d,new OutboxRecord(SOURCE,1,"a",MODEL,e.payloadSha256(),"x".repeat(1024*1024+1))));
+ f.rejected("INVALID_ENVELOPE",new Delivery(e,"x".repeat(257),TARGET,d.signature()));f.unchanged(0,0,3);
+ f.sql.executeInTransaction(()->{f.rejected("AMBIENT_TRANSACTION",d);assertEquals(0,f.count("school_data"));});f.unchanged(0,0,3);
+ try{new SqliteCommittedReceiver(f.sql,SOURCE,TARGET,MODEL,Map.of("weak",new byte[8]));fail();}catch(IllegalArgumentException expected){assertFalse(expected.toString().contains(CANARY));}
+ }
+ }
+ @Test public void goldenTranscriptHasCrossLanguageStableTag()throws Exception{
+ var e=new OutboxRecord("source",42,"change",MODEL,"a".repeat(64),"{\"x\":\"雪\"}");
+ var signed=SqliteCommittedReceiver.sign("key","target",KEY,e);
+ // Independent Python stdlib HMAC fixture; neither runtime supplies this oracle.
+ assertEquals("f1405877cd5999425693e559a147aa27294240456f88e307208b7162ff0bd8ac",HexFormat.of().formatHex(signed.signature()));
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedSnapshotTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedSnapshotTest.java
new file mode 100644
index 00000000..ed71accf
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteCommittedSnapshotTest.java
@@ -0,0 +1,342 @@
+package io.teaql.sqlite;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.teaql.core.BaseEntity;
+import io.teaql.core.meta.*;
+import io.teaql.core.sqlite.*;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.*;
+import org.junit.Test;
+import org.sqlite.SQLiteDataSource;
+import java.nio.file.*;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+import static org.junit.Assert.*;
+
+public class SqliteCommittedSnapshotTest {
+ private static final String SOURCE = "snapshot-source", MODEL = "sha256:" + "0".repeat(64);
+ private static final long ORDER = 9_007_199_254_740_993L;
+ private static final ObjectMapper JSON = new ObjectMapper();
+ private static final SqliteCommittedSnapshot.Limits LIMITS = SqliteCommittedSnapshot.Limits.defaults();
+ private static final class Fixture implements AutoCloseable {
+ final Path directory, path;
+ final SQLiteDataSource dataSource = new SQLiteDataSource();
+ final JdbcSqlExecutor sql;
+ final SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory();
+ final DefaultUserContext context;
+ Fixture(boolean legacy) throws Exception {
+ directory = Files.createTempDirectory("teaql-snapshot-"); path = directory.resolve("data.sqlite");
+ dataSource.setUrl("jdbc:sqlite:" + path);
+ sql = executor(dataSource); sql.execute("PRAGMA journal_mode=WAL");
+ EntityDescriptor root = entity("platform", "name", String.class);
+ // Exercise JDBC label folding without changing the model identity.
+ root.findIdProperty().with("columnName", "Id"); root.findVersionProperty().with("columnName", "Version");
+ metadata.register(root);
+ EntityDescriptor status = entity("order_status", "code", String.class);
+ status.addObjectProperty(metadata, "platform", "platform", "status_list", BaseEntity.class);
+ metadata.register(status);
+ EntityDescriptor order = entity("customer_order", "name", String.class);
+ order.addSimpleProperty("amount", java.math.BigDecimal.class).with("sqlType", "NUMERIC(19,7)");
+ order.addObjectProperty(metadata, "platform", "platform", "order_list", BaseEntity.class);
+ order.addObjectProperty(metadata, "status", "order_status", "order_list", BaseEntity.class);
+ metadata.register(order);
+ root.addObjectProperty(metadata, "favorite_order", "customer_order", "favorite_platform", BaseEntity.class);
+ EntityDescriptor item = entity("order_item", "quantity", Long.class);
+ item.addObjectProperty(metadata, "customer_order", "customer_order", "item_list", BaseEntity.class);
+ item.addSimpleProperty("occurred_at", java.time.Instant.class);
+ item.addSimpleProperty("planned_date", java.time.LocalDate.class);
+ item.addSimpleProperty("active", Boolean.class);
+ item.addSimpleProperty("ratio", Double.class);
+ item.addSimpleProperty("note", String.class);
+ metadata.register(item); metadata.register(entity("empty_probe", "name", String.class));
+ context = new DefaultUserContext(TeaQLRuntime.builder().metadata(metadata).build());
+ sql.execute("CREATE TABLE platform_data(Id INTEGER PRIMARY KEY,Version INTEGER NOT NULL,name TEXT,favorite_order INTEGER REFERENCES customer_order_data(id))");
+ sql.execute("CREATE TABLE order_status_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,code TEXT,platform INTEGER REFERENCES platform_data(Id))");
+ sql.execute("CREATE TABLE customer_order_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,name TEXT,amount TEXT,platform INTEGER REFERENCES platform_data(Id),status INTEGER REFERENCES order_status_data(id))");
+ sql.execute("CREATE TABLE order_item_data(id INTEGER PRIMARY KEY,version INTEGER NOT NULL,quantity INTEGER,customer_order INTEGER REFERENCES customer_order_data(id),occurred_at INTEGER,planned_date TEXT,active INTEGER,ratio REAL,note TEXT)");
+ sql.execute("CREATE TABLE empty_probe_data(id INTEGER PRIMARY KEY,version INTEGER,name TEXT)");
+ new SqliteCommittedEventStore(sql).ensureSchema();
+ if (legacy) sql.executeInTransaction(() -> {
+ sql.execute("PRAGMA defer_foreign_keys=ON");
+ sql.update("INSERT INTO platform_data VALUES(1,3,'SOURCE ROOT',?)", new Object[] {ORDER});
+ sql.execute("INSERT INTO order_status_data VALUES(1001,2,'OPEN',1)");
+ sql.update("INSERT INTO customer_order_data VALUES(?,7,'LEGACY','123.4500000',1,1001)", new Object[] {ORDER});
+ sql.update("INSERT INTO order_item_data VALUES(?,-4,2,?,-315619199877,'2026-10-16',1,0.125,NULL)", new Object[] {ORDER + 1, ORDER});
+ });
+ }
+ SqliteCommittedSnapshot snapshots() { return new SqliteCommittedSnapshot(sql, SOURCE, MODEL); }
+ SqliteModelCheckedCommittedTarget target() { return new SqliteModelCheckedCommittedTarget(sql, SOURCE, MODEL); }
+ SqliteCommittedSnapshot.SnapshotRecord snapshot() { return snapshots().exportSnapshot(context, "snapshot-1", "copy canonical facts", "initialize analytics", LIMITS); }
+ @Override public void close() throws Exception {
+ // Only the exact owned fixture files; no shared DB/cache cleanup.
+ for (String file : List.of("data.sqlite-wal", "data.sqlite-shm", "data.sqlite-journal", "data.sqlite")) Files.deleteIfExists(directory.resolve(file));
+ Files.deleteIfExists(directory);
+ }
+ }
+ private static EntityDescriptor entity(String name, String field, Class> type) {
+ var entity = new EntityDescriptor(); entity.setType(name);
+ entity.addSimpleProperty("id", Long.class); entity.addSimpleProperty("version", Long.class);
+ entity.addSimpleProperty(field, type); return entity;
+ }
+ private static JdbcSqlExecutor executor(SQLiteDataSource dataSource) {
+ return new JdbcSqlExecutor(dataSource).addConnectionInitializer(connection -> {
+ try (var statement = connection.createStatement()) { statement.execute("PRAGMA foreign_keys=ON"); statement.execute("PRAGMA busy_timeout=5000"); }
+ catch (java.sql.SQLException failure) { throw new IllegalStateException("fixture connection setup failed", failure); }
+ });
+ }
+ private static void expect(Runnable action) { try { action.run(); fail("expected snapshot rejection"); } catch (SqliteCommittedEventStore.CommittedEventException expected) { assertNotNull(expected.code()); } }
+ private static long scalar(Fixture f, String sql) { return ((Number) f.sql.queryForList(sql, new Object[0]).get(0).values().iterator().next()).longValue(); }
+ private static void sameState(Fixture a, Fixture b) {
+ for (String table : List.of("platform_data", "order_status_data", "customer_order_data", "order_item_data", "empty_probe_data"))
+ assertEquals(table, a.sql.queryForList("SELECT * FROM " + table + " ORDER BY 1", new Object[0]), b.sql.queryForList("SELECT * FROM " + table + " ORDER BY 1", new Object[0]));
+ }
+ private static String tail(long sequence) {
+ return """
+ {"protocol_version":1,"change_set_id":"tail-1","source_runtime_id":"snapshot-source",
+ "source_sequence":"%d","source_model_hash":"%s","source_transaction_id":"tx-tail",
+ "committed_at":"2026-10-22T00:00:00Z","audit":{"comment":"advance source","purpose":"verify snapshot tail"},
+ "mutations":[{"entity_type":"customer_order","entity_id":"9007199254740993","operation":"update","base_version":"7","committed_version":"8","fields":{"name":"AFTER N","amount":"125.0000000"},"trace_chain":[]},
+ {"entity_type":"order_item","entity_id":"9007199254740994","operation":"recover","base_version":"-4","committed_version":"5","fields":{"quantity":"3"},"trace_chain":[]}]}
+ """.formatted(sequence, MODEL);
+ }
+ private static void commitTail(Fixture f) {
+ JdbcSqlExecutor writer = executor(f.dataSource); // A second real connection while export is reading.
+ writer.executeInTransaction(() -> {
+ writer.update("UPDATE customer_order_data SET version=8,name='AFTER N',amount='125.0000000' WHERE id=?", new Object[] {ORDER});
+ writer.update("UPDATE order_item_data SET version=5,quantity=3 WHERE id=?", new Object[] {ORDER + 1});
+ new SqliteCommittedEventStore(writer).append(SOURCE, "tail-1", MODEL, SqliteCommittedSnapshotTest::tail);
+ });
+ }
+ private static SqliteCommittedSnapshot.SnapshotRecord changed(SqliteCommittedSnapshot.SnapshotRecord original, java.util.function.Consumer change) throws Exception {
+ ObjectNode body = (ObjectNode) JSON.readTree(original.payloadJson()); change.accept(body); String text = body.toString();
+ return new SqliteCommittedSnapshot.SnapshotRecord(original.sourceRuntimeId(), original.sourceSequence(), original.snapshotId(), original.modelHash(),
+ HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))), text);
+ }
+
+ @Test public void legacySnapshotPlusConcurrentTailMatchesSourceAndDoesNotRewind() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(false)) {
+ AtomicBoolean once = new AtomicBoolean();
+ EntityMetaFactory intercepted = new EntityMetaFactory() {
+ public EntityDescriptor resolveEntityDescriptor(String type) { return source.metadata.resolveEntityDescriptor(type); }
+ public void register(EntityDescriptor type) { throw new AssertionError("no model changes"); }
+ public List allEntityDescriptors() { if (once.compareAndSet(false, true)) commitTail(source); return source.metadata.allEntityDescriptors(); }
+ };
+ DefaultUserContext context = new DefaultUserContext(TeaQLRuntime.builder().metadata(intercepted).build());
+ var snapshot = source.snapshots().exportSnapshot(context, "snapshot-1", "copy legacy state", "verify consistent bootstrap", LIMITS);
+ assertTrue(once.get()); assertEquals(0, snapshot.sourceSequence());
+ JsonNode body = JSON.readTree(snapshot.payloadJson()); assertEquals(5, body.path("entity_types").size()); assertEquals(4, body.path("rows").size());
+ assertTrue(snapshot.payloadJson().contains("LEGACY")); assertFalse(snapshot.payloadJson().contains("AFTER N"));
+ assertTrue(snapshot.payloadJson().contains("\"version\":\"-4\""));
+ assertEquals(0, target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS).checkpoint());
+ var events = new SqliteCommittedEventStore(source.sql).outboxAfter(SOURCE, snapshot.sourceSequence(), 10);
+ assertEquals(1, events.size()); assertEquals(1, events.get(0).sourceSequence());
+ assertEquals(1, target.target().apply(target.context, events.get(0)).checkpoint()); sameState(source, target);
+ assertTrue(target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS).duplicate());
+ assertEquals(0, target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS).checkpoint());
+ assertEquals(1, scalar(target, "SELECT source_sequence FROM teaql_event_checkpoint")); sameState(source, target);
+ assertTrue(target.target().apply(target.context, events.get(0)).duplicate());
+ expect(() -> target.snapshots().initializeSnapshot(target.context, source.snapshot(), SqliteCommittedSnapshot.ImportMode.REPLACE_UNINITIALIZED, LIMITS));
+ System.out.println("CMR11_SNAPSHOT_PASS language=java snapshot=0 tail=1 rows=4 catalog=5 concurrent_writer=1 duplicate_after_tail=1");
+ }
+ }
+ @Test public void explicitReplacementHandlesBootstrapRowsButDefaultCannotOverwrite() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(true)) {
+ target.sql.execute("UPDATE platform_data SET name='BOOTSTRAP'"); var snapshot = source.snapshot();
+ expect(() -> target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS));
+ assertEquals(0, scalar(target, "SELECT count(*) FROM teaql_event_snapshot"));
+ target.snapshots().initializeSnapshot(target.context, snapshot, SqliteCommittedSnapshot.ImportMode.REPLACE_UNINITIALIZED, LIMITS); sameState(source, target);
+ }
+ }
+ @Test public void nonzeroSnapshotStartsAtNPlusOneWithoutReplayingOlderHistory() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(false)) {
+ commitTail(source); var snapshot = source.snapshot(); assertEquals(1, snapshot.sourceSequence());
+ target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS); sameState(source, target);
+ var old = new SqliteCommittedEventStore(source.sql).outboxAfter(SOURCE, 0, 10).get(0);
+ expect(() -> target.target().apply(target.context, old)); // No fabricated pre-snapshot Inbox.
+ source.sql.executeInTransaction(() -> {
+ assertEquals(1, source.sql.update("UPDATE customer_order_data SET version=9,name='AT TWO' WHERE id=? AND version=8", new Object[] {ORDER}));
+ new SqliteCommittedEventStore(source.sql).append(SOURCE, "tail-2", MODEL, sequence -> {
+ try {
+ ObjectNode body = (ObjectNode) JSON.readTree(tail(sequence)); body.put("change_set_id", "tail-2");
+ var mutations = body.withArray("mutations"); ObjectNode mutation = (ObjectNode) mutations.get(0);
+ mutation.put("base_version", "8"); mutation.put("committed_version", "9");
+ mutation.with("fields").removeAll().put("name", "AT TWO"); mutations.remove(1);
+ return body.toString();
+ } catch (Exception failure) { throw new AssertionError(failure); }
+ });
+ });
+ var next = new SqliteCommittedEventStore(source.sql).outboxAfter(SOURCE, 1, 10);
+ assertEquals(1, next.size()); assertEquals(2, next.get(0).sourceSequence());
+ target.target().apply(target.context, next.get(0)); sameState(source, target);
+ assertEquals(1, target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS).checkpoint());
+ assertEquals(2, scalar(target, "SELECT source_sequence FROM teaql_event_checkpoint"));
+ System.out.println("CMR11_NONZERO_PASS language=java snapshot=1 tail=2 legacy_history_absent=1");
+ }
+ }
+ @Test public void lateStorageFailureRollsBackReplacementRowsSnapshotAndCheckpoint() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(true)) {
+ target.sql.execute("UPDATE platform_data SET name='BOOTSTRAP'");
+ target.sql.execute("CREATE TRIGGER reject_snapshot BEFORE INSERT ON order_item_data BEGIN SELECT RAISE(ABORT,'snapshot fault'); END");
+ var snapshot = source.snapshot();
+ try { target.snapshots().initializeSnapshot(target.context, snapshot, SqliteCommittedSnapshot.ImportMode.REPLACE_UNINITIALIZED, LIMITS); fail("expected storage failure"); } catch (RuntimeException expected) { }
+ assertEquals("BOOTSTRAP", target.sql.queryForList("SELECT name FROM platform_data", new Object[0]).get(0).get("name"));
+ assertEquals(0, scalar(target, "SELECT count(*) FROM teaql_event_snapshot")); assertEquals(0, scalar(target, "SELECT count(*) FROM teaql_event_checkpoint"));
+ target.sql.execute("DROP TRIGGER reject_snapshot"); target.snapshots().initializeSnapshot(target.context, snapshot, SqliteCommittedSnapshot.ImportMode.REPLACE_UNINITIALIZED, LIMITS); sameState(source, target);
+ }
+ }
+ @Test public void strictCatalogRowsVersionsProtocolAndBudgetsRejectBeforeAnyTargetChange() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(false)) {
+ var snapshot = source.snapshot();
+ for (var invalid : List.of(
+ changed(snapshot, b -> b.withArray("entity_types").remove(0)),
+ changed(snapshot, b -> ((ObjectNode) b.withArray("rows").get(0)).with("fields").put("unknown", "bad")),
+ changed(snapshot, b -> ((ObjectNode) b.withArray("rows").get(0)).put("version", "0")),
+ changed(snapshot, b -> b.put("snapshot_version", 4294967297L)),
+ changed(snapshot, b -> b.withArray("rows").add(b.withArray("rows").get(0).deepCopy()))))
+ expect(() -> target.snapshots().initializeSnapshot(target.context, invalid, LIMITS));
+ expect(() -> target.snapshots().initializeSnapshot(target.context, snapshot, new SqliteCommittedSnapshot.Limits(3, LIMITS.maxPayloadBytes())));
+ expect(() -> target.snapshots().initializeSnapshot(target.context, snapshot, new SqliteCommittedSnapshot.Limits(10, 1)));
+ expect(() -> source.snapshots().exportSnapshot(source.context, "small", "copy", "budget", new SqliteCommittedSnapshot.Limits(3, 100_000)));
+ expect(() -> source.snapshots().exportSnapshot(source.context, "small", "copy", "budget", new SqliteCommittedSnapshot.Limits(10, 1)));
+ assertEquals(0, scalar(target, "SELECT count(*) FROM customer_order_data")); assertEquals(0, scalar(target, "SELECT count(*) FROM teaql_event_snapshot"));
+ assertFalse(snapshot.toString().contains("SOURCE ROOT"));
+ }
+ }
+ @Test public void callerTransactionsAndMissingIntentAreRejected() throws Exception {
+ try (Fixture f = new Fixture(true)) {
+ expect(() -> f.snapshots().exportSnapshot(f.context, "x", " ", "purpose", LIMITS));
+ f.sql.executeInTransaction(() -> expect(() -> f.snapshot()));
+ }
+ }
+ @Test public void ordinaryEventCannotUseSnapshotVersionEscapeHatch() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(false)) {
+ String body = tail(1).replace("\"operation\":\"update\",\"base_version\":\"7\",\"committed_version\":\"8\"", "\"operation\":\"create\",\"base_version\":null,\"committed_version\":\"8\"");
+ var record = new SqliteCommittedEventStore.OutboxRecord(SOURCE, 1, "tail-1", MODEL, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8))), body);
+ expect(() -> target.target().apply(target.context, record)); assertEquals(0, scalar(target, "SELECT count(*) FROM customer_order_data"));
+ }
+ }
+
+ private static SqliteCommittedSnapshotPublisher publisher(Fixture source, String target) { return new SqliteCommittedSnapshotPublisher(source.sql,SOURCE,target,MODEL); }
+ private static void secondTail(Fixture source) {
+ source.sql.executeInTransaction(() -> {
+ assertEquals(1,source.sql.update("UPDATE customer_order_data SET version=9,name='AT TWO' WHERE id=? AND version=8",new Object[]{ORDER}));
+ new SqliteCommittedEventStore(source.sql).append(SOURCE,"tail-2",MODEL,sequence->{
+ try { ObjectNode body=(ObjectNode)JSON.readTree(tail(sequence));body.put("change_set_id","tail-2");var mutations=body.withArray("mutations");var row=(ObjectNode)mutations.get(0);row.put("base_version","8").put("committed_version","9");row.with("fields").removeAll().put("name","AT TWO");mutations.remove(1);return body.toString(); }
+ catch(Exception failure){throw new AssertionError(failure);}
+ });
+ });
+ }
+ @Test public void durableBootstrapKeepsOriginalBytesAndResumesOrdinaryPublisherAfterLostAck() throws Exception {
+ try(Fixture source=new Fixture(true);Fixture target=new Fixture(false)) {
+ commitTail(source);var pub=publisher(source,"analytics");
+ var prepared=pub.prepare(source.context,"bootstrap","copy facts","initialize target",LIMITS);
+ assertEquals(1,prepared.snapshot().sourceSequence());assertFalse(prepared.acknowledged());secondTail(source);
+ assertEquals(prepared,pub.prepare(source.context,"bootstrap","retry copy","retry bootstrap",LIMITS));
+ var normal=new SqliteCommittedOutboxPublisher(source.sql,SOURCE,"analytics",MODEL);
+ expect(()->normal.publishBatch(10,row->{throw new AssertionError("pending bootstrap must block tail");}));
+ assertThrows(IllegalStateException.class,()->pub.publish(source.context,"bootstrap",LIMITS,row->{
+ assertFalse(source.sql.hasActiveTransaction());
+ var receipt=SqliteCommittedSnapshotPublisher.initializeAndAcknowledge(target.context,target.snapshots(),row,"analytics",LIMITS);
+ assertFalse(receipt.duplicate());throw new IllegalStateException("fixture loses committed target ACK");
+ }));
+ assertEquals(0,scalar(source,"SELECT count(*) FROM teaql_event_delivery_cursor"));
+ var restarted=publisher(source,"analytics");
+ assertTrue(restarted.publish(source.context,"bootstrap",LIMITS,row->{
+ var receipt=SqliteCommittedSnapshotPublisher.initializeAndAcknowledge(target.context,target.snapshots(),row,"analytics",LIMITS);assertTrue(receipt.duplicate());return receipt;
+ }).acknowledged());
+ assertEquals(1,normal.status().ackedSequence());
+ assertEquals(1,normal.publishBatch(10,row->{assertEquals(2,row.sourceSequence());return target.target().apply(target.context,row);}).acknowledged());
+ sameState(source,target);assertEquals(2,normal.status().ackedSequence());
+ assertTrue(restarted.publish(source.context,"bootstrap",LIMITS,row->{throw new AssertionError("known ACK must not redeliver");}).acknowledged());
+ assertEquals(2,normal.status().ackedSequence());expect(()->pub.prepare(source.context,"different","copy","bootstrap",LIMITS));
+ System.out.println("CMR12_BOOTSTRAP_PASS language=java baseline=1 tail=2 retained_bytes=1 lost_ack=1 pending_gate=1 no_rewind=1");
+ }
+ }
+ @Test public void acknowledgementAndCursorAreAtomicAndEveryReceiptIdentityIsBound() throws Exception {
+ try(Fixture source=new Fixture(true);Fixture target=new Fixture(false)) {
+ commitTail(source);var pub=publisher(source,"analytics");var snapshot=pub.prepare(source.context,"bootstrap","copy","bootstrap",LIMITS).snapshot();
+ var ack=SqliteCommittedSnapshotPublisher.initializeAndAcknowledge(target.context,target.snapshots(),snapshot,"analytics",LIMITS);
+ for(var bad:List.of(
+ new SqliteCommittedSnapshotPublisher.Receipt("other",ack.targetId(),ack.snapshotId(),1,MODEL,ack.payloadSha256(),false),
+ new SqliteCommittedSnapshotPublisher.Receipt(SOURCE,"other",ack.snapshotId(),1,MODEL,ack.payloadSha256(),false),
+ new SqliteCommittedSnapshotPublisher.Receipt(SOURCE,ack.targetId(),"other",1,MODEL,ack.payloadSha256(),false),
+ new SqliteCommittedSnapshotPublisher.Receipt(SOURCE,ack.targetId(),ack.snapshotId(),2,MODEL,ack.payloadSha256(),false),
+ new SqliteCommittedSnapshotPublisher.Receipt(SOURCE,ack.targetId(),ack.snapshotId(),1,"sha256:"+"1".repeat(64),ack.payloadSha256(),false),
+ new SqliteCommittedSnapshotPublisher.Receipt(SOURCE,ack.targetId(),ack.snapshotId(),1,MODEL,"0".repeat(64),false)))
+ expect(()->pub.publish(source.context,"bootstrap",LIMITS,row->bad));
+ source.sql.execute("CREATE TRIGGER reject_bootstrap_ack BEFORE UPDATE ON teaql_event_snapshot_publication WHEN NEW.acknowledged=1 BEGIN SELECT RAISE(ABORT,'fixture rejects ACK after cursor insert'); END");
+ assertThrows(RuntimeException.class,()->pub.publish(source.context,"bootstrap",LIMITS,row->ack));
+ assertEquals(0,scalar(source,"SELECT count(*) FROM teaql_event_delivery_cursor"));
+ assertEquals(0,scalar(source,"SELECT acknowledged FROM teaql_event_snapshot_publication"));
+ source.sql.execute("DROP TRIGGER reject_bootstrap_ack");assertTrue(pub.publish(source.context,"bootstrap",LIMITS,row->ack).acknowledged());
+ source.sql.execute("DELETE FROM teaql_event_delivery_cursor");
+ expect(()->new SqliteCommittedOutboxPublisher(source.sql,SOURCE,"analytics",MODEL).publishBatch(10,row->{throw new AssertionError("corrupt ACK/cursor must fail before delivery");}));
+ System.out.println("CMR12_ACK_ATOMIC_PASS language=java bound_fields=6 rollback=1 retry=1 corrupt_cursor=1");
+ }
+ }
+ @Test public void bootstrapModeIsExclusiveAndDoesNotJoinCallerTransactionsOrResetPublicationState() throws Exception {
+ try(Fixture source=new Fixture(true);Fixture target=new Fixture(false)) {
+ var normal=new SqliteCommittedOutboxPublisher(source.sql,SOURCE,"incremental",MODEL);
+ expect(()->normal.publishBatch(0,row->{throw new AssertionError();}));assertEquals(0,scalar(source,"SELECT count(*) FROM teaql_event_delivery_cursor"));
+ assertEquals(0,normal.publishBatch(1,row->{throw new AssertionError();}).acknowledged());
+ expect(()->publisher(source,"incremental").prepare(source.context,"bootstrap","copy","bootstrap",LIMITS));
+ assertEquals(0,scalar(source,"SELECT count(*) FROM teaql_event_snapshot_publication"));
+ source.sql.executeInTransaction(()->expect(()->publisher(source,"new-target").prepare(source.context,"bootstrap","copy","bootstrap",LIMITS)));
+ var targetPub=publisher(target,"another-target");targetPub.prepare(target.context,"bootstrap","copy","bootstrap",LIMITS);
+ expect(()->target.snapshots().initializeSnapshot(target.context,source.snapshot(),SqliteCommittedSnapshot.ImportMode.REPLACE_UNINITIALIZED,LIMITS));
+ assertEquals(1,scalar(target,"SELECT count(*) FROM teaql_event_snapshot_publication"));
+ }
+ }
+ @Test public void nontransactionalAdaptersCannotPretendToProvideAtomicBootstrap() throws Exception {
+ try(Fixture source=new Fixture(true);Fixture target=new Fixture(false)) {
+ var queries=new java.util.concurrent.atomic.AtomicInteger();
+ var unsafe=(io.teaql.dataservice.sql.SqlExecutionAdapter)java.lang.reflect.Proxy.newProxyInstance(
+ getClass().getClassLoader(),new Class>[]{io.teaql.dataservice.sql.SqlExecutionAdapter.class},(proxy,method,args)->{
+ if(method.getName().equals("executeInTransaction")){((Runnable)args[0]).run();return null;}
+ if(method.getName().startsWith("query")||method.getName().equals("update")||method.getName().equals("execute"))queries.incrementAndGet();
+ try{return method.invoke(target.sql,args);}catch(java.lang.reflect.InvocationTargetException failure){throw failure.getCause();}
+ });
+ var snapshot=source.snapshot();
+ expect(()->new SqliteCommittedSnapshot(unsafe,SOURCE,MODEL).exportSnapshot(target.context,"test","copy","bootstrap",LIMITS));
+ expect(()->new SqliteCommittedSnapshot(unsafe,SOURCE,MODEL).initializeSnapshot(target.context,snapshot,LIMITS));
+ expect(()->new SqliteCommittedSnapshotPublisher(unsafe,SOURCE,"target",MODEL).prepare(target.context,"test","copy","bootstrap",LIMITS));
+ expect(()->new SqliteCommittedOutboxPublisher(unsafe,SOURCE,"target",MODEL).publishBatch(1,row->{throw new AssertionError();}));
+ assertEquals(0,queries.get());
+ }
+ }
+ @Test public void snapshotHashAuthorityParserReferencesAndConflictingRetryFailClosed() throws Exception {
+ try (Fixture source = new Fixture(true); Fixture target = new Fixture(false)) {
+ var snapshot = source.snapshot();
+ for (var invalid : List.of(
+ new SqliteCommittedSnapshot.SnapshotRecord("another-source", 0, snapshot.snapshotId(), MODEL, snapshot.payloadSha256(), snapshot.payloadJson()),
+ new SqliteCommittedSnapshot.SnapshotRecord(SOURCE, 0, snapshot.snapshotId(), "sha256:" + "1".repeat(64), snapshot.payloadSha256(), snapshot.payloadJson()),
+ new SqliteCommittedSnapshot.SnapshotRecord(SOURCE, 0, snapshot.snapshotId(), MODEL, "0".repeat(64), snapshot.payloadJson()),
+ changed(snapshot, body -> body.put("source_sequence", "00")),
+ changed(snapshot, body -> {
+ for (JsonNode row : body.withArray("rows")) if (row.path("entity_type").asText().equals("customer_order"))
+ ((ObjectNode) row.path("fields").path("platform")).put("entity_id", "999");
+ }))) expect(() -> target.snapshots().initializeSnapshot(target.context, invalid, LIMITS));
+ for (String text : List.of(snapshot.payloadJson() + " {}",
+ snapshot.payloadJson().replace("\"snapshot_version\":1", "\"snapshot_version\":1,\"snapshot_version\":1"))) {
+ var invalid = new SqliteCommittedSnapshot.SnapshotRecord(SOURCE, 0, snapshot.snapshotId(), MODEL,
+ HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))), text);
+ expect(() -> target.snapshots().initializeSnapshot(target.context, invalid, LIMITS));
+ }
+ assertEquals(0, scalar(target, "SELECT count(*) FROM platform_data"));
+ assertEquals(0, scalar(target, "SELECT count(*) FROM customer_order_data"));
+ assertEquals(0, scalar(target, "SELECT count(*) FROM teaql_event_snapshot"));
+ assertEquals(0, scalar(target, "SELECT count(*) FROM teaql_event_checkpoint"));
+ target.sql.executeInTransaction(() -> expect(() -> target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS)));
+ target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS); sameState(source, target);
+ var conflict = changed(snapshot, body -> ((ObjectNode) body.path("audit")).put("comment", "different bytes for same snapshot"));
+ expect(() -> target.snapshots().initializeSnapshot(target.context, conflict, LIMITS));
+ assertTrue(target.snapshots().initializeSnapshot(target.context, snapshot, LIMITS).duplicate());
+ sameState(source, target);
+ System.out.println("CMR11_GUARDS_PASS language=java source=1 model=1 digest=1 parser=2 dangling_fk=1 conflict=1 atomic=1 retry=1");
+ }
+ }
+}
diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteModelCheckedCommittedTargetTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteModelCheckedCommittedTargetTest.java
new file mode 100644
index 00000000..31bd12e4
--- /dev/null
+++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteModelCheckedCommittedTargetTest.java
@@ -0,0 +1,674 @@
+package io.teaql.sqlite;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.teaql.core.BaseEntity;
+import io.teaql.core.meta.EntityDescriptor;
+import io.teaql.core.meta.EntityMetaFactory;
+import io.teaql.core.meta.SimpleEntityMetaFactory;
+import io.teaql.core.sqlite.SqliteCommittedEventStore;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.CommittedEventException;
+import io.teaql.core.sqlite.SqliteCommittedEventStore.OutboxRecord;
+import io.teaql.core.sqlite.SqliteModelCheckedCommittedTarget;
+import io.teaql.core.sqlite.SqliteCommittedOutboxPublisher;
+import io.teaql.provider.jdbc.JdbcSqlExecutor;
+import io.teaql.runtime.DefaultUserContext;
+import io.teaql.runtime.TeaQLRuntime;
+import org.junit.Test;
+import org.junit.Assume;
+import org.sqlite.SQLiteDataSource;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+public class SqliteModelCheckedCommittedTargetTest {
+ private static final String SOURCE = "sqlite-order-source";
+ private static final String HASH = "sha256:" + "0".repeat(64);
+ private static final long ORDER_ID = 9_007_199_254_740_993L;
+ private static final long ITEM_ID = ORDER_ID + 1;
+
+ private record Target(JdbcSqlExecutor sql, SqliteModelCheckedCommittedTarget target,
+ DefaultUserContext context) { }
+
+ private static Target target(Path path) {
+ return target(path, "NUMERIC(19,7)");
+ }
+
+ private static Target target(Path path, String amountType) {
+ SQLiteDataSource source = new SQLiteDataSource();
+ source.setUrl("jdbc:sqlite:" + path);
+ JdbcSqlExecutor sql = new JdbcSqlExecutor(source);
+ EntityDescriptor order = new EntityDescriptor();
+ order.setType("order");
+ order.addSimpleProperty("id", Long.class);
+ order.addSimpleProperty("version", Long.class);
+ order.addSimpleProperty("order_number", String.class);
+ order.addSimpleProperty("status", String.class);
+ order.addSimpleProperty("planned_date", java.time.LocalDate.class);
+ order.addSimpleProperty("occurred_at", java.time.Instant.class).with("required", "true");
+ var amount = order.addSimpleProperty("amount", java.math.BigDecimal.class);
+ if (amountType != null) amount.with("sqlType", amountType);
+ order.addSimpleProperty("active", Boolean.class).with("required", "true");
+ order.addSimpleProperty("ratio", Double.class).with("required", "true");
+ order.addSimpleProperty("optional_note", String.class);
+ order.addSimpleProperty("optional_label", String.class);
+ SimpleEntityMetaFactory metadata = new SimpleEntityMetaFactory();
+ metadata.register(order);
+ EntityDescriptor item = new EntityDescriptor();
+ item.setType("order_item");
+ item.addSimpleProperty("id", Long.class);
+ item.addSimpleProperty("version", Long.class);
+ item.addObjectProperty(metadata, "order", "order", "items", BaseEntity.class)
+ .with("columnName", "order_id");
+ item.addSimpleProperty("quantity", Long.class);
+ metadata.register(item);
+ DefaultUserContext context = new DefaultUserContext(
+ TeaQLRuntime.builder().metadata(metadata).build());
+ sql.execute("CREATE TABLE order_data (id INTEGER PRIMARY KEY, version INTEGER NOT NULL, "
+ + "order_number TEXT NOT NULL, status TEXT NOT NULL, planned_date DATE, "
+ + "occurred_at INTEGER NOT NULL, amount NUMERIC, "
+ + "active INTEGER NOT NULL, ratio REAL NOT NULL, optional_note TEXT, optional_label TEXT)");
+ sql.execute("CREATE TABLE order_item_data (id INTEGER PRIMARY KEY, version INTEGER NOT NULL, "
+ + "order_id INTEGER NOT NULL, quantity INTEGER NOT NULL)");
+ SqliteModelCheckedCommittedTarget target = new SqliteModelCheckedCommittedTarget(sql, SOURCE, HASH);
+ target.ensureSchema();
+ return new Target(sql, target, context);
+ }
+
+ private static String payload(long sequence, String change, String mutations) {
+ return """
+ {"protocol_version":1,"change_set_id":"%s","source_runtime_id":"%s",
+ "source_sequence":"%d","source_model_hash":"%s","source_transaction_id":"tx-%d",
+ "committed_at":"2026-10-16T00:00:00Z",
+ "audit":{"actor_ref":"fixture:operator","comment":"Apply order graph",
+ "purpose":"Verify target application","trace_id":"fixture-trace"},
+ "mutations":%s}
+ """.formatted(change, SOURCE, sequence, HASH, sequence, mutations);
+ }
+
+ private static String created() {
+ return """
+ [{"entity_type":"order","entity_id":"9007199254740993","operation":"create",
+ "base_version":null,"committed_version":"1",
+ "fields":{"order_number":"ORD-0001","status":"DRAFT",
+ "planned_date":"2026-10-16","occurred_at":"-315619199877",
+ "amount":"0",
+ "active":true,"ratio":0.5,"optional_note":null,
+ "optional_label":"initial label"},"trace_chain":[]},
+ {"entity_type":"order_item","entity_id":"9007199254740994","operation":"create",
+ "base_version":null,"committed_version":"1",
+ "fields":{"order":{"entity_type":"order","entity_id":"9007199254740993"},
+ "quantity":"2"},"trace_chain":[]}]
+ """;
+ }
+
+ private static String submitted() {
+ return """
+ [{"entity_type":"order","entity_id":"9007199254740993","operation":"update",
+ "base_version":"1","committed_version":"2",
+ "fields":{"status":"SUBMITTED","optional_note":"submitted note",
+ "optional_label":null},"trace_chain":[]},
+ {"entity_type":"order_item","entity_id":"9007199254740994","operation":"delete",
+ "base_version":"1","committed_version":"-2","fields":{},"trace_chain":[]}]
+ """;
+ }
+
+ private static OutboxRecord record(long sequence, String change, String mutations) {
+ return recordBody(sequence, change, payload(sequence, change, mutations));
+ }
+
+ private static OutboxRecord recordBody(long sequence, String change, String body) {
+ try {
+ String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(body.getBytes(StandardCharsets.UTF_8)));
+ return new OutboxRecord(SOURCE, sequence, change, HASH, digest, body);
+ } catch (java.security.NoSuchAlgorithmException impossible) {
+ throw new AssertionError("JDK SHA-256 is unavailable", impossible);
+ }
+ }
+
+ private static void expect(String code, Runnable action) {
+ try {
+ action.run();
+ fail("expected " + code);
+ } catch (CommittedEventException failure) {
+ assertEquals(code, failure.code());
+ }
+ }
+
+ private static long count(JdbcSqlExecutor sql, String table) {
+ return ((Number) sql.queryForList("SELECT COUNT(*) AS n FROM " + table,
+ new Object[0]).get(0).get("n")).longValue();
+ }
+
+ private static final class ForbiddenBusinessApi extends RuntimeException {
+ ForbiddenBusinessApi(String method) { super("business API reached: " + method); }
+ }
+
+ /** CMR-09: deny every Context method except its actual metadata capability. */
+ @Test public void committedTargetUsesOnlyMetadataAndNeverReplaysBusinessApis() throws Exception {
+ Path path = Files.createTempFile("teaql-target-nonreplay-", ".sqlite");
+ try {
+ Target t = target(path);
+ EntityMetaFactory metadata = EntityMetaFactory.requireFrom(t.context());
+ var unexpected = new java.util.ArrayList();
+ var metadataReads = new java.util.concurrent.atomic.AtomicInteger();
+ io.teaql.core.UserContext guarded = (io.teaql.core.UserContext)
+ java.lang.reflect.Proxy.newProxyInstance(io.teaql.core.UserContext.class.getClassLoader(),
+ new Class>[] {io.teaql.core.UserContext.class}, (proxy, method, args) -> {
+ if (method.getName().equals("capability") && args.length == 1
+ && args[0] == EntityMetaFactory.class) {
+ metadataReads.incrementAndGet();
+ return metadata;
+ }
+ unexpected.add(method.getName());
+ throw new ForbiddenBusinessApi(method.getName());
+ });
+ // Positive controls: the guard must intercept real entrypoints,
+ // not count zero because a test double was never wired in.
+ Runnable[] controls = {
+ () -> guarded.capability(io.teaql.core.checker.Checker.class),
+ () -> guarded.beginFixEvidence(),
+ () -> guarded.saveGraph((io.teaql.core.Entity) null),
+ () -> guarded.businessTime(),
+ () -> guarded.capability(io.teaql.core.InternalIdGenerationService.class),
+ () -> guarded.httpRequests(),
+ () -> guarded.extension("llm"),
+ () -> guarded.extension("notifications")
+ };
+ for (Runnable control : controls) {
+ boolean intercepted = false;
+ try { control.run(); } catch (ForbiddenBusinessApi expected) { intercepted = true; }
+ assertTrue("positive control did not reach the Context guard", intercepted);
+ }
+ assertEquals(8, unexpected.size());
+ unexpected.clear();
+ String deleted = """
+ [{"entity_type":"order","entity_id":"9007199254740993","operation":"delete",
+ "base_version":"2","committed_version":"-3","fields":{},"trace_chain":[]}]
+ """;
+ String recovered = """
+ [{"entity_type":"order","entity_id":"9007199254740993","operation":"recover",
+ "base_version":"-3","committed_version":"4",
+ "fields":{"status":"RECOVERED"},"trace_chain":[]}]
+ """;
+ String[] facts = {created(), submitted(), deleted, recovered};
+ for (int index = 0; index < facts.length; index++) {
+ OutboxRecord event = record(index + 1, "nonreplay-" + index, facts[index]);
+ assertEquals(index + 1, t.target().apply(guarded, event).checkpoint());
+ assertTrue(t.target().apply(guarded, event).duplicate());
+ assertTrue("target invoked a forbidden business API: " + unexpected, unexpected.isEmpty());
+ }
+ Map row = t.sql().queryForList(
+ "SELECT id,version,order_number,status,occurred_at FROM order_data WHERE id=?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(ORDER_ID, ((Number) row.get("id")).longValue());
+ assertEquals(4, ((Number) row.get("version")).longValue());
+ assertEquals("ORD-0001", row.get("order_number"));
+ assertEquals("RECOVERED", row.get("status"));
+ assertEquals(-315_619_199_877L, ((Number) row.get("occurred_at")).longValue());
+ assertEquals(4, count(t.sql(), "teaql_event_inbox"));
+ assertEquals(4, count(t.sql(), "teaql_event_applied_payload"));
+ assertEquals(8, metadataReads.get());
+ System.out.println("CMR_NONREPLAY_PASS language=java controls=8 business_calls=0 metadata_reads=8 operations=4 duplicates=4");
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void appliesSharedConformanceFixtureWhenConfigured() throws Exception {
+ String fixturePath = System.getenv("TEAQL_COMMITTED_EVENT_FIXTURE");
+ Assume.assumeTrue("shared fixture path was not supplied",
+ fixturePath != null && !fixturePath.isBlank());
+ JsonNode fixture = new ObjectMapper().readTree(Files.readString(Path.of(fixturePath)));
+ assertEquals(1, fixture.path("fixture_version").asInt());
+ assertEquals(2, fixture.path("sets").size());
+ Path path = Files.createTempFile("teaql-shared-model-target-", ".sqlite");
+ try {
+ Target t = target(path);
+ for (int index = 0; index < 2; index++) {
+ JsonNode set = fixture.path("sets").get(index);
+ long sequence = Long.parseLong(set.path("source_sequence").asText());
+ OutboxRecord record = recordBody(sequence,
+ set.path("change_set_id").asText(), set.toString());
+ assertEquals(sequence, t.target().apply(t.context(), record).checkpoint());
+ }
+ assertEquals("SUBMITTED", t.sql().queryForList(
+ "SELECT status FROM order_data WHERE id = ?", new Object[] {ORDER_ID})
+ .get(0).get("status"));
+ assertEquals("2026-10-16", t.sql().queryForList(
+ "SELECT planned_date FROM order_data WHERE id = ?", new Object[] {ORDER_ID})
+ .get(0).get("planned_date"));
+ Map instant = t.sql().queryForList(
+ "SELECT typeof(occurred_at) AS storage_type, occurred_at FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals("integer", instant.get("storage_type"));
+ assertEquals(-315_619_199_877L, ((Number) instant.get("occurred_at")).longValue());
+ Map scalars = t.sql().queryForList(
+ "SELECT active, ratio, optional_note, optional_label FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(1L, ((Number) scalars.get("active")).longValue());
+ assertEquals(0.5, ((Number) scalars.get("ratio")).doubleValue(), 0.0);
+ assertEquals("submitted note", scalars.get("optional_note"));
+ assertNull(scalars.get("optional_label"));
+ assertEquals(-2L, ((Number) t.sql().queryForList(
+ "SELECT version FROM order_item_data WHERE id = ?", new Object[] {ITEM_ID})
+ .get(0).get("version")).longValue());
+ assertEquals(2, count(t.sql(), "teaql_event_applied_payload"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void appliesWholeOrderGraphAndReturnsStableDuplicateAcks() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-", ".sqlite");
+ try {
+ Target t = target(path);
+ OutboxRecord first = record(1, "order-graph-0001", created());
+ OutboxRecord second = record(2, "order-graph-0002", submitted());
+ OutboxRecord otherSource = new OutboxRecord("other-source", first.sourceSequence(),
+ first.changeSetId(), first.modelHash(), first.payloadSha256(), first.payloadJson());
+ expect("UNAUTHORIZED_SOURCE", () -> t.target().apply(t.context(), otherSource));
+ OutboxRecord otherModel = new OutboxRecord(first.sourceRuntimeId(), first.sourceSequence(),
+ first.changeSetId(), "sha256:" + "1".repeat(64), first.payloadSha256(), first.payloadJson());
+ expect("MODEL_MISMATCH", () -> t.target().apply(t.context(), otherModel));
+ String unsupported = payload(1, "unsupported", created())
+ .replace("\"protocol_version\":1", "\"protocol_version\":2");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ recordBody(1, "unsupported", unsupported)));
+ expect("SEQUENCE_GAP", () -> t.target().apply(t.context(), second));
+ assertEquals(0, count(t.sql(), "order_data"));
+ assertEquals(1, t.target().apply(t.context(), first).checkpoint());
+ assertTrue(t.target().apply(t.context(), first).duplicate());
+ assertEquals(2, t.target().apply(t.context(), second).checkpoint());
+ var oldAck = t.target().apply(t.context(), first);
+ assertTrue(oldAck.duplicate());
+ assertEquals(1, oldAck.checkpoint());
+ Map order = t.sql().queryForList(
+ "SELECT version, order_number, status FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(2L, ((Number) order.get("version")).longValue());
+ assertEquals("ORD-0001", order.get("order_number"));
+ assertEquals("SUBMITTED", order.get("status"));
+ Map item = t.sql().queryForList(
+ "SELECT version, order_id, quantity FROM order_item_data WHERE id = ?",
+ new Object[] {ITEM_ID}).get(0);
+ assertEquals(-2L, ((Number) item.get("version")).longValue());
+ assertEquals(ORDER_ID, ((Number) item.get("order_id")).longValue());
+ assertEquals(2L, ((Number) item.get("quantity")).longValue());
+ assertEquals(2, count(t.sql(), "teaql_event_inbox"));
+ assertEquals(2, count(t.sql(), "teaql_event_applied_payload"));
+ SQLiteDataSource reopenedSource = new SQLiteDataSource();
+ reopenedSource.setUrl("jdbc:sqlite:" + path);
+ SqliteModelCheckedCommittedTarget reopened = new SqliteModelCheckedCommittedTarget(
+ new JdbcSqlExecutor(reopenedSource), SOURCE, HASH);
+ var retryAfterRestart = reopened.apply(t.context(), first);
+ assertTrue(retryAfterRestart.duplicate());
+ assertEquals(1, retryAfterRestart.checkpoint());
+ String retained = (String) t.sql().queryForList(
+ "SELECT payload_json FROM teaql_event_applied_payload WHERE source_sequence = 2",
+ new Object[0]).get(0).get("payload_json");
+ assertTrue(retained.contains("fixture-trace"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ /** CMR-05/06: all three operations in one set, not separate successful sets. */
+ @Test public void mixedCreateUpdateDeleteRollsBackThenRetriesAndDeduplicates() throws Exception {
+ Path path = Files.createTempFile("teaql-mixed-target-", ".sqlite");
+ try {
+ Target t = target(path);
+ assertEquals(1, t.target().apply(t.context(), record(1, "mixed-baseline", created())).checkpoint());
+ var mutations = (com.fasterxml.jackson.databind.node.ArrayNode) new ObjectMapper().readTree(submitted());
+ mutations.add(new ObjectMapper().readTree("""
+ {"entity_type":"order_item","entity_id":"9007199254740995","operation":"create",
+ "base_version":null,"committed_version":"1",
+ "fields":{"order":{"entity_type":"order","entity_id":"9007199254740993"},
+ "quantity":"3"},"trace_chain":[]}
+ """));
+ OutboxRecord mixed = record(2, "mixed-three-operations", mutations.toString());
+ // The fault fires only if update and delete were already executed.
+ // A failure before those writes cannot masquerade as rollback proof.
+ t.sql().execute("CREATE TRIGGER reject_mixed_create BEFORE INSERT ON order_item_data "
+ + "WHEN NEW.id = 9007199254740995 "
+ + "AND (SELECT version FROM order_data WHERE id = 9007199254740993) = 2 "
+ + "AND (SELECT version FROM order_item_data WHERE id = 9007199254740994) = -2 "
+ + "BEGIN SELECT RAISE(ABORT, 'fixture rejects final create'); END");
+ try {
+ t.target().apply(t.context(), mixed);
+ fail("expected final-create failure after update and delete");
+ } catch (RuntimeException expected) {
+ var order = t.sql().queryForList("SELECT version,status FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(1L, ((Number) order.get("version")).longValue());
+ assertEquals("DRAFT", order.get("status"));
+ assertEquals(1L, ((Number) t.sql().queryForList(
+ "SELECT version FROM order_item_data WHERE id = ?", new Object[] {ITEM_ID})
+ .get(0).get("version")).longValue());
+ assertTrue(t.sql().queryForList("SELECT id FROM order_item_data WHERE id = ?",
+ new Object[] {ITEM_ID + 1}).isEmpty());
+ assertEquals(1, count(t.sql(), "teaql_event_inbox"));
+ assertEquals(1, count(t.sql(), "teaql_event_applied_payload"));
+ assertEquals(1L, ((Number) t.sql().queryForList(
+ "SELECT source_sequence FROM teaql_event_checkpoint WHERE source_runtime_id = ?",
+ new Object[] {SOURCE}).get(0).get("source_sequence")).longValue());
+ }
+ t.sql().execute("DROP TRIGGER reject_mixed_create"); // disarm fault; do not reset data
+ assertEquals(2, t.target().apply(t.context(), mixed).checkpoint());
+ var duplicate = t.target().apply(t.context(), mixed);
+ assertTrue(duplicate.duplicate());
+ assertEquals(2, duplicate.checkpoint());
+ var order = t.sql().queryForList("SELECT version,status FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(2L, ((Number) order.get("version")).longValue());
+ assertEquals("SUBMITTED", order.get("status"));
+ assertEquals(-2L, ((Number) t.sql().queryForList(
+ "SELECT version FROM order_item_data WHERE id = ?", new Object[] {ITEM_ID})
+ .get(0).get("version")).longValue());
+ var added = t.sql().queryForList("SELECT version,order_id,quantity FROM order_item_data WHERE id = ?",
+ new Object[] {ITEM_ID + 1}).get(0);
+ assertEquals(1L, ((Number) added.get("version")).longValue());
+ assertEquals(ORDER_ID, ((Number) added.get("order_id")).longValue());
+ assertEquals(3L, ((Number) added.get("quantity")).longValue());
+ assertEquals(1, count(t.sql(), "order_data"));
+ assertEquals(2, count(t.sql(), "order_item_data"));
+ assertEquals(2, count(t.sql(), "teaql_event_inbox"));
+ assertEquals(2, count(t.sql(), "teaql_event_applied_payload"));
+ assertEquals(2L, ((Number) t.sql().queryForList(
+ "SELECT source_sequence FROM teaql_event_checkpoint WHERE source_runtime_id = ?",
+ new Object[] {SOURCE}).get(0).get("source_sequence")).longValue());
+ String retained = (String) t.sql().queryForList(
+ "SELECT payload_json FROM teaql_event_applied_payload WHERE source_sequence = 2",
+ new Object[0]).get(0).get("payload_json");
+ assertEquals(3, new ObjectMapper().readTree(retained).path("mutations").size());
+ System.out.println("MIXED_TARGET_ATOMIC_PASS language=java mixed=3 rollback=1 retry=1 duplicate=1");
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void rejectsUnknownModelAndRollsBackWhenSecondObjectFails() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-negative-", ".sqlite");
+ try {
+ Target t = target(path);
+ OutboxRecord first = record(1, "order-graph-0001", created());
+ String unknownEntity = created().replace("\"entity_type\":\"order_item\"",
+ "\"entity_type\":\"not_in_model\"");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(), record(1, "unknown-entity", unknownEntity)));
+ String unknown = created().replace("\"quantity\":\"2\"",
+ "\"quantity\":\"2\",\"not_in_model\":1");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(), record(1, "unknown", unknown)));
+ String reverseRelation = created().replace("\"status\":\"DRAFT\"",
+ "\"status\":\"DRAFT\",\"items\":[]");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "reverse-relation", reverseRelation)));
+ String invalidTrace = created().replace("\"trace_chain\":[]",
+ "\"trace_chain\":[{\"kind\":\"AuditReason\",\"entity_type\":\"order\","
+ + "\"entity_id\":\"9007199254740993\",\"comment\":\"Create order\"}]");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "invalid-trace", invalidTrace)));
+ String missingTrace = created().replace(",\"trace_chain\":[]", "");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "missing-trace", missingTrace)));
+ t.sql().execute("CREATE TRIGGER reject_item BEFORE INSERT ON order_item_data "
+ + "BEGIN SELECT RAISE(ABORT, 'reject item'); END");
+ try {
+ t.target().apply(t.context(), first);
+ fail("expected second-object failure");
+ } catch (RuntimeException expected) {
+ assertEquals(0, count(t.sql(), "order_data"));
+ assertEquals(0, count(t.sql(), "order_item_data"));
+ assertEquals(0, count(t.sql(), "teaql_event_inbox"));
+ assertEquals(0, count(t.sql(), "teaql_event_applied_payload"));
+ assertEquals(0, count(t.sql(), "teaql_event_checkpoint"));
+ }
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void preservesLargeLongFieldWithoutJsonNumberRounding() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-large-long-", ".sqlite");
+ try {
+ Target t = target(path);
+ String numeric = created().replace("\"quantity\":\"2\"",
+ "\"quantity\":9007199254740993");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "unsafe-json-number", numeric)));
+ for (String invalid : new String[] {"02", "-0", "9223372036854775808"}) {
+ String malformed = created().replace("\"quantity\":\"2\"",
+ "\"quantity\":\"" + invalid + "\"");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "invalid-integer", malformed)));
+ }
+ assertEquals(0, count(t.sql(), "order_data"));
+ String textual = created().replace("\"quantity\":\"2\"",
+ "\"quantity\":\"9007199254740993\"");
+ t.target().apply(t.context(), record(1, "lossless-long", textual));
+ Object stored = t.sql().queryForList("SELECT quantity FROM order_item_data WHERE id = ?",
+ new Object[] {ITEM_ID}).get(0).get("quantity");
+ assertEquals(9_007_199_254_740_993L, ((Number) stored).longValue());
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void acceptsOnlyCanonicalCalendarDate() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-date-", ".sqlite");
+ try {
+ Target t = target(path);
+ for (String invalid : new String[] {"2026-2-3", "2026-02-30", "2026-10-16T00:00:00Z"}) {
+ String malformed = created().replace("\"planned_date\":\"2026-10-16\"",
+ "\"planned_date\":\"" + invalid + "\"");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "invalid-date", malformed)));
+ }
+ String numeric = created().replace("\"planned_date\":\"2026-10-16\"",
+ "\"planned_date\":20261016");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "numeric-date", numeric)));
+ assertEquals(0, count(t.sql(), "order_data"));
+ t.target().apply(t.context(), record(1, "valid-date", created()));
+ assertEquals("2026-10-16", t.sql().queryForList(
+ "SELECT planned_date FROM order_data WHERE id = ?", new Object[] {ORDER_ID})
+ .get(0).get("planned_date"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void acceptsOnlyLosslessInstantMillisecondText() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-instant-", ".sqlite");
+ try {
+ Target t = target(path);
+ for (String invalid : new String[] {
+ "-315619199877", "\"1960-01-01T00:00:00.123Z\"",
+ "\"1960-01-01T00:00:00.123\"", "\"-0315619199877\"",
+ "\"-0\"", "\"9223372036854775808\""}) {
+ String malformed = created().replace("\"occurred_at\":\"-315619199877\"",
+ "\"occurred_at\":" + invalid);
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "invalid-instant", malformed)));
+ }
+ assertEquals(0, count(t.sql(), "order_data"));
+ assertEquals(0, count(t.sql(), "teaql_event_inbox"));
+ t.target().apply(t.context(), record(1, "valid-instant", created()));
+ Map stored = t.sql().queryForList(
+ "SELECT typeof(occurred_at) AS storage_type, occurred_at FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals("integer", stored.get("storage_type"));
+ assertEquals(-315_619_199_877L, ((Number) stored.get("occurred_at")).longValue());
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void rejectsSqliteRealCoercionOfExactDecimal() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-decimal-", ".sqlite");
+ try {
+ Target t = target(path);
+ for (String decimal : new String[] {
+ "0.1", "123456789012345.6789", "0.12345678", "1234567890123"}) {
+ String inexact = created().replace("\"amount\":\"0\"",
+ "\"amount\":\"" + decimal + "\"");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "inexact-decimal", inexact)));
+ assertEquals(0, count(t.sql(), "order_data"));
+ assertEquals(0, count(t.sql(), "teaql_event_inbox"));
+ assertEquals(0, count(t.sql(), "teaql_event_checkpoint"));
+ assertEquals(0, count(t.sql(), "teaql_event_applied_payload"));
+ }
+ t.target().apply(t.context(), record(1, "exact-decimal", created()));
+ Map stored = t.sql().queryForList(
+ "SELECT typeof(amount) AS storage_type, amount FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals("integer", stored.get("storage_type"));
+ assertEquals(0L, ((Number) stored.get("amount")).longValue());
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void rejectsDecimalWithoutExplicitModeledPrecisionAndScale() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-untyped-decimal-", ".sqlite");
+ try {
+ Target t = target(path, null);
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "untyped-decimal", created())));
+ assertEquals(0, count(t.sql(), "order_data"));
+ assertEquals(0, count(t.sql(), "teaql_event_inbox"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void preservesBooleanFiniteFloatAndExplicitNull() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-scalar-kinds-", ".sqlite");
+ try {
+ Target t = target(path);
+ String textBoolean = created().replace("\"active\":true", "\"active\":\"true\"");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "text-boolean", textBoolean)));
+ String textFloat = created().replace("\"ratio\":0.5", "\"ratio\":\"0.5\"");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "text-float", textFloat)));
+ String nullBoolean = created().replace("\"active\":true", "\"active\":null");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "null-boolean", nullBoolean)));
+ String omitted = created().replace(",\"optional_note\":null", "");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(1, "omitted-note", omitted)));
+ assertEquals(0, count(t.sql(), "order_data"));
+
+ t.target().apply(t.context(), record(1, "valid-scalar-kinds", created()));
+ Map stored = t.sql().queryForList(
+ "SELECT active, ratio, optional_note FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(1L, ((Number) stored.get("active")).longValue());
+ assertEquals(0.5, ((Number) stored.get("ratio")).doubleValue(), 0.0);
+ assertNull(stored.get("optional_note"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void appliesNullableTransitionsWithoutTreatingOmissionAsNull() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-null-transition-", ".sqlite");
+ try {
+ Target t = target(path);
+ t.target().apply(t.context(), record(1, "created-null-transition", created()));
+ String nullRequired = submitted().replace("\"status\":\"SUBMITTED\"",
+ "\"status\":\"SUBMITTED\",\"active\":null");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(2, "null-required-update", nullRequired)));
+ String wrongKind = submitted().replace("\"optional_note\":\"submitted note\"",
+ "\"optional_note\":123");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(),
+ record(2, "wrong-note-kind", wrongKind)));
+ assertEquals(1, count(t.sql(), "teaql_event_inbox"));
+ Map unchanged = t.sql().queryForList(
+ "SELECT version, optional_note, optional_label FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(1L, ((Number) unchanged.get("version")).longValue());
+ assertNull(unchanged.get("optional_note"));
+ assertEquals("initial label", unchanged.get("optional_label"));
+
+ t.target().apply(t.context(), record(2, "changed-null-transition", submitted()));
+ Map stored = t.sql().queryForList(
+ "SELECT version, active, optional_note, optional_label FROM order_data WHERE id = ?",
+ new Object[] {ORDER_ID}).get(0);
+ assertEquals(2L, ((Number) stored.get("version")).longValue());
+ assertEquals(1L, ((Number) stored.get("active")).longValue());
+ assertEquals("submitted note", stored.get("optional_note"));
+ assertNull(stored.get("optional_label"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void rejectsTriggerRewriteAndStalePredecessor() throws Exception {
+ Path path = Files.createTempFile("teaql-model-target-trigger-", ".sqlite");
+ try {
+ Target t = target(path);
+ OutboxRecord first = record(1, "order-graph-0001", created());
+ t.sql().execute("CREATE TRIGGER rewrite_order AFTER INSERT ON order_data "
+ + "BEGIN UPDATE order_data SET status = 'TARGET_OVERRIDE' WHERE id = NEW.id; END");
+ expect("INVALID_ENVELOPE", () -> t.target().apply(t.context(), first));
+ assertEquals(0, count(t.sql(), "order_data"));
+ assertEquals(0, count(t.sql(), "teaql_event_inbox"));
+ t.sql().execute("DROP TRIGGER rewrite_order");
+ t.target().apply(t.context(), first);
+ String stale = submitted().replaceFirst("\\\"base_version\\\":\\\"1\\\"",
+ "\"base_version\":\"9\"").replaceFirst("\\\"committed_version\\\":\\\"2\\\"",
+ "\"committed_version\":\"10\"");
+ expect("VERSION_CONFLICT", () -> t.target().apply(t.context(), record(2, "stale", stale)));
+ assertEquals(1, count(t.sql(), "teaql_event_inbox"));
+ } finally { Files.deleteIfExists(path); }
+ }
+
+ @Test public void orderedPublisherRecoversAmbiguousAckAfterSourceReopen() throws Exception {
+ Path sourcePath = Files.createTempFile("teaql-source-delivery-", ".sqlite");
+ Path targetPath = Files.createTempFile("teaql-target-delivery-", ".sqlite");
+ try {
+ SQLiteDataSource sourceDataSource = new SQLiteDataSource();
+ sourceDataSource.setUrl("jdbc:sqlite:" + sourcePath);
+ JdbcSqlExecutor sourceSql = new JdbcSqlExecutor(sourceDataSource);
+ SqliteCommittedEventStore sourceEvents = new SqliteCommittedEventStore(sourceSql);
+ sourceEvents.ensureSchema();
+ for (String change : new String[] {"order-graph-0001", "order-graph-0002"}) {
+ String mutations = change.endsWith("0001") ? created() : submitted();
+ sourceSql.executeInTransaction(() -> sourceEvents.append(SOURCE, change, HASH,
+ sequence -> record(sequence, change, mutations).payloadJson()));
+ }
+ Target target = target(targetPath);
+ SqliteCommittedOutboxPublisher publisher = new SqliteCommittedOutboxPublisher(
+ sourceSql, SOURCE, "analytics", HASH);
+ assertEquals(2, publisher.status().backlog());
+ expect("ACK_MISMATCH", () -> publisher.publishBatch(2,
+ record -> SqliteCommittedEventStore.InboxOutcome.applied(99)));
+ assertEquals(0, publisher.status().ackedSequence());
+ try {
+ publisher.publishBatch(2, record -> {
+ target.target().apply(target.context(), record);
+ throw new IllegalStateException("acknowledgement lost after target commit");
+ });
+ fail("expected ambiguous acknowledgement");
+ } catch (IllegalStateException expected) {
+ assertEquals("acknowledgement lost after target commit", expected.getMessage());
+ }
+ assertEquals(0, publisher.status().ackedSequence());
+ assertEquals(1, count(target.sql(), "teaql_event_inbox"));
+
+ SqliteCommittedOutboxPublisher restarted = new SqliteCommittedOutboxPublisher(
+ new JdbcSqlExecutor(sourceDataSource), SOURCE, "analytics", HASH);
+ var outcome = restarted.publishBatch(2,
+ record -> target.target().apply(target.context(), record));
+ assertEquals(2, outcome.acknowledged());
+ assertEquals(2, outcome.status().ackedSequence());
+ assertEquals(0, outcome.status().backlog());
+ assertEquals(0, restarted.publishBatch(2, record -> {
+ throw new AssertionError("acked event must not be delivered again");
+ }).acknowledged());
+ assertEquals(2, count(target.sql(), "teaql_event_inbox"));
+ assertEquals("SUBMITTED", target.sql().queryForList(
+ "SELECT status FROM order_data WHERE id = ?", new Object[] {ORDER_ID})
+ .get(0).get("status"));
+ assertEquals(2, new SqliteCommittedOutboxPublisher(sourceSql, SOURCE,
+ "second-target", HASH).status().backlog());
+ expect("MODEL_MISMATCH", () -> new SqliteCommittedOutboxPublisher(sourceSql,
+ SOURCE, "analytics", "sha256:" + "1".repeat(64)).status());
+ sourceSql.update("DELETE FROM teaql_event_outbox WHERE source_runtime_id = ? "
+ + "AND source_sequence = 1", new Object[] {SOURCE});
+ expect("SEQUENCE_GAP", () -> new SqliteCommittedOutboxPublisher(sourceSql,
+ SOURCE, "second-target", HASH).publishBatch(2, record -> {
+ throw new AssertionError("gap must be rejected before delivery");
+ }));
+ } finally {
+ Files.deleteIfExists(sourcePath);
+ Files.deleteIfExists(targetPath);
+ }
+ }
+}
diff --git a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java
index 1b3e663d..a68c810d 100644
--- a/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java
+++ b/teaql-tfp-endpoint/src/test/java/io/teaql/tfp/TfpEndpointPolicyTest.java
@@ -77,6 +77,46 @@ public void requiresTrustedContextAndNeverDropsFilter() throws Exception {
org.junit.Assert.assertTrue(response.get("data") instanceof java.util.List>);
}
+ @Test
+ public void committedReplicationControlsNeverReachOrdinaryExecutors() throws Exception {
+ TfpEndpointHandler handler = handler();
+ ObjectMapper json = new ObjectMapper();
+ String sql = "INSERT INTO probe_data(id,status) VALUES(99,'SQL-CANARY')";
+ for (String control : java.util.List.of("sql", "rawSql", "statement", "statements",
+ "committed_mutation_set", "source_sequence", "replication", "apply")) {
+ var query = (com.fasterxml.jackson.databind.node.ObjectNode) json.readTree(query("id"));
+ query.put(control, sql);
+ assertCode("TFP_INVALID_REQUEST", () -> handler.handleQuery(context, trusted(), json.writeValueAsBytes(query)));
+ var mutation = json.createObjectNode().put("entity", "Probe").put("action", "Create")
+ .put("comment", "inspect ordinary entry boundary").put(control, sql);
+ mutation.putObject("payload").put("status", "PAID");
+ assertCode("TFP_INVALID_REQUEST", () -> handler.handleMutation(context, trusted(), json.writeValueAsBytes(mutation)));
+ org.junit.Assert.assertNull(capturedQuery);
+ org.junit.Assert.assertNull(capturedMutation);
+ }
+ // Neither a protocol-shaped document nor an action name creates dispatch authority.
+ byte[] committed = json.writeValueAsBytes(json.createObjectNode().put("protocol_version", 1)
+ .put("source_runtime_id", "untrusted-source").put("source_sequence", "1"));
+ assertCode("TFP_INVALID_REQUEST", () -> handler.handleQuery(context, trusted(), committed));
+ assertCode("TFP_INVALID_REQUEST", () -> handler.handleMutation(context, trusted(), committed));
+ for (String action : java.util.List.of("Apply", "Replicate", "CommittedMutationSet", "receive", "rawSql")) {
+ var mutation = json.createObjectNode().put("entity", "Probe").put("action", action)
+ .put("comment", "attempt replication dispatch");
+ mutation.putObject("payload").put("status", "PAID");
+ assertCode("TFP_POLICY_VIOLATION", () -> handler.handleMutation(context, trusted(), json.writeValueAsBytes(mutation)));
+ org.junit.Assert.assertNull(capturedMutation);
+ }
+ // Ordinary modeled values are not scanned for SQL keywords.
+ handler.handleQuery(context, trusted(), query("id"));
+ org.junit.Assert.assertNotNull(capturedQuery);
+ var allowed = json.createObjectNode().put("entity", "Probe").put("action", "Create")
+ .put("comment", "save SQL-looking ordinary text");
+ allowed.putObject("payload").put("status", sql);
+ handler.handleMutation(context, trusted(), json.writeValueAsBytes(allowed));
+ org.junit.Assert.assertEquals(sql, ((Probe) ((EntityPersistenceMutation) capturedMutation).getEntity()).getStatus());
+ System.out.println("CMR15_ENDPOINT_SOURCE_PASS language=java controls=8 actions=5 executor_spy=true database=false");
+ }
+
@Test
public void rejectsGovernedRequestsWithoutAnInvokingContext() {
io.teaql.core.TeaQLRuntimeException error = assertThrows(
diff --git a/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java b/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java
index 00e6da37..5495abdb 100644
--- a/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java
+++ b/teaql-tool-http/src/main/java/io/teaql/tools/http/HttpServiceProfile.java
@@ -1,9 +1,14 @@
package io.teaql.tools.http;
import io.teaql.core.http.HttpRequestService;
+import java.io.ByteArrayInputStream;
import java.net.URI;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.EnumSet;
+import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -11,6 +16,7 @@
public final class HttpServiceProfile {
private final String alias;
private final URI baseUri;
+ private final List trustedRoots;
private final Duration connectTimeout;
private final Duration requestTimeout;
private final Duration totalTimeout;
@@ -26,6 +32,7 @@ public final class HttpServiceProfile {
private HttpServiceProfile(Builder b) {
this.alias = b.alias;
this.baseUri = b.baseUri;
+ this.trustedRoots = b.trustedRoots;
this.connectTimeout = b.connectTimeout;
this.requestTimeout = b.requestTimeout;
this.totalTimeout = b.totalTimeout;
@@ -42,6 +49,7 @@ private HttpServiceProfile(Builder b) {
public static Builder builder(String alias, URI baseUri) { return new Builder(alias, baseUri); }
public String alias() { return alias; }
public URI baseUri() { return baseUri; }
+ List trustedRoots() { return trustedRoots; }
public Duration connectTimeout() { return connectTimeout; }
public Duration requestTimeout() { return requestTimeout; }
public Duration totalTimeout() { return totalTimeout; }
@@ -58,6 +66,7 @@ private HttpServiceProfile(Builder b) {
public static final class Builder {
private final String alias;
private final URI baseUri;
+ private List trustedRoots = List.of();
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration requestTimeout = Duration.ofSeconds(5);
private Duration totalTimeout = Duration.ofSeconds(10);
@@ -94,6 +103,39 @@ public Builder budgets(Duration connect, Duration request, Duration total) {
return this;
}
public Builder maxResponseBytes(int bytes) { this.maxResponseBytes = bytes; return this; }
+ /** Replace this profile's custom trust set with exactly one PEM root. */
+ public Builder trustedRootPem(byte[] pem) {
+ if (pem == null) throw new IllegalArgumentException("invalid trusted TLS root");
+ return trustedRootsPem(List.of(pem));
+ }
+
+ /** Explicit short overlap for CA rotation; custom roots replace system roots for this profile. */
+ public Builder trustedRootsPem(List pems) {
+ if (!"https".equalsIgnoreCase(baseUri.getScheme()) || pems == null
+ || pems.isEmpty() || pems.size() > 4) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ try {
+ var parsed = new ArrayList(pems.size());
+ var factory = CertificateFactory.getInstance("X.509");
+ for (byte[] pem : pems) {
+ if (pem == null || pem.length == 0 || pem.length > 64 * 1024) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ var certificates = factory.generateCertificates(new ByteArrayInputStream(pem.clone()));
+ if (certificates.size() != 1
+ || !(certificates.iterator().next() instanceof X509Certificate root)
+ || parsed.contains(root)) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ parsed.add(root);
+ }
+ trustedRoots = List.copyOf(parsed);
+ } catch (java.security.cert.CertificateException failure) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ return this;
+ }
public Builder retry(int attempts, Duration delay, Set methods) {
this.maxAttempts = attempts;
this.retryDelay = positive(delay);
diff --git a/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java b/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java
index 81ff9949..8e82b94f 100644
--- a/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java
+++ b/teaql-tool-http/src/main/java/io/teaql/tools/http/JdkHttpRequestService.java
@@ -12,6 +12,8 @@
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
@@ -24,6 +26,8 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Logger;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
/** Shared JDK transport for startup registered HTTP profiles. */
public final class JdkHttpRequestService implements HttpRequestService {
@@ -265,11 +269,30 @@ private static final class State {
this.profile = profile;
this.clock = clock;
this.refilledAt = clock.nanoTime();
- this.client = HttpClient.newBuilder()
+ HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(profile.connectTimeout())
- .followRedirects(HttpClient.Redirect.NEVER).build();
+ .followRedirects(HttpClient.Redirect.NEVER);
+ if (!profile.trustedRoots().isEmpty()) builder.sslContext(trustedRootContext(profile));
+ this.client = builder.build();
this.tokens = profile.ratePerSecond();
}
+ private static SSLContext trustedRootContext(HttpServiceProfile profile) {
+ try {
+ KeyStore store = KeyStore.getInstance("PKCS12");
+ store.load(null, null);
+ for (int index = 0; index < profile.trustedRoots().size(); index++) {
+ store.setCertificateEntry("root-" + index, profile.trustedRoots().get(index));
+ }
+ TrustManagerFactory managers = TrustManagerFactory.getInstance(
+ TrustManagerFactory.getDefaultAlgorithm());
+ managers.init(store);
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(null, managers.getTrustManagers(), null);
+ return context;
+ } catch (GeneralSecurityException | java.io.IOException failure) {
+ throw new IllegalArgumentException("invalid trusted TLS root");
+ }
+ }
synchronized boolean acquireCircuit() {
if (profile.circuitFailures() == 0) return false;
if (openUntil == 0) return false;
diff --git a/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpProfileTrustTest.java b/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpProfileTrustTest.java
new file mode 100644
index 00000000..34e76316
--- /dev/null
+++ b/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpProfileTrustTest.java
@@ -0,0 +1,243 @@
+package io.teaql.tools.http;
+
+import io.teaql.core.UserContext;
+import io.teaql.core.http.HttpRequestFailure;
+import io.teaql.core.http.HttpRequestService;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Proxy;
+import java.net.InetAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyFactory;
+import java.security.KeyStore;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLSocket;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+/** Dedicated private-CA proof; no Secret Provider or external service is involved. */
+public class HttpProfileTrustTest {
+ private static Path directory;
+ private static final List roots = new ArrayList<>();
+
+ private static void openssl(String... args) throws Exception {
+ var command = new ArrayList(); command.add("openssl"); command.addAll(List.of(args));
+ Process child = new ProcessBuilder(command).directory(directory.toFile())
+ .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start();
+ try {
+ assertTrue("TLS fixture generator exceeded budget", child.waitFor(30, TimeUnit.SECONDS));
+ assertEquals("TLS fixture generator failed", 0, child.exitValue());
+ } finally {
+ if (child.isAlive()) { child.destroyForcibly(); child.waitFor(); }
+ }
+ }
+
+ @BeforeClass public static void createCertificates() throws Exception {
+ directory = Files.createTempDirectory("teaql-http-trust-");
+ for (int i = 0; i < 4; i++) {
+ openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
+ "-subj", "/CN=TeaQL HTTP fixture root " + i,
+ "-addext", "basicConstraints=critical,CA:TRUE",
+ "-addext", "keyUsage=critical,keyCertSign,cRLSign",
+ "-keyout", "root-" + i + ".key", "-out", "root-" + i + ".pem");
+ roots.add(Files.readAllBytes(directory.resolve("root-" + i + ".pem")));
+ }
+ openssl("req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=HTTP fixture",
+ "-addext", "subjectAltName=IP:127.0.0.1", "-keyout", "server.key", "-out", "server.csr");
+ for (int i = 0; i < 2; i++) openssl("x509", "-req", "-days", "1", "-CAcreateserial",
+ "-copy_extensions", "copy", "-in", "server.csr", "-CA", "root-" + i + ".pem",
+ "-CAkey", "root-" + i + ".key", "-out", "server-" + i + ".pem");
+ openssl("req", "-new", "-key", "server.key", "-subj", "/CN=HTTP wrong host fixture",
+ "-addext", "subjectAltName=IP:127.0.0.2", "-out", "wrong-host.csr");
+ openssl("x509", "-req", "-days", "1", "-CAcreateserial", "-copy_extensions", "copy",
+ "-in", "wrong-host.csr", "-CA", "root-0.pem", "-CAkey", "root-0.key",
+ "-out", "wrong-host.pem");
+ }
+
+ @AfterClass public static void removeOwnedPrivateKeys() throws Exception {
+ if (directory == null) return;
+ try (var files = Files.walk(directory)) {
+ for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file);
+ }
+ }
+
+ private static HttpServiceProfile.Builder profile(String alias, URI uri) {
+ return HttpServiceProfile.builder(alias, uri)
+ .budgets(Duration.ofSeconds(3), Duration.ofSeconds(3), Duration.ofSeconds(5));
+ }
+
+ private static UserContext context() {
+ return (UserContext) Proxy.newProxyInstance(UserContext.class.getClassLoader(),
+ new Class>[] { UserContext.class }, (proxy, method, args) -> {
+ if (method.isDefault()) return InvocationHandler.invokeDefault(proxy, method, args);
+ return null;
+ });
+ }
+
+ private static void success(JdkHttpRequestService service, String alias) {
+ var response = service.execute(context(), HttpRequestService.Call.get(alias, "status",
+ "load private upstream status", "verify profile-scoped HTTPS trust"));
+ assertEquals(200, response.status()); assertArrayEquals("ok".getBytes(StandardCharsets.US_ASCII), response.body());
+ }
+
+ private static void rejected(JdkHttpRequestService service, String alias) {
+ var failure = assertThrows(HttpRequestFailure.class, () -> success(service, alias));
+ assertEquals(HttpRequestFailure.Code.UPSTREAM_FAILURE, failure.code()); assertNull(failure.getCause());
+ }
+
+ @Test public void rejectsInvalidTrustInputsAndAcceptsOneToFourDistinctRoots() {
+ URI https = URI.create("https://127.0.0.1/");
+ byte[] old = roots.get(0), next = roots.get(1);
+ byte[] bundled = new byte[old.length + next.length];
+ System.arraycopy(old, 0, bundled, 0, old.length); System.arraycopy(next, 0, bundled, old.length, next.length);
+ var invalid = List.of(List.of(), List.of(new byte[0]), List.of(new byte[65537]),
+ List.of("PRIVATE_ROOT_CANARY".getBytes(StandardCharsets.US_ASCII)), List.of(bundled),
+ List.of(old, old), List.of(old, new String(old, StandardCharsets.US_ASCII)
+ .replace("\n", "\r\n").getBytes(StandardCharsets.US_ASCII)),
+ List.of(old, next, roots.get(2), roots.get(3), old));
+ for (var input : invalid) {
+ var failure = assertThrows(IllegalArgumentException.class,
+ () -> profile("upstream", https).trustedRootsPem(input));
+ assertEquals("invalid trusted TLS root", failure.getMessage()); assertNull(failure.getCause());
+ }
+ assertThrows(IllegalArgumentException.class, () -> profile("upstream", https).trustedRootPem(null));
+ assertThrows(IllegalArgumentException.class, () -> profile("upstream", https).trustedRootsPem(null));
+ assertThrows(IllegalArgumentException.class, () -> profile("upstream", https).trustedRootsPem(Arrays.asList(old, null)));
+ assertThrows(IllegalArgumentException.class,
+ () -> profile("upstream", URI.create("http://127.0.0.1/")).trustedRootPem(old));
+ for (int count = 1; count <= 4; count++) assertEquals(count,
+ profile("upstream", https).trustedRootsPem(roots.subList(0, count)).build().trustedRoots().size());
+ System.out.println("HTTP_TRUST_INPUT_PASS language=java bounded=1 malformed=1 duplicate=1 bundle=1 https_only=1");
+ }
+
+ @Test public void customRootsStayProfileLocalAndDoNotDisableHostnameChecks() throws Exception {
+ try (var peer = new Peer("server-0.pem", 0); var wrong = new Peer("wrong-host.pem", 0)) {
+ var service = new JdkHttpRequestService(List.of(profile("system", peer.uri()).build(),
+ profile("trusted", peer.uri()).trustedRootPem(roots.get(0)).build(),
+ profile("wrong_host", wrong.uri()).trustedRootPem(roots.get(0)).build()));
+ rejected(service, "system"); assertEquals(0, peer.requests.get());
+ success(service, "trusted"); assertEquals(1, peer.requests.get());
+ rejected(service, "system"); assertEquals(1, peer.requests.get());
+ rejected(service, "wrong_host"); assertEquals(0, wrong.requests.get());
+ System.out.println("HTTP_TRUST_ISOLATION_PASS language=java system_reject=2 custom_success=1 hostname_reject=1");
+ }
+ }
+
+ @Test public void overlappingRootsRotateAcrossBothLivePeersWithoutReset() throws Exception {
+ try (var old = new Peer("server-0.pem", 0); var next = new Peer("server-1.pem", 1)) {
+ var oldOnly = new JdkHttpRequestService(List.of(profile("old", old.uri()).trustedRootPem(roots.get(0)).build(),
+ profile("next", next.uri()).trustedRootPem(roots.get(0)).build()));
+ success(oldOnly, "old"); rejected(oldOnly, "next"); assertEquals(0, next.requests.get());
+ var overlap = new JdkHttpRequestService(List.of(profile("old", old.uri()).trustedRootsPem(roots.subList(0, 2)).build(),
+ profile("next", next.uri()).trustedRootsPem(roots.subList(0, 2)).build()));
+ var newOnly = new JdkHttpRequestService(List.of(profile("old", old.uri()).trustedRootPem(roots.get(1)).build(),
+ profile("next", next.uri()).trustedRootPem(roots.get(1)).build()));
+ for (int round = 1; round <= 2; round++) {
+ success(overlap, "old"); success(overlap, "next"); rejected(newOnly, "old"); success(newOnly, "next");
+ assertEquals(1 + round, old.requests.get()); assertEquals(2 * round, next.requests.get());
+ System.out.println("HTTP_TRUST_ROTATION_PASS language=java round=" + round + " old_requests="
+ + old.requests.get() + " new_requests=" + next.requests.get());
+ }
+ }
+ }
+
+ @Test public void builtProfilesSnapshotInputsAndFailedReplacementIsAtomic() throws Exception {
+ try (var peer = new Peer("server-0.pem", 0)) {
+ byte[] copy = roots.get(0).clone(); var input = new ArrayList<>(List.of(copy));
+ var builder = profile("trusted", peer.uri()).trustedRootsPem(input); var before = builder.build();
+ input.clear(); Arrays.fill(copy, (byte) 0);
+ assertThrows(IllegalArgumentException.class, () -> builder.trustedRootsPem(List.of(roots.get(1), new byte[0])));
+ var afterFailure = builder.build(); builder.trustedRootPem(roots.get(1));
+ success(new JdkHttpRequestService(List.of(before)), "trusted");
+ success(new JdkHttpRequestService(List.of(afterFailure)), "trusted");
+ rejected(new JdkHttpRequestService(List.of(builder.build())), "trusted");
+ assertEquals(2, peer.requests.get());
+ }
+ }
+
+ @Test public void profileLimitsAndDuplicateAliasesFailBeforeUse() {
+ URI uri = URI.create("https://127.0.0.1/");
+ for (Duration invalid : List.of(Duration.ZERO, Duration.ofNanos(-1), Duration.ofHours(1).plusNanos(1))) {
+ assertThrows(IllegalArgumentException.class, () -> profile("valid", uri).budgets(invalid, Duration.ofSeconds(1), Duration.ofSeconds(1)));
+ assertThrows(IllegalArgumentException.class, () -> profile("valid", uri).budgets(Duration.ofSeconds(1), invalid, Duration.ofSeconds(1)));
+ assertThrows(IllegalArgumentException.class, () -> profile("valid", uri).budgets(Duration.ofSeconds(1), Duration.ofSeconds(1), invalid));
+ }
+ for (int bytes : List.of(0, 16 * 1024 * 1024 + 1)) assertThrows(IllegalArgumentException.class,
+ () -> profile("valid", uri).maxResponseBytes(bytes).build());
+ for (int attempts : List.of(0, 6)) assertThrows(IllegalArgumentException.class,
+ () -> profile("valid", uri).retry(attempts, Duration.ofMillis(1), Set.of()).build());
+ assertThrows(IllegalArgumentException.class, () -> profile("valid", uri).circuit(1, Duration.ofSeconds(1), 0).build());
+ assertThrows(IllegalArgumentException.class, () -> new JdkHttpRequestService(List.of(profile("same", uri).build(), profile("same", uri).build())));
+ }
+
+ private static final class Peer implements AutoCloseable {
+ final SSLServerSocket server;
+ final Thread worker;
+ final AtomicBoolean closed = new AtomicBoolean();
+ final AtomicReference error = new AtomicReference<>();
+ final AtomicInteger requests = new AtomicInteger();
+ Peer(String certificateFile, int rootIndex) throws Exception {
+ var factory = CertificateFactory.getInstance("X.509");
+ var certificate = (X509Certificate) factory.generateCertificate(
+ new java.io.ByteArrayInputStream(Files.readAllBytes(directory.resolve(certificateFile))));
+ var root = (X509Certificate) factory.generateCertificate(new java.io.ByteArrayInputStream(roots.get(rootIndex)));
+ certificate.checkValidity(); certificate.verify(root.getPublicKey());
+ String key = Files.readString(directory.resolve("server.key")).replace("-----BEGIN PRIVATE KEY-----", "")
+ .replace("-----END PRIVATE KEY-----", "").replaceAll("\\s", "");
+ var privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(key)));
+ var store = KeyStore.getInstance("PKCS12"); store.load(null, null);
+ char[] password = "fixture-only".toCharArray();
+ store.setKeyEntry("server", privateKey, password, new java.security.cert.Certificate[] {certificate, root});
+ var managers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); managers.init(store, password);
+ var tls = SSLContext.getInstance("TLS"); tls.init(managers.getKeyManagers(), null, null);
+ server = (SSLServerSocket) tls.getServerSocketFactory().createServerSocket(0, 16, InetAddress.getByName("127.0.0.1"));
+ server.setSoTimeout(100);
+ worker = new Thread(() -> {
+ while (!closed.get()) {
+ try (var socket = (SSLSocket) server.accept()) {
+ socket.setSoTimeout(2000);
+ var reader = new java.io.BufferedReader(new java.io.InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
+ String first = reader.readLine();
+ if (first == null) continue;
+ assertTrue(first.startsWith("GET /status HTTP/1.1"));
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {}
+ if (line == null) continue;
+ requests.incrementAndGet();
+ socket.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".getBytes(StandardCharsets.US_ASCII));
+ socket.getOutputStream().flush();
+ } catch (java.io.IOException rejectedOrStopped) {
+ // Unknown CA and hostname failures cannot enter HTTP.
+ } catch (Throwable unexpected) { error.compareAndSet(null, unexpected); return; }
+ }
+ }, "teaql-http-trust-peer");
+ worker.start();
+ }
+ URI uri() { return URI.create("https://127.0.0.1:" + server.getLocalPort() + "/"); }
+ @Override public void close() throws Exception {
+ closed.set(true); server.close(); worker.join(5000);
+ assertFalse("owned TLS peer did not stop", worker.isAlive());
+ if (error.get() != null) throw new AssertionError("owned TLS peer failed", error.get());
+ }
+ }
+}
diff --git a/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java b/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java
index 1b0a9d91..e16b1f24 100644
--- a/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java
+++ b/teaql-tool-http/src/test/java/io/teaql/tools/http/HttpResilienceTest.java
@@ -137,6 +137,80 @@ private static HttpRequestService.Call call(HttpRequestService.Method method, St
assertEquals(3, server.requests.get());
}
+ @Test public void missingContextOrIntentCannotStartNetworkIo() {
+ var service = new JdkHttpRequestService(java.util.List.of(profile().build()));
+ assertEquals(HttpRequestFailure.Code.INVALID_INPUT, assertThrows(HttpRequestFailure.class,
+ () -> service.execute(null, get("ok"))).code());
+ for (String[] intent : new String[][] {{" ", "render order"}, {"load order", " \t"}}) {
+ assertEquals(HttpRequestFailure.Code.INVALID_INPUT, assertThrows(HttpRequestFailure.class,
+ () -> service.execute(context(service), HttpRequestService.Call.get(
+ "orders", "ok", intent[0], intent[1]))).code());
+ }
+ assertEquals(0, server.requests.get());
+ System.out.println("HTTP_NUMBERED_PASS language=java case=HTTP-01 requests=0");
+ }
+
+ @Test public void alwaysFailingEndpointStopsAtConfiguredAttemptCeiling() {
+ for (int ceiling : new int[] {1, 2, 3, 5}) {
+ var service = new JdkHttpRequestService(java.util.List.of(profile()
+ .retry(ceiling, Duration.ofMillis(1), Set.of(HttpRequestService.Method.GET)).build()));
+ int before = server.requests.get();
+ var response = service.execute(context(service), call(HttpRequestService.Method.GET,
+ "fail", HttpRequestService.RetryMode.CONFIRMED_IDEMPOTENT));
+ assertEquals(503, response.status());
+ assertEquals(ceiling, response.attempts());
+ assertEquals(ceiling, server.requests.get() - before);
+ }
+ System.out.println("HTTP_NUMBERED_PASS language=java case=HTTP-07 ceilings=1,2,3,5");
+ }
+
+ @Test public void oversizedIncompleteResponseReleasesPeerBeforeNextCall() throws Exception {
+ var service = new JdkHttpRequestService(java.util.List.of(profile().maxResponseBytes(16).build()));
+ assertEquals(HttpRequestFailure.Code.RESPONSE_TOO_LARGE, assertThrows(HttpRequestFailure.class,
+ () -> service.execute(context(service), get("overflow-open"))).code());
+ assertTrue("incomplete oversized response did not close at the peer",
+ server.overflowReleased.await(2, TimeUnit.SECONDS));
+ assertEquals(200, service.execute(context(service), get("ok")).status());
+ assertEquals(2, server.requests.get());
+ System.out.println("HTTP_NUMBERED_PASS language=java case=HTTP-09 peer_closed=1 next_call=1");
+ }
+
+ @Test public void successfulHalfOpenProbeClosesCircuitForConcurrentCalls() throws Exception {
+ AtomicLong monotonic = new AtomicLong(1);
+ HttpOperationalClock clock = new HttpOperationalClock() {
+ @Override public long nanoTime() { return monotonic.get(); }
+ @Override public void pauseNanos(long nanos) { monotonic.addAndGet(nanos); }
+ };
+ var service = new JdkHttpRequestService(java.util.List.of(profile()
+ .circuit(1, Duration.ofSeconds(5), 1).build()), clock);
+ assertEquals(503, service.execute(context(service), get("fail")).status());
+ assertEquals(HttpRequestFailure.Code.CIRCUIT_OPEN, assertThrows(HttpRequestFailure.class,
+ () -> service.execute(context(service), get("ok"))).code());
+ monotonic.addAndGet(Duration.ofSeconds(5).toNanos());
+ assertEquals(200, service.execute(context(service), get("ok")).status());
+ AtomicReference failure = new AtomicReference<>();
+ AtomicInteger completed = new AtomicInteger();
+ Runnable request = () -> {
+ try {
+ assertEquals(200, service.execute(context(service), get("gated")).status());
+ completed.incrementAndGet();
+ } catch (Throwable error) { failure.compareAndSet(null, error); }
+ };
+ Thread first = new Thread(request), second = new Thread(request);
+ first.start(); second.start();
+ try {
+ // Neither response is released until both physical requests arrive.
+ assertTrue("successful probe left circuit in one-at-a-time half-open state",
+ server.gatedEntered.await(3, TimeUnit.SECONDS));
+ } finally {
+ server.releaseGated.countDown(); first.join(2000); second.join(2000);
+ }
+ assertFalse(first.isAlive()); assertFalse(second.isAlive());
+ if (failure.get() != null) throw new AssertionError(failure.get());
+ assertEquals(2, completed.get()); assertEquals(4, server.requests.get());
+ System.out.println("HTTP_NUMBERED_PASS language=java case=HTTP-11 concurrent_after_probe=2");
+ }
+
@Test public void retryIsExplicitAndPostTransportFailureIsAmbiguous() {
HttpServiceProfile configured = profile().retry(3, Duration.ofMillis(1),
Set.of(HttpRequestService.Method.GET)).build();
@@ -632,6 +706,9 @@ private static final class Fixture implements AutoCloseable {
private final AtomicInteger retryPost = new AtomicInteger();
private final ConcurrentLinkedQueue traceparents = new ConcurrentLinkedQueue<>();
private final ConcurrentLinkedQueue retryBodies = new ConcurrentLinkedQueue<>();
+ private final CountDownLatch overflowReleased = new CountDownLatch(1);
+ private final CountDownLatch gatedEntered = new CountDownLatch(2);
+ private final CountDownLatch releaseGated = new CountDownLatch(1);
private volatile boolean closed;
Fixture() throws IOException {
socket = new ServerSocket(0, 32, InetAddress.getLoopbackAddress());
@@ -681,6 +758,20 @@ private void handle(Socket socket) {
if (target.contains("/retry-post")) retryBodies.add(new String(body));
}
if (target.contains("/close")) return;
+ if (target.endsWith("/overflow-open")) {
+ client.setSoTimeout(3000);
+ client.getOutputStream().write(("HTTP/1.1 200 OK\r\nContent-Length: 4096\r\n"
+ + "Connection: close\r\n\r\n" + "L".repeat(32)).getBytes(StandardCharsets.UTF_8));
+ client.getOutputStream().flush();
+ // Never finish the body: only the client can release this connection.
+ try { if (reader.read() == -1) overflowReleased.countDown(); }
+ catch (java.net.SocketException closedByPeer) { overflowReleased.countDown(); }
+ return;
+ }
+ if (target.endsWith("/gated")) {
+ gatedEntered.countDown();
+ if (!releaseGated.await(4, TimeUnit.SECONDS)) return;
+ }
if (target.endsWith("/slow")) Thread.sleep(300);
int status = target.contains("/private-response") ? 503
: target.contains("/redirect") ? 302 : target.contains("/missing") ? 404