Skip to content

feat(core): add checkpoint namespaces - #139

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

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

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.

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

* @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 reads this.threadId instead of this.checkpointNamespace. When 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 unchanged, so the namespace is silently not applied and checkpoints keep landing in the old namespace. The existing test in RunnableConfigTest misses this because its threadId ("conversation-42") differs from the namespace ("tenant-globex").

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

/**
* 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 @return says the method returns $default when no namespace is set, but the implementation returns ofNullable(checkpointNamespace) — i.e. an empty Optional. The $default substitution only happens later in BaseCheckpointSaver.checkpointNamespace(RunnableConfig). A caller trusting this javadoc would expect a non-empty Optional and could skip its own default handling.

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

* @return namespace-qualified thread key
*/
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 threadId with ":", but threadIds are never validated and may themselves contain ":". Two distinct (namespace, threadId) pairs then produce the same key: namespace "a" + threadId "b:c" and namespace "a:b" + threadId "c" both yield "a:b:c", so MemorySaver returns one tenant's checkpoints to the other. Use a separator that cannot appear in either component, or escape/validate the inputs before joining (the documented namespace charset — letters, digits, hyphens, underscores — would work if it were actually enforced, see the core-library.md comment).

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 stores checkpoints under the namespace-qualified checkpointKey, but VersionedMemorySaver (which delegates to a MemorySaver) keys _checkpointsHistoryByThread by bare threadId in its release() (VersionedMemorySaver.java:175) and in versionsByThreadId/lastVersionByThreadId/getCheckpointsByVersion. Two tenants sharing a threadId now get isolated live checkpoints but their released Tags are interleaved into the same version history — versionsByThreadId and getCheckpointsByVersion hand one tenant the other tenant's checkpoint history. Since this PR's stated goal is namespace isolation for the built-in memory savers, VersionedMemorySaver should key its history by checkpointKey(config) too (and HasVersions callers should pass the namespace along).

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 under targetFolder with no validation or normalization. A namespace like "../evil" (or any value containing / or \) makes getPath/getFile point outside the configured checkpoint root, so put writes and deleteFile deletes arbitrary files reachable from the process. Even without hostile intent, namespaces containing characters that are illegal in path segments (:, ?, * on Windows) make every save/load fail. The docs promise trimming and a [A-Za-z0-9_-] charset (see the core-library.md comment) — that validation should actually be enforced here (e.g. in RunnableConfig.Builder.checkpointNamespace or in getNamespaceFolder), rejecting .., path separators, and other illegal characters before resolving.

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 branches on the raw Optional rather than the defaulted value, so an explicitly configured checkpointNamespace("$default") resolves to targetFolder/$default, while omitting the namespace resolves to targetFolder itself. MemorySaver treats both identically (both become the key "$default:<threadId>" via checkpointKey). The same logical namespace therefore isolates differently depending on which saver is used. Either document that "$default" is a reserved value that must not be set explicitly, or normalize an explicit "$default" to the absent-namespace behavior consistently in both savers.

@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 local variable namesapceFolder is misspelled (namespace). Rename it here and at its four usages (lines 157, 166, 172) so the code matches the terminology used everywhere else in this change.

| 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 table states "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but RunnableConfig.Builder.checkpointNamespace() (RunnableConfig.java:314-315) assigns the value as-is — no trimming, no charset check, no rejection. Today the contradiction is not cosmetic: FileSystemSaver uses the value directly as a folder name, so the promised guarantee is exactly what would prevent path traversal and invalid-path failures (see the FileSystemSaver comment). Either implement the validation in the builder (and keep the doc) or reword the doc to state that values are used verbatim.

Comment on lines +38 to +40
default String checkpointNamespace(RunnableConfig config) {
return config.checkpointNamespace().orElse(CHECKPOINT_NAMESPACE_DEFAULT);
}

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 PR introduces checkpointNamespace as a tenant-partition mechanism, but only MemorySaver and FileSystemSaver were updated to use it. All other BaseCheckpointSaver implementations — PostgresSaver, RedisSaver, DynamoDBSaver, OracleSaver, AbstractMysqlServer/MysqlSaver, CockroachDBSaver, HazelcastSaver, and core's own VersionedMemorySaver (whose _checkpointsHistoryByThread is keyed by raw threadId) — still key storage solely by threadId. A deployment that sets checkpointNamespace("tenant-a") on a config and uses, say, PostgresSaver gets zero isolation with no error or warning: two tenants sharing a threadId read and overwrite each other's persisted state, the exact cross-tenant leak the feature advertises. At minimum, the other savers should incorporate checkpointKey(config) (or an equivalent namespace-qualified column/field), or the API should fail loudly when a namespace is set on a saver that ignores it.

@dev-unblocked

dev-unblocked Bot commented Oct 1, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: medium

The PR does not change database schema, so neither schema policy matches. withCheckpointNamespace compares the requested namespace to threadId instead of the current namespace, which can silently return the wrong configuration; additionally, filesystem namespace values are used as paths without the validation claimed in the documentation.

@kaihao-zhao
kaihao-zhao marked this pull request as ready for review October 1, 2026 02:36

@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 +170
public RunnableConfig withCheckpointNamespace(String checkpointNamespace) {
if (Objects.equals(this.threadId, checkpointNamespace)) {
return this;
}

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 is checked against the new checkpointNamespace instead of this.checkpointNamespace. This causes two bugs:

  1. If the requested namespace happens to equal the current threadId, the method returns this without updating the namespace.
  2. If the requested namespace equals the current namespace (but differs from threadId), it needlessly allocates a new config.

The existing withCheckPointId uses the correct pattern: Objects.equals(this.checkPointId, checkPointId). Apply the same here.

        if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {
            return this;
        }

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 to targetFolder.resolve(...). Path.resolve has two dangerous behaviors with untrusted input:

  • An absolute namespace (e.g. /etc/passwd) is returned verbatim, ignoring targetFolder entirely.
  • A relative namespace containing .. (e.g. ../../sensitive) resolves outside targetFolder.

This affects getPath, serialize (which then calls Files.createDirectories on the escaped parent), releaseCheckpoints, and deleteFile, so an attacker-controlled namespace can read/write/delete arbitrary files reachable by the process.

The documentation already states the contract — "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores" — but it is never enforced. Validate the namespace against that pattern (and reject absolute paths) before resolving, for example in BaseCheckpointSaver.checkpointNamespace(RunnableConfig) or in getNamespaceFolder, throwing IllegalArgumentException on violation. At minimum, normalize and confirm the resolved path starts with targetFolder.

Based on AGENT.md

@unblocked-local-kaihao

Copy link
Copy Markdown

Risk Assessment

Risk: medium

The new withCheckpointNamespace method compares the requested namespace to threadId, so it can silently ignore a namespace update when those values match. FileSystemSaver also resolves the namespace directly as a path without enforcing the documented character restrictions, which can undermine the intended storage isolation; no schema-change policy applies.

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