Storage and operations#

How wenchang uses a GCS bucket, what that costs, and how to erase or export one entity's memory. This page describes the GCS backend, GcsStorage (wenchang.storage.gcs); the same MemoryStore operations apply to FilesystemStorage (wenchang.storage.filesystem) except where noted, and the bucket, IAM, timeout, and retry sections do not apply to it. The reference has the Storage protocol and the store's signature.

The bucket#

Memory files are objects in one bucket. One GcsStorage(bucket) wraps one google.cloud.storage.Bucket. Quickstart creates one with gcloud. One GcsStorage and its client are safe to share across threads, as the MCP adapter does; see Threads for caveats.

wenchang finds credentials through Application Default Credentials: locally gcloud auth application-default login, and on Google Cloud the attached service account. Whatever identity the server runs as needs to read, create, overwrite, delete, and list objects in the bucket. roles/storage.objectUser on the bucket grants those. Operator jobs that purge need the same permissions.

One bucket, many people. Every user and project writes into the same bucket under their own path prefix. Access control between users is wenchang's scope rules, not bucket permissions. Anyone who can read the bucket directly can read all of it.

Bucket soft delete and object versioning keep deleted bytes recoverable. Set them to match your retention obligations; see Deleted bytes in GCS.

Objects and metadata#

Each memory file is one object. Its key is the file's path, {scope}/{entity_id}/{area}/{name}.md (see Concepts). Its body is the file content as UTF-8, with content type text/markdown; charset=utf-8. Its version is the object's generation.

Under FilesystemStorage, the same file is {key}%obj holding the exact body, beside {key}%meta, a JSON sidecar with the version (a random string) and the metadata below. Each key segment is percent-encoded. You can read the %obj files directly; see Reference.

The file metadata is GCS custom metadata on the object, under four keys:

Stored key Encoding Example
description Plain string, one line. Lin Qiao, the protagonist
aliases JSON array of strings, in order. ["Qiao", "Lin"]
sources JSON array of strings, sorted. ["claude-code", "inkwell-web"]
last-updated ISO 8601 in UTC with a Z suffix. 2026-10-30T14:02:11.402913Z

Tool results and the index show the last one as last_updated, with an underscore, and aliases and sources as arrays. Other keys on an object are ignored.

Writing objects out of band#

Reads and listings parse all four keys and require every one. An object written another way, such as gcloud storage cp or the GCS console, without them raises MetadataFormatError, which the agent sees as an internal error naming a missing key, such as aliases: missing required key. How far that reaches depends on the tool:

To fix it, set all four keys on the object or delete it. To avoid it, write through a MemoryStore, as the seed script does, or set all four keys yourself. A store with an index cache does not see out-of-band writes; invalidate the entity afterwards.

Files per entity#

An entity is everything under one {scope}/{entity_id}/ prefix: one user's private memory, or one project's shared memory. MemoryStore(max_files_per_entity=...) caps how many files it holds. The default is 500 (wenchang.core.DEFAULT_MAX_FILES_PER_ENTITY); None disables the cap, and any other value must be positive (ValueError otherwise).

store = MemoryStore(storage, max_files_per_entity=200)  # or None for no cap

Only creates are checked: a write_file with expected_version=null outside system/. Replacing a file, append_line, replace_fact, delete_file, and reads never are, so an entity at its cap keeps working on the files it has. Files in system/ and keys that are not well-formed memory paths are not counted. Deleting a file frees a slot.

At the cap, the create fails with a recoverable FileQuotaExceededError whose payload carries limit (plus scope, area, and name). If a file already exists at that path, the create fails with VersionConflictError instead, as it would below the cap. For scope user, area notes, name new, and a limit of 3, the agent sees:

