{
  "$type": "com.whtwnd.blog.entry",
  "theme": "github-light",
  "title": "lexicon設計テクニック",
  "content": "atproto固有のlexicon(API/データスキーマ)策定tipsを思いつくままに書き散らしたやつ。\n\n公式で出ている[ガイド](https://atproto.com/guides/lexicon)を先に読むことを推奨する。\n\n2026/02: 初版(2024/10)からのアップデートを色々反映&トピック2つ追加\n\n## APIレスポンスへのrecord埋め込み\n\nクライアント主導でサービスを機能拡張する方法として、XRPC APIのレスポンスの一部をrecordから取得する、という方法がある。余分なフィールドが増える分にはlexicon更新は不要だし、単に埋め込むだけならappviewの追加対応も必要無い。[^extfield]\n\n[^extfield]: 独自フィールドを作る際は、NSIDのような衝突しないフィールド名にすることが[推奨されている](https://docs.bsky.app/blog/pinned-posts#current-recommendations)。そのNSIDでlexicon作ればスキーマも明示できてお得。独自フィールドに関するアイディアは[旧`$ext`仕様](https://github.com/bluesky-social/atproto-website/pull/30/commits/bd3dd96cc1775a315f68555bd6aadc07852546ee#diff-e37a3b976d77d9d1f671d16b6dab5a3398f47e539cdade565d89f2e762ddefafL247)や[拡張フィールド仕様提案](https://github.com/bluesky-social/atproto/discussions/1889)も参照。\n\n実例としては、一部のbskyクライアントが[viaフィールド](https://github.com/spuithori/tokimekibluesky/commit/3e9e88006fc1025856ae5cd6539b475a4f88d915)でpostにクライアント名を埋め込んでいるのがよく知られている。\nまた、Blueskyは`profile`のビューにもrecordを埋め込むべきだったと[反省している](https://github.com/bluesky-social/atproto/pull/2040#issue-2075616410)。\n\nlexicon的には3種類の埋め込み方がある。\n\n* `ref`で`record`スキーマを参照する\n    * 一番素直な方法だが公式lexiconでは実例が無い。`union`も同様。\n* `ref`でフィールドのスキーマを参照する\n    * recordの一部だけ取ってくる場合に有効。bskyでは`getServices`がこれ。\n    * フィールドの型を`object`に切り出しておかないとできない。\n    * lexicon上でrecordを使うことが明示されない点には注意。lexiconレベルで要請したいなら`description`に書いた方がいい。\n* `unknown`を使う\n    * 一番自由度が高いゆえに一番困るやつ。拡張を予期させるのが利点と言えなくもないか。\n    * bskyだと`getTimeline`等がこれで、[拡張性だけでなくlexiconのバージョン互換性を意識した結果](https://github.com/bluesky-social/atproto/issues/999#issuecomment-1538905166)らしい。\n\n## 色々なデータ置き場\n\n利用者個人の情報を保管する場所は4つある。どの情報をどこに置くかはサービス設計で重要になる。\n\n* repository\n    * 標準的な公開データ置場。JSONで表せるユーザコンテンツは基本ここ。bskyなら`post`とか`follow`とか。\n    * relay(firehose)に乗るのはここだけ。逆に言うと通常はrelay経由でappviewに届くので、更新反映にラグが生じる点は要注意。\n    * 他アカウントに対する指示を(全appview共通で)行いたい場合もここ。`block`とか`threadgate`がこのパターン。\n* blobstore\n    * その名の通りバイナリデータ置場。公開情報。bskyだと画像をここに置いている。\n    * 基本はrepositoryとセットだが、実体は別の場所に保管されているかもしれないし、relayによって中継されない。\n    * アップロード可能なサイズや種類が[PDSによって異なる](https://atproto.com/specs/xrpc#usage-and-implementation-guidelines)ため、環境依存性は高め。\n* preferences API\n    * `getPreferences`/`putPreferences`はbsky APIだが、公式実装ではappviewに繋がっておらず、PDSで管理される。\n    * appviewや他アカウントからは見えないため、クライアント設定を複数端末で共有する場合などに便利。bskyだと登録フィード設定とか。\n    * 将来的にはprivate dataの一種として統合されると思われるが、時期は未定。\n* appview\n    * 専用APIを作ってappview側でデータを保持する。扱いをappviewの自由にできるのが強みであり弱み。\n    * 下手にユーザコンテンツをここに置いてしまうと他appviewから参照できず、ロックインを招く。APIの挙動設定程度にとどめておくのが良い。bskyだとミュート設定などがこれ。\n    * ただし、公開範囲を自由に決められる強みから、chat lexicon(DM)ではこれが使われている。将来的には別の限定公開の仕組みを作るとされているが、実現は遠そう。\n    * BlueskyのCDN(リサイズ画像やストリーミング動画)のように、元データはPDSに置いた上で、appview側で変換したものをメインで使うことはよくあるだろう。この場合、パスがat-uriから一意に決まる構造にしておくと、他appviewでも流用しやすい。\n* プロトコル外\n    * 敢えてatprotoの外(XRPCが喋れないサーバー等)に置くという選択肢もある。既存のリソースやプロトコルに対し、発見やメタデータ付与のためにatprotoを使うパターンで特に有用。\n    * blobにも置き難いような巨大なデータや、閲覧環境を指定したい場合などに、参照だけrecordに入れることで、利用者によるセルフホストが可能になる。\n    * [standard.site](https://standard.site/)が指すatproto外のブログや、[Tangledのknot](https://docs.tangled.org/introduction.html)(gitサーバー)はこれに該当する。\n\nあとは当然クライアントローカルという選択肢もあるが、atproto特有の話は無いため割愛。\n\n将来的には、おそらくpreferencesを置き換える形で[Non-Public Content](https://atproto.com/specs/atp)([personal-private](https://pfrazee.leaflet.pub/3lzhmtognls2q), [shared-private](https://pfrazee.leaflet.pub/3lzhui2zbxk2b))が予告されている。登場は2026年中と意気込んでいるが、実用にはもう少しかかると見ておいた方が安全か。\n\n## 逆参照をrecordで持たない\n\n例えば、[follow](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/graph/follow.json)の逆である`follower` recordは作らないようにすべき。\n\n特に、必ずペアであることを要求するなら設計を見直した方がいい。repositoryを越えたペアだとそれぞれ異なる主体が管理しているので簡単に壊れるし、何かされたことをクライアントが検知してrecord追加する、というのは無理がある。repository内であってもクライアントの裁量になってしまう。\n\nAPライクな[フォローリクエストと承認](https://github.com/bluesky-social/atproto/issues/1352)のような形ならありえるが、その場合もリクエストだけ消える/書き換えられる可能性があることには留意すること。\n\nrecordはrepository所有者のアクションによってのみ作成・編集されるべき、とも言い換えられる。\n\nそれでも逆参照を得たい場合はappviewのAPIを挟むのがセオリーになるが、厳密性を求めると特定のappviewに依存せざるを得なくなったりする。どうしても整合させたい場合は割り切ってrecordに入れるのを諦める(muteのようにappviewに直接突っ込む)のも一案。\n\n## データの共同所有は無理\n\nbskyで言えばモデレーションリストあたりは複数アカウントから共同編集したくなるが、atprotoではそれは無理と思った方がいい。データ(record)の所有者をはっきりさせる必要があるし、repositoryの編集権限は細かく分割できないため、特定のrecordのみ編集できる権限委譲などもできない。[^adxshare]\n\n限定公開コンテンツ([shared-private](https://pfrazee.leaflet.pub/3lzhui2zbxk2b))が実装された暁には、複数人からの編集も期待できるが、現時点では勘定に入れるべきではない。仮に実装されてもrecordとは区別して扱われる可能性が高いため、実現するなら所有者のクライアントが編集を検出して公開recordに反映といった形になるだろう。\n\n[^adxshare]: あるいは[ADX仕様](https://github.com/bluesky-social/atproto/blob/3c7b12e8de4f9e75675d847c7a9a45d5a0e44291/architecture.md#validation)であれば、UCANで編集権限を絞って委譲するようなこともできたかもしれないが、今からその方向に戻れる可能性は低いだろう。\n\n[weaverが目指している](https://weaver.sh/did:plc:yfvwmnlztr4dwkb7hwz55r2g/e/3m6ug3zrwb22v)ように、recordは個々に持って、appview上でのみ共同編集しているように見せるのが穏当と思われる。\n\n## rkey揃えてメタデータ外付け\n\n同じrepositoryのrecordに外からメタデータを付けたい場合、元record→メタデータrecordの参照をどうするかが問題になる。例えば`threadgate`は`post`とは別recordになっているが、特定の投稿のリプライ制限を変更したい場合、appviewに頼らずクライアントだけで`threadgate`のat-uriを得たい。\n\n元recordにリンク追加しても良いが、管理外のlexiconだったりすると独自フィールドを作ることになり、あまり積極的にやりたくはない。そもそも相互参照はなるべく避けたい。\n\nこれの対策として、メタデータのrkeyを元recordと揃えるというルールにして運用で解決する方法がある。[`threadgate`](https://github.com/bluesky-social/atproto/blob/52a596320addd0111a8c347df3a9e85b4bae0417/lexicons/app/bsky/feed/threadgate.json#L8)は実際にこの方法を取っており、[sidecarパターンと呼ばれる](https://atproto.com/specs/record-key)。この場合、at-uriのcollectionを切り替えるだけで相互に変換が可能。\n\nただし、例えば`list`に対する`listitem`のように、1:N(またはM:N)の関係ではこの手法は使えない。\n\n別collectionに分けること自体の利点としては、更新禁止の情報(`post`)と更新可能な情報(`threadgate`)を分けられることや、自分の管理下に無いcollectionにもメタデータが付けられること等が考えられる。\n\n## 型を固定したrecord参照\n\nrecordやAPIでrecordを参照したい場合、最もよく使われるのは`strongRef`だろう。次点で生の`at-uri`か。しかし、これらはrecordであればなんでも参照できる。`at-uri`に至ってはrecordである必要すら無く、アカウントだったりcollectionだったりするかもしれない。\n\n具体例を挙げるなら、例えば`repost`する対象が`post`ではなく`follow`だったり`block`だったりするかもしれない。これはこれで役立つ場合があって、例えば`like`はカスタムフィードにも使われていたりするが、厳密に制限したい場合もあるだろう。\n\n一案として、例えば以下のようにat-uriを分割することで、collectionを固定することができる。ついでにhandleの利用やrkeyが無いat-uriも禁止できてお得。\n\n```json\n    \"postRef\": {\n      \"type\": \"object\",\n      \"required\": [ \"did\", \"rkey\" ],\n      \"properties\": {\n        \"did\": { \"type\": \"string\", \"format\": \"did\" },\n        \"collection\": { \"type\": \"string\", \"const\": \"app.bsky.feed.post\" },\n        \"rkey\": { \"type\": \"string\", \"format\": \"tid\" },\n        \"cid\": { \"type\": \"string\", \"format\": \"cid\" }\n      }\n    }\n```\n\n## at-identifierの検証方法\n\n`at-identifier`はatprotoアカウントを指すことが期待されるが、形式的にはDIDまたはhandleの形でさえあればいいため、本当にアカウントかは分からない。無関係のドメインやDIDかもしれないし、PDSを指すDIDかもしれない。\n\nそれがrepositoryを持つようなアカウントであることを厳密に指定することはlexiconの範囲ではできないため、動的な検証が必要になる。具体的には、DIDドキュメントを取得して、`service`に`#atproto_pds`があることを確認すればいい。\n\n削除済みのアカウント等や偽DIDも弾きたい場合は、実際に対象PDSのエンドポイント(`describeRepo`あたり)を叩く必要がある。\n\n## open union\n\n[`union`](https://atproto.com/specs/lexicon#union)には`closed`というフィールドがあり、デフォルトではfalseとなっている。これをopen unionと呼んだりする。\n\nopen unionは、オブジェクト[^objterm]であれば`refs`に無い型をとってもよい仕様になっている点に注意が必要。これを防ぐためには`closed`をtrueにすればよいが、closed unionは、将来に渡って`refs`が固定されることを意味する。基本はopenなままappviewで追加検証を入れるのがセオリーだろう。\n\n公式lexiconだと、closed unionを使っているのは[`applyWrites`](https://github.com/bluesky-social/atproto/blob/52a596320addd0111a8c347df3a9e85b4bae0417/lexicons/com/atproto/repo/applyWrites.json#L29)のみ。\n\n[^objterm]: ここでいう「オブジェクト」はlexiconの`object`型ではなく、JSONオブジェクトのような辞書・マップを指す。ただし、`union`には`$type`が必要なことから、lexiconで定義された型であることが期待される。(一方`unknown`は`$type`不要のためなんでもいい)\n\n## unionとref\n\n`union`は`ref`のように参照をとることができるが、`union`で`ref`を代替できるわけではない。`ref`に最も近いのは1つだけvariantを持つclosed unionだが、違いが2つある。\n\n1つ目は、`union`の値は必ず`$type`を持たなければいけないこと。これはまあどうでもいい。重要なのは2つ目、`union`ではオブジェクト様の型(スキーマ)しか参照できないということ。\n\n例えば以下のようなlexiconは`ref`でしか書けない。\n\n```json\n{\n    \"lexicon\": 1,\n    \"id\": \"test.defs\",\n    \"defs\": {\n        \"sample\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"value\": { \"type\": \"ref\", \"ref\": \"#limitedstr\" }\n        }\n        },\n        \"limitedstr\": {\n        \"type\": \"string\",\n        \"maxLength\": 100,\n        \"default\": \"placeholder\"\n        }\n    }\n}\n```\n\nこれだけだとあまり嬉しさが伝わらないかもしれないが、`enum`を使う`string`など、重めの型定義を多用する場合に力を発揮する。実際、[`labelValue`](https://github.com/bluesky-social/atproto/blob/4021b08a5851b97aaeb1a60a02b890548927beb4/lexicons/com/atproto/label/defs.json#L139-L154)は`knownValue`付きの`string`としてトップレベルで定義され、[`labelerPolicies`](https://github.com/bluesky-social/atproto/blob/4021b08a5851b97aaeb1a60a02b890548927beb4/lexicons/app/bsky/labeler/defs.json#L75-L78)から参照されている。\n\n余談だが、`ref`で参照できる`defs`直下の定義(`record`等も含む)を仕様ではnamed definitionsと呼ぶことがある。\n\n## unionとunknown\n\n`unknown`は`refs`を持たないopen unionのようなものだと[説明されることがある](https://github.com/bluesky-social/atproto/issues/999#issuecomment-1577769616)。ただ、仕様上は全く一緒というわけでもない。\n\n具体的には、`union`は`$type`を持つ必要があるが、`unknown`にはそのような制約は無い。[^unknowndef]\n\n[^unknowndef]: 以前の仕様では`unknown`の指す型はオブジェクトに限定されていなかったため、そこも`union`との差異だったが、実装上は`unknown`もオブジェクトしか許していなかった。[後のPR](https://github.com/bluesky-social/atproto-website/pull/349)で実装寄りの仕様に修正されたため、`union`との差分は`$type`のみになった。\n\nとはいえその差が影響する場面は稀と思われる。`unknown`の使い所としては、lexicon上に定義が無くて適切な`$type`を示せないデータも許す場合か、`getRecord`のように真に値に非依存な場合くらいだろうか。\n\n## tokenの使い道\n\n`token`は型名文字列のみを値にもつシングルトンのような型だが、型として使う場面はほぼ無い。\n\n`union`に使うことはできないので、そのフィールドは値の有無しか見られない。そもそも`const`付き`string`で代替できる。\n\nではどのような場面で使うかといえば、`knownValues`の文字列定数に説明を付けたい場合に便利。つまりコメントとしての使い方で、型定義自体はあまり意味が無い。細かい説明が必要な場合や、後から意味が変わりそうな場合などに便利。\n\n## lexicon作ったらやっておきたいことリスト\n\n開発中はそこまで気にしなくていい。\n\n* [`goat lex lint`](https://github.com/bluesky-social/goat)\n* 対応するドメインの確保(案外やってない場合がある)\n* [lexiconの公開](https://atproto.com/guides/publishing-lexicons)\n* [OAuthスコープ(permission-set)](https://atproto.com/specs/permission#permission-sets)作成\n* [ドキュメントrecord作成](https://lexicon.garden/lexicon/did:plc:akhgi4ekkeaupiqsis6g2gqg/garden.lexicon.documentation/docs)\n\nなお、schema recordはフィールドがソートされてしまうなど少し面倒もあるので、別途lexiconのjsonを公開しておくといい場合もある。Tangledならlexiconを公開しているアカウントと直接紐付けられるのでGitHubより見つけやすい。\n\n[Hopper](https://hopper.at/spec)(利用者いるのか?)用にビューアURLを提供するのも検討してみてほしい。\n\n## 多言語対応\n\n1つのrecordを多言語化したい場合、以下のように配列化するパターンが使われる。\n\n```json\n\"localized\": {\n    \"type\": \"array\",\n    \"items\": {\n        \"type\": \"object\",\n        \"required\": [\"lang\", \"content\"],\n        \"properties\": {\n            \"lang\": {\n                \"type\": \"string\",\n                \"format\": \"language\"\n            },\n            \"content\": {\n                \"type\": \"string\"\n            },\n        }\n    }\n}\n```\n\nrecordが膨らむのを避けるために別recordに分けるのも手だが、rkeyで言語を判別するためには`any`にしなければならないのが難。",
  "createdAt": "2026-02-23T12:59:22.974Z",
  "visibility": "public"
}