Repository navigation
Make the Spring and LangChain4j integration work against the JamJet runtime - #1
Conversation
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.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
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. Comment |
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.
There was a problem hiding this comment.
🟡 Changes recommended
Digest enforcement, streaming failure recording, timeout auditing, and cross-instance memory uniqueness have unresolved correctness and governance issues.
7 open findings
Timeout decisions are released without runtime recording · New Streaming failures leave executions running indefinitely · New Action digest is not enforced by the advertised runtime · New Execution creation is not atomic across multiple stores · New Agent tool events are misclassified without their agent URI · New Recording failures do not always mean the model was not called · New Disabling durability also disables the approval gate · New
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.
| if (!"approved".equalsIgnoreCase(defaultDecision)) { | ||
| throw new ApprovalRejectedException(executionId, | ||
| "Approval timed out, default decision: rejected"); | ||
| "Approval timed out, default decision: " + defaultDecision); | ||
| } | ||
| return request; |
| @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 |
| private synchronized String findOrStart(String memoryId) { | ||
| String existing = find(memoryId); | ||
| if (existing != null) { | ||
| return existing; | ||
| } |
| private static Object toolNameOf(dev.jamjet.spring.client.model.ExecutionEvent e) { | ||
| Object tool = e.field("tool"); | ||
| return tool != null ? tool : e.field("tool_name"); | ||
| } |
|
|
||
| ## 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). |
| - 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. |
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.
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.



0.1.0 cannot do anything against the JamJet runtime except
GET /health.JamjetRuntimeClientcalls/api/v1/..., which the runtime has never served. Its SPA fallback answers200 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 torejected. The durability advisor's IR is also invalid on the right route (400 ir.workflow_id is required).What changes
runtime/api/src/routes.rsand jamjet-runtime-java'sJamjetEngineClient. AddslistExecutions,listApprovalsandaction_digeston approve. A non-JSON answer is now an error that names the runtime URL.spring-ai-chatworkflow (a valid IR, verified against the runtime), starts an execution per call before the model runs, and records the outcome asspring_ai.completed.JamjetGovernanceException).spring.jamjet.on-runtime-error=fail-openopts out.node_idand the reviewer'saction_digesttoPOST /executions/{id}/approvewhen the runtime holds a pending request. Otherwise it records aspring_ai.approval_decisionevent. A refusal returns the runtime's status and message, and the paused call is released as rejected at once./pendingshows the digest. Only an explicitapprovedlets a call through.chat_memoryevent. Before, it used the memory id as an execution id and read from the audit log. Both the store and the agent fail closed.JamjetTestContainerpointed at an image that does not exist; it now usesjamjetdev/jamjet:0.5.0in dev mode.RuntimeContractITagainst 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
/api/v1), advisors (7 of 11), controller (7 of 7), approval advisor (4 of 4), wiring (1 of 3), LangChain4j (8 of 16).chat_memory_clearedcheck in the memory store. It is an equivalent mutant, because a clear event never carriesmessages_json.RuntimeContractIT: 6 of 6 pass against a localjamjet-server0.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 publishedjamjetdev/jamjet:0.5.0image, 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
🤖 Generated with Claude Code