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; other implementations bind to the same protocol.
For motivation, philosophy, and the relationship to neighbouring projects, see the apotheca overview.
This document is normative.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
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).
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.
A name is a caller-chosen identifier under which bytes are stored. Name constraints are defined in §4.
Bytes denote an arbitrary octet sequence of length in [0, 2^63). The zero-length sequence is a valid value.
A digest in this specification is a SHA-256 hash, represented as 32 octets. SHA-256 is defined by FIPS 180-4.
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.
A cella exposes five operations: deposit, get, and stat 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.
Stores bytes under name.
name was absent, or was present with bytes whose digest equals sha256(bytes). On the absent case, bytes are now stored under name (see §5 for atomicity). On the equal case, the stored bytes are unchanged (idempotent re-deposit).name is 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.
Returns the bytes stored under name. Implementations MUST verify the returned bytes against the stored digest before returning them to the caller.
name is present and verification succeeds. The returned bytes are equal, octet-for-octet, to the bytes most recently passed to a deposit call that returned Ok for name.name is not present.name is 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.
Returns metadata for name without reading the bytes.
size: octet count in [0, 2^63), sha256: 32-octet digest } — name is present.name is 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.
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).
name is present and verification succeeds. The returned bytes are equal, octet-for-octet, to the bytes most recently passed to a set_pinax call that returned Ok for name.name is absent in the pinax namespace.name is 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.Stores bytes under name in the pinax namespace, conditionally on the current value matching expected. The argument expected is Option<Digest>:
expected = None requires name to be absent in the pinax namespace.expected = Some(d) requires name to be present with stored digest equal to d.Outcomes:
bytes are now stored under name, 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.actual = None reports that name was absent; actual = Some(d') reports that name was present with stored digest d'. 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.
The implementation MAY compute sha256(bytes) directly, or MAY compare with the stored bytes by any means that yields the same outcome.
Every present depositum MUST have a stored sha256 digest. There is no mode in which deposita are stored without an associated digest.
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.
The stored sha256 IS the depositum's ETag. Backends that have a native ETag-like field (e.g. S3-compatible object stores) MUST place the apotheca digest in a field whose value is exactly sha256(bytes) (e.g. the x-amz-checksum-sha256 header on S3-compatible backends, not the legacy multipart-derived ETag). Native ETags whose value is not sha256(bytes) MUST NOT be used to satisfy this specification.
The hash function is SHA-256 and is fixed by this specification. Changing the hash function is a protocol revision.
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 the pinax's ETag for backends with native ETag-like fields (§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.
A name is a single non-empty filesystem-safe component:
/). or ..[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.
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.
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.
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.
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.
Concurrent deposit calls for the same name MUST resolve such that exactly one of the following holds:
Ok;Collision; the depositum under name after both calls return is one of the digests submitted, and is the digest reported by every subsequent stat and the digest of the bytes returned by every subsequent get.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).
This section specifies the on-disk layout of the local-filesystem backend.
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.
<root>/
deposita/
<name>/
bytes
meta
tmp/
<staging-id>/
bytes
meta
deposita/<name>/bytes contains the depositum's bytes, octet-for-octet.deposita/<name>/meta contains the depositum's metadata in the format defined in §6.3.tmp/<staging-id>/ is a staging area used to satisfy §5.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.
A conforming local-backend deposit(name, bytes) proceeds as follows:
d = sha256(bytes).deposita/<name>/ exists, parse deposita/<name>/meta. If its sha256 field equals d, return Ok. Otherwise return Collision.<staging-id> (e.g. a random component) and create tmp/<staging-id>/.bytes to tmp/<staging-id>/bytes; fsync the file.tmp/<staging-id>/meta; fsync the file.tmp/<staging-id>/ to deposita/<name>/ (rename(2)).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.
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/.
get(name):
deposita/<name>/ does not exist, return NotFound.deposita/<name>/meta to obtain (size, sha256_stored).deposita/<name>/bytes.size, return IntegrityError.sha256(bytes_read). If it does not equal sha256_stored, return IntegrityError.stat(name):
deposita/<name>/ does not exist, return NotFound.deposita/<name>/meta to obtain (size, sha256_stored).{ size, sha256_stored }.stat does not re-hash and does not read bytes.
Pinakes are stored under <root>/pinakes/, parallel to and disjoint from <root>/deposita/:
<root>/
deposita/ # depositum namespace, write-once (§6.2)
pinakes/ # pinax namespace, compare-and-swap
<name> # one regular file per pinax; content = bytes
<name>.lock # per-name lockfile (created on demand)
tmp/ # staging area, shared (§6.2)
<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.
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.
A conforming local-backend set_pinax(name, bytes, expected) proceeds as follows. The procedure MUST hold an exclusive advisory lock on <root>/pinakes/<name>.lock (created on demand) for the duration of steps 2–7.
d = sha256(bytes).<root>/pinakes/<name>:
actual = None;actual = Some(sha256(content)).actual != expected, release the lock and return Conflict { actual }.actual == Some(d) (idempotent re-set with identical bytes), release the lock and return Ok without writing.<staging-id>, write bytes to tmp/<staging-id>, fsync the file.tmp/<staging-id> to <root>/pinakes/<name> (rename(2) over the existing file is atomic on POSIX). Fsync <root>/pinakes/.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.
get_pinax(name):
<root>/pinakes/<name> does not exist, return NotFound.bytes_read.sha256(bytes_read) and return bytes_read together 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.
The reference CLI binary is apo. It exposes the protocol operations one-for-one, against the default local cella (§6.1) unless configured otherwise.
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. --name is REQUIRED in this form.0 on Ok (including idempotent re-deposit). Exit status non-zero on Collision or any I/O error, with a diagnostic to standard error.apo get <name>
<name> to standard output. Performs verification per §2.2.0 on success. Exit status non-zero on NotFound, IntegrityError, or any I/O error, with a diagnostic to standard error and no partial output if the failure can be detected before writing.apo stat <name>
size <decimal>
sha256 <hex>
0 on success. Exit status non-zero on NotFound or any I/O error.apo pinax get <name>
<name> to standard output. Performs verification per §2.4.0 on success. Exit status non-zero on NotFound, IntegrityError, or any I/O error, with a diagnostic to standard error.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.--expect-absent and --expect <hex> MUST be given. --expect-absent corresponds to expected = None; --expect <hex> corresponds to expected = Some(<hex>) where <hex> is 64 lowercase hexadecimal digits.0 on Ok (including idempotent re-set). On Conflict, exit status non-zero and a diagnostic of the form conflict: actual=<hex> or conflict: actual=absent written to standard error. Other I/O errors exit non-zero with a diagnostic.The error conditions defined by this specification are:
Collision (§2.1) — deposit operation with differing digest under existing name.Conflict (§2.5) — pinax set_pinax precondition 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.
An implementation conformant to the depositum surface MUST implement:
deposit, get, stat)Collision, NotFound, and IntegrityErrorSuch an implementation MAY omit:
/ are rejected.An implementation conformant to the pinax surface MUST implement every requirement of the depositum surface (§9.1), plus:
get_pinax, set_pinax)ConflictA depositum-surface implementation MAY ship pinax-surface features additively without claiming pinax-surface conformance, provided its depositum-surface conformance remains intact.
The following are explicitly out of scope for v1.0-rc1 and will be specified in future revisions:
These are listed here so that implementations and consumers know not to rely on their absence.