ADR-9 — クロスプラグインAPI
ステータス: Accepted、2026-05-08; v0.1.1で実装。
v0.1.1のトラック2がPlugin::FactStore、Plugin::Base#prepare(services)、manifest(produces:/consumes:)、トポロジカルソートされたプラグインロード、#flow_contribution_for戻り値型ティア(スライス1 → 5 + スライス7)を出荷した。クロスプラグインファクト(fact)チャネル(:model_index、:factory_index、:dry_type_aliases、:graphql_type_table、……)は、いまやバンドルされたプラグイン全体で活発に使われている。
コンテキスト
Section titled “コンテキスト”v0.1.0プラグイン契約(contract)(ADR-2)は、すべてのプラグインに独自のファイルごと解析フック(#diagnostics_for_file(path:, scope:, root:))、ファイル読み取り用の独自のIoBoundary、キャッシュ用の独自のPlugin::Base.producer名前空間を提供する。プラグインは完全に独立している — 1つのプラグインが別のプラグインの解析状態を読めず、プロデューサー名前空間(plugin.<id>.<producer>)はADR-7 § 「スライス6-C」に従って意図的にプラグインごとにサンドボックス化されている。
この独立性はプラグインが未実証だったv0.1.0の正しいデフォルトだった。7つの実装例がランドし、Railsエコシステムロードマップ(docs/design/20260508-rails-plugins-roadmap.md)が記録されると、制約は具体的に障害になっている。
rigor-actionpackフェーズ1(強いパラメータ)はrigor-activerecordがすでに構築しているモデルインデックスが必要だ。db/schema.rbを最初から再読み込み・再パースするのは無駄であり、モデルディスカバラーを再実装するとrigor-activerecordのルールからずれていく。rigor-factorybotはファクトリー属性検証に同じモデルインデックスが必要だ。rigor-actionpackフェーズ4(ルートヘルパー消費)はrigor-rails-routesが構築するヘルパーテーブルが必要だ。
これらのクロスプラグイン読み取りはRailsエコシステムプラグイン全体で繰り返される。公認APIなしでは、プラグイン作成者は作業を重複させるか、アドホックな回避策(例:スライス6-Cサンドボックスに違反する共有プロデューサーid)を考案するかのどちらかになる。
v0.1.0プラグイン契約に3つの追加をv0.1.xスライス(slice)としてゲートして追加する。
- プラグインが型付きキーバリューのタプルを公開できる実行ごとの
Plugin::FactStore。他のプラグインは(plugin_id, fact_name)で読み取る。 - 新しい
Plugin::Base#prepare(services)フック。Analysis::Runner.runごとに1回、#initの後、任意の#diagnostics_for_file呼び出しの前に呼び出される。プラグインはここでファクト(fact)を計算して公開する。 - 新しい
manifest(consumes: [...])宣言。プラグインがファクトストアから読み取る(plugin_id, fact_name)ペアを宣言する。ローダーはそれをトポロジカルソートと欠落しているプロデューサーの早期失敗に使用する。
Plugin::FactStore
Section titled “Plugin::FactStore”公開/読み取り/反復操作を持つパブリック読み取り専用の値オブジェクト。Plugin::Services#fact_storeに配置される。
module Rigor module Plugin class FactStore Fact = Data.define(:plugin_id, :name, :value)
def publish(plugin_id:, name:, value:) # Writes to the store. Idempotent if called twice with # the same value (== comparison). Raises # Plugin::FactStore::Conflict if a different value is # published under the same (plugin_id, name). end
def read(plugin_id:, name:) # Returns the published value or nil. Reads do not # establish a dependency — that is what `consumes:` # is for; reads are the data access mechanism. end
def published?(plugin_id:, name:) # Predicate sibling for read. end
def each_fact(&block) # Enumerate every published fact across plugins. # Used by the runner for diagnostic provenance. end end endendライフサイクル: 新鮮なFactStoreインスタンスはすべてのAnalysis::Runner.runの開始時に構築され、終了時に破棄される。ストアは実行をまたいでキャッシュされない — 高コストな基礎計算のキャッシュはプロデューサーの仕事(Plugin::Base.producer)。FactStoreはそのすでにキャッシュされた結果への参照を公開するだけだ。
コンフリクトセマンティクス: 2つのプラグインが同じ(plugin_id, name)の下に公開する場合、2番目の書き込みは最初のものと一致(ノーオペレーション)するか、異なる(raiseする)かのどちらかだ。plugin_idがキーを名前空間化するため、コンフリクトは単一プラグインが2回公開する場合にのみ発生する——したがってコンフリクトはローダー時の無関係なプラグイン間の衝突ではなく、プラグイン作成者のバグを示す。
Plugin::Base#prepare(services)フック
Section titled “Plugin::Base#prepare(services)フック”デフォルトはノーオペレーション。プラグインは他のプラグインが消費するファクトを計算・公開するためにオーバーライドする。
class Activerecord < Plugin::Base manifest(id: "activerecord", version: "0.2.0")
producer :model_index do |_params| # ... existing code ... end
def prepare(services) services.fact_store.publish( plugin_id: manifest.id, name: :model_index, value: model_index ) endend単一のAnalysis::Runner.run内の呼び出し順序:
Plugin::Loader.loadがすべてのプラグインインスタンスを構築し、各プラグインの#init(services)を呼び出す。- ローダーは
consumes:宣言によってプラグインをトポロジカルソートする(プロデューサーが先。サイクルはロードエラー)。 - トポロジカル順序でプラグインごとに、ランナーが
#prepare(services)を呼び出す。プラグインはここでファクトを公開する。 - ランナーはファイルを反復する。各ファイルに対して、すべてのプラグインの
#diagnostics_for_fileが実行される(登録順——既存のセマンティクス)。フックはservices.fact_storeから自由に読み取る。
公開するファクトのないプラグインは#prepareをデフォルトのノーオペレーションのままにする。
失敗の分離: #prepareのraiseはADR-2 § 「プラグイン信頼とI/Oポリシー」に従って:plugin_loader runtime-error診断として分離され、#diagnostics_for_fileのraiseと同じ形状だ。#prepareで失敗したプラグインのファクトは未公開と見なされる。下流のコンシューマーはfact_store.readからnilを見て、グレースフルにデグレードする。
manifest(consumes:)宣言
Section titled “manifest(consumes:)宣言”オプションのマニフェストフィールド。プラグインが読み取るファクトを命名する{ plugin_id:, name: }ハッシュの配列:
class Actionpack < Plugin::Base manifest( id: "actionpack", version: "0.1.0", consumes: [ { plugin_id: "activerecord", name: :model_index }, { plugin_id: "rails-routes", name: :helper_table } ] )endPlugin::Manifest::Consumption値オブジェクト: 凍結されたData.define(:plugin_id, :name)。マニフェストはクラス定義時に形状を検証する。不正な宣言は問題のあるエントリーを名指しするメッセージでArgumentErrorをraiseする。
ローダーはconsumesを2つのことに使用する。
- トポロジカルソート —
consumesグラフの深さ優先ウォークがプラグインの#prepare呼び出しをプロデューサーがコンシューマーの前に実行されるよう順序付ける。サイクルはPlugin::LoadError(:dependency-cycle)をraiseする。決定論性の同率処理: 依存関係がない場合はplugin_idアルファベット順。 - 早期検証 —
Plugin::Loader.loadの終了時、ローダーは消費されたすべての(plugin_id, name)に、一致するproductionを宣言するマニフェストを持つプラグインがレジストリにあることを確認する。これはプロデューサー側のマニフェストフィールドmanifest(produces: [:model_index])を通じて強制される。欠落しているプロデューサーは解析が実行される前に:plugin_loader load-error診断として表面化する。
オプションのconsumes:エントリーセマンティクス: optional: trueでタグ付けされたエントリーは早期検証チェックをスキップする。コンシューマーのfact_store.readはnilを返し、コンシューマーはグレースフルにデグレードしなければならない。
manifest( consumes: [ { plugin_id: "activerecord", name: :model_index, optional: true } ])
def diagnostics_for_file(path:, scope:, root:) ar_index = services.fact_store.read(plugin_id: "activerecord", name: :model_index) return [] if ar_index.nil? # graceful degrade — no AR loaded # ...endoptional: trueは、兄弟がロードされているとエルゴノミクスが改善するが、単独でも機能しなければならないプラグインに使用する。rigor-factorybotが典型的な例だ——rigor-activerecordなしでも動作するが、あれば恩恵を受ける。
パブリックAPIドリフトサーフェス
Section titled “パブリックAPIドリフトサーフェス”このADRは以下を追加する。
Rigor::Plugin::FactStore(新しい名前空間) —publish、read、published?、each_fact、Fact(凍結Data)、Conflict(例外クラス)。Rigor::Plugin::Services#fact_store(新しいアクセサ)。Rigor::Plugin::Base#prepare(services)(新しいフック、デフォルトノーオペレーション)。Rigor::Plugin::Manifest#consumes(新しいattr_reader、デフォルト[])。Rigor::Plugin::Manifest#produces(新しいattr_reader、デフォルト[])。Rigor::Plugin::Manifest::Consumption(新しい凍結Data)。Rigor::Plugin::LoadErrorが:dependency-cycleと:missing-producerの理由コードを得る。
すべての更新は実装と同じコミットのspec/rigor/public_api_drift_spec.rbにランドする。
実装スライシング
Section titled “実装スライシング”推奨順序。各スライスは独立して出荷可能:
Plugin::FactStore値オブジェクト+spec。純粋な値オブジェクト。プラグインローダーの変更はまだなし。ドリフトスナップショットをランド。Plugin::Services#fact_storeアクセサ。Servicesごとにインスタンスが1つ構築される。プラグインは公開と読み取りができる。他は何も変わらない。Plugin::Base#prepare(services)デフォルトフック+Runnerの呼び出し。Runnerはファイルごとの反復の前にすべてのプラグインで#prepareを呼び出す。順序: 登録順(まだトポロジカルソートなし——それはスライス5)。manifest(produces:)+manifest(consumes:)宣言+検証。マニフェストは宣言を持つが、ローダーはまだそれを強制しない。Plugin::Loaderのトポロジカルソート+欠落プロデューサー/サイクル検出。これがconsumes:を拘束力あるものにするスライスだ。- ドキュメント更新 —
docs/internal-spec/plugin-cross-plugin.md(新ファイル)+rigor-plugin-authorSKILLが「フェーズ4.7 — クロスプラグインファクト」セクションを得る。
rigor-actionpackフェーズ1はスライス5が出荷された後にランドする。Tier 1プラグイン(rigor-rails-routes、rigor-rails-i18n、rigor-actionmailer、rigor-activejob)はこれらのスライスを必要とせず、並行してランドできる。
作業上の決定
Section titled “作業上の決定”WD1 — なぜメソッド呼び出しパススルーではないのか?
Section titled “WD1 — なぜメソッド呼び出しパススルーではないのか?”別の設計では、プラグインが互いのパブリックメソッドを直接クエリできるようにする。
ar_plugin = services.plugin_registry.find("activerecord")ar_plugin.model_index # call public methodこれは却下された。理由:
- プラグインを互いのクラスレベルAPIに結合させる。
rigor-activerecordでのメソッド名変更がすべてのコンシューマーを壊す。 - プラグインインスタンスはランナーにとってプライベートだ。それらを公開すると無関係な状態(
@io_boundary、@config)が漏洩する。 - 「ファクト」の抽象は、コンシューマーが実際に欲しいもの——プロデューサーが公開することを選んだ値オブジェクト、プラグインの内部状態ではない——に近い。
FactStore設計は偶発的な結合を防ぐ。唯一の契約は公開された値の形状であり、クロスバージョン互換性が懸念になる場合はlib/rigor-<id>-facts.rb(共有形状gem)に配置された型付きDataクラスでピン留めできる。
WD2 — なぜファクト形状にRBSではないのか?
Section titled “WD2 — なぜファクト形状にRBSではないのか?”RBSはファクト値の型契約を宣言できる。検討されたが延期——形状契約はプロデューシングプラグイン自身のコード(例:Rigor::Plugin::Activerecord::ModelIndex)が所有するのが最善で、コンシューマーはその型にアクセスするためにプロデューシングgemをインポートする。RBSは厳密さ(rigor)を加えるが、すべてのプラグインがパブリック型用の.rbsを出荷することを要求し、これは現在の慣行ではない。プラグインgemの1つがv1.0.0の安定性コミットメントに達したときに再検討する。
WD3 — キャッシュディスクリプタ合成
Section titled “WD3 — キャッシュディスクリプタ合成”コンシューマープラグインが自身のキャッシュプロデューサーキーでファクトを使用する場合、ディスクリプタにはプロデューサーのアイデンティティ+バージョンを含める必要があり、プロデューサーのアップグレードがコンシューマーのキャッシュを無効化する。
producer :strong_params_validation do |params| ar_plugin = services.fact_store.read(...) # current run only cache_for(:strong_params_validation, params: params, descriptor: Cache::Descriptor.new( plugins: [Cache::Descriptor::PluginEntry.new( id: "activerecord", version: ar_plugin_version, # how to get this? config_hash: "" )] )).callend未解決の問い: コンシューマーはどのようにプロデューサーのバージョンを知るか? 選択肢:
A. プロデューサーがファクトペイロードの一部としてバージョンを公開する: { plugin_id:, name:, value:, producer_version: }。
B. services.fact_store.readがプロデューサーメタデータを持つラッパーを返す: Fact(value:, producer_version:)。
C. コンシューマーがプロデューサーのマニフェストを読む: services.plugin_registry.find("activerecord").manifest.version。
オプションBが最もクリーンだ——実装は最初の具体的なニーズ(おそらくrigor-actionpackフェーズ1)まで形状を延期する。
検討した代替案
Section titled “検討した代替案”- 共有プロデューサーid(コンシューマーが
producer :"plugin.activerecord.model_index"を登録する)。却下: ADR-7 § 「スライス6-C」サンドボックスに違反する。キャッシュ帰属が曖昧になる。 - プラグイン間requireおよび直接定数ルックアップ。却下: プラグインgem間でgem依存関係を強制する。FactStoreの目的はgemを独立して取り出せるまま保つことだ。
- 能力ベースのメッセージパッシング。検討済み。現在のユースケースに対して重すぎる。
- 2026-05-08 — 初期提案。Railsエコシステムロードマップのランドによって引き起こされた。
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.