lexicons
lexicons
lexicons are atproto's schema system. they define what records look like and what APIs accept.
NSIDs
a Namespace ID identifies a lexicon:
fm.plyr.track app.bsky.feed.post com.atproto.repo.createRecord
format is reverse-DNS. the domain owner controls that namespace. this prevents collisions and makes ownership clear.
defining a lexicon { "$type": "com.atproto.lexicon", "id": "fm.plyr.track", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["title", "artist", "audioUrl", "createdAt"], "properties": { "title": {"type": "string"}, "artist": {"type": "string"}, "audioUrl": {"type": "string", "format": "uri"}, "album": {"type": "string"}, "duration": {"type": "integer"}, "createdAt": {"type": "string", "format": "datetime"} } } } } }
from plyr.fm/lexicons/track.json
record keys
the "key" field in a record lexicon definition declares what kind of rkey is valid. four types:
type meaning example use tid timestamp-based ID, auto-generated posts, likes, follows — anything users create many of literal: exactly one record per user with this fixed key app.bsky.actor.profile uses literal:self any any valid string — enables semantic keys domain names, integers, application-specific IDs nsid must be a valid NSID less common, used for meta-records keyed by schema "key": "tid" // generates 3jui7akfj2k2a "key": "literal:self" // always "self" "key": "any" // caller picks the rkey
rkey constraints: 1-512 chars, A-Za-z0-9 plus . - _ : ~. the values . and .. are reserved. case-sensitive but lowercase recommended.
sidecar pattern: use the same TID across different collections to link related records. bluesky does this with posts and threadgates — same rkey in app.bsky.feed.post and app.bsky.feed.threadgate signals they belong together.
consumer-side gotcha (the same fact, dualized): shared-lexicon apps emit the same logical record under two collections with one TID — e.g. leaflet writes each publication as both pub.leaflet.publication and site.standard.publication at the same rkey. So one entity has two valid at-uris that differ only in the collection segment. If you store/dedupe on the full at-uri, the two views collide or split; if another record references the entity (a subscription's publication field), it may point at either uri. Match on (did, rkey), not the full at-uri — both views share them. (pub-search hit this 2026-06-25: an exact-uri subscriptions ⋈ publications join silently dropped ~25% of distinct subscribed pubs because subscribers referenced the leaflet uri while we'd indexed the standard.site one. fix = decode did+rkey from publication_uri and join on those.)
important: rkeys are user-controlled. never trust them for authorization or assume they follow conventions — hostile accounts can set arbitrary values.
knownValues
extensible enums. the schema declares known values but validators won't reject unknown ones:
"listType": { "type": "string", "knownValues": ["album", "playlist", "liked"] }
this allows schemas to evolve without breaking existing records. new values can be added; old clients just won't recognize them.
from plyr.fm list lexicon
namespace discipline
plyr.fm uses environment-aware namespaces:
environment namespace production fm.plyr staging fm.plyr.stg development fm.plyr.dev
never hardcode namespaces. configure via settings so dev/staging don't pollute production data.
important: don't reuse another app's lexicons even for similar concepts. plyr.fm defines fm.plyr.like rather than using app.bsky.feed.like. this maintains namespace isolation and avoids coupling to another app's schema evolution.
shared lexicons
for true interoperability, multiple apps can agree on a common schema:
audio.ooo.track # shared schema for audio content
plyr.fm writes to audio.ooo.track (production) so other audio apps can read the same records. this follows the pattern at standard.site.
benefits:
one schema for discovery, any app can read it content is portable - tracks live in your PDS, playable anywhere platform-specific features live as extensions, not forks
from plyr.fm shared audio lexicon research
schema evolution
atproto schemas can only:
add optional fields add new knownValues
you cannot:
remove fields change required fields change field types
plan schemas carefully. once published, breaking changes aren't possible.
what PDS validation actually is
real PDSes validate records by name-list rather than full schema resolution. zds (zig PDS) mirrors the reference PDS's prepare.ts behavior (store.zig:4602-4671, docs/references.md):
$type must equal the collection being written known collections get their rkey rule enforced (tid collections reject non-TID rkeys; literal:self collections require rkey self) validationStatus is "valid" only for known lexicons that passed; everything else is accepted and reported "unknown" — unless the writer requested validate: required, in which case unknown lexicons are refused
so an arbitrary custom lexicon writes fine, and "my record was accepted" carries no schema guarantee. validation lives with consumers.
why this matters
lexicons enable the "cooperative computing" model:
apps agree on schemas → they can read each other's data namespace ownership → no collisions, clear responsibility extensibility → schemas evolve without breaking shared lexicons → true cross-app interoperability
Discussion in the ATmosphere