Skip to content

feat(core): add checkpoint namespaces - #142

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

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

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.

Comment on lines +167 to +174
public RunnableConfig withCheckpointNamespace(String checkpointNamespace) {
if (Objects.equals(this.threadId, checkpointNamespace)) {
return this;
}
return RunnableConfig.builder(this)
.checkpointNamespace(checkpointNamespace)
.build();
}

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 early-return guard compares the wrong field: this.threadId is compared against the new checkpointNamespace instead of this.checkpointNamespace. This causes two correctness problems:

  1. When the existing threadId happens to equal the requested namespace, the method returns this without changing the namespace at all (silent no-op).
  2. When the namespace is already set to the requested value but differs from the threadId, the method needlessly builds a new instance instead of returning this.

This mirrors the correct pattern in withCheckPointId (line 152), which compares this.checkPointId against the argument. The field should be this.checkpointNamespace.

    public RunnableConfig withCheckpointNamespace(String checkpointNamespace) {
        if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {
            return this;
        }
        return RunnableConfig.builder(this)
                .checkpointNamespace(checkpointNamespace)
                .build();
    }

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 raw checkpointNamespace directly into targetFolder::resolve. A namespace containing path separators (e.g. ../sensitive or /etc) resolves to a location outside targetFolder, and serialize (line 90) then calls Files.createDirectories(outFilePath.getParent()) and writes the checkpoint file there. Because releaseCheckpoints lists and writes backup files in that same folder, an attacker-controlled namespace can read/write arbitrary paths reachable by the process.

The API documentation added in this PR (core-library.md) explicitly promises that "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores," but no validation or trimming exists anywhere in the code path. Either the documented contract must be enforced (reject/sanitize names containing /, \, .., or non-allowed characters before resolving), or the docs should be corrected. At minimum, normalize the namespace and verify the resolved path stays within targetFolder:

private Path getNamespaceFolder(RunnableConfig config) {
    var ns = config.checkpointNamespace().map(String::trim).orElse(null);
    if (ns == null || ns.isEmpty() || !ns.matches("[A-Za-z0-9_-]+")) {
        return targetFolder;
    }
    var folder = targetFolder.resolve(ns).normalize();
    if (!folder.startsWith(targetFolder.normalize())) {
        throw new IllegalArgumentException("Invalid checkpoint namespace: " + ns);
    }
    return folder;
}

@unblocked-local-kaihao

Copy link
Copy Markdown

Risk Assessment

Risk: high

FileSystemSaver uses the configured namespace directly as a filesystem path without the trimming or validation claimed by the documentation, so path-like namespace values can escape the target folder and undermine isolation. Also, withCheckpointNamespace compares the requested namespace to threadId, which can incorrectly prevent an update.

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

9 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) was not updated — it copies only threadId, checkPointId, nextNode, and streamMode. As a result, every config copy made through that constructor silently loses the namespace. This affects updateMetadata, removeMetadata, withCheckPointId, and withStreamMode.

Critically, CompiledGraph reassigns the config on every node step via config = updateRunnableConfigMetadata(config, ...) (CompiledGraph.java:953), which goes through updateMetadata → new RunnableConfig.Builder(this). So during a normal invoke/stream run with a namespaced config, the namespace is dropped before the first checkpoint is saved (addCheckpoint → saver.put(config, cp)), and all checkpoints land in the default namespace — tenant isolation silently fails in the primary execution path, and the new tests never exercise a full graph run so they don't catch it.

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 guard compares the wrong field: this.threadId against the new namespace instead of this.checkpointNamespace. Whenever the thread ID happens to equal the requested namespace (e.g. config.withCheckpointNamespace("conversation-42") on a config whose threadId is "conversation-42"), the method returns this and the namespace is silently not applied — checkpoints continue in the old namespace. It also rebuilds a new instance even when the namespace is already equal. Compare the field being replaced, as withCheckPointId does:

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 feeds the raw, unvalidated namespace string into targetFolder::resolve. A namespace containing .. segments (e.g. "../../shared") or an absolute path resolves outside targetFolder, so serialize (via the new Files.createDirectories) will create directories and write checkpoint files anywhere the process can write, and deleteFile can delete files outside the checkpoint root. Nothing in Builder.checkpointNamespace trims or validates the value (the docs in core-library.md even claim such validation exists — see the separate doc comment). Validate the namespace before using it as a path segment, e.g. reject anything not matching [A-Za-z0-9_-]+ in the builder (or at least here), which also matches the documented contract.

