Skip to content

Make the Spring and LangChain4j integration work against the JamJet runtime - #1

Merged
sunilp merged 43 commits into
mainfrom
runtime-contract
Oct 11, 2026
Merged

sunilp merged 43 commits into
mainfrom
runtime-contract

Conversation

@sunilp

@sunilp sunilp commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

0.1.0 cannot do anything against the JamJet runtime except GET /health. JamjetRuntimeClient calls /api/v1/..., which the runtime has never served. Its SPA fallback answers 200 text/html, so the durability and audit advisors, the LangChain4j agent and the memory store log a warning and record nothing. The approval controller returns a raw 500 and the paused call times out to rejected. The durability advisor's IR is also invalid on the right route (400 ir.workflow_id is required).

What changes

  • Client: bare routes and the runtime's real field names, matching runtime/api/src/routes.rs and jamjet-runtime-java's JamjetEngineClient. Adds listExecutions, listApprovals and action_digest on approve. A non-JSON answer is now an error that names the runtime URL.
  • Durability advisor: registers one constant spring-ai-chat workflow (a valid IR, verified against the runtime), starts an execution per call before the model runs, and records the outcome as spring_ai.completed.
  • Fail-closed by default: if the runtime can't record a call, the call is refused (JamjetGovernanceException). spring.jamjet.on-runtime-error=fail-open opts out.
  • Approvals: the controller sends node_id and the reviewer's action_digest to POST /executions/{id}/approve when the runtime holds a pending request. Otherwise it records a spring_ai.approval_decision event. A refusal returns the runtime's status and message, and the paused call is released as rejected at once. /pending shows the digest. Only an explicit approved lets a call through.
  • LangChain4j: the memory store keeps one execution per memory id and reads back the latest chat_memory event. Before, it used the memory id as an execution id and read from the audit log. Both the store and the agent fail closed.
  • Replay testing module: ported to the runtime's event shape and snake_case kinds. JamjetTestContainer pointed at an image that does not exist; it now uses jamjetdev/jamjet:0.5.0 in dev mode.
  • CI: new workflow with SHA-pinned actions. It runs unit tests plus RuntimeContractIT against the real runtime image.

The version stays 0.2.0. It was never published, and the docs already advertise it. Nothing is published here.

Evidence

  • RED before each fix: client contract (9 of 10 failing on /api/v1), advisors (7 of 11), controller (7 of 7), approval advisor (4 of 4), wiring (1 of 3), LangChain4j (8 of 16).
  • Mutation checks, each after an unmutated passing control: 14 of 15 killed. The survivor drops the chat_memory_cleared check in the memory store. It is an equivalent mutant, because a clear event never carries messages_json.
  • RuntimeContractIT: 6 of 6 pass against a local jamjet-server 0.5.0 in dev mode. It covers an advised call's execution and events, an app-side approval round trip, a runtime-held approval decided with its digest (including a 409 refusal path), memory read back after a restart, and a recorded LangChain4j call. In CI, against the published jamjetdev/jamjet:0.5.0 image, 5 pass and the runtime-held approval test is skipped with a reason: that image does not hold a claimed agent tool dispatch for approval, so the test's precondition never appears.

Not covered

  • The model call runs in the app. The runtime holds the record but does not run or replay Spring AI calls, and the README now says so.
  • On origin/main, the runtime only creates pending approvals for agent tool dispatch nodes. A Spring-side approval is therefore recorded as an event, not as a runtime approval.

🤖 Generated with Claude Code

The advisors caught every runtime error, logged a warning and let the
call run. Combined with the wrong routes, that meant a deployment could
run with no record at all and nothing failed. A governance integration
that silently stops governing is the worst way for it to fail, so the
default becomes refusing the call, with fail-open as an explicit opt-in.
JamjetRuntimeClient posted to /api/v1/..., which the runtime has never
served. Its SPA fallback answered every call with 200 text/html, so
nothing was recorded and approvals failed. Paths now match
runtime/api/src/routes.rs and jamjet-runtime-java's JamjetEngineClient,
and the DTOs carry the runtime's field names (current_state, started_at,
kind.type, created_at, raw_event). Adds listExecutions, listApprovals and
action_digest on approve. Any non-JSON answer is now an error that names
the runtime URL, never data.

