{
  "$type": "site.standard.document",
  "content": {
    "$type": "at.markpub.markdown",
    "flavor": "gfm",
    "renderingRules": "marked",
    "text": {
      "$type": "at.markpub.text",
      "markdown": "# lexicons\n\nlexicons are atproto's schema system. they define what records look like and what APIs accept.\n\n## NSIDs\n\na Namespace ID identifies a lexicon:\n\n```\nfm.plyr.track\napp.bsky.feed.post\ncom.atproto.repo.createRecord\n```\n\nformat is reverse-DNS. the domain owner controls that namespace. this prevents collisions and makes ownership clear.\n\n## defining a lexicon\n\n```json\n{\n  \"$type\": \"com.atproto.lexicon\",\n  \"id\": \"fm.plyr.track\",\n  \"defs\": {\n    \"main\": {\n      \"type\": \"record\",\n      \"key\": \"tid\",\n      \"record\": {\n        \"type\": \"object\",\n        \"required\": [\"title\", \"artist\", \"audioUrl\", \"createdAt\"],\n        \"properties\": {\n          \"title\": {\"type\": \"string\"},\n          \"artist\": {\"type\": \"string\"},\n          \"audioUrl\": {\"type\": \"string\", \"format\": \"uri\"},\n          \"album\": {\"type\": \"string\"},\n          \"duration\": {\"type\": \"integer\"},\n          \"createdAt\": {\"type\": \"string\", \"format\": \"datetime\"}\n        }\n      }\n    }\n  }\n}\n```\n\nfrom [plyr.fm/lexicons/track.json](https://github.com/zzstoatzz/plyr.fm/blob/main/lexicons/track.json)\n\n## record keys\n\nthe `\"key\"` field in a record lexicon definition declares what kind of rkey is valid. four types:\n\n| type | meaning | example use |\n|------|---------|-------------|\n| `tid` | timestamp-based ID, auto-generated | posts, likes, follows — anything users create many of |\n| `literal:<value>` | exactly one record per user with this fixed key | `app.bsky.actor.profile` uses `literal:self` |\n| `any` | any valid string — enables semantic keys | domain names, integers, application-specific IDs |\n| `nsid` | must be a valid NSID | less common, used for meta-records keyed by schema |\n\n```json\n\"key\": \"tid\"           // generates 3jui7akfj2k2a\n\"key\": \"literal:self\"  // always \"self\"\n\"key\": \"any\"           // caller picks the rkey\n```\n\nrkey constraints: 1-512 chars, `A-Za-z0-9` plus `.` `-` `_` `:` `~`. the values `.` and `..` are reserved. case-sensitive but lowercase recommended.\n\n**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.\n\n**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.)\n\n**important**: rkeys are user-controlled. never trust them for authorization or assume they follow conventions — hostile accounts can set arbitrary values.\n\n## knownValues\n\nextensible enums. the schema declares known values but validators won't reject unknown ones:\n\n```json\n\"listType\": {\n  \"type\": \"string\",\n  \"knownValues\": [\"album\", \"playlist\", \"liked\"]\n}\n```\n\nthis allows schemas to evolve without breaking existing records. new values can be added; old clients just won't recognize them.\n\nfrom [plyr.fm list lexicon](https://github.com/zzstoatzz/plyr.fm/blob/main/lexicons/list.json)\n\n## namespace discipline\n\nplyr.fm uses environment-aware namespaces:\n\n| environment | namespace |\n|-------------|-----------|\n| production  | `fm.plyr` |\n| staging     | `fm.plyr.stg` |\n| development | `fm.plyr.dev` |\n\nnever hardcode namespaces. configure via settings so dev/staging don't pollute production data.\n\nimportant: 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.\n\n## shared lexicons\n\nfor true interoperability, multiple apps can agree on a common schema:\n\n```\naudio.ooo.track  # shared schema for audio content\n```\n\nplyr.fm writes to `audio.ooo.track` (production) so other audio apps can read the same records. this follows the pattern at [standard.site](https://standard.site).\n\nbenefits:\n- one schema for discovery, any app can read it\n- content is portable - tracks live in your PDS, playable anywhere\n- platform-specific features live as extensions, not forks\n\nfrom [plyr.fm shared audio lexicon research](https://github.com/zzstoatzz/plyr.fm/blob/main/docs/research/2026-01-03-shared-audio-lexicon.md)\n\n## schema evolution\n\natproto schemas can only:\n- add optional fields\n- add new knownValues\n\nyou cannot:\n- remove fields\n- change required fields\n- change field types\n\nplan schemas carefully. once published, breaking changes aren't possible.\n\n## what PDS validation actually is\n\nreal PDSes validate records by name-list rather than full schema resolution.\n[zds](https://tangled.org/zat.dev/zds) (zig PDS) mirrors the reference PDS's\n`prepare.ts` behavior (`store.zig:4602-4671`, `docs/references.md`):\n\n- `$type` must equal the collection being written\n- known collections get their rkey rule enforced (`tid` collections reject\n  non-TID rkeys; `literal:self` collections require rkey `self`)\n- `validationStatus` is `\"valid\"` only for known lexicons that passed;\n  everything else is accepted and reported `\"unknown\"` — unless the writer\n  requested `validate: required`, in which case unknown lexicons are refused\n\nso an arbitrary custom lexicon writes fine, and \"my record was accepted\"\ncarries no schema guarantee. validation lives with consumers.\n\n## why this matters\n\nlexicons enable the \"cooperative computing\" model:\n\n- apps agree on schemas → they can read each other's data\n- namespace ownership → no collisions, clear responsibility\n- extensibility → schemas evolve without breaking\n- shared lexicons → true cross-app interoperability\n"
    }
  },
  "path": "/protocols/atproto/lexicons",
  "publishedAt": "2026-07-08T20:16:04.408Z",
  "site": "at://did:plc:xbtmt2zjwlrfegqvch7fboei/site.standard.publication/notes",
  "textContent": "lexicons\n\nlexicons are atproto's schema system. they define what records look like and what APIs accept.\n\nNSIDs\n\na Namespace ID identifies a lexicon:\n\nfm.plyr.track\napp.bsky.feed.post\ncom.atproto.repo.createRecord\n\n\nformat is reverse-DNS. the domain owner controls that namespace. this prevents collisions and makes ownership clear.\n\ndefining a lexicon\n{\n  \"$type\": \"com.atproto.lexicon\",\n  \"id\": \"fm.plyr.track\",\n  \"defs\": {\n    \"main\": {\n      \"type\": \"record\",\n      \"key\": \"tid\",\n      \"record\": {\n        \"type\": \"object\",\n        \"required\": [\"title\", \"artist\", \"audioUrl\", \"createdAt\"],\n        \"properties\": {\n          \"title\": {\"type\": \"string\"},\n          \"artist\": {\"type\": \"string\"},\n          \"audioUrl\": {\"type\": \"string\", \"format\": \"uri\"},\n          \"album\": {\"type\": \"string\"},\n          \"duration\": {\"type\": \"integer\"},\n          \"createdAt\": {\"type\": \"string\", \"format\": \"datetime\"}\n        }\n      }\n    }\n  }\n}\n\n\nfrom plyr.fm/lexicons/track.json\n\nrecord keys\n\nthe \"key\" field in a record lexicon definition declares what kind of rkey is valid. four types:\n\ntype\t meaning\t example use tid\t timestamp-based ID, auto-generated\t posts, likes, follows — anything users create many of \n literal:<value>\t exactly one record per user with this fixed key\t app.bsky.actor.profile uses literal:self \n any\t any valid string — enables semantic keys\t domain names, integers, application-specific IDs \n nsid\t must be a valid NSID\t less common, used for meta-records keyed by schema\n\"key\": \"tid\"           // generates 3jui7akfj2k2a\n\"key\": \"literal:self\"  // always \"self\"\n\"key\": \"any\"           // caller picks the rkey\n\n\nrkey constraints: 1-512 chars, A-Za-z0-9 plus . - _ : ~. the values . and .. are reserved. case-sensitive but lowercase recommended.\n\nsidecar 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.\n\nconsumer-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.)\n\nimportant: rkeys are user-controlled. never trust them for authorization or assume they follow conventions — hostile accounts can set arbitrary values.\n\nknownValues\n\nextensible enums. the schema declares known values but validators won't reject unknown ones:\n\n\"listType\": {\n  \"type\": \"string\",\n  \"knownValues\": [\"album\", \"playlist\", \"liked\"]\n}\n\n\nthis allows schemas to evolve without breaking existing records. new values can be added; old clients just won't recognize them.\n\nfrom plyr.fm list lexicon\n\nnamespace discipline\n\nplyr.fm uses environment-aware namespaces:\n\nenvironment\t namespace production\t fm.plyr \n staging\t fm.plyr.stg \n development\t fm.plyr.dev\n\nnever hardcode namespaces. configure via settings so dev/staging don't pollute production data.\n\nimportant: 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.\n\nshared lexicons\n\nfor true interoperability, multiple apps can agree on a common schema:\n\naudio.ooo.track  # shared schema for audio content\n\n\nplyr.fm writes to audio.ooo.track (production) so other audio apps can read the same records. this follows the pattern at standard.site.\n\nbenefits:\n\none schema for discovery, any app can read it\ncontent is portable - tracks live in your PDS, playable anywhere\nplatform-specific features live as extensions, not forks\n\nfrom plyr.fm shared audio lexicon research\n\nschema evolution\n\natproto schemas can only:\n\nadd optional fields\nadd new knownValues\n\nyou cannot:\n\nremove fields\nchange required fields\nchange field types\n\nplan schemas carefully. once published, breaking changes aren't possible.\n\nwhat PDS validation actually is\n\nreal 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):\n\n$type must equal the collection being written\nknown collections get their rkey rule enforced (tid collections reject non-TID rkeys; literal:self collections require rkey self)\nvalidationStatus 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\n\nso an arbitrary custom lexicon writes fine, and \"my record was accepted\" carries no schema guarantee. validation lives with consumers.\n\nwhy this matters\n\nlexicons enable the \"cooperative computing\" model:\n\napps agree on schemas → they can read each other's data\nnamespace ownership → no collisions, clear responsibility\nextensibility → schemas evolve without breaking\nshared lexicons → true cross-app interoperability",
  "title": "lexicons",
  "updatedAt": "2026-07-21T19:27:28.082Z"
}