apotheca

A named write-once store. Bytes go in by name, come out by name, never overwritten.

Specification v1.0 RC3 Release Candidate pantheca.org

One cella, multi-backend composition, with a configurable local store and one-way sync to one or more remote backends.

Status

Specification at v1.0 RC3, covering two surfaces:

Conformance is split between the two surfaces (SPEC §9), so an implementation may bind to either alone or to both. The Rust reference implementation is published on crates.io as apotheca v0.3.2 (binary apo, library apotheca), implementing both surfaces.

Read the specification →

Or jump to the changelog.

Name

apotheca — Greek ἀποθήκη (apothēkē), from apo- (away, set aside) + theca (case, repository, from θήκη). A storehouse: the place where things are deposited under a chosen name and kept there. Latin preserved the form (apotheca), and English reaches it through apothecary — the keeper of a named, organized store of items.

The name pairs deliberately with syntheca: both are -theca compounds (storage), differentiated by their prefix. Apo- names the act of putting-away under a chosen designation; syn- names the bringing-together of bytes into a derived address. Same register, distinct semantics — the prefix tells you what kind of store it is.

What apotheca is

A named write-once cella. The caller chooses the name; apotheca stores bytes at that name and rejects any subsequent attempt to write a different value to the same name. Reads return the bytes for a given name. This is the depositum namespace — the substrate that started the project. The term is sedimentary, not financial: each depositum is laid down once under its name and thereafter neither modified nor removed. The verb is deposit (Latin imperative, also the CLI command and library function); the thing stored is a depositum (the past-participle Latin noun, plural deposita). Entry remains a permissible informal synonym for the noun in prose; depositum is the formal noun.

Alongside it, a second, disjoint namespace of pinakes holds small (name, bytes) pairs whose value can be replaced via compare-and-swap: set_pinax succeeds only when the caller passes the prior digest as expected. The name borrows Greek πίναξ (pinax, plural pinakes) — tablet, list-board, registry: the word the Library of Alexandria used for Callimachus's catalogue, a record of what's where. Set against depositum (sediment laid down once and not disturbed), a pinax is the registry slot deliberately designed to be overwritten. Pinakes exist for state pointers and history heads — values that need to change but don't want concurrent writers to clobber each other silently. Deposita stay write-once; pinakes are replaceable via compare-and-swap; the same name can be used in both namespaces without colliding.

apotheca is generic. It knows nothing about who its callers are, how they organize their namespaces, whether buckets are per-user or shared, or what the names mean. A bucket is a bucket. Higher layers — application code, registry services, sync orchestrators, syntheca for content-addressed callers — decide naming policy, access control, quota, and any project-specific concerns. apotheca only enforces “write-once-by-name” for deposita and “compare-and-swap-by-name” for pinakes within a cella.

The rsync analogy, with a twist

apotheca is closest in spirit to rsync, but simpler in two ways and more flexible in a third.

Simpler: it is one-way. Bytes flow from the caller into the cella's backends; there is no two-way reconciliation, no conflict resolution, no detection of remote-side modifications. Writes are write-once, so there is no concept of “newer version on the remote” to merge with.

Simpler: it has no concept of file metadata beyond the name and the bytes. No mtime, no permissions, no symlinks. The caller stores bytes under a name; that is the unit of storage.

More flexible: the local store is not required to be a filesystem mirror of the remote. apotheca can keep its local copy in a .apotheca/ directory (or any other layout) with names flattened, remapped, or content-rearranged on apo deposit — the local store layout is internal to apotheca and decoupled from how the remote backend organizes the same names. This matters when the remote is something like an S3 bucket whose key structure is not a sensible local directory tree, or when the local cache wants to live outside the source tree it is mirroring.

The result is something between rsync (sync to remote) and a content-managed cache (local layout independent of source layout) — but neither, exactly. The closest descriptor is “a one-way named-write-once sync layer with a flexible local store.”

Model

A cella is the unit. One apotheca instance = one cella. Inside a cella, one or more backends, ordered by read priority, with a configurable write-fan-out policy across them.

Typed namespaces are multiple cellae. apotheca itself has no concept of types or prefixes. A project that needs typed namespaces (one for objects, one for indexes, one for manifests, etc.) instantiates one cella per type, each with its own backend list. The triage between them is project code; apotheca knows nothing about it.

This matters because typed cellae route to different backend lists, not just different paths within one backend. One typed cella might be [local, shared-corpus.example.org] for a globally-deduplicated atom store; another [local, per-tenant-bucket] for per-tenant data, possibly encrypted. Same local cache directory on disk, different remotes, possibly different fan-out and encryption policies. The cella is the unit at which backend composition happens.

Callers always know which cella. cella.get(name), never a global apotheca.get(name). This is naturally true in practice, because names are reached by walking typed manifests, not by speculation.

Backend protocol (sketch)

Three operations on deposita: deposit(name, bytes), get(name), stat(name). Two operations on pinakes: get_pinax(name), set_pinax(name, bytes, expected). No enumerate on either namespace — the consumer must know which names it needs, which forces every consumer to maintain its own manifest walk. This is the right discipline anyway, and dropping enumerate is what makes scp/sftp and CDN-fronted backends viable as first-class — neither can list cheaply or at all.

stat returns size and a sha256 digest; the digest is mandatory and serves as the depositum's ETag. get verifies bytes against the stored digest before returning them, so silent corruption surfaces instead of propagating. The deposit operation is write-once-by-name: identical re-deposits are idempotent, different bytes under an existing name are rejected as a collision. Content-addressed callers (syntheca specifically) treat collision as benign dedup when the new bytes hash to the same digest as the existing bytes; that interpretation is automatic at the apotheca layer because collision-detection is digest comparison.

get_pinax and set_pinax mirror the integrity story for pinakes. get_pinax returns the verified bytes; set_pinax is compare-and-swap: it succeeds only when the stored digest equals the caller's expected (or when expected = None and the name is absent), and otherwise reports Conflict { actual } so the caller can rebuild on top of the winning write and retry. Pinakes are intended for small, frequently-replaced values — state-chain heads, history pointers — that don't fit the write-once mould but still want apotheca's integrity guarantees and the same uniform backend story. The pinax namespace is disjoint from the depositum namespace, so the same name can be used in both without collision.

For the precise contract — operation semantics, error conditions, atomicity rules, the local backend's on-disk layout, and the CLI surface — see the specification.

Backend kinds (target set)

Encryption is a wrapper backend, not built in: encrypt bytes with a key, layer over any of the above. Composes; doesn't need to live in core.

What apotheca is not

Out of scope (deliberately)

Relationship to syntheca

syntheca is implemented as a thin layer above apotheca:

Bucket management, backend composition, local store, one-way sync, write-once enforcement — all apotheca's responsibility. syntheca's surface is small because the substrate it sits on is general enough that adding content-addressing is a thin specialization, not a separate stack.

Cella root is the caller's choice — the apotheca library accepts any path. The ~/.apotheca/ default is a property of the apo CLI specifically; consumers above (syntheca, application code) instantiate their own apotheca cellae at their own roots, so a disjoint default like ~/.syntheca/ is the natural state and requires no special coordination at the apotheca layer.