The LangChain4j module used the memory id as an execution id (the
runtime rejects it) and read memory from the audit log (which only holds
approvals). The store now keeps one execution per memory id and reads
the latest chat_memory event; both it and the durable agent register a
valid IR and fail closed by default. A wrapped service with a non-public
interface failed with IllegalAccessException; it is now invoked
accessibly.

The replay-testing module read the old event shape and PascalCase event
names, so it is ported to the runtime's snake_case kinds.

Tests pin the exact method, path and body of every call against a local
server that answers unknown routes the way the runtime does.
The durability advisor built an IR the runtime rejects (no workflow_id,
edges as a map, entry_node, an unknown node kind) and put the prompt in
it, so every call would have registered a different workflow. It now
registers one constant spring-ai-chat workflow (a wait node released by
the spring_ai.completed event), starts an execution per call before the
model runs, and records the outcome, including model failures.

The audit advisor records spring_ai.prompt and spring_ai.response. Both
advisors apply on-runtime-error, and the auto-configured customizer now
adds the audit and approval advisors, which were beans that no
ChatClient used.
JamjetApprovalController let a runtime error escape as a raw 500 before
releasing the waiting call, which then timed out to rejected. Decisions
now go to POST /executions/{id}/approve with the node_id and
action_digest the reviewer saw when the runtime holds a pending request
(the digest is required outside dev mode once approval-integrity lands),
or are recorded as a spring_ai.approval_decision event otherwise. A
refusal comes back with the runtime's status and message, and the call
is released as rejected at once. /pending shows the digest to echo.

The advisor now records the request, lets only an explicit "approved"
through, and rejects a call that needs approval but has no execution
instead of skipping the gate. The wait registry uses a future so a
decision that arrives before the waiter blocks is not lost.
RuntimeContractIT runs against jamjetdev/jamjet with Testcontainers (or
JAMJET_IT_RUNTIME_URL) and asserts what the runtime recorded: an advised
call's execution and events, an application-side approval round trip, a
runtime-held approval decided with its digest, chat memory read back
after a restart, and a recorded LangChain4j call. The old tests only
checked the one route that worked (/health) and the advisors swallowed
errors, so they could not see the mismatch.

JamjetTestContainer pointed at ghcr.io/jamjet-labs/jamjet:0.3.1, which
does not exist; it now runs jamjetdev/jamjet:0.5.0 in dev mode on the
image's port 8080. Adds a CI workflow with SHA-pinned actions.
The README promised crash recovery and graceful degradation, neither of
which the starter did. It now says what each advisor records, that calls
the runtime cannot record are refused by default, how approvals and the
action digest work, and how to run the runtime and the integration
tests. Drops the roadmap section.
@sunilp
sunilp requested a balanced review from Copilot October 10, 2026 15:37
@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f255e007-709b-4fec-a8a0-c546c13fd048

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The jamjetdev/jamjet 0.5.0 image does not hold a claimed agent tool
dispatch for approval, so the test's precondition never appears there
and CI failed after 20s. It still runs in full against a newer
jamjet-server (verified locally); on 0.5.0 it is reported as skipped
with the reason instead of failing on setup.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Digest enforcement, streaming failure recording, timeout auditing, and cross-instance memory uniqueness have unresolved correctness and governance issues.

7 open findings
What changed in this PR

Updates the Spring and LangChain4j integrations to use the JamJet runtime’s actual API contract, with fail-closed governance, approvals, durable memory, and runtime-backed integration testing.

Changes:

  • Reworks runtime routes, payload models, workflows, approvals, and error handling.
  • Adds durable LangChain4j agents and event-backed chat memory.
  • Updates replay tooling, documentation, examples, and CI integration tests.
