syntheca — Specification

version 1.0 RC2

This document specifies the syntheca protocol: a content-addressable storage layer over an apotheca cella. syntheca derives the apotheca name from a hash of the bytes for the depositum namespace; apotheca’s Ok on an equal-digest re-deposit is the benign dedup hit, while apotheca’s Collision outcome (different bytes under one name) surfaces as the hard HashCollision error (§2.1, §3.3). The underlying cella’s pinax (compare-and-swap) namespace is surfaced unchanged as a transparent pass-through.

The specification is language-agnostic. The reference implementation is in Rust (syntheca-rs/).

For motivation and the relationship to apotheca, see README.md. For everything substrate-level — cella model, backend protocol, atomicity, on-disk layout, name validation, pinax mechanics — see ../../apotheca/spec/SPEC.md. This document covers only what is content-addressing-specific plus the pass-through surface.

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119.

1 Scope and terminology

A syntheca cella is an apotheca cella (apotheca SPEC §1.1) augmented with the contract that a depositum’s name is derived from its bytes by a fixed hash function (§3). The cella’s pinax namespace (apotheca SPEC §1.6) is unconstrained by syntheca: pinax names are caller-chosen.

Terminology not redefined here is inherited from apotheca SPEC §1.

1.1 Hash

A hash in this specification is a BLAKE3 digest of length 32 octets. BLAKE3 is defined by the BLAKE3 specification. The hash function is fixed by this specification; changing it is a protocol revision.

1.2 Name encoding

The apotheca depositum name corresponding to a hash is the 64-octet ASCII string of 64 lowercase hexadecimal digits encoding the 32 octets of the hash, most-significant byte first. This string is a valid apotheca name (apotheca SPEC §4.1): non-empty, contains no / or NUL, length within [1, 255].

Two hashes are equal iff their 32-octet sequences are equal. Implementations MUST NOT accept uppercase or mixed-case hex as a hash input on the wire (CLI, library); equality is over the underlying octets and the canonical encoding is lowercase.

This encoding constrains only the depositum namespace. Pinax names are caller-chosen apotheca names with no syntheca-imposed structure.

2 Operations

A syntheca cella exposes deposit, get, and stat on the depositum namespace — plus their OPTIONAL streaming forms deposit_from and get_reader — and get_pinax and set_pinax on the pinax namespace. The depositum operations carry CAS semantics specific to syntheca; the pinax operations are a transparent pass-through to the underlying apotheca cella (apotheca SPEC §2.4–§2.5).

2.1 deposit(bytes)

Stores bytes and returns their hash.

  1. Compute h = blake3(bytes).

  2. Call apotheca.deposit(name=hex(h), bytes).

  3. If apotheca returns Ok, return h.

  4. If apotheca returns Collision, return HashCollision. With a collision-resistant hash this case does not occur from honest inputs; it indicates either backend corruption or a successful adversarial collision against BLAKE3.

The Collision path exists because apotheca’s deposit discriminates by its own digest (SHA-256, apotheca SPEC §2.1), not by BLAKE3. If the existing depositum’s bytes differ from bytes, their SHA-256 digests differ, and apotheca returns Collision even though syntheca submitted the same name. syntheca surfaces this case as HashCollision rather than masking it.

2.2 get(hash)

Returns the bytes stored under hash.

  1. Call apotheca.get(name=hex(hash)). apotheca verifies the bytes against its stored SHA-256 (apotheca SPEC §2.2); a mismatch surfaces as IntegrityError.

  2. If verify_on_read is enabled (§4), compute blake3(bytes) and compare to hash. If they differ, return IntegrityError.

  3. Return the bytes.

Outcomes: Bytes, NotFound, IntegrityError. The bytes are equal, octet-for-octet, to the bytes most recently passed to a deposit call that returned Ok for hash.

2.3 stat(hash)

Returns metadata for hash.

  1. Call apotheca.stat(name=hex(hash)).

  2. Return { size, sha256 } from apotheca’s response. The BLAKE3 hash itself is the input and is not re-reported.

Outcomes: Metadata, NotFound.

stat MUST NOT re-hash the bytes.

2.4 deposit_from(reader)

The OPTIONAL streaming form of deposit2.1) — the same operation with the bytes arriving incrementally, for callers whose input should not be buffered whole:

  1. Open an apotheca stage (apotheca SPEC §2.7).

  2. Stream the caller’s bytes into the stage, updating an incremental BLAKE3 state over the same bytes.

  3. Commit the stage under name = hex(blake3(bytes)) and return the hash, surfacing Collision exactly as deposit does.

The result — outcomes, idempotence, collision handling, and the stored depositum — MUST be indistinguishable from deposit over the concatenated bytes. Memory cost is bounded by the copy buffer wherever the underlying stage streams natively.

2.5 get_reader(hash)

The OPTIONAL streaming form of get2.2): the same bytes served incrementally. Verification moves to the end of the stream: the implementation MUST detect a mismatch — apotheca’s SHA-256 always, the BLAKE3 name comparison when verify_on_read is enabled (§4) — no later than end-of-stream, and MUST surface it as an integrity error on the read that reaches end-of-stream. A consumer that abandons the stream early has read unverified bytes and MUST NOT treat them as verified.

2.6 get_pinax(name)

Pass-through to apotheca.get_pinax(name). name is an apotheca name chosen by the caller (apotheca SPEC §4); syntheca imposes no content-addressing constraint on it.

Outcomes are exactly those of apotheca’s get_pinax (apotheca SPEC §2.4): Bytes, NotFound, IntegrityError. syntheca adds no additional verification beyond apotheca’s mandatory pinax integrity check (apotheca SPEC §3.5); verify_on_read4) does not apply to pinakes.

2.7 set_pinax(name, bytes, expected)

Pass-through to apotheca.set_pinax(name, bytes, expected). expected is Option<Digest> carrying the SHA-256 the caller believes is currently stored, or None to require absence.

Outcomes are exactly those of apotheca’s set_pinax (apotheca SPEC §2.5): Ok, Conflict { actual: Option<Digest> }.

3 Integrity

3.1 Two-hash regime (depositum)

A syntheca depositum has two associated digests: the BLAKE3 hash that names it (§1.1), and the SHA-256 digest stored by apotheca (apotheca SPEC §3.1). Both are mandatory and serve different purposes:

Implementations MUST NOT replace one with the other and MUST NOT change apotheca’s hash function to BLAKE3 to deduplicate the two.

3.2 Verification (depositum)

get always benefits from apotheca’s mandatory SHA-256 verification. get additionally verifies BLAKE3 if verify_on_read is enabled (§4). deposit detects BLAKE3-name collisions against differing bytes through apotheca’s SHA-256-based collision check.

3.3 BLAKE3 collisions

A HashCollision outcome from deposit2.1) indicates that two distinct byte sequences produced the same BLAKE3 digest. With a collision-resistant hash this does not occur from honest inputs; implementations MUST surface this as a hard error, MUST NOT overwrite the existing depositum, and SHOULD log the event.

3.4 Pinax integrity

Pinax integrity is governed entirely by apotheca SPEC §3.5. syntheca adds no further hash; the pinax namespace is single-hash (SHA-256 only) because there is no content-addressed naming contract to verify against.

4 Verify-on-read

Implementations MUST support a verify_on_read mode that, when enabled, recomputes blake3(bytes) on every depositum get and returns IntegrityError if the result does not equal the requested hash.

The default is verify_on_read = true. Implementations MAY allow disabling it per cella for read-mostly hot paths where apotheca’s SHA-256 verification is considered sufficient.

verify_on_read is in addition to apotheca’s SHA-256 verification; disabling it does not disable apotheca’s check. It applies only to the depositum namespace; pinax reads are governed by apotheca’s mandatory pinax integrity check (§3.4).

5 CLI surface

The reference CLI binary is syn. It exposes the operations one-for-one against the default local cella unless configured otherwise. The default cella root is ~/.syntheca/. The root MAY be overridden per invocation; the reference syn binary accepts --cella <PATH> for this.

The syntheca library itself fixes no default — Cella::open takes the root as a parameter — so consumers above syntheca instantiate their own cella at their own root rather than sharing ~/.syntheca/.

5.1 syn deposit

syn deposit <path>
syn deposit -

5.2 syn get

syn get <hash>

5.3 syn stat

syn stat <hash>

5.4 syn pinax get

syn pinax get <name>

5.5 syn pinax set

syn pinax set --name <name> (--expect-absent | --expect <hex>) <path>
syn pinax set --name <name> (--expect-absent | --expect <hex>) -

The pinax CLI is a verbatim mirror of apo pinax {get,set} (apotheca SPEC §7.4–§7.5); it exists on syn so callers do not need to juggle two binaries against the same cella.

6 Errors

The error conditions defined by this specification are:

Errors inherited from apotheca (apotheca SPEC §8) propagate unchanged unless this document maps them otherwise. A malformed hash on input (non-hex, wrong length) is an implementation-defined input error, distinguishable from the protocol-defined errors above; an invalid pinax name is likewise an implementation-defined input error (apotheca SPEC §4).

7 Out of scope

Inherited from apotheca’s out-of-scope list. Additionally:

8 Future work (non-normative)