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
- The answer's receipt → its
context.root. - The set whose
rootequals it → its signed manifest. - The manifest's member 3 →
label,sha256,byte_len. - Their own copy of the document → sha256 → must equal member 3's hash.
- 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.
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
| Field | Required | Notes |
|---|---|---|
name | yes | ≤ 120 characters. PUBLIC. |
slug | no | Derived from name when omitted. PUBLIC, and the key that groups versions of one lineage. |
documents[].label | yes | Citation label — policy.pdf#p3, RFC 9728 §4. ≤ 200 characters. PUBLIC. |
documents[].sha256 | yes | The document's full sha256, 64 hex characters. |
documents[].byte_len | yes | Size as you declare it. Sable never sees the bytes, so this is an assertion. |
documents[].media_type | no | ≤ 100 characters. PUBLIC. |
documents[].uri | no | A 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
| Method | Path | Auth | Notes |
|---|---|---|---|
POST | /v1/sources/sets | session (Member+) | Register a set from fingerprints. Returns the signed manifest and the cite_with root. |
GET | /v1/sources/sets | session (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} | public | The proof: members, manifest, anchor, membership recipe, and the five steps. 404 for an unknown id. |
Related
- Verifiable receipts —
sable_contextand the root this matches. - Memory — retrieval that produces the documents you would declare.
- Anchoring — how a set root reaches a public memo.