File Description
README.md Documents runtime integration and governance behavior.
pom.xml Adds the integration-test profile.
langchain4j-jamjet/​src/​test/​java/​dev/​jamjet/​langchain4j/​StubRuntimeClient.java Expands the runtime test double.
langchain4j-jamjet/​src/​test/​java/​dev/​jamjet/​langchain4j/​JamjetDurableAgentTest.java Tests agent recording and error modes.
langchain4j-jamjet/​src/​test/​java/​dev/​jamjet/​langchain4j/​JamjetChatMemoryStoreTest.java Tests persistent chat memory behavior.
langchain4j-jamjet/​src/​main/​java/​dev/​jamjet/​langchain4j/​JamjetDurableAgent.java Records wrapped service calls as executions.
langchain4j-jamjet/​src/​main/​java/​dev/​jamjet/​langchain4j/​JamjetConfig.java Adds configured wrappers and error modes.
langchain4j-jamjet/​src/​main/​java/​dev/​jamjet/​langchain4j/​JamjetChatMemoryStore.java Stores chat memory in execution events.
langchain4j-jamjet/​src/​main/​java/​dev/​jamjet/​langchain4j/​Failures.java Centralizes LangChain4j failure handling.
jamjet-spring-boot-starter-test/​src/​test/​java/​dev/​jamjet/​spring/​test/​TestEvents.java Builds runtime-shaped test events.
jamjet-spring-boot-starter-test/​src/​test/​java/​dev/​jamjet/​spring/​test/​JamjetReplayExtensionTest.java Updates replay tests for runtime events.
jamjet-spring-boot-starter-test/​src/​test/​java/​dev/​jamjet/​spring/​test/​it/​RuntimeContractIT.java Adds real-runtime contract coverage.
jamjet-spring-boot-starter-test/​src/​test/​java/​dev/​jamjet/​spring/​test/​AgentAssertionsTest.java Updates assertion fixtures.
jamjet-spring-boot-starter-test/​src/​main/​java/​dev/​jamjet/​spring/​test/​RecordedExecution.java Parses current execution/event shapes.
jamjet-spring-boot-starter-test/​src/​main/​java/​dev/​jamjet/​spring/​test/​JamjetTestContainer.java Uses the published runtime container.
jamjet-spring-boot-starter-test/​src/​main/​java/​dev/​jamjet/​spring/​test/​annotations/​WithJamjetRuntime.java Shares container defaults.
jamjet-spring-boot-starter-test/​src/​main/​java/​dev/​jamjet/​spring/​test/​AgentAssertions.java Adapts assertions to snake-case events.
jamjet-spring-boot-starter-test/​pom.xml Adds integration-test dependencies.
jamjet-spring-boot-starter-example/​src/​main/​resources/​application.properties Documents fail-open configuration.
jamjet-spring-boot-starter-example/​src/​main/​java/​dev/​jamjet/​example/​DurableAgentApplication.java Updates runtime startup instructions.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​JamjetChatClientWiringTest.java Tests auto-configured advisors end to end.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​client/​RecordingRuntime.java Adds a recording HTTP test server.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​client/​JamjetRuntimeClientContractTest.java Pins the runtime HTTP contract.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​approval/​JamjetApprovalControllerTest.java Tests approval routing and failures.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​advisor/​StubChatModel.java Adds a counting model test double.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​advisor/​JamjetDurabilityAdvisorTest.java Tests execution lifecycle recording.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​advisor/​JamjetAuditAdvisorTest.java Tests prompt and response events.
jamjet-spring-boot-autoconfigure/​src/​test/​java/​dev/​jamjet/​spring/​advisor/​JamjetApprovalAdvisorFlowTest.java Tests approval gating flows.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​RuntimeErrorMode.java Defines fail-open and fail-closed modes.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​JamjetProperties.java Adds runtime-error and workflow settings.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​JamjetGovernanceException.java Adds the fail-closed exception.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​JamjetAutoConfiguration.java Registers all enabled advisors.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​RecordedCallWorkflow.java Builds valid recorded-call workflow IR.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​StartExecutionRequest.java Aligns execution request serialization.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​PendingApproval.java Models pending runtime approvals.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​ExecutionState.java Maps current execution fields.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​ExecutionEvent.java Maps tagged runtime events.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​AuditEntry.java Maps current audit entries.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​ApproveResponse.java Adds approval node information.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​ApprovalsView.java Models approval-list responses.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​model/​ApprovalDecision.java Adds node and digest bindings.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​JamjetRuntimeClient.java Switches to real runtime routes and contracts.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​client/​JamjetClientException.java Improves runtime error details.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​audit/​JamjetAuditService.java Clarifies audit-log responsibilities.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​approval/​JamjetApprovalController.java Records and forwards approval decisions.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​approval/​ApprovalWaitRegistry.java Prevents lost approval decisions.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​advisor/​RuntimeErrors.java Applies configured runtime-error policy.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​advisor/​JamjetDurabilityAdvisor.java Creates executions and records outcomes.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​advisor/​JamjetAuditAdvisor.java Records prompt and response events.
jamjet-spring-boot-autoconfigure/​src/​main/​java/​dev/​jamjet/​spring/​advisor/​JamjetApprovalAdvisor.java Enforces approval before model calls.
.github/​workflows/​ci.yml Runs unit and runtime integration tests.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 117 to 121
if (!"approved".equalsIgnoreCase(defaultDecision)) {
throw new ApprovalRejectedException(executionId,
"Approval timed out, default decision: rejected");
"Approval timed out, default decision: " + defaultDecision);
}
return request;
Comment on lines +127 to +130
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
if (!properties.isDurabilityEnabled()) {
return response;
if (properties.isDurabilityEnabled()) {
recordOutcome(executionIdOf(response.context()), success(response));
@JsonProperty("user_id") String userId,
@JsonProperty("comment") String comment,
@JsonProperty("state_patch") Map<String, Object> statePatch
@JsonProperty("action_digest") String actionDigest
Comment on lines +134 to +138
private synchronized String findOrStart(String memoryId) {
String existing = find(memoryId);
if (existing != null) {
return existing;
}
Comment on lines +217 to +220
private static Object toolNameOf(dev.jamjet.spring.client.model.ExecutionEvent e) {
Object tool = e.field("tool");
return tool != null ? tool : e.field("tool_name");
}
Comment thread README.md Outdated

## When the runtime is unavailable

By default a call the runtime cannot record is refused: the advisor throws `JamjetGovernanceException` with the runtime's error, and the model is not called. That covers a runtime that is down, a refusal (4xx/5xx), and a `runtime-url` that does not point at the runtime API (any non-JSON answer is an error, never data).
Comment thread README.md Outdated
- If the runtime holds a pending approval for the execution, the decision goes to `POST /executions/{id}/approve`. Send the `node_id` and the `action_digest` shown in `/pending` for the request you reviewed. Outside dev mode the runtime requires the digest to approve and refuses a decision made against a request that has since changed.
- Otherwise it is appended as a `spring_ai.approval_decision` event.

If the runtime refuses the decision or cannot be reached, the endpoint answers with the runtime's status (or 502) and message, and the waiting call is released at once as rejected. Only `"approved"` lets a call through; a timeout applies `default-decision`, and anything other than `approved` rejects. A call that requires approval but has no execution (durability off, or the runtime failed open) is rejected.
sunilp added 19 commits October 10, 2026 21:26
REQUIRES_APPROVAL_KEY was read only by the approval advisor. With
approval at its default (off), or with durability-enabled=false, which
switches off the whole auto-configuration, a call marked as needing
approval reached the model with no pause and no error.

JamjetApprovalGuard is registered by its own auto-configuration with no
property condition, runs first, and refuses a marked call, call or
stream, when no JamjetApprovalAdvisor is on the ChatClient.
The guard that rejects a call nobody answered survived mutation: with it
replaced by "always approve", every test still passed. Adds tests for
the default (rejected), an explicit approved default, and an
unrecognised default, which must reject.
With approval-integrity the runtime refuses a different IR under a
version it already holds, and in fail-closed mode a refused registration
refuses every call. Only a comment protected the IRs from an edit
without a version bump. Golden SHA-256 hashes of the canonical JSON of
all three IRs now fail with a message saying to bump
RecordedCallWorkflow.VERSION.
The runtime never reads X-Tenant-Id: the tenant comes from the API token,
or is "default" in dev mode. The property and the README ("Sent as
X-Tenant-Id") implied an isolation that did not exist. The header is no
longer sent, spring.jamjet.tenant-id and JamjetConfig.tenantId are
deprecated, and setting the property logs a warning at startup.
A failure to record the response or outcome threw "the call was
refused" after the model had run, and a failure to record a model error
replaced that error. Post-call record failures now throw
JamjetRecordingException, which says the call completed and only its
record is missing; when the model itself threw, the caller gets the
model's error with the recording failure attached as suppressed. Same in
the LangChain4j durable agent.

Streams went through Spring AI's default adviseStream: no outcome on
error or cancel, only the last chunk audited. In fail-closed mode a
stream is now refused before the model runs unless
spring.jamjet.allow-streaming=true; an allowed stream records the whole
text and a completed, failed or cancelled outcome. The README says which
failures happen before and after the model call.
The approval endpoints had no authentication: anyone who could reach
the app could list waiting calls, approve or reject them, and name any
approver in the body. Worse, for an execution the runtime held, the
controller forwarded the decision to POST /executions/{id}/approve with
the application's own bearer token. With approval-integrity the runtime
records the token as the approver and enforces separation of duties on
it, so every human decision would have been attributed to the service.

The runtime branch is gone: runtime-held requests are listed read-only
in /pending with the jamjet approve command to run with the reviewer's
own token. An app-side decision requires an authenticated principal
(401 otherwise, nothing released); the principal is the recorded
approver and the body cannot name one. The principal that triggered the
call, captured from the SecurityContext, cannot approve it (403). The
endpoints are registered only with Spring Security on the classpath, or
with spring.jamjet.approval.controller.allow-unauthenticated=true, which
logs a warning and records decisions as unauthenticated.
The controller checked that a call was waiting, then made network
calls, then recorded the decision, then released the call. A call that
timed out in between ran as rejected while the runtime recorded
"approved", and a second decision while the first was in flight
recorded a contradictory event that changed nothing.

A decision now claims the call atomically first. A second or late
decision gets 409 and records nothing. A call whose timeout fires while
a claimed decision is being recorded waits for that decision, so the
record and the call agree.
/pending showed only an execution id and a time, and with
include-prompts=false the prompt was recorded nowhere, so a reviewer
approved blind. Advisors ordered after the approval advisor could also
rewrite the prompt after approval.

/pending now shows the prompt (control and bidi characters escaped, cut
at 2000 characters) and a SHA-256 content digest over every message and
its role. A decision must echo the digest (400 without it, 409 when it
differs) and the digest is recorded with the decision. The approval
advisor now runs last before the model, so what the model receives is
what was approved.
Memory was keyed by (tenant, memory id), so two applications on one
runtime tenant read and wrote each other's memory, and LangChain4j's
default id "default" made that likely. A miss was never cached, so a new
conversation scanned the whole tenant twice, and the store-wide lock made
every new conversation in the process queue behind that scan.

The store now requires a namespace (JamjetConfig.memoryNamespace, the
application's name) and keys executions by (namespace, memory id). A
miss is cached for 30 seconds and locks are per memory id. The README
says the namespace is not access control and that one writer per id is
required, since two replicas starting the same id split its history.
…at startup

"500ms" parsed as "500m" minus a character and threw
NumberFormatException on the first call that needed approval. The
timeout now takes Spring Boot durations (500ms, 30s, 2h, PT30M), keeps a
bare number as minutes as before, and is parsed when the advisor is
created, so a bad value fails the application at startup.
The durable agent recorded every call's arguments and result with no
way to turn it off, unlike the Spring advisors' include-prompts and
include-responses. JamjetConfig.recordContent(false) (and a wrap
overload) now records only the method, the argument count, the outcome
and, on failure, the exception type. The default stays on.
Registration was cached for the life of the process. After a runtime
store reset every POST /executions answered 404, and in fail-closed
mode every call was refused until the application restarted. On a 404
the durability advisor, the LangChain4j agent and the memory store now
register their workflow again and retry once; a second 404 fails closed.
The README said every ChatClient call is recorded, but only clients
built from the auto-configured ChatClient.Builder get the advisors;
ChatClient.create(model) is silently unrecorded and ungated. It also
said dev mode keeps state in memory, which is true of the Docker image
(STORAGE_BACKEND=memory) but not of the jamjet-server binary, whose
SQLite file survives restarts.
GET /executions/{id}/approvals serves a projection that lags the write
path by up to one projector tick (~500 ms). On a runtime that had
already projected the execution, the runtime-held approval IT read the
request as still pending right after deciding it. It now re-reads for up
to 5 seconds before asserting.
When the grace for an in-flight decision ran out, the advisor applied
default-decision. With default-decision=approved and a reviewer's
"rejected" that took longer than the fixed two-minute grace to record,
the runtime recorded the rejection while the call ran approved.

A timed-out call is now always rejected: default-decision is deprecated
and any value other than rejected fails at startup. The grace for an
in-flight decision is derived from the client's connect and read
timeouts, so recording cannot outlive it. If a decision is recorded but
the call stopped waiting anyway (interrupted, or past the grace), the
controller answers 409 and appends spring_ai.approval_decision_void.
The controller accepted any non-null Principal. On the servlet stack
Spring Security hides anonymous principals, but on WebFlux an anonymous
Authentication reaches the handler, and a probe showed it approving as
"anonymousUser". A remember-me login also approved.

A reviewer must now be an authenticated, non-anonymous Authentication;
a remember-me login is refused unless
spring.jamjet.approval.allow-remember-me=true. Ported from the
re-review's probe P5.
GET /jamjet/approvals/pending needed no principal. With Spring Security
on the classpath but no filter chain (or a permitAll chain), POST was
closed but anyone could list waiting calls with their prompts, their
initiators' names and the runtime's request context. The list now
applies the same reviewer rules as a decision. Ported from probe P6.
The controller only checked that a principal was present, so in an app
where customers log in, any of them could approve a paused call; the
role check lived only in a README example. The controller now requires
spring.jamjet.approval.required-authority (default
ROLE_JAMJET_APPROVER) for GET /pending and for decisions, and answers
403 without it. A principal whose authorities cannot be read (not a
Spring Security Authentication) is refused. allow-unauthenticated
remains the only way past it, for local development.
The initiator was read from the thread-local SecurityContext inside
before(), which on a stream runs after Spring AI's hop to another
thread. A probe showed carol's own streamed call with initiator=null,
and carol approving it. The same loss happens with @async calls and on
WebFlux, and nothing in the record said the initiator was unknown.

The guard, first in the chain, and the approval advisor's adviseStream
now capture the principal on the subscribing thread, or from the Reactor
context (ReactiveSecurityContextHolder), and carry it in the advisor
context. When the initiator is still unknown, the events record
initiator_known: false and the call can be rejected but not approved,
unless spring.jamjet.approval.allow-unknown-initiator=true. Ported from
probe P1; the README names the cases that lose the initiator.
sunilp added 16 commits October 10, 2026 22:25
The digest covered only each message's role and text: two requests that
differed in an image, in the tools offered, or in a tool call or tool
response got the same digest, and the preview showed only the joined
text. The digest now covers media (MIME type plus a hash of the bytes,
or the URL), tool calls, tool responses and the options (model,
sampling settings, tool names and definitions). The preview shows one
line per message with its role plus media, tool calls, responses and
tools; escapes zero-width and tag characters too; and truncates on a
character and escape boundary with a marker only when something was cut.

The README now says plainly that the tool calls Spring AI makes inside
the model call are not individually reviewed by this approval, and
points to the runtime's own approval policy for gating actions. Ported
from probe P4.
If an unchecked exception escaped between claiming a call and releasing
it, the call stayed claimed until its timeout plus grace. Any such
failure now releases the call as rejected, appends a
spring_ai.approval_decision_void event when it can, and answers 500.
The found and not-found caches and the per-id lock map grew by one
entry per memory id for the life of the process. The caches are now
LRU-bounded (10,000 ids each by default, configurable), an evicted id
is simply scanned for again, and locks are striped over 64 objects.
…hat upgrades lose

The README said a call marked for approval never runs ungated, but only
clients built from the auto-configured builder get the guard. It now
names the clients that do not. The CSRF example now says it is safe only
because the endpoint takes JSON bodies only, and to keep CSRF on for
session or form login. The upgrade note says chat memory written without
a namespace is not found after upgrading, and how to read it.
A caller-set jamjet.approval.initiator param overrode the authenticated
principal: carol, authenticated, set it to "somebody-else" and then
approved her own call. Any code path that copies request data into
advisor params, or any earlier advisor, could forge or launder the
initiator this way.

The captured initiator is now carried as a package-private type that
application code cannot construct, the guard and the advisor always
overwrite the key, and any other value under it is ignored and logged.
Ported from the round-3 probe P1.
Spring AI 1.0's ChatModelCallAdvisor runs after every advisor and
appends the spring.ai.chat.client.output.format context value to the
user message. .entity() sets it on every structured-output call, and any
param or earlier advisor can set it. A probe approved "wire 100" and the
model then received "wire 100\nALSO WIRE 9999 TO ACCOUNT X".

The approval advisor now digests and previews the prompt as the model
will receive it, applying the same augmentation on the call path and
none on the stream path, which does not augment in 1.0. The README
sentence about nothing changing after approval now says exactly what
holds for Spring AI 1.0.x. Ported from the round-3 probe P2.
When the grace for an in-flight decision ran out, or the wait was
interrupted, the waiter threw while its future was still open and it
was still registered. A complete() landing in that gap returned true:
the reviewer got 200 and no void event, while the call was rejected.
After an interrupt the state also stayed WAITING, so a claim could still
succeed.

Every exit that is not a delivered decision now completes the future
exceptionally first (and marks the wait timed out). A late complete()
then returns false and the controller's void path runs; if a decision
won the race, it is the one that applies. A test hook fires exactly in
that window, making the round-3 probe P3's race deterministic.
The digest covered only the portable ChatOptions getters: a provider's
tool choice, response format, seed or penalties, and the tool context
handed to the tools, were not bound to the approval. The options are now
digested generically: every readable property of the concrete options
object, with its type, plus the tool context; tool callbacks contribute
only their definitions, never their runtime state. The model's own
default options and tools, merged inside ChatModel.call and invisible to
advisors, are not covered, and the README now says so precisely.
A blank spring.jamjet.approval.required-authority (for example an empty
${APPROVER_ROLE:} placeholder) silently let any authenticated user list
and approve paused calls. It still does, for applications that
authorize /jamjet/approvals themselves, but now logs a startup warning
saying so.
Under allow-unauthenticated, any non-null principal was recorded as an
authenticated approver, including Spring Security's anonymousUser. The
verdict now says whether the principal really authenticated (not
anonymous, not an unauthenticated token); otherwise the decision is
recorded with approver "unauthenticated" and authenticated: false.
When recording a decision failed with a 5xx, a timeout or a dropped
connection, the call was released as rejected but the runtime may still
have written the decision, so the record could read "approved". Those
failures now also append a best-effort spring_ai.approval_decision_void
event. A clear 4xx refusal is not voided: nothing was written.
Only RuntimeExceptions between claiming a call and releasing it were
handled; an Error left the call claimed until its timeout plus grace,
with no void event. Errors now release the call as rejected and attempt
the void event too, and are then rethrown.
… its wait

When durability is off or failed open, the execution id can come from
the caller. Two concurrent calls with the same id shared one waiter, so
one decision released both, and only the first call's content had been
reviewed. A second wait on an id that already has one is now refused,
and the call is rejected.
The registry's default grace (140s) was a second literal copy of the
value the advisor derives from the client timeouts (10 + 120 + 10). The
derivation now lives in ApprovalWaitRegistry and the default is computed
from default properties, so the two cannot drift.
The README described allow-unauthenticated only as "without Spring
Security". With Spring Security present it also skips the authority,
anonymous and remember-me checks. The README and the property table now
say so, and how such decisions are recorded.
Two memory ids that share a lock stripe serialize, so a new conversation
can wait behind an unrelated one's linear GET /executions scan or its
POST /executions. The store's javadoc now says so, as the price of
bounded memory.
The Micrometer and OTel bridges are beans that no advisor calls, so the
README now shows them as helpers to call yourself. The replay example
used @WithJamjetRuntime, whose empty container cannot hold an execution
recorded elsewhere; it now uses JamjetReplayExtension with
spring.jamjet.runtime-url.
@sunilp
sunilp merged commit aeffced into main Oct 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants