Nostr アウトボックスモデル memo
- Nostrにおけるアウトボックスモデル調査報告書
GPT-5.6 Sol Deep Research
Nostrにおけるアウトボックスモデル調査報告書
エグゼクティブサマリ
Nostrにおける「アウトボックスモデル」は、ユーザーが kind:10002 の Relay List Metadata を公開し、その人の公開投稿はその人自身の write リレーから読む、その人宛てのメンションや返信はその人の read リレーへ届けるというルーティング方針です。NIP-65 はこれを read / write マーカー付きの r タグで定義しており、kind:10002 は replaceable event なので、各 pubkey ごとに最新のリストが正本として扱われます。Nostr の大規模運用で従来の「みんなが同じ巨大リレーに集まる」前提を減らし、負荷分散・検閲耐性・スケーラビリティの改善を狙う設計です。(NIP-65、NIP-01、Mike Dilger: The Outbox Model)
開発者の実務として重要なのは、Nostrのアウトボックスモデルと、アプリ内部のtransactional outbox的な永続キューを分けて考えることです。前者は「どの relay に読み書きするか」の発見・配送モデル、後者は「ローカル状態更新と relay 送信をどう整合させるか」の信頼性パターンです。NIP-65 だけでは DB 更新とマルチリレー配信の原子的整合性は保証できないため、公開投稿・返信・DM のいずれでも、ローカル永続化した publish queue と per-relay ACK 追跡を併用するのが実装上は最も堅実です。(NIP-65、AWS: Transactional outbox pattern、NIP-01)
公開投稿と DM は必ず分離して設計すべきです。公開投稿・返信・リアクションは主に NIP-65 の 10002 を使って、作者の write リレーとタグ対象ユーザーの read リレーに配送します。一方、DM は NIP-17 で kind:10050 の受信 relay list を使い、NIP-44 で暗号化し、NIP-59 gift wrap でメタデータ保護を行います。NIP-17 は、受信者の 10050 が見つからないなら送るべきではないと明記しています。(NIP-65、NIP-17、NIP-44、NIP-59)
実装戦略としては、NIP-65 を正本にしつつ、未対応クライアント・未発見ユーザーのために gossip 型のフォールバックを残すのが現実的です。Mike Dilger の整理では、outbox model は gossip model の一部であり、実運用では nprofile の relay hints、NIP-05 の relay 情報、kind 3 contact list の relay 情報、イベント上の relay hint などが補助ソースとして使われています。NIPs 自体は optional であり、NIP-65 も draft のため、互換性の観点からもこのフォールバック層は重要です。(Mike Dilger: The Outbox Model、Yana GOSSIP.md、NIPs)
定義と適用パターン
NIP-65 は kind:10002 を使って、ユーザーが通常どこに書き込むか、どこでメンションを読むかを知らせる仕様です。イベントは ["r", <relay-url>, "read"|"write"] のタグを持ち、マーカーが無い場合は read/write 両方として扱います。クライアントは、あるユーザーの投稿取得時にはそのユーザーの write リレーを、あるユーザー宛てのメンション探索時にはそのユーザーの read リレーを使うべきとされています。さらにイベントを publish するときは、作者の write リレーへ送り、タグされた各ユーザーの read リレーにも送るべきで、作者の kind:10002 も送信先 relay に広めるべきとされています。(NIP-65)
Mike Dilger はこれを、従来の relay 手動共有モデルをやめて「人の投稿はその人が書いた relay から読む」モデルだと説明しています。これは Web/RSS に近い発想で、自分は自分の relay に書く、他人の投稿は他人の relay に取りに行くという構図です。outbox model は公開コンテンツ向けの発想であり、DM や private event は別の運び方を要します。(Mike Dilger: The Outbox Model、Yana GOSSIP.md)
一方で、outbox model は gossip model の「狭義の一部」です。gossip model では NIP-65 だけに依存せず、nprofile relay hints、NIP-05 の relays、kind 3、イベントタグの recommended relay URL なども補助的に使って person-relay association を構築します。NIP-65 の普及がまだ不均一なため、実装者の視点では「NIP-65 を第一優先、gossip fallback を第二優先」がもっとも壊れにくい設計です。(Mike Dilger: The Outbox Model、Yana GOSSIP.md、NIPs)
NIP-65 には運用上の指針もあります。kind:10002 の relay list は小さく保つことが推奨され、2026年7月時点の文言では各カテゴリ 2〜4 relayが目安です。また discoverability のために、kind:10002 はできるだけ多くの relay、特に relay list のインデクサとして自然に機能している relay に広めるべきとされています。これにより、フォロワー側が「最初の一歩」で relay list を見つけやすくなります。(NIP-65)
flowchart LR
A[Author] -- publish kind:10002 --> I[Index / bootstrap relays]
A -- publish note/reply --> OW[Author write relays]
F[Follower client] -- discover 10002 --> I
F -- route REQ by author --> OW
T[Tagged user] -- publish kind:10002 --> I
A -- reply/mention fanout --> TR[Tagged users read relays]
D[DM sender] -- fetch kind:10050 --> DR[Recipient DM inbox relays]
D -- NIP-44 + NIP-59 wrapped DM --> DR
Nostr の outbox model をアプリケーションに落とし込むと、最低限、relay list の収集・正規化・キャッシュ、author→relay の route table、publish queue、per-relay ACK 追跡の四層が必要です。Nostrify の outbox チュートリアルも、まず SQLite に NIP-65 を保存し、その route table を使って request/publish の router を実装する構成を示しています。(Nostrify: The Outbox Model)
erDiagram
USER ||--o{ RELAY_LIST : publishes
USER ||--o{ EVENT : authors
USER ||--o{ ROUTE_CACHE : resolves_to
EVENT ||--o{ PUBLISH_QUEUE : fanout
PUBLISH_QUEUE ||--o{ RELAY_ACK : receives
RELAY_LIST ||--o{ ROUTE_CACHE : materializes
USER {
string pubkey PK
}
RELAY_LIST {
string event_id PK
string pubkey FK
int kind
int created_at
json raw_event
}
ROUTE_CACHE {
string pubkey FK
string role
string relay_url
float score
datetime expires_at
}
EVENT {
string event_id PK
string pubkey FK
int kind
int created_at
json raw_event
}
PUBLISH_QUEUE {
string queue_id PK
string event_id FK
string relay_url
string state
int attempt_count
datetime next_attempt_at
}
RELAY_ACK {
string event_id FK
string relay_url
bool ok
string reason
datetime received_at
}
利用シナリオ
タイムライン取得
標準的なタイムライン取得では、まずフォロー対象ユーザーの kind:10002 を bootstrap relay や relay-list index から集め、そこから得た 各 author の write relay に対して REQ を張ります。NIP-01 上、relay は EVENT と EOSE を返し、limit は初期レスポンス時のみに使われます。Nostrify はこのルーティングを reqRouter(filters) で実装し、Yana は「各 contact が最低 N relay でカバーされるように」最小カバー集合を組む戦略を採っています。(NIP-65、NIP-01、Nostrify: The Outbox Model、Yana GOSSIP.md)
sequenceDiagram
participant C as Client
participant B as Bootstrap/Index Relays
participant W1 as Author A write relay
participant W2 as Author B write relay
C->>B: REQ kind:10002 for followed authors
B-->>C: EVENT kind:10002 (A, B relay lists)
B-->>C: EOSE
C->>W1: REQ {authors:[A], kinds:[1], limit:n}
C->>W2: REQ {authors:[B], kinds:[1], limit:n}
W1-->>C: EVENT note(s)
W2-->>C: EVENT note(s)
W1-->>C: EOSE
W2-->>C: EOSE
Note over C: event.id で cross-relay dedupe
返信・メンション配送
返信やメンション付き投稿は、NIP-65 の本来のユースケースです。作者は自分の write relay に送るだけでなく、タグした相手の read relay にも同じイベントを届けるのが推奨です。Mike Dilger は inbox を「相手が自分を follow していない場合でも相手に届くための配送先」と説明しており、Yana も reply/like/repost では会話関係者の inbox relay と自分の outbox relay を混ぜて fanout しています。(NIP-65、Mike Dilger: The Outbox Model、Yana GOSSIP.md)
sequenceDiagram
participant App as Publisher App
participant DB as Local DB
participant Q as Publish Worker
participant OW as Author write relays
participant IR as Tagged users read relays
App->>DB: save event draft + publish_queue
DB-->>App: commit OK
App->>Q: enqueue job
Q->>OW: EVENT signed_event
Q->>IR: EVENT signed_event
OW-->>Q: OK true / duplicate / rate-limited
IR-->>Q: OK true / duplicate / restricted
Q->>DB: store per-relay ACK, retry failures
Note over Q,DB: しきい値達成で delivered、失敗は backoff
DM配送
DM は public note と同じ outbox fanout で処理してはいけません。NIP-17 では、受信者の kind:10050 に列挙された relay にのみ送ること、relay 側は kind:1059 の gift wrap を p タグ対象者だけに見せること、list は小さく 1〜3 relay に保つことが推奨されています。暗号化は NIP-44、ラッピングは NIP-59、認証保護は NIP-42 が前提です。(NIP-17、NIP-44、NIP-59、NIP-42)
sequenceDiagram
participant S as Sender
participant R as Recipient profile/index relays
participant D as Recipient DM relays
participant X as Recipient client
S->>R: REQ kind:10050 for recipient
R-->>S: EVENT kind:10050
S->>S: NIP-44 encrypt rumor
S->>S: NIP-59 gift wrap with one-time key
S->>D: EVENT kind:1059
D-->>S: OK true / auth-required
X->>D: AUTH challenge response if required
D-->>X: EVENT kind:1059 for recipient only
実装パターンとコード例
推奨する実装方針
Nostr に outbox model を導入する際は、protocol routing と delivery reliability を分けると実装が整理しやすくなります。routing 層は NIP-65 / NIP-17 / fallback hints から relay set を決め、delivery 層は永続 queue、ACK 分類、再送、dead-letter、再同期を担います。AWS の transactional outbox pattern が示すとおり、DB 更新と通知送信の二重書き込みはそのままでは不整合を起こしやすいため、ローカル state と relay fanout をまたぐ部分には publish queue を置くべきです。(AWS: Transactional outbox pattern、NIP-65、NIP-17)
実務上のおすすめは、ローカル DB に「最終的に relay に送る完全な signed event」と「relay ごとの配信タスク」を保存する構成です。NIP-01 では relay が OK で duplicate: を返すことがあり、これは「relay が既にその event.id を保持している」ことを意味するので、配信成功と同等に扱えます。event.id はシリアライズされたイベントの SHA-256 で決まり、同一 payload なら relay を跨いでも同一です。(NIP-01)
データモデル例
以下は、ブラウザ・モバイル・サーバーサイドのどれでも流用しやすい最小構成です。これは仕様そのものではなく、NIP-65/NIP-17/NIP-01 の要件を実務的に満たすための推奨モデルです。(NIP-65、NIP-17、NIP-01)
| テーブル | 主キー | 目的 | 主要カラム |
|---|---|---|---|
relay_lists |
event_id |
取得した kind:10002 / kind:10050 の原本保存 |
pubkey, kind, created_at, raw_json, verified_sig, source_relay |
route_cache |
(pubkey, role, relay_url) |
正規化済み route table | role(write/read/dm), score, source, fresh_until |
events |
event_id |
signed event の保存 | pubkey, kind, created_at, raw_json |
publish_queue |
queue_id |
relay 単位の配信タスク | event_id, relay_url, state, attempt_count, next_attempt_at, last_error |
relay_acks |
(event_id, relay_url) |
relay 応答の監査 | ok, reason, received_at, latency_ms |
relay_capabilities |
relay_url |
NIP-11/NIP-66 に基づく relay 制約 | auth_required, payment_required, restricted_writes, max_limit, supported_nips, rtt_* |
API・イベントフローとトランザクション処理
公開投稿の標準フローは、次の順序にすると安定します。まず最新の route cache から作者の write relay と tagged user の read relay を求め、次に signed event を生成し、最後に events と publish_queue を同一トランザクションで保存します。commit 後、worker が relay ごとに EVENT を送り OK を記録し、duplicate は成功、rate-limited は backoff、restricted や auth-required は capability / credential 不足として別扱いにします。NIP-01 の OK reason と NIP-11 の capability 情報は、この分類の基準として使えます。(NIP-01、NIP-11)
-- 例: サーバーサイド/ローカルDB共通の publish queue 生成
BEGIN;
INSERT INTO events(event_id, pubkey, kind, created_at, raw_json)
VALUES (:event_id, :pubkey, :kind, :created_at, :raw_json);
INSERT INTO publish_queue(queue_id, event_id, relay_url, state, attempt_count, next_attempt_at)
VALUES
(:q1, :event_id, :relay_1, 'pending', 0, NOW()),
(:q2, :event_id, :relay_2, 'pending', 0, NOW()),
(:q3, :event_id, :relay_3, 'pending', 0, NOW());
COMMIT;
worker(event_id, relay_url):
send ["EVENT", signed_event]
classify relay response:
OK true -> success
OK true duplicate:* -> success
OK false rate-limited:* -> retry with exponential backoff
OK false auth-required:* -> authenticate then retry
OK false restricted:* -> manual/policy action
timeout/network error -> retry with jitter
ライブラリ比較
以下の比較は、公式 NIP 群、Rust Nostr Book、Nostrify、NDK Kotlin、Go の新世代ライブラリ文書、および relay-selection 実装者文書をもとにしています。NDK OutboxTracker、rust-nostr gossip crate、go-nostr sdk hints DB、welshman/router など、既存実装は総じてheuristic / stateful / async であり、厳密に同じルーティング結果を返すわけではありません。逆に @innis/nostr-relay-selection は純粋関数の policy layer を目指しており、engine と policy の分離設計の参考になります。(@innis/nostr-relay-selection、fiatjaf.com/nostr、NDK Kotlin、Nostrify: The Outbox Model)
| 言語 | 主な実装 | outbox 対応の性格 | 実務上の含意 |
|---|---|---|---|
| TypeScript | Nostrify | SQLite に NIP-65 を保存して reqRouter / eventRouter を自作する前提 (Nostrify: The Outbox Model) |
UI/アプリ固有の routing policy を作り込みやすい |
| Kotlin | NDK for Android | outbox enabled by default、author ごとの relay goal、メトリクスとイベントストリームあり (NDK Kotlin) | Android で observability まで含めて導入しやすい |
| Rust | rust-nostr | EventBuilder.relay_list() と RelayMetadata で kind:10002 を安全に生成 (Rust Nostr Book: NIP-65) |
signer/DB/relay pool を自前統合する低レイヤ実装向き |
| Go | fiatjaf.com/nostr |
旧 go-nostr の後継で、README は sdk に outbox relay management を含むと説明 (fiatjaf.com/nostr、nbd-wtf/go-nostr) |
新規実装は archived な旧 go-nostr よりこちらが自然 |
| Policy-only | @innis/nostr-relay-selection |
pure function / deterministic policy 層 (@innis/nostr-relay-selection) | engine と routing policy を分離したい場合に有効 |
サンプルコード
以下のコードは、上記ライブラリ・仕様に沿った短い実装例です。プロダクションでは署名検証、relay URL 正規化、capability cache、再送制御、永続化を追加してください。API 名や機能の根拠は各 snippet 前後の説明に付した出典に対応しています。(Rust Nostr Book: NIP-65、NDK Kotlin、Nostrify: The Outbox Model、fiatjaf.com/nostr)
TypeScript
type RelayRole = "read" | "write" | "both";
interface RouteEntry {
pubkey: string;
relayUrl: string;
role: RelayRole;
}
function selectWriteRelays(routes: RouteEntry[], pubkey: string): string[] {
return routes
.filter(r => r.pubkey === pubkey && (r.role === "write" || r.role === "both"))
.map(r => r.relayUrl);
}
function selectReadRelays(routes: RouteEntry[], pubkeys: string[]): string[] {
const set = new Set<string>();
for (const pubkey of pubkeys) {
for (const r of routes) {
if (r.pubkey === pubkey && (r.role === "read" || r.role === "both")) {
set.add(r.relayUrl);
}
}
}
return [...set];
}
// 公開投稿の配送先 = 自分の write + タグ相手の read
function calcPublishTargets(
routes: RouteEntry[],
authorPubkey: string,
taggedPubkeys: string[],
): string[] {
return [...new Set([
...selectWriteRelays(routes, authorPubkey),
...selectReadRelays(routes, taggedPubkeys),
])];
}
Rust
rust-nostr の Rust/JS バインディング側では、EventBuilder.relay_list() や Tag::relay_metadata() で kind:10002 を組み立てられます。null / None を指定した relay は read/write 両方として解釈されます。(Rust Nostr Book: NIP-65)
use nostr_sdk::prelude::*;
fn build_relay_list(keys: &Keys) -> anyhow::Result<Event> {
let relays = std::collections::HashMap::from([
(RelayUrl::parse("wss://relay.example.com")?, Some(RelayMetadata::Write)),
(RelayUrl::parse("wss://mentions.example.com")?, Some(RelayMetadata::Read)),
(RelayUrl::parse("wss://both.example.com")?, None),
]);
let event = EventBuilder::relay_list(relays).sign_with_keys(keys)?;
Ok(event)
}
Kotlin
NDK for Android は outbox model を有効化したうえで、author 指定 filter に対し自動的に author の write relay へ購読を広げます。observability も標準で持っています。(NDK Kotlin)
val ndk = NDK(
explicitRelays = setOf("wss://relay.damus.io", "wss://nos.lol")
).apply {
enableOutboxModel = true
relayGoalPerAuthor = 2
outboxRelayUrls.add("wss://purplepag.es")
}
ndk.connect()
val filter = NDKFilter(
authors = setOf("alice_pubkey_hex", "bob_pubkey_hex"),
kinds = setOf(1)
)
val sub = ndk.subscribe(filter)
// 監視
val stats = ndk.outboxMetrics.snapshot()
println("coverage=${stats.authorCoverageRate}, cacheHit=${stats.cacheHitRate}")
Go
Go では、fiatjaf.com/nostr が旧 go-nostr の後継で、README は sdk に outbox relay management を含むとしています。以下は helper に依存しない、最小の kind:10002 生成例です。新規採用では archived な nbd-wtf/go-nostr より後継ライブラリを選ぶのが安全です。(fiatjaf.com/nostr、nbd-wtf/go-nostr)
package main
import (
"encoding/json"
"fmt"
"time"
nostr "fiatjaf.com/nostr"
)
func main() {
sk := "YOUR_HEX_PRIVATE_KEY"
pub, _ := nostr.GetPublicKey(sk)
evt := nostr.Event{
PubKey: pub,
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Kind: 10002,
Tags: nostr.Tags{
{"r", "wss://relay.example.com", "write"},
{"r", "wss://mentions.example.com", "read"},
{"r", "wss://both.example.com"},
},
Content: "",
}
_ = evt.Sign(sk)
b, _ := json.Marshal(evt)
fmt.Println(string(b))
}
設計上の注意点
整合性と重複排除
もっとも重要なのは、local state を保存したのに relay 送信に失敗した、またはその逆という二重書き込み問題を潰すことです。公開投稿・返信・設定更新・DM いずれでも、events と publish_queue を同一トランザクションで確定し、relay 送信は非同期 worker に委ねるべきです。relay の OK が duplicate: を返した場合は、NIP-01 上すでにその relay がイベントを持っているので success と等価に扱えます。(AWS: Transactional outbox pattern、NIP-01)
event の重複排除キーは、基本的に event.id で十分です。NIP-01 では id がシリアライズ済みイベントの SHA-256 と定義されており、同じイベントを複数 relay に送っても ID は変わりません。つまり **「同一 event.id を relay ごとに ACK 追跡する」**設計が最も単純で、cross-relay dedupe にも向きます。(NIP-01)
順序保証と replaceable event の扱い
Nostr はマルチリレー分散系なので、グローバルな total order は前提にできません。NIP-01 が定めるのは「単一 relay の初期レスポンスにおける created_at / id ベースの順序」であり、cross-relay にまたがる整列はクライアント側責務です。したがって UI や downstream 処理では、少なくとも per-author の created_at + event.id で安定ソートし、到着順を真の順序だとみなさない方が安全です。これは NIP-01 からの推論ですが、実装上は非常に重要です。(NIP-01)
kind:10002 は replaceable event なので、同一 pubkey + kind では最新だけが保持され、同一 timestamp なら lexical order で最小の id が残ります。relay list の更新を別プロセスや別デバイスから同時に行うと race になりやすいため、relay list editor は単一更新源に寄せるか、少なくとも monotonic created_at と stable serialization を守るべきです。(NIP-01)
署名・鍵管理
Nostr の relay routing 自体は kind:10002 / 10050 で発見できますが、relay 送信・認証・DM 保護には鍵管理が絡みます。relay が NIP-42 AUTH を要求する場合、クライアントは challenge-response を処理しなければなりません。NIP-11 の auth_required / payment_required / restricted_writes は事前の capability cache に取り込み、publish path で認証や支払いの分岐を入れると無駄な失敗が減ります。(NIP-42、NIP-11)
DM では、NIP-17 が one-time wrapper key と timestamp のランダマイズを要求しており、Gift Wrap はランダムな一時鍵で署名されるため、relay 側の pubkey ベース anti-spam が効きにくくなります。そのため NIP-59 は、gift wrap の受け入れ時に NIP-42 認証を併用できるとしています。公開投稿の outbox と DM の inbox はこの点で threat model が異なるので、同じ publish pipeline に流しても 必ず別 policy を噛ませるべきです。(NIP-17、NIP-59)
プライバシー
outbox model は完全分散性を高めますが、代償として 知らない relay に接続しにいく必要が生じます。Mike Dilger はこれを privacy attack vector と明示しており、対策として VPN/Tor、接続前承認 UI、AUTH 前承認などを挙げています。特に「author の relay list を辿って未知 relay に接続する」時点で、クライアントの閲覧対象がネットワークレベルで漏れる可能性があります。高リスク用途では NIP-44 自体にも限界があり、仕様も specialized E2EE apps の利用を勧めています。(Mike Dilger: The Outbox Model、NIP-44)
DM relay はさらに慎重であるべきです。NIP-17 は kind:1059 を recipient にだけ serve すること、NIP-42 でそれを enforce することを推奨し、NIP-59 も possible なら AUTH 付き recipient relay へだけ wrapped event を送るべきだとしています。つまり、DM 受信 relay を「通常の public relay と同じ trust level」で扱う実装は避けるべきです。(NIP-17、NIP-59)
レート制限とフォールトトレランス
rate limiting は想定内です。NIP-01 の OK reason には rate-limited:, restricted:, blocked:, pow: などがあり、これらは自動再送してよい失敗と設計変更が必要な失敗を分けるヒントになります。たとえば rate-limited は指数 backoff+jitter、auth-required は認証フロー、payment_required は課金 UI、restricted は relay policy mismatch として扱うのが合理的です。(NIP-01、NIP-11)
実務では、全 relay 成功を待つより success threshold を持つ方がよいです。たとえば公開投稿は「自分の主要 write relay のうち 2つ成功」で delivered、残りはバックグラウンド再送、返信は「作者 write 1件 + tagged users read 1件以上」を可視化条件にする、といった形です。これは仕様ではなく実装上の推奨ですが、clients が全 listed relay を常には使わないという実装者議論とも整合します。コミュニティでは、ソケット数やデータ量・バッテリーの都合から clients は relay list の一部しか使わないことがあるため、relay 選択と優先度づけが重要だと議論されています。(NIP-65 prioritization discussion (PR #822)、@innis/nostr-relay-selection)
運用上の注意点
監視とメトリクス
outbox model を本番運用するなら、最低でも route coverage、relay list freshness、publish queue backlog、per-relay success rate、latency を監視すべきです。NDK Kotlin は cacheHitRate、knownRelayListCount、fetchSuccessRate、avgFetchDurationMs、authorCoverageRate、dynamicRelaysAdded、さらに outbox event stream まで公開しています。自前実装でも同等の KPI を揃えると、routing 不良・relay 劣化・discoverability 低下を可視化できます。(NDK Kotlin)
NIP-66 は relay discovery / liveness monitoring のイベントを定義していますが、仕様自体が「これを必須にしてはならない」「単一 monitor を信用してはならない」と明記しています。したがって relay health は NIP-66 を補助信号として使い、最終判断は自クライアントの実測 RTT、ACK 成功率、タイムアウト率で行うのが安全です。(NIP-66)
バックアップと復旧
バックアップ対象は、relay_lists、route_cache、events、publish_queue、relay_acks の五つが中核です。relay 自体は replaceable event を上書きしたり、gift wrap を保持しなかったりし得るので、relay を唯一の正本にしてはいけません。特に kind:10002 は replaceable で古い値が消えてもおかしくなく、NIP-59 も gift wrap は relay が保存しないことがあり得るとしています。(NIP-01、NIP-59)
復旧時は、まず自分の最新 kind:10002 と必要なら kind:10050 を再配布し、その後に publish queue の未完了タスクだけを再送するのがよいです。NIP-65 は relay list の discoverability を重視しており、Mike Dilger も relay migration や censorship move に備えて clients が最新 relay list を定期的に読み直すべきだと述べています。(NIP-65、Mike Dilger: The Outbox Model)
マイグレーションと互換性テスト
relay migration は outbox model で一番事故が起きやすい点の一つです。実務では、旧 relay と新 relay へ一定期間 dual-publish し、その期間中に kind:10002 を更新して新 relay を discoverability relay に十分広める方が安全です。とくに major clients が relay list の一部しか使わないことがある以上、relay 移行を一発切り替えにすると見えない断線が起きやすくなります。(NIP-65、NIP-65 prioritization discussion (PR #822))
互換性テストは、少なくとも次の軸を含めるべきです。NIP-65 未対応相手、NIP-11 auth_required relay、payment_required relay、DM relay の 10050 不在、replaceable event race、duplicate ACK、relay timeout/partitionです。NIPs は optional であるため、「未実装相手と壊れずに話せるか」は成功条件そのものです。(NIPs、NIP-11、NIP-01、NIP-17)
推奨アーキテクチャと導入手順
推奨アーキテクチャ
以下の構成が、クライアント・モバイルアプリ・サーバーサイド bot のどれでも使いやすい基本形です。relay 選択 policy を pure に保つなら policy layer と engine layer を分け、route decisions 自体は副作用なしでテストできるようにすると保守しやすくなります。これは @innis/nostr-relay-selection の考え方とも一致します。(@innis/nostr-relay-selection)
flowchart LR
Signer[Signer / Key Manager]
Drafts[Draft Store]
RL[Relay List Collector]
RC[Route Cache]
Policy[Routing Policy]
Queue[Publish Queue]
Worker[Relay Worker]
Caps[Relay Capability Cache]
Metrics[Metrics / Alerts]
Relays[(Nostr Relays)]
Signer --> Drafts
RL --> RC
RC --> Policy
Caps --> Policy
Drafts --> Queue
Policy --> Queue
Queue --> Worker
Worker --> Relays
Worker --> Metrics
RL --> Metrics
Caps --> Metrics
導入手順
-
bootstrap relay を定義し、
kind:10002収集機構を先に作る。 NIP-65 は discoverability relay への拡散を推奨しており、実装例ではpurplepag.esのような relay を bootstrap 用に置いています。nprofileの relay hint や kind 3 を補助入力にする fallback も、この段階で入れると導入初期の欠損が減ります。(NIP-65、NDK Kotlin、Nostrify: The Outbox Model、Mike Dilger: The Outbox Model) -
自分の
kind:10002をまず正しく publish する。 relay list は小さく保ち、少数の高信頼 relay をwriteとreadに分けて宣言し、更新時は discoverability relay にも十分に広めます。これがないと他クライアントはあなたの outbox に到達できません。(NIP-65) -
自分用の route cache を作る。 原本の
relay_listsと、実際に routing に使うroute_cacheを分離してください。後者には source・freshness・score・last_seen を持たせ、policy の差し替えを容易にします。Nostrify のように SQLite に relay list を保存して router で使う構成は、最初の実装として扱いやすいです。(Nostrify: The Outbox Model) -
publish queue を導入する。 DB 更新と relay fanout を分け、per-relay ACK・retry・dead-letter を実装します。NIP-01 の
OKsemantics を分類に使い、duplicateを success とみなしてください。(AWS: Transactional outbox pattern、NIP-01) -
read path を outbox 化する。 最初は profile/timeline だけを author
writerelay 参照に切り替え、通知や thread view は段階導入にします。Yana のように「各 author が最低 N relay でカバーされる最小集合」を使うと、接続数と検閲耐性のバランスが取りやすいです。(Yana GOSSIP.md) -
reply/mention fanout を実装する。 作者の
writerelay に加えて、tagged users のreadrelay にも送ります。公開メッセージ系でも inbox 配送が必要であることは、NIP-65 と NIP-A4 の両方が示しています。DM は別 pipeline にし、10050未発見なら送らないようにします。(NIP-65、NIP-A4、NIP-17) -
AUTH / capability / relay health を組み込む。 NIP-11 の制限情報、NIP-42 認証フロー、必要なら NIP-66 のモニタ情報を取り込みます。ただし NIP-66 に依存してアプリを止めてはいけません。relay の実測 RTT と per-relay success rate を優先してください。(NIP-11、NIP-42、NIP-66)
-
最後に default relay 依存を縮小する。 outbox coverage が十分になってから、従来の「全体に同じ relay を固定する」読み方を縮小します。Mike Dilger の問題提起どおり、固定 relay 共有モデルは大規模化で中央集権と見えない欠損を生みやすいからです。(Mike Dilger: The Outbox Model)
参考実装と推奨事項
参考実装・ライブラリ
| 種別 | 実装 / 文書 | 使いどころ |
|---|---|---|
| 公式仕様 | NIP-01 Basic Protocol (NIP-01) | EVENT / REQ / OK / EOSE、event ID、replaceable semantics の基礎 |
| 公式仕様 | NIP-65 Relay List Metadata (NIP-65) | outbox/inbox routing の本体 |
| 公式仕様 | NIP-17 Private Direct Messages (NIP-17) | DM relay list 10050、DM publish rules |
| 公式仕様 | NIP-44 / NIP-59 / NIP-42 (NIP-44、NIP-59、NIP-42) | 暗号化・gift wrap・relay AUTH |
| 公式仕様 | NIP-11 / NIP-66 (NIP-11、NIP-66) | relay capability / health probe |
| Rust | Rust Nostr Book NIP-65 page (Rust Nostr Book: NIP-65) | kind:10002 の builder 実装例 |
| Kotlin | NDK for Android README (NDK Kotlin) | outbox enable、metrics、dynamic relay subscription |
| TypeScript | Nostrify Outbox tutorial (Nostrify: The Outbox Model) | DB + router + relay pool の実装骨格 |
| TypeScript | @innis/nostr-relay-selection (@innis/nostr-relay-selection) |
pure policy layer 設計の参考 |
| Go | fiatjaf.com/nostr package docs (fiatjaf.com/nostr) |
新規 Go 開発の候補。README に SDK outbox 管理あり |
| 実装者議論 | Mike Dilger の解説、Yana GOSSIP.md (Mike Dilger: The Outbox Model、Yana GOSSIP.md) | gossip fallback、privacy、minimal relay cover の実践知 |
まとめ
開発者としての結論は明快です。公開コンテンツは NIP-65 を正本にして author-centric に読む、配送は author write + tagged user read に送る、DM は NIP-17/10050 に完全分離する、これが Nostr の outbox model を壊さず取り込む最短ルートです。仕様上も実装者議論上も、これがもっとも整合的です。(NIP-65、NIP-17、Mike Dilger: The Outbox Model)
ただし、NIP-65 だけで本番品質にはならない点が重要です。NIPs は optional で、既存クライアントの対応状況はまちまちです。そのため、導入初期は gossip fallback を残しつつ、内部では transactional outbox 的な publish queue を使って delivery reliability を担保するべきです。relay health、capability、AUTH、retry/backoff、duplicate ACK、migration を一つの delivery pipeline に整理できれば、outbox model は単なる relay 設定機能ではなく、Nostr ネイティブな分散配信基盤として機能します。(NIPs、AWS: Transactional outbox pattern、NIP-01、NIP-11、NIP-66)
最後に実装指針を一言でまとめると、**「NIP-65 を routing source of truth にし、ローカル publish queue を delivery source of truth にする」**です。この二層構造にすると、設計・運用・セキュリティ・スケーラビリティ・互換性のすべてが整理しやすくなります。(NIP-65、AWS: Transactional outbox pattern、Nostrify: The Outbox Model、NDK Kotlin)
参照リンク
- NIP-65
- NIP-01
- Mike Dilger: The Outbox Model
- AWS: Transactional outbox pattern
- NIP-17
- NIP-44
- NIP-59
- Yana GOSSIP.md
- NIPs
- Nostrify: The Outbox Model
- NIP-42
- NIP-11
- @innis/nostr-relay-selection
- fiatjaf.com/nostr
- NDK Kotlin
- Rust Nostr Book: NIP-65
- nbd-wtf/go-nostr
- NIP-65 prioritization discussion (PR #822)
- NIP-66
- NIP-A4
- Reference: https://github.com/nostr-protocol/nips/blob/master/65.md
- Reference: https://github.com/nostr-protocol/nips/blob/master/01.md
- Reference: https://mikedilger.com/gossip-model/
- Reference: https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html
- Reference: https://github.com/nostr-protocol/nips/blob/master/17.md
- Reference: https://github.com/nostr-protocol/nips/blob/master/44.md
- Reference: https://github.com/nostr-protocol/nips/blob/master/59.md
- Reference: https://github.com/frnandu/yana/blob/master/GOSSIP.md
- Reference: https://github.com/nostr-protocol/nips
- Reference: https://nostrify.dev/relay/outbox
- Reference: https://github.com/nostr-protocol/nips/blob/master/42.md
- Reference: https://github.com/nostr-protocol/nips/blob/master/11.md
- Reference: https://jsr.io/%40innis/nostr-relay-selection
- Reference: https://pkg.go.dev/fiatjaf.com/nostr
- Reference: https://github.com/nostr-dev-kit/kotlin/blob/master/README.md
- Reference: https://rust-nostr.org/sdk/nips/65.html
- Reference: https://github.com/nbd-wtf/go-nostr
- Reference: https://github.com/nostr-protocol/nips/pull/822
- Reference: https://github.com/nostr-protocol/nips/blob/master/66.md
- Reference: https://github.com/nostr-protocol/nips/blob/master/A4.md
Write a comment