Skip to content

feat: default persistent caching to verified lazy storage - #10754

Draft
dmadisetti wants to merge 1 commit into
dm/cache-key-correctnessfrom
dm/cache-signed-default
Draft

dmadisetti wants to merge 1 commit into
dm/cache-key-correctnessfrom
dm/cache-signed-default

Conversation

@dmadisetti

Copy link
Copy Markdown
Member

This pull request was authored by a coding agent.

Summary

Stacked on #10753; review this PR against dm/cache-key-correctness.

mo.persistent_cache currently defaults to unsigned pickle, while automatic cell caching uses the lazy manifest/blob format. This change defaults persistent caching to lazy, signed storage at the same release boundary as the cache-key invalidation in #10753.

  • Default synchronous functions, asynchronous functions, and context managers to method="lazy"; retain explicit pickle and JSON methods.
  • Make cryptography a native runtime dependency. Verification on never automatically becomes off: without verification capability, reads miss and writes are skipped while the computation runs normally. Explicit off and strict remain available.
  • Publish a complete machine-local key with an atomic hard link that cannot replace another creator's key. Concurrent first-time writers and later readers adopt the same identity. Damaged key files are preserved for explicit recovery instead of silently rotating the identity.
  • Document the default, trust requirements, migration, and browser impact. Preserve existing tests of unsigned formats by making their loader choice explicit.

Compatibility and review points

This intentionally removes the local-file and same-origin WASM verification bypasses. Browser caches require cryptography and a configured trusted signer; otherwise cells recompute. Native-only computations cannot recompute in the browser, so existing cached exports that relied on the bypass need explicit trust/verification configuration before this merges.

Automatic key publication requires hard-link support in the user state directory. If it fails, verified persistence is unavailable and computations still run. The signing algorithm and manifest signature format are unchanged. No browser/GPU or performance benchmark was run.

Validation

  • Seven regression cases failed before the implementation: three default API forms, concurrent key creation, and three downgrade-policy cases.
  • Python 3.12: 536 passed, 3 skipped, 2 xfailed, 6 xpassed across caching, cached execution, and watcher suites, including optional data libraries.
  • Python 3.13: 124 signing/default/policy tests pass, including a fresh-process test that reuses a signed entry and recomputes after signature tampering.
  • The extensionless-script test also fails on untouched upstream and remains excluded from the broader run.
  • Full make check passes with Nix-provided make, pnpm, and pixi: frontend lint/type checking, typos, copyright, Ruff, all 787 Python modules, and lock validation.

Pre-review checklist

  • Maintainer discussion recorded for the dependency, default, and browser changes.
  • Human author reviewed the generated code line by line.

Merge checklist

  • Contributor guidelines read.
  • Documentation and API docstrings updated.
  • Regression tests added and run.
  • Full CI passes.

@vercel

vercel Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marimo-docs Ready Ready Preview Sep 4, 2026 11:12pm UTC

Request Review

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Browser cryptography is not loaded, and asynchronous lazy writes introduce correctness and failure regressions for the new default.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Defaults persistent caching to signed lazy storage and strengthens verification behavior.

Changes:

  • Switches all persistent-cache forms to lazy signed manifests.
  • Preserves signing identity during corruption and concurrent creation.
  • Updates dependencies, tests, and migration/browser documentation.
File summaries
File Description
marimo/_save/save.py Changes the default cache method and API docs.
marimo/_save/loaders/lazy.py Enforces verification without insecure fallback.
marimo/_save/signing.py Publishes keys safely and preserves damaged files.
pyproject.toml Promotes cryptography to a native dependency.
docs/api/caching.md Documents formats, trust, and migration.
docs/guides/exporting/webassembly_html.md Documents browser verification requirements.
tests/_save/test_signed_defaults.py Tests defaults, concurrency, and restart verification.
tests/_save/test_signing.py Tests damaged-key preservation.
tests/_save/test_cache_versions.py Keeps legacy fixture explicitly pickle-based.
tests/_save/store/test_store.py Keeps mock-store coverage on pickle.
tests/_save/loaders/test_lazy_wasm.py Marks unsigned WASM fixture explicitly.
tests/_save/loaders/test_lazy_signing.py Tests fail-closed verification policies.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pyproject.toml
Comment on lines +13 to +14
# Sign persistent cache manifests on native Python.
"cryptography>=42.0.0; sys_platform != 'emscripten'",
Comment on lines +80 to +84
Cached exports require `cryptography` and a trusted signer in the browser to
restore signed entries. If either is unavailable, marimo recomputes the cells
instead of accepting unsigned or unverifiable data. A cell that requires a
native-only package cannot recompute in the browser. The previous automatic
same-origin verification bypass no longer applies.
Comment on lines +1359 to +1362
verification != "off"
and self._can_verify()
and signer is not None
and signer.can_sign
Comment thread marimo/_save/save.py
name: str | Callable[..., Any] | None = None,
save_path: str | None = None,
method: LoaderKey = "pickle",
method: LoaderKey = "lazy",

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

7 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="marimo/_save/signing.py">

<violation number="1" location="marimo/_save/signing.py:492">
P2: Key publication now depends on `os.link` hard-link support. On filesystems that cannot create hard links (e.g. a FAT/exFAT or some network-mount state dir), `os.link` raises a non-`FileExistsError`, the `except Exception` swallows persisted-cache capability, and — with verification `on` no longer degrading — every persistent cache read misses and every write is skipped for the session. `os.replace` previously worked on those filesystems. Catch or fall back for the non-EEXIST case (e.g. retry with `os.replace`) so caching still works where hard links are unavailable.</violation>

<violation number="2" location="marimo/_save/signing.py:492">
P2: When a symlink is placed at `key_file` after the initial `exists()` check, `os.link` raises `FileExistsError` and the following `read_text()` follows it. The loader therefore adopts an attacker-selected private key, defeating the atomic non-replacement trust boundary; read the winner through a no-follow file descriptor and use the in-memory key for the publishing process.</violation>
</file>

<file name="pyproject.toml">

<violation number="1" location="pyproject.toml:14">
P2: `cryptography` is only consumed by the persistent-cache signing feature, which is coded to degrade gracefully without it (all uses are guarded by `DependencyManager.cryptography.has()` and imports are deferred; reads miss and writes are skipped while computation runs normally). Making it a hard base runtime dependency forces every user — including those who never use persistent caching — to install a native Rust-compiled package, enlarging installs and risking conflicts with pinned environments. Keep it out of the base install and wire it as a feature-scoped dependency (e.g., an extra such as `marimo[cache]` or back in `recommended`) so the default runtime still works through the existing fallback for users who install the signing capability explicitly.</violation>
</file>

<file name="docs/guides/exporting/webassembly_html.md">

<violation number="1" location="docs/guides/exporting/webassembly_html.md:80">
P2: This section makes a trusted signer in the browser a hard prerequisite for cached exports but never explains how a reader satisfies it, so the documented workflow can't be followed. The old same-origin bypass is gone, and `docs/api/caching.md` only explains trust via the writer's own user configuration, which doesn't exist for arbitrary users opening an exported HTML page. Explain how the export establishes the trusted signer (e.g. bundling the writer's public fingerprint into the export) or link to the configuration that applies in the browser; otherwise readers have no way to make cached exports restore entries.</violation>

<violation number="2" location="docs/guides/exporting/webassembly_html.md:84">
P3: The new paragraph is followed by two consecutive blank lines before 'With caching, you can publish WebAssembly notebooks...', while every other paragraph in this file uses a single blank line. Trim the extra blank line so the doc stays consistent and passes the repo's markdownlint MD012 rule.</violation>
</file>

<file name="docs/api/caching.md">

<violation number="1" location="docs/api/caching.md:107">
P3: The new trust section tells users to 'configure the writer's public fingerprint as a trusted signer in your user configuration' but never gives the configuration key, section, or value format. Readers cannot act on the instruction. Point to the exact key (e.g. `trusted_signers` with a `SHA256:<base64>` fingerprint under the cache/signing section) or link to the configuration reference so the sharing workflow is actionable.</violation>
</file>

<file name="tests/_save/test_signed_defaults.py">

<violation number="1" location="tests/_save/test_signed_defaults.py:107">
P3: The `os.replace` patch here never fires. `_get_machine_signer` publishes its key via `os.link(tmp, key_file)` (marimo/_save/signing.py:492) and does not call `os.replace` anywhere in the resolution path, so `publish` is never invoked with `original_replace`. Drop the `os.replace` patch (and the `original_replace` variable) so the test setup reflects the actual hard-link publish mechanism and doesn't imply `os.replace` is part of the identity-preserving logic.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread marimo/_save/signing.py
try:
os.unlink(tmp)
except OSError:
os.link(tmp, key_file)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Key publication now depends on os.link hard-link support. On filesystems that cannot create hard links (e.g. a FAT/exFAT or some network-mount state dir), os.link raises a non-FileExistsError, the except Exception swallows persisted-cache capability, and — with verification on no longer degrading — every persistent cache read misses and every write is skipped for the session. os.replace previously worked on those filesystems. Catch or fall back for the non-EEXIST case (e.g. retry with os.replace) so caching still works where hard links are unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At marimo/_save/signing.py, line 492:

<comment>Key publication now depends on `os.link` hard-link support. On filesystems that cannot create hard links (e.g. a FAT/exFAT or some network-mount state dir), `os.link` raises a non-`FileExistsError`, the `except Exception` swallows persisted-cache capability, and — with verification `on` no longer degrading — every persistent cache read misses and every write is skipped for the session. `os.replace` previously worked on those filesystems. Catch or fall back for the non-EEXIST case (e.g. retry with `os.replace`) so caching still works where hard links are unavailable.</comment>

<file context>
@@ -485,34 +484,27 @@ def _get_machine_signer(
             try:
-                os.unlink(tmp)
-            except OSError:
+                os.link(tmp, key_file)
+            except FileExistsError:
+                # The winner published its complete key before creating the
</file context>

Comment thread marimo/_save/signing.py
try:
os.unlink(tmp)
except OSError:
os.link(tmp, key_file)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When a symlink is placed at key_file after the initial exists() check, os.link raises FileExistsError and the following read_text() follows it. The loader therefore adopts an attacker-selected private key, defeating the atomic non-replacement trust boundary; read the winner through a no-follow file descriptor and use the in-memory key for the publishing process.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At marimo/_save/signing.py, line 492:

<comment>When a symlink is placed at `key_file` after the initial `exists()` check, `os.link` raises `FileExistsError` and the following `read_text()` follows it. The loader therefore adopts an attacker-selected private key, defeating the atomic non-replacement trust boundary; read the winner through a no-follow file descriptor and use the in-memory key for the publishing process.</comment>

<file context>
@@ -485,34 +484,27 @@ def _get_machine_signer(
             try:
-                os.unlink(tmp)
-            except OSError:
+                os.link(tmp, key_file)
+            except FileExistsError:
+                # The winner published its complete key before creating the
</file context>

Comment thread pyproject.toml
# Dependencies should have lower bounds, which should be as loose as possible.
dependencies = [
# Sign persistent cache manifests on native Python.
"cryptography>=42.0.0; sys_platform != 'emscripten'",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: cryptography is only consumed by the persistent-cache signing feature, which is coded to degrade gracefully without it (all uses are guarded by DependencyManager.cryptography.has() and imports are deferred; reads miss and writes are skipped while computation runs normally). Making it a hard base runtime dependency forces every user — including those who never use persistent caching — to install a native Rust-compiled package, enlarging installs and risking conflicts with pinned environments. Keep it out of the base install and wire it as a feature-scoped dependency (e.g., an extra such as marimo[cache] or back in recommended) so the default runtime still works through the existing fallback for users who install the signing capability explicitly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At pyproject.toml, line 14:

<comment>`cryptography` is only consumed by the persistent-cache signing feature, which is coded to degrade gracefully without it (all uses are guarded by `DependencyManager.cryptography.has()` and imports are deferred; reads miss and writes are skipped while computation runs normally). Making it a hard base runtime dependency forces every user — including those who never use persistent caching — to install a native Rust-compiled package, enlarging installs and risking conflicts with pinned environments. Keep it out of the base install and wire it as a feature-scoped dependency (e.g., an extra such as `marimo[cache]` or back in `recommended`) so the default runtime still works through the existing fallback for users who install the signing capability explicitly.</comment>

<file context>
@@ -10,6 +10,8 @@ description = "A library for making reactive notebooks and apps"
 # Dependencies should have lower bounds, which should be as loose as possible.
 dependencies = [
+    # Sign persistent cache manifests on native Python.
+    "cryptography>=42.0.0; sys_platform != 'emscripten'",
     # For maintainable cli
     "click>=8.0,<9; sys_platform != 'emscripten'",
</file context>


## Exporting with cached execution { #exporting-with-cached-execution }

Cached exports require `cryptography` and a trusted signer in the browser to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This section makes a trusted signer in the browser a hard prerequisite for cached exports but never explains how a reader satisfies it, so the documented workflow can't be followed. The old same-origin bypass is gone, and docs/api/caching.md only explains trust via the writer's own user configuration, which doesn't exist for arbitrary users opening an exported HTML page. Explain how the export establishes the trusted signer (e.g. bundling the writer's public fingerprint into the export) or link to the configuration that applies in the browser; otherwise readers have no way to make cached exports restore entries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/guides/exporting/webassembly_html.md, line 80:

<comment>This section makes a trusted signer in the browser a hard prerequisite for cached exports but never explains how a reader satisfies it, so the documented workflow can't be followed. The old same-origin bypass is gone, and `docs/api/caching.md` only explains trust via the writer's own user configuration, which doesn't exist for arbitrary users opening an exported HTML page. Explain how the export establishes the trusted signer (e.g. bundling the writer's public fingerprint into the export) or link to the configuration that applies in the browser; otherwise readers have no way to make cached exports restore entries.</comment>

<file context>
@@ -77,6 +77,13 @@ incompatibilities early:
 
 ## Exporting with cached execution { #exporting-with-cached-execution }
 
+Cached exports require `cryptography` and a trusted signer in the browser to
+restore signed entries. If either is unavailable, marimo recomputes the cells
+instead of accepting unsigned or unverifiable data. A cell that requires a
</file context>

restore signed entries. If either is unavailable, marimo recomputes the cells
instead of accepting unsigned or unverifiable data. A cell that requires a
native-only package cannot recompute in the browser. The previous automatic
same-origin verification bypass no longer applies.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The new paragraph is followed by two consecutive blank lines before 'With caching, you can publish WebAssembly notebooks...', while every other paragraph in this file uses a single blank line. Trim the extra blank line so the doc stays consistent and passes the repo's markdownlint MD012 rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/guides/exporting/webassembly_html.md, line 84:

<comment>The new paragraph is followed by two consecutive blank lines before 'With caching, you can publish WebAssembly notebooks...', while every other paragraph in this file uses a single blank line. Trim the extra blank line so the doc stays consistent and passes the repo's markdownlint MD012 rule.</comment>

<file context>
@@ -77,6 +77,13 @@ incompatibilities early:
+restore signed entries. If either is unavailable, marimo recomputes the cells
+instead of accepting unsigned or unverifiable data. A cell that requires a
+native-only package cannot recompute in the browser. The previous automatic
+same-origin verification bypass no longer applies.
+
+
</file context>

Comment thread docs/api/caching.md
is unavailable, reads miss and writes are skipped, including in WebAssembly.
Missing signing capability does not prevent the computation from running.

To share caches, configure the writer's public fingerprint as a trusted signer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The new trust section tells users to 'configure the writer's public fingerprint as a trusted signer in your user configuration' but never gives the configuration key, section, or value format. Readers cannot act on the instruction. Point to the exact key (e.g. trusted_signers with a SHA256:<base64> fingerprint under the cache/signing section) or link to the configuration reference so the sharing workflow is actionable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/api/caching.md, line 107:

<comment>The new trust section tells users to 'configure the writer's public fingerprint as a trusted signer in your user configuration' but never gives the configuration key, section, or value format. Readers cannot act on the instruction. Point to the exact key (e.g. `trusted_signers` with a `SHA256:<base64>` fingerprint under the cache/signing section) or link to the configuration reference so the sharing workflow is actionable.</comment>

<file context>
@@ -91,6 +91,34 @@ letting you pick up where you left off.
+is unavailable, reads miss and writes are skipped, including in WebAssembly.
+Missing signing capability does not prevent the computation from running.
+
+To share caches, configure the writer's public fingerprint as a trusted signer
+in your user configuration. Keep the writer's private key private. Trust a
+signer only if you trust the code in its cached payloads: signatures do not
</file context>


with (
patch.object(signing, "generate_keypair", generate),
patch.object(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The os.replace patch here never fires. _get_machine_signer publishes its key via os.link(tmp, key_file) (marimo/_save/signing.py:492) and does not call os.replace anywhere in the resolution path, so publish is never invoked with original_replace. Drop the os.replace patch (and the original_replace variable) so the test setup reflects the actual hard-link publish mechanism and doesn't imply os.replace is part of the identity-preserving logic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/_save/test_signed_defaults.py, line 107:

<comment>The `os.replace` patch here never fires. `_get_machine_signer` publishes its key via `os.link(tmp, key_file)` (marimo/_save/signing.py:492) and does not call `os.replace` anywhere in the resolution path, so `publish` is never invoked with `original_replace`. Drop the `os.replace` patch (and the `original_replace` variable) so the test setup reflects the actual hard-link publish mechanism and doesn't imply `os.replace` is part of the identity-preserving logic.</comment>

<file context>
@@ -0,0 +1,162 @@
+
+    with (
+        patch.object(signing, "generate_keypair", generate),
+        patch.object(
+            os, "replace", lambda *a, **kw: publish(original_replace, *a, **kw)
+        ),
</file context>

This branch was successfully deployed

1 active deployment
Preview — 7cab18a8 Deployed Sep 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request team-draft

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants