Unofficial Draft
Document license not yet selected. No license is granted by publication of this draft.
Atproto web names is a proposal for resolving a short, human-readable
name of the form @handle/name into loaded, attributable web content,
using AT Protocol identity as the namespace root. It covers resolution of names
to records, the vocabulary those records use to describe where content
lives, the origin model that isolates loaded content, and the
obligations on clients and publishers that make the result trustworthy.
It does not specify how any particular host stores bytes. Hosts are pluggable, and several existing ones are addressable without modification.
This document is a draft of a potential specification. It has no official standing of any kind and does not represent the support or consensus of any standards organization.
This document refers to the general proposal as Atproto web names.
The atw: link-scheme name is provisional; names marked TBD are
placeholders.
This is revision 1, a working draft. It has not been implemented, and several rules rest on AT Protocol implementation behaviour that has not been verified against running servers; those are marked [verify] in Section 9. Section 15 lists what remains open. Numeric limits in Section 8 are first estimates and have not been validated against real content.
Interested parties should expect normative requirements to change.
As well as sections marked as non-normative, all authoring guidelines, diagrams, examples, and notes in this specification are non-normative. Everything else in this specification is normative.
The key words MAY, MUST, MUST NOT, OPTIONAL, SHOULD, and SHOULD NOT in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.
This document places requirements on two classes of implementation:
Requirements on handlers (Section 4.2.1) constrain what may be registered in the handler table rather than on any single implementation.
Examples and notes in this document are non-normative. Appendices A through C are non-normative.
This specifies how a client turns a short, human-readable name into loaded, attributable content, using atproto identity as the namespace root. Names arrive by any route a URL does: typed, clicked, pasted, shared, or embedded in a document.
An address looks like:
@portable.agency/blog as displayed
atw://portable.agency/blog as linked
The identity segment is a namespace. The second segment is a short name within it. There is no per-site DNS name and no allocation registry.
In scope: resolution, the pointer record, the origin model, the client obligations that make the result trustworthy.
Out of scope: how any particular target hosts its bytes. Atproto web names can point at wisp, standard.site, IPFS, plain HTTPS, or any other system with a handler. Supporting a host requires a handler, not changes to the host's storage or publishing mechanisms.
This draft covers public websites (Section 16). Access-controlled content already has a defined handling; cryptographically private content is deferred to atproto's own private-data work and enters at a single identified extension point (Section 4.3).
In priority order. Where they conflict, the higher one wins, and several rules elsewhere in this document are that conflict being resolved.
1. A name never claims more than it can substantiate.
This is the governing rule, and it decides more of the spec than any
other. The name is what this design exists to provide; if
@portable.agency/blog can mean "proven to that identity" or "some host
asserted this" with nothing distinguishing them, the design has no value
over a plain link.
Everything downstream follows: dispositions rather than a grading scale (Section 4.3), no fallback to a weaker target (Section 4.4), no derived origin for unverified content (Section 5.1), no stale handle shown anywhere a name appears (Section 5.10), no link rendered as resolved before it is (Section 5.9).
Its converse sets the tolerance for everything else (Section 13.3): imperfections in reach and recovery are accepted; imperfections in what a name claims are not.
2. Identity is the namespace root, and it must survive its keys.
Names derive from a DID, never from a key (Section 1.2), so rotation, recovery, and handle changes leave names and storage intact. A design where a key mistake is terminal is not one an individual can run for years.
3. Verification belongs in the client.
Every intermediary is then a cache that cannot lie, so hosting becomes a performance decision rather than a trust decision (Section 7). This is what makes mirrors free, gateways optional, and self-hosting meaningful.
4. Portable across platforms, not tied to one.
Resolution and description (Layers 1–2) are identical everywhere. Only loading varies, and Section 5 specifies properties rather than mechanisms, so an Electron client, a mobile client, and a compatibility gateway can all be conforming.
5. Layers compose, and none depends on the one above it.
Resolution works without loading; loading works without petnames; petnames work without any handler (Section 5.11.5). The naming layers (Section 5.10) are the clearest case: a DID resolves with no handle, a handle works with no petname, and a petname survives a handle that stops resolving.
This is why optional layers can be left to clients without leaving the design incomplete, and why a later addition does not become a flag day.
6. Extensible without a flag day.
New hosts are new handlers, not spec revisions (Section 4.2). New fields are ignorable unless the publisher marks them critical (Section 4.1). A design that requires coordinated upgrades will not get them.
7. Good enough, not perfect.
This design need not solve every gap already present in the HTTP web. Where it shares such a gap, that may be a known cost rather than a blocker; Section 13.3 lists the ones accepted deliberately. This is a limit on what the design must solve, not a goal of matching HTTP or the web platform. HTTP is usable as a transport, not a feature-parity target.
The substrate is heterogeneous, non-hostile, and imperfect. Many PDS implementations exist and more will. They are not adversaries, but they vary, they have warts, and parts of their behaviour are unspecified by atproto itself.
Three consequences shape the design:
Verification handles lying; it does not handle varying. A PDS cannot forge a signature or substitute content-addressed bytes, so dishonesty is already answered. The exposure is everything verification does not touch: liveness, rate limits, reclamation timing, optional endpoint support, and error semantics.
Prefer mitigations that are safe everywhere over mitigations that are necessary somewhere. Where implementation behaviour is unspecified, the publisher-side answer should be harmless on implementations that did not need it. Section 9.3's staging records are the model: unnecessary overhead against a PDS with lazy blob reclamation, essential against an eager one, and correct without knowing which you have.
Goal 7 (Section 1.1) applies to the substrate too. PDS implementations will have bugs, gaps, and divergent behaviour, exactly as HTTP servers do. The design should degrade rather than break, and should not wait for a uniform substrate that will never arrive.
| Layer | What it does | Portable? |
|---|---|---|
| 1. Resolution | (identity, name) → route record |
Yes, identical everywhere |
| 2. Description | What the record says the name points at | Yes |
| 3. Loading | Fetching and rendering a target | No; platform-specific |
Layers 1 and 2 are normative for every implementation. Layer 3 specifies required properties, not mechanisms.
Every reject below is a hard failure rendered as client chrome (Section 5.7); no branch degrades to a weaker outcome (Section 4.4).
Identity. An atproto account, named by a DID, usually addressed by its handle.
Short name. The second address segment. Unique within one identity, enforced by record key uniqueness in the repo.
Root name. An address with no short name, resolving to the reserved
record key self (Section 4.7).
Petname. A label a user assigns to an identity, local to that user's client and bound to a derived origin (Section 5.11). Distinct from a handle, which the publisher assigns, and from a short name, which is a path segment.
Display form / link form / record form. The three forms a name takes
(Section 3.1). Display form is @handle/name; link form is
atw://handle/name; record form is the AT-URI of the route record.
Route record. The record a short name resolves to. Collection
agency.portable.route (TBD), record key = the short name.
Target. One entry in a route record: a URI plus optional transport hints.
Alias. A route record naming another short name in the same identity instead of carrying targets (Section 4.7.1).
Switching record. The single record whose write is the moment a site changes. Every handler must resolve through one (Section 4.2.1).
Generation pointer. A switching record for a handler whose content accumulates across many records, naming which set is current.
Staging record. A record written during publishing solely to keep uploaded blobs referenced. No handler resolves them (Section 9.3).
Provable. A target whose bytes are proven to the identity that named them, by either proof mechanism below.
Asserted. A target whose location the identity states but whose content nothing proves.
Proof mechanism. How a provable target is proven. The set is closed: inclusion, where content lives in a record proven by MST inclusion under a signed commit; and content-address, where a proven record names a CID against which served bytes are re-hashed (Section 4.3.1).
Disposition. What a client does with a target: load, redirect,
or reject (Section 4.3).
Handler. Client code that fetches and renders one class of target,
keyed by collection NSID for at:// targets and by scheme otherwise.
The web does the same dispatch twice, on Content-Type and on URI
scheme, and calls the code a handler in both cases; this is that, keyed
on a third thing. A client supporting a given handler is the analogue of
a browser supporting a given media type: a matter of reach, not of
conformance (Section 10.2).
Transport. Where bytes are fetched from, as distinct from which content is fetched. Mirrors and caches are transports; choosing among them is not a security decision (Section 4.4).
Mirror. An advisory alternative transport for content-addressed bytes, listed as a hint on a target.
Derived origin. The origin a provable target loads into, computed from the identity and short name (Section 5.1).
Floor. The highest (rev, contentCID) pair a client has seen for an
origin, below which content is refused (Section 5.2). Client metadata,
not site storage.
Content CID. The CID of the selected target's switching record (Section 4.2.1). Every handler resolves through exactly one, so this identifier always exists and changes exactly when the site changes.
Capability set. Which proof mechanisms, handlers, and schemes a client implements (Section 10.2).
Intermediary. A cache or gateway between a client and a PDS. An
intermediary serving com.atproto.sync.* performs no selection; one
serving a compatibility HTTP surface does (Section 10.3).
A name has three forms. They serve different purposes and are not interchangeable.
How a name appears wherever a person reads one:
@<handle>/<short-name>
@<handle> root name, Section 4.7
The provisional atw: scheme is the link form for Atproto web names.
It is what authors write, what gets shared, and what a client resolves
on activation:
atw://b.example/notes
atw://b.example/notes/chapter-2
atw://b.example root name
atw://did%3Aplc%3Axxxx/notes handle-independent
atw:notes same identity, Section 5.9
The identity is the authority component. RFC 3986 defines authority as the naming entity that governs the namespace of the rest of the URI, and that is exactly what an identity does here: short names are record keys in its repo, and uniqueness within them is enforced by the repo (Section 3.3).
The authority names the namespace. It does not assert that the bytes are
proven, that is Section 4.3's job, and the answer differs per target.
HTTP draws the same line: example.com governs /path, and TLS
authenticates a connection rather than content. This design answers the
integrity question better, not differently.
DIDs MUST be percent-escaped. A colon in an authority component
begins a port, so atw://did:plc:xyz/notes does not parse. Clients MUST
accept atw://did%3Aplc%3Axyz/notes.
DID link form is a permalink: it survives the target changing handles, at the cost of being unreadable. Clients SHOULD offer handle form as the default share affordance and DID form as an explicit durable alternative, the same division a pretty URL and a permalink have on the web. Clients SHOULD display a DID link under the target's resolved name rather than as the raw identifier (Section 5.10.2), and SHOULD escape one they generate rather than expecting an author to.
Absence of // means the current identity. atw:notes is shorthand
resolving against the containing site's identity (Section 5.9);
atw://b.example/notes names one explicitly. The presence of an
authority component carries that distinction, so both forms parse
unambiguously.
atw MUST NOT be a client's origin scheme. Where a platform derives
origins by registering a hierarchical scheme (Section 11), that scheme's
authority is a derived label, not an identity. Reusing atw for it
would put two different meanings behind one authority component. Links
name names; origins are computed, and the two never share a scheme.
Link form MUST NOT contain an origin. Origins are derived (Section 5.1) and their string representation differs per platform (Section 11). A link containing one would be unshareable and would break when its author's client differed from its reader's.
The AT-URI of the route record itself:
at://did:plc:xxxx/agency.portable.route/blog
at://did:plc:xxxx/agency.portable.route/self
This identifies the record and is what Section 3.3 resolves. It is not a link form and SHOULD NOT be published as one, for two reasons: AT-URI syntax terminates at the record key, so it cannot carry the subpath that Section 3.3 requires be passed to the handler; and it embeds the route collection's NSID, which is unsettled (Section 15), so every published link would depend on a vocabulary decision that has not been made.
at:// also already means "identify a record" across the wider
ecosystem, including in clients that resolve it to record viewers. A
link asks for something to be rendered, which is a different request.
The two schemes are neighbours by design. at:// identifies a
record; atw:// renders a site. Sharing a prefix makes them read as one
family, the way http: and https: do, and the shared shape is
accurate:
both name an identity as the authority over what follows. They differ in
what they ask for, not in who governs it.
Clients MUST accept display form and link form as names to resolve, and
MUST treat them as equivalent. @b.example/notes and
atw://b.example/notes name the same thing.
Clients SHOULD display display form, and MUST be able to display it for
any origin they have created. A root name SHOULD display as @<handle>
with no trailing segment.
An at:// URI naming any collection other than the route collection is
outside this specification. Clients MUST NOT treat such a URI as a name
to load, and a client that handles it otherwise is doing something this
document does not describe.
Segments after the short name are the subpath, passed to the handler (Section 4.2.2). They take no part in resolving the route record, whose record key is the short name alone.
A query component is passed to the handler with the subpath. It takes no part in route record resolution, in path canonicalization (Section 5.6.1), or in origin derivation (Section 5.1). A handler MAY ignore it.
A fragment is client-side only. It MUST NOT be transmitted, and takes no part in resolution, canonicalization, derivation, or the floor.
A trailing separator with no short name, as in atw://b.example/,
resolves to the root name (Section 4.7). It is not a name of empty
string, and Section 6.2's listing is a client affordance rather than a
different resolution.
alsoKnownAs
MUST contain at://<handle>. A handle that resolves to a DID which
does not claim it back MUST be rejected.Step 3 is not optional. Without it a hostile resolver returns any DID it likes and the handle means nothing.
Clients SHOULD perform DNS resolution themselves over DoH rather than
delegating to a third-party resolveHandle endpoint. A client that does
delegate MUST still perform step 3, which reduces a hostile resolver to
denial of service rather than impersonation.
DID documents MAY additionally be resolved over alternative transports (Section 14.3). These are redundancy, never a requirement.
This specification is a profile of AT Protocol, and the following are normative rather than left to the implementer. Field semantics come from the AT Protocol identity, repository, data model and cryptography specifications, which an implementer needs alongside this one.
Handle resolution. Clients MUST attempt both the _atproto.<handle>
DNS TXT record and https://<handle>/.well-known/atproto-did. Where both
resolve and disagree, DNS wins. Where only one resolves, that result is
used. Handles are compared and stored lowercase, after IDNA processing
(Section 13.2).
DID methods. Clients MUST support did:plc and did:web. A client
supporting neither cannot resolve anything; a client supporting one is
not interoperable, since publishers choose independently.
The did:plc directory endpoint is configuration, not a constant. It
is a trust anchor and a single point of failure, so clients MUST allow it
to be changed and SHOULD allow a mirror to be used for redundancy
(Section 14.3).
DID document fields. The repo signing key is the verification method
whose id fragment is #atproto. The account's host is the service entry
whose id fragment is #atproto_pds. Clients MUST select by fragment and
MUST NOT assume array position. Where either is absent, resolution fails.
Signature curves are those the AT Protocol cryptography specification blesses. A client MUST support all of them, since a publisher's key type is not the reader's choice.
The route collection NSID is configuration. Until Section 15 settles it, implementations MUST read it from configuration rather than compiling it in, so that fixing the name is a settings change rather than a release.
TTL bounds. A client MUST clamp a cached handle resolution to at least 60 seconds and at most 24 hours regardless of the TTL served. Honouring a zero TTL makes every display a DNS query (Section 5.10.2); honouring a month-long one makes a transfer invisible for a month.
A DID resolving does not mean its content should load.
| State | Behaviour |
|---|---|
| Active | Load normally |
| Migrating | Load normally; the DID is stable across PDS moves; follow the current serviceEndpoint |
| Deactivated | Do not load. Client chrome, per Section 5.7 |
| Suspended | Do not load |
| Taken down | Do not load, cache-wide, for every origin under the DID |
Takedown is the state moderation uses in practice, and the only one that reaches back into already-cached content. Clients SHOULD treat a PDS reporting takedown as authoritative for that repo even when content-addressed bytes remain available from a mirror, the mirror cannot lie about the bytes, but it also cannot know the account's state.
Takedown stops loading. It MUST NOT erase user data. Clients MUST NOT delete storage in a derived origin in response to any account-state signal.
Account state is reported by the PDS, the party this design declines to trust. Erasing storage on takedown would let a hostile operator destroy local data for every user who ever loaded that publisher's sites, a stronger capability than any attack this spec defends against. Withholding a load is recoverable; erasure is not, and is the user's action to take.
Note the asymmetry this creates with Appendix B: a mirror serving proven blobs will continue to satisfy requests from clients that never re-resolve. Takedown is a request to stop, not a guarantee of erasure (Section 13).
The short name is the record key.
com.atproto.sync.getRecord
?did=<did>
&collection=agency.portable.route
&rkey=<short-name>
Consequences of using the rkey:
com.atproto.repo.listRecords on the collection enumerates an
identity's names for free, which is what address-bar completion wants.Clients MUST use com.atproto.sync.getRecord, not
com.atproto.repo.getRecord. Only the sync endpoint returns the MST
blocks proving the record's inclusion in the signed commit. The repo
endpoint returns the record on the host's say-so. An implementation that
uses the repo endpoint appears to work identically and has silently
removed the entire verification story.
Clients MUST verify:
Short name syntax is atproto record key syntax: 1–512 characters
from A-Za-z0-9.-_~:, and not . or ... Record keys are
case-sensitive; so is this address segment. Clients SHOULD warn on
case-only collisions during publishing.
Subpaths. Address segments after the short name are passed to the
handler, which interprets them. A route record names a site; the handler
decides what /welcome means inside it.
Two layers with different invalidation rules. Conflating them is the most common way to get this wrong.
Pointers (handle→DID, DID→doc, DID+name→route record→root CID) are mutable.
Content (CID→bytes) is immutable and never invalidates. Shared across versions, names, and identities. This is where hit rate lives and it belongs on disk.
Clients with a persistent process MAY subscribe to a filtered firehose for push invalidation, the model wisp hosting services already use (Section 14.1). Filtering by collection rather than by repo means this does not disclose which names the user visits.
The firehose is an invalidation signal, not a source of proven content. Simplified streams such as jetstream emit JSON without the CAR blocks that carry the signed commit, so records taken from them are unproven. A cache built by writing firehose records straight into a store and serving them has no verification story at all, however faithful the bytes.
This matters because it is the cheap path and the standard pattern.
Implementations MUST re-fetch via com.atproto.sync.getRecord to obtain
proof, using the stream only to learn that something changed. A
(collection, rkey) pair from the stream tells a cache exactly which
entry to invalidate; the proof comes from the sync endpoint.
rev is a per-repo cache epoch. com.atproto.sync.getLatestCommit
returns {cid, rev} without a CAR, so one cheap request revalidates
every name under an identity at once. This is a stronger primitive than
per-resource validators, closer to a surrogate key than an ETag, and
intermediaries should build on it. Note it advances on any repo write,
including ones unrelated to any site (Section 5.2), so a cache SHOULD
compare the commit's touched collections before invalidating site
entries.
Snapshot consistency. Where a handler assembles a site from multiple
records, clients SHOULD pin one page load to a single commit rev and
resolve every resource against it, and publishers SHOULD write all
records for one logical update in a single commit via
com.atproto.repo.applyWrites. Without both, a reader can observe a
torn state, new index, old article. See Appendix B.
Offline and stale pointers. Content addressing makes offline operation work well, cached bytes verify against a cached CID with no network at all. The mutable half needs a rule.
rev floor (Section 5.2). Serving
cached content is not the same as accepting older content.{
"lexicon": 1,
"id": "agency.portable.route",
"defs": {
"main": {
"type": "record",
"description": "Maps a short name to loadable targets. Record key is the short name; 'self' is the root name.",
"key": "any",
"record": {
"type": "object",
"properties": {
"targets": {
"type": "array",
"minLength": 1,
"maxLength": 16,
"items": { "type": "ref", "ref": "#target" },
"description": "Ordered by publisher preference. Clients select one and stop."
},
"alias": {
"type": "string",
"maxLength": 512,
"description": "Short name in this same repo. Mutually exclusive with targets. Must not chain."
},
"policy": { "type": "ref", "ref": "#policy" },
"status": {
"type": "string",
"knownValues": ["active", "retired"],
"default": "active",
"description": "'retired' is a tombstone: the name is deliberately gone, not transiently unresolvable."
},
"successor": {
"type": "string",
"format": "at-uri",
"description": "Optional. Where a retired name's content moved."
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
},
"target": {
"type": "object",
"required": ["uri"],
"properties": {
"uri": { "type": "string", "format": "uri" },
"mirrors": {
"type": "array",
"maxLength": 8,
"items": { "type": "string", "format": "uri" },
"description": "Alternative sources for the same content-addressed bytes. Advisory."
}
}
},
"policy": {
"type": "object",
"description": "May only narrow client defaults, never widen them.",
"properties": {
"remoteSubresources": { "type": "boolean", "default": false },
"allowedOrigins": {
"type": "array",
"maxLength": 32,
"items": { "type": "string", "format": "uri" }
},
"critical": {
"type": "array",
"maxLength": 32,
"items": { "type": "string", "maxLength": 128 },
"description": "Policy keys a client MUST understand or fail closed."
}
}
}
}
}
Exactly one of targets or alias MUST be present, unless status is
retired, in which case both MUST be absent. A record carrying both, or
neither without being retired, is invalid and MUST NOT load.
A missing record and a retired record mean different things and MUST be handled differently.
| Record absent | status: retired |
|
|---|---|---|
| Means | Unknown; may be transient | Deliberately withdrawn |
| Client behaviour | Fail, treat as possibly temporary | Fail, state it is permanent |
| Origin storage | Retain | Retain; MAY offer the user a clear action |
successor |
n/a | MAY be offered as a link, never auto-followed |
A client cannot distinguish "deleted" from "the PDS is down" without this, so deletion alone strands an origin's storage with no explanation anyone can give the user.
Auto-following a successor is forbidden for the same reason Section 4.4 forbids fallback: a retired name's successor is chosen by whoever controls the DID now, which may not be who the user trusted.
A target is a URI plus optional transport hints. Hints MUST NOT be required for correctness: a client ignoring every hint MUST still be able to load the target by resolving its URI.
mirrors is the one defined hint. It lists alternative sources for the
same content, tried in any order alongside the canonical source (Section
4.4):
{
"uri": "at://did:plc:xxxx/example.handler.site/blog",
"mirrors": ["https://cache.example/", "https://cdn.example/"]
}
A mirror serves the same content-addressed bytes and is checked against the same CIDs, so a hostile mirror can withhold but cannot substitute. Mirrors MUST NOT be used to reach content whose CID is not already known from a proven record.
Handling is asymmetric by design.
policy are governed by the critical
list. A client encountering an unrecognized policy key named in
critical MUST fail closed. One not named there MAY be ignored.A blanket fail-closed rule on unknown policy keys would make the vocabulary permanently unextensible: adding any field would hard-fail every older client, a flag day for every future extension. A blanket ignore rule inverts Section 5.3 silently, a publisher tightens something, an older client drops what it cannot parse, and the content loads with looser capabilities than the publisher declared.
The critical list lets the publisher decide which it is, per field:
"policy": {
"remoteSubresources": false,
"someFutureRestriction": {},
"critical": ["someFutureRestriction"]
}
Publishers MUST list any restriction whose omission would change behaviour unsafely, and SHOULD NOT list fields that merely hint.
A client that does not implement critical MUST fail closed on any
unrecognized key inside policy. Such a client cannot determine which
unknowns are critical, so it must treat all of them as if they were.
Without this rule the mechanism is an open downgrade: an older client
would ignore both critical and the restriction it names, and load with
looser capabilities than the publisher declared.
This is why critical is defined in the first revision rather than
added later. A client predating it has no safe reading of a record that
uses it, and the rule above is what makes that predictable rather than
silent.
It also constrains how this specification may change. A later revision
that forbids something previously allowed cannot be made safe by any
declaration an older client makes, since that client cannot refuse a
rule it has never seen. Such a change MUST therefore arrive either as a
policy field publishers mark critical, or as a new origin derivation
byte (Section 5.1) so the content loads into an origin older clients
never enter. A restrictive change with neither door is forbidden
regardless of its merit.
This is the general rule for any future extension. Fields that grant or hint are ignorable; fields that restrict are ignorable only if the publisher says so.
NSIDs beginning example. below are placeholders standing for whatever
handlers a publisher's content actually uses.
{
"$type": "agency.portable.route",
"targets": [
{ "uri": "at://did:plc:xxxx/example.handler.site/blog" },
{ "uri": "at://did:plc:xxxx/place.wisp.fs/blog" },
{ "uri": "at://did:plc:xxxx/site.standard.publication/3k2..." },
{ "uri": "ipfs://bafy..." },
{ "uri": "https://blog.example.com/" }
],
"policy": {
"remoteSubresources": false
}
}
For at:// targets, the collection NSID is the handler key. Adding
support for a host is registering an NSID in a table.
The table below is illustrative, not normative. Its entries are existing or prototype systems, included to show the range of shapes a handler can take; none is privileged by this specification, and a conforming client may implement any subset or none of them (Section 10.2).
| NSID | Shape | Proof |
|---|---|---|
place.wisp.fs |
manifest record → compressed blobs | content-address |
site.standard.publication |
publication → documents, matched by path |
inclusion |
pub.leaflet.document |
document renderer | inclusion |
app.szzt.site |
site record → deployment → manifest → blobs | content-address |
Non-at:// targets dispatch on scheme.
No spec revision is needed to support a new host, there is no target type union to extend, and the record stays readable by anything that already parses AT-URIs. Which handlers exist is an ecosystem question rather than a specification one; what this document constrains is the shape a handler must have (Section 4.2.1) and how a client declares which it implements (Section 10.2).
There is no registry. Handlers are self-describing by NSID, clients implement whichever they choose, and a client encountering an unimplemented handler skips that target and says so (Section 10.2). If enough handlers exist to make coordination worthwhile, a registry is something the ecosystem can add without this document changing.
Every handler MUST resolve its target through exactly one record whose write is the moment the site changes. wisp's manifest record is one example; a handler that assembles a site from N records with no single switch cannot publish atomically and MUST NOT be registered.
Publishers cannot fix this themselves: atproto gives atomicity within one commit and large publishes do not fit in one (Section 9.4), so the thing that flips must always be one small record.
For inclusion-proven handlers this means a generation pointer: content records accumulate under any rev, and a pointer record names which generation is current. Readers resolve through the pointer, so partial writes are invisible until it moves.
A handler is code, and this specification does not define one. A client that loads anything therefore needs at least one handler specification, which is separate work. What this section fixes is the boundary between them, so that a handler written against one client works in another.
A client gives a handler:
| Target URI | The entry selected in Section 4.4 |
| Subpath | Address segments after the short name, canonicalized per Section 5.6.1 |
| Query | Passed through; the handler MAY ignore it (Section 3.1.4) |
| Pinned revision | The commit the load is pinned to (Section 3.4) |
| Byte range | Optional, for media (Section 11) |
A handler returns, for a request it can satisfy:
| Bytes | The resource |
| Declared content type | Never sniffed by the client (Section 5.5) |
| Switching record CID | The content CID for the floor (Section 5.2) |
| Proof mechanism | Inclusion or content-address (Section 4.3.1) |
A handler returns a path-level miss for a path absent from a site it has otherwise proven, which is after-proof content under Section 5.7. Anything else is a failure, and failures are the client's to render.
A handler specification MUST state its path resolution rules (Section 5.6), its switching record, and its resource size ceiling (Section 8). A handler MUST NOT fetch anything the client did not authorize, and MUST NOT return bytes it has not verified.
A target is provable or asserted. This determines the disposition, and the disposition determines everything downstream.
The bytes are proven to the identity that named them. Two mechanisms:
Proven by inclusion. Content lives in the record, proven by MST
inclusion under the signed commit (Section 3.3). No hashing step; the
proof from resolution is the proof of the content.
site.standard.document and pub.leaflet.document work this way.
Proven by content address. The record names a CID; every byte served
is re-hashed against it; a mismatch is a hard failure. place.wisp.fs
works this way. An ipfs:// target qualifies because its CID came
from the proven route record, not because IPFS is involved.
Provable targets load into a derived origin (Section 5.1).
The identity asserts a location. TLS authenticates the host. Nothing
authenticates the content. https:// targets, and the url field of a
site.standard.publication.
An asserted target is a signpost, not a backend. The client
navigates away to the target's own origin under ordinary web rules. It
MUST NOT synthesize an origin, and wherever it displays the current
location it MUST show the destination, not @handle/name.
Displaying the name over an unverified third-party origin is worse than a plain link: it lends the identity's authority to a host the identity only asserted. The name got the user there; it does not vouch for what is there.
http://, or any case where the chain breaks. Clients MUST fail rather
than degrade.
A target has exactly one handler and one disposition. A
site.standard.publication target loads from its document records; its
url field is a separate target the publisher may also list, with its
own disposition. Same site, two entries, two behaviours, not one entry
interpreted two ways.
A target's disposition MUST NOT depend on who is asking. It is fixed by the record and the client's handler support, never by authentication state, session, or identity of the viewer.
An implementation that gives an authenticated viewer a load and an
anonymous one a redirect makes the origin vary per viewer: storage
materializes and vanishes with auth state, and Section 5.2's per-origin
rollback floor becomes meaningless. The tempting case is auth-gated
HTTPS content, which is asserted for everyone (Section 14.1).
This invariant is what lets new proof mechanisms be added later without retroactive storage loss. Breaking it is cheap now and expensive to retrofit.
The two mechanisms above, inclusion and content-address, both prove against a public signed commit. That is the only reason Section 16 scopes this draft to public content.
A future mechanism proving content that is not publicly readable slots in here and nowhere else. Resolution (Section 3), origins (Section 5.1), rollback (Section 5.2), and capability policy (Section 5.3) are all orthogonal to whether the bytes are readable, and none of them should need revision.
Clients MUST select the first target whose handler and disposition they support, and then stop.
If the selected target fails, that is a hard failure. A client MUST NOT fall through to a later target on error.
Falling through on error means anyone who can break the first fetch forces the client onto a weaker target, and the weakest entry in the list silently sets the security level of the name. Ordering expresses publisher preference among targets the client can handle. It is not an error-recovery mechanism.
This constrains targets, not transports. Retrying the same content from a different source, a second mirror, a cache, the origin PDS after a cache miss, is not a fallback and is explicitly permitted. Content addressing makes the choice of transport free: every source is checked against the same CID, so none of them can be the weak one.
The distinction has to be drawn or a careful implementer reads Section 4.4 as forbidding mirror retry, which removes the resilience Section 7 depends on. A target says what content; a transport says where to get it. Only the first is a security decision.
Clients MUST select the first provable target they support, and MAY select an asserted target only if no provable target is supported.
Stated as "prefer the provable one where both describe the same content"
this would be unimplementable: a client cannot know that
https://blog.example.com/ and at://…/example.handler.site/blog carry
the same bytes without loading both, which the rule above forbids. Any
implementation of that phrasing would be a heuristic, and a heuristic
here is attack surface. Ordering among provable targets is publisher
preference; the provable/asserted boundary is not negotiable by
ordering.
A redirect may land on a URL that resolves back through a route record.
Loops are ordinary control flow rather than an edge case, and they can
span record types: route → publication → url → route.
Clients MUST cap redirect hops at the limit in Section 8 and MUST treat exhaustion as a hard failure rather than selecting another target (Section 4.4). Loop detection MUST span record types.
A route record asserts only: the controller of this DID says this short name points here.
That is strong for provable targets and weak for asserted ones.
For asserted targets the binding MAY be upgraded to mutual with a
reverse proof from the target, a TXT record under the target domain
carrying the route record's AT-URI, or standard.site's <link rel>
pattern (Section 14.4). Clients that find one SHOULD indicate it.
This is optional by design. Requiring it kills the "just point at my existing blog" case, which is the case that gets people using this.
An address with no short name resolves to the reserved rkey self,
following the atproto convention for singleton records.
@portable.agency → at://did:plc:xxxx/agency.portable.route/self
self is an ordinary route record in every respect: same targets, same
dispositions, same proof, same origin derivation. Nothing about Section
4.3 or Section 5 special-cases it.
@<handle> and @<handle>/self are two spellings of one name.
Clients MUST resolve them identically and MUST derive the same origin
for both. An implementation that derives from the empty string for one
and "self" for the other produces two origins for one site, with
storage and rollback state split between them.
self is therefore not available as an ordinary short name, a publisher
writing to that rkey is configuring their root.
Absence is not an error to paper over. If no self record exists,
@<handle> does not resolve. Clients MAY offer a directory of the
identity's names via com.atproto.repo.listRecords (Section 3.3), but
such a directory is client chrome, not content: it MUST NOT load into a
derived origin and MUST NOT be presented as the identity's site.
A route record MAY alias another name in the same identity instead of carrying targets:
{
"$type": "agency.portable.route",
"alias": "blog"
}
This exists because "my root is my blog" is the common case, and the
alternative, duplicating targets into self, silently creates a second
origin for what the publisher believes is one site.
@portable.agency
aliasing blog loads into blog's origin, not a separate one. Same
site, same storage, same rollback floor.alias and targets.The no-chaining rule removes alias loops by construction rather than by budget, which is why Section 4.5's hop cap does not apply here.
Requirement, not mechanism: each (identity, short-name) pair gets
a distinct origin. Names under one identity do not share storage with
each other. Loaded content never shares an origin with privileged client
surfaces.
The origin string will differ per platform. The property must not.
Derivation:
label = base32( version_byte || sha256( did || 0x00 || short_name ) )
Encoding is RFC 4648 base32, lowercase, without padding. The version byte
for this revision is 0x01. did and short_name are their UTF-8 bytes,
with the DID in the form it appears in the DID document.
The label is the full 53 characters and MUST NOT be truncated. A 33 byte input encodes to 53 base32 characters, which fits the 63 character limit a DNS label allows and therefore every platform host slot in Section 11. Truncating would weaken collision resistance and let two platforms derive different origins for one name.
short_name is the name after alias resolution (Section 4.7). An
aliased root derives the origin of its alias target, not of self.Only load targets get a derived origin. redirect targets use
their own origin (Section 4.3). The client MUST NOT synthesize a durable
origin for content it cannot verify, otherwise anyone who compromises
that host inherits everything accumulated under the name.
This asymmetry decides the rule. With content-addressed origins, substituted bytes produce a different origin and land in an empty sandbox, failing safe. With name-addressed origins, substituted bytes land in the same origin with full access to accumulated storage. Name unification is only sound where resolution-to-bytes is proven end to end.
Clients MUST record, per origin, the highest (rev, contentCID) pair
seen, and MUST refuse content proven against an older commit when the
content CID differs.
A proof whose content CID equals the highest known content CID is
accepted regardless of its rev. The rev comparison applies only when
the content itself differs.
The content CID is the CID of the selected target's switching record (Section 4.2.1). Using the switching record rather than a root manifest CID makes the rule uniform: Section 4.2.1 requires every handler to have exactly one such record, and its write is by definition the moment the site changes, so its CID is both always available and exactly the right identity to compare.
rev values are TIDs and MUST be compared as ASCII strings. TID encoding
is sortable, so string comparison and chronological comparison agree.
Clients do not need to consult the device clock (Appendix A.2).
rev is a TID the PDS enforces as monotonically increasing per repo, so
unlike a publisher-declared version number it is not forgeable by the
publisher. This is the atproto-native answer to the problem IWA solves
with update-manifest version numbers (Section 14.2).
It closes the multi-transport skew hole: a stale mirror or unupdated IPFS pin serving an older deployment into an origin that newer code has already written storage into.
rev advances on any write to the repo, and a site shares its repo
with its publisher's posts and follows. An active poster churns their
epoch continuously while their site is unchanged, so a mirror holding a
valid proof of identical content at an older rev would be refused
under a rev-only rule. The rejection is spurious, it fires more often
the more active the publisher, and the obvious workaround, always fetch
the origin PDS for a current rev, deletes Section 7 entirely.
The pair gets all three cases right:
| Situation | Content CID | Rev | Outcome |
|---|---|---|---|
| Attacker serves superseded content | Differs | Lower | Refused |
| Mirror serves current content, stale proof | Same | Lower | Accepted |
| Publisher deliberately reverts | Differs | Higher | Accepted |
The floor is about content, not commit position. rev is how content
changes are ordered; it is not itself the thing being ordered.
The rev floor is durable per-origin state, and Section 5.4 forbids
drive-by navigation from creating durable state. These do not conflict,
but the resolution must be explicit or implementations will pick one and
break the other.
A floor is client metadata, not site storage. It is written by the client, unreadable by the site, and recorded for every origin including ephemeral ones, skipping them would disable rollback protection for exactly the unvetted sites most likely to be hostile. The table must be bounded:
A change to the DID document's verification method MUST reset the
rev floor for every origin under that DID, and SHOULD discard
content cached against the superseded key.
Without this, the floor blocks the recovery it is supposed to survive. Run the compromise case: an attacker obtains a signing key, publishes a malicious deploy, a client loads it. The origin now carries a high floor and attacker-written storage. The publisher rotates, but the obvious recovery, republishing known-good content, is refused by the very rule meant to protect them, and any client holding the malicious deploy keeps serving it until it re-resolves.
Rotation is the recovery signal, and it is the only one available: the
old key can still sign anything, so no rule expressed in terms of rev
alone can distinguish a rollback attack from a rollback repair.
The reset is itself an attack primitive, and must be guarded. Whoever controls the rotation keys can rotate and then serve arbitrarily old content, rolling a site back to a version with a known vulnerability, into an origin holding storage written by newer code. Rotation-key compromise therefore grants republish and rollback, where without this rule it would grant republish alone.
The guard is the recovery window. For did:plc, a rotation can be
overridden by a higher-priority key within the operation log's recovery
window, which exists for exactly this case.
This is what makes Section 1.1's separation of identifier from key meaningful. A design where rotation does not reset the floor still loses the site on key compromise, the loss is merely deferred to the first recovery attempt. A design where it resets unguarded trades that for a rollback channel.
Content-addressed verification is worthless if a verified page loads a remote script. The bytes of the HTML are proven; what executes is not.
isolated-app: scheme.)policy in the
route record.A publisher-supplied policy may only narrow the client's defaults, never widen them. The record is publisher-controlled input, not configuration.
Manifest-only resolution governs <script src>. Three vectors never
reach a manifest and MUST be handled explicitly:
javascript: URLs MUST be rejected everywhere, including as the
resolved destination of a cross-name link (Section 5.9). This is the
one reachable from another publisher's content.data: URLs MUST NOT be a script source, and MUST NOT be
navigable at top level. Inline as manifest entries instead; a data:
script is unproven bytes executing in a proven origin.blob: URLs are permitted only for blobs created by already-proven
script within the same origin. They inherit the creating origin, so
the constraint is that the creator was itself proven.Inline <script> and eval are governed by the client's baseline CSP.
Clients SHOULD forbid both by default, since a site assembled from a
manifest has no need for either.
Clients MUST define what Origin a derived origin sends on outbound
requests, and SHOULD send the derived origin's own scheme and host
rather than omitting the header or sending null.
Section A.2's note that CORS is unnecessary concerns this design's
fetches of content-addressed blobs. It says nothing about how existing
services behave when called from an unfamiliar scheme. Servers with
string-matched allowlists will encounter a scheme they have never seen;
omitting the header or sending null is worse than sending a value,
because null is a value some servers treat as same-origin.
Drive-by navigation MUST NOT create durable state that the site can write or read.
| Ephemeral (default) | Pinned | |
|---|---|---|
| Created by | Navigation | Explicit user action |
| Storage | Session-only | Persistent |
| Permissions | None | Promptable |
| Quota | Shared, capped | Own allocation |
This section governs site-visible state. Client metadata about an origin, notably the rollback floor (Section 5.2.2), and a petname where one exists (Section 5.11), is written by the client, is not readable by the site, and is recorded for ephemeral origins as well. Omitting a floor for unpinned origins would disable rollback protection for exactly the unvetted sites most likely to be hostile.
Without this tier, unbounded origin creation becomes quota exhaustion and permission prompts from origins the user cannot evaluate, the problem that stalled IWA (Section 14.2).
Promotion is also where preference labels apply (Section 5.13.3). Safety labels are consulted earlier, before any load.
Type comes from the manifest or record. Clients MUST NOT sniff.
An entry with no declared type MUST be served as a non-executable
default (application/octet-stream), never guessed from its bytes or
extension.
Content addressing does not help here. A text file whose bytes sniff as
HTML is exactly what the publisher signed, so a sniffing client executes
script in the derived origin with a valid proof. This is the mistake
that produced X-Content-Type-Options: nosniff; there is no reason to
make it a second time when the manifest already enumerates every
resource.
atproto blob references carry mimeType alongside the CID, so for
blob-backed handlers the type is already proven by the same chain as the
bytes. Handlers SHOULD use it rather than introducing a parallel
declaration.
Each handler MUST define, and each client MUST follow:
/docs serves /docs/index.html, redirects to /docs/, or
fails;Clients MUST NOT apply their own defaults here. Every HTTP server answers these differently, and the difference is observable because relative URLs inside a document resolve against the result. Two clients disagreeing means the same proven bytes render differently, which defeats the purpose of proving them.
Before any lookup or escape check, a path MUST be normalized:
%252e%252e becomes traversal.a//b → a/b.. segments... segments. A .. that would ascend past the root is a
hard failure, not a clamp to root.The same normalizer MUST produce the path used for the Section 5.3 escape check and the path used for manifest lookup. Normalizing twice, or checking one form and looking up another, is the classic traversal bug: the check passes on a form the lookup never sees. Handlers MUST NOT re-normalize after the check.
Manifests SHOULD store paths already in canonical form, and clients SHOULD reject a manifest containing any entry that is not canonical rather than normalizing it on the publisher's behalf. A non-canonical entry is either a mistake or an attempt to present two spellings of one resource.
Failures fall on two sides of proof and MUST be handled differently.
Before proof. The name does not resolve; no self record exists
(Section 4.7); the record is retired (Section 4.1.1); the account is
deactivated, suspended, or taken down (Section 3.2.2); an alias is
dangling or chained (Section 4.7.1); no target is supported (Section
9.2); verification fails; the rollback floor is violated (Section 5.2);
a limit is exceeded (Section 8). These MUST render as client chrome.
Rendering publisher-supplied content for a verification failure means
rendering unproven bytes into the origin, which Section 10 forbids
outright.
After proof. A path that does not exist within a proven site. This is ordinary content. The handler decides; a handler's configured not-found path belongs here, and the response loads into the origin like any other proven bytes.
The test is whether the failure occurred before or after the identity's claim over the bytes was established.
Clients MUST NOT send a referrer on cross-origin navigation from a derived origin.
Per-name origins make the referrer a disclosure of which name the user came from, at finer granularity than the web's, and there is no legacy content depending on the header. Fixing this by default costs nothing; leaving it to a per-site header repeats the web's mistake of making the safe setting opt-in.
Origin strings differ per platform (Section 11), so a proven site cannot hardcode a URL for another name. Links between names are therefore written in link form and resolved by the client.
Resolution MUST be lazy. Clients MUST NOT resolve a link as a side effect of rendering. Resolution happens on activation, or on a deliberate user gesture such as hover-to-preview.
Eager resolution amplifies and deanonymizes. A page of 500 links would fire 500 resolutions on render for links nobody clicked, making any published page an amplifier against third parties; and an attacker linking to handles they control learns which reader IPs resolve them.
A link passes through states, and a client MUST NOT present a later state's confidence in an earlier one.
| State | Presentation |
|---|---|
| Unresolved (default) | Show the target's handle and short name. MUST NOT imply verification |
| Resolving | MAY indicate progress. Still unverified |
| Resolved, loadable | MAY show the petname (Section 5.11) in place of the handle |
| Resolved, asserted target | MUST indicate that activation leaves for another origin (Section 4.3) |
| Resolved, no handler | Inert, with the reason and the missing handler named (Section 10.2) |
| Resolved, retired | Inert, stated as permanent (Section 4.1.1) |
| Unresolvable | Inert, stated as possibly transient |
Inert means visible and non-activatable. Dropping a link conceals a publisher's intent; following one anyway invites Section 4.4's downgrade.
Link text is publisher-controlled and MUST NOT be trusted as a name.
This is the phishing vector, and it is the publisher's to exploit rather than the network's: nothing prevents
<a href="atw://attacker.example/x">@trusted.example/blog</a>
Link text is content. The target is a name. When the two disagree, the text is asserting something the client can check and has no obligation to repeat.
Descriptive link text raises no such question, which is why Section 5.9.1 recommends text that is either the target's name or plainly not one. Goal 1 (Section 1.1) applies to publishers as much as to clients: a name must not claim more than it can substantiate, and link text impersonating a name is that claim made by someone else.
This design is a two-layer naming system in the sense of Stiegler's petname model, and the third layer is deliberately left to clients.
| Layer | Here | Securely unique | Globally unique | Memorable | Assigned by |
|---|---|---|---|---|---|
| Key | DID | Yes | Yes | No | The system |
| Nickname | Handle | No | Yes | Yes | The publisher |
| Petname | (client-side, Section 5.11) | Yes | No | Yes | The user |
The short name is not a petname. It is assigned by the publisher and scoped to the publisher's namespace, which makes it a second nickname, structurally a path segment, not a naming layer.
All naming insecurity is in the first address segment.
Within a proven identity the short name is securely unique: rkey
uniqueness is enforced by the repo (Section 3.3), so @x/docs
unambiguously denotes one record. The difficulty in distinguishing
@a.example/docs from @b.example/docs is entirely a handle problem.
The second segment is sound and needs no additional layer.
A petname layer therefore need only cover identities, not names: a user
petnames @portable.agency, and @portable.agency/blog inherits it.
Wherever a client displays a name, a location field, a link, a history entry, a share target, a tab title, it MUST use whichever of these is available, in order:
Clients MUST NOT display a handle cached alongside a pinned origin. A pinned origin whose handle has since moved to another party would otherwise be labelled with a name that no longer belongs to its publisher, an impersonation the client itself performs.
A DID MUST NOT be the ambient display. It belongs to the key layer (Section 5.10), and cannot be read, remembered, or compared by the person looking at it. Substituting one on failure does not inform the user; it trains them to accept opaque strings where they are meant to recognise something, which is the habit confusable-name attacks rely on, and an attacker who can disrupt handle resolution installs that habit for free. Section 5.12 is where the DID is available to anyone who wants it.
Where a petname exists, resolution is not on the display path at all,
which removes the cost of step 2: a resolution per display leaks
browsing to the DNS resolver. Clients relying on step 2 SHOULD read the
cached DID document's alsoKnownAs rather than re-querying DNS, and
SHOULD revalidate on a schedule rather than per render. The DID document
is already fetched during Section 3.2, so the common case costs nothing
extra.
Goal 1 settles which name wins where they disagree: a label the user chose substantiates better than a handle whose current ownership the client must take on trust.
Petnames are OPTIONAL and entirely client-side. Nothing in the record format, the lexicon, or the protocol changes to support them. This section exists because the layer below is built to receive them, and clients that skip it should know what they are leaving unused.
Four concrete things, none of which the user gets from the handle alone.
A name that cannot be taken from them. Handles are rented domains
that transfer, lapse, and get sold. A user who reads @portable.agency
is trusting that whoever holds that domain today is who held it when
they decided to trust it. A petname is theirs: it does not move when the
handle moves, and a transfer shows up as "the site you call Portable
now presents a different handle" rather than as nothing at all.
Recognition instead of verification. Without a petname, every return visit asks the user to re-read a handle and decide whether it is the one they meant, a comparison against a string they half-remember, which is exactly the comparison confusable characters defeat. A petname replaces that with recognition: they see the word they chose, or they do not. Nobody else can cause the word they chose to appear.
Names in their own vocabulary. Publisher-assigned names are constrained by what was available and by how the publisher thinks of their own work. A user can call something Tax stuff or Mira's recipes, labels meaningful in the user's life rather than in the publisher's marketing. This is not a security property, and it is probably why anyone would actually use the feature.
Display that works when the network does not. Resolution can fail: DNS is unreachable, a PDS is down, the user is offline. If neither a petname nor a current handle is available, the client states that the identity is unresolved (Section 5.10.2); the DID remains available through inspection (Section 5.12), not as the site's display name. A petname is local, so it displays the same on a plane as at a desk.
A handle is a name the user is given and must keep trusting; a petname is a name the user has, and substantiates without asking anything of anyone.
A petname binds a user-chosen string to a securely unique local key. The derived origin (Section 5.1) already is one:
base32( version_byte || sha256( did || 0x00 || short_name ) )
It is computed locally, collision-free, and stable across handle changes and transfers. A petname table is therefore a map from user string to derived origin, requiring no new identifier and no coordination with anyone.
Clients SHOULD scope petnames to the identity rather than to each name under it, per Section 5.10.1.
Synchronizing a petname table between a user's own clients is out of band. It is client configuration, like bookmarks or a keychain, and whatever a client already uses to move settings between a user's devices works here unchanged: the table is a map of short strings to derived origins, and derived origins are computed identically everywhere at Layers 1 and 2.
This spec deliberately does not define a record for it. Putting petnames on the wire would make a local, optional, user-controlled layer into a protocol feature, and would publish, to anyone reading the repo, the list of identities a user has chosen to name and what they call them. That is a disclosure the user gains nothing from, and it would break goal 5 (Section 1.1) by making the top layer depend on the bottom one.
Note the limit this leaves, so it is not mistaken for an oversight: a user on a client with no sync has no petnames on a second device, and falls back to handle display (Section 5.10.2) there. That is the same position bookmarks are in, and the same remedy applies.
Section 5.4 already has the user promoting an origin from ephemeral to pinned by deliberate action. That is where the petname model places petname assignment: at the establishment of an ongoing relationship rather than at first sight.
Clients SHOULD offer to set a petname at the moment of pinning, and SHOULD default it to the identity's current handle so the common case requires no typing.
This sidesteps the standing adoption problem with petname systems: users will not perform a naming ceremony for their own safety. Here it rides on a gesture they already want for persistence, and the security benefit is a side effect of the storage benefit.
Fixed for return visits. A confusable or homograph handle (Section 12) cannot collide with a label the user chose. A handle transfer (Section 13.3) does not silently relabel a pinned site. Cross-name links (Section 5.9) can show a petname where one exists and flag its absence unmistakably where none does.
Not fixed: first contact. A user handed @portable.agency/blog with
no petname for it is in exactly the position Section 13 describes, and
that is where homographs bite hardest. Petnames defend established
relationships, not introductions; no petname system has solved this and
this one does not either.
Section 5.4's ephemeral tier already communicates "you have no relationship with this yet," and a petname makes the second visit safe. That is a real improvement over the web, where the second visit is exactly as unsafe as the first, but it is not a solution to phishing.
The layers compose because each is separable:
No layer depends on the one above it, which is what makes the petname layer safe to leave optional and safe to add later.
The layers can also be carried by different things: layers 1 and 2 travel over the protocol, the petname layer over whatever the client already uses for its own configuration (Section 5.11.2). Forcing every layer through one channel would put the user's private vocabulary on the wire.
Goal 1 (Section 1.1) says a name must not claim more than it can substantiate. Its corollary is that a person MUST be able to see what a name does substantiate.
Clients MUST provide an inspection surface, reachable from wherever a name is displayed, that reports at least:
| Identity | Petname if set, current handle, verification status of the handle↔DID check (Section 3.2), and the DID |
| Name | The short name, and the route record it resolved to |
| Disposition | load or redirect, and for a load, which proof mechanism (Section 4.3.1) |
| Handler | Which handler rendered it, by NSID (Section 4.2) |
| Freshness | The commit rev and content CID this origin is pinned to (Section 5.2) |
| Tier | Ephemeral or pinned, and what that grants (Section 5.4) |
This is where a DID belongs. Inspection is requested, not ambient: the person asked, so an unreadable ground-truth identifier is the right answer rather than clutter in a place meant for recognition (Section 5.10.2). The analogue is the certificate viewer behind a browser's lock icon, nobody reads it in passing, and it must be there.
Clients MUST make inspection available for an unresolved identity, since that is the state in which a person most needs it, and MUST NOT require network access to report anything already known locally.
Clients SHOULD report the same information for a redirect target (Section 4.3.2) before activation, which is what Section 5.9.3's inspectable-target requirement asks for.
This section constrains what is reported, not how. A panel, a detail view, a command output and a log line are all conforming.
Labels are the only revocation channel in this design (Section 13.3). They are third-party assertions about a subject, published by a labeler and consulted by a client.
A label names its source DID, its subject, a value, and a creation time, and carries a signature when it passes between services. Clients MUST verify that signature against the labeler's DID using the machinery of Section 3.
Structurally a label is the same kind of object as a route record: a signed assertion by a named party, verified the same way, and worth exactly what its author is worth. Consuming labels requires trusting no unauthenticated service.
A label targets an account DID, a record AT-URI, or, with a content identifier, one specific version of a subject.
The last is worth more here than in a feed. Because content is immutable and content-addressed, a label naming a CID is portable and permanent: it applies to those bytes under any name, published by any identity, indefinitely. Labelling a DID or a route record blocks a location; labelling a CID blocks the content itself, with no successor name to chase.
This is stronger than the web's arrangement, where blocking follows names and names are cheap.
Clients MUST distinguish two kinds of label value, because they need opposite resolution rules.
Safety labels assert that loading the content is harmful: malware, credential phishing, abuse material. The most restrictive assertion from any subscribed labeler wins, and the check happens before load (Section 5.13.4). A user who subscribed to a labeler wants it to be able to stop them.
Preference labels describe content so a person can decide: adult material, subject matter, spoilers. These resolve from per-value user settings and MUST NOT give a single labeler a veto.
Collapsing the two produces either a censorship channel, where any subscribed labeler can unilaterally suppress a competitor, or a safety mechanism too weak to stop anything. Which values fall in which family is a labeler's declaration and a user's setting, not this specification's to enumerate.
Clients MUST consult safety labels before load, against a locally held set, and MUST NOT query a labeler per navigation.
Both halves are required. Consulting only at promotion (Section 5.4) never fires for a drive-by visit, which is the case a phishing label exists to stop. Querying per navigation discloses browsing to the labeler, turning the revocation channel into a surveillance channel.
A local set satisfies both: the decision costs no network call, and the labeler learns only that a client synced. Clients SHOULD sync over the label subscription stream rather than polling, and MAY backfill on first subscription.
Preference labels MAY be consulted at promotion or per user setting, since their outcome is presentational.
A label arriving after content is cached MUST take effect on the next load. Content already read has been read, which is the limit Section 13.3 records for revocation generally.
The proof chain does not depend on the device clock (Appendix A.2). Label expiry would introduce a clock dependency, so:
Clients MUST name the labeler responsible whenever a label changes what a person sees. Presenting a third party's assertion as the client's own finding is the same defect as presenting link text as a name (Section 5.9.3).
Clients MAY allow a user to proceed past any label, MUST NOT do so silently, and SHOULD scale the friction to the family: a preference label is a setting, a safety label is a deliberate act.
Clients MUST NOT fail closed on an unreachable labeler. They serve from the last known set and SHOULD report that it is stale.
Failing closed lets a labeler outage stop browsing. Discarding the set when the labeler is unreachable would let a network attacker unblock everything by blocking one service. A local set degrades to staleness, which is the correct failure.
No labeler is mandated. Zero subscribed labelers is a valid configuration, and means the client has no revocation channel. Clients MAY ship defaults and MUST allow them to be removed. Requiring a particular labeler would rebuild the central authority the rest of this design removes.
A publisher may embed label values in their own records, authenticated by the repo commit rather than a separate signature.
Self-labels are additive only. A client MAY apply them, MUST NOT treat their absence as evidence of anything, and MUST NOT let one weaken a label from a subscribed labeler. They are publisher-controlled content, subject to the same caution as link text (Section 5.9.3).
The two are separate and neither substitutes for the other.
| Takedown (Section 3.2.2) | Label (this section) | |
|---|---|---|
| Asserted by | The account's host | Any labeler |
| Means | This host will not serve it | This party asserts something about it |
| Scope | That host only | Whoever subscribes |
| Effect on load | Refuse | Depends on family and settings |
| Effect on storage | None; never erases | None |
| Evaded by | Moving to another host | Unsubscribing |
A takedown is enforcement by a party that was serving the content. A label is advice from a party that may never have hosted anything. A client applies both, and an intermediary serving a compatibility surface (Section 10.3) applies its own, which is one reason such a gateway is not neutral.
Resolution (Section 3) answers where a name goes. Discovery answers what names exist. This section constrains client behaviour, because discovery is where a person first meets a name they did not already know, the case Section 13.1 lists as unprotected. It does not specify an index.
Completion sources, in the order a client SHOULD prefer them:
Clients MUST NOT transmit partial input to a third party by default. Each keystroke sent to a remote suggester discloses browsing in progress, including input never submitted and text typed by mistake. Sections 5.9.2 and 4.10.2 refuse the same disclosure for links and display.
Remote suggestion MUST be opt-in, MUST name the service it queries, and MUST be disableable without disabling local completion. Sources 1–4 need no third party at all.
com.atproto.repo.listRecords over the route collection returns every
name an identity publishes. One request, no index, no AppView, and the
repo already guarantees the names are unique within it (Section 3.3).
This is the natural response to a trailing separator, a person who has
typed @b.example/ is asking what is there, and it is the directory
Section 4.7 permits when a root name has no self record. As that
section requires, such a listing is client chrome: it MUST NOT load into
a derived origin and MUST NOT be presented as the identity's site.
The request goes to that identity's PDS, which learns of the interest. That is the same disclosure as visiting (Section 13), made slightly earlier.
Discovery does not require a global index. Three shapes exist, with different trust and privacy properties:
| Source | Covers | Notes |
|---|---|---|
| A PDS | The identities it hosts (com.atproto.sync.listRepos) |
Any PDS is a directory of its own community, self-hosted ones included |
| The DID directory | Identities under a given DID method | Global for did:plc; enumerable in bulk rather than queryable by prefix |
| A third-party index | Whatever it chose to index | May rank, search full text, and decide what is seen |
The first matters most: because a PDS enumerates its own accounts, a community that runs a server is a browsable neighbourhood without anyone operating a global service. Clients SHOULD allow a user to add a PDS as a suggestion source.
A client MUST attribute a suggestion to the source that produced it. A service that ranks decides what people encounter, and an unattributed suggestion hides who made that decision.
A suggestion is a claim by whoever produced it. It is not a resolution and MUST NOT be presented as one.
A suggester can place @burrit0.space in front of someone who never
typed it, and nothing here prevents that. These rules ensure it arrives
labelled as a proposal from a named source rather than as something the
client has vouched for.
Indexing the content of sites is an indexer's work, fed by the firehose (Section 3.4), and is out of scope here.
Two properties matter for anyone building one. Provable
content (Section 4.3.1) can be verified by the indexer when it indexes
and by the reader when they arrive, so a result can be checked rather
than trusted, which the web cannot offer. And robots.txt means nothing
in this design (Section A.2): records reach indexers over the firehose
whether or not a site asks them not to.
A subscription is a list of names a person wants to hear about, plus a
change signal. Both already exist: the list is client state, and the
signal is the (rev, contentCID) pair the floor already tracks
(Section 5.2). No new record type is required, and this specification
defines none.
Subscriptions are client-side by default. A list of what a person reads is a reading history. Publishing it as records would put that history on the firehose for anyone to index, which is a disclosure the subscriber gains nothing from, so publishing a subscription MUST be an explicit act with the consequence stated. The default is the same position petnames take (Section 5.11.2): local state, no wire format, synchronised between a person's own clients out of band.
| Source | Cost | Discloses |
|---|---|---|
| Firehose filtered by collection | All route writes network-wide | Nothing about which names are followed (Section 3.4) |
com.atproto.sync.getLatestCommit per identity |
One cheap request per identity, no CAR | To each publisher, that someone follows them |
| A third-party notification service | One connection | The entire subscription list to one party |
Clients SHOULD prefer the firehose where bandwidth allows, since it is the only option that reveals nothing, and SHOULD fall back to per-identity polling rather than a third-party service. A third-party notifier MUST be opt-in and MUST name itself.
rev is a maybe; the content CID is the answer. A repo's revision
advances on any write, including writes unrelated to any site
(Section 5.2.1), so a changed rev means only that the route record is
worth re-fetching. A changed switching record CID means the site actually
changed. Polling is therefore one cheap request plus an occasional
confirmation.
A notification MAY be derived from unproven data. Content MUST NOT be.
The firehose carries no proof (Section 3.4), but the consequence of a false notification is only a wasted check, so a client MAY act on a stream event to decide that something is worth fetching. It MUST then fetch with proof before displaying anything as content, and MUST NOT present a title or excerpt taken from the stream as the publisher's words.
Route records name sites, not items, so a subscription to a name yields
"this changed" and nothing finer. Finer granularity needs an item
vocabulary, and standard.site already is one (Section 14.4).
Where a name's selected target is a site.standard.publication, its
document records are the items. Each carries a title, a publication time
and a path, and that path is what Section 3.1.4 passes to the handler, so
a document becomes an address with no additional mapping:
site.standard.document with path /welcome
under a publication named by @b.example/blog
is atw://b.example/blog/welcome
The route record supplies the name, standard.site supplies the items,
and the path field joins them. Neither vocabulary needs to change.
Granularity follows the target. An inclusion-proven document target
yields an item feed. A content-addressed target yields site-level change
only, because a manifest records files rather than articles and a
changed file is not an article. This is a further reason for the
recommendation in Section 9.9 that longform publishers emit
standard.site records alongside their route records: it is what makes
their work subscribable at the level a reader cares about.
Items obtained this way are subject to Section 6.6.2. A document record proven by MST inclusion is content and may be displayed; the same record seen on the firehose is a notification until proven.
RSS is not a competitor here. A publisher may offer both, and a client may consume a feed from an asserted target under ordinary web rules (Section 4.3.2).
A conforming client needs no gateway. DID document →
serviceEndpoint → com.atproto.sync.getRecord and
com.atproto.sync.getBlob. No gateway, no AppView, no relay. CORS does
not apply because a protocol handler is not a web origin.
Gateways remain useful for three things, none of which is authority:
Load. Every visitor fetching every blob from one PDS makes a popular site a sustained load on its owner's server, which may be small and which has rate limits set for unrelated reasons. The failure mode is that success takes the site down and the owner's posting with it.
Compatibility. Anyone without a conforming client, initially, everyone, needs an HTTPS URL.
standard.site verification. The <link rel> handshake needs a
reachable HTTPS document (Section 14.4).
A gateway serving provable content is a cache of content-addressed bytes the client re-hashes anyway. It cannot lie, needs no trust, and any number may exist. This matches wisp's stated posture exactly: hosting services are read-only caches that can always reconstruct from the repo (Section 14.1). Direct clients reuse the same cache → mirror → PDS chain with the same verification, and simply start further down it.
The correct summary is that this spec demotes gateways from authority to CDN. It does not eliminate them.
Every limit below is a MUST for clients. Publishers exceeding one produce content that conforming clients refuse.
| Limit | Rationale | |
|---|---|---|
| Targets per record | 16 | Bounded resolution work per name |
| Redirect hops | 5 | Section 4.5's cap, made concrete |
| Alias hops | 0 | Aliases never chain (Section 4.7) |
| Manifest entries | 10,000 | Bounds validation and index memory |
| Resource size | Handler-defined | Must be declared, not implicit |
| Decompressed size | 100× declared, 256 MB absolute | See below |
| Concurrent blob fetches per load | 8 | Protects the publisher's PDS (Section 7) |
| Mirrors per target | 8 | Bounded retry work |
| Short name length | 512 | atproto record key ceiling |
| Address length | 2,048 | Bounded parsing; well above any legitimate name |
| Floor table entries | 10,000 | Section 5.2.2's bound, made concrete |
Decompression. Content addressing proves the compressed bytes and
says nothing about their expansion ratio. A small, validly proven blob
can expand without bound. place.wisp.fs compresses by design (Section
14.1), so this is live, not hypothetical. Clients MUST enforce both a
ratio cap and an absolute ceiling, and MUST abort mid-stream rather than
after, since a post-hoc check has already allocated the memory.
Aggregate limits matter as much as per-item ones. A manifest of 10,000 entries each just under the resource ceiling is within every individual limit. Clients SHOULD enforce a total-bytes budget per load and fail closed when it is exhausted.
Clients MUST NOT accept a publisher-supplied value that loosens a limit, that is Section 5.3's narrowing-only rule applied to resources rather than capabilities.
Clients SHOULD NOT impose stricter limits. These are exact, not floors; halving a ceiling makes conforming content fail on one implementation and succeed on another. Where resource constraints force one, the client MUST report it as a client limitation (Section 10.2).
The rest of this spec is read-side, written against a hostile network. This section is the write side, and its governing observation is that the write path has been designed against a cooperative one. It assumed a single publisher, publishing quickly, in one commit, to a PDS with no limits. None of those hold.
Items marked [verify] rest on PDS implementation behaviour that should be confirmed against multiple running implementations before being treated as settled. Section 1.3's posture applies most directly here: every mitigation below is written to be safe across the range rather than tuned to one implementation, so verification should change the tuning of these rules, not their existence.
A commit is atomic. Blob upload is not part of any commit, a blob
uploaded via com.atproto.repo.uploadBlob exists before anything
references it.
This creates a window in which uploaded blobs are unreferenced, and PDSes reclaim unreferenced blobs. [verify] the reclamation policy and timing of target PDS implementations; the hazard's existence is structural, but its window is not specified by atproto.
The consequence is size-dependent and silent: a small site uploads and commits within seconds and never notices, while a large site can have its earliest blobs reclaimed before the record referencing them lands. It presents as flaky hosting, not as an error.
Publishers MUST land the switching record last.
Nothing references new content until step 5, so a publish that fails at any earlier step is invisible to readers rather than half-visible. This is what gives a deploy readiness semantics without adding a readiness concept to the protocol.
Publishers SHOULD verify their own deploy through a conforming read path between steps 4 and 5. It is cheap, it runs against the same proofs readers will use, and it catches manifest errors while they are still unpublished.
Do not rewrite an unchanged record. If the switching record's
content is byte-identical to the current one, its CID is identical and
the write accomplishes nothing except advancing rev. Given Section
5.2, needless rev churn is not free. Publishers SHOULD compare before
writing.
The window in Section 9.1 is closed by referencing blobs as they upload rather than only at the end.
Publishers SHOULD write staging records: small records, in a collection no handler resolves, that reference each batch of uploaded blobs. A staging record's only job is to keep its blobs referenced.
upload batch 1 → write staging record 1
upload batch 2 → write staging record 2
...
write switching record → delete staging records
Each staging record is small enough to commit comfortably, blobs are never unreferenced for longer than one batch, and no reader ever sees a staging record because no handler resolves that collection. The torn state exists in the repo and is invisible through the name.
Staging records also give a resumed publish (Section 9.4) a record of what already uploaded, without needing to probe every blob.
Publishers SHOULD clean up staging records after the switch, and clients MUST ignore them, a staging collection is not a target.
com.atproto.repo.applyWrites bounds how much can land in one commit
[verify], and PDSes rate-limit writes per hour and per action
[verify] the current values. A site with many files is many blob
uploads, and publishing may simply not complete in one pass.
Two consequences the spec must own:
Atomicity survives, but only through the switch. A publish spanning
many commits is still atomic at the name, because Section 4.2 requires
the handler to resolve through one record. Appendix B's advice to use
applyWrites for one logical update holds for updates that fit; beyond
that, the switching record is the mechanism, not the commit.
This is a real advantage of deep CAS over shallow signing, and Appendix B undersells it: one manifest record switches a site of any size, whereas per-resource signing needs a generation pointer to get the same property.
Publishing MUST be resumable. Content addressing makes this nearly free, step 2 of Section 9.2 skips what already exists, so a publish interrupted by a rate limit resumes by re-running from the top. A publisher that cannot resume will, on a large site, leave blobs unreferenced across a multi-hour backoff, which is Section 9.1's hazard held open for exactly as long as it takes to matter.
Publishers SHOULD back off on rate-limit responses rather than retrying tightly, and SHOULD prefer fewer, larger blobs where the handler permits, since limits are generally per-action rather than per-byte [verify].
Section 5.2 handles mirrors lagging behind the origin. The reverse also happens: the switching record commits, and mirrors do not yet hold the blobs it names. Readers get a proven, current manifest naming content nobody can serve.
This self-heals given the rest of the spec: mirrors are advisory hints (Section 4.1), and retrying the same CID from another source, including the origin PDS, is a transport choice, not a fallback (Section 4.4). A mirror miss is a cache miss.
Publishers SHOULD allow mirrors to pre-warm between Section 9.2's steps 4 and 5, since blobs are retrievable from the origin before the switch flips. Publishers MUST NOT treat a mirror as the sole source of any blob: a mirror that has not caught up would then make a proven site unloadable.
Retiring is not deleting (Section 4.1).
status: retired rather
than delete the record. Deletion is indistinguishable to a client from
the PDS being unreachable.successor MAY name where content moved. It is offered, never
followed.(DID, name).
Publishers SHOULD NOT reuse a retired name, and clients SHOULD warn
when a retired name returns to active.This is accepted, not solved (Section 13.3). It is the same hazard as re-registering a lapsed domain and inheriting its cookies and localStorage, which the web has always had. Making it safe by construction would mean putting something mutable into Section 5.1's derivation, and Section 5.1 is the one part of this design that cannot change later without destroying every site's storage. Discipline plus a warning is the cheaper trade.
Nothing in atproto prevents two agents holding credentials for one DID from writing the same route record. An organizational handle with several people, or a publisher running two deploy pipelines, is an ordinary multi-writer conflict on an rkey.
swapRecord when updating a record they read,
pinning the CID they saw. Without it, concurrent deploys silently
last-write-wins, and the loser's blobs are left referenced by nothing.Who may hold credentials for a shared identity is an atproto question,
not this spec's. The conflict mechanics are this spec's, because the
failure mode, orphaned blobs plus a rev advanced past content that was
never switched to, is specific to how publishing works here.
Section 5.2 makes rotation the mechanism by which a publisher recovers from signing key compromise, and the guard on that reset is the DID recovery window. Two obligations follow:
A publisher without rotation-key access can still publish. They cannot recover.
A conforming publisher MUST do all of the following.
swapRecord when updating a record that was read (Section 9.7).A conforming publisher SHOULD do all of the following.
style.a3f9c2.css defeats
cross-version dedup by giving identical bytes different paths, and
the caching it buys is already provided by content addressing
(Section A.2). Every static-site toolchain does this by default and
it must be turned off.site.standard records alongside route records for longform
content (Section 14.4), which is what makes it subscribable per item
(Section 6.7).A conforming publisher MUST NOT do any of the following.
A conforming client MUST do all of the following.
did:plc and did:web, selecting DID document entries by id
fragment (Section 3.2.1).com.atproto.sync.getRecord and verify commit
signature and MST inclusion (Section 3.3).@<handle> and @<handle>/self identically, to the same
origin (Section 4.7).(identity, name) a distinct origin, derived only for
load targets (Section 5.1).rev per origin (Section 5.2).rev floor for every origin, ephemeral included, and
honour a rotation reset only after the recovery window (Section
5.2).javascript: everywhere and data: as a script source
(Section 5.3).critical
(Section 4.1).rev, and apply the rev comparison only when content differs
(Section 5.2).com.atproto.sync.getRecord, never from a
simplified firehose stream (Section 3.4).successor (Section 4.1).A conforming client MAY support any subset of handlers, including none beyond redirect. See Section 10.1.
Handler support is open-ended (Section 4.2), so a ladder of profiles would be unsound: no level could be complete, and the top of one would mean different things in different implementations. There is one profile and a declared capability set instead.
Core is the baseline and is mandatory. It comprises every requirement in Section 10 plus redirect handling, and loads nothing itself. A conforming reader, browser extension, indexer, or crawler can be Core and remain useful: it resolves names, verifies identity, applies Section 4.4's selection rules, and redirects with the disposition reported.
Anything beyond Core is a capability set (Section 10.2), not a higher profile. A client that implements one handler is not less conforming than one that implements five; it is conforming with a smaller set.
Two properties of Core make it a useful floor rather than a formality:
A client's capability set is:
at:// schemes it can load, if any.Unlike the handler list, the proof-mechanism set is closed and therefore meaningful to compare between implementations. Two clients supporting different handlers but the same mechanisms have the same security properties and differ only in reach.
Clients MUST document their capability set. Clients MUST state the distinction when a name fails for lack of a handler: this client cannot load this site and this site is broken are different statements, and Section 4.4 forbids conflating them by falling through.
Clients SHOULD name the missing handler when reporting such a failure, so a user can find a client that does support it. Clients MUST NOT represent an unsupported handler as a defect in the publisher's content.
There is no negotiation in the HTTP Accept sense, and none is
possible. The route record enumerates targets; the client selects
locally under Section 4.4. A PDS serves records and blobs and has no
knowledge of, or influence over, which target a client chose.
This is a consequence of the layering rather than an omission. Selection is a security decision (Section 4.4), and a security decision delegated to a remote party is not one. A negotiation protocol would give the serving side influence over which disposition the client ends up with, which is the downgrade Section 4.4 exists to prevent.
What exists instead is one-directional: publishers declare targets in preference order, clients select from what they can prove and load. The publisher expresses intent; the client decides.
An unknown handler NSID is resolvable, atproto NSIDs resolve to lexicons, so a client can discover the shape of records it has never seen.
This does not confer support. A handler is code, not a schema: knowing a record's fields says nothing about how its content is assembled, verified, or rendered. Clients MUST NOT attempt to load a target whose handler they do not implement, however well they can parse it.
Support is therefore always explicit, and adding a handler is a client release rather than a runtime discovery.
An intermediary serving com.atproto.sync.* to conforming clients
(Section 11.2) performs no selection. The client resolves, selects, and
verifies; the intermediary only moves bytes. No capability declaration
is involved.
An intermediary serving a compatibility HTTP surface to non-conforming browsers (Section 7) is a different matter: it selects a target on the browser's behalf, because the browser cannot.
Such an intermediary:
A browser reaching content this way has none of Section 5's guarantees: the origin is the gateway's, verification happened elsewhere, and the browser trusts the gateway as it would any web server. The guarantees stop at the gateway.
The resolver SHOULD be a pure function with transports injected. DNS, HTTP fetch, cache, clock. Everything in Section 3 and Section 4 is then shared across platforms and testable without a network.
| Platform | Origin mechanism | Notes |
|---|---|---|
| Electron | registerSchemesAsPrivileged + protocol.handle |
standard, secure, supportFetchAPI, stream. Not bypassCSP. Separate session partition; no preload. |
| iOS | WKURLSchemeHandler |
Custom schemes are not secure contexts without additional work |
| Android | WebViewAssetLoader |
Maps onto a virtual https:// origin; a different model; verify the property still holds |
Platform caveats to settle up front:
<video>/<audio>. Satisfy them by
slicing already-verified bytes. Whole-blob hashing is affordable while
resource size ceilings hold. The failure mode if ignored is media
silently not playing.com.atproto.sync.getBlob returns whole blobs. There is no range
support, no conditional request, no compression negotiation, and rate
limits set for a PDS's normal workload rather than for serving a popular
site.
Consequences for implementers:
Range by slicing bytes already fetched and verified. The PDS
will not help, and the whole blob had to be hashed anyway.An intermediary sitting in front of one or more PDSes SHOULD expose
com.atproto.sync.* rather than inventing a protocol. A conforming
client then points at a different base URL with no new code path, and
verification is what makes that safe, the intermediary is an atproto
read-path cache, not a trusted party. A compatibility HTTP surface for
non-conforming browsers (Section 7) is a second face on the same store.
Intermediaries MUST coalesce concurrent upstream requests. Section 8 caps a client at 8 concurrent blob fetches, which does nothing for an intermediary: a cold cache with ten thousand concurrent readers becomes ten thousand upstream requests against one publisher's VPS. That is exactly the failure Section 7 exists to prevent, caused by the thing meant to prevent it. Single-flight per CID, per record, and per identity resolution.
An intermediary's job is to be one well-behaved client to the origin PDS on behalf of many readers.
Two independent implementations are expected, so conformance should rest on shared fixtures rather than on prose agreement: records plus identity state in, expected outcome out. Adversarial cases are the point.
Each vector below is keyed to the section it exercises. A conforming client should produce the stated outcome for every vector covering a section it implements.
alsoKnownAs, reject. or .., rejectalias and targets, invalidstatus: retired, invalidstatus: retired and targets, invalidpolicy listed in critical, fail closedpolicy, client not implementing critical,
fail closedstatus: retired, fail as permanent, storage retainedsuccessor, successor not followedurl → route, bounded,
then hard failure@handle and @handle/self, identical resolution, identical origin@handle where no self record exists, fail, no directory
substitutedself'srev after an unrelated repo
write, acceptrev, refusetext/plain whose bytes sniff as HTML,
served as declareda/%252e%252e/b, single decode pass, then rejecta//b and a request for a/b, one canonical form.. ascending past root, hard failure, not clamped to rootjavascript: URL reached via a cross-name link, rejectdata: URL as a script source, rejectatw://handle/name and @handle/name, identical resolutionatw://did%3Aplc%3Axxx/name, resolves without handle resolutionatw://did:plc:xxx/name, colons unescaped, rejectedatw:name inside proven content, resolves against the containing
identity onlyatw:name where the containing content is unproven, not resolvedatw://name where name was meant as shorthand, treated as an
identity, not shorthandat:// URI naming a non-route collection, not loadedatw://b.example/, resolves to the root nameatw://b.example/blog?q=1, query reaches the handler, absent from the
record key and the derived originatw://b.example/blog#top, fragment never transmittedrev values compared as ASCII stringsswapRecord, detected
rather than silently mergedTrusted: the client and its update channel, the DID method's own guarantees (log integrity and the recovery window), the cryptographic primitives, and the user's device.
Not trusted: every network participant, including the publisher's own PDS, every mirror and intermediary, DNS, suggestion services, indexers, and labelers.
Protected: integrity of provable content against everyone except the holder of the identity's signing key; namespace exclusivity within an identity; origin isolation between names; freshness against rollback; and honest naming, meaning the client never presents a claim it cannot substantiate.
Not protected: reader anonymity (Section 13.2), unpublishability, availability, confidentiality (Section 16), and recognition of a confusable name on first contact (Section 6.4).
Out of scope: compromise of the user's device, traffic analysis beyond the readership disclosure named below, and any guarantee that content remains reachable.
| Adversary | Can | Cannot | See |
|---|---|---|---|
| PDS operator | Withhold, rate-limit, serve stale, misreport account state, observe readership | Forge a commit, alter a record undetected, substitute bytes, erase client storage | Sections 3.2.1, 7 |
| Mirror or intermediary | Withhold, serve superseded content, observe readership | Substitute bytes, promote itself to authority | Sections 4.4, 7 |
| On-path network | Block, delay, tamper with asserted targets | Alter provable content | Section 4.3 |
| DNS or handle layer | Break handle resolution, register confusables | Make a DID claim a handle it does not | Sections 3.2, 13.2 |
| Handle successor | Take the label | Take origins, storage, or past content | Section 5.10 |
| Signing key holder | Publish anything under that identity's names | Roll back below the floor until a rotation settles | Section 5.2 |
| Rotation key holder | Take over the identity; reset the floor and roll back | Act inside the recovery window unopposed by a higher-priority key | Sections 5.2.3, 9.8 |
| Publisher | Impersonate in link text, escape paths, widen policy, ship decompression bombs, inject non-manifest scripts | Exceed a limit, have unproven bytes served, borrow another identity's origin | Sections 5.3, 5.9.3, 8 |
| Suggester or index | Propose a confusable, rank, observe typed input | Present a suggestion as verified, borrow a petname, pin an origin | Section 6.4 |
| Another loaded site | Nothing across names | Reach another name's storage or scripts | Section 5.1 |
| Labeler | Propose labels; suppress content for its subscribers | Compel a client, recall content already read, act unattributed | Section 5.13 |
Two properties fall out of the table. Every adversary that can withhold is unable to substitute, which is what makes hosting a performance decision rather than a trust decision (Section 7). And the publisher is an adversary: most of Section 5's rules defend the person reading a site against the person who published it.
Identity takeover inherits everything. Compromise of a DID's rotation keys yields every origin and all accumulated storage under that identity. Roughly par with domain transfer, so not disqualifying, but it is the real argument for publisher key hygiene guidance.
Unbounded origin creation. Any identity can mint arbitrarily many origins at no cost. Section 5.4 is the mitigation; a client that skips it will have a quota problem.
Homograph and near-handle attacks. Handles are domains and inherit every confusable-name problem DNS has. Wherever a client displays a name, a location field, a link, a share sheet, a history entry, is the mitigation point, and a client with no location field has more of them to get right, not fewer.
Petnames (Section 5.11) close this for return visits, since a chosen label cannot be collided with. They do not close first contact, which is where confusables do their work, and Section 6.4 is the path that delivers most of it. Section 5.4's ephemeral tier is the partial answer there: an unpinned origin communicates that no relationship exists yet.
Note an asymmetry in normalization: short names are ASCII by record-key syntax, but handles are IDN and are not. Clients MUST apply IDNA processing to handles before display and before comparison, and SHOULD mark mixed-script handles distinctly. A client that normalizes for resolution but not for display shows one name while loading another.
Direct reads disclose readership. Section 7 removes gateways from the trust path, but gateway-mediated reading also hid readers from publishers. A client fetching blobs directly tells the publisher's PDS operator exactly who read what, and the publisher may operate that PDS themselves.
This is a real cost of the design, not an oversight, and it runs opposite to the usual decentralization intuition: the more direct the fetch, the worse the reader's privacy. Clients that care SHOULD fetch through a cache because a cache learns the reader's identity in the publisher's place, verification is unaffected either way, which is what makes the choice available at all.
Connection-origin decoupling. This intentionally breaks the implicit guarantee that bytes in an origin arrived over a connection authenticated to that origin, the objection Mozilla raised against Signed HTTP Exchanges (Section 14.6). That guarantee is replaced by proof to a signed commit, strictly stronger for provable targets and explicitly absent for asserted ones, which is why Section 4.3 separates them by disposition rather than grading them on one scale.
Revocation is advisory. Labels (Section 5.13) are a retraction channel, but clients choose their labelers and cached content survives. No content addressed this way can be assumed unrecallable.
Goal 7 (Section 1.1) permits deliberately accepting gaps already present in the HTTP web where addressing them would cost more than it buys. The comparisons below explain those trade-offs; they do not set a feature-parity goal or promise equivalent behaviour. The list also identifies limitations specific to this design, so each is argued once here rather than rediscovered as an oversight.
| Problem | Web equivalent | Why accepted |
|---|---|---|
| Retired name reuse inherits storage (Section 9.6) | Re-registering a lapsed domain inherits its cookies and localStorage | The fix would put mutable state into Section 5.1's derivation, and that derivation cannot change later without destroying every site's storage |
| Readership disclosed to the publisher's PDS (Section 13) | Server access logs | Intrinsic to fetching from someone. Mitigable by fetching through a cache, which verification makes safe |
| No true revocation (Section 13) | Cached pages, archives, mirrors | Anything that reached a public log is out of the publisher's hands. Takedown is a request to stop, not erasure |
| Handle transfer moves the display name (Section 5.10) | Domain transfer | Storage and origins key on DID and survive it; only the label moves. Petnames (Section 5.11) remove it entirely for anyone who set one |
| Homograph and confusable handles at first contact (Section 13) | IDN homograph attacks | Inherited from DNS. Petnames close return visits (Section 5.11); first contact is unsolved everywhere |
| Identity compromise inherits everything (Section 13) | Losing control of a domain | Par with the web's worst case. Section 5.2 and Section 9.8 make recovery possible, which is more than a stolen domain offers |
| Key rotation grants a rollback channel (Section 5.2) | Certificate reissuance after compromise | The alternative is no recovery at all. Bounded by the DID recovery window |
| Unbounded origin creation (Section 13) | Unbounded subdomain registration | Section 5.4's ephemeral tier makes drive-by creation cost nothing durable |
| Per-platform origin strings, so storage does not migrate (Section 11) | No web equivalent; strictly worse here | Accepted because the alternative is DNS, which is what the design exists to avoid |
| Clients with differing capability sets reach different content (Section 10.2) | Browsers with differing codec and format support | Declared capability sets and explicit error messages, rather than silent degradation |
Three problems are not accepted: substituted content, which is always a hard failure; silent downgrade, which Section 4.4 forbids even though the web permits it in several places; and client-performed impersonation, a stale handle, a link presented as resolved, an asserted target shown as proven, which is forbidden in every case.
That division is goal 1 stated as a tolerance, and it is the test for anything added later: does this let a name overstate itself? If yes it is a defect whatever it buys. If no it is a cost to weigh.
wisp is the nearest existing system to this design, so the comparison is the most useful one in this section.
| wisp.place | This spec | |
|---|---|---|
| Address | sites.wisp.place/{handle}/{site} |
@handle/name, resolved to a derived origin |
| DNS per site | None required | None required |
| Namespace root | Handle | Handle for input, DID for everything durable |
| Uniqueness | rkey within the repo | rkey within the repo |
| Source of truth | The author's PDS | The author's PDS |
| Hosts are | Read-only caches, reconstructable from the repo | Read-only caches, reconstructable from the repo |
| Storage | Files compressed into blobs (place.wisp.fs, place.wisp.subfs for chunking) |
Handler's business; place.wisp.fs is a registered handler |
| Invalidation | Hosting services watch the firehose | Same, but firehose is invalidation-only (Section 3.4) |
| Who verifies | The hosting service; the reader trusts it over TLS | The client, against the signed commit and CIDs |
| Origin isolation | One shared origin for every publisher | One origin per (identity, name) |
| Atomic switch | Manifest record | Required of every handler (Section 4.2) |
| Custom domains | place.wisp.domain, optional |
Optional, and not the addressing mechanism |
| Private sites | In wisp storage, off-PDS | Out of scope (Section 16) |
| Self-hostable | Yes | Yes; capability sets, Section 10.2 |
Where they agree, they agree completely. PDS as source of truth, hosts as reconstructable caches, rkey-scoped names with no allocation registry, custom domains as optional decoration. Those were wisp's positions first and this spec adopts them without modification.
The two substantive differences:
Who verifies. In wisp the hosting service verifies and the reader trusts the host over TLS, a sound model, and the reason wisp works in any browser today. This spec moves verification into the client, which demotes the host to a cache that cannot lie (Section 7) but requires a conforming client to get any benefit. wisp's model reaches everyone; this one reaches fewer people with a stronger guarantee. Both are legitimate, and the gateway is where they meet.
Origins. wisp serves every publisher from one origin, so all sites
share storage, cookies, and scripting access. Section 5.1 requires
per-name origins. This is why a conforming client cannot simply proxy
wisp's HTTP surface, it would inherit the shared origin, and why
place.wisp.fs is registered as a handler that reads the records
directly instead.
The alignment test: a wisp site should be nameable by a route record with no change to wisp. If that ever stops being true, this spec has a bug.
Borrowed: the framing of hosting services as read-only caches that can always reconstruct from the repo, which this spec adopts as its gateway posture (Section 7); firehose-driven cache invalidation (Section 3.4); and handle-namespaced, path-per-site addressing, which is the shape generalized here.
Also diverging: wisp's URL keys on handle, while Section 3.4 and Section 5.1 key durable state on DID, so a handle transfer does not hand the previous owner's storage to its new holder.
Out of scope rather than disputed: wisp's private sites live in wisp storage, never on the PDS or firehose, so they are unprovable by this spec's chain (Section 16). The two models therefore do not cover the same ground.
IWA packages apps into signed Web Bundles served from an isolated-app:
scheme whose hostname is a Web Bundle ID: base32 of an Ed25519 public
key plus a short type suffix. Transport is irrelevant to origin
identity.
Borrowed:
isolated-app: scheme; Section 5.3 rejects entries escaping
the origin at load time.Aligned: the premise that origin identity can be anchored in a signature rather than a connection.
Diverges, structurally:
pkarr publishes signed DNS packets to the Mainline DHT under Ed25519 public keys, giving keys that resolve as sovereign TLDs, with HTTP relays for clients that cannot open UDP sockets.
Adopted: as an optional transport for DID documents (Section 3.2), reducing dependence on a single DID directory. Listed as redundancy, never required. A client with native networking can speak the DHT directly rather than through relays, which is where most of the decentralization claim otherwise leaks.
Not adopted, as the naming layer, for three reasons:
@portable.agency/blog is
typeable, so pkarr sits below the layer that matters here.standard.site (site.standard.publication, site.standard.document)
gives publishers a shared vocabulary for longform content, with
documents carrying path, metadata, and text content, verified by a
<link rel> on the served page pointing back at the AT-URI.
The verification direction is inverted between the two specs, which is what makes them compose rather than compete:
| standard.site | This spec | |
|---|---|---|
| Authoritative side | The website | The record |
| Delete the record | Site still works | Name ceases to exist |
| Proof direction | URL proves the record | Commit proves the bytes |
| Granularity | Per document | Per site |
| Audience | Indexers, readers | Clients navigating |
Aligned / adopted:
site.standard.publication is a first-class handler (Section 4.2),
proven by inclusion (Section 4.3). Documents' path fields supply the
subpath tree, so @portable.agency/blog/welcome resolves without this
spec inventing a path mechanism.url field is an ordinary asserted target, a
redirect, not a second way to load the same entry (Section 4.3).<link rel> reverse-proof pattern is the model for Section 4.6.Unresolved friction: site.standard.document expects an HTTPS URL.
Content at a derived origin has none. Discovery still works, because
indexers consume records from the firehose rather than crawling. But the
<link rel> verification handshake needs a reachable document, so a
route-only site is indexed but unverified, no publication badge. This is
cosmetic rather than structural, and it is the one remaining reason a
publisher might want a gateway URL they would not otherwise need
(Section 7).
Signed HTTP Exchanges. The same origin/connection decoupling, and the source of the objection recorded in Section 13. This spec accepts it rather than answering it.
WebFinger links. Ordered typed pointers with the same downgrade
and over-claiming failure modes. Relevant before Section 4 is frozen.
IPFS. CID-subdomain origins unify on content and lose storage on every publish; IPNS-key origins unify on name. Section 5.1 is the second shape with a DID where IPNS has a raw key, which is what makes rotation possible.
Tangled Pages. Origin-per-identity, path-per-site, backed by git
repos and sh.tangled.repo records. The closest existing implementation
of the addressing shape, differing in that it uses real DNS subdomains.
agency.portable.route is a placeholder. If this is meant
to be a shared vocabulary, the domain it sits under is a governance
question, not just a naming one, see how site.standard was
established collaboratively rather than under one publisher.site.standard.document records is a reader view, not the site, and
the ecosystem's content-type parsing is still immature. Should the
spec say so, or is that a client's problem?<link rel>
handshake needs a reachable HTTPS document, which content at a
derived origin does not have (Section 14.4). Accept the missing
badge, define an atproto-native alternative, or treat a gateway URL
as the answer?title field would help, and
would be publisher-controlled text displayed beside a name, the
attack Section 5.9.3 covers for link text. Add it with the same
never-treated-as-a-name rule, or leave names to speak for themselves?This draft covers public websites. Both proof mechanisms in Section 4.3 prove against a public signed commit, so provable content is by construction publicly readable.
That is a scope decision, not a gap. Three things get called private and only one of them is deferred:
Unlisted. Content is public and fully provable; the name is
unguessable, a random rkey rather than blog. Drafts, review links,
pages the publisher would rather not advertise. No spec change; this is
a publishing-UI affordance. Named here because it is what most requests
for "private" actually want.
Access-controlled. Content behind an auth wall on some host, wisp's private sites (Section 14.1), or any gated HTTPS endpoint. Already handled: these are asserted targets, so they redirect (Section 4.3). The client cannot verify them, so they get no derived origin and make no proof claim. The cost is real and the treatment is accurate. Note that the disposition is fixed for everyone, per Section 4.3, an auth-gated target does not become provable for viewers who hold credentials.
Cryptographically private. Content whose bytes are on the PDS but unreadable. Deferred to atproto's own private-data work rather than invented here. When it lands, it enters as a third proof mechanism in Section 4.3 and touches nothing else: resolution, origins, rollback, and capability policy are all orthogonal to whether bytes are readable.
Three properties to carry into that work rather than rediscover:
This section is non-normative.
Same job, different answers. Section A.2 covers what HTTP needs that this design does not.
| HTTPS + DNS + CA | This spec | |
|---|---|---|
| Name | Domain, rented from a registrar | @handle/name, a record in a repo |
| Link syntax | https://host/path |
atw://handle/name/path (Section 3.1.2) |
| Cost of a name | Per domain, annually, forever | A record write. Names within an identity are free |
| Who authorizes it | Registry → registrar → registrant | Whoever controls the DID |
| Name → location | DNS, cached by TTL | Handle → DID → PDS → record, cached by rev (Section 3.4) |
| Authenticates | The connection | The bytes |
| Trust anchor | ~150 root CAs, any of which can issue for any name | The DID document, controlled by the publisher |
| Revocation | CRL, OCSP, short-lived certs | Rotation (Section 5.2); labelers as an advisory channel |
| Key rotation | Reissue, name unaffected | Rotate, name unaffected; and it clears the rollback floor |
| Origin | scheme + host + port | Derived from (DID, name) (Section 5.1) |
| Origin isolation cost | A DNS name, and a certificate | Nothing |
| Content integrity | None beyond the transport; SRI per tag, opt-in | Structural: every byte proven (Section 4.3) |
| Freshness | ETag, Last-Modified, Cache-Control, expiry | Monotonic rev + content CID (Section 5.2). No clocks anywhere |
| Mirrors | Must be trusted; CDN sees and can alter plaintext | Cannot lie, only withhold (Section 7) |
| Rollback protection | None | Required (Section 5.2) |
| Publisher key loss | Reissue from the CA | Rotate, if rotation keys were kept (Section 9.8) |
| Domain loss | Total: name, content, and origin storage | Handle loss costs the label; DID retains origins and storage |
| Readership | Server logs, plus the CDN's | Same, and worse when fetching direct (Section 13) |
| Offline | Works only from cache, unverifiable | Verifies fully offline against a cached CID |
| Reach | Every browser | Conforming clients; gateways for the rest (Section 7) |
This section is non-normative.
| System | Key layer | Nickname layer | Petname layer |
|---|---|---|---|
| Web + DNS | none | Domain | Bookmarks, degenerate |
| SSH | Host key | none | known_hosts plus Host aliases |
| Secure Scuttlebutt | Feed ID | Self-assigned | Yes |
| Tor onion services | Address is the key | none | Bookmarks |
| Nostr | npub | NIP-05 identifier | none |
| pkarr | Public key | none | Explicitly deferred to callers |
| This spec | DID | Handle | Optional, client-side (Section 5.11) |
SSH is the most widely deployed working petname system and the closest structural analogue: a key the user never reads, a local alias they chose, and a loud warning when the binding changes. The web is the outlier in having no key layer at all, a domain is simultaneously the only identifier and the only name, which is why losing it loses everything (Section 13.3).
On Zooko's triangle, this design currently spends both its choices on global: the DID is secure and global but unmemorable, the handle is memorable and global but not secure. The memorable-and-secure corner is the one left unoccupied, and Section 5.11 is how a client occupies it without the protocol changing.
This section is non-normative.
Much of HTTP's accumulated machinery patches a gap this design closes lower down. Porting it adds surface without adding safety.
| HTTP mechanism | Why it is unnecessary here |
|---|---|
| ETag, Last-Modified, 304, Cache-Control | A CID is a permanently valid validator. Only the name→CID pointer expires, under its own rules (Section 3.4) |
| Fingerprinted filenames | Content addressing already makes aggressive caching safe. Fingerprinting defeats cross-version dedup by giving identical bytes different paths (Section 9.9) |
| Subresource Integrity | Executables resolve from the manifest (Section 5.3), so integrity is structural rather than per-tag |
| CORS, preflight | Blob fetches carry no ambient authority and mutate nothing. Section 5.3 is stricter and enforced at load time |
| HSTS, upgrade-insecure-requests, mixed-content blocking | All defend a transport-derived guarantee this design does not rely on |
| DNSSEC, CAA, Certificate Transparency, pinning | A DID plus a signed commit answers name ownership directly. DNS remains only for handle→DID, secured by Section 3.2's bidirectional check |
rel=canonical, www-vs-apex, 301 migration |
One name resolves to one record. A handle change leaves the DID and every origin intact |
| robots.txt, sitemap.xml | Records reach indexers via the firehose regardless, and listRecords enumerates a site more completely. robots.txt implies a withholding power the publisher does not have |
Expiry, max-age, certificate validity windows |
No clocks anywhere in the proof chain. Nothing breaks on a wrong device clock and nothing expires unattended |
Not everything is deletable. Content types (Section 5.5), path resolution (Section 5.6), error semantics (Section 5.7), and referrer policy (Section 5.8) are real problems the web solved for real reasons; Section 5 adopts the lessons with tighter defaults.
This section is non-normative.
Three areas.
Reach. Every device made in the last thirty years speaks HTTPS. This design reaches conforming clients, and everyone else through a gateway that reintroduces the trust properties it was built to remove. That is the central adoption cost, and Section 7 exists to manage it rather than deny it.
Ambient infrastructure. Caching, compression negotiation, range requests, load balancing, DDoS absorption, decades of accumulated operational tooling that assumes HTTP. Section 11.1 is largely a list of things a PDS will not do for you that nginx would.
Failure familiarity. When HTTPS breaks, operators know why. When this breaks, a floor rejection, a missing handler, an unsettled rotation, nobody has intuition yet. Section 5.7's requirements on error surfaces are a partial answer and not a substitute for operational experience.
This section is non-normative.
Names are not rented. The recurring cost of a domain is why most people publish inside someone else's namespace. A name here costs a record write, and names within an identity are free, which is the whole premise.
The trust anchor is the publisher. HTTPS asks you to trust that none of ~150 root CAs will issue for a name they do not control. Here the DID document is the anchor and the publisher controls it.
Integrity is structural, not transport-derived. HTTPS authenticates a connection; anything cached, mirrored, or replayed is unverifiable afterwards. Content proven against a signed commit stays proven, in a cache, on a mirror, on a laptop with no network.
Origin isolation is free. On the web, a separate origin costs a DNS name and a certificate, which is why so much unrelated content shares one. Here it costs a hash (Section 5.1).
Rollback protection exists at all. The web has no mechanism to stop a cache or mirror serving a superseded version.
No clocks. Nothing expires, so nothing breaks because a device's clock is wrong (Section A.2).
This section is non-normative.
This design trades reach for guarantees. The web's model reaches everyone with a weaker claim; this one reaches fewer people with a stronger one, the same axis Section 14.1 places wisp on, further along it.
That trade only pays if goal 1 holds. A design that reached fewer people and let names overstate themselves would be strictly worse than the web, which is why the constraints that look severe (no fallback, no synthesized origin for unverified content, no stale display) are the ones that cannot be relaxed for adoption's sake.
This section is non-normative.
Section 4.3 admits two proof mechanisms. This appendix is why, and what each costs.
This section is non-normative.
Pure content addressing cannot name anything. A CID identifies bytes; it says nothing about who published them or whether they are current. Any content-addressed system that wants a human-readable name needs a signed mutable pointer at the root. IPNS for IPFS, a signed record here. Pure CAS is not an option, only a choice about how deep it goes below the pointer.
Pure signing cannot carry bulk. atproto's own blob references are CIDs, so a repo holding anything larger than record-sized content is already a hybrid. Pure signing is not an option either.
The real question is therefore not which, but where the boundary sits between the signed layer that establishes what is current, and the content-addressed layer that gets bytes from anywhere.
This section is non-normative.
| Signed layer | Content-addressed layer | |
|---|---|---|
| Establishes | Whose, and which version | That these are the bytes |
| Wants to be | Small, mutable, authoritative | Large, immutable, cacheable |
| Needs at load | Key material, DID resolution | Nothing but a hash function |
| Rollback | Native, via rev |
None; a CID is a fact, not a version |
| Dedup | None | Across versions, sites, identities |
| Mirrors | Must be trusted or re-verified | Trustless by construction |
This section is non-normative.
Signed record → root CID → whole tree content-addressed. wisp and IPFS+IPNS are examples.
Gains: atomic deploys, the root CID flips and the entire site changes at once, with no torn intermediate state. One signature verified per load regardless of resource count. Maximum dedup, since unchanged subtrees share CIDs across versions.
Costs: content is opaque to the firehose, indexers see blobs, not prose, so syndication and labeling have nothing to read. No per-resource granularity for moderation. A manifest is required, and it is an extra artifact to fetch and validate.
This section is non-normative.
Every resource is its own signed record, proven by MST inclusion. No root CID. standard.site, Leaflet.
Gains: content is legible on the firehose, so indexing, labeling, and syndication work with no extra publishing step. Per-resource granularity, delete one post. The collection is the site; no manifest to maintain.
Costs: no dedup; the same image in two sites is two blobs. N resources need N fetches and N inclusion proofs. Unsuited to binary assets of any size.
And a real hazard: torn deploys. Updating three documents as three commits lets a reader observe a mixed state, new index, old article. Two mitigations, and the spec should recommend both:
com.atproto.repo.applyWrites.rev and resolve every
resource against it.A pinned rev gives shallow signing exactly the snapshot consistency a
root CID gives deep CAS. This is the closest thing to a free result in
the comparison, and it is why Section 5.2's rev tracking earns its
keep twice over.
But applyWrites has a ceiling, so the mitigation runs out exactly
when a site gets large. Past that point atomicity comes only from
Section 4.2's switching record: content accumulates across many commits
and a generation pointer names which set is current. Deep CAS gets this
for free, one manifest record switches a site of any size, while shallow
signing has to construct it. Section 9.4 covers the mechanics.
This is a larger advantage for deep CAS than the rest of this appendix implies, and it grows with site size.
This section is non-normative.
This spec lets the boundary move per target, unified by Section 4.3's disposition model. A publisher picks per site: prose takes shallow signing and gets indexed; an application takes deep CAS and gets atomic, dedup'd, binary-friendly delivery. One origin model covers both.
The costs:
Two proof paths to implement and test, with two failure modes. Section 12's vectors must cover both.
Revocation is asymmetric. Deleting a record removes it from the current MST, so it stops proving against current HEAD. Deleting a blob does not retract the CID from a manifest, and mirrors retain bytes. Deep-CAS content is therefore harder to unpublish than shallow-signed content. Note that neither is true revocation, anyone holding an older commit holds a proof that was valid when made (Section 13).
Enumeration is asymmetric. Shallow-signed sites are enumerable via
listRecords; deep-CAS sites are opaque without the manifest. This
matters for moderation tooling, which can survey one and not the other.
Clients implementing one mechanism reach less content. Such a client will encounter names it cannot load, and Section 4.4 forbids it from falling through to a weaker target instead.
This section is non-normative.
Signing blobs is redundant. A CID inside a proven record already transitively signs the bytes. Adding a separate signature over content-addressed content adds key handling for no gain.
Hashing record content is redundant. MST inclusion under a signed commit already proves it. This is why Section 4.3 keeps inclusion and content-address as distinct mechanisms rather than forcing everything through a hash.
This section is non-normative.
Keep the hybrid, and put manifests in records rather than blobs. A manifest as a record is MST-proven, appears on the firehose, and is therefore legible and labelable even when the content it points at is not, which recovers part of what deep CAS gives up, at negligible cost.
This section is non-normative.
End to end, @portable.agency/blog entered or activated in a client, on
a client implementing content-address proof and a content-addressed
handler (Section 10.2).
1. Parse. Input form. Identity portable.agency, short name blog.
Not a root name, so no self substitution (Section 4.7).
2. Handle → DID. DoH query for _atproto.portable.agency TXT
returns did=did:plc:vypurakoz7ftwpsspcrjqdy7. Cached under the
record's TTL, keyed on the DID (Section 3.4).
3. DID → document. Resolve the DID. Confirm alsoKnownAs contains
at://portable.agency, without this the handle means nothing (Section
3.2). Read serviceEndpoint for the PDS. Note the verification method;
the rev floor will be recorded against it (Section 5.2).
4. Account state. PDS reports active. Proceed (Section 3.2).
5. Route record.
GET com.atproto.sync.getRecord
?did=did:plc:vypurakoz7ftwpsspcrjqdy7
&collection=agency.portable.route
&rkey=blog
Returns a CAR. Verify the commit signature against the DID document's
verification method, verify MST inclusion, confirm the proven path's
rkey is blog (Section 3.3). Record rev.
6. Rollback check. rev is at or above the stored floor for this
origin, and the verification method is unchanged. Proceed (Section 5.2).
7. Validate the record. targets present, alias absent, 3 targets
(within 16), no unknown keys inside policy (Section 4.1, Section 8).
8. Select. First target is at://…/example.handler.site/blog. The
NSID maps to a handler this client has; disposition is load, proof by
content address (Section 4.2, Section 4.3). Selection stops here, later
targets are not attempted, and will not be attempted if this one fails
(Section 4.4).
9. Derive the origin.
label = base32( 0x01 || sha256( "did:plc:vypurakoz7ftwpsspcrjqdy7" || 0x00 || "blog" ) )
Ephemeral by default: session-only storage, no permissions (Section 5.4).
10. Load. Handler resolves the site record to a deployment to a MASL
manifest. Manifest is itself a record, so it is MST-proven. Path /
resolves per the handler's rules (Section 5.6) to index.html. Fetch
the blob, re-hash against the CID, mismatch would be fatal (Section 10).
Serve with the manifest's declared type; no sniffing (Section 5.5).
Decompression bounded by ratio and ceiling (Section 8).
11. Subresources. style.css and app.js resolve from the
manifest. A <script src> pointing off-origin would be rejected at load
time, not at fetch time (Section 5.3).
12. Links. <a href="atw://other.example/notes">Notes</a> remains
unresolved when the page renders and carries no verification claim. On
activation or a deliberate user gesture such as hover-to-preview, the
client resolves the name in full, including the bidirectional handle
check. It presents the result according to Section 5.9.3; unresolvable
links render inert (Section 5.9).
13. Display. Address bar shows @portable.agency/blog, re-resolved
from the DID rather than cached (Section 5.10), with the integrity
disposition reported (Section 4.3).
This section is non-normative.
If the first target were https://blog.example.com/ instead, steps 9–13
would not happen. Disposition is redirect: the client navigates to
that origin under ordinary web rules, the address bar shows
blog.example.com, no origin is derived, no proof is claimed, and the
referrer is suppressed on the way out (Section 4.3, Section 5.8).
Same name, same identity, same record type, a different disposition, and the user can see which one they got.