Skip to content

feat(core): add checkpoint namespaces - #135

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

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

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.

2 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 checkpointNamespace. This means every with* method (e.g. withCheckPointId, withStreamMode, withCheckpointNamespace, updateMetadata) that internally calls RunnableConfig.builder(this) will silently drop the checkpoint namespace from the resulting config.

Add the missing line:

this.checkpointNamespace = config.checkpointNamespace;

* @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 this.threadId with checkpointNamespace instead of this.checkpointNamespace with checkpointNamespace. This means the method will incorrectly return this (skipping the update) when the thread ID happens to equal the requested namespace, and will always create a new object even when the namespace hasn't changed.

Compare with the sibling method withCheckPointId which correctly uses Objects.equals(this.checkPointId, checkPointId).

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

@unblocked-local-kaihao

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

Copy link
Copy Markdown

Risk Assessment

Risk: medium

withCheckpointNamespace compares the requested namespace with threadId instead of the current namespace, so it can incorrectly return the unchanged configuration. The documentation also promises namespace trimming and character restrictions that the implementation does not enforce.

@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.

1 issue 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.

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 passes the user-supplied checkpointNamespace directly to targetFolder::resolve with no validation. A malicious or accidental namespace value such as ../../etc or an absolute path like /tmp will resolve outside the intended targetFolder, allowing reads/writes to arbitrary locations on the filesystem.

The documentation (core-library.md) states "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but no such validation is actually enforced anywhere in the code.

Add validation either in BaseCheckpointSaver.checkpointNamespace(), in Builder.checkpointNamespace(), or at minimum in getNamespaceFolder itself. For example:

private static final Pattern SAFE_NAMESPACE = Pattern.compile("[A-Za-z0-9_-]+");

private Path getNamespaceFolder(RunnableConfig config) {
    return config.checkpointNamespace()
            .map(ns -> {
                if (!SAFE_NAMESPACE.matcher(ns).matches()) {
                    throw new IllegalArgumentException(
                        "checkpointNamespace must contain only letters, digits, hyphens, or underscores: " + ns);
                }
                return targetFolder.resolve(ns);
            })
            .orElse(targetFolder);
}

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