Cannot create a new file at scope user, area notes, name new: memory in scope user is at its limit of 3 files (system/ files not counted). Add this content to an existing file instead (append_line, replace_fact, or write_file with that file's version), or merge related files into one and delete the merged ones with delete_file, then retry.

followed by the standard recoverable guidance, so the agent consolidates and retries in the same turn without asking the user.

The cap is soft. Counting and writing are not atomic, so concurrent creates in one entity can exceed it by at most the number of creates racing at the boundary. Each checked create counts the entity's keys with Storage.list_page, in pages of max(limit - count, list_page_size) keys, stopping once it reaches the limit: at most the limit plus the uncounted keys plus one list_page_size page, and usually one list call.

Index cost#

get_memory_index is what the agent calls at the start of every session. It costs you in two places.

An index cache removes the listing for entities that have not changed.

The index cache#

You can give MemoryStore a cache for get_memory_index. The cache is optional, and without one nothing changes. With one, the store caches each entity's listing separately, and every write or delete through the store invalidates that entity's entry. Ordering, the size cap, and capped are still worked out on each call, so a cached index has the same shape as an uncached one.

wenchang does not ship a cache server. You supply two functions over the cache you already run, such as Redis.

What you provide#

wenchang.index_cache.IndexCache(get, set, ttl=None, key_prefix="wenchang:index:") is a frozen configuration object.

Both functions are required: set is how the store invalidates, so a read-only cache cannot work. Keys and values are opaque strings. Do not parse them.

Pass it to the store:

store = MemoryStore(storage, index_cache=IndexCache(get=..., set=..., ttl=timedelta(minutes=10)))

store.index_cache returns the configured cache, or None.

A Redis example#

This adapter uses redis-py. It is an illustration, not something wenchang depends on: install the client yourself.

import os
from datetime import timedelta

import redis
from google.cloud import storage

from wenchang.core import MemoryStore
from wenchang.index_cache import IndexCache
from wenchang.storage.gcs import GcsStorage

# decode_responses=True makes get return str, which the cache must return.
r = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)


def cache_get(key: str) -> str | None:
    return r.get(key)


def cache_set(key: str, value: str, ttl: timedelta | None) -> None:
    if ttl is None:
        r.set(key, value)
    else:
        r.set(key, value, px=max(1, ttl // timedelta(milliseconds=1)))


index_cache = IndexCache(get=cache_get, set=cache_set, ttl=timedelta(minutes=10))

bucket = storage.Client(project="my-gcp-project").bucket(os.environ["WENCHANG_BUCKET"])
store = MemoryStore(
    GcsStorage(bucket),
    scope_priority=("user", "project", "organization"),
    index_cache=index_cache,
)

Points to notice:

In-process cache#

wenchang.index_cache.InMemoryIndexCache is a thread-safe cache held in one process, for tests and single-process hosts:

from datetime import timedelta

from wenchang.index_cache import IndexCache, InMemoryIndexCache

cache = InMemoryIndexCache()
index_cache = IndexCache(get=cache.get, set=cache.set, ttl=timedelta(minutes=5))
store = MemoryStore(storage, index_cache=index_cache)

It drops expired entries only when they are read, so with ttl=None it grows without bound. A host that runs several instances needs a shared cache: each instance's in-process cache misses the other instances' writes.

Writes that bypass the cache#

write_file, append_line, replace_fact, and delete_file on a store that has the cache invalidate the entity themselves, whether the storage call succeeded or raised. Anything else that changes files in the bucket does not. That includes:

The simplest fix is to give those writers the same IndexCache. Otherwise, after they finish, call MemoryStore.invalidate_index(scope, entity_id) on a store that has the cache:

seed_store = MemoryStore(GcsStorage(bucket))  # no cache, so its writes do not invalidate
# ... seed_store writes under organization/o-hanlin-press/ ...
cached_store = MemoryStore(GcsStorage(bucket), index_cache=index_cache)
cached_store.invalidate_index("organization", "o-hanlin-press")

The next get_memory_index that includes that entity lists storage again. invalidate_index also overwrites the entity's cached listing, so deleted descriptions and aliases do not stay in your cache. A purge calls it once all of its deletes finish.

invalidate_index raises TypeError for a non-str argument and ValueError for an invalid scope or entity ID, before touching the cache. It does nothing on a store with no cache. Unlike the store's own invalidation, it does not swallow cache errors: if set raises, so does invalidate_index, because the caller asked for the invalidation and should know it did not happen.

A writer that does not invalidate leaves the entity stale until its next write through the cached store, or until ttl expires.

When the cache fails#

The cache never makes a memory operation fail.

Warnings go to the wenchang.core logger. They name the scope and the exception or result type, never the entity ID or the key. An exception that is not an Exception subclass (such as KeyboardInterrupt) still propagates. Storage errors while listing propagate as they do without a cache, and nothing is cached for that entity.

A swallowed invalidation failure leaves the entity's old listing in place, and only ttl bounds how long it is served. Watch for these warnings.

Guarantees and limits#

The store keeps a random generation token for each entity. A reader records the token before listing storage and tags its cached listing with it. Every write replaces the token after its storage call, so a listing taken before that write is never served afterwards, even if it is stored late. What this does not cover:

The cache holds memory metadata. Cached values contain each file's description, aliases, and sources. Keys contain scope names and entity IDs, which are often user IDs. Your cache inherits the same data-handling obligations as the bucket. File content is never cached.

Timeouts and retries#

wenchang has no timeout or retry setting of its own. GcsStorage calls google-cloud-storage with that library's defaults, plus one loop:

Storage call GCS request google-cloud-storage 3.x 2.x
get, list_page get metadata, download, list retried retried
put_if_version upload with if_generation_match retried retried
delete_if_version delete with if_generation_match retried retried
put upload with no precondition retried not retried

MemoryStore writes only through put_if_version and delete_if_version, so a retried write is guarded by its generation. put is what a restore calls.

How a failure reaches the agent:

Erasing an entity#

Erasure requests and project or org deletion need everything stored for one entity removed or handed over. MemoryStore has two methods for that:

Both are operator-only. They are not agent tools and not on the transport: TransportClient, MemoryTools, and the MCP adapter do not have them. Call them on a MemoryStore directly; InProcessClient.store returns the store a client wraps. Both work on GcsStorage, InMemoryStorage, and FilesystemStorage.

To erase one user, for example under a GDPR erasure request:

  1. Stop writers. Change your resolver so it no longer grants that user's scope, and let in-flight sessions end.
  2. Export, if you need to hand the data over or keep a copy.
  3. Purge the prefix, on a store configured like your server's, including its index_cache, so the purge invalidates the cached index. The cache needs a finite ttl for erasure; see Guarantees and limits.
  4. Clear retained bytes. Purge removes live objects only. See Deleted bytes in GCS.
  5. Check shared scopes. Purge does not touch facts about the user in project or organization. See Facts in shared scopes.
import logging
from datetime import timedelta

from wenchang.core import ExportedObject, MemoryStore, PurgeIncompleteError
from wenchang.identity import Identity, SandboxResolver, ScopeGrant
from wenchang.index_cache import IndexCache, InMemoryIndexCache
from wenchang.scope import ScopePolicy
from wenchang.storage.memory import InMemoryStorage
from wenchang.tools import bind_tools
from wenchang.transport import InProcessClient

log = logging.getLogger("erasure")


def revoke_grant(user_id: str) -> None:
    """Your auth layer: stop resolving any caller to this user's grant."""


def erase_user(store: MemoryStore, user_id: str) -> tuple[tuple[ExportedObject, ...], int]:
    revoke_grant(user_id)  # 1. stop writers, and let in-flight sessions end
    backup = store.export("user", user_id)  # 2. optional: hand over or archive
    try:
        deleted = store.purge("user", user_id)  # 3. everything under user/{user_id}/
    except PurgeIncompleteError as exc:
        # Objects kept reappearing: a writer is still active. Stop it and re-run.
        log.error("erasure incomplete in scope %r after %d passes", exc.scope, exc.passes)
        raise
    log.info("purged %d objects", deleted)
    return backup, deleted


# The server's store configuration, cache included, so the purge invalidates
# the user's cached index.
storage = InMemoryStorage()
cache = InMemoryIndexCache()
store = MemoryStore(
    storage, index_cache=IndexCache(get=cache.get, set=cache.set, ttl=timedelta(minutes=10))
)

identity = Identity(grants={"user": ScopeGrant("u-tianlong", "owner")})
tools = bind_tools(
    InProcessClient(store), SandboxResolver(identity), None, ScopePolicy({}), source="inkwell-cli"
)
for area, name, line in [
    ("preferences", "prose-style", "- [stated] Close third person.\n"),
    ("workflows", "drafting", "- [stated] Drafts in the morning.\n"),
]:
    tools.write_file("user", area, name, line, name, [], None)
assert len(tools.get_memory_index().entries) == 2  # this listing is now cached

backup, deleted = erase_user(store, "u-tianlong")

assert [o.key for o in backup] == [
    "user/u-tianlong/preferences/prose-style.md",
    "user/u-tianlong/workflows/drafting.md",
]
assert backup[0].data == b"- [stated] Close third person.\n"
assert deleted == 2
assert not storage.list_page("user/u-tianlong/", None, 1)  # nothing left under the prefix
assert not tools.get_memory_index().entries  # the cached listing was invalidated

Facts in shared scopes#

purge removes one entity's prefix and nothing else: for a user, everything under user/{user_id}/. Facts about that user that the agent filed in a project or organization scope are under that project's or organization's prefix, so they survive. Nothing in a shared file attributes a fact to the user it is about or the user who wrote it: sources records surfaces, not people. wenchang cannot find those facts for you.

How purge works#

A pass lists the prefix from the start and deletes each object it sees, guarded on the listed version. An object that changed or vanished in the meantime is left for the next pass. Passes repeat until one lists nothing.

Purge calls invalidate_index once, after its last storage call, whether it succeeded or failed. On success a cache error propagates and the delete count is lost; re-running returns 0 and invalidates again. On failure a cache error is logged to wenchang.core and the purge's own exception is raised. A reader that listed before the purge can still store its pre-purge listing in your cache. It is never served, but it stays until the entity is indexed again or ttl expires.

An invalid entity_id is echoed in the ValueError message (invalid entity_id: '...'), the same message invalidate_index and get_memory_index raise. If entity IDs identify people, do not log that message as is.

Deleted bytes in GCS#

Purge removes live objects only. A bucket's soft-delete policy (7 days by default on new GCS buckets) and object versioning keep deleted bytes recoverable for their retention window. Set both to match your retention obligation: gcloud storage buckets update gs://BUCKET --clear-soft-delete disables soft delete.

Export and restore#

export lists the prefix and gets each object, returning them in ascending key order. Each ExportedObject has:

Field Value
key The full storage key.
data The raw body, as bytes.
metadata The stored metadata map, read-only, with the stored keys.
version The storage version token.

Nothing is parsed, so malformed keys, corrupt metadata, and non-UTF-8 bodies are exported as they are. To restore, put each object back through the same storage the store uses, then invalidate the entity's cached index. This round trip writes two files, exports and purges them, and restores them. The asserts show that keys, bytes, and metadata come back unchanged, that each object gets a new version, and that the files read back through the store:

from collections.abc import Iterable
from datetime import timedelta

from wenchang.core import ExportedObject, MemoryStore
from wenchang.identity import Identity, SandboxResolver, ScopeGrant
from wenchang.index_cache import IndexCache, InMemoryIndexCache
from wenchang.scope import ScopePolicy
from wenchang.storage import Storage
from wenchang.storage.memory import InMemoryStorage
from wenchang.tools import bind_tools
from wenchang.transport import InProcessClient


def restore(
    storage: Storage, store: MemoryStore, objects: Iterable[ExportedObject], user_id: str
) -> None:
    for o in objects:
        storage.put(o.key, o.data, o.metadata)
    # Direct puts bypass the index cache.
    store.invalidate_index("user", user_id)


# The server's store configuration; the storage is the one the store wraps.
storage = InMemoryStorage()
cache = InMemoryIndexCache()
store = MemoryStore(
    storage, index_cache=IndexCache(get=cache.get, set=cache.set, ttl=timedelta(minutes=10))
)

identity = Identity(grants={"user": ScopeGrant("u-tianlong", "owner")})
tools = bind_tools(
    InProcessClient(store), SandboxResolver(identity), None, ScopePolicy({}), source="inkwell-cli"
)
for area, name, line, aliases in [
    ("preferences", "prose-style", "- [stated] Close third person.\n", ["voice"]),
    ("workflows", "drafting", "- [stated] Drafts in the morning.\n", []),
]:
    tools.write_file("user", area, name, line, name, aliases, None)

backup = store.export("user", "u-tianlong")
store.purge("user", "u-tianlong")
assert not tools.get_memory_index().entries  # gone, and the empty listing is cached

restore(storage, store, backup, "u-tianlong")

restored = store.export("user", "u-tianlong")
assert [(o.key, o.data, dict(o.metadata)) for o in restored] == [
    (o.key, o.data, dict(o.metadata)) for o in backup
]
assert all(r.version != b.version for r, b in zip(restored, backup, strict=True))  # new versions

prose = tools.read_file("user", "preferences", "prose-style")
assert prose.content == "- [stated] Close third person.\n"
assert prose.metadata.aliases == ("voice",) and prose.metadata.sources == {"inkwell-cli"}
assert [e.path for e in tools.get_memory_index().entries] == [
    "user/u-tianlong/workflows/drafting.md",
    "user/u-tianlong/preferences/prose-style.md",
]  # the cached empty listing was invalidated