apotheca — Specification
version 1.0 RC3
This document specifies the apotheca protocol: the operations a cella exposes, the integrity guarantees those operations make, the constraints on names, the atomicity rules backends must honour, the on-disk layout of the local backend, and the CLI surface that exposes the protocol to the shell.
The specification is language-agnostic. The reference implementation
is in Rust, published as the
apotheca crate
(https://gitlab.com/pantheca/apotheca/apotheca-rs);
other implementations bind to the same protocol.
For motivation, philosophy, and the relationship to neighbouring
projects, see README.md. For the reasoning
behind specific decisions, see
RATIONALE.md.
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119.
1 Scope and terminology
1.1 Cella
A cella is the unit of storage. It holds an unbounded set of named deposita, each depositum consisting of a name and the bytes stored under that name. A cella is composed of one or more backends; this document specifies the single-backend case in full and defers multi-backend composition to a future revision (see §10).
1.2 Depositum
A depositum is a (name, bytes) pair durably held by a
cella. The term is geological: like sedimentary deposita, each
depositum is laid down once and is thereafter neither modified nor
removed. Once a deposit operation returns Ok, the bytes
under that name are immutable for the lifetime of the cella. A
depositum is present in a cella once its deposit
operation has returned Ok and the cella has not since been
corrupted or destroyed.
"Entry" is permissible as an informal English synonym in prose; normative text in this specification uses depositum exclusively.
1.3 Name
A name is a caller-chosen identifier under which bytes are stored. Name constraints are defined in §4.
1.4 Bytes
Bytes denote an arbitrary octet sequence of length in
[0, 2^63). The zero-length sequence is a valid value.
1.5 Digest
A digest in this specification is a SHA-256 hash, represented as 32 octets. SHA-256 is defined by FIPS 180-4.
1.6 Pinax
A pinax is a (name, bytes) pair held by a cella whose bytes
MAY be replaced by a subsequent successful set_pinax. A
cella’s pinakes are kept in a namespace disjoint from its deposita:
the same name MAY be used for both a pinax and a depositum without
collision (see §4.3). The term is borrowed from
Greek πίναξ (pinax), tablet or list-board; the plural is
pinakes. Where a depositum is sediment laid down once
and never disturbed, a pinax is a surface designed to be
overwritten.
A pinax is present in a cella once any set_pinax for
that name has returned Ok and the cella has not since been
corrupted or destroyed.
Pinakes are intended for small, frequently-updated values such as state pointers and history heads. The spec imposes no maximum size; implementations SHOULD support pinakes of at least 1 KiB and MAY reject larger pinax bytes with an implementation-defined error.
2 Operations
A cella exposes six operations: deposit, get,
stat, and deposit_cas on the depositum namespace;
get_pinax and set_pinax on the pinax namespace. Each
operation is total: it terminates with one of the outcomes listed for
it.
2.1 deposit(name, bytes)
Stores bytes under name.
Ok —
namewas absent, or was present with bytes whose digest equals sha256(bytes). On the absent case,bytesare now stored undername(see §5 for atomicity). On the equal case, the stored bytes are unchanged (idempotent re-deposit).Collision —
nameis present with bytes whose digest differs from sha256(bytes). The stored bytes MUST NOT be modified.
The implementation MAY compute sha256(bytes) directly, or
MAY compare the candidate bytes with the stored bytes by any means
that yields the same outcome.
2.2 get(name)
Returns the bytes stored under name. Implementations MUST
verify the returned bytes against the stored digest before returning
them to the caller.
Bytes —
nameis present and verification succeeds. The returned bytes are equal, octet-for-octet, to the bytes most recently passed to adepositcall that returnedOkforname.NotFound —
nameis not present.IntegrityError —
nameis present, but the bytes read from the backend have a digest that does not equal the stored digest. The bytes MUST NOT be returned to the caller.
IntegrityError indicates backend corruption (silent disk
error, partial write surviving recovery, tampering). It is a real
condition, not a theoretical one; callers SHOULD propagate it as
an error rather than retry.
2.3 stat(name)
Returns metadata for name without reading the bytes.
Metadata = {
size: octet count in[0, 2^63),sha256: 32-octet digest } —nameis present.NotFound —
nameis not present.
stat MUST be implementable without transferring the bytes.
On backends where bytes and metadata are co-located (e.g. local
filesystem), implementations MAY return metadata derived from
on-disk state without re-hashing on every call.
2.4 get_pinax(name)
Returns the bytes stored under name in the pinax namespace.
Implementations MUST verify the returned bytes against the stored
digest before returning them to the caller (see
§3).
Bytes —
nameis present and verification succeeds. The returned bytes are equal, octet-for-octet, to the bytes most recently passed to aset_pinaxcall that returnedOkforname.NotFound —
nameis absent in the pinax namespace.IntegrityError —
nameis present, but the bytes read from the backend have a digest that does not equal the stored digest. The bytes MUST NOT be returned to the caller.
2.5 set_pinax(name, bytes, expected)
Stores bytes under name in the pinax namespace,
conditionally on the current value matching expected. The
argument expected is Option<Digest>:
expected = Nonerequiresnameto be absent in the pinax namespace.expected = Some(d)requiresnameto be present with stored digest equal tod.
Outcomes:
Ok — the precondition held;
bytesare now stored undername, replacing any prior value (see §5.4 for atomicity). If the new bytes’ digest equals the stored digest of an already-present pinax, the call is idempotent and the stored bytes are unchanged.Conflict { actual: Option<Digest> } — the precondition did not hold.
actual = Nonereports thatnamewas absent;actual = Some(d')reports thatnamewas present with stored digestd'. The stored bytes MUST NOT be modified.
The actual digest reported with Conflict MUST be
observed by the same atomic check that determined the precondition
failed. Implementations SHOULD return Conflict to the
caller rather than retry internally; the standard compare-and-swap
pattern is for the caller to retry with actual as the new
expected.
Backend accommodation: on backends whose native
conditional-write primitive is keyed by a token other than the digest
(e.g. ETag-conditioned object stores), the same-atomic-check
requirement on actual is relaxed: actual MAY be
derived from a separate read issued immediately after the failed
conditional write. On such backends actual is advisory under
continued concurrent modification — the retry pattern above
re-verifies it — and a digest cycle between the check and the write
(ABA) can yield a Conflict whose actual equals
expected. Callers MUST treat any Conflict as a
failed precondition regardless of the reported actual. The
Ok/Conflict decision itself remains bound to the
backend’s atomic conditional write in all cases.
The implementation MAY compute sha256(bytes) directly, or
MAY compare with the stored bytes by any means that yields the
same outcome.
2.6 deposit_cas(name, bytes)
Stores bytes under name under a caller-asserted
content-addressed precondition: the caller asserts that if a
depositum is already present under name, its bytes equal
bytes. The caller is responsible for choosing name such
that this precondition holds — typically by deriving name
from bytes via a hash function — but the implementation makes
no assumption about how name was chosen.
Outcomes:
Ok —
namewas absent (andbytesare now stored undername; see §5 for atomicity), or was present with stored bytes whose digest equals sha256(bytes) — an idempotent re-deposit.Collision —
nameis present with stored bytes whose digest differs from sha256(bytes). The stored bytes are unchanged.
The implementation MUST return Collision whenever the
stored sha256 disagrees with sha256(bytes); collision
detection is on the same normative footing as for deposit
(§2.1). What deposit_cas MAY skip is the
pre-emptive existence read that deposit performs before
writing. A conforming implementation may issue a single
put-if-not-exists and defer the digest comparison to the rare
conflict branch (e.g. on the backend’s AlreadyExists
response), saving one round-trip on fresh deposits where the backend
reports successful create without a prior read. The trade-off is paid
by the idempotent-re-deposit case, which costs one extra metadata
read compared to deposit.
The hash function used to derive name is opaque to apotheca.
The stored sha256 required by §3.1 is
computed from bytes independently of name’s derivation;
the two MAY use different hash functions. For example, syntheca
derives name via BLAKE3 while apotheca stores
sha256(bytes) as the depositum’s digest, yielding independent
verification across two hash families on the syntheca-on-apotheca
stack.
Under a collision-resistant naming function and honest
content-addressed callers, Collision is unreachable from
honest inputs: it signals either backend corruption or a successful
adversarial collision against the caller’s hash family. Callers
MUST treat Collision as a hard error, MUST NOT overwrite
the existing depositum, and SHOULD log the event — the same
response required for deposit (§2.1,
§3.2).
Atomicity (§5) applies identically to
deposit_cas.
3 Integrity
3.1 Mandatory hashing
Every present depositum MUST have a stored sha256 digest. There is no mode in which deposita are stored without an associated digest.
3.2 Verification
get MUST verify; deposit MUST detect collisions
by digest equivalence; stat MUST report the stored digest.
The digest is the only quantity that defines "same bytes" or
"different bytes" for the purposes of this specification.
3.3 Integrity field
Every present depositum’s stored sha256 (§3.1)
MUST be carried alongside the depositum’s bytes in a
backend-appropriate integrity field. The field is the canonical
carrier for get verification (§2.2) and stat
reporting (§2.3); it exists in apotheca’s protocol
independently of any backend-native checksum mechanism.
Each backend kind realizes the integrity field in its conventional
metadata mechanism. On backends that use HTTP-style user metadata,
the canonical key name is apotheca-checksum:
S3-compatible (AWS S3, Cloudflare R2, MinIO, ...):
x-amz-meta-apotheca-checksumon the object’s user metadata.Google Cloud Storage:
x-goog-meta-apotheca-checksumon the object’s custom metadata.Azure Blob Storage:
x-ms-meta-apotheca-checksumon the blob’s metadata.Local filesystem: the
sha256line of the per-depositummetafile (§6.3); the abstract integrity field is realized by §6.3’s meta file format rather than by an HTTP-style header.
In every case, the integrity field’s value is the 64-character lowercase hexadecimal encoding of the depositum’s sha256.
Implementations MAY additionally carry the digest in a
backend-native checksum field (e.g. x-amz-checksum-sha256 on
S3-compatible backends, whose server-side validation provides extra
protection against in-transit corruption). Such native carriage is
defense-in-depth and does not affect conformance; conformance keys on
the integrity field above.
Backend-native ETag-like fields (e.g. S3’s ETag header, which
on multipart uploads is an MD5-of-MD5s rather than
sha256(bytes)) MUST NOT be used to satisfy this
specification’s integrity requirements.
3.4 Hash function
The hash function is SHA-256 and is fixed by this specification. Changing the hash function is a protocol revision.
3.5 Pinakes
The integrity guarantees of
§3.1–§3.4 apply equally
to pinakes: every present pinax MUST have a stored sha256 digest;
get_pinax MUST verify; set_pinax MUST detect
equivalence by digest; the stored sha256 is carried in the same
integrity field as for deposita, per the per-backend mapping in
§3.3. The local backend MAY derive the
stored digest from the stored bytes on demand rather than persisting
it in a separate file, provided every observable read returns a
digest identical to what would be returned by a backend that stores
it separately.
4 Name policy
4.1 Names
A name is a single non-empty filesystem-safe component:
non-empty
contains no path separator (
/)contains no NUL octet
is not
.or..length in octets is in
[1, 255]
Names are octet sequences, not Unicode strings. Implementations MUST NOT apply Unicode normalisation. Two names are equal iff their octet sequences are equal.
4.2 Future names
Future revisions MAY admit names containing /, with each
/-separated segment subject to the §4.1 component
constraints. Implementations SHOULD treat the namespace as opaque:
foo/bar and foo have no hierarchical relationship as
far as the protocol is concerned.
4.3 Pinakes and deposita
Pinakes and deposita are kept in disjoint namespaces within a cella. The same name MAY refer to a pinax and a depositum simultaneously; operations on the depositum namespace (§2.1–§2.3) are independent of operations on the pinax namespace (§2.4–§2.5). Name constraints (§4.1) apply identically to both namespaces.
5 Atomicity
The guarantees in this section hold across concurrent callers regardless of their locus: threads within one process, multiple handles onto the same cella root, and separate processes all observe the same atomicity and linearisation behaviour.
5.1 All-or-nothing visibility
A successful deposit operation MUST be all-or-nothing with
respect to readers: a concurrent get or stat MUST
observe either the full prior state of the name (including absence)
or the full new depositum (bytes plus digest). No intermediate state
in which bytes are present without their digest, or vice versa, is
observable.
5.2 Crash safety
After a process crash, host crash, or power loss, the cella MUST be in a state consistent with §5.1: every name that is observable as present MUST have its bytes and its stored digest both intact and matching. Implementations MAY discard partially-written deposita during recovery.
5.3 Concurrent writers
Concurrent deposit calls for the same name MUST resolve
such that exactly one of the following holds:
both writers passed bytes with the same digest, and both observe
Ok;writers passed bytes with differing digests, and at least one observes
Collision; the depositum undernameafter both calls return is one of the digests submitted, and is the digest reported by every subsequentstatand the digest of the bytes returned by every subsequentget.
5.4 Pinax atomicity
A successful set_pinax MUST be all-or-nothing with respect
to readers: a concurrent get_pinax MUST observe either the
full prior value (including absence) or the full new value. No
intermediate state in which bytes and digest disagree, or in which
the pinax transiently appears absent during a replacement, is
observable.
The precondition check against expected and the write are a
single atomic step. Concurrent set_pinax calls for the same
name MUST be linearised: each call observes the result of all
calls preceding it. If two calls would store differing bytes under
the same expected, at most one MAY observe Ok; the
others MUST observe Conflict whose actual reflects
either the prior state or the state set by the call that succeeded.
After a process crash, host crash, or power loss, every pinax observable as present MUST have its bytes and stored digest both intact and matching. Implementations MAY discard partially-written pinax staging files during recovery (§6.5).
6 Local backend on-disk layout
This section specifies the on-disk layout of the local-filesystem backend.
6.1 Cella root
The cella root is a directory. The default cella root is
~/.apotheca/. The root MAY be overridden by configuration;
the layout below is relative to it.
6.2 Layout
<root>/
deposita/
<name>/
bytes
meta
tmp/
<staging-id>/
bytes
meta
deposita/<name>/bytescontains the depositum’s bytes, octet-for-octet.deposita/<name>/metacontains the depositum’s metadata in the format defined in §6.3.tmp/<staging-id>/is a staging area used to satisfy §5.
6.3 Meta file format
The meta file is UTF-8 text, exactly:
size <decimal>
sha256 <hex>
with one trailing newline after the sha256 line.
<decimal> is the unsigned decimal representation of the
depositum’s size in octets. <hex> is the depositum’s sha256 as
64 lowercase hexadecimal digits.
Implementations MUST reject meta files that do not match this grammar exactly.
6.4 Atomic deposit procedure
A conforming local-backend deposit(name, bytes) proceeds as
follows:
Compute
d = sha256(bytes).If
deposita/<name>/exists, parsedeposita/<name>/meta. If itssha256field equalsd, returnOk. Otherwise returnCollision.Choose a fresh
<staging-id>(e.g. a random component) and createtmp/<staging-id>/.Write
bytestotmp/<staging-id>/bytes; fsync the file.Write the meta file to
tmp/<staging-id>/meta; fsync the file.Fsync the staging directory.
Atomically rename
tmp/<staging-id>/todeposita/<name>/(rename(2)).Fsync the parent of
deposita/<name>/.
Step 7 is the linearisation point: before it, no reader observes the
new depositum; after it, every reader observes both bytes and
meta together.
If any step fails, the staging directory MAY be removed; if it is left behind, recovery (§6.5) handles it.
6.5 Recovery
On startup, or on demand, an implementation MAY scan tmp/
and remove staging directories older than an implementation-defined
threshold. tmp/ content is never authoritative; deposita
become authoritative only when renamed into deposita/.
6.6 Get and stat procedures
get(name):
If
deposita/<name>/does not exist, returnNotFound.Parse
deposita/<name>/metato obtain(size, sha256_stored).Read
deposita/<name>/bytes.If the read length does not equal
size, returnIntegrityError.Compute
sha256(bytes_read). If it does not equalsha256_stored, returnIntegrityError.Return the bytes.
stat(name):
If
deposita/<name>/does not exist, returnNotFound.Parse
deposita/<name>/metato obtain(size, sha256_stored).Return
{ size, sha256_stored }.
stat does not re-hash and does not read bytes.
6.7 Pinax layout
Pinakes are stored under <root>/pinakes/, parallel to and
disjoint from <root>/deposita/. Their lockfiles are stored in
a separate <root>/pinax-locks/ directory. deposita/
and the shared tmp/ staging area are as defined in
§6.2:
<root>/
deposita/ # depositum namespace, write-once
pinakes/ # pinax namespace, compare-and-swap
<name> # one regular file per pinax; content = bytes
pinax-locks/ # per-pinax lockfiles (created on demand)
<name>
tmp/ # staging area, shared
<staging-id>
Each pinax is stored as a single regular file at
<root>/pinakes/<name> whose content is the pinax’s bytes. The
local backend recomputes the stored digest from the file content on
each read (§3.5). No meta file is
written for pinakes.
Lockfiles MUST NOT be placed inside <root>/pinakes/ itself:
every file in that directory is a pinax, and an auxiliary file
placed there shadows the pinax namespace. (A lockfile at
pinakes/<name>.lock is observable as a phantom pinax named
<name>.lock, and a legitimate set_pinax for a name
ending in .lock would rename over a live lockfile and break
the §6.8 mutual exclusion.) The lockfile
reuses the pinax’s own name unsuffixed, which also preserves the full
§4.1 name length range on filesystems with a 255-octet
filename limit.
Migration note (non-normative): cellae written against drafts
before v1.0-rc3 may contain stale zero-length <name>.lock
files inside pinakes/; under this revision those read as
pinakes. They were lockfiles, not data, and MAY be removed
manually.
This layout permits atomic file-over-file replacement via
rename(2), which is portable and crash-safe; the
per-name-directory layout used for deposita (§6.2)
does not admit a portable atomic-replace primitive on POSIX.
6.8 Atomic set_pinax procedure
A conforming local-backend set_pinax(name, bytes, expected)
proceeds as follows. The procedure MUST hold an exclusive advisory
lock on <root>/pinax-locks/<name> (created on demand) for the
duration of steps 2–7.
Compute
d = sha256(bytes).Acquire the exclusive lock.
Determine the current state of
<root>/pinakes/<name>:if it does not exist, set
actual = None;else read the file content and set
actual = Some(sha256(content)).
If
actual != expected, release the lock and returnConflict { actual }.If
actual == Some(d)(idempotent re-set with identical bytes), release the lock and returnOkwithout writing.Otherwise, choose a fresh
<staging-id>, writebytestotmp/<staging-id>, fsync the file.Atomically rename
tmp/<staging-id>to<root>/pinakes/<name>(rename(2)over the existing file is atomic on POSIX). Fsync<root>/pinakes/.Release the lock and return
Ok.
Step 7 is the linearisation point. Readers (get_pinax) do not
take the lock and observe whichever rename is current.
If any step after step 6 fails, the staging file is orphaned and recovery (§6.5) handles it.
6.9 get_pinax procedure
get_pinax(name):
If
<root>/pinakes/<name>does not exist, returnNotFound.Read the file content as
bytes_read.Compute
sha256(bytes_read)and returnbytes_readtogether with this digest as the verification.
The local backend, having derived the digest from the bytes just
read, cannot observe an integrity mismatch within this procedure. The
IntegrityError outcome of §2.4 is reachable
only on backends that store the digest separately from the bytes
(e.g. S3-compatible, where the digest is a header) and observe a
mismatch between the stored and recomputed digests.
7 CLI surface
The reference CLI binary is apo. It exposes the protocol
operations one-for-one, against the default local cella
(§6.1) unless configured otherwise.
7.1 apo deposit
apo deposit [--name <name>] <path>
apo deposit --name <name> -
<path>reads bytes from the named file. With<path>and no--name, the name defaults to the basename of<path>. With--name, the given name is used.-reads bytes from standard input.--nameis REQUIRED in this form.Exit status
0onOk(including idempotent re-deposit). Exit status non-zero onCollisionor any I/O error, with a diagnostic to standard error.
7.2 apo get
apo get <name>
Writes the bytes for
<name>to standard output. Performs verification per §2.2.Exit status
0on success. Exit status non-zero onNotFound,IntegrityError, or any I/O error, with a diagnostic to standard error and no partial output if the failure can be detected before writing.
7.3 apo stat
apo stat <name>
Writes a human-readable summary of the metadata to standard output, in the form:
size <decimal> sha256 <hex>Exit status
0on success. Exit status non-zero onNotFoundor any I/O error.
7.4 apo pinax get
apo pinax get <name>
Writes the bytes for the pinax
<name>to standard output. Performs verification per §2.4.Exit status
0on success. Exit status non-zero onNotFound,IntegrityError, or any I/O error, with a diagnostic to standard error.
7.5 apo pinax set
apo pinax set --name <name> (--expect-absent | --expect <hex>) <path>
apo pinax set --name <name> (--expect-absent | --expect <hex>) -
<path>reads bytes from the named file;-reads from standard input.Exactly one of
--expect-absentand--expect <hex>MUST be given.--expect-absentcorresponds toexpected = None;--expect <hex>corresponds toexpected = Some(<hex>)where<hex>is 64 lowercase hexadecimal digits.Exit status
0onOk(including idempotent re-set). OnConflict, exit status non-zero and a diagnostic of the formconflict: actual=<hex>orconflict: actual=absentwritten to standard error. Other I/O errors exit non-zero with a diagnostic.
8 Errors
The error conditions defined by this specification are:
Collision(§2.1) —depositoperation with differing digest under existing name.Conflict(§2.5) — pinaxset_pinaxprecondition failed.NotFound(§2.2, §2.3, §2.4) — name absent in the relevant namespace.IntegrityError(§2.2, §2.4) — stored bytes do not match the stored digest.
Implementations MAY surface additional implementation-defined
errors (I/O failure, permission denied, malformed meta file,
exhausted disk space, …). Such errors MUST be distinguishable from
the protocol-defined errors above; in particular, an I/O failure
MUST NOT be reported as NotFound, Collision, or
Conflict.
9 Conformance
9.1 Depositum-surface conformance
An implementation conformant to the depositum surface MUST implement:
§4.1 (Names)
§6.1–§6.6 (local backend layout, atomic deposit procedure, recovery, get/stat for deposita)
§8 (errors), restricted to
Collision,NotFound, andIntegrityError
Such an implementation MAY omit:
§2.6 (
deposit_cas) — the content-addressed fast-path is optional; callers requiring its semantics usedeposit(§2.1) at the cost of the pre-emptive read-then-decide round-trip.§4.2 (multi-segment names) — names with
/are rejected.§10 (multi-backend composition).
All pinax-related subsections (the pinax surface — see §9.2).
9.2 Pinax-surface conformance
An implementation conformant to the pinax surface MUST implement every requirement of the depositum surface (§9.1), plus:
§1.6 (pinax terminology)
§3.5 (pinax integrity)
§4.3 (disjoint pinax/depositum namespaces)
§5.4 (pinax atomicity)
§8 (errors), additionally including
Conflict
A depositum-surface implementation MAY ship pinax-surface features additively without claiming pinax-surface conformance, provided its depositum-surface conformance remains intact.
10 Future work (non-normative)
S3-compatible backends occupy a middle ground: they are
implementable today — §3.3 pins their
integrity field, §2’s outcomes and
§5’s atomicity apply in full, and conditional
writes (put-if-absent, put-if-match) supply the CAS primitive — and
the reference implementation ships one. What this revision does
not yet specify is their on-store layout (object key scheme,
staging), so two independent implementations sharing one bucket are
not guaranteed to interoperate, and names that are not valid UTF-8
(legal per §4.1) may be rejected by such backends with
an implementation-defined error. A future revision will pin the S3
object layout; until then S3 key layout is implementation-defined.
See also the backend accommodation on actual in
§2.5.
The following are explicitly out of scope for v1.0-rc3 and will be specified in future revisions:
S3 on-store layout (above); further backend kinds (scp/sftp, read-only HTTP).
Multi-backend cellae, write fan-out semantics, read priority.
Encryption-as-wrapper backend.
Multi-segment names (§4.2).
Configuration mechanism (cella root override, per-cella backend lists).
These are listed here so that implementations and consumers know not to rely on their absence.