Schema Design, Storage Architecture & CRUD API
Scale target: 10 Projects × 100 Notebooks · Iceberg-inspired metadata layer · No database
This document defines the bucket schema for marimo notebook create, read,
update, and delete operations. It uses the Bucket port without an external
database. The content model follows the Apache Iceberg metadata
indirection pattern. catalog.json is the only mutable content pointer, and
immutable snapshots and versions retain history. _system/ also stores mutable
coordination records outside the content snapshot chain.
Core invariant:
catalog.jsonis the only content pointer overwritten in place. Every content-history write creates a new object._system/stores mutable sessions (§4.8), app claims (§4.8.1), editor claims (§4.8.2), identities (§4.10), and tokens (§4.11). These records do not participate in the snapshot chain. Each CAS-managed record is written through its owning core service.
- No database — the configured
Bucketadapter is the persistence layer - O(1) reads for listing projects and notebooks — no object scanning
- Immutable snapshots give audit history for free
- Self-describing notebooks — each notebook folder is a complete, portable unit
- A local notebook has a latest-only
workspace/. A Git-synced notebook stores each pushed workspace inside its immutable version. - Typed source pointer —
localnotebooks store source in the workspace.gitnotebooks receive pushed revisions from an external workflow. - Append-only audit events — one immutable object per event
- Scales comfortably to 10 projects × 100 notebooks per project (1,000 total)
Because there is no database, the object store itself must provide the guarantees the design leans on:
- Strong read-after-write consistency — a read (or list) immediately following a write reflects the latest data, so a writer always sees the snapshot it just committed.
- Conditional writes (compare-and-swap) —
If-Matchon ETag for the atomiccatalog.jsonswap, returning412 Precondition Failedon mismatch;If-None-Match: *(create-if-absent) for first-write bootstrapping. - Stable ETags —
head,list, andgetreturn the same non-empty ETag for an unchanged object, and the ETag changes whenever the contents change (a content-hash backend such asstorage-fsmay keep it across an identical rewrite). The per-process metadata and session caches, and theensureInitializedcatalog check, rely on this to skip downloads. A backend that cannot supply an ETag returns'', and none of these caches store or match such a value.
AWS S3 provides both (strong read-after-write consistency since 2020; If-Match / If-None-Match on PutObject since 2024). Verify these for any other S3-compatible target (MinIO, Ceph RGW, Backblaze B2, Tigris, …) — conditional-write support in particular varies by implementation and version. This is the single most important portability constraint for the design.
The _system/ namespace is the heart of the architecture. Understanding it first makes the rest of the schema obvious.
catalog.json → snapshots/{id}.json → full project + notebook index
↑
only file ever mutated in place
catalog.json is a tiny pointer file — it names the current snapshot. Every write operation clones the current snapshot, applies changes, writes a new immutable snapshot, then atomically swaps catalog.json to point at it using a conditional PUT on ETag. If two writers race, one gets a 412 Precondition Failed and retries. No corruption is possible.
ensureInitialized caches a positive "catalog populated" result for 30 seconds per process. A wiped or reset bucket can take up to 30 seconds per replica to be detected and re-initialized.
A single GET on the current snapshot returns the complete project and notebook listing — all metadata, no code. At 10 projects × 100 notebooks with ~200 bytes of metadata per notebook, the snapshot is roughly 200KB. That's the entire index in one fast GET.
Every snapshot carries a schema_version field. When the snapshot structure
changes, the service upgrades the current snapshot in memory before it writes
the next snapshot. It does not rewrite old snapshots.
s3-bucket/
├── _system/
│ ├── catalog.json ← THE single mutable pointer
│ ├── snapshots/
│ │ └── {snapshot-id}.json ← immutable, one per write
│ ├── sessions/
│ │ └── {project-id}/
│ │ └── {session-id}.json ← kernel session record
│ ├── version-prune-cutoffs/
│ │ └── {project-id}/
│ │ └── {notebook-id}.json ← monotonic version boundary for new sessions (CAS)
│ ├── proposal-payloads/
│ │ └── {project-id}/
│ │ └── {notebook-id}/
│ │ └── {proposal-id}.json ← immutable payload-expiry marker
│ ├── app-pools/
│ │ └── {project-id}/
│ │ └── {notebook-id}.json ← reservations, assignments, and visit leases (CAS)
│ ├── apps/
│ │ └── {project-id}/
│ │ └── {notebook-id}.json ← legacy app-session claim (CAS)
│ ├── editors/
│ │ └── {project-id}/
│ │ └── {notebook-id}.json ← persistent-editor claim (CAS)
│ ├── reconcile/
│ │ └── orphans/
│ │ └── {sandbox-id}.json ← first-seen marker for an undated orphan
│ ├── job-runs/
│ │ └── {project-id}/
│ │ └── {run-id}.json ← execution/finalization marker (deleted after finalization)
│ ├── identities/
│ │ └── {user-id}.json ← user display identity (mutable, CAS-managed)
│ ├── tokens/
│ │ └── {token-id}.json ← personal access token record (mutable, last-writer-wins)
│ ├── cli-authorizations/
│ │ └── {authorization-id}.json ← short-lived PKCE login grant (CAS-claimed)
│ ├── cli-device-user-codes/
│ │ └── {user-code}.json ← immutable lookup claim for a device grant
│ ├── oauth-clients/
│ │ └── {client-id}.json ← immutable dynamic client registration
│ ├── oauth-authorizations/
│ │ └── {authorization-id}.json ← short-lived MCP OAuth grant (CAS-claimed)
│ ├── oauth-rate-limits/
│ │ └── {endpoint}.json ← shared MCP OAuth request window (CAS)
│ ├── integrations/ ← organization-wide integrations (§4.12)
│ │ ├── _names/
│ │ │ └── {name}.json ← name claim (CAS, app-claim pattern)
│ │ └── {integration-id}/
│ │ ├── integration.json ← CAS-managed head
│ │ └── versions/
│ │ └── {000001}.json ← immutable configuration version
│ ├── idempotency/
│ │ └── {digest}.json ← replay record with a 24h TTL
│ ├── events/
│ │ └── {YYYY-MM-DD}/
│ │ ├── {event-id}.json ← one immutable object per event
│ │ └── _idempotency/
│ │ └── {key}.json ← stable key mapped to one event
│ ├── sandbox-diagnostics/
│ │ └── {user-id}.json ← per-admin sandbox diagnostic lease (CAS)
│ ├── _maintenance.lock ← advisory maintenance lease
│ ├── _session_lifecycle.lock ← advisory session-lifecycle lease
│ └── _jobs.lock ← advisory job-scheduler lease
│
└── projects/
└── {project-id}/
├── project.json ← project metadata
├── alerts.json ← project alert destinations (CAS-owned, encrypted)
├── integrations/
│ ├── _names/
│ │ └── {name}.json ← per-name singleton claim (CAS, app-claim pattern)
│ └── {integration-id}/
│ ├── integration.json ← head: name/kind/enabled/current_version (CAS-managed)
│ └── versions/
│ └── {000001}.json ← immutable config version (create-if-absent)
└── notebooks/
└── {notebook-id}/
├── meta.json ← notebook metadata (title, author, tags…)
├── README.md ← human description
├── source.json ← `local` or `git` source metadata
├── integration_sync_token.json ← push-sync token hash (push-mode Git sources only)
├── fs_snapshot.json ← optional provider filesystem-snapshot pointer
├── workspace/ ← local sources only: latest sandbox workspace
│ ├── notebook.py ← latest local code
│ ├── pyproject.toml ← latest local dependencies
│ └── data/cars.csv ← optional persisted runtime file
├── proposals/
│ └── {proposal-id}/
│ ├── proposal.json ← immutable capture manifest
│ ├── changes/
│ │ └── {index} ← temporary changed-file bytes
│ └── publication.json ← CAS-managed publication state
├── jobs/ ← headless runs (§4.13)
│ └── {job-id}/
│ ├── job.json ← definition head (CAS, JobsService)
│ ├── occurrences/
│ │ └── {YYYYMMDDTHHmmZ}.json ← scheduled-fire claim (create-if-absent, immutable)
│ ├── run-index/
│ │ └── {reverse-ulid}.json ← empty, immutable newest-first history index
│ └── runs/
│ └── {run-id}/
│ ├── run.json ← run record (CAS, JobRunService)
│ ├── output.html ← write-once rendered output
│ └── logs.txt ← write-once stdout+stderr (capped)
└── versions/
└── {version-id}/
├── version.json
├── notebook.py ← local source snapshot
├── pyproject.toml ← local dependency snapshot
├── workspace/ ← Git sources only: complete synced tree
├── git/ ← pull-mode Git sources only: credential-free `.git` payload
├── workspace.zip ← optional packed startup copy of workspace + `.git`
├── notebook.html ← optional snapshot (see below)
└── session.json ← optional snapshot (see below)
For a local source, the notebook-level workspace/ folder is the latest
mirror of the sandbox working directory. It contains notebook.py and
pyproject.toml. It can also contain runtime files when
MARIMOHUB_PERSIST_WORKSPACE=workspace.
- Latest-only and non-versioned.
workspace/is overwritten in place on each save/teardown and is never touched by version pruning. The immutable per-version record lives underversions/{vid}/(§4.7). - Runtime files are opt-in. Under the default
MARIMOHUB_PERSIST_WORKSPACE=source,workspace/contains onlynotebook.py+pyproject.toml. Underworkspace, the sandbox's non-source files are captured here on teardown and restored on the next session (§8). - The mount is rooted at
workspace/. When a sandbox mounts the bucket, the working dir maps toworkspace/. Becausemeta.json/README.md/source.json/versions/sit outsideworkspace/, control metadata is never exposed to user code in the sandbox.
For a git source, each sync writes a complete tree under
versions/{vid}/workspace/. The source pointer selects one immutable version.
The notebook has no mutable workspace mirror. A session receives a copy of the
selected version and cannot write changes back.
Each synced version keeps a best-effort workspace.zip copy of the final sandbox
tree. Provisioning prefers this single object and falls back to the canonical
workspace/ and git/ objects when it is absent or invalid. The packed copy is
created for combined workspace and Git inputs up to 32 MiB.
| Path | Type | Description |
|---|---|---|
_system/catalog.json |
JSON | Single entry point. Points to the current snapshot. This is the only mutable content pointer. |
_system/snapshots/{id}.json |
JSON | Immutable index snapshot. Lists all projects and notebooks with metadata. Written once, never modified. |
_system/sessions/{pid}/{sid}.json |
JSON | Live session record, partitioned by project so a project-scoped read lists only _system/sessions/{pid}/. Created on notebook open, updated by heartbeat, and retired by an explicit stop or lifecycle deadline. Closing a browser tab does not immediately delete it. |
_system/version-prune-cutoffs/{pid}/{nid}.json |
JSON | Monotonic { cutoff_version_id } advanced before pruning old version folders. A new source-pinned session writes its record and then rejects itself if its version is at or before the cutoff; otherwise a later cutoff advance must see and protect that session. CAS updates are owned by SessionService. Hard deletion removes the record. |
_system/proposal-payloads/{pid}/{nid}/{proposal-id}.json |
JSON | Immutable expiry marker listing the temporary proposal change objects eligible for retention cleanup. It remains until every listed payload is deleted successfully. |
_system/app-pools/{pid}/{nid}.json |
JSON | Versioned pool record owned by AppPoolStore: reservations, visit assignments, visit leases, and deletion tombstones. See App pool records. |
_system/apps/{pid}/{nid}.json |
JSON | Legacy app-singleton claim, released through SessionService during pool migration. New app admissions use _system/app-pools/{pid}/{nid}.json. See §4.8.1. |
_system/editors/{pid}/{nid}.json |
JSON | Per-notebook persistent-editor claim. Records the shared/exclusive mode, names the only session allowed to save, and records idempotent takeover phases. All writes go through SessionService. See §4.8.2. |
_system/identities/{user-id}.json |
JSON | User display identity ({ id, email, name, picture_url?, suspended_at? }). Owned and CAS-updated by IdentityService. Resolves opaque author/user_id IDs to a person and gates suspended users at authentication time. |
_system/tokens/{token-id}.json |
JSON | Personal access token record ({ id, user_id, name, hash, … }) keyed by the ULID embedded in the presented mhub_pat_ bearer, so verification is a single GET. Stores only the SHA-256 of the secret. Mutable, last-writer-wins (coarse last_used_at refresh); revocation deletes the object. See §4.11. |
_system/cli-authorizations/{authorization-id}.json |
JSON | Ten-minute browser-to-CLI PKCE grant. Stores hashes/challenges, never a PAT or plaintext authorization secret. CliAuthorizationService CAS-claims it before minting one token, then deletes it. See §4.11.1. |
_system/cli-device-user-codes/{user-code}.json |
JSON | Create-if-absent lookup claim from an eight-letter device user code to its short-lived CLI authorization. CliAuthorizationService owns the claim. It deletes the claim after exchange and prunes it after ten minutes. See §4.11.1. |
_system/oauth-clients/{client-id}.json |
JSON | Immutable OAuth client registration. OAuthClientStore owns each record. Public clients expire after 90 days. See §4.11.2. |
_system/oauth-authorizations/{authorization-id}.json |
JSON | Ten-minute MCP OAuth authorization owned by OAuthAuthorizationService. Approval stores a one-time code hash with CAS. Exchange claims the record with CAS before it mints one scoped PAT and deletes the record. See §4.11.2. |
_system/oauth-rate-limits/{endpoint}.json |
JSON | Bounded sliding-window timestamps for an MCP OAuth endpoint. OAuthRateLimitService uses ETag CAS so all serving replicas enforce one deployment quota. Expired timestamps are removed on the next admitted request. See §4.11.2. |
_system/events/{YYYY-MM-DD}/{event-id}.json |
JSON | Structured event log. One immutable object per event, keyed by a monotonic ULID under a per-day prefix. Primary audit trail. |
_system/events/{YYYY-MM-DD}/_idempotency/{key}.json |
JSON | Create-if-absent mapping from a stable operation key to one event ID and serialized event body. A retry repairs a missing event object from this marker without changing its append position or payload. |
_system/idempotency/{digest}.json |
JSON | Recorded POST-create response for an Idempotency-Key, keyed by sha256(user:route\nkey). Replayed on retry; pruned after 24h. See idempotency.md. |
_system/reconcile/orphans/{sandbox-id}.json |
JSON | First-seen Unix timestamp for an active sandbox that has no session record and no provider creation time. The reconciler creates and deletes these markers. |
_system/sandbox-diagnostics/{user-id}.json |
JSON | Per-admin CAS lease for a sandbox startup diagnostic ({ sandbox_id, expires_at }). It prevents overlapping tests across serving replicas and makes the active sandbox visible to reconciliation. SandboxDiagnosticLease owns all writes; release CAS-writes a null free marker, and expiry recovers leases left by a crashed request. |
_system/_maintenance.lock |
JSON | Advisory lease for snapshot and event retention. MaintenanceLock owns this key. |
_system/_session_lifecycle.lock |
JSON | Advisory lease for the session lifecycle and reconciliation sweep. The lifecycle loop owns this key. |
projects/{pid}/project.json |
JSON | Project metadata: name, description, owner, members, tags. Optional security_labels (classification + compartments), written only by ProjectService.setSecurityLabels under super-admin standing; see §4.3. |
projects/{pid}/alerts.json |
JSON | Project-scoped Slack and signed-webhook destinations. CAS-managed and written only by ProjectAlertStore; endpoint URLs and signing secrets are path-bound managed ciphertext. The record is retained through the soft-delete window so the final project deletion alert can be delivered. |
projects/{pid}/integrations/{iid}/integration.json |
JSON | Integration head: kind, instance name, enabled, and the current_version pointer. CAS-managed via ETag mutateObject — written only by ProjectIntegrationsStore (a version bump must be atomic against a concurrent edit). See §4.12. |
projects/{pid}/integrations/{iid}/versions/{n}.json |
JSON | Immutable integration config version, keyed by zero-padded number so key order matches version order. Written create-if-absent. Secret fields contain managed ciphertext envelopes or external reference markers, never resolved plaintext. Sessions pin these version numbers, so history is never rewritten. |
projects/{pid}/integrations/_names/{name}.json |
JSON | Per-name singleton claim ({ integration_id, claimed_at }) anchoring integration-name uniqueness — the same claim class as the app claim, written only by ProjectIntegrationsStore via acquireSingletonClaim/releaseSingletonClaim. See §4.12. |
_system/integrations/{iid}/integration.json |
JSON | Organization-wide integration head. It has the same fields as a project integration head, except it has no project_id. OrgIntegrationsStore is its only writer. See §4.12. |
_system/integrations/{iid}/versions/{n}.json |
JSON | Immutable organization-wide configuration version. It follows the same create-if-absent and secret-handling rules as a project integration version. |
_system/integrations/_names/{name}.json |
JSON | Organization-wide name claim. Claims are unique within this tier, so a project integration can use the same name. |
projects/{pid}/notebooks/{nid}/meta.json |
JSON | Notebook metadata: title, description, status, author, tags, last_run_at. Never contains code or paths. Optional security_labels override, written only by NotebookService.setSecurityLabels; see §4.4. |
projects/{pid}/notebooks/{nid}/README.md |
Markdown | Human-readable description and usage notes. |
projects/{pid}/notebooks/{nid}/source.json |
JSON | Typed source record. local stores the current version pointer. git stores Git coordinates, mode, sync state, and the current synced version. |
projects/{pid}/notebooks/{nid}/integration_sync_token.json |
JSON | Push-sync credential for one notebook. The record stores a token hash, not the plaintext token. Present only for push-mode Git notebooks. |
projects/{pid}/notebooks/{nid}/fs_snapshot.json |
JSON | Optional. Pointer to the notebook's current CoreWeave-native filesystem snapshot ({ snapshot_id, captured_at, owner_user_id }). Mutable, last-writer-wins, written only by the teardown snapshot path. Exclusive editors restore snapshots only for the same owner; shared editors may restore across starters. Present only under MARIMOHUB_COMPUTE_COREWEAVE_FILESYSTEM_SNAPSHOT=true. Not recommended alongside MARIMOHUB_PERSIST_WORKSPACE=workspace — the two double-persist state. |
projects/{pid}/notebooks/{nid}/proposals/{proposal-id}/proposal.json |
JSON | Immutable proposal manifest containing capture strategy, author/session provenance, pinned Git source revision, and ordered change hashes. Created with create-if-absent. |
projects/{pid}/notebooks/{nid}/proposals/{proposal-id}/changes/{index} |
any | Temporary bytes for a non-delete proposal change. The index corresponds to proposal.json.changes; delete changes have no object. These objects may be deleted after retention expires even though their manifest remains. |
projects/{pid}/notebooks/{nid}/proposals/{proposal-id}/publication.json |
JSON | CAS-managed proposal publication state owned by NotebookProposalService. It records either pending state or the durable provider change-request result. For an updated change request, the root proposal's head_commit advances as the shared current-head pointer after each child result is durable. |
projects/{pid}/notebooks/{nid}/workspace/notebook.py |
Python | Latest local notebook code. Present only for a local source. |
projects/{pid}/notebooks/{nid}/workspace/pyproject.toml |
TOML | Latest local Python dependency manifest. Present only for a local source. |
projects/{pid}/notebooks/{nid}/workspace/{path} |
any | Files from an imported folder or a local notebook sandbox. Imported local workspaces always persist these files. Other local notebooks require MARIMOHUB_PERSIST_WORKSPACE=workspace. Latest-only and non-versioned. |
projects/{pid}/notebooks/{nid}/workspace/{dir}/.marimohub-directory |
empty | Directory marker that keeps an otherwise empty workspace directory listable. Written create-if-absent by NotebookWorkspaceService.createDirectory; hidden from listings, search, and sandbox restores; reserved as a path segment. |
projects/{pid}/notebooks/{nid}/workspace_mutation_claim.json |
JSON | Short-lived lease ({ holder, expires_at }) serializing workspace-browser mutations for one notebook. CAS-managed and written only by NotebookWorkspaceService; renewed during long copies and deletes, released to { holder: null, expires_at: null }, and replaced in place once expired. |
projects/{pid}/notebooks/{nid}/versions/{vid}/notebook.py |
Python | Immutable version snapshot of the code. |
projects/{pid}/notebooks/{nid}/versions/{vid}/pyproject.toml |
TOML | Dependency snapshot at time of version. |
projects/{pid}/notebooks/{nid}/versions/{vid}/version.json |
JSON | Version metadata: saved_at, author, message, parent_id, optional snapshot descriptors. |
projects/{pid}/notebooks/{nid}/versions/{vid}/workspace/ |
any | Complete immutable tree from one Git sync. Present only for a git source version. |
projects/{pid}/notebooks/{nid}/versions/{vid}/git/ |
any | Immutable credential-free .git payload for a pull-mode source version. Restored into the sandbox workdir at provision and pruned with the enclosing version. With a root_path, .git is restored to the Git root (the configured workdir) and the workspace to <workdir>/<root_path>. |
projects/{pid}/notebooks/{nid}/versions/{vid}/workspace.zip |
ZIP | Optional duplicate of the final synced sandbox tree used only as a startup fast path. Pull-mode Git metadata is stored under .git/. Canonical reads and fallback restores continue to use workspace/ and git/. |
projects/{pid}/notebooks/{nid}/versions/{vid}/notebook.html |
HTML | Optional. Rendered HTML snapshot, copied from __marimo__/notebook.html on session teardown. Immutable once written. |
projects/{pid}/notebooks/{nid}/versions/{vid}/session.json |
JSON | Optional. marimo session state (cell outputs), copied from __marimo__/session/{notebook}.py.json on teardown. Immutable. |
The single mutable entry point. Overwritten atomically using conditional PUT with If-Match on the current ETag.
{
"version": 1,
"updated_at": "2025-03-05T14:22:00Z",
"current_snapshot_id": "snap-7h2k9qm4xz7rp3w8",
"current_snapshot_key": "_system/snapshots/snap-7h2k9qm4xz7rp3w8.json",
"previous_snapshot_id": "snap-5g43rv2s9pfw8w4d"
}Written once per mutating operation and never modified. The schema_version
field controls how the service reads the structure.
{
"snapshot_id": "snap-7h2k9qm4xz7rp3w8",
"schema_version": 1,
"created_at": "2025-03-05T14:22:00Z",
"operation": "notebook.create",
"actor": "user_abc123",
"projects": [
{
"id": "proj-7h2k9qm4xz7rp3w8",
"name": "Data Science",
"description": "Exploratory analysis notebooks",
"owner": "user_abc123",
"created_at": "2025-01-10T09:00:00Z",
"updated_at": "2025-03-05T14:22:00Z",
"notebook_count": 3,
"security_labels": null,
"notebooks": [
{
"id": "nb-5g43rv2s9pfw8w4d",
"title": "Revenue Analysis",
"description": "Monthly revenue breakdown",
"status": "active",
"source_type": "local",
"author": "user_abc123",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-03-05T14:22:00Z",
"tags": ["finance", "monthly"],
"last_run_at": "2025-03-04T08:30:00Z",
"security_labels": null,
"key_prefix": "projects/proj-7h2k9qm4xz7rp3w8/notebooks/nb-5g43rv2s9pfw8w4d"
}
]
}
]
}
key_prefixis an internal physical locator. The service removes it from API responses. Clients address notebooks by{project_id, notebook_id}.
Security-label projection. Project and notebook entries carry a tri-state
security_labels: an object (labeled), null (known unlabeled), or absent
(indeterminate — a legacy entry, or a mutation in flight). List filtering
decides labeled/unlabeled entries in one batch and resolves indeterminate ones
from the authoritative project.json / meta.json, failing closed on a read
error. security_labels_pending: true marks a mutation in flight so routine
projection writes (projectCatalogPatch and friends) do not resurrect the
pre-mutation labels; only the mutation's own finalizing commit clears it. Both
fields are written through CatalogService.mutateSnapshot like every other
entry field.
| Field | Values | Writer |
|---|---|---|
security_labels |
{ classification, compartments }, null, absent |
Routine projections (from the authoritative record); parked absent by a label mutation |
security_labels_pending |
true or absent |
Set by ProjectService/NotebookService.setSecurityLabels, cleared by the same mutation's finalize |
{
"id": "proj-7h2k9qm4xz7rp3w8",
"name": "Data Science",
"description": "Exploratory analysis notebooks",
"owner": "user_abc123",
"members": [
{ "user_id": "user_abc123", "role": "admin" },
{ "user_id": "user_def456", "role": "editor" }
],
"created_at": "2025-01-10T09:00:00Z",
"updated_at": "2025-03-05T14:22:00Z",
"tags": ["analytics", "internal"],
"security_labels": { "classification": "SECRET", "compartments": ["element-a"] }
}Security labels (optional): a classification plus required compartments, normalized on write (compartments deduplicated and sorted). Present only on labeled projects; absent means unlabeled. Written only by
ProjectService.setSecurityLabels— an ETag CASmutateObjecton this record, bracketed by two catalog commits (project.security_labels.pending, thenproject.security_labels) that form the audit trail and park the snapshot projection indeterminate while the record changes (§4.2). Requires super-admin standing (security-labels.raise/security-labels.lower); no project role grants label authority.If-Matchis honored (412 on a stale version, checked before the projection is parked).
Member roles:
managerhas full project control, including member management.editorcan change notebooks.viewerhas read-only access.adminis reserved for the project owner, deployment super admins, and grandfathered member rows. See §12.
Contains only notebook-level concerns. No code, no paths, no source details — those live in source.json and workspace/. This is what the API returns when a user views a notebook listing.
{
"id": "nb-5g43rv2s9pfw8w4d",
"project_id": "proj-7h2k9qm4xz7rp3w8",
"title": "Revenue Analysis",
"description": "Monthly revenue breakdown by region",
"status": "active",
"author": "user_abc123",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-03-05T14:22:00Z",
"last_run_at": "2025-03-04T08:30:00Z",
"tags": ["finance", "monthly"],
"runtime": {
"python_version": "3.11",
"marimo_version": "0.9.0"
},
"security_labels": { "classification": "SECRET", "compartments": ["element-a", "element-z"] }
}Valid status values: draft · active · archived · deleted
Security labels (optional): a notebook-level override enforced in addition to the project labels, so it can only add restrictions. Same write discipline as
project.json(§4.3): onlyNotebookService.setSecurityLabels, ETag CAS,notebook.security_labels.pending/notebook.security_labelscatalog commits, super-admin standing,If-Matchhonored. Lowering or clearing an override is decided against the notebook's current labels, so an admin outside the compartment cannot relax it.
The source record selects a local workspace or an immutable Git-synced workspace. A local source uses this shape:
{
"schema_version": 1,
"type": "local",
"current_version_id": "ver_01ARZ3NDEKTSV4RRFFQ69G5FAV"
}An imported local source also stores entry_notebook, relative to its workspace root.
Legacy local sources omit this field and use notebook.py.
The live entry file keeps its original path. Version snapshots still use versions/{vid}/notebook.py.
The original entry remains protected during workspace moves and deletes.
Folder imports use projects/{pid}/imports/{import-id}/ (paths.project(pid).notebookImport(id)):
| Object | Owner and lifetime |
|---|---|
snapshot.zip |
Immutable folder bytes; expires after 24 hours, deleted after one hour of grace. |
preparation.json |
Immutable actor and expiry record; deleted 7 days after the import expires. |
items/{entry-sha256}.json |
NotebookImportService CAS receipt; deleted 7 days after the import expires. |
Import IDs are imp- plus 16 random characters. A receipt key is the SHA-256 hex of the entrypoint path, so any valid
path fits the 255-byte segment and 1024-byte key limits. The receipt's input.entry_notebook is the source of truth.
Folder paths are limited to 896 UTF-8 bytes and 255 bytes per segment. The longest key a path is stored under is
projects/{pid}/notebooks/{nid}/versions/{vid}/workspace/{path} (111-byte prefix).
Each receipt binds the actor and submitted notebook settings to one entrypoint. Each attempt gets a separate notebook ID and a ten-minute lease. Retries cannot overwrite another attempt's workspace. Runtime settings are resolved for new attempts only. Completed replays return the stored result, even if deployment options change.
Receipt states: preparing (an attempt holds the lease), publishing (every file exists; publication was authorized),
complete (stores the published NotebookMeta), and expired (fenced by expiry, the retry limit, sweep, or a
replay that found the notebook deleted).
After every file exists, CAS advances preparing to publishing. Only NotebookImportService calls
NotebookService.publishImportNotebook, and only for a receipt it fenced to publishing. Publication is an idempotent
catalog append (appendNotebookEntry with ifAbsent).
If the notebook was deleted before a replay completes it, the receipt becomes expired and the caller must restart.
Maintenance completes interrupted publication and removes abandoned attempt prefixes, including late writes.
It preserves the winning workspace and completed receipts. Replays cannot overwrite edits or recreate a deleted notebook.
Seven days after the import expires, maintenance logs any receipt still publishing and deletes the whole import prefix.
Imported local workspaces retain supporting files even under MARIMOHUB_PERSIST_WORKSPACE=source.
The workspace root is the working directory and is added to PYTHONPATH for nested entrypoints.
See Import notebooks for selection, limits, and browser queue behavior.
A Git source stores type: "git", a provider, sync_mode: "push" | "pull",
Git coordinates, and sync timestamps. current_version_id is null before the
first successful sync.
source.json is a discriminated union on type. The current schema supports
local and git records. A local record points to the current version in the
bucket. A git record stores Git coordinates, its push or pull mode, and the
state of the last sync.
In push mode, an external workflow sends the workspace to /api/sync/git/v1
with a notebook-scoped sync token. Pull mode has no token. The hub reads the
GitHub branch and creates a credential-free shallow Git directory through the
source-control adapter. Both modes store repository files under
versions/{vid}/workspace/. Pull mode also stores .git content under the
sibling versions/{vid}/git/ prefix. The optional workspace.zip startup
artifact duplicates the final sandbox tree and is pruned with the enclosing version.
See the sync guide for the API and token lifecycle.
{
"version_id": "ver_01ARZ3NDEKTSV4RRFFQ69G5FAV",
"notebook_id": "nb-5g43rv2s9pfw8w4d",
"saved_at": "2025-03-05T14:22:00Z",
"author": "user_abc123",
"message": "Add regional breakdown by Q",
"parent_id": "ver_01ARZ3NDEKTSV4RRFFQ69G5FA0",
"html_snapshot": { "captured_at": "2025-03-05T14:40:00Z", "size_bytes": 524288 },
"session_snapshot": { "captured_at": "2025-03-05T14:40:00Z", "size_bytes": 81920 }
}Versions are immutable once written. A local save writes a new version, updates
source.current_version_id, and updates the notebook-level workspace. A Git
sync writes the complete workspace inside the new version. It then advances the
source pointer with CAS.
Optional snapshot descriptors. html_snapshot and session_snapshot record the presence and metadata of a rendered HTML snapshot (notebook.html) and a marimo session state file (session.json) sitting alongside the code in the version folder. They carry captured_at and size_bytes — never a storage path (clients address notebooks by ID, §13). Each field is absent when that artifact wasn't captured; both are written only on session teardown (§8) and only if marimo actually produced the source file, so most versions have neither. Adding these optional fields is forward-tolerant — a reader that doesn't know them ignores them — so it requires no breaking migration of existing version.json objects.
A session represents a live marimo kernel instance — a running Python process attached to a notebook. Sessions are mutable records (like catalog.json and the identity directory, §4.10), but they are independent: session writes never touch the snapshot chain.
Records are partitioned by project (_system/sessions/{pid}/{sid}.json) so the interactive reads — listActiveByProject (every notebook-table render) and findReusable (every session create) — list just that project's prefix instead of scanning every session in the deployment. Deployment-wide sweeps (the stale-expiry and terminal-reap passes, per-user session count) still list the whole _system/sessions/ prefix recursively. Because the key now carries the project id, the routing token for proxy exposure mode encodes {pid}.{sid} (see proxyToken.ts) so the forwarder can rebuild the key.
{
"session_id": "sess-4m8v6k2q9r5t7w3x",
"notebook_id": "nb-5g43rv2s9pfw8w4d",
"project_id": "proj-7h2k9qm4xz7rp3w8",
"user_id": "user_abc123",
"status": "running",
"started_at": "2025-03-05T14:30:00Z",
"last_heartbeat": "2025-03-05T14:35:00Z",
"authorization_expires_at": "2025-03-05T15:30:00Z",
"runtime": {
"python_version": "3.11",
"marimo_version": "0.9.0"
},
"sandbox_id": "sb-3f6h9k2m5p8r4t7v",
"sandbox_url": "https://sandbox.example.com/sess-4m8v6k2q9r5t7w3x",
"compute_profile": "small",
"used_fallback": false
}When a session goes failed, markFailed may persist an optional sanitized error object so the client polling the record can render why instead of a bare failed. It is omitted on healthy records and never carries secret material:
{ "status": "failed", "error": { "code": "SERVICE_UNAVAILABLE", "message": "Sandbox unreachable" } }Valid status values: starting · running · terminating · terminated · failed · expired
sandbox_id / sandbox_url link the record to the live kernel runtime (a separate container/compute service); compute_profile records the configured profile name used at launch and is absent when profiles are unset; used_fallback records whether a fallback runtime was used. The optional field is forward-compatible with existing records and requires no migration.
authorization_expires_at records the earliest applicable entitlement or subject-security-context deadline. OIDC group entitlements expire with the browser session or external access token that supplied them. Login-policy entitlements expire with the browser session. Session reuse can shorten this deadline, but the lifecycle never extends it. At expiry, it destroys direct subdomain kernels. HTTP proxy requests fail closed, and the Node proxy closes established WebSockets. This teardown skips the final capture so that the kernel stops promptly. Periodic snapshots limit potential data loss. The session API does not return this internal field.
Session lifecycle:
POST /sessionscreates astartingrecord. Successful provisioning changes it torunningand records the sandbox location.- A heartbeat updates
last_heartbeat. The service coalesces bucket writes to one write per minute. DELETE /sessions/{id}changes the record toterminating. Teardown changes it toterminatedafter the sandbox is gone.- The lifecycle sweep changes an overdue live session to
expired. - The reaper deletes old
terminated,failed, andexpiredrecords after the retention period. Records with asandbox_idalso requiresandbox_reclaimed_at, which confirms sandbox destruction.
On object storage vs. a live state store for sessions: the object store is appropriate for session records — created on open, updated periodically, read for display — and the record carries
sandbox_id/sandbox_urlpointing at the live kernel runtime (a separate container/compute service). It is not appropriate for sub-second kernel state (output streaming, variable inspection); that lives in the kernel runtime itself, backed by a low-latency state store (in-memory cache / stateful coordinator) if cross-request coordination is needed. For MVP — tracking who has what open, TTL cleanup — the object store is sufficient.
App sessions. A session record may carry mode: "app": the notebook served as a read-only application via marimo run instead of the editor. App pool records route accounts to versioned sandboxes; user_id records the starter for aggregate compute caps. Apps load copy-only and never write back notebook code, outputs, workspace files, or filesystem snapshots. app_pool: true identifies managed pool members; source_version_id pins their committed source. active_connections and connections_checked_at hold the last kernel connection probe. Absent mode means edit.
This legacy singleton claim remains readable during migration. New app admissions use app pool records and never acquire this claim.
{ "session_id": "sess-4m8v6k2q9r5t7w3x", "claimed_at": "2025-03-05T14:30:00Z" }SessionService.releaseApp CAS-writes the free marker session_id: null only when the claim still names the retiring session. A raw delete could remove a different holder acquired after the read. Notebook or project deletion may remove subordinate claims as cleanup.
Legacy apps are adopted as drain-only pool members. Stop singleton writers before enabling pool writers; see the coordinated rollout requirements.
The editor claim is the persistence fence for edit sandboxes. Exactly one
non-ephemeral session holds it. Under shared, every editor attaches to that
holder. Under exclusive, only the holder attaches; another editor explicitly
chooses a discard-only temporary session or a takeover.
{
"session_id": "sess-…",
"sharing": "exclusive",
"claimed_at": "2026-07-31T12:00:00Z",
"transfer": {
"takeover_id": "client-idempotency-key",
"requested_by": "user_b",
"phase": "draining",
"requested_at": "2026-07-31T12:10:00Z",
"drain_lease_id": "server-attempt-id",
"drain_lease_expires_at": "2026-07-31T12:20:00Z",
"drain_lease_stage": "capturing",
"drain_lease_progress_deadline_at": "2026-07-31T12:40:00Z"
}
}Claim acquisition, stale-holder replacement, release, and takeover phase changes use ETag CAS. Snapshot and teardown operations write notebook content only when the session still holds the claim.
A takeover moves through three phases:
requestedreserves the expected holder and connection state.drainingprotects ownership while save or sandbox destruction retries. A CAS-acquired lease serializes recovery so concurrent retries cannot save or destroy the old sandbox twice. The holder renews its ten-minute deadline every minute until it atomically moves the claim toready. An expired lease cannot be renewed by its former holder. Renewal is also capped at 30 minutes without progress. Finishing notebook capture, filesystem snapshotting, or sandbox destruction starts a new 30-minute progress window. Another replica can therefore recover a drain whose worker stopped or whose current operation is permanently stuck.readypermits only the requester to create the replacement session.
The create route completes the transfer after the replacement reaches
running. A save or destruction failure does not create the replacement.
The event log is the primary audit trail. Object stores provide no atomic append, and a single shared per-day file would lose events under concurrent writers (a read-modify-write race). So each event is written as its own immutable object, keyed by a monotonic ULID under a per-day prefix. This is write-safe with no locking, and because ULIDs sort lexicographically by time, listing a day's prefix returns events in append order.
Every event carries a schema_version field so the log stays parseable as the event shape evolves. Reading a day lists _system/events/{date}/, excludes _idempotency/, and parses each event object. Operations that need retry-safe appends first create an immutable _idempotency/{key}.json marker that records the event ID and body. A scheduled job may optionally compact a finished day into a single archive file for export/analytics.
// _system/events/2025-03-05/01HXYZ9ABCDEFGHJKMNPQRSTVW.json
{
"id": "01HXYZ9ABCDEFGHJKMNPQRSTVW",
"schema_version": 1,
"ts": "2025-03-05T14:22:00Z",
"event": "notebook.create",
"actor": "user_abc123",
"project_id": "proj-7h2k9qm4xz7rp3w8",
"notebook_id": "nb-5g43rv2s9pfw8w4d",
"snapshot_id": "snap-7h2k9qm4xz7rp3w8"
}Event types (emitted today): every catalog mutation is appended by CatalogService after its winning CAS commit (best-effort: a failed append never fails the mutation, it bumps the events.append_failed metric). The event field is the operation name: project.create · project.update · project.members · project.delete · project.gc · notebook.create · notebook.update · notebook.delete · notebook.gc · notebook.synced.create · notebook.synced.sync. Context fields (project_id, notebook_id, …) ride along per operation. Project managers read one day with GET /api/v1/projects/{pid}/events?date=. Super admins read a paginated deployment stream with GET /api/v1/events. Each deployment query covers at most 30 UTC days.
Planned, not yet emitted: session lifecycle (session.create · session.terminate · session.expired), notebook.run, notebook.snapshot (snapshot capture on teardown, §8), and migration.run.
The identity directory maps a stable user id — the auth sub, which is what every author / user_id / actor / owner field stores — to that user's current human-readable identity. It exists so the UI can render those opaque ids as a person (e.g. "Started by Ada Lovelace", "Created by …") without denormalizing a name/email onto every notebook, session, and snapshot.
// _system/identities/user_abc123.json
{
"id": "user_abc123",
"email": "ada@example.com",
"name": "Ada Lovelace",
"picture_url": "https://idp.example.com/avatar/ada",
"suspended_at": "2026-08-11T18:00:00Z",
"updated_at": "2026-08-11T18:00:00Z"
}If the identity provider supplies no display name, name uses the email local-part. AuthUser.name is optional. OIDC reads the name claim from the profile scope. Access reads its name claim. The optional picture_url must be an HTTPS URL from the identity provider. An absent suspended_at means the user is active; a timestamp blocks browser sessions and personal access tokens at authentication time.
Why this shape — store the id, refresh the directory. The foreign keys throughout the store (author, session.user_id, snapshot.actor, project.owner/members) keep storing the stable id only, never a name/email copy. A copy denormalized at write time would go stale when a user later changes their display name or email; instead the directory is refreshed on every authenticated request, so resolution always reflects the latest known identity. Reads resolve ids → identities in one batch lookup (GET /api/v1/users?ids=…, §13) rather than touching the snapshot.
Mutability & write semantics. An identity object is mutable and owned by IdentityService. Profile and suspension updates use ETag compare-and-swap with retries. A profile refresh applies the latest IdP fields while preserving suspended_at, including when it races a suspension update on another replica. This record is independent of the catalog.json snapshot chain and never touches it. The key is the URL-encoded user id, so non-subdomain-safe sub values (e.g. auth0|abc) remain addressable.
Suspension cache. Authentication reads status by the requested user key; it never scans the directory. Each process caches active results as fresh for 10 seconds, serves them stale while revalidating until a hard age of 30 seconds, then blocks for storage and fails closed if status cannot be verified. Suspended results are fresh for five minutes and remain denied while stale revalidation runs. Cache loads are single-flighted and bounded to 10,000 least-recently-used entries per process.
Write volume. The upsert runs in the auth middleware on every /api/v1/* request, but is write-coalesced: the service keeps a per-process id → {email,name} signature cache and skips the PUT when the incoming identity is unchanged since this process last wrote it (IdentityService.upsert, packages/core/src/services/identity/IdentityService.ts). So steady-state cost is ~one PUT per user per process lifetime (plus one whenever their email/name actually changes), not one per request. The upsert is best-effort — a failure is logged and never blocks the request.
A personal access token record is the machine-credential counterpart of a session cookie. The token acts as its issuing user. A scoped record can only reduce the user's current authority.
// _system/tokens/01HXY0S6GWMBASVAG3PZ7Y2K5T.json
{
"id": "01HXY0S6GWMBASVAG3PZ7Y2K5T",
"user_id": "user_abc123",
"name": "ci-deploy",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"created_at": "2026-07-24T14:30:00Z",
"expires_at": "2026-10-22T14:30:00Z",
"last_used_at": "2026-07-24T15:00:00Z"
}This record is a legacy version 1 token. It has no credential_version or
grant. It grants full PAT access within the user's current authority.
A scoped token has this version 2 shape:
{
"id": "01HXY0S6GWMBASVAG3PZ7Y2K5T",
"user_id": "user_abc123",
"name": "notebook-runner",
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"credential_version": 2,
"grant": {
"actions": ["project.read", "integration.read", "integration.use", "session.start"],
"projects": ["proj-7h2k9qm4xz7rp3w8"]
},
"oauth": {
"client_id": "01HXY0S6GWMBASVAG3PZ7Y2K5V",
"resource": "https://hub.example.com/mcp",
"scopes": ["mcp:tools"]
},
"created_at": "2026-07-24T14:30:00Z",
"expires_at": "2026-10-22T14:30:00Z"
}actions is "*" or a unique canonical action list. projects is "*" or
1 to 100 unique project IDs. Parsing fails for a missing, inconsistent, or
unknown credential version. The optional oauth object binds an OAuth token to
its client, MCP resource, and scopes. Other fields remain loose so the daily
last_used_at rewrite preserves fields from later releases.
Why keyed by token id. The presented bearer is
mhub_pat_<tokenId>_<secret>. The embedded ULID enables one-GET verification
without a mutable index. Version 1 hashes the secret. Version 2 hashes
mhub_pat:v2:<tokenId>:<secret>. This domain separation prevents a legacy
verifier from authenticating a scoped token after prefix rewriting. The
plaintext is never stored.
Token listing and creation scan the deployment-wide _system/tokens/ namespace.
They filter the records by user after the scan. The scan includes expired
records. The 20-token live-token cap does not bound this storage cost.
Mutability & write semantics. Creation is a plain PUT to a fresh, unique token-id key. The only rewrite is the last_used_at refresh, and it is conditional — an If-Match on the ETag read at load time — so a token revoked (deleted) between load and the touch is not resurrected by a stale write; the touch is also coalesced to once per UTC day, keeping the request hot path read-only. Revocation is a plain DELETE. Positive verifications are cached per process with a short TTL (TokenService.CACHE_TTL_MS), which also bounds the cross-replica revocation lag.
The Node proxy has a similar per-process cache for successful editor admissions, including VS Code and other surface proxies. An entry lives up to 5 seconds, or less when the credential or entitlements expire sooner. Token signature, sign-in, suspension, and authorization deadlines are checked on every request. So stopping a session, revoking a role, deleting a project, or changing labels can take up to 5 seconds to block an editor connection that was already admitted. App and preview admissions are never cached.
Known limitation: Docker and Podman give each kernel a random local port. When MARIMOHUB_SANDBOX_AUTH is off, a new session could in theory reuse the port of a session stopped within that 5-second window, and a cached admission for the stopped session would then reach the new kernel.
A loopback grant binds the user, PAT lifetime, and PKCE challenge to a one-time
mhub_cli_… code. A device grant starts without a user. It has an eight-letter,
base-20 user code. The create-if-absent user-code claim provides an exact lookup.
Browser approval uses CAS to add the user and PAT lifetime. Grants expire after
ten minutes. Scoped requests also bind a requested token grant. Approval can
remove actions or projects but cannot add them.
The CLI receives the authorization secret. Storage contains only its SHA-256 hash. Device polling must also supply the PKCE verifier before it reveals the grant status. User codes are case-insensitive. Each process limits preview and approval attempts separately to five per user. Polling has a separate limit.
CliAuthorizationService is the only writer. Legacy records use
device_pending, pending, and claimed. Scoped records use
device_pending_v2, pending_v2, and claimed_v2. The v2 records carry the
requested grant. Approved and claimed records also carry the narrowed grant.
Exchange uses CAS to claim a pending record before PAT creation. This prevents replay and concurrent token creation. Exchange then deletes the authorization and user-code claim. Creation prunes at most 100 expired authorization records. Device creation also prunes at most 100 expired user-code claims. Approval, exchange, and polling do not prune records.
Old replicas reject the v2 states. During a mixed rollout, they can reject a scoped authorization or token. They cannot mint or authenticate it as a legacy full-access token.
OAuthClientStore stores immutable public-client registrations under
_system/oauth-clients/. Each client uses
token_endpoint_auth_method: "none", has permitted redirect URIs, and expires
after 90 days. A registration request prunes at most 100 expired records.
OAuthAuthorizationService stores ten-minute records under
_system/oauth-authorizations/. begin stores the client, redirect, PKCE
challenge, state, scopes, and resource. Approval uses ETag compare-and-swap
(CAS) to bind the user, token grant, PAT lifetime, and one-time code hash.
The service verifies the bindings, claims the record with CAS, and mints one
scoped PAT. This PAT stores the client ID, exact MCP resource, and mcp:tools
scope. The MCP endpoint rejects other PATs. A finally block deletes the
authorization. Denial uses CAS to mark the pending record as denied before
deletion. It then redirects with error=access_denied.
OAuth PATs expire within 90 days. The consent page defaults to 7 days. The authorization server does not issue refresh tokens.
OAuthRateLimitService owns one bounded CAS record per public OAuth endpoint
under _system/oauth-rate-limits/. All replicas share these sliding windows.
CAS contention fails closed as a rate-limit rejection; storage failures remain
server errors.
Dynamic registration is anonymous. Each successful registration emits an
oauth_client_registered event with the generated client ID and redirect count.
The event omits client-supplied names and URIs.
A project integration instance — a named, versioned configuration of a code-registered kind (postgres, iceberg_rest, …) rendered into every session's sandbox as env vars + files. Two records:
// projects/proj-x/integrations/intg-y/integration.json (head — CAS-managed)
{
"id": "intg-7h2k9qm4xz7rp3w8",
"project_id": "proj-7h2k9qm4xz7rp3w8",
"kind": "postgres",
"name": "prod",
"enabled": true,
"current_version": 3,
"created_by": "user_abc123",
"created_at": "2026-07-30T14:30:00Z",
"updated_at": "2026-07-30T15:00:00Z"
}
// projects/proj-x/integrations/intg-y/versions/000003.json (immutable)
{
"schema_version": 1,
"version": 3,
"kind": "postgres",
"kind_schema_version": 1,
"config": { "host": "db.internal", "password": { "$secret": { "kind": "managed", "envelope": { "kek_id": "…", "alg": "A256GCM", "iv": "…", "ciphertext": "…" } } } },
"created_by": "user_abc123",
"created_at": "2026-07-30T15:00:00Z",
"change_note": "rotate password"
}Mutability and write semantics. Version records are immutable and use
create-if-absent writes. A concurrent writer that loses a version number uses
the next number. Every head update uses ETag CAS through mutateObject.
ProjectIntegrationsStore is the only writer for a project head.
Two concurrent configuration edits both create history records. The head points
to the larger committed version number. Session records pin each rendered
integration as { id, name, kind, version }. This pin identifies the exact
configuration while the integration exists.
The head and its configuration form one concurrency unit. There is no separate mutable object for an individual secret field. API clients update the complete integration with the head ETag.
Replacing an inline secret writes new ciphertext into a new immutable version. It does not overwrite ciphertext in an older version. Old ciphertext remains until deletion removes the entire integration history. This is an explicit retention tradeoff of versioned integration storage.
Deletion removes the head first. A concurrent head update then fails its CAS
and removes the version that it appended. The delete operation then removes the
remaining integration objects. Session pins and integration.* events retain
the audit identity, but they do not retain deleted secret configuration.
Secret fields contain managed ciphertext envelopes or external reference markers. A reference marker contains only its backend and locator. The resolver fetches its value during a supported connection test or session rendering. A save validates the reference shape and configured backend without fetching the value.
The managed encryption context includes the head path and field path. As a result, a copied envelope cannot decrypt at a different path. A copy decrypts and encrypts each managed value for its destination. It keeps reference markers unchanged. The store rejects secret boxes outside registered secret fields.
The name claim (_names/{name}.json, name URI-encoded) anchors "one instance per name" the same way the app claim anchors "one app per notebook": { integration_id, claimed_at }, acquired via acquireSingletonClaim (create-if-absent, ETag-CAS replace when stale) and released by CAS-ing integration_id to the free marker null, never a bare delete. Writers put the head first, then claim — a holder is live iff its head still exists under that name, so the claim self-heals after a crash between the two writes, and two concurrent creates are arbitrated by the claim key: the loser deletes its own just-written objects. A rename claims the new name (reverting the head on conflict, so a combined rename+config PATCH that loses commits nothing) and then frees the old; delete frees the name last. Sole writer: ProjectIntegrationsStore. _names/ cannot collide with an instance directory (ids are always intg-…), and listings skip it.
The organization tier stores the same head, version, and name-claim records
under _system/integrations/. OrgIntegrationsStore is the only writer, and
the API limits its management routes to super admins. Organization heads do not
contain project_id. Identity validation rejects a record loaded through the
wrong tier.
Organization managed values use an HKDF context from the _system/ head path.
As a result, an envelope from one tier cannot be decrypted in the other tier.
For a new non-ephemeral session, the project store combines both tiers. It uses
an enabled organization integration unless the project has an integration with
the same name. The project integration takes precedence even when it is
disabled, which supports both overrides and opt-outs. Name claims are unique
only within one tier, so both tiers can contain an integration named
warehouse.
A job is a per-notebook headless-run definition (schedule + policy); a run is the CAS-managed record of one execution until it reaches a terminal status. See docs/jobs.md for the user-facing behavior.
// projects/proj-x/notebooks/nb-y/jobs/job-z/job.json (head — CAS-managed)
{
"schema_version": 1,
"id": "job-7h2k9qm4xz7rp3w8",
"notebook_id": "nb-3w8h2k9qm4xz7rp3",
"project_id": "proj-7h2k9qm4xz7rp3w8",
"name": "Nightly refresh",
"enabled": true,
"schedule": { "cron": "0 6 * * *", "timezone": "Europe/Berlin" },
"parameters": { "region": "eu-west-1" },
"retry": { "max_retries": 1, "backoff_seconds": 60 },
"timeout_seconds": 1800,
"concurrency_policy": "forbid",
"notifications": { "on": ["failure"] },
"created_by": "user_abc123",
"created_at": "2026-09-01T10:00:00Z",
"updated_at": "2026-09-01T10:00:00Z"
}
// …/jobs/job-z/occurrences/20260902T0400Z.json (immutable, create-if-absent)
{ "run_id": "run_01JX…", "fired_at": "2026-09-02T04:00:12Z", "outcome": "run" }
// …/notebooks/nb-y/job-index/{created_at}_{job-id}.json (empty, immutable)
// …/jobs/job-z/run-index/{reverse-ulid}.json (empty, immutable, create-if-absent)
// …/jobs/job-z/runs/run_01JX…/run.json (CAS-managed until terminal)
{
"schema_version": 1,
"run_id": "run_01JX…",
"job_id": "job-7h2k9qm4xz7rp3w8",
"notebook_id": "nb-3w8h2k9qm4xz7rp3",
"project_id": "proj-7h2k9qm4xz7rp3w8",
"status": "succeeded",
"trigger": "schedule",
"scheduled_for": "2026-09-02T04:00:00Z",
"source_version_id": "ver_01JX…",
"parameters": { "region": "eu-west-1" },
"attempt": 1,
"sandbox_id": "sb-…",
"timeout_seconds": 1800,
"queued_at": "2026-09-02T04:00:12Z",
"started_at": "2026-09-02T04:00:40Z",
"finished_at": "2026-09-02T04:03:05Z",
"deadline_at": "2026-09-02T04:40:40Z",
"exit_code": 0,
"output": { "html_bytes": 48211, "logs_bytes": 912 }
}
// _system/job-runs/proj-x/run_01JX….json (create-once; deleted after finalization)
{ "run_id": "run_01JX…", "continuation_run_id": "run_01JY…", "job_id": "job-…", "notebook_id": "nb-…", "project_id": "proj-…", "created_at": "…" }Ownership and mutability.
job.json— mutable, ETag CAS throughmutateObject, written only byJobsService, the same discipline as an integration head (§4.12).JobsServicealso maintains the snapshot's per-notebookjobsindex ({ id, enabled, schedule, updated_at }) throughCatalogService.mutateSnapshot, so the scheduler enumerates scheduled jobs from two GETs instead of scanningprojects/**. The timestamp prevents delayed updates from replacing newer index entries.job.jsonstays authoritative for the full definition. Offline catalog recovery (CatalogService.recover, writers stopped) is the only other writer. Rebuild mode indexes every survivingjob.jsonhead. Snapshot mode keeps the jobs in the selected snapshot's index, validates each against its surviving head, and reports unlisted heads asexcluded.job-index/{created_at}_{job-id}.json— empty, create-if-absent entries for oldest-first API pagination without materializing every definition.run.json— an operational record like a session (§4.8): every status change is a CAS transition through the run state machine, written only byJobRunService. Terminal runs are never rewritten; retention deletes wholeruns/{rid}/prefixes.occurrences/{key}.json— create-if-absent, immutable. The key is the UTC minute of the occurrence, so replicas with skewed clocks compute the same key and collide on the conditional PUT: the fire-exactly-once anchor. A crash between the claim and the run record leaves a claim naming a run that does not exist; the next tick re-writes that record idempotently with the claimedrunorskipoutcome (the claim is the commit point, the record write is retryable).run-index/{reverse-ulid}.json— empty, create-if-absent history entries. Complementing each Crockford Base32 digit makes ascending object-key order equal newest-first run order, so a cursor page reads only that page's run records. Retention deletes the entry with itsruns/{rid}/prefix._system/job-runs/{pid}/{rid}.json— the execution/finalization index: written create-if-absent before the run record and retained after the terminal CAS until the scheduler finishes idempotent auditing and retry creation. A marker without a record past the grace window is pruned by maintenance._system/job-operations/{pid}/{nid}/{jid}.json— an expiring singleton claim that serializes scheduling, manual triggers, updates, and deletion for one job._system/job-deletions/{pid}/{nid}/{jid}.jsonis the durable deletion fence owned byJobsServiceand checked before enqueueing or updating. Deletion writes the fence and removes both discovery indexes before cancelling runs; it deletesjob.jsonlast so interrupted cleanup can retry.- Outputs — write-once under a fresh run prefix; no CAS needed. They are not
notebook versions and never advance
source.json.
Job definitions and job runs have independent schema_version constants and
read-upgrade functions. Deploy readers for a new version before enabling its
writers. Older replicas reject unknown job or run versions instead of rewriting
them. Additive fields within a version are preserved by the loose stored
schemas; enum changes require a version bump. Project alert destination kinds
are open strings in storage, so an older replica preserves a kind introduced by
a newer one, while API inputs accept only the kinds that replica knows.
Deleting a job deletes its whole prefix (after cancelling active runs); deleting a notebook or project reclaims the subtree with everything else.
/app/{slug} opens the existing shared app and keeps the slug in the address bar.
Project managers and owners manage links through Share notebook → App links.
A notebook can have multiple links. Slugs share one deployment-wide namespace.
They contain 1–63 characters total: lowercase ASCII letters, digits, or hyphens,
with / between segments (for example, /app/team/overview). Each segment must
start and end with a letter or digit. Empty segments, dots, backslashes, spaces,
and percent escapes are rejected. Prefixes are independent aliases: owning
team grants no rights to team/overview.
Browser URLs and storage keys preserve slashes. API clients percent-encode the
whole slug as one path parameter, for example /api/v1/deep-links/team%2Foverview.
Resolve and release also accept literal slashes because path-prefix proxies can
decode %2F before forwarding the request. Both forms use the same slug validation
and notebook permissions.
Links retain authentication, project permissions, notebook security labels, and
session admission checks. Version 1 accepts only app targets and the inherit
access policy. Other target types and access policies require a future change.
Existing notebooks need no migration.
DeepLinkService is the sole writer of these records:
_system/deep-links/{slug}.json: the authoritative mapping, or a released marker.projects/{pid}/notebooks/{nid}/deep-links/{slug}.json: an empty index marker for notebook-local lists. The service writes it before the global registration. Lists check ownership against the global record, so stale markers are harmless.
Registration uses create-if-absent or ETag compare-and-swap (CAS). Release uses
CAS to replace the mapping with a marker. The bucket has no conditional delete.
Each new registration gets a new ID, so stale removal requests cannot release it.
Resolution reads one mapping without a bucket scan, then checks target access.
Resolver and notebook link-list responses use Cache-Control: no-store.
Release makes the slug available immediately. Old shared URLs can then open another notebook. Release does not stop app sessions or change notebook permissions.
Deleted notebooks and projects stop resolving immediately. Cleanup releases their slugs with CAS. Registration can also reclaim a slug whose target is deleted or missing. Storage errors never count as proof of deletion. Purge retries cleanup before it deletes local indexes.
Resource IDs (proj-, nb-, snap-, sess-, job-, prev-) are a short prefix plus a 16-character lowercase base32 random body — subdomain-safe and unguessable, but NOT time-sortable (see packages/core/src/ids.ts / schema.ts). Version IDs (ver_) and job run IDs (run_) use uppercase ULIDs because their lexicographic order is load-bearing for version pruning and newest-first run history. The examples below are illustrative; the regex in ids.ts is authoritative.
Do not infer recency from snapshot key order. Because snapshot IDs are random, listing
_system/snapshots/does not return entries in creation order. The current snapshot is always the one named bycatalog.json— never the "last" key. Where chronological order is needed (retention, recovery), use each object's storage timestamp (uploaded/LastModified) or the snapshot'screated_atfield, not the ID.
The current snapshot is always the one named by
catalog.json. An interrupted write can momentarily create a snapshot it never commits (the writer deletes it on conflict — see §7.3 / §11).catalog.jsonis the single source of truth for "current."
| ID Format | Usage |
|---|---|
proj-{rand} |
Project — random 16-char body, for example proj-7h2k9qm4xz7rp3w8 |
nb-{rand} |
Notebook — for example nb-5g43rv2s9pfw8w4d |
snap-{rand} |
Snapshot — random, not time-sortable, for example snap-… |
job-{rand} |
Notebook job — random 16-char body, for example job-7h2k9qm4xz7rp3w8 |
ver_{ulid} |
Notebook version — uppercase ULID, time-sortable on purpose |
run_{ulid} |
Job run — uppercase ULID, time-sortable for newest-first history |
sess-{rand} |
Session — for example sess-… |
| opaque string | User/actor — the authentication provider supplies this value |
intg-{rand} |
Integration — random 16-character body |
sb-{rand} |
Sandbox — random 16-character body |
prev-{rand} |
Notebook preview — random 16-character body |
A notebook's source.json declares where its code lives. The service reads
source.type and selects the correct load path. The snapshot index,
meta.json, versioning, and session model do not depend on the source type.
source.type |
Code location | Write path |
|---|---|---|
local |
Notebook-level workspace/ |
The API and session teardown save the workspace |
git |
versions/{vid}/workspace/ |
An external workflow pushes a complete revision |
Both source types keep files in the bucket. A git source has
provider: "github" and sync_mode: "push". Its current_version_id selects
the active immutable workspace. The hub does not pull from the repository host.
See §4.6 and the sync guide.
Exactly 2 bucket GETs regardless of scale.
GET _system/catalog.json
→ { current_snapshot_key: '_system/snapshots/snap-7h2k9qm4xz7rp3w8.json' }
GET _system/snapshots/snap-7h2k9qm4xz7rp3w8.json
→ Full project + notebook index (metadata only, no code)
// Step 1: Fetch metadata in parallel
GET projects/{pid}/notebooks/{nid}/meta.json
GET projects/{pid}/notebooks/{nid}/README.md
GET projects/{pid}/notebooks/{nid}/source.json
// Step 2: Resolve code based on source.type
if source.type === "local":
GET projects/{pid}/notebooks/{nid}/workspace/notebook.py
if source.type === "git" and source.current_version_id === null:
return NOT_FOUND (the synced notebook has not been synced)
if source.type === "git":
GET projects/{pid}/notebooks/{nid}/versions/{source.current_version_id}/workspace/{source.entry_notebook}
For a git source, source.entry_notebook is the validated notebook path from
source.json, relative to source.root_path. A successful push confirms that
this path exists in the immutable workspace before it updates
source.current_version_id.
Every write follows the same atomic sequence. Steps 1–2 are safe to retry independently. Step 5 is the commit.
// Step 1: Write notebook content files
PUT projects/{pid}/notebooks/{nid}/meta.json
PUT projects/{pid}/notebooks/{nid}/README.md
PUT projects/{pid}/notebooks/{nid}/source.json
PUT projects/{pid}/notebooks/{nid}/workspace/notebook.py // local only
PUT projects/{pid}/notebooks/{nid}/workspace/pyproject.toml
// Step 2: Write immutable version snapshot
PUT projects/{pid}/notebooks/{nid}/versions/{vid}/notebook.py
PUT projects/{pid}/notebooks/{nid}/versions/{vid}/pyproject.toml
PUT projects/{pid}/notebooks/{nid}/versions/{vid}/version.json
// Step 3: Read catalog + current snapshot
GET _system/catalog.json → save ETag as current_etag
GET _system/snapshots/{current_snapshot_id}.json
// Step 4: Write new snapshot
PUT _system/snapshots/{new_snapshot_id}.json
// Step 5: Atomic catalog swap (conditional PUT)
PUT _system/catalog.json If-Match: {current_etag}
// If 412 Precondition Failed:
// DELETE _system/snapshots/{new_snapshot_id}.json ← remove the orphan
// then retry from Step 3
// Step 6: Write event object (best-effort, non-blocking)
PUT _system/events/{today}/{event-id}.json
A Git sync uses a different content write. It stores the fetched or uploaded
tree under a new versions/{vid}/workspace/ prefix. Pull mode also stores Git
metadata under versions/{vid}/git/. The sync then advances
source.current_version_id with ETag CAS. See §4.6.
Conflict Safety: If two writers race on Step 5, one receives
412 Precondition Failedand retries from Step 3. Steps 1–2 are idempotent — re-running them on retry is safe; the content files are already written before the conflict window opens. The loser also deletes the snapshot it wrote in Step 4 before retrying, so a failed attempt never leaves an orphan. Note that the content files in Step 1 are written before and outside the conditional swap: two concurrent saves to the same notebook are last-writer-wins on the liveworkspace/notebook.py, while the immutableversions/{vid}/objects remain the authoritative per-version record.
// Step 1: Soft-delete — mark status: 'deleted' in new snapshot
// All objects remain intact for 30-day grace period
// Step 2: Atomic catalog swap (same as 7.3 Steps 3–5)
// Step 3 (deferred, after grace period): Hard delete
DELETE projects/{pid}/notebooks/{nid}/meta.json
DELETE projects/{pid}/notebooks/{nid}/README.md
DELETE projects/{pid}/notebooks/{nid}/source.json
// + all workspace/* objects (notebook.py, pyproject.toml, any runtime files)
// + all versions/{vid}/* objects
Sessions are managed independently of the snapshot chain. A session write never touches catalog.json or any snapshot — it writes directly to _system/sessions/{pid}/{sid}.json.
| Route | Description |
|---|---|
GET /projects/{pid}/sessions |
List active project sessions with pagination |
POST /projects/{pid}/notebooks/{nid}/sessions |
Create or reuse a notebook session |
GET /projects/{pid}/notebooks/{nid}/sessions/{sid} |
Get one session |
POST /projects/{pid}/notebooks/{nid}/sessions/{sid}/heartbeat |
Update the session heartbeat |
DELETE /projects/{pid}/notebooks/{nid}/sessions/{sid} |
Save if permitted, destroy, and retire a session |
GET /projects/{pid}/notebooks/{nid}/editor-session |
Inspect persistent editor ownership |
POST /projects/{pid}/notebooks/{nid}/editor-session/takeover |
Take over an exclusive persistent editor |
A scheduled job (cron, every 5 minutes) performs two passes over _system/sessions/:
- Expire — any session whose
last_heartbeatis older than 5 minutes is flipped toexpired. - Reap — any
terminated,failed, orexpiredrecord whoselast_heartbeatis older than 24 hours is deleted.
The reap pass prevents unbounded growth under _system/sessions/. The same
maintenance cycle also reconciles provider sandboxes and applies retention to
snapshots, events, idempotency records, and soft-deleted content.
Persisting every heartbeat is the dominant session write cost: at 50 concurrent sessions on a 30s interval that is ~50 × 2 × 60 × 24 ≈ 144,000 PUTs/day, each a read-modify-write on a small object. Two levers bound it, in order of preference:
- Don't persist routine heartbeats to the bucket. Liveness belongs in the live kernel runtime; the bucket record only needs to change on create, status transition (
starting→running), and terminate. The expiry sweep can then read liveness from the runtime rather than fromlast_heartbeat. This drops heartbeat writes to ~0. - If heartbeats must be persisted, widen the interval (e.g. 60s) and/or coalesce — the 5-minute expiry TTL tolerates a coarser cadence.
v1 takes the second lever: heartbeat persistence is coalesced to at most once per 60s per session (SessionService.heartbeat skips the write when the session is already running and its stored heartbeat is younger than the interval). This bounds writes to ~1/min/session regardless of client cadence — roughly halving the figure above at a 30s cadence — and stays well within the 5-minute expiry TTL. Moving liveness off the bucket entirely (the first lever) remains a future option.
This write-back path applies to persistent sessions on local notebooks. A user edits the live workspace. The sandbox can mount that workspace or copy it back during teardown. Neither path creates an immutable version during the session.
Teardown creates a version from the final local workspace. It can attach the
optional __marimo__/notebook.html and
__marimo__/session/{notebook}.py.json artifacts to that version.
Git-synced sessions are ephemeral. They load a copy of the selected immutable workspace and discard all session changes. They do not create teardown versions or snapshots.
On teardown, before destroying the sandbox, the service:
// During SandboxProvisioner.teardown, before sandbox.destroy():
// 1. Always read the final notebook back (not only in the mount-fallback case),
// so the live workspace/notebook.py is persisted and the session's edits are captured.
// READ_FAILED / BACKEND_ERROR on notebook.py rejects capture so the caller can retry;
// NOT_FOUND logs `session_notebook_missing` and continues without code.
read notebook.py, pyproject.toml from the sandbox workspace
PUT projects/{pid}/notebooks/{nid}/workspace/notebook.py // live code
PUT projects/{pid}/notebooks/{nid}/workspace/pyproject.toml // live deps
// 2. Cut a new version IF the content changed since current_version_id
// (skip when unchanged, so a read-only session creates no spurious version).
if content != current version:
vid = ver_{ulid}
PUT versions/{vid}/notebook.py
PUT versions/{vid}/pyproject.toml
PUT versions/{vid}/version.json // message e.g. "Session edits", author = session user
update source.json.current_version_id = vid
prune to MAX_VERSIONS (§7.3)
else:
vid = current_version_id
// 3. Copy marimo's optional artifacts into THAT version's folder (best-effort).
if exists __marimo__/notebook.html:
PUT versions/{vid}/notebook.html Content-Type: text/html
if exists __marimo__/session/{notebook}.py.json:
PUT versions/{vid}/session.json Content-Type: application/json
patch versions/{vid}/version.json with html_snapshot / session_snapshot descriptors (§4.7)
// 4. Capture the rest of the workspace IF PERSIST_WORKSPACE=workspace (best-effort).
// Steps 1–2 own the root source files (notebook.py / pyproject.toml).
// Capture excludes those files, regenerable caches at any depth, and .git/hooks/.
// It includes __marimo__/ and hidden files, subject to path, file-type, and size limits.
// Budget order: visible files, __marimo__/, other hidden paths, then each .git/ (nested too) all-or-nothing.
// Step 3 separately saves only the selected HTML/session artifacts in versions/.
if PERSIST_WORKSPACE == "workspace":
for each eligible runtime file under the working dir (including hidden files and __marimo__/):
PUT projects/{pid}/notebooks/{nid}/workspace/{path} // binary-safe
delete workspace/{path} objects no longer present in the sandbox // mirror deletes
// 5. Emit notebook.update (version) + best-effort notebook.snapshot (artifacts).
Steps 1–2 (the version cut) own the source files and are the durable record of the session's edits; step 4 captures the rest of the working dir into workspace/ and is covered below. The HTML/session step is a copy, not an export — no marimo export command runs; teardown only persists files marimo already wrote. This extends the SandboxProvisioner.teardown → commitSession flow (which reads notebook.py/pyproject.toml back) by routing the persist through the versioning service so a version is cut.
The version cut (steps 1–2) is the durable record of the session's edits, so a hard failure there should surface as a teardown error. The artifact copy (step 3) is purely additive and best-effort/non-fatal (mirroring version pruning, §7.3): a missing or unreadable __marimo__ file just means that version has no snapshot. Snapshots are therefore sparse — a version has one only if (a) it was cut by a torn-down session and (b) marimo produced the file — and version.json records which (if any) are present.
Because steps 1–3 target the same new version, the saved code, its rendered HTML, and the session outputs are coherent: the
notebook.htmlwas rendered from exactly thenotebook.pystored beside it. This is the key reason teardown cuts the version before attaching the snapshots, rather than pinning them onto a stalecurrent_version_id.
MARIMOHUB_PERSIST_WORKSPACE (source (default) | workspace) controls whether the sandbox's non-source runtime files survive a session. It governs step 4 of teardown above and the restore on the next provision.
| Mode | What workspace/ holds |
Capture (teardown) | Restore (provision) |
|---|---|---|---|
source |
notebook.py + pyproject.toml only |
source files only (via the version cut) | brings back exactly the source |
workspace |
source files plus runtime files (e.g. data/cars.csv) |
source files via the version cut, then mirror the rest of the working dir | brings back source and runtime files |
- Restore is unconditional. On provision, the contents of
workspace/are loaded into the sandbox working dir before marimo starts. Becauseworkspace/reflects whatever was last captured, no mode branch is needed —sourcemode simply has only the two source files to restore. - Capture is mode-gated and binary-safe. Under
workspace, capture copies runtime files from the working directory intoworkspace/without changing their bytes. This includes__marimo__/, hidden directories such as.git/, and dotfiles such as.envand.gitignore. Capture excludes the root source files (notebook.pyandpyproject.toml), which the version cut saves separately. It also excludes regenerable directories at any depth (.venv/,__pycache__/,.ipynb_checkpoints/,.mypy_cache/,.pytest_cache/,.ruff_cache/,node_modules/) and.DS_Storefiles; the list lives inpackages/core/src/integrations/workspaceIgnore.tsand proposal capture shares it. Capture never stores.git/hooks/at any depth, and restore never writes stored hooks back, because hooks are executable code. Mirror deletes remove stored runtime files that no longer exist in the sandbox. They leave the root source files and regenerable paths alone. A change fromworkspacetosourcealso removes the stored runtime files. - Caps. Capture is bounded by a max total-bytes and max file-count limit; a file skipped because it would exceed the cap is logged (
console.warn), never silently truncated. Selection is ordered so that caches cannot crowd out data: visible files first, then__marimo__/, then other hidden paths, in listing order within each group..git/goes last and is all-or-nothing, because a partial repository restores corrupt. If the whole tree does not fit the remaining budget, capture skips it with one warning and keeps the stored.git/copy instead of mirror-deleting it. A stale but complete repository is safer than none. The same rule applies to each nested repository (pkg/.git/), decided separately after the root.git/. workspace/is never pruned. Version pruning (§11) only reapsversions/{vid}/; the latest-onlyworkspace/mirror is untouched.
Schema migrations are a first-class concern because there is no database to run ALTER TABLE against. All migration targets — catalog.json, snapshot structure, meta.json, project.json, source.json, and the event log — have different migration strategies.
Prerequisite (satisfied): every independently migrated object carries a
schema_versionfield.catalog.jsonuses itsversionfield.
| Target | Strategy |
|---|---|
| Snapshot structure | Lazy — the service upgrades the current snapshot in memory before it writes the next snapshot. Old snapshots remain as-is. |
meta.json / project.json / source.json |
Fan-out migration job — reads all affected objects, rewrites with the new schema, emits migration.run. Iterate with pagination and process in chunks so the job stays within the runtime's per-invocation request/time limits (resume via list cursor; idempotent — see below). |
_system/events/** |
Never migrated — event records are immutable history. New event shapes get a bumped schema_version field. Consumers must handle multiple versions. |
catalog.json |
Migrated in place during the first write after a deployment that changes the catalog version. Its strict version value is not forward-tolerant. |
No
schema_versionby design: Operational records have noschema_version. This group includes sessions (_system/sessions/**), identities (_system/identities/**), tokens (_system/tokens/**), CLI and OAuth authorizations (_system/cli-authorizations/**,_system/oauth-authorizations/**), andfs_snapshot.json. Writers replace mutable records. Cleanup removes short-lived records. Readers accept compatible changes through optional fields and defaults.OAuth clients (
_system/oauth-clients/**) also omitschema_version, but they are immutable and retained for 90 days. Readers must remain backward-compatible with records written during that window. API response objects are also unversioned. The route path (/api/v1) versions the API contract.
async function runMigration(s3, fromVersion, toVersion, migrateFn) {
// s3.list is paginated; iterate the cursor and process in chunks so the job
// stays within the runtime's per-invocation request/time limits.
const keys = await s3.list({ prefix: 'projects/' });
for (const key of keys.filter((k) => k.endsWith('meta.json'))) {
const obj = await s3.get(key);
const data = JSON.parse(obj.body);
if (data.schema_version === fromVersion) {
const migrated = migrateFn(data);
migrated.schema_version = toVersion;
await s3.put(key, JSON.stringify(migrated));
}
}
await appendEvent(s3, {
event: 'migration.run',
from_version: fromVersion,
to_version: toVersion,
actor: 'system',
});
}Fan-out safety: Each migration checks
schema_versionbefore it writes. You can safely run an interrupted migration again. Run migrations during a low-traffic period and verify completion in the event log.
The bucket contains structured audit events. Application logs go to the
configured process or platform log destination. The bucket has no
_system/logs/ tree.
| Path | Purpose |
|---|---|
_system/events/{YYYY-MM-DD}/{event-id}.json |
Structured audit event. Each event is an immutable object under a UTC-day prefix. |
_system/snapshots/ |
Immutable catalog history. The maintenance job applies the configured snapshot-retention policy. |
Append strategy: S3-compatible storage has no native append, and read-modify-write on a shared file loses concurrent events. So each event is a separate immutable object keyed by a monotonic ULID (see §4.9) — naturally concurrency-safe, no locking. Reading a day lists that day's prefix. For high event volume, a buffer (in-memory queue / streaming pipeline) can batch writes upstream, but per-event objects are correct and sufficient at this scale.
| Topic | Detail |
|---|---|
| Retention | Keep snapshots for 90 days (with a floor of the N most recent), then delete via the maintenance cron — MaintenanceService.expireSnapshots (packages/core/src/services/catalog/MaintenanceService.ts). Every commit writes a new snapshot, so without this the bucket grows without bound (the Iceberg "snapshot-expiry" problem). |
| Recovery | Use the offline recovery procedure to select a surviving snapshot or rebuild from records. |
| Compaction | Not needed at this scale. If snapshot exceeds 1MB, split into per-project manifest files referenced by the snapshot instead of embedded. |
| Emergency rollback | Snapshot recovery restores catalog membership with current metadata. For local notebooks, use POST /api/v1/projects/{pid}/notebooks/{nid}/versions/{vid}/restore to roll back code. It copies the selected version's code and dependencies into the live workspace and writes a new version. |
| Snapshot size at full scale | ~200KB (200 bytes × 1,000 notebooks) |
| Version-folder artifacts | The HTML/session snapshots (§8) live inside the immutable versions/{vid}/ folder, not in _system/snapshots/. They need no separate retention job: NotebookService.pruneVersions deletes the whole version prefix when it ages out of MAX_VERSIONS, reaping notebook.html / session.json along with the code. |
Snapshot protection. Retention preserves the current and previous snapshots. Normal CAS conflict cleanup deletes only the losing writer's snapshot. If the catalog or its snapshot is missing, follow the recovery runbook. Surviving project objects or snapshots block empty initialization. Unreferenced snapshots require review because their creation time does not prove commit.
Authentication identifies the caller. Authorization controls their actions. The API resolves identity before it reaches storage.
Identity is resolved per request and controlled by the AUTH_MODE setting:
AUTH_MODE |
Use case | Mechanism | Identity |
|---|---|---|---|
access |
Multi-user | Cloudflare Access JWT (CF-Access-JWT-Assertion) verified against the team JWKS |
{ id: sub, email } from verified claims |
none |
Single-user, self-hosted | None — a fixed singleton user from config | { id: USER_ID, email: USER_EMAIL } |
A request that fails authentication receives 401 UNAUTHORIZED. The verified user is attached to the request context and used as the actor recorded in snapshots and events.
All routes use the same effectiveRole calculation:
- Project ownership and super-admin status grant
admin. - Explicit membership overrides defaults. Multiple matching memberships use the highest role.
- Project
default_roleoverrides deployment and OIDC defaults for non-members.nonegrants no default access. - An absent project default or
inherituses the higher deployment or OIDC default.
Project-default changes require manager at the API boundary. ProjectService.updateProject persists them through the existing metadata CAS.
The stored project.json field default_role is optional; an absent field means inherit.
Project responses always include default_role and report inherit when nothing is stored.
A change emits the broadcast-only project.default_access_changed project alert.
project.read requires viewer; app.read admits app-user to minimal app metadata.
Notebook content changes require editor; notebook administration and project changes require manager.
See the role matrix and app restrictions.
Normal reads mask inaccessible or soft-deleted resources as 404; insufficient write permissions return 403 FORBIDDEN.
Security labels and credential scopes can further restrict access.
Catalog entries carry optional member_ids, member_emails, and member_groups projections, but no roles or default access.
A missing member_groups field is empty; it grants no directory access.
Directory search builds membership candidates from the snapshot and confirms them against current project heads.
A stale projection cannot retain a revoked grant.
Listings read permissions from project.json because catalog projections can lag changes.
Each listing reuses project reads across role, tag, and label checks.
Roles, credential scopes, and labels are filtered before pagination and totals.
This preserves the catalog snapshot model (§7.1) without a separate per-user index.
MARIMOHUB_SUPER_ADMINS entries containing @ match login emails case-insensitively; other entries match user IDs exactly.
Static super-admin status applies to PATs. OIDC group-derived status applies to browser sessions and enabled external access tokens, but does not transfer to PATs.
Super admins cannot demote or remove project owners. Role elevation does not bypass lifecycle checks or token scopes.
Project creation follows MARIMOHUB_PROJECT_CREATION.
App-only users always need super-admin status or project-creator, including on open deployments.
The server resolves app-only status from current defaults and effective memberships on each request.
Creators become project owners with the reserved admin role.
Project members contain exactly one user_id, email, or group, plus a role.
Group ids use the auth port's shared validator and match authenticated groups exactly.
Equal-role ties report the user-id source before email, and email before group.
Group rows cannot carry admin and never become user rows during invite claiming.
ProjectService writes group members and their member_groups projection through the existing metadata and catalog CAS paths.
These writes are separate; authorization must use the project head after a projection failure.
Group mutations use { group } selectors, separate from string selectors for user ids and invite emails.
The schema version remains 1. See group membership rollout before storing group rows or rolling back.
Older versions parse project.json with a stripping schema, so they drop or ignore default_role.
A member change or rename on an old replica rewrites the record without it, and a rollback ignores it.
Either way the project falls back to the inherited default: the deployment default (editor out of the box) or a higher OIDC group or login-policy default-role grant.
Stop every old replica before storing a value other than inherit.
Before rollback, reset overrides to inherit. A project that used none then grants nonmembers the inherited default again; explicit memberships do not suppress it.
Keep such a project members-only by setting the deployment default to none and removing the OIDC group and login-policy default-role grants that would apply.
See default access rollout.
app-user, viewer, editor, and manager are assignable; existing non-owner admin memberships remain valid.
Upgrade every replica before assigning a role that older versions cannot parse.
Rollback requires removing or explicitly remapping unsupported assignments and configuration.
Never silently map app-user to viewer, which grants source access.
App users can start, attach to, and heartbeat shared apps regardless of MARIMOHUB_VIEWER_MODE.
They cannot use editor sessions, secondary surfaces, or stop/restart controls.
Viewer runtime access depends on MARIMOHUB_VIEWER_MODE:
staticserves saved HTML throughGET …/notebooks/{nid}/html, with the same authorization as other content reads.applicationsalso permits shared app start, attach, and heartbeat.ephemeral-sandboxalso permits private editor sessions, accessible only to their owner.
Viewer sandboxes are stamped ephemeral: true and never receive WIF credentials.
Every capture or teardown path skips write-back: no versions, HTML/session snapshots, workspace mirrors, or filesystem snapshots.
These paths include explicit stop, idle/lifetime reaping, periodic capture, and reconciliation.
An app-user downgrade blocks access to existing editor sessions, including previously owned ephemeral sessions.
See source protection and revocation for direct sandbox URL limitations.
The API is an authenticated boundary between the frontend and the Bucket
port. It runs in the Node and Cloudflare Worker entrypoints. It owns auth,
resource IDs, source handling, snapshots, sessions, and audit events.
Clients address notebooks by {project_id, notebook_id}. They never receive
storage credentials, raw bucket access, or the stored key_prefix.
Routes are defined with @hono/zod-openapi: each declares typed request and response schemas, and the live spec is served at GET /api/v1/doc (OpenAPI 3.1). That generated document — not a hand-maintained table — is the source of truth for paths, parameters, request bodies, and response shapes. All routes are mounted under /api/v1, in these groups:
- Auth — current user and personal access token management
- Users — identity resolution and directory search
- Projects — project, member, and audit-event management
- Audit — the super-admin deployment audit stream
- Notebooks — local and Git-synced notebooks, content, versions, restore, duplicate, HTML snapshots, and sync-token rotation
- Sessions — list, create, inspect, heartbeat, stop, editor ownership, and exclusive takeover
- Integrations — kinds, project and organization instances, versions, copy, and connectivity tests
- System — deployment version and capability limits
JSON responses use one envelope. Success:
{
"success": true,
"data": {
/* route-specific */
}
}Errors (no data; a typed code plus a human-readable message):
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Notebook nb-… not found" } }The HTML snapshot route returns raw text/html on success. Its errors use the
JSON envelope.
| HTTP | code |
When |
|---|---|---|
| 401 | UNAUTHORIZED |
Authentication missing or invalid (§12) |
| 403 | FORBIDDEN |
Authorization denied — caller lacks the required role on the project (§12) |
| 404 | NOT_FOUND |
Resource does not exist |
| 409 | CONFLICT |
Write conflict — CAS retry budget on catalog.json exhausted |
| 412 | PRECONDITION_FAILED |
Conditional write mismatch surfaced from storage |
| 422 | VALIDATION_ERROR |
Request body or params failed schema validation |
| 500 | INTERNAL_ERROR |
Unexpected server error |
- Versioning —
/api/v1and the OpenAPIinfo.versionidentify the current contract. Put a breaking contract under a new route version. - Pagination — project, notebook, notebook-version, project-session, integration-instance, and integration-version lists use opaque keyset cursors. Bounded lists, such as project members and API tokens, return arrays. The OpenAPI response schema is authoritative for each route.
- Idempotency — reads,
PUT/PATCH, and soft-DELETEare naturally idempotent.POSTcreates are not by default: a retry after a network failure or a409 CONFLICTcan create a duplicate. Clients that care send anIdempotency-Keyheader onPOST /projectsandPOST …/notebooks— the server storeskey → resultunder_system/idempotency/and replays it on retry (24h TTL).POST …/sessionsis already idempotent on(user, notebook)via its reuse path. Seeidempotency.md.
All catalog changes use CatalogService.mutateSnapshot. It reads the catalog
and current snapshot, applies one mutation, and writes a new immutable snapshot.
It then updates catalog.json with an ETag conditional write. If another writer
wins, it removes its uncommitted snapshot and retries against the new catalog.
Only the successful writer appends the mutation event.
| Metric | Value |
|---|---|
| Projects | 10 |
| Notebooks per project | 100 |
| Objects per notebook (no versions) | 5 (meta.json, README.md, source.json, notebook.py, pyproject.toml) |
| Total notebook objects | 10 × 100 × 5 = 5,000 |
project.json files |
10 |
| Snapshots (90-day window @ 20/day) | ~1,800 |
| Session records (peak concurrent) | ~50 |
| Event objects (90 days @ ~20/day) | ~1,800 |
| Total objects (no versions) | ~8,660 |
| Snapshot size at full scale | ~200KB |
| Version folders (per notebook) | ≤ MAX_VERSIONS (50), each ~3 objects (notebook.py, pyproject.toml, version.json) |
| Optional snapshot objects | + ≤ 2 per version that ran a session (notebook.html ~0.5MB, session.json); sparse — only versions with a torn-down session have them |
Version + snapshot growth is bounded by
MAX_VERSIONS. Each notebook keeps at most 50 version folders; the optional HTML/session snapshots add at most two more objects to the versions that captured them. Both are reaped when the version ages out (pruneVersions, §11), so a notebook's footprint is capped regardless of edit/run frequency. HTML dominates the bytes (~0.5MB each), so a heavily-run notebook approaches ~50 × 0.5MB ≈ 25MB at the ceiling — size it viaMAX_VERSIONSif that matters.
| Operation | Request count |
|---|---|
| List all projects + notebooks | 2 GETs |
| Open a notebook | 4 GETs (meta + README + source + notebook.py) |
| Create/update notebook | ~8 PUTs + 2 GETs + 1 conditional PUT + 1 event PUT |
| Delete notebook (soft) | 2 GETs + 1 PUT + 1 conditional PUT |
| Session heartbeat | 1 PUT |
Heartbeat write volume dominates session cost. At 50 concurrent sessions on a 30s interval, persisted heartbeats are ~144,000 PUTs/day — far more than all CRUD traffic combined. §8 covers how to bound this (don't persist routine heartbeats; widen the interval).
| Topic | Detail |
|---|---|
| Object-store versioning | Enable native object versioning and lifecycle expiry as a backup below the snapshot layer. This can recover overwritten latest-only files and out-of-band deletions. See operations.md §1. |
| Notebook ordering | Notebook lists use created_at and the random notebook ID as a stable tiebreaker. Manual ordering needs a new explicit field. |
| Additional source types | local and push-synced git are implemented (§4.6). A future source type must define its write, sync, cache, and credential rules. |
| Read-side tenant isolation | Project-level isolation is implemented (§12). Listings confirm access from authoritative project heads; snapshot member projections alone do not grant access. Per-notebook ACLs and storage-enforced isolation for untrusted tenants remain open. |
| Execution logs locality | Run logs currently go to _system/events/. Alternative: projects/{pid}/notebooks/{nid}/runs/{run-id}.log for per-notebook locality. Tradeoff is discoverability vs. centralization. |
| Live state for real-time sessions | If kernel output streaming or sub-second variable inspection is needed, live session state belongs in a low-latency store (in-memory cache / stateful coordinator) co-located with the kernel runtime. Object storage retains the durable audit record. |
| Version pruning | Resolved. NotebookService.pruneVersions keeps the most recent MAX_VERSIONS (50) per notebook, pruning on each save (packages/core/src/services/content/NotebookService.ts). Snapshot and event growth are handled separately by MaintenanceService (operations.md §5). |
| Configurable version count | A variable such as MARIMOHUB_NOTEBOOK_MAX_VERSIONS can let operators trade history depth for storage. The same count can bound the optional HTML and session snapshots (§8). |
| HTML / session snapshots | Session teardown can capture both files (§8). GET …/notebooks/{nid}/html serves the newest HTML snapshot. The session-state JSON has no read route. API saves do not generate HTML because they have no live kernel. |
ThumbnailService owns projects/{pid}/notebooks/{nid}/thumbnail.json (ETag CAS).
The record holds separate custom and automatic image references. Custom images
take precedence. Automatic references include the captured source version, HTML hash,
renderer revision, and capture time. A deletion tombstone blocks in-flight captures.
Under the same notebook prefix:
thumbnails/{uuid}.pngimages are immutable. Maintenance removes unreferenced images after one day.- Create-once
thumbnail-attempts/{sandbox-id}.jsonmarkers prevent duplicate shutdown attempts across replicas. Maintenance removes markers after one day.
Soft deletion retains thumbnail objects during the recovery grace period. Hard deletion or garbage collection removes them. Images require notebook authorization and have no public bucket URLs.
_system/app-pools/{pid}/{nid}.json is a versioned CAS record owned by AppPoolStore.
It stores sandbox reservations, visit assignments, and per-visit leases together.
Each assignment is scoped to (user_id, visit_id). Its visit_id remains recorded during reconnect grace.
Legacy assignments without visit_id split into one assignment per live visit before routing or expiry.
AppPoolService applies the pure router decisions and coordinates session provisioning and retirement.
Compute operations run outside CAS retries. Operation tokens fence late provisioning completions.
Retiring members remain recorded until sandbox reclamation succeeds.
Notebook and project deletion CAS-mark their pools with deleted_at, retire all members, and clear assignments.
Maintenance retains each member until reclamation succeeds, even after the notebook or project files are removed.
The empty deletion tombstone remains to prevent delayed admissions from recreating the pool.
The legacy _system/apps/{pid}/{nid}.json claim remains owned by SessionService during migration.
New pool members do not acquire that singleton claim. See App pools for rollout and presence semantics.
_system/warm-pools/{backend}.json is a CAS record owned by WarmPoolStore.
It stores pool configuration fingerprints, retry deadlines, and sandbox members.
Member states are creating, ready, claimed, and retiring.
Creation reservations precede provider calls. Claims record their destination session before session publication.
An assigned claim remains until session reclamation, so an interrupted handoff does not lose ownership.
Retiring records remain until provider deletion succeeds. A claimed sandbox never returns to ready.
Orphan reconciliation discovers every record under _system/warm-pools/ after its provider snapshot.
A failed listing or unreadable warm record prevents orphan deletion during that sweep.
Pool cleanup uses recorded sandbox IDs and does not require provider enumeration.
The maintenance replica uses _system/_warm_pool.lock as its advisory lease.
Pool CAS transitions remain authoritative when maintenance passes overlap.
NotebookPreviewService owns each CAS record at _system/previews/{pid}/{nid}/{preview-id}.json.
Records hold the source, prepared revision, runtime ownership ledger, and session admission reservations.
PreviewStore owns these CAS records:
_system/preview-projects/{pid}.json: bounded membership, recovery intents, and artifact reservations._system/preview-receipts/{pid}.json: bounded idempotency receipts, valid for seven days from creation._system/preview-work.json: rotating project cursor and deployment-wide preparation leases._system/preview-cleanup-cursor.json: independent rotating cleanup cursor.
A one-minute deadline fences each creation attempt, independently of the receipt’s seven-day deduplication window. A rejected capacity reservation can retry with the same key and a fresh attempt deadline. Deleted receipts still reject retries. Expired receipts are pruned on project activity; idle receipt heads hold at most 1,000 entries and do not participate in scheduling.
Creation reserves membership before writing the preview record. Workers can materialize interrupted writes from the stored intent.
Listing reads only this bounded membership; it never scans historical preview records or receipts.
Deletion closes the receipt immediately. After reclamation and a 15-minute grace period, cleanup marks the membership terminal,
deletes the preview record, and removes membership. Terminal intents prevent interrupted cleanup from recreating active records.
Empty project CAS heads remain to fence concurrent writers; individual preview records do not remain permanently.
Workers page through removable _system/preview-active-projects/{pid}/{work_id}.json markers instead of retained heads.
Each activation uses a new work ID. Its marker is written before membership, and project mutations write a fresh revision.
Removing a stale marker first CAS-updates the project head to fence pending publications. Interrupted writes leave discoverable orphan markers.
Each pass selects up to four active projects and scans at most sixteen marker pages, reclaiming orphans as it goes.
Empty historical heads do not add scheduling latency; rotation time depends on projects with outstanding preview work.
Runtime access requires live membership and an active preview record.
Preparation runs independently of launches and maintenance. Leases fence publication; deadlines abort provider reads and stop workspace writes. Project reservations bound both revision counts and aggregate workspace bytes before archive download. Cleanup retires compute immediately and retains artifacts, ownership, and reservations through the cleanup grace period. This lets in-flight preparation uploads settle before artifact deletion.
Each revision has a unique internal notebook ID, immutable workspace, and preview ownership marker in preview-runtime.json.
Ordinary meta.json is absent, preventing older replicas from rewriting metadata without preview protections.
These notebooks never enter catalog snapshots or share pools, source artifacts, or editors with the parent.
Metadata reads require a published revision, use current parent labels and configuration, and reject inactive owners.
Normal notebook APIs reject internal IDs. Session APIs allow authorized access to running preview sessions.
Reconciliation retains revisions with unreclaimed sessions. Each entry in revisions records its runtime notebook ID, state (preparing, ready, or retiring), and cleanup deadline. Reconciliation marks unused revisions as retiring before deleting their artifacts.
Admission reservations and pruning share the preview CAS record, so admitted revisions cannot be pruned before session creation.
Committed reservations remain until sandbox reclamation; abandoned reservations without sessions expire after ten minutes.
Late admissions fail the ownership check. Cleanup defers fresh starting sessions and retries failed destruction without capturing edits.
App pool tombstones remain under their normal owner.
Preview sessions persist immutable origin provenance at creation: parent notebook, preview, prepared version, and commit.
The stored session's notebook_id remains the internal workspace identity used by compute, pools, and cleanup.
sessionResourceNotebookId resolves the public parent identity; API projections never return the hidden workspace ID.
sessionResourcePath provides the notebook or preview navigation target without reading runtime metadata.
Session controls accept the public parent notebook ID, then resolve their workspace and pool from the stored session.
History reads authorize against current parent labels; live operations additionally validate preview ownership and expiry.
Runtime cleanup does not erase provenance from retained sessions or audit events.
ResumableUploads owns records under two prefixes:
| Scope | Prefix | Caller |
|---|---|---|
| Workspace | projects/{pid}/notebooks/{nid}/uploads/ |
NotebookWorkspaceService |
| Customer objects | projects/{pid}/object-uploads/ |
Object-upload routes |
Each prefix has a CAS index.json with at most 16 reservations for declared sizes and expiry times.
The budget is the configured workspace total or 5 GiB per project for customer objects.
Each {id}.json stores the destination, size, owner, expiry, part size, part references and hashes, and publication state.
It also stores the native upload ID and completed result when available.
Object records also bind the integration ID, bucket, and revision. API requests enforce current authorization and upload ownership.
S3 and R2 workspace uploads create an isolated destination session at start. The upload record reserves each part checksum by CAS before forwarding bytes to the provider. A part appears in progress responses only after its provider receipt is saved. Retries must match its reserved checksum. Completion commits these receipts without storing or reading temporary part objects.
Other uploads keep staged parts at {id}/parts/{n} with create-if-absent writes and exact-size checks.
Retries must match the saved hash. Assembly checks each part against its saved size and hash.
Existing staged records keep this path when the adapter gains isolated-session support.
Completion uses CAS to transition uploading → publishing → complete.
An interrupted publishing record never replays its write, because a later writer can change the destination.
Workspace completion holds the mutation claim and rechecks capacity and editor state.
| Assembly | Adapters |
|---|---|
| Direct multipart session | S3, R2 workspace uploads |
Staged Bucket.multipart |
Azure; existing S3/R2 staged uploads |
Bucket.putStream |
FS, GCS |
| Buffered, at most 64 MiB | Other adapters |
Azure stays staged because other writes to the same blob can discard uncommitted blocks. GCS, filesystem, and object-browser uploads also retain staging. Multipart conditional writes are unsupported. The workspace mutation claim serializes create-only checks. Successful publication survives cleanup failure. Completion retries and maintenance release reservations and remove parts without another write.
Uploads expire after 24 hours. Completed results remain available for seven days past expiry; cancellation preserves completed receipts. A confirmed publication can finish after expiry. A confirmed publication does not revive a receipt already cancelled by maintenance. Notebook/import maintenance aborts known native uploads and removes staged parts. Records remain seven more days so later sweeps can remove parts from delayed requests. Azure expires uncommitted blocks through provider retention. Abort must preserve an existing committed blob. Hard deletion aborts known sessions before removing upload records. Failed aborts leave records for retry. Configure provider lifecycle cleanup for sessions whose creation response or receipt was lost.