Privacy
How Stem decides who may read what, by computing each node's readers from the authority graph and mapping every blob to the nodes it belongs to, and how it keeps honest books on everything it serves and receives so that revocation, audits and private-by-default have precise meanings.

Part of Stem. This page defines the read side of the system: audiences, readers, the mapping from blobs to nodes that makes every access check a join, uniform denial, and the two ledgers that make up privacy bookkeeping. The write side is Authority. Until encryption exists, being allowed to read and being allowed to receive over sync are the same thing, which the roadmap states plainly; this page is therefore also the sync permission model.

Audiences

An audience is the set of principals a grant speaks about. Five kinds exist, and each resolves to a set of principals at evaluation time.

Kind

Resolves to

everyone

every peer, authenticated or not

key

one principal

group

the live members of a Group

bearer

whoever presents the secret whose hash is stored

readers

the readers of another node, evaluated at read time

Public and private are not two systems. Public is the audience everyone; private is any narrower audience; the machinery is the same. This is the observation the permissions investigation made about today's visibility table, that it already behaves like grants to two hard-coded audiences, and Stem makes the audience a real part of the model.

Readers

The readers of a node are the principals that hold read or higher over it, computed from the authority graph and the node's access mode. Writing readers(n) for a node n with parent p:

    if n.access = inherit (the default): readers(n) = readers(p) ∪ grants(n) ∪ admins(n)

    if n.access = own: readers(n) = grants(n) ∪ admins(n)

    if n.access = target: readers(n) = readers(target(n)) ∪ grants(n) ∪ admins(n)

where grants(n) is the union of the audiences of live grants at read or above whose subject covers n (the node itself, or an ancestor without exact), and admins(n) is the owner plus every principal with admin over n. readers(root) has no parent term. A grant to everyone anywhere in the chain makes the set "everyone", and the daemon records that as a flag rather than enumerating.

Examples

Alice's space has a public root (Grant{root, everyone, read}), a folder team with access: own and Grant{team, group(G), read}, and inside it a document plan with the default inherit, plus a document secret with access: own and Grant{secret, key(Bob), read}.

    readers(root) = everyone.

    readers(team) = members of G, plus Alice and any admins. The parent term is dropped by own, so the public root does not leak into the team folder.

    readers(plan) = readers(team) plus its own grants (none) = members of G and Alice.

    readers(secret) = Bob and Alice. Being inside team does not add the group, because own drops the parent term.

Bob comments on plan. The comment is a node in Bob's space with access: target and target alice/<plan's node id>, so readers(comment) = readers(plan) plus Bob. When Alice later makes team public by granting everyone on it, the comment becomes public too, without Bob doing anything. When she removes the grant, the comment becomes private again. Comment visibility tracks the document, which is the behaviour today's copied visibility field cannot provide.

Alice shares plan with Dave, who is not in G: Grant{plan, key(Dave), read}. Dave reads plan and its comments, and nothing else in team. Sharing one resource did not require sharing the folder.

From nodes to blobs

A reader asks for blobs, not nodes, and a peer serves blobs. The bridge is the blob access relation: every blob is mapped to the nodes whose state it belongs to, and a blob may be read by the readers of any of those nodes.

The handler fills this relation as it indexes:

    a Node blob belongs to its own node;

    a Change belongs to every node whose head Node blob reaches it through heads and dep links;

    a Snapshot belongs to every node whose head Node blob reaches it through snapshot and prev links;

    a file or media blob belongs to every node whose state embeds it through file links;

    a Grant, Revocation or Group belongs to the node that is its subject (or, for a group, to every node that has a grant to the group), and is additionally readable by everyone named in its audience, since a principal may always see the grants that give it access;

    a comment's blobs belong to the comment node, whose readers are the target's.

Access to a blob is then one question: is there a node n in blob_access(b) with the requesting principal in readers(n)? Because readers are materialised per node, the question is a join, and every surface asks it: sync, the blockstore, listings, search, citations, feeds. There is one evaluator and it runs at indexing time; everything else filters by join. This is what keeps the public path as cheap as it is today, which the prior-art study said to protect: for a public node the materialised answer is "everyone" and no per-reader work happens.

A blob reachable from two nodes with different readers is readable through either. That reveals nothing: possession of a CID plus an authorized path to it is exactly the access criterion, and content addressing already implies that a shared file is a shared file. What a grant never does is follow a bare CID mention into someone else's content: blob_access is filled only through the owner-signed structure of the node (its Node blobs, their heads, deps, prev and file links), never through links in text. Access does not cross an ownership boundary; an embed of someone else's private document renders only for a reader who holds their own path to it.

Uniform denial

A peer that may not read a blob, a node or a name receives the same answer as if it did not exist. Fetch reports an unreadable CID under missing; a listing omits unreadable children; Reconcile folds unreadable items out of its fingerprints so their count and hashes are not reflected; a URL resolution fails the same way for a missing node and a forbidden one; Offer never tells a sender which of its blobs the receiver already held. This closes the existence oracle that today's .dagjson endpoint has, where "blob is not public" is a different answer from "not found". Every surface fails closed: an error evaluating access is a denial.

Metadata privacy

The existence, name and placement of a private node are themselves private. The Node blob is in blob_access of its own node, so a peer that may not read the node never receives the blob that carries its name and parent. A parent's listing includes only children the reader may read. Links from a private node to other resources are read from its state, which the reader cannot see. Backlinks shown on a public document include only citing sources the reader may read. The facts the indexer derives about a node (title, child count, comment count) are served under the same rule as the node.

Two things remain visible to a peer that may not read a node: that the parent exists (if the parent is readable), and the sizes and timestamps of range fingerprints in reconciliation, which are folded over the readable set only and therefore reveal nothing about hidden items. The roadmap notes that a private link or a private schema can itself reveal information; Stem's rule is that anything derived from a node's state is served under the node's readers.

Private by default

A new space has an owner and no grants. Its readers are the owner. Nothing in it is readable by anyone else until the owner publishes a grant, and "publishing" a document means granting everyone on it or on an ancestor. The Seed app creates a space with Grant{root, everyone, read} when the user asks for a public site, and without it when the user asks for a private workspace. The roadmap records the shift from "public by default" toward private by default as a knowledge system's expectation; Stem makes it a one-grant difference rather than two code paths.

Creating a private document under a public folder is access: own on the new node. Creating a private folder is the same on the folder, and its children inherit. Children of private documents, the roadmap's phase two, need no new mechanism.

Bearer audiences

A bearer grant stores the SHA-256 of a secret. A client presents the secret over HTTPS to a site, which hashes it and treats the request as a member of the audience for the duration of a session. Bearer audiences are HTTP-only: a peer-to-peer connection authenticates with account keys and cannot present a bearer secret, so "anyone with the link" readers reach content through a site, which is where links are opened anyway. A bearer grant is revoked like any other, and a site records its serves to bearer sessions in the disclosure ledger with basis bearer.

Bookkeeping

Privacy bookkeeping is two ledgers that every peer keeps, defined by two record types.

The disclosure ledger

A disclosure records that this peer served a blob to a peer at a time, and the basis on which it did so: the blob was public; the peer had authenticated as an account covered by a named grant; the peer presented a bearer secret for a named grant; the peer is the space's declared site under a named sync grant; or the peer had just offered the blob and was served it back. Every Fetch response writes one row per blob served. Public serves may be aggregated by the daemon for storage reasons, but private serves are kept in full.

The ledger answers:

    Who has received this blob, and under which grant? The question revocation needs.

    What did this peer send to that peer in the last month? The question a user asks when a device is lost.

    Which grants are actually being used? The question an owner asks before pruning.

ListDisclosures reads it.

The transfer log

A transfer records one batch of blobs received from a peer: who sent it, which account they had authenticated as, the scope they claimed the blobs belong to, when, and how many blobs were received, indexed, stashed and rejected. Per-blob verdicts are kept alongside. Every Fetch the local peer makes, every Offer it accepts and every local Publish writes a transfer.

The log answers:

    Where did this blob come from, and what did the sender claim about it? Provenance for anything that looks wrong.

    Which peers send me blobs I reject? The question abuse handling needs.

    What has this peer accepted into a private scope, and from whom? The question an audit asks.

ListTransfers reads it.

The board behind Stem called this blob accounting: knowing which blobs are visible to which audience and who vouched for them. Blob access answers the first; the two ledgers answer the second.

Revocation, honestly

Revoking a grant changes what honest peers serve from the moment the revocation is indexed. It does not and cannot un-send bytes. Stem says this plainly in the spec and expects clients to say it in their interface copy: after revocation, the disclosure ledger lists who already holds the blobs, and that list is the true extent of the disclosure. A space that needs stronger guarantees needs encryption, which is a later layer: the sync access level is reserved for it, and a future Grant extension can carry a wrapped content key without changing anything on this page. Until then, private means access control on delivery, enforced identically on every surface.

What disappears

The following behaviours of the shipping daemon, documented in Privacy and the permissions investigation, have no counterpart in Stem because one evaluator answers every question.

Today

Why it is gone

AGENT keys read private content over HTTP but not over peer sync

There is one read level and one evaluator; the surface does not matter

A WRITER at any path receives the whole space's private blobs over sync, but needs a root-scoped grant over HTTP

Readers are per node; a grant covers its subject's subtree and nothing else

Bitswap fails open on blobs with no visibility rows while the blockstore fails closed

Fetch replaces Bitswap and both paths ask the same join; an unmapped blob is unreadable

CreateRef hard-codes public visibility (VULN-5)

There is no visibility field; readability comes only from grants the owner signed

The .dagjson endpoint distinguishes "not public" from "not found"

Uniform denial on every surface

Comment visibility is copied at creation and never reconciled

The target access mode follows the document at read time

A siteUrl host receives private blobs because the home document names it

A site needs a sync grant, revocable like any other

Private documents must be single-segment paths with random names

Any node can be unnamed and placed anywhere

Enforcement is opt-in per deployment (-public-only)

Grant evaluation is the only read path

Open questions

    Open: the retention period of the disclosure ledger, and whether public serves are logged at all or only counted.

    Open: whether readers audiences may cross spaces (a grant in Alice's space naming the readers of a node in Bob's space). The evaluation is well defined; the question is whether the dependency on another space's authority graph is wanted.

    Open: the exact shape of the encryption layer. Candidates: a wrapped-key field on Grant, or a separate Key blob with its own audience.

See also

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime