lexicons

nate July 8, 2026
Source

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

Loading comments...