Nostr アウトボックスモデル memo

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-65NIP-01Mike Dilger: The Outbox Model

開発者の実務として重要なのは、Nostrのアウトボックスモデルと、アプリ内部のtransactional outbox的な永続キューを分けて考えることです。前者は「どの relay に読み書きするか」の発見・配送モデル、後者は「ローカル状態更新と relay 送信をどう整合させるか」の信頼性パターンです。NIP-65 だけでは DB 更新とマルチリレー配信の原子的整合性は保証できないため、公開投稿・返信・DM のいずれでも、ローカル永続化した publish queue と per-relay ACK 追跡を併用するのが実装上は最も堅実です。(NIP-65AWS: Transactional outbox patternNIP-01

公開投稿と DM は必ず分離して設計すべきです。公開投稿・返信・リアクションは主に NIP-65 の 10002 を使って、作者の write リレーとタグ対象ユーザーの read リレーに配送します。一方、DM は NIP-17 で kind:10050 の受信 relay list を使い、NIP-44 で暗号化し、NIP-59 gift wrap でメタデータ保護を行います。NIP-17 は、受信者の 10050 が見つからないなら送るべきではないと明記しています。(NIP-65NIP-17NIP-44NIP-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 ModelYana GOSSIP.mdNIPs

定義と適用パターン

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 ModelYana 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 ModelYana GOSSIP.mdNIPs

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 tablepublish queueper-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 は EVENTEOSE を返し、limit は初期レスポンス時のみに使われます。Nostrify はこのルーティングを reqRouter(filters) で実装し、Yana は「各 contact が最低 N relay でカバーされるように」最小カバー集合を組む戦略を採っています。(NIP-65NIP-01Nostrify: The Outbox ModelYana 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-65Mike Dilger: The Outbox ModelYana 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-17NIP-44NIP-59NIP-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 routingdelivery 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 patternNIP-65NIP-17

実務上のおすすめは、ローカル DB に「最終的に relay に送る完全な signed event」と「relay ごとの配信タスク」を保存する構成です。NIP-01 では relay が OKduplicate: を返すことがあり、これは「relay が既にその event.id を保持している」ことを意味するので、配信成功と同等に扱えます。event.id はシリアライズされたイベントの SHA-256 で決まり、同一 payload なら relay を跨いでも同一です。(NIP-01

データモデル例

以下は、ブラウザ・モバイル・サーバーサイドのどれでも流用しやすい最小構成です。これは仕様そのものではなく、NIP-65/NIP-17/NIP-01 の要件を実務的に満たすための推奨モデルです。(NIP-65NIP-17NIP-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 を生成し、最後に eventspublish_queue同一トランザクションで保存します。commit 後、worker が relay ごとに EVENT を送り OK を記録し、duplicate は成功、rate-limited は backoff、restrictedauth-required は capability / credential 不足として別扱いにします。NIP-01 の OK reason と NIP-11 の capability 情報は、この分類の基準として使えます。(NIP-01NIP-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 OutboxTrackerrust-nostr gossip cratego-nostr sdk hints DBwelshman/router など、既存実装は総じてheuristic / stateful / async であり、厳密に同じルーティング結果を返すわけではありません。逆に @innis/nostr-relay-selection は純粋関数の policy layer を目指しており、engine と policy の分離設計の参考になります。(@innis/nostr-relay-selectionfiatjaf.com/nostrNDK KotlinNostrify: 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()RelayMetadatakind:10002 を安全に生成 (Rust Nostr Book: NIP-65 signer/DB/relay pool を自前統合する低レイヤ実装向き
Go fiatjaf.com/nostr go-nostr の後継で、README は sdk に outbox relay management を含むと説明 (fiatjaf.com/nostrnbd-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-65NDK KotlinNostrify: The Outbox Modelfiatjaf.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/nostrnbd-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 いずれでも、eventspublish_queue を同一トランザクションで確定し、relay 送信は非同期 worker に委ねるべきです。relay の OKduplicate: を返した場合は、NIP-01 上すでにその relay がイベントを持っているので success と等価に扱えます。(AWS: Transactional outbox patternNIP-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-42NIP-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-17NIP-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 ModelNIP-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-17NIP-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-01NIP-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 coveragerelay list freshnesspublish queue backlogper-relay success ratelatency を監視すべきです。NDK Kotlin は cacheHitRateknownRelayListCountfetchSuccessRateavgFetchDurationMsauthorCoverageRatedynamicRelaysAdded、さらに 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_listsroute_cacheeventspublish_queuerelay_acks の五つが中核です。relay 自体は replaceable event を上書きしたり、gift wrap を保持しなかったりし得るので、relay を唯一の正本にしてはいけません。特に kind:10002 は replaceable で古い値が消えてもおかしくなく、NIP-59 も gift wrap は relay が保存しないことがあり得るとしています。(NIP-01NIP-59

復旧時は、まず自分の最新 kind:10002 と必要なら kind:10050 を再配布し、その後に publish queue の未完了タスクだけを再送するのがよいです。NIP-65 は relay list の discoverability を重視しており、Mike Dilger も relay migration や censorship move に備えて clients が最新 relay list を定期的に読み直すべきだと述べています。(NIP-65Mike Dilger: The Outbox Model

マイグレーションと互換性テスト

relay migration は outbox model で一番事故が起きやすい点の一つです。実務では、旧 relay と新 relay へ一定期間 dual-publish し、その期間中に kind:10002 を更新して新 relay を discoverability relay に十分広める方が安全です。とくに major clients が relay list の一部しか使わないことがある以上、relay 移行を一発切り替えにすると見えない断線が起きやすくなります。(NIP-65NIP-65 prioritization discussion (PR #822)

互換性テストは、少なくとも次の軸を含めるべきです。NIP-65 未対応相手NIP-11 auth_required relaypayment_required relayDM relay の 10050 不在replaceable event raceduplicate ACKrelay timeout/partitionです。NIPs は optional であるため、「未実装相手と壊れずに話せるか」は成功条件そのものです。(NIPsNIP-11NIP-01NIP-17

推奨アーキテクチャと導入手順

推奨アーキテクチャ

以下の構成が、クライアント・モバイルアプリ・サーバーサイド bot のどれでも使いやすい基本形です。relay 選択 policy を pure に保つなら policy layerengine 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

導入手順

  1. bootstrap relay を定義し、kind:10002 収集機構を先に作る。 NIP-65 は discoverability relay への拡散を推奨しており、実装例では purplepag.es のような relay を bootstrap 用に置いています。nprofile の relay hint や kind 3 を補助入力にする fallback も、この段階で入れると導入初期の欠損が減ります。(NIP-65NDK KotlinNostrify: The Outbox ModelMike Dilger: The Outbox Model

  2. 自分の kind:10002 をまず正しく publish する。 relay list は小さく保ち、少数の高信頼 relay を writeread に分けて宣言し、更新時は discoverability relay にも十分に広めます。これがないと他クライアントはあなたの outbox に到達できません。(NIP-65

  3. 自分用の route cache を作る。 原本の relay_lists と、実際に routing に使う route_cache を分離してください。後者には source・freshness・score・last_seen を持たせ、policy の差し替えを容易にします。Nostrify のように SQLite に relay list を保存して router で使う構成は、最初の実装として扱いやすいです。(Nostrify: The Outbox Model

  4. publish queue を導入する。 DB 更新と relay fanout を分け、per-relay ACK・retry・dead-letter を実装します。NIP-01 の OK semantics を分類に使い、duplicate を success とみなしてください。(AWS: Transactional outbox patternNIP-01

  5. read path を outbox 化する。 最初は profile/timeline だけを author write relay 参照に切り替え、通知や thread view は段階導入にします。Yana のように「各 author が最低 N relay でカバーされる最小集合」を使うと、接続数と検閲耐性のバランスが取りやすいです。(Yana GOSSIP.md

  6. reply/mention fanout を実装する。 作者の write relay に加えて、tagged users の read relay にも送ります。公開メッセージ系でも inbox 配送が必要であることは、NIP-65 と NIP-A4 の両方が示しています。DM は別 pipeline にし、10050 未発見なら送らないようにします。(NIP-65NIP-A4NIP-17

  7. AUTH / capability / relay health を組み込む。 NIP-11 の制限情報、NIP-42 認証フロー、必要なら NIP-66 のモニタ情報を取り込みます。ただし NIP-66 に依存してアプリを止めてはいけません。relay の実測 RTT と per-relay success rate を優先してください。(NIP-11NIP-42NIP-66

  8. 最後に 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-44NIP-59NIP-42 暗号化・gift wrap・relay AUTH
公式仕様 NIP-11 / NIP-66 (NIP-11NIP-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 ModelYana 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-65NIP-17Mike 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 ネイティブな分散配信基盤として機能します。(NIPsAWS: Transactional outbox patternNIP-01NIP-11NIP-66

最後に実装指針を一言でまとめると、**「NIP-65 を routing source of truth にし、ローカル publish queue を delivery source of truth にする」**です。この二層構造にすると、設計・運用・セキュリティ・スケーラビリティ・互換性のすべてが整理しやすくなります。(NIP-65AWS: Transactional outbox patternNostrify: The Outbox ModelNDK Kotlin



参照リンク


Write a comment