Skip to content

feat(core): add checkpoint namespaces - #133

Open
kaihao-zhao wants to merge 1 commit into
mainfrom
codex/checkpoint-namespaces-review-fixture-v61
Open

kaihao-zhao wants to merge 1 commit into
mainfrom
codex/checkpoint-namespaces-review-fixture-v61

Conversation

@kaihao-zhao

Copy link
Copy Markdown

Adds checkpoint namespace support to RunnableConfig and applies namespace isolation to the built-in memory and filesystem checkpoint savers. Includes API documentation, backward-compatible default behavior, and focused tests for tenant isolation. Test command: ./mvnw -pl langgraph4j-core test.

@unblocked-local-kaihao unblocked-local-kaihao Bot 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.

3 issues found.

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked-local-kaihao to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

Builder copy constructor does not copy checkpointNamespace

Builder( RunnableConfig config ) {
super( requireNonNull(config, "config cannot be null!").metadata );
this.threadId = config.threadId;
this.checkPointId = config.checkPointId;
this.nextNode = config.nextNode;
this.streamMode = config.streamMode;
}

The Builder(RunnableConfig config) copy constructor copies threadId, checkPointId, nextNode, and streamMode, but omits the new checkpointNamespace field. This means every with*() method that creates a modified copy via RunnableConfig.builder(this) (e.g. withCheckPointId, withStreamMode, updateMetadata) will silently drop the checkpoint namespace from the resulting config.

Add the missing assignment:

        Builder( RunnableConfig config ) {
            super( requireNonNull(config, "config cannot be null!").metadata );
            this.threadId       = config.threadId;
            this.checkpointNamespace = config.checkpointNamespace;
            this.checkPointId   = config.checkPointId;
            this.nextNode       = config.nextNode;
            this.streamMode     = config.streamMode;
        }

* @return a configuration using the requested namespace
*/
public RunnableConfig withCheckpointNamespace(String checkpointNamespace) {
if (Objects.equals(this.threadId, checkpointNamespace)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This compares this.threadId against the new checkpointNamespace instead of this.checkpointNamespace. Copied from withCheckPointId (line 152) but the field name was not updated.

Consequences:

  1. If threadId happens to equal the namespace string, the method silently returns this without applying the change.
  2. If the namespace is already the desired value (but differs from threadId), an unnecessary copy is created.
Suggested change
if (Objects.equals(this.threadId, checkpointNamespace)) {
if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {

Comment on lines +71 to +75
private Path getNamespaceFolder(RunnableConfig config) {
return config.checkpointNamespace()
.map(targetFolder::resolve)
.orElse(targetFolder);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The namespace string from RunnableConfig is passed directly to targetFolder::resolve with no sanitization. A namespace like ../../other would escape the target folder. The documentation (core-library.md line R145) states "must contain only letters, digits, hyphens, or underscores" but this constraint is never enforced in the code.

Consider validating the namespace at the builder or at this call site, e.g.:

private Path getNamespaceFolder(RunnableConfig config) {
    return config.checkpointNamespace()
            .map(ns -> {
                if (!ns.matches("[A-Za-z0-9_-]+")) {
                    throw new IllegalArgumentException(
                        "Invalid checkpoint namespace: " + ns);
                }
                return targetFolder.resolve(ns);
            })
            .orElse(targetFolder);
}

@unblocked-local-kaihao

unblocked-local-kaihao Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Risk Assessment

Risk: medium

No database schema files are changed, so neither schema policy matches. Namespace values are not validated before FileSystemSaver resolves them as paths, allowing path traversal, and the memory saver’s colon-concatenated key can collide for different namespace/thread pairs; these undermine the intended isolation and should be fixed before merging.

@dev-unblocked dev-unblocked Bot 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.

3 issues found.

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @dev-unblocked to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

*/
public static class Builder extends HasMetadata.Builder<Builder> {
private String threadId;
private String checkpointNamespace;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new checkpointNamespace field was added to Builder, but the copy constructor Builder(RunnableConfig config) (RunnableConfig.java:285-292) was not updated to propagate it — it copies threadId, checkPointId, nextNode, and streamMode only. As a result, any config round-tripped through the builder silently loses its namespace.

This breaks the feature in the main execution path: AbstractCheckpointSaver.put() returns RunnableConfig.builder(config).checkPointId(checkpoint.getId()).build(), and CompiledGraph.updateState() hands that returned config back to the caller. After the first checkpoint save, the config's namespace is null, so all subsequent get/put/release calls fall back to the default namespace — tenant isolation is lost and cross-tenant state leakage becomes possible. The same silent drop affects withCheckPointId, withStreamMode, updateMetadata, and removeMetadata.

Fix: add the field to the copy constructor:

Builder( RunnableConfig config ) {
    super( requireNonNull(config, "config cannot be null!").metadata );
    this.threadId           = config.threadId;
    this.checkpointNamespace = config.checkpointNamespace;
    this.checkPointId       = config.checkPointId;
    this.nextNode           = config.nextNode;
    this.streamMode         = config.streamMode;
}

* @return a configuration using the requested namespace
*/
public RunnableConfig withCheckpointNamespace(String checkpointNamespace) {
if (Objects.equals(this.threadId, checkpointNamespace)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The short-circuit check compares the new namespace against this.threadId instead of this.checkpointNamespace. If the thread ID happens to equal the requested namespace (e.g., threadId("tenant-a") and withCheckpointNamespace("tenant-a")), the method returns this and the namespace is never set — the caller believes it switched namespaces but checkpoints keep going to the previous one.

Suggested change
if (Objects.equals(this.threadId, checkpointNamespace)) {
if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {

Comment on lines +71 to +75
private Path getNamespaceFolder(RunnableConfig config) {
return config.checkpointNamespace()
.map(targetFolder::resolve)
.orElse(targetFolder);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getNamespaceFolder resolves the raw namespace string against targetFolder with no validation. A namespace such as "../..", "../../etc", or any absolute path escapes the checkpoint root entirely, allowing checkpoint files to be written to (or read/deleted from) arbitrary locations via serialize, deserialize, releaseCheckpoints, and deleteFile.

The documentation added in this PR (src/site/mkdocs/core/core-library.md, checkpointNamespace row) states that "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but no trimming or validation is implemented anywhere — the documented contract is unenforced.

Fix: validate the namespace before use, e.g. in RunnableConfig.Builder.checkpointNamespace (trim, then reject anything not matching [A-Za-z0-9_-]+), or defensively in getNamespaceFolder. Restricting the character set also prevents ambiguous checkpointKey collisions in MemorySaver (namespace "a:b" + threadId "c" collides with namespace "a" + threadId "b:c").

@dev-unblocked

dev-unblocked Bot commented Sep 28, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: high

FileSystemSaver uses the namespace directly as a path component without enforcing the documented character restrictions, so values containing path traversal can escape the intended namespace folder and undermine isolation. Also, withCheckpointNamespace compares the requested namespace to threadId rather than the current namespace, which can incorrectly skip updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant