Portal
Documentation: all sections

Proof-carrying answers

sable_context already fingerprints the documents a call declared and stamps an ordered root on the signed receipt. That proves what context was declared — but the root is an opaque 64-hex string. A reader holding an answer that says “per source 3” has nothing to resolve source 3 against, and no way to check that source 3 is what it was at the time.

A source set is the missing half: a named, versioned, immutable list of documents — each one a (label, sha256, byte_len) triple — whose ordered root is computed exactly the way the receipt's context root is computed. That identity is the entire mechanism.

Source sets are built and tested. They are not on the production deployment yet, so the calls below describe a surface you cannot reach at api.buildsable.com today.

Fingerprints, never bytes

Sable accepts hashes. There is no field on the wire and no column in the database that could hold a document, and a request carrying any field the endpoint does not recognise is refused rather than quietly stripped — a silently ignored "text" would leave you believing you had uploaded something. Nothing here is sealed, because nothing sensitive is stored.

The exception is stated rather than implied: label, uri, media_type and the set's name and slug are caller-controlled and public to anyone holding the set id. A filename can itself be a disclosure. The set id is an unguessable UUID, which is obscurity, not access control, and this page calls it obscurity.

The chain a skeptic walks

  1. The answer's receipt → its context.root.
  2. The set whose root equals it → its signed manifest.
  3. The manifest's member 3 → label, sha256, byte_len.
  4. Their own copy of the document → sha256 → must equal member 3's hash.
  5. Optionally, the anchored batch root → the document list existed by that block.

Step 4 is the one that matters, and Sable is not in it.

Honesty box

Proven: that a document with this exact sha256 was declared as source N of this named set at this time, signed by Sable's receipt key; and, where an answer's receipt carries context.root equal to this set's root, that the call declared exactly these documents, in this order.

Not proven: that the model read them, used them, or reasoned correctly from them. Not that the documents are true, authentic, or lawfully held. Sable never sees the document bytes, so byte_len, label, media_type and uri are your unverified assertions, recorded and signed as assertions.

Public: labels and URIs.

The root

root = sha256( utf8( concat( sha256_hex(doc_0), sha256_hex(doc_1), …, sha256_hex(doc_n-1) ) ) )

Each member's full 64-character lowercase hex sha256, concatenated in ordinal order with no separator and no domain tag, then hashed. Order is part of the identity.

This is byte-identical to the recipe sable_context uses for the context.root it stamps on a receipt. Declare the same documents, in the same order, and the two roots are the same string — which is what makes a citation resolvable without a second mechanism, a second verifier, or a second thing to trust.

Member hashes are full digests, never prefixes: a prefix can collide, and a citation that can collide is not a citation.

Verify the root yourself

Neither snippet asks Sable for anything except the public JSON.

curl -s https://api.buildsable.com/v1/sources/public/<set_id> | python3 -c '
import sys, json, hashlib
s = json.load(sys.stdin)
members = sorted(s["members"], key=lambda m: m["ordinal"])
concat = "".join(m["sha256"] for m in members)
root = hashlib.sha256(concat.encode("utf-8")).hexdigest()
print("recomputed", root); print("published ", s["root"])
print("MATCH" if root == s["root"] else "MISMATCH")'

The public endpoint also returns root_recomputes, which is Sable checking itself: the members it just served are folded and compared with the stored root. A false there means the row was tampered with underneath the gateway, and the artifact says so rather than serving a proof that silently fails for the reader.

Quickstart

curl -s https://api.buildsable.com/v1/sources/sets \
-H "Authorization: Bearer $SESSION" \
-H "Content-Type: application/json" \
-d '{
  "name": "Board pack Q3",
  "slug": "board-pack",
  "documents": [
    {"label":"policy.pdf#p3","sha256":"3b1e…","byte_len":48213,"media_type":"application/pdf"},
    {"label":"RFC 9728 §4","sha256":"a07d…","byte_len":9122,"uri":"https://example.org/rfc9728"}
  ]
}'

# {
#   "id": "…", "name": "Board pack Q3", "slug": "board-pack", "version": 1,
#   "root": "9f2c…",
#   "root_alg": "root = sha256( utf8( concat(sha256_hex(doc_0), …) ) ) …",
#   "member_count": 2, "members": [ … ],
#   "receipt": "…", "signature": "0x…", "signer": "0x…",
#   "anchor": null,
#   "proof_url": "https://api.buildsable.com/v1/sources/public/…",
#   "cite_with": {
#     "how": "declare these documents as `sable_context` on the call, in this exact order, …",
#     "context_root": "9f2c…"
#   },
#   "trust_model": "A source set proves that a document with this exact sha256 …"
# }

Versions, not edits

A set is written once. There is no PATCH: a citation points at an id, and an id whose member list could change under a reader would make every manifest ever issued a lie.

Re-registering the same slug mints a new set at the next version, so a lineage is nameable — “board-pack v3” — while every version stays independently verifiable forever.

What DELETE does, and does not

It removes Sable's copy: the set row, its documents, and with them the public endpoint. It does not un-issue the signed manifest anybody already holds — that signature is valid for as long as the key is — and it does not remove an anchor, which is on a public chain. The delete response says exactly that rather than implying a retraction that cannot happen.

Fields and limits

FieldRequiredNotes
nameyes≤ 120 characters. PUBLIC.
slugnoDerived from name when omitted. PUBLIC, and the key that groups versions of one lineage.
documents[].labelyesCitation label — policy.pdf#p3, RFC 9728 §4. ≤ 200 characters. PUBLIC.
documents[].sha256yesThe document's full sha256, 64 hex characters.
documents[].byte_lenyesSize as you declare it. Sable never sees the bytes, so this is an assertion.
documents[].media_typeno≤ 100 characters. PUBLIC.
documents[].urinoA public locator so a reader can fetch a copy to hash. ≤ 512 characters, scheme from https, http, ipfs, ar, s3, gs. Never fetched by Sable. The allowlist exists because this field is rendered by third parties: javascript: and data: locators are how a harmless metadata string becomes somebody else's XSS.

Document count matches the sable_context item cap exactly, on purpose: a set too large to declare on a single call could never produce the receipt that cites it. Signed manifests are capped at 128 KiB, because the members ride inside the signature — that is what makes a citation checkable without trusting Sable's database.

Held sets are capped per account, and DELETE frees a slot; a rolling 30-day creation cap bounds the rate at which manifests accumulate, and unlike a lifetime ceiling it always clears on its own.

Endpoints

MethodPathAuthNotes
POST/v1/sources/setssession (Member+)Register a set from fingerprints. Returns the signed manifest and the cite_with root.
GET/v1/sources/setssession (Viewer+)Your sets, newest first, with the held and window caps.
GET/v1/sources/sets/{id}session (Viewer+)One set, with its members and anchor state.
DELETE/v1/sources/sets/{id}session (Member+)Take the set down from Sable. Does not retract a manifest or an anchor.
GET/v1/sources/public/{id}publicThe proof: members, manifest, anchor, membership recipe, and the five steps. 404 for an unknown id.