OGuardAI
Integrations

Java SDK

A Java client for the OGuardAI API built on java.net.http, with fail-closed guarded calls, sessions, and streaming

The oguardai-sdk artifact provides a Java client for the OGuardAI API. It is built on java.net.http with Gson for JSON, has no other runtime dependency, and enforces the runtime's trust boundary on the client side.

Install

The artifact is not published to Maven Central or any other public repository. Build it from the repository and install it into your local repository, then declare the dependency:

git clone https://github.com/oronts/OGuardAI.git
cd OGuardAI/sdks/java
mvn install
<dependency>
  <groupId>com.oronts</groupId>
  <artifactId>oguardai-sdk</artifactId>
  <version>0.1.0</version>
</dependency>

Requires Java 11 or newer.

Transform and Rehydrate

OGuardAIClient client = OGuardAIClient.builder()
    .baseUrl("http://localhost:3000")
    .apiKey(System.getenv("GUARDAI_API_KEY")) // only if the server runs with auth
    .build();

TransformResponse result = client.transform(new TransformRequest()
    .input("Contact Erika Mustermann at erika.mustermann@example.com")
    .policy("default"));
// result.safeText: "Contact {{person:p_001:2f9a4d81c6e0}} at {{email:e_001:b57e03d2a941}}"

String llmReply = callYourLLM(result.safeText); // sees tokens only

RehydrateResponse restored = client.rehydrate(new RehydrateRequest()
    .output(llmReply)
    .sessionState(result.sessionState)
    .outputChannel("customer_email")
    .restoreMode("formatted"));
// restored.restoredText: "Dear Frau Erika Mustermann, ..."

Guarded Call and Chat

guardedCall wraps any String -> String step: it transforms the input, runs your function on the tokenized text, and rehydrates the output. It fails closed, so if the transform returns no session state the function is never run and no unrestored output is returned.

String answer = client.guardedCall(
    safe -> callYourLLM(safe),
    "Draft a reply to Erika Mustermann",
    new OGuardAIClient.GuardedCallOptions().policy("default").outputChannel("customer_email"));

guardedChat tokenizes a whole conversation in one call and returns the safe messages plus the session state needed to restore the reply.

List<Map<String, Object>> messages = List.of(
    Map.of("role", "system", "content", "You are a support agent."),
    Map.of("role", "user", "content", "I am Erika Mustermann, erika.mustermann@example.com"));

GuardedChatResult chat = client.guardedChat(messages,
    new OGuardAIClient.GuardedChatOptions().policy("german-support"));

String reply = callYourChatLLM(chat.safeMessages); // sees tokens only
String restored = client.rehydrateReply(reply, chat.sessionState, "customer_email");

Detect, Health, Capabilities

DetectResponse detected = client.detect(new DetectRequest("Email test@example.com, SSN 123-45-6789"));
for (DetectedEntity e : detected.entities) {
    System.out.printf("%s: %s (%.2f)%n", e.type, e.value, e.confidence);
}

// includeValues is a boxed Boolean, so a false is transmitted verbatim to keep raw
// values out of a value-free caller (a primitive would be dropped and default to true).
client.detect(new DetectRequest("SSN 123-45-6789").includeValues(false));

HealthResponse health = client.health();          // health.status == "healthy"
CapabilitiesResponse caps = client.capabilities(); // caps.entityTypes, caps.languages, caps.detectors

Note: entity.span.start and entity.span.end are UTF-8 byte offsets, not String indices. Java indexes by UTF-16 code unit, so substring with them mis-slices any non-ASCII text. Slice the encoded bytes instead:

byte[] bytes = input.getBytes(StandardCharsets.UTF_8);
String raw = new String(bytes, e.span.start, e.span.end - e.span.start, StandardCharsets.UTF_8);

Output Guard

outputGuard re-scans text an LLM already generated for newly surfaced PII and returns the guarded output plus whether the guard fired. It is stateless, so it mints no session and needs none. Use it as a second pass on any output that did not come through a rehydrate.

OutputGuardResponse guarded = client.outputGuard(new OutputGuardRequest(llmReply)
    .policy("default")
    .outputChannel("customer_email"));
// guarded.safeOutput: the text with any newly generated PII masked
// guarded.outputGuardTriggered: true when the guard rewrote the output

Sessions

A Session carries session state across a multi-turn exchange. Use one per conversation; it is not safe for concurrent use.

Session session = client.newSession("german-support");

TransformResponse turn1 = session.transform("Ich bin Erika Mustermann, Kundennummer 948221");
String reply = callYourLLM(turn1.safeText);
RehydrateResponse restored = session.rehydrate(reply, "customer_email");

// Turn 2: entities are deduplicated across turns, so Erika Mustermann stays p_001.
TransformResponse turn2 = session.transform("Meine E-Mail ist erika.mustermann@example.com");

Streaming

Streaming runs the full pipeline then streams the safe (transform) or restored (rehydrate) text to your consumer. The returned terminal event carries the session state needed to rehydrate a streamed transform. A terminal server error throws an OGuardAIException.

StringBuilder safe = new StringBuilder();
StreamEvent terminal = client.transformStream(
    "Contact Erika Mustermann",
    new OGuardAIClient.TransformStreamOptions().policy("default"),
    chunk -> safe.append(chunk));

String sessionState = terminal.sessionState; // rehydrate the streamed output with this

Batch

Transform or detect many inputs in one request. Each item may carry its own policy, language, and customPatterns (ad-hoc regex detectors scoped to that item only, mirroring a single transform), so one batch can mix languages and per-item rules. sessionMode is independent (default) or shared.

BatchTransformResponse batch = client.batchTransform(new BatchTransformRequest()
    .sessionMode("independent")
    .items(List.of(
        new BatchTransformRequest.Item("Ich bin Erika Mustermann").language("de"),
        new BatchTransformRequest.Item("Contact max.mustermann@example.com").language("en"))));
for (BatchTransformResponse.Result r : batch.results) {
    System.out.println(r.index + " " + r.safeText + " " + r.error);
}

// includeValues(false) keeps raw values out of a value-free caller.
BatchDetectResponse detected = client.batchDetect(new BatchDetectRequest()
    .includeValues(false)
    .items(List.of(new BatchDetectRequest.Item("SSN 123-45-6789").policy("strict-pii"))));

Revocation and Policy Preview

// Revoke a value so future rehydrates return [DELETED].
client.revoke("email", "erika.mustermann@example.com");
client.bulkRevoke(List.of(new RevokeRequest("email", "a@b.com")));

// Preview policy decisions without transforming (evaluate-policy).
EvaluatePolicyResponse decision = client.evaluatePolicy(
    List.of(new PolicyEntity("email", "erika.mustermann@example.com")),
    "default");

RAG

The RAG methods carry a corpusId for cross-document token identity: passing the same corpusId on ingest, query, and context makes the same raw value tokenize to the same token id across documents, which is what lets a query entity align with an ingested document entity.

// 1. Ingest: chunk + tokenize a document, store safe chunks in your vector DB
RagIngestResponse ingest = client.ragIngest(new RagIngestRequest(documentText)
    .chunkingStrategy("paragraph")
    .corpusId("support-kb"));
List<String> docSessions = ingest.chunks.stream()
    .map(c -> c.chunkSessionState).collect(Collectors.toList());

// 2. Query: tokenize the user question for vector search
RagQueryResponse query = client.ragQuery(new RagQueryRequest(userQuestion).corpusId("support-kb"));
List<Hit> hits = vectorSearch(query.safeQuery);

// 3. Context: merge retrieved chunks into a safe LLM context
RagContextResponse ctx = client.ragContext(new RagContextRequest(
        hits.stream().map(h -> h.text).collect(Collectors.toList()), query.sessionState)
    .documentSessions(docSessions)
    .corpusId("support-kb"));
String llmAnswer = callYourLLM(String.join("\n", ctx.safeChunks));

// 4. Answer: restore host-side using the accumulated RAG session
RagAnswerResponse answer = client.ragAnswer(new RagAnswerRequest(llmAnswer, ctx.sessionState)
    .outputChannel("user_output"));

// GDPR erasure: forget every value in an ingest session
client.ragDelete(ingest.chunks.get(0).chunkSessionState);

Error Handling

Every failure is an OGuardAIException (unchecked). Read code() for the specific failure, or use the predicate methods.

try {
    client.transform(new TransformRequest().input("..."));
} catch (OGuardAIException e) {
    if (e.isPolicyDenied()) {
        // 403: a redacted entity's rule set on_redact: reject
    } else if (e.isSessionExpired()) {
        // 410: session TTL elapsed
    } else if (e.isOutputBlocked()) {
        // 422: the output guard blocked newly generated PII
    } else if (e.isRateLimited()) {
        String retryAfter = e.retryAfter();
    } else {
        System.out.printf("error [%s] status=%d%n", e.code(), e.statusCode());
    }
}

Predicates: isAuth, isValidation, isPolicyDenied, isSessionExpired, isOutputBlocked, isTokenRepair, isRateLimited, isTimeout.

Configuration

OGuardAIClient client = OGuardAIClient.builder()
    .baseUrl("http://localhost:3000")   // required
    .apiKey("your-api-key")             // optional: sent as X-API-Key
    .timeout(Duration.ofSeconds(30))    // optional per-attempt timeout (default 30s)
    .maxRetries(3)                       // optional; retries 429/503, honors Retry-After, 0 to disable
    .build();

Runnable example: European bank support desk

A complete walkthrough lives in the repository at examples/java-quickstart. The scenario is a European bank's support desk, and raw customer data never reaches the LLM: it only ever sees semantic tokens like {{iban:ib_001}}, and real values are restored deterministically afterward. One Main class runs seven steps in order:

  1. German gender-aware customer support: tokenize a whole conversation with guardedChat, let the model draft a formal reply against the tokens, then restore the real values for the customer with rehydrateReply.
  2. English CRM copilot draft: guardedCall wraps transform, your model, and rehydrate in one call and fails closed if the transform returns no session.
  3. Policy preview: evaluatePolicy shows what the policy would do with a set of entities, with the raw values omitted so the preview itself carries no PII.
  4. Detection-only scan: detect with includeValues(false) reports which kinds of PII appear without returning the PII.
  5. RAG over a classified internal document: ragIngest, ragQuery, ragContext (with access-level filtering), and ragAnswer. The vector store only ever holds safe chunks.
  6. Session lifecycle: sessionStatus and deleteSession. On the sealed backend, delete reports stateless because the caller holds the only copy.
  7. GDPR erasure: bulkRevoke makes future rehydration return [DELETED] for the customer's values.

Needs NER. The German steps detect person and company names, so they require the NER detector sidecar, not the runtime alone. The docker compose stack below brings up the runtime plus the NER detector. The example's german-support policy declares person detection as required, so a runtime without the sidecar rejects the transform (fail closed, HTTP 503) rather than let an un-tokenized name reach the model. The built-in regex still covers the structured formats (IBAN, email, phone), but the German steps need the full stack with NER.

The model calls in the example are stubs that echo the tokenized text, so it runs offline-coherent. Swap draftFollowUp and the reply construction for your real provider call; it will only ever receive tokens. All names, emails, IBANs, phone numbers, and IDs are fictional test data.

The example depends on the SDK artifact, so build and install it into your local Maven repository first, then start the full stack and run the example. All commands run from the examples/java-quickstart folder:

# Build and install the SDK to your local Maven repo:
(cd ../../sdks/java && mvn -q install -DskipTests)

# The full stack runs api_key auth, so generate the secrets it requires (session, the server/detector
# hop, Redis, and one client API key) and export them for both compose and the client. Alternatively
# source a .env written by deploy/scripts/gen-secrets.sh.
export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)
export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)
export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)
export GUARDAI_API_KEY_1=$(openssl rand -hex 32)
# The client key ships least-privilege (channel user_output only); widen it to the channels this
# example restores on.
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,customer_email

# Start the server with the NER sidecar so person and company names are detected:
docker compose -f ../../deploy/docker/docker-compose.yml up -d

# Then run the example, sending the client key as X-API-Key:
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"
mvn -q compile exec:java

Once the SDK is published, depend on it directly instead of the local install, as shown in the Install section above.

Project

examples/java-quickstart/pom.xml declares the SDK dependency and wires the exec-maven-plugin so mvn -q compile exec:java runs com.example.Main:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.oronts.examples</groupId>
  <artifactId>oguardai-java-quickstart</artifactId>
  <version>0.1.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>11</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <exec.mainClass>com.example.Main</exec.mainClass>
  </properties>

  <dependencies>
    <dependency>
      <groupId>com.oronts</groupId>
      <artifactId>oguardai-sdk</artifactId>
      <version>0.1.0</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.1.0</version>
      </plugin>
    </plugins>
  </build>
</project>

Program

The complete program, verbatim from examples/java-quickstart/src/main/java/com/example/Main.java:

package com.example;

import com.oronts.oguardai.BulkRevokeResponse;
import com.oronts.oguardai.DetectResponse;
import com.oronts.oguardai.DetectRequest;
import com.oronts.oguardai.DetectedEntity;
import com.oronts.oguardai.DocumentMetadata;
import com.oronts.oguardai.EvaluatePolicyResponse;
import com.oronts.oguardai.GuardedChatResult;
import com.oronts.oguardai.OGuardAIClient;
import com.oronts.oguardai.PolicyContext;
import com.oronts.oguardai.PolicyDecision;
import com.oronts.oguardai.PolicyEntity;
import com.oronts.oguardai.RagAnswerRequest;
import com.oronts.oguardai.RagAnswerResponse;
import com.oronts.oguardai.RagContextRequest;
import com.oronts.oguardai.RagContextResponse;
import com.oronts.oguardai.RagIngestRequest;
import com.oronts.oguardai.RagIngestResponse;
import com.oronts.oguardai.RagQueryRequest;
import com.oronts.oguardai.RagQueryResponse;
import com.oronts.oguardai.RevokeRequest;
import com.oronts.oguardai.SessionDeleteRequest;
import com.oronts.oguardai.SessionDeleteResponse;
import com.oronts.oguardai.SessionLookupRequest;
import com.oronts.oguardai.SessionStatusResponse;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * An enterprise walkthrough of the OGuardAI Java SDK, modelled on a European bank's support desk.
 * Every step keeps raw customer data inside the trusted zone: the LLM only ever sees semantic tokens like
 * {@code {{iban:ib_001}}}, and real values are restored deterministically afterward.
 *
 * <p>The flow, end to end:
 * <ol>
 *   <li>German gender-aware customer support (guardedChat + rehydrate)</li>
 *   <li>English CRM copilot draft (guardedCall)</li>
 *   <li>Policy preview before sending (evaluatePolicy)</li>
 *   <li>Detection-only PII scan with values suppressed (detect)</li>
 *   <li>RAG over an internal, classified document (ingest, query, context, answer)</li>
 *   <li>Session lifecycle inspection and teardown (sessionStatus, deleteSession)</li>
 *   <li>GDPR erasure of a customer's data (revoke)</li>
 * </ol>
 *
 * <p>Run (from the repo root): install the SDK, start the server with the German policy, then in this
 * folder run {@code mvn -q compile exec:java}. Set {@code GUARDAI_API_KEY} if the server runs with
 * auth. The German steps assume the NER sidecar is up so person and company names are detected.
 */
public final class Main {

    private static final String SUPPORT_POLICY = "german-support";

    public static void main(String[] args) {
        OGuardAIClient client = OGuardAIClient.builder()
                .baseUrl(env("GUARDAI_URL", "http://localhost:3000"))
                .apiKey(System.getenv("GUARDAI_API_KEY"))
                .build();

        germanSupportReply(client);
        englishCrmDraft(client);
        policyPreview(client);
        detectionOnlyScan(client);
        String documentSession = ragOverInternalDoc(client);
        sessionLifecycle(client, documentSession);
        gdprErasure(client);
    }

    /**
     * Step 1: A German customer writes in with their name, IBAN, and email. The whole conversation is
     * tokenized in one call, the support model drafts a formal ("Sie") reply against the tokens, and
     * we restore the real values only for the customer-facing channel. Gender and formality metadata
     * ride along so the restored reply stays grammatically correct in German.
     */
    private static void germanSupportReply(OGuardAIClient client) {
        System.out.println("== 1. German gender-aware support ==");
        List<Map<String, Object>> messages = new ArrayList<>();
        messages.add(message("system", "Du bist ein hoeflicher Support-Agent einer Bank. Antworte in der Sie-Form."));
        messages.add(message("user",
                "Guten Tag, ich bin Frau Erika Mustermann. Meine IBAN ist DE97 9900 0000 0123 4567 89 "
                        + "und meine E-Mail erika.mustermann@example.de. Wann kommt meine Rueckerstattung?"));

        GuardedChatResult chat = client.guardedChat(messages,
                new OGuardAIClient.GuardedChatOptions().policy(SUPPORT_POLICY).language("de"));

        String userSafe = "";
        for (Map<String, Object> m : chat.safeMessages) {
            if ("user".equals(m.get("role"))) {
                userSafe = String.valueOf(m.get("content"));
            }
        }
        System.out.println("  tokenized customer message -> " + userSafe);

        // Your support model replies using only the tokens it saw, echoed verbatim so they
        // round-trip; a fabricated id would resolve to nothing (fail closed).
        String modelReply = "Sehr geehrte " + firstToken(userSafe, "person")
                + ", Ihre Rueckerstattung auf das Konto " + firstToken(userSafe, "iban")
                + " ist in Bearbeitung.";
        String restored = client.rehydrateReply(modelReply, chat.sessionState, "user_output");
        System.out.println("  restored reply -> " + restored + "\n");
    }

    /**
     * Step 2: The CRM copilot drafts a short English follow-up. guardedCall wraps the whole
     * transform to rehydrate round-trip: the model only receives tokenized text, and it fails closed
     * (never runs the model) if the transform returns no session.
     */
    private static void englishCrmDraft(OGuardAIClient client) {
        System.out.println("== 2. English CRM copilot draft ==");
        String draft = client.guardedCall(
                Main::draftFollowUp,
                "Draft a note to Max Mustermann at max.mustermann@acme.co.uk about invoice 4471.",
                new OGuardAIClient.GuardedCallOptions().policy(SUPPORT_POLICY).language("en"));
        System.out.println("  restored draft -> " + draft + "\n");
    }

    /**
     * Step 3: Before anything is sent, preview what the policy would do with a set of entities. No
     * transformation happens, and the raw values are omitted from the request so this preview itself
     * never carries PII.
     */
    private static void policyPreview(OGuardAIClient client) {
        System.out.println("== 3. Policy preview (no PII in the request) ==");
        List<PolicyEntity> entities = Arrays.asList(
                new PolicyEntity("person"),
                new PolicyEntity("iban"),
                new PolicyEntity("email"));
        EvaluatePolicyResponse decisions = client.evaluatePolicy(
                new com.oronts.oguardai.EvaluatePolicyRequest()
                        .policy(SUPPORT_POLICY)
                        .entities(entities)
                        .context(new PolicyContext().callerRole("support_agent").callerPurpose("customer_reply")));
        for (PolicyDecision d : decisions.decisions) {
            System.out.println("  " + d.entityType + " -> " + d.action + " (restore: " + d.restoreMode + ")");
        }
        System.out.println();
    }

    /**
     * Step 4: A detection-only scan for a compliance dashboard. include_values=false keeps the raw
     * value out of the response, so the dashboard learns what kinds of PII appear without ingesting
     * the PII itself.
     */
    private static void detectionOnlyScan(OGuardAIClient client) {
        System.out.println("== 4. Detection-only scan, values suppressed ==");
        DetectResponse detected = client.detect(new DetectRequest(
                "Beschwerde von Herrn Max Mustermann, max.mustermann@example.de, Tel. +49 89 12345678.")
                .language("de")
                .includeValues(false));
        for (DetectedEntity e : detected.entities) {
            System.out.println("  found " + e.type + " (value withheld: '" + e.value + "')");
        }
        System.out.println();
    }

    /**
     * Step 5: RAG over an internal, classified document. The document is chunked and tokenized on
     * ingest; the vector store then only ever holds safe chunks. At query time the user question is
     * tokenized, the retrieved chunks are re-assembled into a safe context, the model answers against
     * tokens, and the final answer is rehydrated for the analyst.
     *
     * @return the document's ingest session_state, reused by step 6 to inspect and tear down.
     */
    private static String ragOverInternalDoc(OGuardAIClient client) {
        System.out.println("== 5. RAG over a classified internal document ==");
        String document =
                "Kunde: Erika Mustermann, IBAN DE97 9900 0000 0123 4567 89.\n\n"
                        + "Fall 4471: Rueckerstattung ueber 240 EUR wegen doppelter Abbuchung genehmigt.";

        RagIngestResponse ingest = client.ragIngest(new RagIngestRequest(document)
                .policy(SUPPORT_POLICY)
                .language("de")
                .corpusId("support-cases")
                .documentMetadata(new DocumentMetadata()
                        .documentId("case-4471")
                        .classification("confidential")
                        .owner("support")));
        System.out.println("  ingested " + ingest.chunks.size() + " safe chunk(s), "
                + ingest.totalEntities + " entities");

        // What the vector store would hold: the safe chunk text plus its sealed per-chunk session.
        List<String> retrievedSafeChunks = new ArrayList<>();
        List<String> chunkSessions = new ArrayList<>();
        // The vector store also has to hold each chunk's classification. Without it the context
        // stage has nothing to compare the caller's clearance against and drops the chunk as
        // most-restrictive (fail closed).
        List<String> chunkClassifications = new ArrayList<>();
        for (RagIngestResponse.Chunk c : ingest.chunks) {
            retrievedSafeChunks.add(c.safeText);
            chunkSessions.add(c.chunkSessionState);
            chunkClassifications.add(c.documentMetadata != null && c.documentMetadata.classification != null
                    ? c.documentMetadata.classification
                    : "restricted");
        }

        RagQueryResponse query = client.ragQuery(new RagQueryRequest("Wie viel bekommt Erika Mustermann zurueck?")
                .policy(SUPPORT_POLICY)
                .language("de")
                .corpusId("support-cases"));
        System.out.println("  tokenized query -> " + query.safeQuery);

        // Merge the retrieved chunks' token identities into the query session. access_level filters
        // out anything above the caller's clearance; here the analyst may see confidential material.
        RagContextResponse context = client.ragContext(new RagContextRequest(retrievedSafeChunks, query.sessionState)
                .policy(SUPPORT_POLICY)
                .language("de")
                .corpusId("support-cases")
                .documentSessions(chunkSessions)
                .chunkClassifications(chunkClassifications)
                .accessLevel("confidential"));
        System.out.println("  context chunks kept: " + context.safeChunks.size()
                + ", filtered: " + context.chunksFiltered);

        // Your model answers using only the tokens present in the safe context.
        String modelAnswer = context.safeChunks.isEmpty()
                ? "Keine Angaben verfuegbar."
                : "Auf Basis des Falls: " + context.safeChunks.get(0);
        RagAnswerResponse answer = client.ragAnswer(new RagAnswerRequest(modelAnswer, context.sessionState));
        System.out.println("  restored answer -> " + answer.restoredAnswer);
        System.out.println("  tokens resolved: " + answer.tokensResolved + "\n");

        return ingest.sessionState;
    }

    /**
     * Step 6: Inspect and then tear down the RAG session. On the sealed backend there is no
     * server-side copy, so delete reports {@code stateless}: the caller holds the only copy and
     * dropping the blob is the deletion.
     */
    private static void sessionLifecycle(OGuardAIClient client, String documentSession) {
        System.out.println("== 6. Session lifecycle ==");
        try {
            SessionStatusResponse status = client.sessionStatus(SessionLookupRequest.byState(documentSession));
            System.out.println("  status: " + status.status + " on backend " + status.backend);

            SessionDeleteResponse deleted = client.deleteSession(SessionDeleteRequest.byState(documentSession));
            System.out.println("  delete -> deleted=" + deleted.deleted + ", status=" + deleted.status);
        } catch (com.oronts.oguardai.OGuardAIException e) {
            // A missing or cross-tenant session is one non-disclosing 404; never a data leak.
            if (e.isSessionNotFound()) {
                System.out.println("  session not found (non-disclosing)");
            } else {
                throw e;
            }
        }
        System.out.println();
    }

    /**
     * Step 7: The customer exercises their right to erasure. Revoking the values makes every future
     * rehydration return {@code [DELETED]} for them; only an HMAC digest is stored server-side, never
     * the raw value.
     */
    private static void gdprErasure(OGuardAIClient client) {
        System.out.println("== 7. GDPR erasure ==");
        BulkRevokeResponse revoked = client.bulkRevoke(Arrays.asList(
                new RevokeRequest("email", "erika.mustermann@example.de"),
                new RevokeRequest("iban", "DE97 9900 0000 0123 4567 89")));
        System.out.println("  revoked " + revoked.revokedCount + " value(s); future rehydration returns [DELETED]");
    }

    /** Your CRM model. It only ever sees the tokenized text; this stub echoes it. */
    static String draftFollowUp(String safe) {
        return "Hello, following up regarding: " + safe;
    }

    /**
     * The first {@code {{type:id:cap}}} token of {@code type} in {@code safe}, verbatim. The cap is
     * load-bearing: a shortened {@code {{person:p_001}}} does not resolve on rehydrate.
     */
    static String firstToken(String safe, String type) {
        Matcher m = Pattern.compile("\\{\\{" + Pattern.quote(type) + ":[^}]+\\}\\}").matcher(safe);
        return m.find() ? m.group() : "";
    }

    static Map<String, Object> message(String role, String content) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("role", role);
        m.put("content", content);
        return m;
    }

    static String env(String key, String fallback) {
        String v = System.getenv(key);
        return (v != null && !v.isEmpty()) ? v : fallback;
    }

    private Main() {
    }
}

Expected output

Abbreviated. Token ids are random per run, the restore forms depend on the german-support policy, and person and company names appear only when the NER sidecar is running. The lines match the System.out.println calls in Main:

== 1. German gender-aware support ==
  tokenized customer message -> Guten Tag, ich bin Frau {{person:p_001:...}}. Meine IBAN ist {{iban:ib_001:...}} und meine E-Mail {{email:e_001:...}}. Wann kommt meine Rueckerstattung?
  restored reply -> Sehr geehrte <person>, Ihre Rueckerstattung auf das Konto <iban> ist in Bearbeitung.

== 2. English CRM copilot draft ==
  restored draft -> Hello, following up regarding: Draft a note to <person> at <email> about invoice 4471.

== 3. Policy preview (no PII in the request) ==
  person -> <action> (restore: <mode>)
  iban -> <action> (restore: <mode>)
  email -> <action> (restore: <mode>)

== 4. Detection-only scan, values suppressed ==
  found email (value withheld: '')
  found phone (value withheld: '')
  # person also appears when the NER sidecar is running

== 5. RAG over a classified internal document ==
  ingested <n> safe chunk(s), <n> entities
  tokenized query -> Wie viel bekommt {{person:p_001:...}} zurueck?
  context chunks kept: <n>, filtered: <n>
  restored answer -> Auf Basis des Falls: ...
  tokens resolved: <n>

== 6. Session lifecycle ==
  status: <status> on backend <backend>
  delete -> deleted=<bool>, status=stateless

== 7. GDPR erasure ==
  revoked 2 value(s); future rehydration returns [DELETED]

Scope

This release covers transform, rehydrate, file rehydrate (rehydrateFile, the JSON /v1/rehydrate/file route), detect, output guard, health, capabilities, evaluate-policy, batch transform and detect, revoke and bulk revoke, revocation count, the full RAG pipeline (ingest, ingest-batch, query, context, answer, delete), session status and delete, the guarded-call, guarded-chat, and rehydrate-reply helpers, a stateful session, and streaming. The multipart transform file upload, the image endpoints, and the remaining admin endpoints (diagnostics, metrics, policy validate and reload) are served by the HTTP API and the TypeScript and Python SDKs; they are not yet wrapped in the Java client.