Comment on lines +24 to +25
final var checkpointKey = checkpointKey(config);
return new Tag( threadId(config), _checkpointsByThread.remove( checkpointKey ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MemorySaver now namespaces its map via checkpointKey, and VersionedMemorySaver delegates get/list/put/release to it — so those paths became tenant-isolated by this change. But VersionedMemorySaver.release (VersionedMemorySaver.java:178) records the released Tag into _checkpointsHistoryByThread keyed by the raw threadId only. Two tenants using the same thread ID in different namespaces therefore share one version history: tenant A's released checkpoints become visible in tenant B's history via getCheckpointsByVersion/versionsByThreadId (studio time-travel support). The class is now internally inconsistent — current checkpoints isolated, released history not. Key the history by checkpointKey(config) as well.

Comment on lines +48 to +49
default String checkpointKey(RunnableConfig config) {
return "%s:%s".formatted(checkpointNamespace(config), threadId(config));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

checkpointKey joins namespace and thread ID with a plain ":" into a flat string key. Since neither component is restricted from containing :, distinct pairs collide: namespace "a" + threadId "b:c" yields the same key "a:b:c" as namespace "a:b" + threadId "c" (and the default namespace "a" with threadId "$default:b" collides with namespace "$default" + threadId "a:b"-style combinations). In MemorySaver a collision means one tenant reads or removes another tenant's checkpoint list. Use a separator that cannot appear in either component (e.g. URL-encode the parts, or key the map by a namespace/threadId record).

Comment on lines +72 to +74
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 reads config.checkpointNamespace() directly instead of the interface's checkpointNamespace(config) default, which maps an absent namespace to CHECKPOINT_NAMESPACE_DEFAULT ("$default"). So an omitted namespace stores at targetFolder/ root (backward compatible), but a config that explicitly sets .checkpointNamespace("$default") — using the public constant this same PR introduces — stores under targetFolder/$default/. The two configs are the same logical namespace per BaseCheckpointSaver.checkpointNamespace, yet FileSystemSaver puts them in two different physical locations, and MemorySaver (which does use checkpointKey) treats them identically. Either treat the explicit default the same as omitted (map "$default" to targetFolder), or document that the constant must not be set explicitly.

/**
* Returns the namespace used to partition checkpoints.
*
* @return the configured namespace, or {@code $default} when no namespace is set

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 accessor's contract says "@return the configured namespace, or {@code $default} when no namespace is set", but the implementation returns ofNullable(checkpointNamespace) — an empty Optional when unset. The $default fallback only exists on BaseCheckpointSaver.checkpointNamespace(config). Correct the javadoc to match the actual shape:

Suggested change
* @return the configured namespace, or {@code $default} when no namespace is set
* @return the configured namespace, or an empty {@code Optional} when no namespace is set

| Attribute | Type | Description |
| --------- | ---- | ----------- |
| **threadId** | `String` | A unique identifier for the execution thread/session. Essential for checkpoint-based persistence, as it groups related executions together. Allows resuming interrupted graphs or maintaining conversation history. |
| **checkpointNamespace** | `String` | Optional partition for checkpoint data. Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores. |

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 table row states "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but Builder.checkpointNamespace (RunnableConfig.java:314) assigns the raw string with no trimming and no charset check, and no saver validates it either. The doc promises a guarantee the code does not provide — and the missing validation is what lets a namespace like "../.." escape the checkpoint folder in FileSystemSaver (see the security comment there). Either implement the documented validation in the builder or remove the claim from the docs.

@Override
protected Tag releaseCheckpoints(RunnableConfig config, LinkedList<Checkpoint> checkpoints) throws Exception {
final var currentPath = getPath(config);
final var namesapceFolder = getNamespaceFolder(config);

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 local variable is spelled namesapceFolder (transposed letters). It is used in three places below; rename to namespaceFolder for consistency with getNamespaceFolder.

Suggested change
final var namesapceFolder = getNamespaceFolder(config);
final var namespaceFolder = getNamespaceFolder(config);

@dev-unblocked

dev-unblocked Bot commented Oct 1, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: high

checkpointNamespace is documented as restricted, but the implementation does not validate it before FileSystemSaver resolves it as a directory under the target folder. A namespace containing path traversal segments can escape that folder and undermine the intended checkpoint isolation.

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