diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..ccebda4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,4 @@
+# Hash-pinned experiment inputs must survive Windows checkout byte-exactly.
+experiments/document-tail/fixture.json -text whitespace=cr-at-eol
+experiments/document-tail/provenance.json -text whitespace=cr-at-eol
+experiments/document-tail/source-* -text whitespace=cr-at-eol
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 680ef81..25c928b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,11 @@ jobs:
node-version: "22"
- run: node --test scripts/sync-release-version.test.mjs
- run: node --test scripts/abstention-replay.test.mjs
+ - run: node --experimental-strip-types --test scripts/document-tail.test.mjs
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: python -m unittest discover -s scripts -p 'document_tail_test.py'
- run: npm ci
- run: npm ci
working-directory: mcp-server
diff --git a/.gitignore b/.gitignore
index 48740cb..f74de8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,7 @@ node_modules/
dist/
.wrangler/
.dev.vars
+
+# Offline experiment databases / Python caches
+*.sqlite
+__pycache__/
diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md
index 987c920..03239b4 100644
--- a/docs/0-requirements.ja.md
+++ b/docs/0-requirements.ja.md
@@ -679,3 +679,7 @@ Issue #263 の調査で再現した issue_comment.deleted の部分削除: Vecto
両 CD packaging job は bridge package、lock root、MCP Bundle manifest、MCP registry の npm version を同じ検証済み release tag から同期し、pack / publish 前に一致を assert する。依存版、schema、runtime 条件、Worker の挙動を保持する。対象 field と検証 command は [版数と公開成果物](installation.ja.md#versioning-and-published-artifacts) に定義する。commit 済み版は placeholder のままとし、trusted publishing は別変更とする。
source の `docs/_Sidebar.md` は既存 Feedback Memory EN/JA ページを参照し、release 後の Wiki navigation に含める。
+
+### Offline document-tail comparison (#279)
+
+固定公開文書の後半発見を検討する opt-in sparse-only 実験は [Document Tail Replay](5-document-tail-replay.ja.md) の契約に従う。prefix8000 / fullbody / nonoverlap8000 の比較、入力・source hash、named target と exact literal source の別採点、静的rerank予算、節長分布を記録する。既定の検索・embedding・reranker・live索引は変更しない。
diff --git a/docs/0-requirements.md b/docs/0-requirements.md
index 9a58ecc..5afe028 100644
--- a/docs/0-requirements.md
+++ b/docs/0-requirements.md
@@ -684,3 +684,7 @@ Issue #259 version contract: live inline doc/wiki uses the returned-text SHA-256
Both CD packaging jobs must derive the bridge package, lock root, MCP Bundle manifest and MCP registry npm versions from the same validated release tag and assert agreement before packing or publishing. Preserve dependency versions, schemas, runtime constraints and Worker behavior. [Versioning and published artifacts](installation.md#versioning-and-published-artifacts) owns the affected fields and validation command. Committed versions remain placeholders; trusted publishing is a separate change.
The source `docs/_Sidebar.md` must link the existing Feedback Memory EN/JA pages so release wiki navigation includes them.
+
+### Offline document-tail comparison (#279)
+
+固定公開文書の後半発見を検討する opt-in sparse-only 実験は [Document Tail Replay](5-document-tail-replay.ja.md) の契約に従う。prefix8000 / fullbody / nonoverlap8000 の比較、入力・source hash、named target と exact literal source の別採点、静的rerank予算、節長分布を記録する。既定の検索・embedding・reranker・live索引は変更しない。
diff --git a/docs/5-document-tail-replay.ja.md b/docs/5-document-tail-replay.ja.md
new file mode 100644
index 0000000..5a602b4
--- /dev/null
+++ b/docs/5-document-tail-replay.ja.md
@@ -0,0 +1,50 @@
+# Offline Document Tail Replay
+
+長い資料の後半を発見する候補の比較を、固定公開入力から再計算する opt-in CLI。既定検索、embedding、reranker、live DB、デプロイは変更しない。全文索引や分割方式を本番に採用する変更ではない。
+
+```sh
+# Node >=22.6 (TypeScript stripping), Python >=3.10 with SQLite FTS5
+python scripts/document-tail-replay.py --out /tmp/document-tail-new
+node --experimental-strip-types --test scripts/document-tail.test.mjs
+python -m unittest discover -s scripts -p 'document_tail_test.py'
+```
+
+`--out` は存在しない新規ディレクトリを指定する。CLIは原子的に作成し、既存出力・入力・DBを上書きしない。既存のsymlink先も拒否する。ネットワーク、認証、Workers、NGR共有DB、モデルを使わない。失敗時の部分出力も再利用しない。異なる新規dirで実行する。`prepared.json`、3 SQLite DB、`results.json`、`sections.json`、`REPORT.md` をそのdirに生成する。DBやpreparedをコミットしない。
+
+## 固定入力と再現契約
+
+`experiments/document-tail/fixture.json` は2026-10-13に独立captureした11公開文書 / 7事前queryの新規fixture。frozen corpus、WSLC、`corpora/` 配下は入力に含めない。公開文書中でcorpusに言及していても、そのcorpus自体は読み込まない。`provenance.json` は各文書のcommit、Git blob SHA、本文SHA256、immutable URLを保持し、parentがGitHub Contentsの固定refと照合した記録。runnerはネットワーク再取得せず、その本文hashとblobをローカル検証する。
+
+`protocol.json` はfixture/provenanceとsource snapshotのSHA256を固定する。sourceは github-rag-mcp `da23db3f077a2975e11fc4ab16cfab1556098045` のbyte-exact写し。segmentForFtsをその写しから実行し、escapeFtsQueryは同じ写しからTypeScript注釈のみを除いて抽出する。source snapshotは実行中のsrcとは別物。runnerは入力と全snapshot hash、各本文version hashを照合する。
+
+- prefix: `(path + '\n\n' + body).slice(0,8000)`、JavaScript UTF16 units。production snapshotと同じ切断。
+- full: 同じ入力全体。
+- chunks: 同じ入力の固定非重複 <=8000 units。surrogate pairを割る境界だけ1unit戻す。pathはchunk0のみ、重複・title再挿入なし。結合が元入力に一致することを検証する。
+- natural FTS5: `porter unicode61 remove_diacritics 2`、単一external-content列。strict ANDを先に、relaxed ORを後に採る。各tier内BM25昇順、rowidでtie break、全候補列挙後にbest rowでentity dedup。
+- 順位付けが使うのはqueryとoptional exact repo filterのみ。target/eligible-sourceは順位付け後の採点にだけ使用する。
+
+環境version(Node/ICU/Python/SQLite/compile options)、bytes/rows、build時間、prepare時間、1warmup +31queryのsample/median、全候補・snippet・match windowsを出す。ローカル時間・DB bytesは本番CPU/RAM/費用の保証ではない。Node/ICU/SQLite versionが変わるとtokenや順位が変わりうる。CIの固定順位検査はそのfixture契約であり汎用FTS品質を保証しない。
+
+## 指標と観測された範囲
+
+named target hit@1/@3は事前指定pathの順位。literal-source entity hitは同repoの原本文にexact query文字列がある任意entityの順位で、意味的goldではない。eligible entityがprefix由来で偶然rankすることもあるため、**top1のindex row自体にphraseがあるか**も別に記録する。negativeはこのfixture中のliteral不在を検査するだけで、productionで答えられないことを意味しない。`ngr_prefix` の `judgment relation` は個々のtokenがprefix内にあるgeneral-query controlであり、phrase-in-prefix controlではない。
+
+固定fixtureでのtail3件の再計算:
+
+| Arm | named target @1/@3 | literal-source entity @1/@3 | top1 index rowにliteral |
+|---|---|---|---|
+| prefix | 0/3, 1/3 | 1/3, 1/3 | 0/3 |
+| full | 3/3, 3/3 | 3/3, 3/3 | 3/3 |
+| chunks | 2/3, 3/3 | 3/3, 3/3 | 3/3 |
+
+webhook chunkの1位は別資料にも同じ根拠literalがあるため、named-target差からfullの品質優位を導かない。prefix/fullはproduction natural topK3の各branch cap、outer6、entity dedupとの14結果の一致を別監査する。chunkは全列挙/dedupでありproduction candidate capとは異なる。コード/trigram/dense/graph/RRFを含めた本番の検索経路は測定しない。
+
+## 静的根拠露出と節長
+
+現在のsnapshotのrerank内容予算は `1700-min(query.length,200)` UTF16 units。full入力と採点targetのwinning8000 chunkのprefixには3tailのphraseがどれも収まらない。見出し節候補はpath+改行2つ+節(見出しmarkup含む)の仮想prefixで、2/3が見える。NGRは19454-unitの節の後半にあり隠れる。これは節retrieval/rerankingを実行した結果ではない。
+
+節解析はfence外のATX h1-h6 / single-line setext h1/h2間を区切り、親子集約しない。非空preambleを含み、空preambleを別集計。indented code、blockquote、listのsetext候補を除外する簡易parserで、完全なCommonMark対応を主張しない。UTF16 nearest-rank percentilesを記録する。tail対象root3は74節、59節が<=1700。多数の短節があってもsection chunkだけで十分とは証明しない。
+
+## 残課題
+
+端-to-端モデル正答率、誤陽性のrelevance注釈、広いcorpusでの評価は未測定。採用前にはunchanged-hash backfill、deletion/replacement、provenance、chunk ID/offset、entity dedup、candidate budget、rerank evidence slice、return/fetch-source semantics、D1 statement/size/CPU制約を決める必要がある。chunk境界でphrase/tokenが分割される影響も未評価。
diff --git a/experiments/document-tail/fixture.json b/experiments/document-tail/fixture.json
new file mode 100644
index 0000000..e46cb55
--- /dev/null
+++ b/experiments/document-tail/fixture.json
@@ -0,0 +1,133 @@
+{
+ "scope": "new development corpus assembled from live retrieved public docs; excludes all corpora/ frozen artifacts; not representative production corpus",
+ "docs": [
+ {
+ "repo": "Liplus-Project/neuron-graph-rag",
+ "path": "docs/requirements.md",
+ "url": "https://github.com/Liplus-Project/neuron-graph-rag/blob/main/docs/requirements.md",
+ "version": "sha256:4e3bf59075021a65f46e2f2e9f09dfc654e7e0a489682b701fa80dad758eb277",
+ "text": "# Requirements\n\n## 1. Purpose\n\nこのプロジェクトは、ハイブリッド検索、型付き知識グラフ、決定論的な活性伝播、成功フィードバックを、一つの観測可能な RAG パイプラインとして提供する。\n\n## 2. Premises\n\n- 公開 API は特定のデータ源に依存しない。\n- コアは Python 標準ライブラリだけで動作する。\n- MCP 接続は同一 repository 内の任意 adapter とし、コアの必須依存にしない。\n- sparse 検索は BM25、dense 検索は決定論的な feature-hashing encoder を既定実装とする。\n- dense encoder は差し替え可能とし、既定実装を意味埋め込みモデルとは見なさない。\n- グラフは型付き有向エッジであり、重みと事実性を別々に保持する。\n- 活性は検索時刻に紐づく動的状態であり、知識の確信度やエッジの事実性とは別軸である。\n\n## 3. Functional requirements\n\n1. 文書ノード、metadata、知識確信度を SQLite に永続化できる。\n2. 型付きエッジ、結合重み、事実性を SQLite に永続化できる。\n3. BM25 と dense cosine similarity を正規化し、入口スコアへ統合できる。\n4. 上位入口ノードから、重み、事実性、hop decay を使って決定論的に活性を伝播できる。\n5. 入口スコアとグラフ活性を統合して結果を順位付けできる。\n6. 結果ごとに sparse、dense、入口、グラフ、最終スコアと伝播経路を説明できる。\n7. 検索 trace と検索結果を、成功フィードバックとは別の記録として保持できる。\n8. 成功時に明示された利用ノードへ至る経路だけを強化できる。\n9. 検索しただけではエッジ重みを変更しない。\n10. 活性値は半減期に従って時間減衰する。\n11. 活性減衰はノード確信度とエッジ事実性を変更しない。\n12. 同一コーパスで通常のハイブリッド検索とグラフ統合検索を比較できる。\n13. 任意 MCP adapter の `search`、`record_source_use`、`record_outcome`、`write_judgment` と judgment 専用 read tool 契約を、実装と transport から独立して定義する。\n14. source-use を `retrieved`、`selected`、`validated`、`used` に分け、新規 `used` への遷移だけを即時 reinforcement に接続する。\n15. `corrected`、`rolled_back` などの delayed outcome を source-use と別に記録し、初期契約では edge weight を自動変更しない。\n16. MCP adapter は trace、node、enum、stage 順序、idempotency を境界で検証する。\n17. 各 MCP tool の model-facing description 自体が、feedback の呼び分けと reinforcement 条件を consuming AI へ伝える。\n18. persistent core の trace は自動 expiry しない。retention を設ける deployment は `search` description と output に期限を明示し、expiry 後の feedback を `unknown_trace` とする。\n19. github-rag-mcp の D1 `search_docs` を正本として、repo / type / per-type limit と固定順から決定論的な小 fixture を生成できる。\n20. `search_docs.vector_id / content` を node ID / text へ、`doc_edges` の両端と `edge_kind` を typed edge へ変換し、欠損 endpoint は node を捏造せず除外理由を記録できる。\n21. D1 取得は単一 SELECT / WITH query のみに制限し、各 query の `rows_written=0`、`changes=0`、`changed_db=false` を検証できる。\n22. fixture と分離した provenance report に schema fingerprint、coverage、取得時刻、取得時点で未解消の既知 gap、redaction 件数を記録し、再取得前後の count / commit / 最新時刻を比較できる。未解消の既知 gap がない場合は空配列を記録する。\n23. 実 D1 の明示的な wiki doc path 集合から、弱連結で決定論的な評価 fixture を生成できる。gold case と品質結果を見た後に選択集合を変更しない。\n24. 12 件以上の gold query を direct lookup、relation、negative control に分け、query、期待 node、許容 rank、source URL、relation 時の期待 endpoint / edge type を保持できる。\n25. baseline hybrid と graph-integrated retrieval を同一 corpus、encoder、query で実行し、全体・cohort 別の MRR、Hit@3、rank delta、改善・同値・悪化件数を機械可読に出力できる。\n26. relation の説明は score だけでなく、固定した one-hop / two-hop の endpoint と edge type に対して照合する。\n27. success feedback 前後で edge weight と全 gold case の rank を比較し、credited path 外の edge 変更と非対象 case の rank 変更を明示する。\n28. 活性伝播は共通 interface の下で、現行正方向加算、有限活性 budget、側方抑制、query-conditioned transmission、反復競合を選択できる。\n29. 各検索 trace は strategy、伝播 step 数、展開数、活性総量、収束有無、停止理由を決定論的な diagnostics として保持する。\n30. neural dynamics experiment は development と doc path が重ならない connected holdout、gold、探索空間、最大 variant 数、選択規則、停止規則を結果観測前に固定する。\n31. 候補選択は development result だけで行い、relation MRR と negative-control MRR の Pareto gate、worst-cohort MRR、展開数、構造複雑度、variant ID の順で一意に決める。\n32. development gate を通る候補がない場合は holdout を開かず既定を変更しない。候補がある場合だけ holdout を一度評価し、cohort 退行、path 不一致、feedback 汚染のいずれかがあれば採用しない。\n33. experiment result は gate 不合格、Pareto 支配、holdout 不採用を含む全 variant を上書きせず versioned artifact として保存する。\n34. recurrent activation は global inhibition に加え、同じ source の sibling neighbor だけを競合させる local strategy を選択できる。\n35. local recurrent strategy は query relevance と active path identity を独立に有効化でき、競合集合ごとに source、path identity、neighbor 数、query relevance、配分前後の message 総量を記録する。\n36. local recurrent experiment は production D1 から read-only 取得した新しい development / holdout を旧 development / 開封済み holdout および相互間で分離し、両 provenance と contamination audit を結果観測前に固定する。\n37. 旧 development result は family と baseline の探索的根拠だけに使用し、旧 holdout は fixture identifier の重複拒否以外では読み込まない。\n38. local recurrent experiment は `current`、`recurrent-balanced`、neighbor / query / path の4 ablationを合わせた6 variantsに固定し、parameter gridを追加しない。\n39. development候補はrelation MRRで両baselineを厳密に上回り、direct / negative-control MRRがcurrentから退行せず、全relation pathとfeedback isolationを満たす場合だけ選択する。\n40. development候補がある場合だけ、未観測holdoutで`current`、`recurrent-balanced`、選択候補を一度評価する。resultの再実行と上書きを拒否する。\n41. local recurrent strategyは未観測holdoutで同じgateを通過した場合だけ既定候補になり、それ以外では`current_positive_additive`を維持する。\n42. entry retrievalはgraph競合の外側にzero-hop anchorとして保持でき、競合前後で同一値であることをtrace diagnosticsで検証できる。\n43. anchored graph signalは少なくとも1 edgeを通ったmessageだけで構成し、zero-hop seed residualとzero-hop pathを含めない。\n44. dense retrievalとgraph propagationを独立に無効化でき、BM25-only ablationではdense encoderとgraph traversalを実行しない。\n45. explanationはBM25 / denseのraw・normalized値、競合前後のentry anchor、graphのraw・normalized値、final score、zero-hop / graph path種別を区別して保持する。\n46. anchored hybrid experimentはproduction D1からread-only取得した新development / holdoutを、過去39 doc pathsを含む4 fixturesおよび相互間で分離し、provenanceとcontamination auditを結果観測前に固定する。\n47. anchored hybrid experimentは`current`、`bm25-only`、BM25+現行graph、BM25+dense anchorのlocal/query local、BM25 anchorのlocalを合わせた6 variantsだけを比較する。\n48. development候補はrelation MRRがcurrentを厳密に上回り、direct / negative-controlが退行せず、relation path、feedback isolation、anchor invariant、edge-only graph signalをすべて満たす場合だけ選択する。\n49. 候補がある場合だけ未観測holdoutで`current`、`bm25-only`、選択候補を一度評価し、同じgateを通過した場合だけdefault変更候補とする。\n50. graph activationは`max`、`none`、`l1_mass`を一般設定として選択でき、zero totalを決定論的に全0へ変換できる。\n51. final fusionは既存linearに加え、entry rankとpositive graph nodeだけのgraph rankを使うbottom-centered weighted RRFを選択できる。\n52. 各traceはentry / graph rank、entry / graph fusion component、normalization、fusion strategy、RRF k、positive graph node数を保持し、final orderingを機械的に再計算できる。\n53. fusion calibration experimentはproduction D1の新しい3-node development / holdoutを、既存7 fixturesの50 unique doc pathsおよび相互間から分離し、provenance、balanced gold、contamination audit、6 variantsを結果観測前に固定する。\n54. development候補はrelation MRRをcurrentから厳密に改善し、少なくとも1 relation caseを個別改善し、direct / negative-controlのcohort MRRと全個別rankを退行させず、path、feedback、anchor、edge-only graph、formula auditを満たす場合だけ選択する。\n55. development候補がある場合だけ未観測holdoutでcurrentと選択候補を一度評価し、同じgateを通過した場合だけdefault変更候補とする。\n56. `search_channels`は同一queryからBM25 lexical laneとanchored edge-only relation laneを独立trace IDで同時返却し、cross-lane final score、combined rank、single winnerを生成しない。\n57. lexical laneはBM25だけで順位付けし、dense retrievalとgraph propagationをlane順位へ使用せず、保存hitへgraph pathを持たせない。\n58. relation laneはBM25+dense entryをseed選択だけに使い、`anchored_local_competition`で1 edge以上を通過したpositive graph nodeをraw activation降順・node ID昇順で順位付けする。\n59. channel provenanceはcallerのchannel自己申告でなく独立trace IDに保存し、default config の`record_success`はlexical traceでedgeを変更せず、relation traceでは保存済みcredited pathだけを強化する。\n60. 同一nodeが両laneに現れる場合も各rankと説明を保持し、片方のtraceに保存されていないnodeへのfeedbackをatomicに拒否する。\n61. independent-channel experimentはproduction D1からread-only取得した相互disjointな2-node / 1-edge developmentとholdoutを、既存9 fixturesの全node pathから分離し、各splitの4-case hard gate、provenance、contamination audit、lane規則、feedback規則、停止規則を結果観測前に固定する。\n62. developmentでlane parity、relation個別改善、edge-only path、独立trace、edge不変、feedback帰属、cross-lane拒否、決定性の全hard gateを通過した場合だけholdoutを一度開き、同じgateを全通過した場合だけ`search_channels`をvalidatedと記録する。\n63. blind channel-selection experimentはv1 fixture / gold / manifest / evaluator / runner / development resultをbyte hashで固定し、既存`search()`、`search_channels()`、default、feedback、v1 artifactを変更しない。\n64. blind packetはopaque case ID、query、lane semantics、独立trace、lane内rank、node本文とsource metadata、raw / projected relation path、agreementだけを含み、cohort、intended channel、期待node / rank / path、gate、過去結果、lane scoreを含めない。\n65. judge responseはpacketだけを受けるfresh agent 3体が独立生成し、trace / node所属を検証した後に`(channel, node)`のmajorityで集約する。actual LLM callはparent orchestratorだけが行い、core、runner、CIはjudgeを呼ばない。\n66. relation pathはraw stepを保存したままendpoint / edge typeへ射影して照合し、zero-hopを拒否する。judge raw response、parse結果、model、agent type、実行時刻をimmutable artifactへ保存する。\n67. development packet / response / resultはresult-free commitのpush後に各一度だけ生成し、全12 gate通過時だけholdout packetを一度生成して異なるfresh judge 3体で判定する。全観測artifactは上書きと再生成を拒否する。\n68. development gate通過前のholdoutではpacket生成、`search_channels()`実行、judge提示、v2 gold照合を禁止する。byte hash、schema、既存contract testの非表示process readはselectionに使わず、holdout open countへ含めない。\n69. v1 / v2 frozen text artifactのbyte hash監査はraw checkout bytesを最初に照合する。raw不一致時は全改行がLFまたはCRLFの一種類で、もう一方へのexact変換だけが固定hashと一致する場合に限ってcheckout変換を許可し、本文差分、mixed newline、bare CRを拒否する。\n70. node-first blind experimentはv1 / v2 manifest、prompt、packet、judge artifact、result、source、runner、tests、experiment docsをraw-firstかつLF / CRLFだけを許可するhash監査で固定し、v2 invalid responseをretry、補完、再集約しない。\n71. v3はv1のdirect / directional-negative queryを維持し、relation queryだけedge先targetを自然文で明示する固定overrideを使う。stage packetは4 caseを保持するが、judge用packetとresponseは一つのopaque caseだけを含む。\n72. developmentは4 case x fresh 3 judgesの12 independent invocationsとし、各agent contextを再利用せず、repo、web、tool、gold、他case、他judge、prior responseを渡さない。actual LLM invocationはparent orchestratorだけが行う。\n73. v3集約は`node_id`の2/3 majorityを採否の正本とし、同じnodeへ複数laneから投票した場合もnode correctnessを満たす。channel voteはtrace provenanceと分布だけに使い、gold channel gateを持たない。\n74. relation traceを選んだresponseはselected nodeのraw pathを保存し、endpoint / edge typeへの射影が固定relation pathと一致してzero-hopでないことを監査する。feedbackは実行せず、lexical / relation traceの強化provenanceだけを記録する。\n74a. node-first captureの前にmanifest、stage packet、およびstageが参照する全case packetのcase ID、path、実byte hashを照合する。いずれかが不一致ならjudge起動、raw response read、capture artifact write、aggregationを開始せず、unassessedのまま停止する。\n75. trace-credited feedback adaptation experimentは、同一のfrozen corpus、config、query schedule、limit、時刻規則でcontrolとtreatmentを比較し、controlはfeedbackを記録してedge mutationを適用せず、treatmentだけがrelation traceのcredited pathを強化する。\n76. feedback adaptationのdevelopment / holdout、feedback event、score query、expected path、gate、registered run count、hash、contamination audit、exclusive outputを結果観測前に固定し、development全gate通過時だけholdoutを一度開く。\n77. feedback adaptation reproductionはprior resultを選択入力にせず、prior fixtureとの識別子だけのcontamination auditを行う。raw relation stepは比較前に`source_id`、`target_id`、`edge_type`だけへ射影し、runtime fieldを含むsynthetic testでpath identityをfreeze前に検証する。\n78. 新規 feedback-adaptation experiment の primary relation gate は、baseline relation MRR が 1.0 未満ならtreatmentのstrict improvement、baseline relation MRR が 1.0 ならtreatmentのnon-regressionを要求する。いずれも endpoint/type projected path、direct lexical / directional-negative controls、credited-only mutation、deterministic replay、contamination、immutable output を含む全 safety gate を必須とする。ceiling case のnon-regression pass は追加の順位改善を示せないことを記録するだけで、generalization、default変更、production採用を許可しない。\n79. repository-native controlled corpus v3 の engine-backed trajectory experiment は、source commit、split / cluster identity、explicit-link edge、0 / 1 / 3 / 10 feedback schedule、query、used node、credited path、control / treatment、gate、manifest hash、exclusive output を観測前に固定する。control は relation trace と used node を記録して edge を変更せず、treatment だけが同じ schedule の relation trace ID を `record_success` に渡す。headroom は 0 から 10 で厳密改善し途中 checkpoint で退行せず、control case と ceiling case も退行せず、credited edge 以外が変化しない場合だけ development gate を通過する。development 全 gate 通過時だけ holdout を一度開き、観測後は evaluator、fixture、gold、schedule、manifest、gate、docs を変更しない。\n80. sibling relation feedback normalization は、明示的に有効化された candidate config でのみ、relation trace の credited edge を強化し、その edge と同じ source から出る未 credit sibling だけを局所的に正規化できる。lexical trace、zero-hop、未関係 source、credited sibling は変更しない。candidate は synthetic isolation test と result-free development / holdout 相当の relation、direct、lexical、negative-control gate を通過するまで default にしない。\n81. sibling normalization controlled evaluation は、評価対象 source commit / hash、相互に identity-disjoint な development / holdout cluster、明示 edge、baseline `0.0` / treatment `1.0`、query、used node、credited path、mutation scope、rollback、係数 / 時刻 schedule、hard gate、exclusive output を観測前に固定する。実 `NeuronGraphRAG` の `search_channels` relation trace ID を `record_success` に渡し、headroom strict improvement、ceiling・direct・lexical・directional-negative non-regression、path・mutation・atomicity・determinism の全 development gate 通過時だけ holdout を一度開く。観測後は protocol artifact と docs を変更せず、既定値と external D1 claim を変更しない。\n82. frozen evaluation の historical source hash は、manifest path の初回追加 commit または manifest が明示する lowercase full 40-hex source / baseline / prior commit の exact blob bytes に対して検証する。後続の committed manifest rewrite、mutable ref / revision expression、未知 commit、非 ancestor commit、manifest bytes差、欠落 path、hash 不一致を fail closed にし、同名 path の current working tree を過去の evidence として扱わない。既存 protocol が明記する raw-first LF / CRLF whole-file alternate だけを維持し、本文差、mixed newline、bare CR、その他の byte 差を拒否する。\n83. soft-start feedback reinforcement は明示 opt-in の relation-only candidate とし、最初の新規 `used` で通常 bounded increment の固定 ratio 分だけを credited path へ適用する。最初の独立 `confirmed` は同じ schedule の残量を一回分の通常 increment まで補い、後続 confirmation は固定 decay ratio で加算する。used 時は sibling normalization を行わず、confirmation の actual delta だけを同一 source の uncredited sibling へ配分する。duplicate、lexical、zero-hop、別 source、uncredited edge、negative outcome は変更せず、candidate mechanics の合格だけで default や local serving policy を変更しない。\n84. soft-start snapshot evaluation は、local source database を read-only URI と SQLite backup API で一度だけ transaction-consistent snapshot へ複製し、同じ snapshot のfresh clone上で `control`、`used_q3_s1`、`confirmed_r05_s1`、`soft_start_r025_r05_s1` の固定4 armを比較する。query、public node / edge identifier、outcome、3回のfresh trace schedule、checkpoint、metric、hard gate、exclusive output、snapshot / protocol hashを登録result生成前に固定する。source database、live config、snapshot本体、private本文、absolute private pathをpublic artifactへ含めず、developmentを一度だけ実行し、全hard gate通過時だけholdoutを一度開く。不支持または判定不能を保存してもprotocolを調整せず、local cutover、library default、external corpus、production qualityへ自動で一般化しない。\n85. baseline-aware soft-start snapshot evaluation は、v1のprotocol、gate、observed result、private snapshotを変更、再実行、再集計、入力再利用せず、fresh snapshot、新規namespace、新規output、v1 observed developmentと異なるcredited edge identityを使う。各relation caseのinitial weight、reinforced count、evidence count、confirmation countを結果前に登録し、q3/s1のfirst mutationを`max(1, quorum - initial evidence count)`で導出する。導出不能、baseline不一致、event budget内のquorum capacity不足はregistered resultを作らずfailure reportで停止する。v2 protocolはregistered output不在のfreeze-only PRで固定し、そのsquash merge後の別Issueでdevelopmentを一度だけ実行する。全8 hard gate通過時だけholdoutを一度開き、支持結果もlocal cutover候補に限定してsource database、live config、library defaultを変更しない。\n86. outcome-driven feedback deactivation はsoft-startと同時にだけ有効化できるdefault-off candidateとする。provisional / confirmationごとにcredited加算と同時発生したsame-source sibling normalization減算を一つのsigned mutation journalへ永続化し、因果帰属できる`corrected` / `rolled_back`だけが未反転contributionを基礎weight未満へ下げずexact reversalする。`superseded`はedge、evidence、trace、outcomeを削除せずrelation edgeをdormantにして通常activationから除外し、同じ保存済みcredited pathの後続`confirmed`で再活性化する。duplicate、retry、restart、transaction failure、lexical、zero-hop、別source、uncredited edge、因果帰属不能outcomeは二重減算または局所外mutationを行わない。\n87. outcome-driven deactivation evaluation はcontrol / candidate、`corrected` / `rolled_back` / `superseded`、exact credited / sibling inverse、baseline floor、dormancy / reactivation、rank / locality、source isolation、exclusive outputを結果観測前に固定する。protocolはregistered output不在のfreeze-only PRで固定し、そのsquash merge後のsuccessor Issueでdevelopmentを一度だけ実行する。全hard gate通過時だけholdoutを一度開き、観測前後にquery、case、schedule、metric、gate、default、live configを変更しない。\n88. NGR 自身の新規 judgment graph は SQLite の stable identity、revision、lifecycle、provenance、typed relation を machine-readable 正本とする。add / update / supersede / archive / restore / hard-delete candidate は raw SQL でなく atomic domain API を通し、stale revision、dangling relation、部分更新、二重 successor を fail closed にする。archive は通常 retrieval から外す論理的忘却、hard delete は履歴参照のない archived candidate だけに許す物理削除として分離する。current graph の deterministic export / atomic import と SQLite backup / integrity-checked restore を維持し、既存 Wiki entry の本番移行は fixture 検証後に分離する。\n89. Li+ / NGR Decision Structure Wiki pilot は各 repository の index が列挙する entry だけを専用の新規 SQLite へ取り込み、repository namespace 付き identity、page 本文、Wiki URL、repository、取得 commit、source state、typed relation を保持する。duplicate identity、unknown relation target、parser ambiguity、partial publication、既存出力の上書きを fail closed にし、SQLite / supersession integrity、deterministic export、backup を検証する。Wiki、既存検索 DB、凍結済み feedback 実験 DB は変更せず、本 pilot だけで正本を切り替えない。\n90. judgment 専用 read API は `search_judgments`、`get_judgment`、`traverse_judgments` を提供し、current revision、lifecycle、statement、rationale、provenance、typed relation を返す。search は judgment の既存 node projection に同じ lexical / dense scorer と有効 weight を適用し、既定で active のみ、明示指定時だけ archived を含め、repository namespace で絞り込める。traversal は relation type、incoming / outgoing / both、1 以上の有限 hop を受け、cycle-safe な hop 優先・stable identity 順を維持する。三操作と対応 MCP tool は成功時・失敗時とも judgment、revision、relation、retrieval trace、feedback、node、edge、activation を永続変更せず、MCP は read-only annotation、未知 field を拒否する schema、既存 error envelope を持つ。既存 `search`、feedback、`write_judgment`、library default は変更しない。\n91. optional MCP server の永続 database path は明示 `--database`、`NGR_DATABASE`、`~/.ngrdb/knowledge.db` の順で解決し、同一端末・同一ユーザーの複数 AI client が client-neutral な user-owned 正本を共有できる。明示 path と core library の `:memory:` 既定を維持し、file-backed connection だけに WAL と 5 秒の bounded busy timeout を適用する。既存 database は自動移動・統合せず、source / destination / backup をすべて明示し、source integrity、SQLite backup、destination integrity、既存 output 上書き拒否を備えた一回性 migration だけを提供する。network drive、cloud sync、remote deployment は保証範囲外とする。\n92. judgment relation semantics は共有 SQLite の relation type registry、definition revision、namespace、provenance、active / deprecated lifecycle を正本とする。`depends_on`、`refines`、`supersedes`、`conflicts_with`、`informs` は `docs/Decision-Structure.md` と一致する revision 1 として決定的かつ冪等に登録し、既存 relation は内容を失わず `assertion_kind=explicit` と revision 1 binding へ移行する。新規 assertion は登録済み type の current revision を束縛し、未知 type は revision 未束縛の explicit assertion として保存する。active type は warning なし、未知 / deprecated type は structured advisory warning を返すが write を拒否しない。registry write は atomic domain API と `expected_revision` を要求し stale update を fail closed にする。read / validate は成功・失敗とも全 persistent table を変更しない。canonical `judgment_relations` は explicit assertion 専用とし、reasoner、推論結果、taxonomy、OWL / RDF / JSON-LD、query expansion、retrieval score / feedback / activation への意味接続を含めない。export / import、backup / restore、integrity check は registry revision と assertion binding を保持する。\n93. github-rag-mcp / NGR retrieval parity benchmark は、public repository の固定 commit と全 Markdown path、blob / content hash、source URL を共通 document surface とし、development / holdout の gold identity を分離する。direct、semantic、relation、directional negative cohort へ同一 query / filter / top-k を渡し、raw github-rag-mcp search / stored content、NGR rank / MRR / Hit@k / source / path explanation、更新追随を保存する。keyword / graph result は固定 repository / doc type / path surface 外を拒否し、keyword `vector_id` は一意でなければならない。capture、exact claim、result は各 stage 一回だけ exclusive-create し、claim / result の protocol、stage、freeze commit、capture hash、manifest hash registry、raw capture、全 hard gate shape を fail-closed に相互検証して、development の全 hard gate 通過時だけ holdout を開く。freeze commit は第一親に manifest がなく当該 commit で初導入された `origin/main` 上の commit だけを受理し、同じ artifact を含む後続 commit を拒否する。既存 #63 / #64 artifact は参照だけに留め、登録 result のない freeze merge commit を唯一の protocol input とする。latency / resource は初期 hard gate にせず、共有 DB、feedback、default、production service、repository integration を変更しない。\n75. v3 implementation、prompt、manifest、query override、schema、集約、path audit、hash規則、gate、stop rule、testsをresult-free commitでpushした後、development stage / 4 case packet / 12 responses / resultを各一度だけ生成する。\n76. development全12 gate通過時だけholdout stageを一度生成し、異なるfresh 12 judgesで同じgateを評価する。packet、response、resultの上書き、観測後の規則変更、実LLM品質値のCI再生成を拒否する。\n\n## 4. Constraints\n\n- GNN 学習、自動正誤判定、分散実行、GitHub 専用 UI は対象外とする。\n- reinforcement は成功の申告でのみ発火し、単なる retrieval impression を学習信号にしない。\n- MCP SDK、transport、認証、remote deployment はコア要件に含めない。\n- 経路は循環を避け、最大 hop 数と結果ごとの最大説明経路数で計算量を制限する。\n- edge weight の強化には上限を設ける。ただし、既存 weight が上限を超えている場合も強化処理で現在値を引き下げない。\n\n## 5. Acceptance verification\n\n- `python -m unittest discover -s tests -v` が単体テストと統合テストを通過する。\n- `python -m neuron_graph_rag demo` が取り込み、検索、成功フィードバック、再検索を実演する。\n- `python -m neuron_graph_rag eval` が baseline hybrid と graph retrieval の比較指標を出力する。\n- `python -m neuron_graph_rag benchmark --fixture ... --gold ...` が固定実コーパス上の比較、説明経路、feedback isolation、仮説判定を出力する。\n- CI が editable install、test、eval を新規環境で実行する。\n- [Optional MCP Feedback Interface](optional-mcp-interface.md) が tool semantics、input、output、failure、core mapping、依存境界、repository 分離条件を定義する。\n- `tests/fixtures/d1_liplus_wiki.json` が実 D1 形状から ingest、検索、時系列 metadata、graph activation、success feedback を再現する。\n- [D1 corpus fixture](d1-corpus-fixture.md) が read-only 取得、認証境界、provenance、coverage 比較、再取得手順を定義する。\n- [Real-corpus benchmark](real-corpus-benchmark.md) が gold freeze、判定規則、観測結果、外挿限界を定義する。\n- [Neural dynamics experiment](neural-dynamics-experiment.md) が development / holdout 分離、固定探索空間、候補選択、単一 holdout 開封、停止規則を定義する。\n- [Local recurrent competition experiment](neural-dynamics-local-competition-experiment.md) が新規D1 subgraph、contamination audit、query / path ablation、二baseline gateを定義する。\n- [Anchored BM25 and graph hybrid experiment](anchored-bm25-graph-hybrid-experiment.md) がzero-hop anchor、edge-only graph signal、BM25 ablation、新規D1 split、単一holdout gateを定義する。\n- [Anchored fusion calibration experiment](anchored-fusion-calibration-experiment.md) がgraph normalization、bottom-centered RRF、新規D1 split、個別case gateを定義する。\n- [Independent retrieval channels experiment](independent-retrieval-channels-experiment.md) が非融合lane、独立trace provenance、feedback帰属、4-case hard gate、単一holdout開封を定義する。\n- [Blind LLM channel selection experiment](blind-llm-channel-selection-experiment.md) がanswer-free packet、fresh judge分離、majority集約、path射影、result-free freeze、conditional holdoutを定義する。\n- [Node-first blind selection experiment](node-first-blind-selection-experiment.md) がsingle-case invocation、node-majority、channel provenance分離、v1 / v2不変監査、conditional holdoutを定義する。\n- [Trace-credited feedback adaptation experiment](feedback-adaptation-experiment.md) がcontrol / treatmentの因果比較、result-free freeze、conditional holdoutを定義する。\n- [Trace-credited feedback adaptation reproduction experiment](feedback-adaptation-reproduction-experiment.md) が新規D1 split、prior-result非参照、endpoint/type-only path projection、conditional holdoutを定義する。\n- [Engine-backed feedback trajectory experiment](engine-backed-feedback-trajectory-experiment.md) が repository-native controlled corpus v3 上の 0 / 1 / 3 / 10 feedback trajectory、実 relation trace、credited-only mutation、result-free freeze、conditional holdout を定義する。\n- [Sibling relation feedback normalization](sibling-relation-feedback-normalization.md) が opt-in candidate の局所 sibling 正規化、trace isolation、default 変更前の検証境界を定義する。\n- [Sibling normalization controlled evaluation](sibling-normalization-controlled-evaluation.md) が repository-native corpus、result-free hash freeze、実 relation trace feedback、mutation / rollback gate、conditional holdout を定義する。\n- [Historical source verification](historical-source-verification.md) が frozen manifest path の初回追加 commit、明示 full source commit ID、exact blob、ancestor、path、newline portability、fail-closed 境界を定義する。\n- [Confirmed-outcome feedback reinforcement](confirmed-outcome-feedback-reinforcement.md) が confirmed-only と soft-start の明示 policy、永続 schedule、transaction、receipt、default-preserving boundary を定義する。\n- [Soft-start snapshot evaluation](soft-start-snapshot-evaluation.md) が transaction-consistent private snapshot、固定4 arm、result-free freeze、privacy、one-time development、conditional holdout、local cutover境界を定義する。\n- [Outcome-driven feedback deactivation](outcome-driven-feedback-deactivation.md) がsigned contribution journal、exact reversal、dormancy / reactivation、default-off境界、result-free freezeを定義する。\n- [Baseline-aware soft-start snapshot evaluation](baseline-aware-soft-start-snapshot-evaluation.md) がfresh baseline stateからのq3 boundary導出、v1 evidence isolation、capacity preflight、新規one-time result境界を定義する。\n- [Canonical SQLite judgment graph](canonical-sqlite-judgment-graph.md) が judgment source-of-truth、domain write API、logical forgetting、hard-delete boundary、deterministic portability、backup / restore を定義する。\n- [Optional MCP Feedback Interface](optional-mcp-interface.md#shared-database-home) が user-owned database home、path precedence、file-backed concurrency policy、fail-closed migration、local-only 保証境界を定義する。\n- [Decision Wiki pilot migration](decision-wiki-pilot-migration.md) が Li+ / NGR Wiki import の対象境界、identity namespace、provenance、fail-closed 条件、再現手順を定義する。\n- [GitHub RAG / NGR retrieval parity benchmark](github-retrieval-parity-benchmark.md) が固定 public document surface、identity-disjoint query / gold、raw capture provenance、one-time development、conditional holdout、retrieval non-regression gateを定義する。\n"
+ },
+ {
+ "repo": "Liplus-Project/neuron-graph-rag",
+ "path": "docs/specifications/runtime-wheel.md",
+ "url": "https://github.com/Liplus-Project/neuron-graph-rag/blob/main/docs/specifications/runtime-wheel.md",
+ "version": "sha256:5e51153c9e8c75fefaadaa3d3b4b67bbafca8a70d690cd75ce922d4cf285328e",
+ "text": "# 通常 wheel の配布境界\n\n## 目的と前提\n\n通常 wheel は、利用者がインストールして実行する NGR の公開 API、CLI、optional MCP adapter を提供する。checkout に残る凍結済み実験 module は再現証拠の source path を維持する。既存 `docs/requirements.md` は過去の frozen manifest に登録されているため、その bytes を変えず、この追加仕様で配布境界を定義する。\n\n## 要件\n\n1. 通常 wheel は公開 package API、CLI の `demo` / `eval` / `benchmark`、optional MCP adapter と共有ローカル HTTP コマンド、利用ガイドで直接案内する opt-in 検索 API と、その実行に必要な module を含む。MCP SDK 自体は `mcp` extra に限る。\n2. 実験専用 module は通常 wheel から除外する。checkout の legacy import path、frozen manifest、fixture、観測証拠、one-shot runner は維持する。\n3. 配布対象は module 名の印象ではなく、公開 import root、CLI、MCP、文書化された API、動的 import の監査で決める。新しい runtime module の追加は配布リストと隔離 wheel 検証を同じ変更で更新する。\n4. 新規実験専用コードは repository 直下の `experiments/` に置く。既存の凍結済み実験 source file は移動しない。\n5. NGR の既定検索、feedback 設定、観測結果を変更しない。\n\n## 受入検証\n\n- 隔離した通常 wheel の file list に実験専用 module がなく、公開 API と opt-in API、CLI の既存3コマンドが動く。\n- optional MCP の導入環境では、同じ wheel で tool 一覧、検索、source-use feedback、outcome、judgment の書込・検索が動く。\n- checkout の core / 実験 test と CI が通る。配布前後の module 数と source bytes の差を記録する。\n\n配布 module の理由、除外範囲、実測値、利用手順は[通常 wheel と checkout 実験コードの境界](../guides/runtime-wheel-boundary.md)を参照する。\n\nIssue #290 の中央設定・ホーム移行は配布 runtime として `user_config` と `home_migration` を allowlist に含める。optional MCP の `native_registration` も wheel に収録し、checkout 外の import と移行 CLI の help で検証する。\n"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/0-requirements.md",
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/main/docs/0-requirements.md",
+ "version": "sha256:2fbb9191ea766fe7bd09eed5dc3f0d88aefdac0f26e6e72d5f90e7631f91f29a",
+ "text": "# github-rag-mcp Requirements Specification\n\nLanguage: English | [Japanese](0-requirements.ja.md)\n\n## Overview\n\n`github-rag-mcp` is an MCP server that gives AI agents retrieval over GitHub project state.\n\nThe system indexes:\n\n- issues\n- pull requests\n- releases\n- repository documentation\n- commit diffs (judgment history, including changes to deleted files)\n\nThe design goal is not transcript memory. The goal is recoverable project state.\n\nThis project treats GitHub as a shared working memory:\n\n- Issues preserve requirements, open decisions, and task state.\n- Pull requests preserve implementation, review, and CI state.\n- Docs preserve stabilized understanding.\n- Releases preserve shipped checkpoints.\n- Commit diffs preserve the judgment history of what changed and why (including deleted files and non-`.md` extensions).\n\nSearch is used to recover the relevant slice of that state when an agent needs to act.\n\nCounterpart:\n\n- [github-webhook-mcp](https://github.com/Liplus-Project/github-webhook-mcp) provides push-based awareness.\n- `github-rag-mcp` provides retrieval over durable state.\n\n## Principles\n\n### State over complete memory\n\nThe project does not aim to store every conversation or every reasoning trace.\n\nInstead it preserves the state required for the next correct action.\n\n### No unnecessary additions\n\nThe memory layer should not add speculative or decorative information that changes the meaning of the original GitHub artifacts.\n\n### No loss of required information\n\nThe memory layer should not remove the constraints, decisions, and current status that another agent or session needs to continue the work safely.\n\n### Human-visible memory\n\nThe source artifacts remain human-readable and reviewable. Retrieval is an index over those artifacts, not a hidden replacement for them.\n\n## Architecture\n\n```text\nGitHub webhooks + GitHub API\n |\n v\n Cloudflare Worker\n + MCP surface\n + webhook receiver\n + cron poller\n + embedding pipeline\n + hybrid retrieval (dense + sparse + RRF fusion + cross-encoder rerank)\n |\n +--> Vectorize (dense: BGE-M3 1024d, cosine)\n +--> D1 FTS5 (sparse: BM25, porter + trigram)\n +--> Durable Object/SQLite (structured state, watermarks)\n +--> Workers AI BGE-M3 (embedding generation)\n +--> Workers AI bge-reranker-base (cross-encoder rerank)\n```\n\n## Components\n\n### 1. MCP Surface\n\nThe worker exposes MCP tools over HTTP.\n\nResponsibilities:\n\n- accept authenticated MCP requests\n- expose semantic and structured retrieval tools\n- return state in a format that downstream agents can consume directly\n\nWorker Zod contracts are the source of truth. The bridge answers `tools/list` from generated static JSON without startup auth/network. `scripts/generate-tool-contracts.mjs` regenerates those artifacts; `scripts/check-schema-drift.mjs` compares all four tools against their actual served nested input/output schemas, bounds, defaults and annotations in CI. This extends the previous name/enum-only guard (#259).\n\n#### Protocol revision: 2026-07-28, single lane\n\nThe Worker serves MCP protocol revision **2026-07-28 only**. There is no\ncompatibility lane for the 2025 era: a request without the revision's\nper-request `_meta` envelope — including a 2025 `initialize` handshake — is\nanswered with the unsupported-protocol-version error naming the one revision\nthe endpoint serves. The SDK offers a stateless fallback for the older era;\nit is deliberately not enabled (gh#224).\n\nThe reason is not that a fallback would be technically wrong, but that an\nunmaintained one becomes dead code. Its removal condition is observable (no\ntraffic reaches the old lane) yet nobody goes and observes it. The breakage is\nabsorbed on a different channel instead: a minor release plus a README notice.\n\nConsequences of statelessness:\n\n- No session. Each request is served by a server instance built for it alone,\n so nothing is resolved from a session ID and `mcp-session-id` is not issued.\n- User identity comes from the OAuth props of the request being served, read\n through the handler's per-request auth context rather than from an instance\n field.\n- The Durable Objects that once served MCP (`RagMcpAgent`, `RagMcpAgentV2`) are\n out of the serving path. Their classes remain exported as retired stubs\n because Cloudflare requires every class named in a past migration to exist in\n the script; removing them needs a `deleted_classes` migration. `IssueStore`,\n which holds real data, gains independent memory tables in #259.\n\nThe revision is a private contract between this repository's two artifacts. The\nnpx bridge (`mcp-server/`) is the only way to reach the Worker — `server.json`\ndeclares stdio transport alone — so no third-party client negotiates with it,\nand the bridge pins the revision instead of probing for it. The bridge's own\nface toward Claude Desktop stays on SDK v1 / the 2025 era; that side is a\nseparate migration (gh#228).\n\nBecause the Worker is one shared deployment and each bridge is per user, the\nflip is asymmetric: the Worker moves at once, while a bridge follows only when\nits host process restarts. A user pinned to an older bridge version does not\nrecover by restarting. That cohort is explicitly cut off.\n\n### 2. Webhook Receiver\n\nThe webhook receiver ingests GitHub events in near real time.\n\nSupported event families:\n\n- `issues`\n- `pull_request`\n- `release`\n- `push`\n\nResponsibilities:\n\n- verify webhook signatures with `GITHUB_WEBHOOK_SECRET`\n- validate GitHub source IPs\n- route events to the embedding pipeline\n- update both semantic and structured stores\n\n`push` is used to detect changes in all `.md` files across the repository. The same `push` events also drive per-commit diff indexing: each commit produces N vectors (one per file with a textual patch), with embedding input being `commit message + file path + patch`. This surface makes deleted files and non-`.md` extensions searchable as judgment history.\n\n**Doc deletions on the `push` path** tear down the same three surfaces the cron reap does — Vectorize, D1 FTS5, and the structured store — each independently, so one failing surface cannot strand the others (issue #206). Graph edges are again not torn down, for the same reason as the cron reap: a doc vector ID is never a `doc_edges` endpoint. Unlike the cron reap there is no per-run deletion cap: a push payload names its own removals, so the loop is bounded by the event rather than by an accumulated backlog. The `deleted` count in the response body counts docs whose three surfaces all came down, so `removed - deleted` is the partial-teardown count visible in the delivery log; the cron reap's counter instead counts attempts, because there it doubles as the budget counter that gates the ETag hold.\n\n### 3. Cron Poller\n\nThe cron poller is the fallback path.\n\nResponsibilities:\n\n- repair missed webhook deliveries\n- backfill new repositories\n- refresh changed issues, pull requests, releases, docs, GitHub Wiki pages, issue/PR comments, and commit diffs\n- keep the stores converged even after transient failures\n\nThe poller runs hourly in the current deployment, split across four cron triggers so each invocation gets its own Cloudflare Workers subrequest budget. Each upsert fans out to Store DO + Vectorize + D1 FTS + AI embed (up to four internal subrequests), so even the comments + diffs combination overshoots the per-Worker ceiling on busy repositories; the heavy surfaces run one-per-cron, staggered by 15 minutes:\n\n- **`0 * * * *` (light)** — issues, pull requests, releases, docs\n- **`15 * * * *` (comments)** — issue / PR comments only\n- **`30 * * * *` (diffs)** — commit diffs only\n- **`45 * * * *` (wiki)** — GitHub Wiki pages only\n\nDispatch is performed inside `handleScheduled` by inspecting `controller.cron`. Unknown cron expressions fall through to a no-op log to prevent silent regressions when triggers are added later.\n\n**The ETag hold.** Three surfaces make conditional requests — issue / PR (`If-None-Match` on the issues list), releases (on the releases list), and docs (on the repository tree) — and one rule governs all three: **a run that left work behind does not store the ETag it just received.** Work is left behind whenever an item did not reach the retrieval surfaces, whatever the reason — a per-run cap deferred it, or its embed failed. Either way the item carries a retry marker (an empty `bodyHash`, or a `blobSha` the store never advanced) and needs the next run to look at it again; a stored ETag makes that run's conditional request answer 304 and return before it does, leaving the marked item to wait for some unrelated item on the same surface to change. `lastPolledAt` still advances, so a holding run stays observable.\n\nThe rule is deliberately one condition rather than three, because per-surface conditions drift apart: the releases branch watched its upsert cap alone and the docs branch watched its two caps, so on both an embed failure still refreshed the ETag and stalled the retry (issue #211). The issue / PR surface reached the general form first (issue #215). *Holding* is expressed as \"keep the previously stored ETag\" on the releases and docs surfaces and as \"store none\" on the issue / PR surface; the two are equivalent, because a run that received a 200 for the stored ETag has proved the surface already differs from it, so the next request is answered 200 either way.\n\nThe remaining ingestion surfaces are outside this rule by construction, not by omission: the comment poller issues no conditional request (it re-walks the most recent parents every run), and the wiki poller's `etag` watermark column holds its walk cursor rather than an HTTP ETag.\n\nThe issue / PR poller fetches `(lastPolledAt, now]` sorted by `updated_at` ascending, at most `MAX_PAGES_PER_RUN` × 100 = 200 items, and embeds at most `MAX_EMBEDDINGS_PER_RUN` = 50 of them. It obeys the same watermark invariant as the commit-diff surface: **the watermark never advances past the earliest item the run left off the retrieval surfaces** — deferred by the embedding budget, or failed to embed. The two bounds are separate: how far the fetch reached (the poll start time, or the last fetched item when pagination capped) is an upper bound, and the ingest boundary pins the watermark below it, one second earlier so GitHub's `since` filter still re-includes the boundary item.\n\nWithout that pin the surface leaked at a rate of fetched-minus-embedded per run. Both of the old watermark branches landed *above* every deferred item — the batch is ascending, so the budget always runs out on its newest end — and the deferred item's empty `bodyHash` marked it for a retry that no later `since` window would ever fetch. Measured 2026-08-03, that left about 55% of the issue and pull request history of the indexed repositories absent from the index, scattered rather than in contiguous ranges because the loss follows `updated_at` order and not number order (issue #210). **The ETag is withheld on the same condition**, per the ETag hold above.\n\nThe releases poller reads the whole releases list in one conditional request and upserts at most `MAX_RELEASE_UPSERTS_PER_REPO_PER_RUN` = 10 of them per repo per run. It needs no watermark of its own — the list is bounded and re-read in full — so the ETag hold is its only leftover mechanism: releases the cap deferred, and releases whose embed failed, are both stored with an empty `bodyHash` and are reachable next run only because the ETag was held (issue #149 / #211).\n\nThe commit-diff poller runs in two phases:\n\n- **forward phase** — enumerates the window `(lastPolledAt, pollStartTime]` and ingests its **oldest** commits first, acting as redundancy when webhook delivery has stalled. Watermark namespace: `diffs:${repo}`.\n- **backward phase** — walks backward through history using `until=oldestUnprocessedDate`, backfilling commits that predate the webhook or a fresh deployment. Watermark namespace: `diffs_backfill:${repo}`.\n\nEach phase is bounded on two axes per repo per run: at most 5 commits, and at most `diffFileBudgetPerPhase` files. Upserts through `processAndUpsertCommitDiff` are idempotent on `(repo, commit_sha, file_path)`, so overlap between webhook and either phase is safe.\n\nA commit count does not bound what a phase spends. Each indexed file costs 3 subrequests worst case — the D1 FTS mirror write, the store row, and the amortised share of its embed batch (a batch spends 2 and holds at least 2 files, since `MAX_EMBEDDING_INPUT_CHARS` caps an input at 8000 characters, which is at most 24000 UTF-8 bytes, and the endpoint charges a batch its input count times its longest input: 2 × 24003 = 48006 against a ceiling of 60000) — and one commit carries up to 300 files, so 5 commits span two orders of magnitude of cost. The measured consequence: a 44-file commit in the last repo of `POLL_REPOS` had all three of its embed batches rejected with `Too many subrequests by single Worker invocation` while the repos ahead of it in the loop indexed normally, and the invariant below then held its watermark on that commit — the same deterministic, cron-after-cron stall the token axis produced, on the file axis (issue #238).\n\nThe diff surface therefore declares its share of the invocation ceiling explicitly: `DIFF_SUBREQUEST_BUDGET_PER_RUN` = 900 of the 1000 Cloudflare allows. The diffs cron is the surface's own invocation, so that ceiling is not shared with docs / wiki / issues / releases — but it *is* shared across every repo in `POLL_REPOS` and both phases, which is the sharing that produced the failure above. `diffFileBudgetPerPhase` divides the declared share by `repoCount × 2` and converts the remainder to files. Deriving it from the repo list rather than fixing a literal is deliberate: `POLL_REPOS` grows by ordinary config commits (issue #233 appended the sixth repo), and a literal sized against the list of the day overruns the ceiling on the next append, surfacing as failures on whichever repo the loop reaches last rather than on the change that caused them. A floor of 5 files holds under a repo list long enough to divide the budget below it — an overrun is retried by the next cron, whereas a budget of zero stalls every diff watermark permanently, which is the failure being removed.\n\nThe file budget can split one commit across runs. Nothing is thinned: a run indexes a prefix of the files still outstanding, and the next run reads the already-indexed paths back from the structured store (`GET /diffs`) and continues from there. That read is what makes the split converge — without it a bounded run re-indexes the same leading files every cron tick and the commit never finishes. It is spent only on a commit whose file count exceeds the budget, so an ordinary commit costs nothing to find out, and a store error on it counts the commit as failed rather than as \"nothing indexed yet\", which would silently restart it.\n\nBoth phases obey one watermark invariant: **a watermark never moves past a commit that has not been successfully ingested.** A commit counts as ingested only when its detail fetch succeeded and `processAndUpsertCommitDiff` reported zero failed files *and* no files left outstanding — embedding and Vectorize failures are reported by return value rather than by throwing, so the return value is part of the check. The criterion is the dense side only: a D1 FTS mirror or store-row failure is logged by the pipeline and deliberately not counted, since the vector has already landed and the sparse index reconciles on reindex; gating the watermark on the mirror would let an FTS-side outage stall the whole diff surface. Consequences:\n\n- a failed commit, a commit the file budget only partly indexed, and every commit either per-run cap deferred, all stay inside the next run's window. A partly-indexed commit is not an error — it is mid-split, and holding the watermark is what keeps the rest of its files reachable\n- a failed commit list leaves the watermark untouched, so the whole period stays retryable\n- the forward phase must know the oldest end of its window before advancing, so the window is enumerated with a 100-entry page; a full page means \"not enumerable\" and the window end is halved (at most 8 times) until one page covers it. If even then the window overflows, the run holds the watermark and logs — a visible stall is preferred over a silent gap.\n\nThe tradeoff is liveness: a commit that fails on every attempt blocks its phase's watermark. The run log names the boundary commit, and `POST /admin/diff-watermark` (see the installation guide) moves the watermark manually — the same endpoint used to replay a period whose commits were lost by an earlier version of the poller.\n\nThe docs poller reads the repository tree with a conditional `If-None-Match` request and diffs it against the stored doc records: entries whose blob SHA moved are re-embedded, entries present in the store but absent from the tree are reaped. **The reap tears down three surfaces** — Vectorize, D1 FTS5, and the structured store — each independently, so a Vectorize failure cannot strand the D1 rows users actually retrieve. It is three and not the wiki reap's four because a doc vector ID is never a `doc_edges` endpoint: `indexWikiEdges` is the only writer and both the source and the computed destination IDs are wiki vector IDs. Add the edge teardown here if that invariant changes (issue #203). The reap reaches only the current vector-ID generation; the pre-migration generation is unreachable by construction and is cleaned out separately (see Vector Store, \"Pre-migration generation\").\n\n**Reap budget.** The reap is capped at `MAX_DOC_DELETIONS_PER_REPO_PER_RUN` (default 5) per repo per run, the same guard the wiki reap carries: at 3 subrequests per deletion an unbounded loop over a mass deletion could exhaust the light cron's invocation budget on its own and starve every repo behind it — a single PR removing 66 `.md` files puts all of them on one run. The drain is monotonic, since a reaped doc's store row is gone and the leftover set only shrinks. This surface therefore has three ways to leave work behind, and **any one of them holds the tree ETag back** per the ETag hold above: the fetch cap leaves changed docs unprocessed (issue #149), the delete cap leaves deletions unreaped (issue #203), and a failed embed leaves a doc whose store row still carries the old `blobSha` (issue #211).\n\nNeither the docs nor the releases loop carries an embedding cap of its own. Their fan-out caps (10 each) sit below `MAX_EMBEDDINGS_PER_RUN` = 50 and bound the embed count by construction, so the guards those loops used to hold were unreachable — and unreachable code left the reader unable to tell whether it also owed the ETag hold, which is how the issue #211 hole read as ambiguous rather than as a defect. **That constant relation is asserted by a test**, not left to the comments at the cap sites: fan-out caps on this repo get retuned (issue #134 took the comment fetch cap from 30 to 10), and raising one past 50 would otherwise remove the surface's embed bound in silence. A failing assertion is not a verdict that the new cap is wrong — it says the loop now needs its embedding guard back, folded into that loop's ETag-hold condition so a deferred item still holds the ETag.\n\nThe wiki poller runs in the `:45` cron and is the only ingestion path for GitHub Wiki content. Wiki pages live in a separate git repo (`{repo}.wiki.git`) that GitHub does not expose through the REST API or webhook events; the poller therefore performs three lightweight HTTP calls per repo:\n\n1. Probe `https://github.com/{repo}.wiki.git/info/refs?service=git-upload-pack` to detect whether the wiki exists (200 = present, 404 = disabled or absent — skip the rest).\n2. Scrape the public `/{repo}/wiki/_pages` HTML index for page slugs (the link href shape `/{repo}/wiki/{slug}` is stable enough for a tolerant regex; underscore-prefixed pseudo-pages like `_pages` / `_history` / `_new` are filtered out). Two details of the index are load-bearing (issue #184): `Home` is never listed even though it exists, so it is unioned into the enumeration unconditionally; and the slug is a *lossy* rendering of the page title (`E. Li+language` routes to `E.-Li-language` while the file is `E.-Li+language.md`), so the link text is kept as a second filename candidate.\n3. For each page, fetch the raw markup body from `https://raw.githubusercontent.com/wiki/{repo}/{name}.{ext}`, where `name` is the title-derived filename first and the slug second. Markup extension is detected via probe order (`md` → `markdown`); subsequent polls reuse the previously found extension to skip the probe. The narrowed probe set is deliberate — every miss spends one of the invocation's 1000 subrequests (issue #130).\n\nChange detection uses a content SHA-256 hash (no git blob SHA is available without invoking the wiki git smart-HTTP protocol). Pages whose hash differs from the stored value are re-embedded.\n\n**Coverage.** The page walk is bounded by `MAX_WIKI_FETCHES_PER_REPO_PER_RUN` (default 20), counted per *HTTP attempt* rather than per page so a page needing several candidates cannot exceed the ceiling by fanning out. Because the budget is smaller than a deep wiki, the walk is **circular and resumes from a stored cursor** — the last slug probed, held in the `wiki:{repo}` watermark row's `etag` column, so no schema change was needed. Every page is therefore reached within ceil(pages / budget) runs. Restarting at the head of the enumeration each run is what made pages past position ~20 structurally unreachable and left most of a 77-page wiki unindexed (issue #184). `MAX_WIKI_EMBEDDINGS_PER_RUN` (default 30) remains a separate axis: it caps the Workers AI embed budget, not the walk.\n\n**Run-wide budget.** Every budget named above is *per repo*, and the wiki cron walks all of `POLL_REPOS` inside one invocation, so each repo kept its own contract while nothing watched the sum — and the sum is what Cloudflare charges. Measured on the 2026-08-15T03:45Z tick: five repos indexed normally and the sixth had every raw-content probe rejected with `Too many subrequests by single Worker invocation`, reporting 8 failures for pages nobody had looked at. The starvation lands wholly on whichever repo the loop reaches last, which is a property of the loop and not of the repo. `WIKI_SUBREQUEST_BUDGET_PER_RUN` therefore declares the surface's share of the invocation, `wikiFetchBudgetForPass` converts what is left of it into the pass's fetch cap, and `runWikiSurfaces` subtracts each pass's *measured* spend before admitting the next repo. Subtraction rather than division — the diff surface's `diffFileBudgetPerPhase` divides, because its phases are bounded per repo up front, whereas a wiki walk's cost is only known once it has run, so a repo that finds nothing changed hands its share on almost whole. When the remainder can no longer fund a pass, the remaining repos are **not called at all**: their cursors stay where the previous run left them and the next invocation resumes there. That is the same \"hit the ceiling, keep the cursor, finish clean\" behaviour a single deep wiki already had on the per-repo fetch budget, lifted to the run axis. The reap's own budgets are charged in full when spent but deliberately *not* reserved at admission — reserving each repo's worst case (50) against a share of 250 would tax every pass for work the reap almost never does, and would defer repos on a run where nothing happened at all.\n\n**The accounting unit is not the one Cloudflare charges, and the share alone does not fix the observed case.** The constants are read off call sites — one per `fetch`, per Durable Object round trip, per D1 statement, per Vectorize call, per Workers AI call. Two things follow, and the second is the one that matters. First, the gap: in these units the failing run had spent between ~70 and ~292 when the sixth repo's first probe threw, against a documented ceiling of 1000, and nothing visible in the poller explains the difference — at least one binding costs more than the 1 this accounting gives it, and Workers expose no runtime counter to measure it with, so no constant here can be calibrated against the real limit. Second, the range above is not measurement error but the cost profile itself: an unchanged page is charged only its fetch attempts, because the embed fan-out is added past the hash-comparison, so a steady-state run over already-indexed wikis costs a fraction of a bulk import over the same page counts. **On the steady-state profile — the normal shape of an hourly cron, and the shape that actually threw — a share of 250 defers nothing and the run proceeds in exactly the order that failed.** The share is therefore a bound on the bulk-import shape, where each fetched page carries the embed fan-out and a handful of repos reaches 250 quickly; it is not the mechanism that addresses the reported failure. `DIFF_SUBREQUEST_BUDGET_PER_RUN` is 900 because the diff surface's per-file estimate was derived against measured rejections; this surface has no such derivation, so its value is a judgement inside the observed range rather than a calibration. A regression test pins the negative result so it cannot quietly stop being true.\n\n**What does cover it: the wall is observable.** Every budget above is an estimate, but the limit being reached is not — the binding throws and names itself, which is a measurement rather than a guess. Exhaustion is invocation-scoped and terminal: once one subrequest is rejected, every later one in the same invocation is too, which is why the observed run kept throwing probe after probe while the walk kept converting those throws into recorded page failures. So the first sighting ends the run. The page being probed is left unobserved with the cursor unmoved, the reap is skipped wholesale (its probes would hit the same wall, and a withheld reap costs nothing because the candidate set only shrinks), and every remaining repo is deferred untouched. What this cannot do is prevent the *first* rejection — that is the detection event — so the literal \"no `Too many subrequests` in the log\" is not achievable without knowing the real per-binding cost. What is achieved is that one rejection no longer becomes a cascade of false page failures on whichever repo the loop reached last.\n\n**Run order rotates.** A run-wide budget spent in list order thins the same tail every cron — the original defect with its overrun bounded, since loop position would still decide the outcome. `rotateReposForRun` offsets the walk order by the tick, so with an hourly cron and N repos each repo leads once every N hours and a repo deferred by an exhausted share is served early on a later tick. The offset is derived from the cron's `scheduledTime` rather than stored, so it needs no watermark and stays deterministic for a given tick.\n\n**Observed and unobserved misses are separate counters.** A page whose every candidate answered 404 is an observation and counts as `failed`. A page whose probes *threw* is not — subrequest exhaustion throws — and counts as `inconclusive` instead. Both were reported identically under the literal \"all candidates 404\", which is how eight pages that were present read as missing. `inconclusive` still advances the cursor: the page is retried on the next lap, whereas holding it would let one permanently unreachable page stall the walk.\n\n**The budget yields to the first page.** A probe the budget cuts short is not an observed 404, so the walk breaks *before* the cursor moves and retries the page next pass (issue #185). That self-heals only when the next pass can afford the page's whole candidate list; a budget below it re-probes the same page forever and the walk never advances (issue #192). So while the pass has visited nothing, the candidate list outranks the budget: the first page always runs its probes to the end, and a miss there is a real all-candidate 404 that counts as a failure and moves the cursor. Every page after the first keeps the strict check. The overrun is bounded by one page's candidate count minus one — at most 3 subrequests, once per pass — and it is the only case where a pass's `fetches` may exceed its budget. What absorbs it is the margin under the run-wide share below, not the documented invocation ceiling this sentence used to invoke; a pass is never admitted at all unless the share can fund a page for it.\n\n**Lap completion.** A second watermark row, `wiki-lap:{repo}`, holds the *lap anchor* — the slug the current sweep started after. The pass reports `wrapped: true` when it reaches the page immediately before the anchor, i.e. when the cursor has come all the way back around, and the anchor then moves to that page so the next lap starts after it. This is what makes the admin endpoint's `done` reachable: reporting \"this one pass saw every page\" can never be true for a wiki with more pages than one pass may fetch, so the documented \"call until `done`\" loop had no terminating condition (issue #188). The anchor is resolved by slug order rather than by a stored index, so a page added or deleted mid-lap cannot desync it. An explicit `cursor=` override opens a fresh lap at that point.\n\n**Orphan reap.** Pages absent from the current `_pages` index are removed from Vectorize, D1 FTS5, the graph edge table, and the structured state store. The candidate set is the union of the structured store *and* the live `search_docs` rows: a page missing from the store but still present in the index is invisible to a store-only diff, which is how eight renamed-away pages stayed resolvable in search for months (issue #184). Four guards bound the blast radius — reaping is skipped entirely when the `_pages` index could not be read (an empty slug set means \"we are blind\", not \"everything was deleted\"), every remaining candidate is probed for existence before it is deleted (below), the pass is capped per repo per run on two separate budgets — `MAX_WIKI_DELETIONS_PER_REPO_PER_RUN` (default 5) for deletes and `MAX_WIKI_REAP_PROBES_PER_REPO_PER_RUN` (default 15) for probes — and each surface is torn down independently so a Vectorize failure cannot strand the D1 rows users actually retrieve.\n\n**Pre-reap existence probe.** The index-unreadable guard covers a *total* enumeration failure. A **partial** one — `_pages` returning fewer pages than the wiki holds — is indistinguishable from a real deletion at the set level, and pushes live pages into the reap set; the per-run cap slows that but does not stop it, because the cron repeats. So the check leaves the set and addresses the page: each candidate's raw content is fetched, and only an observed 404 on every candidate extension authorizes the delete (issue #187). A 200, a network error, or any other status withholds it and is reported as `orphansWithheld`; withholding cannot deadlock, since a page that really was deleted keeps answering 404 and drains on a later run. The verdict is per page, so a legitimate bulk deletion still drains at the normal rate. The probe addresses `raw.githubusercontent`, not the rendered `github.com/{repo}/wiki/{slug}` URL: measured 2026-08-01, a nonexistent page there 302-redirects to the wiki root and lands on 200, so it can never report absence. Cost is at most one subrequest per candidate per extension (`md`, `markdown`), spent outside the walk's fetch budget and bounded by the probe budget at 30 per repo per run. **Withholding spends a probe, not a delete slot.** Charging it to the delete budget was correct for the index but wrong for the drain: the candidate list is stably sorted, so while an enumeration stays short the same withheld heads fill the delete budget on every run and a page that really was deleted, ordering behind them, is never reached until the enumeration recovers (issue #197). So the loop walks the whole candidate list and stops on whichever budget runs out first, and `orphansDeferred` reports what it never reached rather than what fell past the delete cap. Residual gap, unchanged from before the guard: a page stored under a title-derived filename (`E.-Li-language` → `E.-Li+language.md`) answers 404 to its own slug, and the link text that carries the filename only exists in the enumeration the page is missing from. A separate warn-only log fires when the candidate set reaches half the indexed page set — a ratio cannot separate a bulk cleanup from a short enumeration, so it decides nothing and only leaves the shape of the run readable.\n\n**Immediate recovery.** `POST /admin/backfill-wiki?repo=owner/repo[&limit=N][&cursor=SLUG]` runs the same pass on demand with an explicit fetch budget (1..40, default 20), sharing the cron's cursor so the two advance one another. Call it repeatedly until the response reports `done: true` — one lap takes ceil(pages / limit) calls, not one; pass an empty `cursor=` to restart the walk (and the lap) from the head. Each call is its own Worker invocation and therefore gets its own subrequest budget.\n\n### 4. Embedding Pipeline\n\nEmbedding model:\n\n- `@cf/baai/bge-m3`\n\nCurrent implementation:\n\n- 1024 dimensions\n- cosine similarity\n- body hash comparison for issue and release change detection\n- blob SHA comparison for documentation change detection\n- commit diffs are append-only: once `(commit_sha, file_path)` is indexed it is not recomputed\n\nResponsibilities:\n\n- prepare embedding input from title + body or path + content\n- skip unchanged records when safe\n- upsert vectors with metadata into Vectorize\n- mirror the same content into the D1 FTS5 `search_docs` table for the sparse (BM25) side, choosing tokenizer_kind `nat` or `code` by surface type\n- keep retryable failures detectable on the next run\n- D1 FTS5 upsert failures do not invalidate a successful Vectorize upsert on the embed path; the stored bodyHash drives the next attempt and the next reindex reconciles the sparse side\n- on the metadata-only path (state / labels / milestone / assignees changed, body did not) a failed mirror write is **not** best-effort: the diff baseline is held so the next poll or webhook delivery retries. The baseline is the IssueStore record itself, so advancing it past a failed mirror makes the miss permanent — a state-only change never brings the body change the embed path waits for (issue #209)\n- the dense and sparse mirrors on that path are written independently: a row with no vector (issue #210) still gets its sparse state updated\n- for commit diffs: batch-embed a commit's file list through Workers AI (`text: string[]`) and upsert each batch's N vectors in one `VECTORIZE.upsert` call\n- batches are cut on what the endpoint charges a call — its input count times its longest input — not on the sum of the inputs, not on a file count, not on an estimated token count, and not on a character count. The ceiling is the endpoint's per-call context, not bge-m3's documented 8192-token per-input maximum, and it is unpublished, so `WORKERS_AI_BATCH_CONTEXT_LIMIT` records it from the rejections (`3030: Max context reached 60678 tokens but model supports only 60000`). What those rejections count is `count × longest`, not a sum: four of them divide exactly by their input counts — 18 × 3371 = 60678, 17 × 3789 = 64413, 20 × 4296 = 85920, 16 × 4296 = 68736 — and the last two are the same commit sent at different counts with the per-input quotient unchanged, which a true sum could only produce if the 4 dropped inputs had been identical in length. The endpoint pads every slot of a batch to its longest member and bills the padded width across the whole count. Bytes remain the unit each input is measured in, because they *dominate* the token count rather than approximating it: byte fallback is the finest split the tokenizer can make — a character the vocabulary lacks is decomposed into the bytes of its UTF-8 encoding — so no token spans less than one byte of the input, and one input's cost is its byte length plus `TOKEN_OVERHEAD_PER_INPUT`. That overhead is 3: the two sentinels the model wraps around each input (`` … ``), plus the SentencePiece word-boundary marker, which is emitted as a token of its own where it does not merge into the first piece and so consumes no byte. It is charged per input rather than as one flat reserve because nothing bounds a batch's input count, and many tiny inputs — each carrying its own three — is where a flat reserve comes up short. The three budgets before this one all bounded the *sum* of the inputs, in three successive units, and the endpoint kept rejecting the same batches at the same figure. The first estimated tokens, reading ASCII at 3 characters per token while bge-m3 splits a diff patch nearer 1.4 — `+`/`-` prefixes, indentation, punctuation and short identifiers all tokenize small — so it ran about 2.1x optimistic and passed 60678- and 64413-token batches as fitting a 30000-token budget (issue #241). The second charged characters, on the premise that a token spans at least one character; byte fallback is exactly the case that breaks it, since one 3-byte Japanese character can cost 3 tokens, and production answered a batch charged at most 60000 characters with 68736 tokens — 1.146 tokens per character (issue #244). The third charged UTF-8 bytes, which are below every split the tokenizer can make and so were not optimistic at all — and the reported figure did not move by a single token, because the sum never appears in what the endpoint bills, whatever unit it is summed in (issue #246). What is left assumed on the per-input axis is that the tokenizer's NFKC normalization does not expand the input in bytes, which holds except for compatibility characters that decompose into several (Arabic ligatures, CJK square abbreviations) — a different class of exposure from the retired premises, which broke on ordinary diff text, where this one needs an input made predominantly of one rare block. A commit that goes over fails its whole chunk, and a commit whose vectors never landed is one the diff watermark holds on (issue #178), so the surface stalls there permanently instead of passing it by — deterministically, on the same commit every cron tick, which is what separates it from the transient subrequest overruns sharing the log (issue #236). Two properties follow from the padding that no sum axis has: the largest input sets the unit price for the whole batch, so a single large input among small ones is charged as if all of them were large, and grouping inputs of similar size is therefore what makes a batch efficient. Inputs are nonetheless taken in the order given, without sorting — the caller slices its parallel arrays (files, metadata) on the returned boundaries, and the position-for-position correspondence between inputs and returned vectors is what the upsert depends on, so sorting would buy fewer calls at the cost of an index-set return type and a permutation the caller has to carry, and it buys nothing against the worst case the file budget above is sized on. No margin is held under the ceiling: a bound that holds by construction has nothing left for a margin to buy, and every extra batch spends two subrequests on an invocation budget this worker already overruns. Equality with the ceiling is admissible, since the rejections name charges strictly above it. An input whose own cost exceeds the budget is sent alone — cutting it down further is the truncation axis (`MAX_EMBEDDING_INPUT_CHARS`), and dropping it would lose the file from the index. That same truncation caps one input at 8000 characters, which is at most 24000 bytes, so `2 × 24003 = 48006` fits and a batch holds at least 2 files whatever the payload is made of, which is the floor the poller's per-file subrequest estimate rests on. That floor is unchanged by the move off the sum: the two formulas agree exactly on a batch of equal inputs, which is the case it is read off, and the file budget above still covers it at exactly 3 subrequests per file\n\n**Missing-entry repair.** The watermark fix stops the leak but does not fill the hole: a stranded item is only re-fetched when its `updated_at` moves, and closed history never moves again. `POST /admin/backfill-issue-index?repo=owner/repo` (see the installation guide) walks the gap directly — the repository's issue-number space is dense and bounded, so the numbers with no `search_docs` issue / PR row are exactly the missing set, and a numeric cursor states how far the sweep has reached. A timestamp cursor over the same set would reintroduce the ordering the defect exploited. Numbers GitHub no longer has (deleted or transferred) answer 404 and are counted rather than retried. The ingest is forced past the body-hash check: every candidate is known to be missing a retrieval surface, and a matching hash — which an embed whose FTS5 mirror failed leaves behind — would otherwise skip it permanently. Unlike the state repair this one embeds, so every candidate carries the full ingest fan-out and the caller drives the sweep one batch at a time; `dry_run=true` measures the gap without spending it. The sweep obeys the same invariant as the poller's watermark: **the cursor never advances past the first candidate a call failed to ingest**, so a per-call limit set too high costs a wasted call rather than a missed item, and the result does not depend on how accurately that limit models the subrequest budget (issue #216). A number GitHub no longer has is exempt — nothing will ever ingest it, so holding there would stall the sweep instead of bounding a retry. The tradeoff is the poller's: a candidate that fails on every attempt stops the sweep. Here that is visible rather than silent (`nextCursor` comes back equal to the `cursor` passed in), and because a human or an AI drives this endpoint rather than cron, stepping over the blocking number is a matter of passing the next `cursor` by hand.\n\n**Stale-state repair.** Rows left behind by the ordering defect above keep answering searches as live items, and no ordinary poll reaches them: their diff baseline already matches GitHub. `POST /admin/backfill-issue-state?repo=owner/repo` (see the installation guide) reconciles them per repository — one paginated `state=open` listing supplies the truth, indexed `open` rows absent from it are set to `closed` on both sides, and nothing is re-embedded (the dense side re-upserts the existing values with only `state` replaced). The repair is one-way (`open` → `closed`), which is the direction the defect produced; it aborts rather than act on a truncated open listing, since absence from that listing is what marks a row closed.\n\n### 5. Vector Store (Dense)\n\nVectorize is the dense side of hybrid retrieval. It stores semantic embeddings and metadata for:\n\n- repository\n- item type (`issue` / `pull_request` / `release` / `doc` / `wiki_doc` / `diff` / `issue_comment` / `pr_review` / `pr_review_comment`)\n- state\n- labels (individual slots label_0..3 + CSV fallback)\n- milestone\n- assignees (individual slots assignee_0..1 + CSV fallback)\n- update timestamp\n- release tag name\n- documentation path (doc rows)\n- wiki page slug + extension (wiki_doc rows)\n- commit SHA, file path, file status, commit date, commit author, blob SHA (diff only)\n\nFresh-install metadata index allocation (10/10 platform slots):\n\n- Pre-filter capable: repo, type, state, milestone, doc_path\n- Stored for future pre-filter: label_0, label_1, label_2, label_3, assignee_0\n\n`assignee_1` remains stored in vector metadata and remains available to the current post-filter, but has no metadata index. The ten-index platform ceiling makes the index allocation a priority choice: `doc_path` replaced the unused future `assignee_1` pre-filter slot so directory-scoped document retrieval can narrow the dense candidate population before ranking.\n\nVectorize metadata filters support AND between fields only, not OR. A query like `label_0 = \"bug\" OR label_1 = \"bug\"` cannot be expressed. Labels and assignees therefore remain post-filtered via overfetch strategy. When Vectorize adds OR or `$in`-across-fields support, the expanded fields are immediately usable for pre-filtering.\n\n**Vector ID scheme.** IDs are `{prefix}:{base64url(sha256(parts joined by NUL))}` — a short per-surface prefix plus a 43-char digest, 45–46 bytes against Vectorize's 64-byte ID cap. The hash exists because the earlier plain-text scheme (`{repo}#doc-{path}`) overflowed that cap on long paths; it was replaced in April 2026 (`215e2e2` / PR #84).\n\n**Pre-migration generation.** Vectors written under the plain-text scheme are unreachable from every delete path, since all of them compute the current ID. They stay in the index, answer dense queries with pre-migration content, and take a candidate slot away from the live row for the same file — deleting the file reaps only the current-generation row (issue #204). `POST /admin/purge-legacy-vectors?repo=owner/repo` (see the installation guide) closes this out per repository: the legacy ID is deterministic from the path, so the orphan set is rebuilt from the repository tree — plus an explicit `paths` list for files already deleted, which nothing left in the worker can enumerate — and deleted in capped batches, with no re-embedding. Only rebuilt legacy IDs are ever passed to the delete call, and IDs over the 64-byte cap are dropped rather than sent, since the cap rejected them at upsert time. The residue is closed rather than growing (the legacy scheme stopped writing at the migration), so the purge is one-off per repository, not a recurring job.\n\nThe vector store is the semantic retrieval layer, not the canonical state store.\n\n### 6. Full-Text Index (Sparse, D1 FTS5 / BM25)\n\nD1's FTS5 virtual tables provide the sparse side of hybrid retrieval.\n\nRationale:\n\n- Dense-only retrieval has known recall weakness on sparse-information queries (code identifiers, proper nouns, SHA prefixes, exact terms).\n- As of 2026, hybrid search is the production baseline in the industry; rerankers are a further optional layer on top.\n- Cloudflare D1 is pre-compiled with SQLite FTS5 including the BM25 ranking function, and the `fts5` virtual table module is usable directly from Workers.\n\nSchema overview:\n\n- `search_docs` — external content table (source of truth, `vector_id` primary key). It carries the tokenizable text twice: `content` is the raw text, `content_fts` is the word-segmented form.\n- `search_docs_nat_fts_v3` — active FTS5 virtual table with porter + unicode61 tokenizer for natural-language surfaces (issue / PR / release / doc / wiki_doc / comment / review surfaces). Its external content is `content_fts`.\n- `search_docs_code_fts_v2` — active FTS5 virtual table with trigram tokenizer for code / SHA / identifier surfaces (diff). Its external content is the raw `content`. Superseded generations (nat v2, and both v1 tables) are retained but never queried or updated so a previously corrupt index is not touched during recovery.\n\nTokenizer selection:\n\n- `porter` — stem-based matching appropriate for natural language.\n- `trigram` — substring matching appropriate for SHA prefixes, CamelCase tokens, and file paths.\n- A `tokenizer_kind` column on `search_docs` decides which virtual table a row is indexed in.\n- Each active FTS table declares a tokenizer-filtered view of `search_docs` as its external-content relation, so the declared content rows and indexed rows are the same set. Triggers cascade inserts / updates / deletes automatically, and every branch is guarded by `tokenizer_kind`: a row is inserted into and deleted from exactly one FTS5 table. Sending an FTS5 `delete` command to the opposite tokenizer for a row it never indexed is forbidden because it corrupts the external-content index.\n- Recovery creates a fresh FTS generation and backfills each table with a `WHERE tokenizer_kind = ...` filter. It does not use FTS5 `rebuild`, because `rebuild` would copy every `search_docs` row into each tokenizer and violate the same isolation invariant.\n\nWord segmentation (natural-language side):\n\n- `unicode61` breaks tokens on non-alphanumeric characters only. Japanese has no such separators, so an entire run of kana/kanji is indexed as one token and a Japanese phrase query matches nothing — hybrid retrieval silently degraded to dense-only for the majority of this corpus.\n- D1 cannot load a custom tokenizer: `tokenize = 'icu'` fails with `no such tokenizer: icu`. The word boundaries therefore have to be inserted before the text reaches SQLite.\n- `Intl.Segmenter` (available in workerd, no dependency) does that split. It runs on both sides of the index: the ingest path stores the segmented text in `content_fts`, and the query path builds the nat `MATCH` string from the segmented query. Symmetry is the correctness condition — the segmenter over-splits some katakana words, which costs precision but not recall, because the query is split the same wrong way.\n- Text containing no CJK is passed through verbatim, so English behavior is byte-identical to the previous generation.\n- The nat and code branches build **separate** `MATCH` strings from the same query: segmented for nat, raw for code (inserted spaces would break trigram substring matching).\n- `content` stays raw because the reranker consumes it and the trigram index indexes it.\n- Migration 0006 can only seed `content_fts` with a copy of `content` (the segmentation exists only in JS). `POST /admin/backfill-fts-segments` re-segments the existing rows in batches, driven by a `rowid` cursor; it is idempotent (a row already segmented is skipped without a write), so an interrupted run can simply be restarted.\n\nQuery length tiering (nat side):\n\n- A conjunctive `MATCH` (every token required) gets monotonically harder to satisfy as the query grows. Measured on production against one indexed document: 3 tokens returned 3 candidates, ~12 returned 2, ~17 returned 0. Natural-language questions live in exactly that band, so the sparse half of hybrid retrieval was effective for short keyword strings only.\n- The cliff is language-independent. Japanese reaches it sooner because segmentation turns particles into standalone tokens, but a long English question falls off it too — so the fix is language-independent as well: no stopword or particle list is maintained.\n- The nat side therefore issues **two** `MATCH` strings, strict (all tokens) and relaxed (any token), as separate arms of the same UNION with a `tier` column, ordered by tier before BM25 score. Strict hits keep exactly the ranks they had; the relaxed arm can only append below them, so short-query precision does not regress, and it becomes visible only insofar as the strict arm under-fills `topK` — which is the failure mode itself. No query-length threshold is needed.\n- Relaxed-tier precision is carried by BM25: IDF weighting ranks documents covering more (and rarer) query terms above the rest and drives high-frequency function words (`の` / `は` / `the` / `of`) toward zero contribution. That is the ranker doing what a stopword list would have done, without one.\n- Both tiers ship in a single statement rather than \"retry with OR when AND returns nothing\": the retry shape costs a second D1 round-trip per query and would leave the measured mid-band (~12 tokens, 2 weak hits) unimproved. Cost accepted: the relaxed arm walks the posting lists of every query token, so a long query reads more rows than before. The next lever, if that ever bites, is IDF-aware term pruning via an `fts5vocab` table.\n- Because a document can be returned by both tiers, results are deduplicated by `vector_id` (best tier, then best score wins) before the `topK` cap.\n- The code (trigram) arm stays strict. Substring matching over identifiers does not have the same cliff.\n\nThe `vector_id` mirrors the Vectorize vector ID (deterministic SHA-256 based) so RRF fusion can join dense and sparse hits without an extra round-trip.\n\nMetadata filters (`repo`, `type`, `state`, `milestone`) are applied as SQL WHERE predicates, matching the pre-filter capability of the Vectorize side.\n\nBM25 ranking is obtained via the `bm25()` auxiliary function (lower score = better); scores are converted to ranks for RRF fusion.\n\n### 7. Structured State Store\n\nDurable Object with SQLite stores structured records for:\n\n- issues and pull requests\n- releases\n- documentation file state (repo `docs/` and other tracked `.md`)\n- GitHub Wiki page state (page slug, extension, content hash)\n- commit diff file state (one row per file-in-commit)\n- polling watermarks\n\nThis store supports:\n\n- `get_issue_context`\n- `list_recent_activity`\n- enrichment of semantic search hits\n\n**Recency window.** Every `/recent*` endpoint reads a half-open window `[since, until)` over the surface's own timestamp column (`updated_at`, `published_at`, `commit_date`), newest first, capped at `limit` rows. Both bounds are part of the SQL. Applying `until` to the returned rows instead is what made old windows unreachable: the cap takes the newest rows first and the filter then drops all of them, so a window holding thousands of rows still read as zero (issue #194). The per-endpoint cap stays — it bounds the subrequest / D1 read budget — but it now truncates the window rather than replacing it.\n\n### 8. Graph Index (opt-in, D1 `doc_edges`)\n\nAn additive graph layer that indexes relationships between Decision-Structure wiki entries (kebab-case wiki pages). A third surface alongside dense (Vectorize) and sparse (FTS5), but **never read by retrieval unless explicitly requested**.\n\n- **Schema**: `doc_edges(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)` (`migrations/0002_graph_edges.sql`). src/dst are deterministic wiki vector IDs (`wikiDocVectorId`).\n- **Edge extraction**: at wiki index time (`processAndUpsertWikiDoc`), when another known wiki slug in the same repo appears in the content, an A→B \"mention\" edge is written (`indexWikiEdges` in `src/graph.ts`). **Deterministic slug-match (no LLM, no lossy extraction)**; the dst ID is computed, so dangling edges to not-yet-indexed pages are allowed. Typed edges (supersede/depend/conflict) are future scope.\n- **Traversal**: `queryNeighbors` walks 1–2 hop undirected neighbors of the seeds via `WITH RECURSIVE` (standard SQLite, no extension).\n- **Retrieval integration**: `search` gains `graph_expand` (default false) / `graph_hops` (default 1). Only when true, the final result set seeds a traversal and related wiki pages are returned as a **separate axis** — a `graph_results` array alongside `results`, never merged into it (issue #234). **When false the behavior is byte-identical to before (no regression), and `graph_results` is absent entirely.**\n- **Axis separation (issue #234)**: the graph axis has no relevance score — `doc_edges` carries no weight and traversal orders by hop only. Fusing it into the keyword ranking therefore required inventing one, which shipped as `score: 0` rows at the tail of `results`; a consumer could not tell those apart from candidates the rankers scored at zero, so graph-derived candidates could not compete for rank at all. The fix is to stop fusing rather than to invent a score: within its own array `graph_hop` ascending is sufficient ordering (1 hop is nearer than 2), so no common scale is needed. Items on this axis carry identity, `graph_hop` and `graph_from`, and **no** score field — absence of a score is not a score of zero. `count` counts the keyword axis; `graph_neighbors` counts the relationship axis. Consumer triage, stated in the tool description: on both axes = two independent paths agreed (strongest); keyword only = the words matched; relationship only = the vocabulary did not match but the entry is structurally adjacent. The general property is that a single fused ranking has nowhere to report per-axis health — the same property that made the Japanese-query sparse-side zero (#180) visible only after `fusion` was switched to a single ranker.\n- **Delete fan-out**: on wiki page deletion, `deleteEdgesForVector` removes edges touching that vector. The repository-docs reap does not call it, because a doc vector ID is never an endpoint here (issue #203).\n- **Backfill**: `POST /admin/backfill-edges?repo=owner/repo` (GITHUB_TOKEN header) re-extracts edges from the stored content of already-indexed wiki pages (no GitHub refetch).\n- **Evaluation**: real-use observation after ship (does judgment-learning surface related decisions), not an offline eval harness.\n\n## Retrieval Model\n\nThe retrieval layer supports a 3-tier pipeline: hybrid search (dense + sparse), RRF fusion, and cross-encoder reranking, with structured filtering applied along the way (the 2026 production baseline).\n\n### 3-tier Hybrid Retrieval (default)\n\nExpected retrieval behavior:\n\n1. Generate an embedding for the query via Workers AI BGE-M3.\n2. Build Vectorize metadata filter (dense side) and D1 SQL WHERE clause (sparse side) from the same structured params (repo, state, type, milestone, and document `path_prefix` are pre-filtered on both sides).\n3. Overfetch internally on both sides (requestedTopK × 5, max 50). Unconditional: entity aggregation (step 8) collapses several rows into one result on every path, so the candidate pool must exceed top_k even when the reranker is off. The reranker processes at most 50 candidates per call.\n4. Query Vectorize (dense) and D1 FTS5 (sparse, BM25) in parallel.\n5. Combine the two rankers via Reciprocal Rank Fusion (RRF, k=60).\n6. Post-filter labels (AND logic, expanded fields + CSV fallback) and assignee over the fused view.\n7. When the reranker is enabled (default ON), re-score the post-filtered candidates with `@cf/baai/bge-reranker-base` and reorder by reranker score, descending.\n8. Collapse rows that point at the same entity, keeping the highest-ranked row as the representative (see Entity Aggregation).\n9. Trim to requestedTopK and return results with structured context.\n\n#### Reciprocal Rank Fusion (RRF)\n\nRRF formula:\n\n```\nscore(d) = sum_over_rankers ( 1 / (k + rank_r(d)) )\n```\n\n- k = 60 is the canonical value from Cormack et al. (2009) and the de-facto default in production hybrid search (Elasticsearch, Vespa, Milvus).\n- `rank_r(d)` is the 1-based rank of document d under ranker r; documents missed by a ranker contribute 0 from that ranker.\n- Dense and sparse scores have non-comparable scales; normalizing to rank is what makes fusion valid.\n- Documents that appear in only one ranker still get partial credit, which boosts recall.\n\n### Cross-encoder Reranker\n\nThe 3rd tier reranker uses `@cf/baai/bge-reranker-base` (Workers AI).\n\nRationale:\n\n- As of 2026, the cross-encoder reranker is treated as a baseline production layer (hybrid + reranker), not an optional add-on. Cloudflare's AI Search primitive (2026-04-16) ships `@cf/baai/bge-reranker-base` as the rerank stage.\n- Bi-encoders (BGE-M3) and sparse BM25 are strong on recall but weaker on precision@k than cross-encoders. Re-scoring the top RRF-fused candidates with a cross-encoder lifts precision without re-architecting the recall layer.\n- Reranking uses the same `env.AI` primitive as BGE-M3 embedding, so no additional binding is required.\n\nImplementation constraints and known limitations:\n\n- bge-reranker-base inherits BAAI's 512-token context window. Each `(query, candidate content)` pair is truncated by character budget (query: 200 chars max, pair total: 1700 chars max) since no tokenizer is available in Workers.\n- Workers AI requires every `contexts[].text` to be at least one character long (error 5006: \"Length of '/contexts/N/text' must be >= 1 not met\"). Candidates whose content is still empty at call time are filtered out of the rerank input; if 0 or 1 non-empty candidates remain the call is skipped entirely.\n- Reranker input content is supplied from two sources. Candidates with a sparse (FTS5) hit carry their content inline. Dense-only candidates have no FTS row, so their content is backfilled from D1 `search_docs` in a single batched `vector_id IN (...)` query issued once per search — never a per-candidate fan-out. The acute case was a Japanese query before the nat index was segmented: FTS5 tokenization yielded zero BM25 hits, every candidate was therefore dense-only, and without this backfill the whole candidate set reached the reranker empty and the cross-encoder never ran. Segmentation removed that specific cause, but any candidate the sparse ranker misses still arrives without content, so the backfill remains required. A D1 failure during backfill is non-fatal: the search proceeds with whatever content is already available.\n- `rerank_applied: true` is reported only when at least one candidate actually received a reranker score. An empty reranker result (no scorable content) is treated the same as a failure — fusion order stands and `rerank_applied` is `false`. The invariant is that `rerank_applied: true` always implies at least one non-null `rerank_score` in the response.\n- The reranker processes at most 50 candidates per call — chosen as the join point between the Workers AI Free tier neuron budget (10,000/day) and industry median (50–75 candidates).\n- bge-reranker-base is English-centric and not multilingual. Japanese issue/PR content may see degraded quality. This is observed at runtime; switching to `bge-reranker-v2-m3` or an external reranker (Voyage / Cohere) is tracked as a separate issue when needed. Note that the dense-only content backfill above makes the cross-encoder *run* on Japanese queries; it does not make the model multilingual, so ranking quality remains a separate axis.\n- On reranker call failure or unexpected response shape, the system falls back gracefully to the post-filter (RRF) order and reports `rerank_applied: false`.\n\nCost estimate:\n\n- Workers AI public pricing: bge-reranker-base = 283 neurons/M tokens.\n- Per-search estimate: ~7.5 neurons (query 30 tokens + 50 candidates × 500 avg tokens, embedding included).\n- Free tier (10,000 neurons/day) supports ~1,300 searches/day at this rate.\n- Actual neuron usage is read from a `usage` field on the response when present and reconciled against the estimate. The field is not officially documented as of 2026-04, so absence is tolerated silently.\n\n### Entity Aggregation\n\nOne underlying thing is indexed as several rows: a file is a `doc` row plus one `diff` row per commit that touched it, an issue is its own row plus one `issue_comment` row per comment, a PR is its own row plus its `pr_review` / `pr_review_comment` rows. They all compete for slots in the same `top_k` pool. Measured on the production index (2026-08-01, `top_k: 10`, rrf + rerank), roughly 6 of 10 slots held independent information; a `dense_only` probe put 3 of 5 slots on one file.\n\nKeeping the diff rows indexed is deliberate — they are the judgment history, and storing changes as commit diffs is what keeps the index small — so the collapse happens at the presentation stage (after fusion, rerank and time sort; before the trim), not in the index.\n\n**What counts as one entity.** The entity is the *referent* a row points at, never the *event* that produced the row.\n\n| rows | key | collapse |\n|---|---|---|\n| a file's `doc` row + its `diff` rows across commits | `file:{repo}:{doc_path ?? file_path}` | yes |\n| an issue + its `issue_comment` rows | `thread:{repo}:{number}` | yes |\n| a PR + its `pr_review` / `pr_review_comment` rows | `thread:{repo}:{number}` | yes |\n| different files touched by one commit | — | **no** |\n| an issue and the PR that closes it | — | **no** |\n| the same source file copied into another repo | — | **no** |\n\nCollapsing versions of one referent cannot hide an independent thing, because the number of referents is unchanged. Collapsing by event would: one commit touches several distinct files, and folding them into a single slot hides files that are genuinely independent. So the key carries the path and not the commit SHA, and an issue and the PR that closes it stay two entities — they are one unit of work, but the `Closes #N` link that would join them is not in the index, and putting it there is an index-side change.\n\nCross-repo duplication (the same Li+ source file copied into every user repo's `.claude/`) is out of scope: deciding those are one entity needs either a content hash in the index or a path-normalizing heuristic — the first is an index change, the second folds genuinely different files, and a stale copy's difference is itself information.\n\n`wiki_doc` and `release` rows have exactly one row per referent, so they key on the row identity and never collapse.\n\n**Representative.** The highest-ranked row of the group after fusion / rerank / time sort — the newest version is *not* pinned. This is what keeps \"when did this change\" answerable: for such a query the relevant old `diff` ranks top, so it is the row that survives. Pinning the newest version would delete the answer.\n\n**Response shape.** `top_k` counts entities, so a caller asking for 10 gets 10 independent entities. A representative that absorbed other rows carries one additional field; the collapsed rows are referenced, never dropped:\n\n```json\n{\n \"...\": \"(the representative's existing fields)\",\n \"same_entity\": {\n \"count\": 3,\n \"others\": [\n { \"type\": \"diff\", \"url\": \"...\", \"updated_at\": \"...\", \"score\": 0.0161, \"commit_sha\": \"601aa38\" }\n ]\n }\n}\n```\n\n`count` includes the representative, so it is at least 2. The field is present only when at least one row was collapsed, and it is additive — a client that ignores it sees the pre-aggregation shape.\n\n### Fusion mode toggle\n\n`search` accepts a `fusion` parameter:\n\n- `rrf` (default) — combine dense and sparse via RRF.\n- `dense_only` — query Vectorize only (debugging, semantic-heavy queries).\n- `sparse_only` — query D1 FTS5 BM25 only (debugging, exact-term / identifier queries).\n\n`search` also accepts a `rerank` parameter:\n\n- `true` (default) — re-score the RRF-fused candidates with bge-reranker-base.\n- `false` — skip rerank (faster, no Workers AI rerank cost; recommended for short-identifier queries where lexical match is already decisive, or for debugging).\n\nThe retrieval layer is intended to recover working state, not merely keyword matches. The 3-tier design lets sparse cover BGE-M3's weak regime (short identifiers, SHA prefixes, proper nouns), RRF maintains recall across both rankers, and the cross-encoder lifts precision on the surviving candidates.\n\n## MCP Tools\n\n### `search`\n\nPurpose:\n\n- **3-tier hybrid search** (dense + sparse BM25 → RRF fusion → cross-encoder rerank) over issues, pull requests, releases, documentation, and commit diffs\n- use `type: \"diff\"` to retrieve judgment history (including deleted files and non-`.md` extensions)\n\nParameters:\n\n- `query` required\n- `repo` optional\n- `state` optional\n- `labels` optional\n- `milestone` optional\n- `assignee` optional\n- `type` optional\n- `top_k` optional\n- `fusion` optional — `rrf` (default) / `dense_only` / `sparse_only`\n- `rerank` optional — `true` (default) / `false`\n- `since` / `until` optional — half-open time window `[since, until)`\n- `vector_ids` optional — stored-content fetch; see below\n\nReturns:\n\n- ranked matches with repository, type, state, labels, milestone, assignees, URL, and RRF fused `score`\n- `vector_id` on every result (and on every `same_entity.others` entry) — the row's index key and the handle `vector_ids` takes\n- additional debug fields per result: `dense_score`, `sparse_score`, `dense_rank`, `sparse_rank`, `rerank_score` (null when rerank disabled or when graceful fallback engaged)\n- `same_entity` on results that absorbed other rows of the same entity (see Entity Aggregation); `top_k` counts entities, not rows\n- top-level metadata: `fusion`, `dense_candidates`, `sparse_candidates`, `rerank_requested`, `rerank_applied`, `filters_unmatched`\n\n**Unmatched filters (`filters_unmatched`).** `repo` takes the full slug (`owner/repo`) and matches exactly — on the dense side as a Vectorize metadata `$eq`, on the sparse side as `d.repo = ?`. `path_prefix` selects doc rows whose repository-relative path begins with the named directory prefix. A bare repository name or a path prefix that selects no doc row therefore produces a response shaped exactly like a genuine zero-hit search. `filters_unmatched` separates the two: it is always present in search mode, `[]` means every applied filter selected a non-empty population (so `count: 0` really is \"no hits\"), and a listed name means that filter's population is empty — the value is wrong, not the query. The tool description tells the caller when to pass `repo`: whenever the question is about a known repository, since without it a near-duplicate from another repository can rank first, and to leave it unset only when the question spans repositories. The default stays unfiltered so cross-repository questions keep working.\n\nThe distinction is worth a field because agentic multi-step search inverts the cost of a silent zero. In a single search a zero is a dead end the caller inspects; in a search loop it is a normal intermediate result (\"nothing down this angle\") that the caller consumes and moves past, so the mis-specified filter never surfaces and one query out of the budget is spent on a false negative.\n\nThe checks are existence probes run only when the candidate set is empty — a non-empty candidate set already proves the filters matched, so the extra reads stay off the hot path. The repository probe runs first. When it succeeds, the path probe checks doc rows inside that repository; a bad repository therefore reports `repo` without falsely blaming the path that may be valid elsewhere. A failed probe reports nothing rather than asserting a mismatch it did not observe. Resolving a short repository name to a full slug is deliberately out of scope — that needs an ambiguity design for a name matching several repositories.\n\n**Scan mode (empty query).** Vectorize / FTS5 / reranker are skipped and the result set is aggregated from the structured store's recency endpoints. `since` / `until` and document `path_prefix` are pushed down to the store, so a window returns matching rows whenever it holds them, however far back it sits and however many newer documents exist outside the prefix. `since` defaults to 7 days before `until` (before now when `until` is omitted), so an `until`-only query does not degenerate into an empty window above its own ceiling.\n\n**Document path prefix.** `path_prefix` is valid only with `type: \"doc\"`. It names a repository-relative directory, must end in `/`, must not begin with `/`, contain backslashes, NUL, empty segments, or `.` / `..` segments, and must fit in 64 UTF-8 bytes because Vectorize indexes only that prefix of string metadata. Dense and sparse retrieval use the same half-open lexical range over `doc_path`; scan mode pushes the range into the doc store query and verifies `startsWith` on returned rows. Fetch mode continues to ignore it because `vector_ids` names rows directly.\n\nScan mode adds one top-level field, `truncated`, which is true when the window holds more rows than the response carries — either an endpoint filled its row cap, or the merged set was longer than `top_k`. This is what tells a caller that zero results means \"no such rows\" rather than \"the read stopped short\": walk backwards by re-issuing the scan with `until` set to the oldest row returned. A gap-hunting tool that cannot separate those two answers reports absent rows that exist and misses rows that do not, which is how #178 was mis-diagnosed twice in one day.\n\n**Stored-content fetch (`vector_ids`).** Pass the `vector_id` values carried by earlier results and the tool returns the body text the index already holds for those exact rows. Every indexed type is covered — `issue`, `pull_request`, `issue_comment`, `pr_review`, `pr_review_comment`, `release`, `diff`, `doc`, `wiki_doc` — where before only `doc` and `wiki_doc` had any path to a body at all, so locating something with `search` and then reading it cost a second round trip through `gh` or grep. The text is read from D1; no GitHub API call is made, and the subrequest budget is untouched.\n\nThis is a mode, not a tool. Four tools were consolidated into `search` in #104 / #105, and a dedicated body-fetch tool would be the removed `get_doc_content` returning under a new name; the consolidation's own design intent was that modes are expressed by the parameter set, so this one is too. Precedence is `vector_ids` → empty `query` → hybrid search. Metadata filters do not apply in fetch mode: the rows are named by the caller, not selected by the server.\n\nWhat comes back is the index's copy of the body — the embedding input, truncated by the ingest pipeline at 8000 characters — and not the live source. Inlined text carries no mark of which it is, so the shape states it: the response carries `content_source: \"index\"` and `content_max_chars`, and each row carries `content_chars` plus `content_truncated`. The flag is read off the length rather than recorded at ingest, so a body whose natural length is exactly the ceiling reports as truncated; that error direction costs one needless re-read, while the other direction would pass a prefix off as a whole document.\n\nIds are carried by search-mode results only — scan mode reads the structured store, whose rows have no index key. Unknown or stale ids are listed in `not_found` and the remaining rows still return. Partial success is the contract rather than a convenience, because `vector_id` is explicitly a handle for reaching a row in the result set it arrived in — not a durable identifier. The id scheme has been migrated once already (`src/pipeline/legacy-vector-id.ts`), so a caller replaying an old id must still get the rows that are live.\n\n`include_content` is a separate axis and stays unchanged: it re-reads whole files from the GitHub contents API (and `raw.githubusercontent.com/wiki`) because a doc needs its full text, and its 5-row cap exists to bound that API fan-out. Fetch mode reads D1 and is bounded by the ids the caller listed (max 50 per call, mirroring the `top_k` ceiling). Same word, different source and different bound — one flag over both would be one guarantee over two behaviors.\n\nOut of scope: the stored text is the embedding input, so one 8000-character limit currently serves both embedding and retention. Giving the FTS side an untruncated body is the root-level shape, but it requires re-indexing existing rows. Fetch mode returns what the index already holds.\n\n### `get_issue_context`\n\nPurpose:\n\n- aggregate the state around one issue or pull request\n\nReturns:\n\n- issue or PR details\n- linked PRs\n- branch information\n- CI status\n- sub-issues when available\n- related releases when inferable\n\n### `list_recent_activity`\n\nPurpose:\n\n- provide a recent activity feed across tracked repositories\n\nReturns:\n\n- created, updated, and closed issue or PR activity\n- release publication activity\n- documentation update activity\n- commit diff indexing activity\n\n## Authentication\n\nAuthentication uses a GitHub App with OAuth 2.1.\n\nRequirements:\n\n- authenticate the MCP client user\n- access repositories through the installed app\n- use GitHub tokens for API reads\n- reuse a cached Dynamic Client Registration only when its registered redirect\n URI set covers every localhost callback URI requested for the current OAuth\n flow; otherwise register a replacement client before authorization\n\n### Bridge OAuth recovery (issue #250)\n\nThe local bridge reads shared token storage on each credential request, including\nupdates made by another bridge process. A token rejected with 401 or a failed\nrefresh must not be reused unchanged from memory or disk in that process. Valid\nexisting credentials remain usable. Concurrent calls share one authorization\nattempt; a pending call returns promptly and a later call observes completion,\nfailure, or the five-minute timeout without an unhandled promise rejection.\nThe callback page reports success only after token exchange and storage succeed.\nErrors and logs must not expose tokens, codes, state, verifiers, or authorization\nURLs. This changes neither MCP protocols nor search semantics.\nNew token records retain their issuing client ID for refresh even when another\nprocess replaces shared client registration; legacy records use that registration\nas a fallback. Token and registration writes replace complete files atomically.\n\n## Storage Rules\n\n### Canonical memory surfaces\n\nThe canonical project memory remains in GitHub artifacts:\n\n- issue bodies and labels\n- pull requests and review state\n- docs in the repository\n- releases\n- commit history (diffs)\n\nThe retrieval system indexes those surfaces. It does not replace them as source of truth.\n\n### Update behavior\n\n- webhook updates should be applied as soon as practical\n- cron should reconcile any drift\n- failed embeddings must remain retryable\n- deleted issues / PRs / releases / docs must be removable from both the semantic index (Vectorize) and the sparse index (D1 FTS5)\n- commit diffs are append-only and are not part of the delete path\n\n## Current Deployment Assumptions\n\n- TypeScript codebase\n- Cloudflare Workers runtime\n- Vectorize for dense semantic search (dense side of hybrid retrieval)\n- Cloudflare D1 for FTS5 BM25 sparse search (sparse side of hybrid retrieval; schema managed via migrations)\n- Workers AI for embedding generation\n- Durable Object / SQLite for structured state\n- one deployment may track multiple repositories via `POLL_REPOS`\n\n## Operational Constraints\n\n### Worker invocation pressure\n\nWorkers AI calls per invocation are limited, so embedding work must be batched conservatively.\n\n### Cron CPU pressure\n\nLarge initial syncs can exceed CPU limits, so pagination and resumable watermarks are required.\n\n### Durable Object resets\n\nDeployments may reset Durable Object state, so the system must recover by replaying from GitHub through webhook and cron paths.\n\n### Free-tier hard stop (D1 / Vectorize / Workers AI)\n\nWorkers AI Free (10,000 Neurons/day), D1 Free, and Vectorize Free all specify that exceeding the free quota causes `operations will fail with an error` (hard stop). Overage billing only applies when the account is on a paid Workers plan. Because the managed AI Search product is not used, AI-Search-specific hard-stop uncertainty is out of scope here.\n\n### Retry safety\n\nIf an embedding attempt fails, the state must remain detectable as incomplete so the next run can retry.\n\n## Future Scope\n\nIssue #275 の固定capture比較の契約は [Offline Abstention Replay (JA)](4-abstention-replay.ja.md) を参照する。既定検索の変更とreader効果の採用判断は含まない。\n\n- stronger ranking and filtering behavior\n- better multi-agent handoff retrieval\n- better cross-repository state recovery\n\n## Retrieval feedback memory (Issue #259)\n\nImplement retrieval history, selected/validated/used records, retrieved/usage activation with a 3600-second half-life, and confirmed-only real-path reinforcement with receipt-based reversal atomically in one PR. Share and isolate by authenticated GitHub principal. Successful retrieval commits its trace atomically; memory/partial retrieval failures emit no trace and cannot accept feedback. History writes are enabled by default, keyword ranking is preserved, and use_memory changes only same-hop graph ordering. Save actual two-hop edges.\n\nThe APIs are `memory_history`, `record_source_use`, and `record_outcome`. [2-feedback-memory.md](2-feedback-memory.md) owns constants, bounds, source version identity, batch/idempotency, ledger, errors, migration 0008-before-deployment, historical backfill and bridge artifacts. Do not duplicate conversations, bodies or credentials. Claims of retrieval quality improvement require separate evaluation.\n\nIssue #263: exclude canonical identity failures per source and pass valid sources to the existing atomic save. Preserve retrieval rows and order; return and persist partial-recording status, exclusion counts and reasons in settings. Distinguish all-unresolved from a genuine zero-hit search. Unresolved rows receive no source_id and cannot accept feedback. Exclude graph paths from reinforcement unless the origin, intermediates and terminal can be verified. Storage failures and incomplete retrieval continue to emit no trace.\n\nIssue #263 investigation reproduces partial issue_comment.deleted teardown: retain DO canonical identity until both Vectorize and FTS deletions succeed. Index or DO deletion failure returns 503; no automatic retry is added. Causality for the remaining production row is unverified; [investigation](3-unresolved-source-investigation.ja.md) separates evidence from hypotheses. Production DB mutation, bulk repair, version updates and releases are outside this fix.\n\nIssue #259 version contract: live inline doc/wiki uses the returned-text SHA-256 version and provenance separate from the index snapshot. Changed live text gets a different source ID even at the same index timestamp. Do not duplicate the body in memory.\n\n## Release metadata consistency (Issue #261)\n\nBoth CD packaging jobs must derive the bridge package, lock root, MCP Bundle manifest and MCP registry npm versions from the same validated release tag and assert agreement before packing or publishing. Preserve dependency versions, schemas, runtime constraints and Worker behavior. [Versioning and published artifacts](installation.md#versioning-and-published-artifacts) owns the affected fields and validation command. Committed versions remain placeholders; trusted publishing is a separate change.\n\nThe source `docs/_Sidebar.md` must link the existing Feedback Memory EN/JA pages so release wiki navigation includes them.\n"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/0-requirements.ja.md",
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/main/docs/0-requirements.ja.md",
+ "version": "sha256:86ab0352e8b15b04679cb045f56b6da1b3d0e21535c287bcdc7f42e7b6156420",
+ "text": "# github-rag-mcp 要求仕様\n\n言語: [English](0-requirements.md) | 日本語\n\n## Overview\n\n`github-rag-mcp` は GitHub 上の project state を AI agent が検索・取得できるようにする MCP サーバーです。\n\n対象にする artifact:\n\n- issue\n- pull request\n- release\n- repository documentation\n- commit diff(削除済みファイルを含む判断履歴)\n\nこのシステムの目標は transcript memory ではなく recoverable project state です。\n\n本プロジェクトは GitHub を shared working memory として扱います。\n\n- issue は要件、未解決事項、タスク状態を保持する\n- pull request は実装、review、CI 状態を保持する\n- docs は固定化された理解を保持する\n- release は出荷済みの節目を保持する\n- commit diff は「その時点でどう判断して何を変えたか」の履歴を保持する(削除済みファイル・非 .md 拡張子を含む)\n\nagent が行動するときは、必要な状態の断面を search で引き戻します。\n\nカウンターパート:\n\n- [github-webhook-mcp](https://github.com/Liplus-Project/github-webhook-mcp) は push ベースの awareness を担う\n- `github-rag-mcp` は durable state 上の retrieval を担う\n\n## Principles\n\n### State over complete memory\n\n会話や思考過程を丸ごと保存することは目指さない。\n\n次の正しい行動に必要な state を保存し、取り戻せるようにすることを目指す。\n\n### No unnecessary additions\n\nmemory layer は GitHub artifact の意味を変えるような推測や装飾を足さない。\n\n### No loss of required information\n\nmemory layer は別 agent や別 session が安全に続きを行うために必要な制約、判断、現在地を落とさない。\n\n### Human-visible memory\n\nsource of truth は引き続き人間が読める GitHub artifact にある。retrieval はその上に載る index であり、置き換えではない。\n\n## Architecture\n\n```text\nGitHub webhooks + GitHub API\n |\n v\n Cloudflare Worker\n + MCP surface\n + webhook receiver\n + cron poller\n + embedding pipeline\n + hybrid retrieval (dense + sparse + RRF fusion + cross-encoder rerank)\n |\n +--> Vectorize (dense: BGE-M3 1024d, cosine)\n +--> D1 FTS5 (sparse: BM25, porter + trigram)\n +--> Durable Object/SQLite (structured state, watermarks)\n +--> Workers AI BGE-M3 (embedding generation)\n +--> Workers AI bge-reranker-base (cross-encoder rerank)\n```\n\n## Components\n\n### 1. MCP Surface\n\nworker は HTTP 上で MCP tools を公開する。\n\nResponsibilities:\n\n- 認証済み MCP request を受ける\n- semantic retrieval と structured retrieval の tool を公開する\n- downstream agent がそのまま使える形式で state を返す\n\nWorker の Zod contract を source of truth とし、bridge は生成した静的 JSON で `tools/list` を返す。起動時の auth/network は不要。`scripts/generate-tool-contracts.mjs` で再生成し、`scripts/check-schema-drift.mjs` は全4 tool の実 protocol と nested input/output schema、bounds、defaults、annotations の一致を CI で検査する。名前/enum のみだった旧 guard を拡張する(#259)。\n\n#### プロトコル版 2026-07-28、単レーン\n\nWorker が提供する MCP プロトコル版は **2026-07-28 のみ**。2025 系の互換レーンは\n持たない。この版が要求するリクエスト毎の `_meta` envelope を欠くリクエスト\n(2025 系の `initialize` ハンドシェイクを含む)は、endpoint が提供する唯一の版を\n名指しした unsupported-protocol-version エラーで返る。SDK は旧版向けの\nステートレスなフォールバックを用意しているが、意図して有効化していない(gh#224)。\n\n理由はフォールバックが技術的に誤りだからではなく、保守されないフォールバックが\n死にコード化するからである。畳む条件は観測可能(旧レーンへの到達がゼロになる)だが、\n観測しに行く者がいない。破断はコードではなく別チャネル、すなわち minor リリースと\nREADME 注意書きで吸収する。\n\nステートレス化の帰結:\n\n- セッションが無い。各リクエストはそのリクエスト専用に組み立てたサーバー\n インスタンスが処理する。セッション ID から解決するものは無く、`mcp-session-id`\n も発行しない。\n- ユーザー同一性は、処理中のリクエストが持つ OAuth props から取る。インスタンスの\n フィールドではなく、ハンドラのリクエスト毎 auth context 経由で読む。\n- かつて MCP を提供していた Durable Object(`RagMcpAgent` / `RagMcpAgentV2`)は\n 経路から外れた。過去の migration が名指しするクラスはスクリプト内に存在せねば\n ならないという Cloudflare の制約のため、退役スタブとして export だけ残す。撤去には\n `deleted_classes` migration が要る。`IssueStore` は実データを保持し、#259 では独立 memory table を追加する。\n\nこの版は本 repository の 2 成果物のあいだの私的な契約である。Worker へ到達する経路は\nnpx ブリッジ(`mcp-server/`)だけ——`server.json` は stdio トランスポートしか宣言\nしていない——ため、第三者クライアントが版を交渉することはなく、ブリッジ側は探索せず\n版を pin する。ブリッジが Claude Desktop に向ける面は SDK v1 / 2025 系のまま。\nそちらの移行は別 issue(gh#228)。\n\nWorker は全員共有の 1 デプロイ、ブリッジは利用者ごとであるため、切替は非対称になる。\nWorker は一斉に飛び、ブリッジはホストプロセスの再起動でようやく追随する。版を固定して\nいる利用者は再起動しても復旧しない。この層の切り捨ては明示的に受け入れる。\n\n### 2. Webhook Receiver\n\nwebhook receiver は GitHub event を near real time で取り込む。\n\n対象 event family:\n\n- `issues`\n- `pull_request`\n- `release`\n- `push`\n\nResponsibilities:\n\n- `GITHUB_WEBHOOK_SECRET` で署名検証する\n- GitHub source IP を検証する\n- event を embedding pipeline に渡す\n- semantic store と structured store の両方を更新する\n\n`push` はリポジトリ内の全 `.md` ファイルの変更検出に使う。同じ `push` event から per-commit diff も index する(1 commit × N files → N vector、各 vector は commit message + file path + patch を embedding input にする)。これにより削除済みファイルや非 `.md` 拡張子の判断履歴も semantic 検索可能になる。\n\n**`push` 経路の doc 削除**は cron reap と同じ 3 面を teardown する — Vectorize / D1 FTS5 / structured store — それぞれ独立に実行するので、1 面の失敗が他を取り残すことはない(issue #206)。graph edge を teardown しないのも cron reap と同じ理由で、doc vector ID が `doc_edges` の端点になりえないため。cron reap と違い per-run の削除枠は持たない。push payload が自分の削除件数を持っており、蓄積した backlog ではなく event で上限が決まるからである。レスポンスの `deleted` は 3 面すべてが落ちた doc を数えるので、`removed - deleted` が部分失敗の件数として delivery log から読める。cron reap 側の counter は代わりに**試行数**を数えるが、あちらではその counter が ETag hold を制御する budget counter を兼ねているためである。\n\n### 3. Cron Poller\n\ncron poller は fallback path である。\n\nResponsibilities:\n\n- webhook 取りこぼしを補償する\n- 新しい repository の backfill を行う\n- issue、pull request、release、docs、GitHub Wiki page、issue/PR comments、commit diff の変更を再取得する\n- 一時障害後も store を収束させる\n\n現在の deployment では hourly で 4 つの cron trigger に分けて実行する。各 upsert が Store DO + Vectorize + D1 FTS + AI embed と最大 4 internal fetch を生むため、heavy 同居 (comments + diffs) でも per-Worker subrequest 上限を超える。surface 単独単位で 15 分ずつずらして発火させる:\n\n- **`0 * * * *` (light)** — issues / pull requests / releases / docs\n- **`15 * * * *` (comments)** — issue/PR comments のみ\n- **`30 * * * *` (diffs)** — commit diffs のみ\n- **`45 * * * *` (wiki)** — GitHub Wiki page のみ\n\n各 invocation は独立した subrequest 予算を持つ。dispatch は `controller.cron` で `handleScheduled` 内で行う。未知の cron 表現は no-op log で silent regression を防止する。\n\n**ETag の据え置き.** 条件付き request を出す surface は 3 つある — issue / PR(issues list への `If-None-Match`)、releases(releases list)、docs(repository tree)— そしてこの 3 面は 1 つの規則に従う: **やり残しがある run は、受け取った ETag を保存しない。** やり残しとは retrieval surface に載らなかった項目のことで、理由は問わない — per-run cap が見送った場合も、embed が失敗した場合も同じ。どちらでも項目には retry の印が付き(空の `bodyHash`、または store 側で進んでいない `blobSha`)、次 run がその項目を見に来ることが前提になる。ところが ETag を保存すると、次 run の条件付き request は 304 を返して項目を見る前に return してしまい、印の付いた項目は同じ surface の**無関係な**項目が変更されるまで待たされる。`lastPolledAt` は進めるので、据え置いた run 自体は観測できる。\n\n条件を 3 面それぞれに持たせず 1 つに揃えているのは、面ごとの条件が離れていくからである。releases 面は upsert cap だけを、docs 面は 2 本の cap だけを見ていたため、どちらも embed 失敗では ETag を更新してしまい retry が止まっていた(issue #211)。この一般形に最初に到達したのは issue / PR 面(issue #215)。*据え置き*の実装は releases / docs 面が「直前に保存した ETag を保持」、issue / PR 面が「何も保存しない」だが、この 2 つは等価である。保存済み ETag を送って 200 が返った run は、その surface が既にその ETag と異なることを証明しているので、次の request はどちらの形でも 200 になる。\n\n残りの取り込み surface がこの規則の外にあるのは、書き漏らしではなく構造上の理由である。comment poller は条件付き request を出さない(毎 run 直近の parent を歩き直す)。wiki poller の `etag` watermark 列が持っているのは HTTP ETag ではなく walk の cursor である。\n\nissue / PR poller は `(lastPolledAt, now]` を `updated_at` 昇順で取得する。1 run あたり最大 `MAX_PAGES_PER_RUN` × 100 = 200 件を fetch し、そのうち embed するのは最大 `MAX_EMBEDDINGS_PER_RUN` = 50 件。この surface は commit diff と同じ watermark 不変条件に従う: **その run が retrieval surface に載せられなかった最も古い項目を watermark が追い越さない** — embedding 予算で見送った項目と、embed に失敗した項目の両方が対象。2 つの境界は別物として扱う。fetch がどこまで届いたか(poll 開始時刻、pagination が打ち切られた場合は最後に fetch した項目の `updated_at`)は上限にすぎず、取り込み境界がその下に watermark を留める。留める位置は境界の 1 秒手前で、GitHub の `since` filter が境界の項目自身を再び含むようにするため。\n\nこの pin が無い間、この surface は「fetch 件数 − embed 件数」の速度で取りこぼしていた。旧実装の watermark はどちらの分岐でも見送った項目より**新しい**位置に着地する — batch は昇順なので、予算はいつもその新しい端で尽きる — 一方で見送った項目に付く空の `bodyHash` は retry の印であって、以後どの `since` window もその項目を fetch しない。2026-08-03 の実測で、索引対象 repository の issue / pull request 履歴の約 55% が索引に載っていなかった。欠落が連続した番号帯ではなく散発に見えるのは、脱落が番号順ではなく `updated_at` 順に起きるため(issue #210)。**ETag も同じ条件で書き戻さない**(上記「ETag の据え置き」に従う)。\n\nreleases poller は releases list 全体を 1 回の条件付き request で読み、1 repo 1 run あたり最大 `MAX_RELEASE_UPSERTS_PER_REPO_PER_RUN` = 10 件を upsert する。list は有界で毎回全件読み直すため、この面は watermark を持たない。したがって ETag の据え置きがこの面唯一のやり残し機構である。cap が見送った release も、embed に失敗した release も、どちらも空の `bodyHash` で保存され、次 run で拾えるのは ETag を据え置いたからにすぎない(issue #149 / #211)。\n\ncommit diff poller は 2-phase 構成:\n\n- **forward phase** — `(lastPolledAt, pollStartTime]` の window を列挙し、その中の**古い側から**取り込む(webhook 取りこぼし時の redundancy)。watermark namespace は `diffs:${repo}`。\n- **backward phase** — `until=oldestUnprocessedDate` で履歴を徐々に遡行する(新規 deployment や webhook 起動前の commit を backfill する経路)。watermark namespace は `diffs_backfill:${repo}`。\n\n1 repo 1 run あたりの上限は、各 phase が 2 軸で持つ: commit 数 5 件と、file 数 `diffFileBudgetPerPhase`。`processAndUpsertCommitDiff` の upsert は `(repo, commit_sha, file_path)` で idempotent なので、webhook / 両 phase 間で overlap しても副作用はない。\n\ncommit 数は phase の消費量を縛れない。1 file の index は worst case で 3 subrequest(D1 FTS mirror 書き込み、store row、および embed batch の按分——1 batch は 2 subrequest を使い、`MAX_EMBEDDING_INPUT_CHARS` が 1 input を 8000 文字=最大 24000 UTF-8 バイトに切り、endpoint は batch を「input 件数 × 最長 input」で課金するので 2 × 24003 = 48006 が天井 60000 に収まり、1 batch は最低 2 file を載せる)であり、1 commit は最大 300 file を運ぶため、5 commits のコストは 2 桁の幅を持つ。実測された帰結: `POLL_REPOS` の末尾 repo にある 44 file の commit で、embed batch 3 本すべてが `Too many subrequests by single Worker invocation` で拒否された。同じ loop の手前にある repo は正常に index されていた。そして後述の不変条件がその commit で watermark を止める——token 軸が起こしたのとまったく同じ、毎 cron 決定論的に再現する停止が、file 軸で起きた(issue #238)。\n\nそこで diff surface は invocation 予算のうち自分の取り分を明示する: `DIFF_SUBREQUEST_BUDGET_PER_RUN` = Cloudflare が許す 1000 のうち 900。diffs は専用 cron を持つので、この天井を docs / wiki / issue / release と共有はしていない。しかし `POLL_REPOS` の全 repo と両 phase では共有しており、上記の失敗を生んだのはそちらの共有である。`diffFileBudgetPerPhase` は宣言した取り分を `repoCount × 2` で割り、残りを file 数に換算する。literal を固定せず repo list から導出するのは意図的である: `POLL_REPOS` は通常の config commit で増える(issue #233 が 6 番目の repo を追加した)ため、その日の list に合わせた literal は次の追加で天井を超え、しかもその超過は原因となった変更ではなく loop が最後に到達した repo の失敗として現れる。repo list が長く予算を下回るところまで割った場合は 5 file の下限が効く——超過は次の cron が再試行するが、予算 0 は全 diff watermark を恒久的に止める。それはいま取り除こうとしている失敗そのものである。\n\nfile 予算は 1 commit を複数 run に分割しうる。間引きはしない: 1 run は残っている file の先頭側を index し、次の run は structured store(`GET /diffs`)から index 済み path を読み戻してそこから続ける。この読み戻しが分割を収束させる——これが無いと、上限付きの run が毎 cron 同じ先頭側を再 index し、その commit は永遠に完了しない。読み戻しを行うのは file 数が予算を超える commit だけなので、通常の commit はその判定に subrequest を払わない。またこの読み戻しが store 側エラーで失敗した場合は「まだ何も index されていない」と読むのではなく commit の失敗として扱う。前者は分割を黙って先頭からやり直させてしまう。\n\n両 phase は同一の watermark 不変条件に従う: **取り込みに成功していない commit を watermark が追い越さない。** commit が「取り込み済み」とみなされるのは、detail 取得が成功し、かつ `processAndUpsertCommitDiff` の failed が 0 で、**さらに未処理の file が残っていない**場合のみ(embedding / Vectorize の失敗は throw ではなく戻り値で報告されるため、戻り値も判定に含める)。判定は dense 側のみを見る: D1 FTS mirror / store row の失敗は pipeline 側で log のみ・failed に計上しない設計で、vector は既に landing 済み・sparse index は reindex で reconcile されるため。mirror 側まで watermark の条件にすると、FTS 側の障害が diff surface 全体を止めてしまう。帰結:\n\n- 失敗した commit、file 予算で一部しか index されなかった commit、そして 2 つの 1 run 上限のいずれかで持ち越された commit は、いずれも次回 run の window に残る。一部だけ index された commit はエラーではなく分割の途中であり、watermark を止めることが残りの file への到達性を保つ\n- commit list 取得が失敗した run は watermark を動かさないので、その期間全体が再試行対象のまま残る\n- forward phase は window の古い端を確定してからでないと watermark を進められないため、window は 100 件/page で列挙する。page が満杯なら「列挙不能」とみなして window 終端を半分に縮める(最大 8 回)。それでも収まらない場合は watermark を保持して log に出す — silent な欠損より、観測できる stall を選ぶ。\n\nトレードオフは liveness で、毎回失敗し続ける commit はその phase の watermark を止める。run log に境界 commit の SHA が出るほか、`POST /admin/diff-watermark`(installation guide 参照)で watermark を手動で移動できる。旧版 poller が取りこぼした期間を再走査させる経路も同じ endpoint。\n\ndocs poller は `If-None-Match` 付きの条件付きリクエストで repository tree を読み、保存済みの doc record と差分を取る。blob SHA が動いた entry は re-embed し、store にあって tree に無い entry は削除する。**削除は 3 面を teardown する** — Vectorize / D1 FTS5 / structured store — それぞれ独立に実行するので、Vectorize の失敗が実際に retrieval される D1 行を取り残すことはない。wiki 側の 4 面に対してここが 3 面なのは、doc vector ID が `doc_edges` の端点になりえないため。`indexWikiEdges` が唯一の writer であり、src 側も算出される dst 側も wiki vector ID になる。この不変条件が変わったら、ここに edge の teardown を足すこと(issue #203)。なお削除が届くのは現行の vector ID 世代だけで、移行前世代は構造上到達できず、別経路で掃除する(Vector Store の「移行前世代」を参照)。\n\n**削除の枠.** 削除は 1 repo 1 run あたり `MAX_DOC_DELETIONS_PER_REPO_PER_RUN`(既定 5)で cap する。wiki の削除と同じ guard で、1 件あたり 3 subrequest かかるため、大量削除に対して上限なく回すと light cron の invocation 予算を単独で食い潰し、後ろに並ぶ repo を飢えさせうる — 1 つの PR が `.md` を 66 件削除すれば、その全件が 1 run に集中する。削除済み doc の store 行は消えるので残りの集合は縮む一方であり、drain は単調。したがってこの surface にはやり残しの経路が 3 本あり、**どれが効いても tree ETag は据え置く**(上記「ETag の据え置き」に従う): fetch 枠は未処理の変更 doc を残し(issue #149)、削除枠は未削除の doc を残し(issue #203)、embed 失敗は store 行が古い `blobSha` を保持したままの doc を残す(issue #211)。\n\ndocs / releases のどちらの loop も、自前の embedding cap を持たない。両者の fan-out cap(各 10)が `MAX_EMBEDDINGS_PER_RUN` = 50 より小さく、embed 件数を構造的に抑えているため、かつて置かれていた guard は到達不能だった。到達不能な分岐は「それも ETag の据え置きを負うべきか」を読み手が判断できない状態を残し、issue #211 の穴が欠陥ではなく曖昧さとして読めてしまう原因になっていた。**この定数関係はテストで検証している**——各 cap の位置のコメントに委ねてはいない。この repo の fan-out cap は実際に調整される(issue #134 は comment fetch cap を 30 から 10 に下げた)ので、50 より上に引き上げた時に、その面の embed 上限が黙って消えてしまうからである。assertion が落ちることは「新しい cap が誤り」という判定ではない。その loop に embedding guard を戻し、見送った項目が ETag を据え置けるよう、その loop の ETag 据え置き条件に組み込む必要がある、という意味である。\n\nwiki poller は `:45` cron 専属で、GitHub Wiki content の唯一の取り込み経路。Wiki は別 git repo (`{repo}.wiki.git`) に存在し、REST API も webhook event も持たないため、poller が repo ごとに 3 段の HTTP 呼び出しで処理する:\n\n1. `https://github.com/{repo}.wiki.git/info/refs?service=git-upload-pack` を打って wiki 存在検出(200 = 存在、404 = 無効化済 or 未設置 = skip)。\n2. `/{repo}/wiki/_pages` HTML を scrape して page slug を列挙(`/{repo}/wiki/{slug}` 形式の link を tolerant な regex で拾う、`_pages` / `_history` / `_new` 等の特殊 pseudo-page は除外)。この index には効いてくる癖が 2 つある(issue #184): `Home` は実在するのに列挙に現れないので無条件で union する。そして slug は page title の**不可逆な**変換で(`E. Li+language` は `E.-Li-language` に routing されるが実ファイルは `E.-Li+language.md`)、link の表示テキストを 2 つ目のファイル名候補として保持する。\n3. 各 page について `https://raw.githubusercontent.com/wiki/{repo}/{name}.{ext}` から raw markup を取得(`name` は title 由来のファイル名を先に、slug を後に試す)。markup 拡張子は probe 順 (`md` → `markdown`) で検出、次回以降は前回ヒット拡張子を再利用して probe を省略。probe 集合を絞ってあるのは意図的で、1 回の miss が invocation の 1000 subrequest を 1 消費するため(issue #130)。\n\n変更検出は SHA-256 content hash(wiki git smart-HTTP を叩かないと git blob SHA は取れないため、content 直接 hash)。hash 差分のある page だけ re-embed。\n\n**カバレッジ.** page 走査の上限は `MAX_WIKI_FETCHES_PER_REPO_PER_RUN`(既定 20)。page 単位ではなく **HTTP 試行単位**で数えるので、候補を複数持つ page が fan-out で上限を超えることはない。この予算は深い wiki より小さいため、走査は**循環し、保存された cursor から再開する** — 最後に probe した slug を `wiki:{repo}` watermark 行の `etag` 列に置くので、schema 変更は不要。結果として全 page が ceil(pages / 予算) run 以内に到達する。毎 run 列挙の先頭から舐め直す実装が、位置 20 以降を構造的に到達不能にし、77 page の wiki の大半を未索引のまま放置していた(issue #184)。`MAX_WIKI_EMBEDDINGS_PER_RUN`(既定 30)は別軸のまま — Workers AI の embed 予算の上限であって、走査の上限ではない。\n\n**run 全体の予算.** 上に挙げた予算はすべて *repo 単位* である。wiki cron は 1 invocation の中で `POLL_REPOS` を全て歩くので、各 repo は個別には規約を守る一方、その総和を見る主体がいなかった — そして Cloudflare が課金するのは総和のほうである。2026-08-15T03:45Z の tick で観測: 5 つの repo は正常に索引され、6 つ目は raw content probe が全て `Too many subrequests by single Worker invocation` で弾かれ、誰も見ていない page について 8 件の failure を記録した。枯渇は loop が最後に到達した repo が一身に受ける。これは loop の性質であって、その repo の性質ではない。そこで `WIKI_SUBREQUEST_BUDGET_PER_RUN` が invocation に対するこの surface の取り分を宣言し、`wikiFetchBudgetForPass` がその残りを pass の fetch 上限へ変換し、`runWikiSurfaces` が各 pass の *実測* 消費を差し引いてから次の repo を通す。除算ではなく減算である — diff surface の `diffFileBudgetPerPhase` が除算なのは phase の費用が repo ごとに事前に確定するからで、wiki の走査費用は走らせてみるまで分からない。おかげで変更が無かった repo は取り分をほぼ丸ごと後続へ渡す。残予算が pass を賄えなくなった時点で、以降の repo は**呼び出し自体を行わない** — cursor は前 run が残した位置のまま据え置かれ、次の invocation がそこから再開する。深い wiki 1 つが repo 単位の fetch 予算に対して既に持っていた「上限に達したら cursor を残して正常終了」という挙動を、run 全体の軸へ持ち上げた形である。reap の予算は消費した分を全額計上する一方、admission では意図的に確保 *しない* — 取り分 250 に対して repo ごとの最悪値 50 を先取りすると、reap がほとんど何もしない通常時にも全 pass へ課税することになり、何も起きていない run で repo を先送りしてしまう。\n\n**この会計単位は Cloudflare の課金単位ではなく、取り分だけでは観測された事象を直せない.** 定数は call site から読み取ったもので、`fetch` 1 回・Durable Object 往復 1 回・D1 statement 1 本・Vectorize 呼び出し 1 回・Workers AI 呼び出し 1 回をそれぞれ 1 として数えている。ここから 2 つのことが従う。重要なのは 2 つ目である。1 つ目は差の存在: この単位では、6 つ目の repo の最初の probe が例外を投げた時点の消費は約 70(全 page 未変更の場合)から約 292(全 page 再 embed の場合)の間であり、文書化された上限 1000 には遠い。poller 側から見えるものでこの差は説明できず、少なくとも 1 つの binding がこの会計の 1 より高く課金されている。Workers は実行時に消費量を数える手段を提供していないので、**このファイルのどの定数も実際の上限に対して較正できない**。2 つ目は、上の幅が計測誤差ではなく費用プロファイルそのものだという点: 未変更の page は embed fan-out が hash 比較の後で加算されるため fetch 試行分しか課金されない。つまり索引済みの wiki に対する定常運転の run は、同じ page 数の bulk import に比べてごく僅かしか消費しない。**定常状態のプロファイル —— 毎時 cron の通常の姿であり、実際に例外を投げたときの姿 —— では、取り分 250 は何も先送りせず、run は失敗したときと全く同じ順序で進む。** したがって取り分が縛るのは bulk import の形(fetch した page ごとに embed fan-out が乗るため、数 repo で 250 に達する)であって、報告された failure に対処する機構ではない。`DIFF_SUBREQUEST_BUDGET_PER_RUN` が 900 なのは diff surface の per-file 見積もりが実測された reject に対して導出されているからで、この surface にはその導出が無い以上、値は観測された幅の中での判断であって較正ではない。この否定的な結果は回帰テストで固定してあり、黙って成り立たなくなることはない。\n\n**では何が対処しているか: 壁は観測できる.** 上に挙げた予算はすべて見積もりだが、上限に到達したこと自体は見積もりではない — binding が例外を投げ、しかも自分の名前を名乗る。これは推測ではなく計測である。枯渇は invocation スコープかつ終端的で、1 つの subrequest が拒否された後は同じ invocation の後続も全て拒否される。観測された run が probe ごとに例外を投げ続け、walk がそれを page の failure として記録し続けたのはそのためである。よって最初に検知した時点で run を終了する。probe 中だった page は未観測のまま cursor を動かさず、reap は丸ごと省略し(その probe も同じ壁に当たるうえ、候補集合は縮む一方なので見送りに costs は無い)、残りの repo は全て未接触で先送りする。これで**できない**のは *最初の* 拒否を防ぐことで、それが検知イベントそのものだからである。したがって「ログに `Too many subrequests` が出ない」という字義どおりの達成は、binding ごとの実費用を知らない限り不可能である。達成されるのは、1 回の拒否が「loop が最後に到達した repo における偽の page failure の連鎖」に変わらなくなることである。\n\n**処理順は run ごとに回転する.** run 全体の予算を list 順に消費すると、痩せるのは毎 cron 同じ最後尾である — 溢れだけを止めた元の欠陥そのもので、結果を決めるのは依然として loop 上の位置になる。`rotateReposForRun` が tick によって走査順をずらすので、毎時 cron と N repo なら各 repo は N 時間に 1 度先頭に立ち、予算枯渇で先送りされた repo は後続の tick で早い位置に回る。offset は保存せず cron の `scheduledTime` から導出するため watermark を必要とせず、同じ tick に対しては決定的である。\n\n**観測した miss と観測していない miss は別のカウンタ.** 候補が全て 404 を返した page は観測であり `failed` に計上する。probe が *例外を投げた* page は観測ではなく — subrequest 枯渇は例外を投げる — `inconclusive` に計上する。両者は \"all candidates 404\" という同一の文言で報告されており、実在する 8 つの page が「無い」と読めたのはそのためである。`inconclusive` でも cursor は前進させる。その page は次の周回で再試行される一方、cursor を留めると恒久的に到達できない page 1 つが walk 全体を停止させうるからである。\n\n**先頭 page では予算より候補リストを優先する.** 予算切れで打ち切った probe は「404 を観測した」ことにはならないので、walk は cursor を進める**手前**で break し、その page を次の pass に持ち越す(issue #185)。これが自己修復するのは、次の pass がその page の候補リストを最後まで払える場合だけで、予算が 1 page の候補数を下回ると同じ page を永久に probe し直し、walk は前進しない(issue #192)。そこで、その pass でまだ 1 page も visit していない間は候補リストを予算より優先する — 先頭 page は必ず候補を試し切り、そこでの miss は候補を全て観測した上での 404 なので failure として計上し cursor も前進させる。2 page 目以降は従来どおり厳密に予算で打ち切る。超過は 1 page の候補数 - 1 が上限(最大 3 subrequest)で、pass ごとに 1 回だけ。pass の `fetches` が予算を超えうる唯一のケースでもある。これを吸収するのは、この文がかつて引き合いに出していた文書上の invocation 上限ではなく、後述の run 全体の取り分が持つ余裕である。そもそも取り分が 1 page 分を賄えない pass は admission されない。\n\n**周回の完了.** 2 本目の watermark 行 `wiki-lap:{repo}` が *lap anchor* — 現在の周回がどの slug の次から始まったか — を保持する。pass は anchor の直前の page に到達した時点、つまり cursor が一周して戻ってきた時点で `wrapped: true` を返し、anchor はその page へ移動して次の周回がその次から始まる。これが admin endpoint の `done` を到達可能にしている。「この 1 回の pass で全 page を踏破した」という意味では、1 pass の fetch 予算より page 数が多い wiki で真になりようがなく、「`done` まで呼び続けろ」という手順に停止条件が無かった(issue #188)。anchor は保存した index ではなく slug 順で解決するので、周回の途中で page が増減しても desync しない。`cursor=` を明示指定した場合はその地点から新しい周回を開始する。\n\n**孤児の削除.** 現在の `_pages` index に無い page は Vectorize / D1 FTS5 / graph edge table / structured store から削除する。候補集合は structured store **と** 実際の `search_docs` 行の和集合。store から消えているのに index には残っている page は store だけを見る差分からは見えず、これが改名済み 8 page を数ヶ月間 search から引ける状態で残した原因だった(issue #184)。影響範囲は 4 つの guard で抑える — `_pages` index が読めなかった run では削除を完全に skip(空の slug 集合は「全部消えた」ではなく「こちらが盲目」の意)、残った候補は削除前に 1 件ずつ実在を probe する(下記)、1 repo 1 run あたり削除は `MAX_WIKI_DELETIONS_PER_REPO_PER_RUN`(既定 5)・probe は `MAX_WIKI_REAP_PROBES_PER_REPO_PER_RUN`(既定 15)と枠を分けて cap、各 surface は独立に teardown して Vectorize の失敗が実際に retrieval される D1 行を取り残さないようにする。\n\n**削除前の実在確認.** index が読めなかった場合の guard が守るのは列挙の**全面**失敗。**部分**失敗 — `_pages` が実際より少ない page を返す — は集合のレベルでは本物の削除と区別がつかず、実在する page が削除候補に混入する。1 run 5 件の cap は速度を落とすだけで、cron が繰り返す以上は止まらない。そこで確認を集合の外へ出し、page 本体に問い合わせる。候補ごとに raw content を fetch し、候補となる全 extension で 404 を実際に観測した場合にのみ削除を許可する(issue #187)。200・ネットワークエラー・その他の status はいずれも削除を見送り、`orphansWithheld` として報告する。見送りが deadlock を作ることはない — 本当に削除された page は 404 を返し続けるので、後続の run で消える。判定は page 単位なので、正当な大量削除は従来どおりの速度で進む。probe の宛先は `raw.githubusercontent` であって、描画側の `github.com/{repo}/wiki/{slug}` ではない。2026-08-01 の実測では、存在しない page は wiki の root へ 302 redirect して 200 に着地するため、不在を報告できない。コストは候補 1 件あたり extension 数(`md` / `markdown`)以下の subrequest で、walk の fetch 予算とは別枠、probe 枠によって 1 repo 1 run あたり 30 で頭が止まる。**見送りが消費するのは probe 枠であって削除枠ではない。** 削除枠に付けても索引の正しさは保たれるが、drain が止まる — 候補リストは安定ソートされているので、列挙が短いままの間は同じ見送り候補が毎 run 削除枠を埋め、その後ろに並ぶ「本当に削除された page」は列挙が回復するまで到達すらされない(issue #197)。そこで loop は候補リスト全体を走査し、どちらかの枠が尽きた時点で打ち切る。`orphansDeferred` は「削除枠から溢れた数」ではなく「この run で到達しなかった候補数」を表す。guard 導入前から変わらない残余の穴: title 由来のファイル名で保存された page(`E.-Li-language` → `E.-Li+language.md`)は自分の slug に対して 404 を返すが、そのファイル名を運ぶ link text は、当の page が欠けている列挙の中にしか存在しない。別軸として、候補数が索引済み page 数の半分に達した run では警告ログのみを出す — 比率では大量整理と列挙の欠損を区別できないので何も決定させず、run の形を読める状態にするだけに留める。\n\n**即時復旧.** `POST /admin/backfill-wiki?repo=owner/repo[&limit=N][&cursor=SLUG]` が同じ pass を明示予算(1..40、既定 20)で即時実行する。cron と cursor を共有するので互いに前進させ合う。response の `done: true` まで繰り返し呼ぶ — 1 周に必要な呼び出し回数は 1 回ではなく ceil(pages / limit) 回。`cursor=`(空)を渡すと列挙の先頭から walk(と周回)をやり直す。各 call は独立した Worker invocation なので、それぞれ独自の subrequest 予算を持つ。\n\n### 4. Embedding Pipeline\n\nembedding model:\n\n- `@cf/baai/bge-m3`\n\n現行実装:\n\n- 1024 dimensions\n- cosine similarity\n- issue / release は body hash で変更検出する\n- documentation は blob SHA で変更検出する\n- commit diff は append-only。一度 index した (commit_sha, file_path) は再計算しない\n\nResponsibilities:\n\n- title + body または path + content から embedding input を作る\n- 安全な場合は unchanged record を skip する\n- vector と metadata を Vectorize に upsert する\n- 同じ content を D1 FTS5 の `search_docs` table にも upsert する(sparse 側同期、tokenizer_kind は type に応じて `nat` / `code` を自動選択)\n- embedding 失敗時も次回 retry できる状態を保つ\n- embed 経路では D1 FTS5 upsert 失敗は Vectorize upsert を無効化しない。保存した bodyHash が次回の試行を駆動し、次の reindex で sparse 側が reconcile される\n- metadata のみの経路(body は変わらず state / labels / milestone / assignees が変わった場合)では、mirror 書き込みの失敗を best-effort 扱いに**しない**。差分検出の基準を進めずに保持し、次の poll / webhook 配信で再試行させる。基準は IssueStore の record そのものなので、失敗した mirror を追い越して基準を進めると取り残しが恒久化する — state だけの変更は、embed 経路が待っている body 変更を二度と連れてこない(issue #209)\n- この経路の dense / sparse mirror は互いに独立して書く。vector が欠けている行(issue #210)でも sparse 側の state は更新される\n- commit diff は 1 commit 分の file リストを batch embed(Workers AI の `text: string[]` 対応を利用)し、batch ごとに 1 回の Vectorize upsert で N vector を書き込む\n- batch の切れ目は、endpoint が 1 call に課金する量 — **input 件数 × その batch の最長 input** — で決める。input の合計でも file 件数でも token 数の推定でも文字数でもない。天井は bge-m3 の documented な per-input 上限 8192 token ではなく 1 call 単位の上限であり、非公開なので `WORKERS_AI_BATCH_CONTEXT_LIMIT` として拒否応答から記録する(`3030: Max context reached 60678 tokens but model supports only 60000`)。その拒否が数えているのは合計ではなく `件数 × 最長` である。4 件の観測がいずれも input 件数で割り切れ — 18 × 3371 = 60678、17 × 3789 = 64413、20 × 4296 = 85920、16 × 4296 = 68736 — しかも後ろの 2 件は同一 commit を異なる件数で投げたもので、1 件あたりの商が 4296 のまま動かない。これが真の合計であれば、除外された 4 件が全て同一長でない限り起こりえない。endpoint は batch の各スロットを最長の input に padding し、その幅を件数分だけ課金している。1 input を測る単位はこれまで通り UTF-8 バイト数である。バイト数は token 数の近似ではなく**上界**だからで、tokenizer が作れる最も細かい分解が byte_fallback — 語彙に無い文字はその UTF-8 バイト列へ分解される — であり、1 token は入力の 1 バイト以上を必ず消費する。したがって 1 input のコストはそのバイト数に `TOKEN_OVERHEAD_PER_INPUT` を加えた値になる。この overhead が 3 なのは、model が各 input を挟む sentinel 2 個(`` … ``)に加え、SentencePiece の語境界マーカーが先頭 piece に併合されない場合に単独の token として現れ、入力バイトを 1 つも消費しないためである。1 batch 固定の引当てではなく input ごとに課金するのは、batch の input 件数に上限が無く、極小 input が多数並ぶ場合(そのそれぞれが自分の 3 を持つ)に固定引当てでは足りなくなるためである。これ以前の 3 つの予算はいずれも input の**合計**を縛っており、単位を 3 度替えても endpoint は同じ batch を同じ数値で拒否し続けた。1 つ目は token 数の推定で、ASCII を 3 文字/token として読むが bge-m3 が diff patch を割る密度は約 1.4 文字/token である — `+` / `-` 接頭、インデント、記号、短い識別子がいずれも細かく分割される — ため約 2.1x 楽観に振れ、実測 60678 / 64413 token の batch を「30000 token 予算に収まる」と判定して通していた(issue #241)。2 つ目は文字数で、「1 token は 1 文字以上に対応する」という前提に立っていたが、byte_fallback がまさにその前提を破る経路であり、3 バイトの日本語 1 文字が 3 token になりうる。実測では、文字数で高々 60000 と課金した batch に 68736 token が返った — 1 文字あたり 1.146 token である(issue #244)。3 つ目は UTF-8 バイト数で、これは tokenizer のどの分解よりも下にあるので楽観ではまったくなかった — それでも報告値は 1 token も動かなかった。どの単位で合計しようと、合計は endpoint の課金式に現れないからである(issue #246)。per-input 軸に残る前提は「tokenizer の NFKC 正規化が入力をバイト数で膨らませない」ことだけであり、これは互換分解を持つ文字(アラビア語の合字、CJK の組文字)を除いて成立する — 退けた前提が通常の diff テキストで破れたのとは性質が異なり、こちらは入力の大半がその稀なブロックで占められている必要がある。天井を超えた commit は chunk 全体を失敗させ、vector が載らなかった commit は diff watermark が留まる対象(issue #178)なので、その commit を飛ばすのではなく surface がそこで恒久的に停止する。しかも毎 cron 同じ commit で同じ結果になる決定論的な失敗で、同じログに同居する一過性の subrequest 超過とはそこで性質が分かれる(issue #236)。padding から、合計軸には無かった 2 つの性質が従う: 最大の input が batch 全体の単価を決めるので、小さい input の中に大きいものが 1 件混ざれば全件がその長さで課金される。したがってサイズの近いものをまとめるほど batch の効率は上がる。それでも input は与えられた順のまま扱い、並べ替えは行わない — 呼び出し側は返された境界で自分の並列配列(files / metadata)を切っており、input と返り vector の位置対応こそが upsert の前提だからである。並べ替えは call 数を減らす代わりに index 集合の返り値型と呼び出し側が持ち回る置換を要求し、しかも上の file 予算が想定している最悪ケースには何も効かない。天井の下に margin は取らない — 構成上成立する上界に margin が買えるものは残っておらず、batch が 1 つ増えるごとに subrequest を 2 つ消費し、この worker は既に invocation 予算を超過しているためである。天井ちょうどは許容する。拒否応答が名指すのは天井を厳密に超えた課金額だからである。単独で予算を超える input はそのまま単独で送る — さらに削るのは truncate 軸(`MAX_EMBEDDING_INPUT_CHARS`)の仕事であり、落とせばその file が索引から消えるため。その truncate が 1 input を 8000 文字=最大 24000 バイトに抑えるので `2 × 24003 = 48006` は天井に収まり、payload の中身によらず 1 batch は最低 2 file を保持する。これが poller の per-file subrequest 見積りが立っている床である。合計軸から移っても この床は動かない: 2 つの式は input が等長の batch では厳密に一致し、床を読み取るのはまさにその場合であり、上の file 予算が 1 file あたりちょうど 3 subrequest として引き続き賄っている\n\n**索引欠落の修復.** watermark の修正は漏れを止めるだけで、既に空いた穴は埋まらない — 取り残された項目が再 fetch されるのは `updated_at` が動いたときだけで、閉じた履歴はもう動かない。`POST /admin/backfill-issue-index?repo=owner/repo`(installation guide 参照)が欠落そのものを走査する。repository の issue 番号空間は密かつ有界なので、`search_docs` に issue / PR 行が無い番号がそのまま欠落集合であり、数値 cursor が「どこまで走査したか」を厳密に表せる。同じ集合を時刻 cursor で辿ると、欠陥が突いた順序をそのまま持ち込むことになる。GitHub 側に既に無い番号(削除・transfer 済み)は 404 を返すので、retry せず計上のみ。取り込みは body-hash 判定を強制的に飛ばす: 候補はいずれも retrieval surface が欠けていると分かっている項目であり、hash が一致していると(embed 成功後に FTS5 mirror が失敗した行がこの状態になる)そのまま恒久的に skip されてしまうため。state 修復と違いこちらは embed を伴い、1 候補ごとに取り込み fan-out の全額がかかるので、呼び出し側が batch を跨いで sweep を進める。`dry_run=true` は予算を使わずに欠落量だけを測る。sweep は poller の watermark と同じ不変条件に従う: **取り込みそこねた最初の候補を cursor が追い越さない。** これにより 1 call の上限を大きく取りすぎたときの代償は 1 回分の無駄な呼び出しであって取りこぼしではなく、結果が「上限値が subrequest 予算をどれだけ正確に写しているか」に依存しなくなる(issue #216)。GitHub 側に既に無い番号は例外 — 誰が何回試しても取り込めないので、そこで止めると retry の境界にならず sweep が停止する。トレードオフは poller と同じで、恒久的に失敗する候補があると sweep は止まる。ただしそれは応答に現れる(`nextCursor` が渡した `cursor` と同じ値で返る)し、この endpoint は cron ではなく人間 / AI が駆動するので、詰まった番号を手動の `cursor` 指定で越えられる。\n\n**取り残した state の修復.** 上の順序欠陥が残した行は、生きている項目として検索に出続ける。通常の poll では到達できない — 差分検出の基準がすでに GitHub と一致しているため。`POST /admin/backfill-issue-state?repo=owner/repo`(installation guide 参照)が repository 単位でこれを揃える。ページングした `state=open` 一覧を正とし、そこに無い索引済み `open` 行を dense / sparse 両側で `closed` にする。再 embed は伴わない(dense 側は既存の値をそのまま再 upsert し、`state` だけ差し替える)。修復は一方向(`open` → `closed`)で、欠陥が生んだ方向に一致する。open 一覧が打ち切られる場合は何もせず中断する — 「一覧に無いこと」が close の根拠だから。\n\n### 5. Vector Store (Dense)\n\nVectorize は hybrid retrieval の dense 側を担う。次の metadata を伴う semantic embedding を保持する。\n\n- repository\n- item type(`issue` / `pull_request` / `release` / `doc` / `diff`)\n- state\n- labels(個別スロット label_0..3 + CSV フォールバック)\n- milestone\n- assignees(個別スロット assignee_0..1 + CSV フォールバック)\n- update timestamp\n- release tag name\n- documentation path\n- commit SHA / file path / file status / commit date / commit author / blob SHA(diff only)\n\nfresh install向けmetadata index割当(platform上限10枠):\n\n- Pre-filter 対応: repo, type, state, milestone, doc_path\n- 将来の pre-filter 用に格納: label_0, label_1, label_2, label_3, assignee_0\n\n`assignee_1` は vector metadata に保存し続け、現行 post-filter からも利用できるが、metadata index は持たない。platform の10枠上限に対する優先順位として、未使用だった将来用 `assignee_1` pre-filter 枠を `doc_path` へ振り替え、directory 単位の文書検索が ranking 前に dense 候補母集合を絞れるようにする。\n\nVectorize の metadata filter はフィールド間で AND のみサポートし、OR は非対応。`label_0 = \"bug\" OR label_1 = \"bug\"` のようなクエリは表現できない。そのため labels / assignees は overfetch + post-filter で recall を改善している。Vectorize が OR または `$in`-across-fields をサポートした時点で、個別フィールドは即座に pre-filter 化可能。\n\n**vector ID 体系.** ID は `{prefix}:{base64url(sha256(NUL 区切りで連結した parts))}` — surface ごとの短い prefix と 43 文字の digest で、Vectorize の 64 byte 上限に対して 45〜46 byte。hash 化したのは、それ以前の平文形式(`{repo}#doc-{path}`)が長い path で上限を超えたため。2026 年 4 月に置き換えた(`215e2e2` / PR #84)。\n\n**移行前世代.** 平文形式で書かれたベクトルは、どの削除経路からも到達できない — いずれも現行 ID を計算するため。index に残り続け、移行前の内容のまま dense 検索に出て、同じファイルの現行行から候補枠を 1 つ奪う。ファイルを削除しても現行世代の行が reap されるだけである(issue #204)。`POST /admin/purge-legacy-vectors?repo=owner/repo`(installation guide を参照)が repository ごとにこれを畳む。旧 ID は path から決定的に定まるので、孤児集合を repository tree から組み直し — すでに削除済みで worker 側からは列挙できない path は明示 `paths` で補い — 上限付きのバッチで削除する。再 embed は伴わない。削除呼び出しに渡すのは組み直した旧 ID だけであり、64 byte 上限を超える ID は送らずに落とす(upsert 時点で弾かれており、対応するベクトルが存在しないため)。残差は増えずに閉じている(旧形式の書き込みは移行時点で止まっている)ので、この purge は repository ごとに一回性であって定期実行ではない。\n\nvector store は semantic retrieval layer であり、canonical state store ではない。\n\n### 6. Full-Text Index (Sparse, D1 FTS5 / BM25)\n\nD1 の FTS5 virtual table は hybrid retrieval の sparse 側を担う。\n\n採用理由:\n\n- dense-only retrieval はコード識別子、固有名詞、SHA、exact term などの sparse 情報で recall を落とす構造弱点が既知\n- 2026 時点の業界標準では hybrid search が production baseline (reranker は併用例もある別階層)\n- Cloudflare D1 は SQLite FTS5 pre-compiled、BM25 ranking 内蔵、Workers から virtual table として利用可能\n\nSchema 概要:\n\n- `search_docs` — external content table(source of truth、vector_id を primary key)。索引対象テキストを 2 列で持つ: `content` が生本文、`content_fts` が分かち書き済み本文\n- `search_docs_nat_fts_v3` — porter + unicode61 tokenizer の稼働中 FTS5 virtual table(自然言語: issue / PR / release / doc / wiki_doc / comment / review)。external content は `content_fts`\n- `search_docs_code_fts_v2` — trigram tokenizer の稼働中 FTS5 virtual table(コード / SHA / identifier: diff)。external content は生の `content`。旧世代(nat v2 および v1 の 2 table)は過去の corruption を修復時に触らないため残すが、query / update 対象にはしない\n\ntokenizer 選択:\n\n- `porter` — 自然言語の stem matching に適する\n- `trigram` — SHA prefix、CamelCase、file path などの部分一致に適する\n- tokenizer_kind 列で row をどちらの virtual table に振り分けるか決定\n- 各稼働中 FTS table は `search_docs` の tokenizer-filtered view を external-content relation に指定し、宣言上の content row 集合と実際の index row 集合を一致させる。trigger で自動 sync し、すべての分岐を `tokenizer_kind` で guard して、各 row は片方の FTS5 table にだけ insert / delete する。索引したことのない反対側 tokenizer に FTS5 `delete` command を送ることは禁止する(external-content index が壊れるため)\n- 復旧時は新しい FTS generation を作り、`WHERE tokenizer_kind = ...` で各 table を個別 backfill する。FTS5 `rebuild` は全 `search_docs` row を各 tokenizer に流して同じ分離不変条件を壊すため使用しない\n\n分かち書き(自然文側):\n\n- `unicode61` は非英数字でしか token を切らない。日本語には区切り記号が無いため仮名漢字の連なりが丸ごと 1 token になり、日本語の句クエリは何にも当たらない。本 corpus の大半が日本語であるため、hybrid retrieval が実質 dense 単独に退化していた\n- D1 はカスタム tokenizer を読み込めない(`tokenize = 'icu'` は `no such tokenizer: icu` で失敗する)。したがって語境界は SQLite に渡る前に挿入する必要がある\n- 分割には `Intl.Segmenter`(workerd 同梱、依存追加なし)を使う。索引側と検索側の両方で走らせる: 取り込み経路は分かち書き済み本文を `content_fts` に格納し、検索経路は分かち書き済みクエリから nat 側の `MATCH` 文字列を組む。**対称性が正しさの条件**である。カタカナ語などは誤分割するが、クエリ側も同じ誤分割を通るため、劣化するのは精度だけで再現率は落ちない\n- CJK を含まないテキストは素通しするため、英語の挙動は前世代と byte 単位で同一\n- nat 分岐と code 分岐は同一クエリから**別々の** `MATCH` 文字列を組む(nat = 分かち書き後、code = 生。挿入した空白は trigram の部分一致を壊すため)\n- `content` を生のまま保持するのは、reranker の入力であり、かつ trigram index の索引対象であるため\n- migration 0006 は `content_fts` を `content` のコピーで初期化することしかできない(分かち書きは JS にしか存在しない)。既存 row の再分割は `POST /admin/backfill-fts-segments` が `rowid` cursor でバッチ実行する。分かち書き済みの row は書き込まずに skip するため idempotent で、中断しても先頭から再実行できる\n\nクエリ長の階層化(nat 側):\n\n- 全トークンを要求する連言 `MATCH` は、クエリが長くなるほど満たすのが単調に難しくなる。本番で同一の索引済み文書に対し実測すると、3 トークンで候補 3 件、約 12 トークンで 2 件、約 17 トークンで 0 件だった。自然文の問いはまさにこの帯域に入るため、hybrid の sparse 側が実効していたのは短い単語列に対してだけだった\n- この崖は言語非依存である。日本語は分かち書きで助詞が独立トークンになるぶん早く到達するが、英語の長文クエリでも落ちる。したがって対処も言語非依存にする(stopword / 助詞のリストは持たない)\n- nat 側は strict(全トークン)と relaxed(いずれかのトークン)の **2 本**の `MATCH` 文字列を、同一 UNION の別 arm として `tier` 列付きで発行し、BM25 score より先に tier で並べる。strict の hit は従来と同じ順位を保ち、relaxed はその下に追加されるだけなので、短いクエリの precision は退行しない。relaxed が見えるのは strict が `topK` を埋めきれなかった分だけ——それが崖そのものである。クエリ長の閾値は不要\n- relaxed 側の precision は BM25 が担う。IDF 重みが「より多くの・よりレアな語を含む文書」を上位に置き、高頻度の機能語(`の` / `は` / `the` / `of`)の寄与をほぼ 0 にする。stopword リストがやるはずの仕事を、リストを持たずに ranker がやる\n- 2 tier を「AND が 0 件なら OR で再試行」ではなく単一 statement に載せるのは、再試行形式が D1 への往復を 1 回増やすうえ、実測の中間帯(約 12 トークンで弱い 2 件)を改善しないため。受容したコスト: relaxed arm は全クエリトークンの posting list を辿るので、長いクエリでは従来より読む row が増える。問題化した場合の次の手は `fts5vocab` table を使った IDF ベースの語の刈り込みであり、手書きの stopword リストではない\n- 同一文書が両 tier から返り得るため、`topK` で切る前に `vector_id` で重複排除する(tier が良い方、次に score が良い方を残す)\n- code(trigram)arm は strict のまま。識別子の部分一致は同じ崖を持たない\n\nvector_id は Vectorize 側と同一(deterministic SHA-256 ベース)で、RRF 合成時に dense hit と sparse hit を追加 round-trip なしで join できる。\n\nVectorize metadata filter と同じく、`repo` / `type` / `state` / `milestone` は SQL WHERE 句で pre-filter する。\n\nBM25 ranking 関数 `bm25()` で score を取得する。値が小さいほど good match。RRF 合成では rank に変換する。\n\n### 7. Structured State Store\n\nDurable Object + SQLite は次の structured record を保持する。\n\n- issue / pull request\n- release\n- documentation file state(repo `docs/` 等の `.md` ファイル)\n- GitHub Wiki page state(page slug、拡張子、content hash)\n- commit diff file state(1 row = 1 file-in-commit)\n- polling watermark\n\nこの store は次を支える。\n\n- `get_issue_context`\n- `list_recent_activity`\n- semantic search hit の enrichment\n\n**recency window.** `/recent*` 系 endpoint は、各 surface 自身の timestamp 列(`updated_at` / `published_at` / `commit_date`)に対する半開区間 `[since, until)` を新しい順・最大 `limit` 件で読む。両端とも SQL の条件である。`until` を返り値側で後置フィルタすると古い窓に到達できない——cap が先に最新側を取り、フィルタがそれを全部落とすため、数千行ある窓でも 0 件に見える(issue #194)。endpoint あたりの cap は subrequest / D1 読み取り予算の境界として維持するが、これは「窓を置き換える」ものではなく「窓の中を打ち切る」ものになった。\n\n### 8. Graph Index(opt-in, D1 `doc_edges`)\n\n判断知(Decision Structure = wiki の kebab エントリ)の関係を索引化する additive なグラフ層。dense(Vectorize)/ sparse(FTS5)に並ぶ第3の surface だが、**既定では retrieval から読まれない**。\n\n- **スキーマ**: `doc_edges(src_vector_id, dst_vector_id, repo, src_slug, dst_slug, edge_kind, updated_at)`(`migrations/0002_graph_edges.sql`)。src/dst は wiki の決定的 vector_id(`wikiDocVectorId`)。\n- **エッジ抽出**: wiki ページ index 時(`processAndUpsertWikiDoc`)、同 repo の既知 wiki slug が本文に出現したら A→B の \"mention\" エッジを生成(`src/graph.ts` の `indexWikiEdges`)。**決定的 slug-match(LLM 不要・ロスなし)**。dst は計算で求まるので未 index でも記録可(dangling 可)。typed(supersede/depend/conflict)は将来スコープ。\n- **traversal**: `queryNeighbors` が `WITH RECURSIVE`(標準 SQLite、拡張不要)で seed の 1–2 hop neighbor を無向に辿る。\n- **retrieval 統合**: `search` の `graph_expand`(既定 false)/ `graph_hops`(既定 1)。true の時のみ、RRF 後の最終結果を seed に neighbor を辿り、関連 wiki ページを **独立した軸**として返す — `results` とは別の `graph_results` 配列であり、`results` には混ぜない(issue #234)。**false の時は既存挙動と完全同一(回帰なし)で、`graph_results` フィールド自体が存在しない**。\n- **軸の分離(issue #234)**: 関係性軸には関連度スコアが無い。`doc_edges` にエッジ重みは無く、走査は hop 順のみだからである。単一順位へ融合するには共通尺度が要るため、以前は `score: 0` を付けて `results` の末尾に置いていた。消費側からはそれが「ランカーが 0 と評価した候補」と区別できず、結果として関係性由来の候補は順位競争に参加できなかった。対処はスコアの新設ではなく融合の停止である。独立した配列の中では `graph_hop` 昇順が軸内順序として十分に意味を持つ(1 hop は 2 hop より近い)ので、共通尺度は不要になる。この軸の要素は識別情報と `graph_hop` / `graph_from` を持ち、スコア系フィールドは**持たない** — スコアが無いことは 0 点ではない。`count` はキーワード軸の件数、`graph_neighbors` は関係性軸の件数を数える。消費側の triage 指針は tool description に明記する — 両軸に出現=独立した二経路の一致で最優先、キーワードのみ=語の一致、関係性のみ=語彙は一致しないが構造的に近い。一般化すると、**単一の融合済みランキングには軸ごとの健全性を報告する場所が無い**。日本語句クエリで sparse 側が候補ゼロになる事象(#180)が `fusion` を単一ランカーに切り替えて初めて検出されたのと同じ性質である。\n- **delete fan-out**: wiki ページ削除時に `deleteEdgesForVector`(当該 vector を端点に持つエッジを除去)。repository docs 側の削除はこれを呼ばない — doc vector ID はここの端点になりえないため(issue #203)。\n- **backfill**: `POST /admin/backfill-edges?repo=owner/repo`(GITHUB_TOKEN ヘッダ)。既存 index 済み wiki の content から一括抽出(GitHub 再取得不要)。\n- **評価**: 本番 ship 後の実運用観測(judgment-learning が関連判断を拾えるか)。offline eval harness は作らない。\n\n## Retrieval Model\n\nretrieval layer は hybrid search(dense + sparse)+ cross-encoder rerank + structured filter を 3 段で支える(2026 production baseline)。\n\n### 3-tier Hybrid Retrieval (default)\n\n想定フロー:\n\n1. query の embedding を Workers AI BGE-M3 で生成\n2. structured params から Vectorize filter(dense 側)と D1 SQL WHERE(sparse 側)を同時構築(repo, state, type, milestone と文書 `path_prefix` は両側で pre-filter)\n3. 内部 topK を常にオーバーフェッチ(requestedTopK × 5, max 50)。条件なしなのは、8 の entity 集約がどの経路でも複数行を 1 件に畳むため、rerank 無効時でも候補プールが top_k を上回っていなければ要求件数を満たせないからである。reranker は最大 50 件まで処理\n4. dense (Vectorize.query) と sparse (D1 FTS5 MATCH + BM25) を並列実行\n5. 両 ranker の結果を Reciprocal Rank Fusion(RRF、k=60)で合成\n6. 合成後の rank 順に、labels(AND ロジック、個別フィールド + CSV フォールバック)と assignee を post-filter\n7. reranker 有効時(default ON)は post-filter 後の候補を `@cf/baai/bge-reranker-base` で re-score し、reranker score 降順に並び替え\n8. 同一 entity を指す行を畳み、最上位の行を代表にする(Entity Aggregation 参照)\n9. requestedTopK にトリムして structured context と共に返す\n\n#### Reciprocal Rank Fusion (RRF)\n\nRRF 公式:\n\n```\nscore(d) = sum_over_rankers ( 1 / (k + rank_r(d)) )\n```\n\n- k = 60(Cormack et al. 2009 の canonical 値、Elasticsearch / Vespa / Milvus などの production default)\n- rank_r(d) は ranker r における document d の 1-based rank(その ranker にヒットしていない場合は contribution 0)\n- dense と sparse で score の scale が非互換でも、rank に正規化することで合成可能\n- 片側にしかヒットしない document も部分点を得られる(recall boost)\n\n### Cross-encoder Reranker\n\n3 段目の reranker は `@cf/baai/bge-reranker-base`(Workers AI)を使用する。\n\n採用理由:\n\n- 2026 業界標準で reranker は production baseline の必須層(hybrid + reranker)。Cloudflare AI Search 公式 (2026-04-16) も `@cf/baai/bge-reranker-base` を rerank primitive として組み込み済\n- bi-encoder(BGE-M3)と sparse BM25 は recall 向上には強いが、precision@k では cross-encoder に劣る。RRF で fuse した上位候補を cross-encoder で re-rank することで precision を底上げできる\n- 既存 BGE-M3 embedding と同じ `env.AI` primitive で呼び出せるため追加 binding 不要\n\n実装制約と既知の限界:\n\n- bge-reranker-base は context window 512 tokens(BAAI 元仕様)。`(query, candidate content)` pair が超過しないよう char-budget ベースで truncate(query 200 chars 上限、pair 合計 1700 chars 上限)\n- Workers AI は `contexts[].text` に length >= 1 を要求(エラー 5006: \"Length of '/contexts/N/text' must be >= 1 not met\")。呼び出し時点で content が空のままの候補は rerank 入力から除外する(残った件数が 0 / 1 の場合は AI 呼び出しをスキップして passthrough)\n- reranker 入力の本文は 2 系統から供給する。sparse(FTS5)hit を持つ候補は本文を inline で保持している。dense-only 候補は FTS row が無いため、D1 `search_docs` から `vector_id IN (...)` の一括クエリ 1 回で本文を補完する(1 件ずつの fan-out はしない)。最も顕著だったのは nat index を分かち書きする前の日本語 query で、FTS5 のトークン化で BM25 hit が 0 件になり全候補が dense-only になるため、補完が無いと候補集合が空本文のまま reranker に渡り cross-encoder が一度も動かなかった。分かち書きでこの原因は解消したが、sparse 側が取りこぼした候補は依然として本文を持たずに届くので、補完自体は引き続き必要である。補完中の D1 失敗は致命的に扱わず、既に得られている本文だけで検索を継続する\n- `rerank_applied: true` は、実際に 1 件以上へ reranker score が付与された場合にのみ報告する。空の reranker 結果(スコア可能な本文が 0 件)は失敗と同じ扱いで、fusion 順を維持し `rerank_applied: false` とする。不変条件: `rerank_applied: true` なら必ず 1 件以上の `rerank_score` が non-null\n- reranker は最大 50 件 / 1 検索(Workers AI Free tier 10,000 neurons/day と業界中央値の整合点)\n- bge-reranker-base は英語ベース・多言語非対応。日本語 issue/PR では精度低下リスクあり(runtime 観察対象、将来的に bge-reranker-v2-m3 提供開始時または外部 reranker への切替を別 issue で検討)。上記の dense-only 本文補完は cross-encoder を「動かす」ためのものであって、モデルを多言語対応にするものではない。順位品質は別軸のまま\n- reranker 呼び出し失敗・想定外レスポンス時は graceful fallback(fusion 順を維持、`rerank_applied: false` で通知)\n\n呼び出しコスト試算:\n\n- Workers AI 公式単価: bge-reranker-base = 283 neurons/M tokens\n- 1 検索あたり試算: 約 7.5 neurons (query 30 tokens + 候補 50 件 × 平均 500 tokens, embedding 含む)\n- Free tier 10,000 neurons/day で約 1,300 検索/day 上限\n- neuron 実測値はレスポンスに `usage` フィールドが含まれる場合に取得し、理論試算と照合する(公式未文書化のため存在しない場合は黙ってスキップ)\n\n### Entity Aggregation\n\n1 つの実体が複数行として索引される。ファイルは `doc` 行 + それを触った commit の数だけの `diff` 行、issue は本体 + コメント数だけの `issue_comment` 行、PR は本体 + `pr_review` / `pr_review_comment` 行。これらが同一の `top_k` プールで枠を奪い合う。本番索引での実測(2026-08-01、`top_k: 10`、rrf + rerank)では独立した情報は 10 枠中 6 前後、`dense_only` では 5 枠中 3 枠が同一ファイルだった。\n\ndiff を索引し続けるのは意図的である——diff は判断履歴そのものであり、変更を commit diff として持つ設計が索引の容量を抑えている。したがって畳むのは表示段(fusion / rerank / time sort の後、trim の手前)であって索引側ではない。\n\n**同一実体の定義.** 実体とはその行が指している対象(referent)であって、その行を生んだ作業(event)ではない。\n\n| 行 | key | 畳む |\n|---|---|---|\n| ファイルの `doc` 行 + 複数 commit の `diff` 行 | `file:{repo}:{doc_path ?? file_path}` | する |\n| issue + その `issue_comment` 行 | `thread:{repo}:{number}` | する |\n| PR + その `pr_review` / `pr_review_comment` 行 | `thread:{repo}:{number}` | する |\n| 同一 commit が触った別々のファイル | — | **しない** |\n| issue と、それを閉じる PR | — | **しない** |\n| 同一ソースファイルの他 repo への複製 | — | **しない** |\n\n同じ対象の複数の版を畳んでも対象の数は減らないので、集約が独立した情報を隠すことはない。一方「作業」で畳むと、1 つの commit が触った別々のファイルが 1 枠になり、実際に独立した対象が隠れる。だから key はパスを持ち commit SHA を持たない。issue と、それを閉じる PR も 2 実体のまま残す——同一の作業単位ではあるが、両者を結ぶ `Closes #N` は索引に無く、入れるには索引側の変更が要る。\n\nrepo 横断の複製(Li+ source が各 user repo の `.claude/` に複製されている件)は対象外。同一と判定するには内容 hash を索引に持たせるか、パスの正規化ヒューリスティクスを置くかが要る。前者は索引側の変更で、後者は本当に別物のファイルを誤って畳む。加えて配布先が古い場合はその差異自体が情報である。\n\n`wiki_doc` / `release` は 1 実体 1 行なので行の identity を key にし、畳まれることはない。\n\n**代表の選び方.** fusion / rerank / time sort 後の順位が最上位の行。最新版は固定**しない**。これが「いつ変わったか」を問うクエリへの答えを残す: そのクエリでは該当する古い `diff` が最上位に来るので、それが代表として残る。最新版固定だと答えそのものが消える。\n\n**返却形式.** `top_k` は実体の数で数えるので、10 を要求した呼び出し側には独立実体 10 件が返る。他の行を吸収した代表 item にはフィールドが 1 つ増える。畳んだ行は参照として付され、捨てられない:\n\n```json\n{\n \"...\": \"(代表 item の既存フィールド)\",\n \"same_entity\": {\n \"count\": 3,\n \"others\": [\n { \"type\": \"diff\", \"url\": \"...\", \"updated_at\": \"...\", \"score\": 0.0161, \"commit_sha\": \"601aa38\" }\n ]\n }\n}\n```\n\n`count` は代表を含むので必ず 2 以上。1 件以上畳んだ場合のみ付く。フィールド追加であって既存フィールドの変更ではないので、無視する client には集約前と同じ形に見える。\n\n### 切替オプション\n\n`search` の `fusion` パラメータで retrieval mode を切り替え可能:\n\n- `rrf` (default) — dense + sparse を RRF で合成\n- `dense_only` — Vectorize のみ(debug、semantic 特化クエリ)\n- `sparse_only` — D1 FTS5 BM25 のみ(debug、exact term / identifier クエリ)\n\n`rerank` パラメータで cross-encoder rerank を切り替え可能:\n\n- `true` (default) — RRF 合成後に bge-reranker-base で re-score\n- `false` — rerank skip(faster、Workers AI rerank cost なし。短い識別子クエリで lexical match が決定的な場合や debug に推奨)\n\nこの retrieval layer の目的は keyword match ではなく working state の復元である。3 段化の意図は、BGE-M3 の semantic 表現が弱い短い識別子・SHA prefix・固有名詞で sparse がカバーし、RRF で recall を確保しつつ、cross-encoder で precision を底上げすることにある。\n\n## MCP Tools\n\n### `search`\n\nPurpose:\n\n- issue / pull request / release / documentation / commit diff を **3-tier hybrid search**(dense + sparse BM25 → RRF 合成 → cross-encoder rerank)で引く\n- `type: \"diff\"` 指定で判断履歴(削除済みファイル・非 .md 拡張子を含む)を引ける\n\nParameters:\n\n- `query` required\n- `repo` optional\n- `state` optional\n- `labels` optional\n- `milestone` optional\n- `assignee` optional\n- `type` optional\n- `top_k` optional\n- `fusion` optional — `rrf` (default) / `dense_only` / `sparse_only`\n- `rerank` optional — `true` (default) / `false`\n- `since` / `until` optional — 半開区間 `[since, until)` の時間窓\n- `vector_ids` optional — 保存済み本文の取得。下記参照\n\nReturns:\n\n- repository、type、state、labels、milestone、assignees、URL、RRF fused score を含む ranked match\n- 全 result(および `same_entity.others` の各要素)に `vector_id` — その行の索引キーであり、`vector_ids` が取る取っ手\n- 追加 debug フィールド: `dense_score`、`sparse_score`、`dense_rank`、`sparse_rank`、`rerank_score`(rerank 無効時または fallback 時は null)\n- 同一実体の他の行を吸収した結果には `same_entity`(Entity Aggregation 参照)。`top_k` は行数ではなく実体数で数える\n- top-level metadata: `fusion`、`dense_candidates`、`sparse_candidates`、`rerank_requested`、`rerank_applied`、`filters_unmatched`\n\n**フィルタ不成立(`filters_unmatched`).** `repo` はフルスラッグ(`owner/repo`)の完全一致である——dense 側は Vectorize metadata の `$eq`、sparse 側は `d.repo = ?`。`path_prefix` は repository-relative path が指定 directory prefix で始まる doc 行を選ぶ。短いリポジトリ名や、doc 行を1件も選ばない path prefix を渡すと、返るレスポンスは「本当にヒットが無かった」場合と同じ形になる。`filters_unmatched` がこの2つを分ける: search mode では常に存在し、`[]` は適用した全フィルタが空でない母集合を選べたこと(つまり `count: 0` は真にヒットゼロ)を意味し、名前が載っていればそのフィルタの母集合が空、すなわち誤っているのはクエリではなくフィルタの値である。\n\n区別にフィールドを割く理由は、エージェンティックな多段検索が silent zero のコストを反転させるからである。単発検索ならゼロは呼び出し側が見に行く行き止まりだが、検索ループの中では「この角度には何も無かった」という正常な中間結果として消費されて次へ回る。フィルタ不成立が表に出ないまま、クエリ予算を1回分、偽陰性に使って終わる。\n\n判定は候補集合が空のときだけ存在確認クエリを走らせる——候補が1件でもあればフィルタが成立した証拠なので、追加の読みが hot path に乗ることはない。まず repository を確認し、成立した場合だけその repository 内の doc path を確認する。誤った repository に対して、別の repository では妥当かもしれない path まで誤りと報告しないためである。プローブ自体が失敗した場合は「観測していない不成立」を主張せず、何も報告しない。短い名前からフルスラッグへの自動解決は意図的に非スコープ——複数リポジトリにマッチする名前の曖昧解決を設計する必要がある。\n\n**scan mode(query 空).** Vectorize / FTS5 / reranker を経由せず、structured store の recency endpoint から集約する。`since` / `until` と文書 `path_prefix` は store 側へ push down されるので、窓に対象行があれば、その窓がどれだけ古くても、prefix 外に新しい文書が何件あっても返る。`since` 省略時の既定は `until` の 7 日前(`until` も省略時は現在の 7 日前)。`until` だけ指定した問い合わせが「下限が上限より新しい空窓」に潰れないための既定である。\n\n**文書 path prefix.** `path_prefix` は `type: \"doc\"` と組み合わせた場合だけ有効。repository-relative directory を表し、末尾 `/` が必須で、先頭 `/`、backslash、NUL、空 segment、`.` / `..` segment を含めず、Vectorize が string metadata の先頭64 byteだけを索引する制約に合わせUTF-8で64 byte以内とする。dense / sparse は `doc_path` に同じ半開 lexical range を適用し、scan は range を doc store query へ押し下げ、返った行を `startsWith` でも確認する。fetch mode は `vector_ids` が行を直接名指しするため、従来どおり無視する。\n\nscan mode は top-level に `truncated` を追加する。窓が応答に載せた以上の行を持つとき true になる(endpoint が cap 一杯まで返した、または merge 後の件数が `top_k` を超えた)。これが「該当なし」と「読み切れていない」を呼び出し側に区別させる: 返った最古の行の時刻を次の `until` にして遡ればよい。両者を区別できない欠損調査ツールは、存在しない欠損を報告し実在する取り込みを見落とす——#178 の再検証で 1 日に 2 度踏んだ誤りがこれである。\n\n**保存済み本文の取得(`vector_ids`).** 先行する結果が持つ `vector_id` を渡すと、索引がその行について既に保持している本文を返す。対象は索引済みの全 type——`issue` / `pull_request` / `issue_comment` / `pr_review` / `pr_review_comment` / `release` / `diff` / `doc` / `wiki_doc`。従来は `doc` と `wiki_doc` にしか本文を返す経路が無く、`search` であたりを付けたあと本文を読むには `gh` や grep での一往復が必要だった。本文は D1 から読む。GitHub API を新たに叩かず、subrequest も増えない。\n\nこれは tool ではなく mode である。#104 / #105 で 4 tool が `search` 1 本へ統合されており、本文取得の専用 tool は削除された `get_doc_content` を別名で復活させることに等しい。統合時の設計意図が「mode はパラメータ集合で表現する」であったため、本 mode もその作法に乗る。優先順位は `vector_ids` → `query` 空 → hybrid search。fetch mode では metadata filter は適用しない——行はサーバが選ぶのではなく呼び出し側が名指しするからである。\n\n返るのは索引が持つ本文の複製——embedding input であり、取り込み時に 8000 文字で truncate されている——であって、生きているソースそのものではない。inline された文字列自体はどちらであるかを示さないので、形が示す: 応答は `content_source: \"index\"` と `content_max_chars` を持ち、各行は `content_chars` と `content_truncated` を持つ。このフラグは取り込み時に記録した値ではなく長さから導いているため、本文の自然長がちょうど上限だった行も truncate 済みとして報告される。この向きの誤りは不要な読み直し 1 回で済むが、逆向きの誤りは断片を完全な本文として通してしまう。\n\nid が載るのは search mode の結果だけである——scan mode は structured store を読んでおり、その行は索引キーを持たない。未知・失効した id は `not_found` に載せ、残りの行はそのまま返す。部分成功は利便ではなく契約である。`vector_id` は「その結果集合の中で行に到達するための取っ手」であって永続識別子ではない——採番は既に一度移行している(`src/pipeline/legacy-vector-id.ts`)ため、古い id を再送した呼び出し側にも生きている行は返さなければならない。\n\n`include_content` は別軸であり、既存挙動は変更しない。doc は完全なファイルが必要なので GitHub contents API(および `raw.githubusercontent.com/wiki`)からファイル全体を読み直しており、5 件上限はその API fan-out を抑えるためにある。fetch mode は D1 を読み、上限は呼び出し側が挙げた id の数(1 回あたり最大 50、`top_k` の上限に合わせた)である。同じ「content」でも取得元も上限の根拠も異なる。1 つのフラグに両方を載せることは、1 つの保証で 2 つの挙動を覆うことになる。\n\n非スコープ: 保存されている文字列が embedding input そのものであるため、8000 文字の上限が embedding と保存の双方を兼ねている状態にある。FTS 側に truncate しない本文を持たせるのが根本の形だが、既存行の再 index が必要になる。fetch mode は「既に持っているものを返す」に限定する。\n\n### `get_issue_context`\n\nPurpose:\n\n- 単一 issue または pull request の周辺 state を集約する\n\nReturns:\n\n- issue / PR details\n- linked PRs\n- branch information\n- CI status\n- sub-issues\n- 推定可能な related releases\n\n### `list_recent_activity`\n\nPurpose:\n\n- tracked repository 群の recent activity feed を返す\n\nReturns:\n\n- created / updated / closed issue / PR activity\n- release publication activity\n- documentation update activity\n- commit diff indexing activity\n\n## Authentication\n\n認証は GitHub App + OAuth 2.1 を使う。\n\nRequirements:\n\n- MCP client user を認証する\n- installation 済み repository に access する\n- GitHub API read に token を使う\n- cached Dynamic Client Registration は、登録済み redirect URI 集合が今回の\n OAuth flow で要求する localhost callback URI を全て含む場合だけ再利用し、\n 含まない場合は authorization 前に client registration を置き換える\n\n### Bridge OAuth recovery(issue #250)\n\nローカル bridge は credential を取得するたびに共有 token ファイルを読み、別の\nbridge process が保存した更新も取り込む。401 または refresh 失敗で無効と判明した\ntoken を、その process がメモリや disk からそのまま再採用してはならない。\n有効な既存 credential は引き続き利用する。同時呼び出しは一つの authorization\nattempt を共有し、pending の呼び出しは速やかに戻る。後続の呼び出しは完了・失敗・\n5 分の timeout を観測でき、未処理 Promise rejection を発生させない。\ncallback page の成功表示は token 交換と保存の成功後に出す。error と log は\ntoken・code・state・verifier・authorization URL を含めない。\nMCP protocol と検索の意味は変更しない。\n新しい token record は発行時の client ID を保持し、別 process が共有 client\nregistration を置き換えてもその ID で refresh する。旧 record は共有 registration\nを fallback として使う。token と registration は完成したファイルを atomic に置換する。\n\n## Storage Rules\n\n### Canonical memory surfaces\n\ncanonical project memory は引き続き GitHub artifact 側にある。\n\n- issue body と label\n- pull request と review state\n- repository docs\n- releases\n- commit history(diff)\n\nretrieval system はそれらを index する。source of truth を置き換えない。\n\n### Update behavior\n\n- webhook update はできるだけ早く反映する\n- cron は drift を解消する\n- embedding failure は次回 retry 可能でなければならない\n- delete された issue / PR / release / doc は semantic index(Vectorize)と sparse index(D1 FTS5)の両方から削除する\n- commit diff は append-only なので delete 経路対象外\n\n## Current Deployment Assumptions\n\n- TypeScript codebase\n- Cloudflare Workers runtime\n- Vectorize for dense semantic search(hybrid retrieval の dense 側)\n- Cloudflare D1 for FTS5 BM25 sparse search(hybrid retrieval の sparse 側、migration 経由でスキーマ管理)\n- Workers AI for embedding generation\n- Durable Object / SQLite for structured state\n- `POLL_REPOS` により 1 deployment で複数 repository を追跡できる\n\n## Operational Constraints\n\n### Worker invocation pressure\n\n1 invocation あたりの Workers AI 呼び出しには制限があるため、embedding work は保守的に batch する必要がある。\n\n### Cron CPU pressure\n\n大きな initial sync は CPU limit を超えうるため、pagination と resumable watermark が必要である。\n\n### Durable Object resets\n\ndeployment により Durable Object state が reset されることがあるため、webhook と cron の両経路から GitHub を再読して回復できる必要がある。\n\n### Free-tier hard stop(D1 / Vectorize / Workers AI)\n\nWorkers AI Free(10,000 Neurons/day)、D1 Free、Vectorize Free はいずれも daily / monthly limit 超過で `operations will fail with an error` の hard stop 規約。overage billing は Paid plan 契約時のみ発生する。AI Search(managed)採用しないため AI Search 固有の hard stop 不確実性はスコープ外。\n\n### Retry safety\n\nembedding が失敗した record は incomplete と分かる形で残し、次回 run で retry できるようにする。\n\n## Future Scope\n\nIssue #275 の既定変更を伴わない固定capture比較は [Offline Abstention Replay](4-abstention-replay.ja.md) に入力契約・指標・残るreader評価を定義する。\n\n- ranking と filtering の改善\n- multi-agent handoff retrieval の改善\n- cross-repository state recovery の改善\n\n## 検索 feedback memory(Issue #259)\n\n検索履歴、selected/validated/used、半減期3600秒の retrieved/usage activation、confirmed-only の実 path 強化と receipt 指定取消しを同じ PR で実装する。認証済み GitHub principal ごとに共有・分離する。成功検索は trace を原子的に保存し、保存失敗や部分取得は trace を発行せず feedback 不可とする。検索は既定で履歴を書き、keyword の順位は維持する。use_memory は graph の同 hop 内だけに影響し、2 hop の実 edge を保存する。\n\n新 API は `memory_history`、`record_source_use`、`record_outcome`。設定値、上限、ID/version、batch/idempotency、ledger、error、0008先行移行、既存行 backfill と artifact の契約は [2-feedback-memory.ja.md](2-feedback-memory.ja.md) に定義する。会話/本文/credential の複製は行わず、品質向上の主張は別評価を必要とする。\n\nIssue #263: canonical identity の失敗は資料単位で除外し、正常資料は既存の原子的保存に渡す。検索結果と順序は維持し、部分記録の状態・除外件数・理由を応答と保存 settings に残す。全資料未解決は正常な0件検索と区別する。未解決資料に source_id を発行せず、feedback を不可とする。経路の起点・中間・終点を確認できない graph path は強化対象から除外する。保存失敗と実際の部分取得は引き続き trace を発行しない。\n\nIssue #263 の調査で再現した issue_comment.deleted の部分削除: Vectorize とFTSの両方が削除成功するまでDO canonicalを保持し、片側失敗とDO削除失敗は503を返す。自動再試行は追加しない。対象の残存1件との因果は未確認であり、[調査記録](3-unresolved-source-investigation.ja.md) に事実と仮説を分けて保存する。本番DB変更・一括補修・版番号更新・releaseはこの修正に含めない。\n\n#259 の版契約: live inline doc/wiki は返した本文の SHA-256 を version とし、index snapshot と provenance を分ける。同じ索引 timestamp でも live 本文更新は別 source_id にする。本文を memory に複製しない。\n\n## リリース metadata の一致(Issue #261)\n\n両 CD packaging job は bridge package、lock root、MCP Bundle manifest、MCP registry の npm version を同じ検証済み release tag から同期し、pack / publish 前に一致を assert する。依存版、schema、runtime 条件、Worker の挙動を保持する。対象 field と検証 command は [版数と公開成果物](installation.ja.md#versioning-and-published-artifacts) に定義する。commit 済み版は placeholder のままとし、trusted publishing は別変更とする。\n\nsource の `docs/_Sidebar.md` は既存 Feedback Memory EN/JA ページを参照し、release 後の Wiki navigation に含める。\n"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/2-feedback-memory.md",
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/main/docs/2-feedback-memory.md",
+ "version": "sha256:02dc1d8f0c95eea85db64dd23f2e2d491a399262316e8535dbb59d21ba750c1b",
+ "text": "# Private retrieval feedback memory\n\nLanguage: English | [Japanese](2-feedback-memory.ja.md)\n\nThis specification completes Issue #259's four connected functions: retrieval history, explicit source usage, decaying activation, and reversible graph feedback. It stores the query, retrieval settings, returned source provenance and explicit decisions. It does not archive conversations or duplicate document bodies, titles, access credentials, or authentication props.\n\n## Identity and traces\n\nEvery successful `search` call (search, scan, stored-content fetch, and zero hits) commits a UTC trace before returning `trace_id`, `timestamp`, `feedback_available: true`, `source_id`, `provenance`, and current `activation` on returned rows, including folded `same_entity.others`. The request records filters and controls; settings record mode, effective scan window, fusion/rerank outcomes and both retrieval axes. Source provenance is `{repo,type,identity,version,content_source}`. Identity is issue/PR number, release tag, doc/wiki path, `[commit_sha,file_path]` for diffs, or canonical GitHub comment/review ID. `content_source:\"index\"` uses captured `updated_at` as `version` (the indexed snapshot timestamp for doc/wiki pages). Live inline doc/wiki bodies carry `content_source:\"github_live\"` and `content_version:\"sha256:\"`; that hash is their provenance version and enters the source ID. `index_updated_at` preserves the indexed timestamp as supplemental audit metadata without defining the live version. The same live body stays the same source version when the index timestamp changes; a changed returned body changes its source ID even when the index timestamp is unchanged. Stored-content fetch and graph-axis bodies refer to the indexed copy and keep separate index provenance. Memory stores only the hash/provenance, never the live body. Arbitrary supplemental fields, vector handles and mode do not enter the ID hash.\n\n`source_id` identifies a particular source version, not the unversioned entity. Same entity and captured version have the same ID after a vector-handle migration; an updated version has a different ID. Activation belongs to that version. Mention strength belongs to canonical `[repo,src_slug,dst_slug,edge_kind]`, independently of vector IDs and document versions. Reindexing mention topology neither resets nor rewrites private learned state. Removed mentions cannot be traversed; a saved historical path remains auditable and reversible.\n\nThe server derives `github:` from verified MCP OAuth props. Caller identity fields are not trusted. Same-user clients share private traces and receipts; other users get `unknown_trace`/`unknown_confirmation` rather than someone else's data. The internal DO interface is not publicly routed.\n\n`memory_history {limit:10}` returns newest trace summaries and `next_cursor`; pass that cursor for the next page. `memory_history {trace_id,limit:10}` returns sources, current stages/activation, settings and oldest-first receipt audit. Its `next_cursor` pages **receipts within that trace**, not trace summaries. Cursors belong to the authenticated principal and, for details, the trace. Limits are 1..50; source snapshots cap at 300 per trace, saved trace input at 300,000 characters, queries at 4096 characters, metadata strings at 256, usage batches at 100 entries, and reason/idempotency handles at 1000/128 characters. SQL reads use owner/cursor indexes and bounded pages; no full-history fetch is used.\n\nA failed retrieval is not recorded as successful. If retrieval is partial (a scan surface, sparse retrieval, or graph expansion failed), or the atomic memory write fails, retrieval can still return with `memory_unavailable: true`, `feedback_available: false` and **no `trace_id`**. `memory_error` distinguishes `retrieval_incomplete` and `memory_save_failed`. Memory rejection codes describe ownership, stage, key conflict and confirmation errors without echoing the submitted query or handles. Retrieval error logging emits fixed messages.\n\n### Partial source recording (Issue #263)\n\nCanonical identity and live-version failures exclude only the affected rows from storage; retrieval rows, order and text remain intact. Valid sources commit atomically with a trace and overall `feedback_available:true`. Unresolved rows return `feedback_available:false` and `memory_exclusion_reason:canonical_identity_unavailable|live_version_unavailable`, without source_id/provenance/activation. This applies to results, graph_results and their same_entity.others members.\n\n`memory_recording` returns status complete/partial/unresolved, recorded_sources (distinct saved sources), excluded_sources (excluded returned occurrences), and exclusions [{location,reason}]. Locations name response positions only, e.g. results[0].same_entity.others[1]; no text, title or vector ID is copied. The same object persists as settings.memory_recording in history summaries and details. Old traces lack this field. All-unresolved results emit memory_error:all_sources_unresolved, memory_unavailable and no trace. Genuine zero-hit retrieval saves an empty complete trace. Unexpected exceptions and storage failures are not classified as row exclusions.\n\nGraph origins, intermediates and terminals are checked against indexed metadata using the actual traversed vector IDs, including nodes omitted from the output and dangling nodes. Verify canonical identity, repository and directional mention endpoint slugs. An unverifiable path saves path:[] while retaining the returned graph_path. A resolved terminal can record usage but returns graph_feedback_available:false and graph_feedback_exclusion_reason:unverifiable_graph_path; confirmation yields no_graph_path without credit. memory_recording retains excluded_graph_paths and graph_exclusions. Other verified paths keep confirmation receipts and reversal. Internal node snapshots are removed before response/storage; graph metadata reads cover the union of at most three nodes per returned path. No storage migration is needed.\n\n## Usage and idempotency\n\n`record_source_use {trace_id,idempotency_key,uses:[{source_id,stage}]}` records:\n\n| Stage | Meaning | Activation addition |\n|---|---|---:|\n| selected | Chosen for investigation | 0.2 |\n| validated | Exact source checked and judged usable | 0.5 |\n| used | Actually used in an answer or decision | 1.0 |\n\nStages advance one step at a time. A batch can contain all three steps for the same source after the caller has performed them. Trace membership, stage order, owner and every batch entry are checked in one SQLite `transactionSync`; any rejection rolls back usage, activation and receipt. Resending an already completed stage is a no-op. Retrieval alone is not usage or confirmation.\n\nIdempotency keys are principal-wide across mutation tools and traces. A retry with the same parsed payload returns the original receipt. Another payload with that key returns `idempotency_conflict`. JSON object field order does not affect equivalence; array order does. Concurrent requests serialize through the DO and transaction. A new operation needs a new key.\n\n## Activation and learned strength\n\n`MEMORY_POLICY` fixes half-life at 3600 seconds. On reads/updates, value decays as `value * 0.5^(max(0,elapsed_ms)/3600000)`. Backwards time contributes zero elapsed, and updates keep timestamps monotonic. No cron is needed. `retrieved` adds 0.1 per distinct returned source per successful trace; `usage` adds the explicit stage amounts above. Each channel caps at 10, total at 20; values are finite and nonnegative. Readback includes both channels, their total, and UTC `updated_at`. Reversal removes relation credit; it does not erase a historical usage or its activation.\n\nSearch writes history by default and has `readOnlyHint:false`. Keyword fusion, scores, ranks and rerank behavior are unchanged. `graph_expand:false` remains the graph default. `use_memory:false` preserves the existing graph output order/limit. `use_memory:true` in search mode orders a bounded pool of up to 200 current graph candidates by hop ascending, then summed decayed path strength descending, preserving original order on ties; it then returns at most `min(top_k*2,30)`. This can admit a learned candidate outside the previous output cap. Initial zero strength yields the existing order. Learning never mixes the axes or adds a relevance score; only opt-in graph rows carry `learned_strength` (sum of relation credit, not relevance). Scan/fetch ignore this ordering switch.\n\n## Confirmations and causal reversal\n\n`record_outcome {trace_id,idempotency_key,outcome:\"confirmed\",source_ids,reason}` requires used sources from that trace. It credits only the actual one/two directional mention edges saved by the graph traversal. No origin-to-terminal shortcut, lexical edge or zero-hop edge is created. Usage alone never strengthens a relation.\n\nEach independent trace can add 0.25 per saved edge, with a decayed edge-strength cap of 5. A trace contributes to an edge only once, even when multiple returned sources share it, confirmation is repeated, or its old contribution was reversed. The applied delta is the remaining cap headroom (possibly zero). Receipt and ledger save path, delta, timestamp, reason and active/reversed status. Duplicate, cap, lexical, and already-reversed operations expose non-application reasons.\n\n`corrected`/`rolled_back` requires **one explicit `confirmation_id` from the same trace** and omits `source_ids`. Reversal deactivates precisely that receipt's contributions and subtracts their present decayed deltas. Later independent contributions remain; no scalar reset or inverse guess is used. Unknown/ambiguous confirmations reject atomically. Audit preserves the original receipt and a separate reversal receipt. Reversed traces cannot reacquire credit for the same edge.\n\n## Deployment and schema preparation\n\nNo version, tag or public release is created by this change. No new Worker binding or DO class migration is needed: independent `memory_*` tables are created additively inside existing SQLite `IssueStore`. Existing issue tables and watermarks are retained.\n\nApply D1 migration **0008 before deploying or merging into a branch that automatically deploys**. It adds `comment_id`/`review_id`, with zero as the unresolved legacy sentinel. Deployment preparation (operator action; do not publish credentials):\n\n```powershell\n# Read-only preflight\nnpx wrangler d1 migrations list github-rag-fts --remote\nnpx wrangler d1 execute github-rag-fts --remote --command \"PRAGMA table_info(search_docs)\"\n# After the operator authorizes production mutation, before Worker deployment\nnpx wrangler d1 migrations apply github-rag-fts --remote\n# Verify both new columns; then deploy the reviewed Worker commit\nnpx wrangler d1 execute github-rag-fts --remote --command \"PRAGMA table_info(search_docs)\"\n# Read-only unresolved-row count (after 0008)\nnpx wrangler d1 execute github-rag-fts --remote --command \"SELECT type,COUNT(*) AS unresolved FROM search_docs WHERE (type IN ('issue_comment','pr_review_comment') AND comment_id=0) OR (type='pr_review' AND review_id=0) GROUP BY type\"\n```\n\nAfter deployment, authorized `POST /admin/backfill-source-identities?repo=owner/repo&limit=50[&cursor=TYPE:ID]` repairs historical unchanged rows from existing canonical DO events. Supply the existing administrative credential through a private header; no token belongs in a URL, artifact or shell history. Follow `next_cursor` until `done:true`. Limit 1..100, at most three bounded DO reads and one atomic D1 batch per page. Restarting is safe; no embeddings, GitHub body refetch or index reset is needed. Normal unchanged comment/review ingest also repairs IDs before its hash-skip return. Rows whose canonical event is absent from the DO remain unresolved; inspect that gap before any separate reingest. Unresolved sources cannot accept feedback; valid sources use partial recording above. Backfill does not rewrite private traces/credits. See the [investigation](3-unresolved-source-investigation.ja.md) for historical ID omission, the remaining row, and the reproduced deletion-path fix.\n\nThe bridge includes `server/search-schema.json` and `server/memory-tools.json`. `node scripts/generate-tool-contracts.mjs` regenerates them from Worker Zod schemas; `node scripts/check-schema-drift.mjs` compares all shipped tools' exact nested input/output contracts, bounds, defaults and annotations against the actual protocol. Package both JSON files in npm/mcpb artifacts. Worker deployment enables direct-client tools; the published bridge must include the new artifacts for stdio clients to discover them. Public version selection and publication are subsequent operations.\n\n## Verification and synthetic example\n\nRun `npx vitest run --config vitest.workers.config.ts src/memory-e2e.workers.test.ts src/memory.workers.test.ts`. These fixtures use synthetic principals and local in-process SQLite/D1. The observed lifecycle is search of `syntheticneedle` -> graph path `a->b->c` -> selected/validated/used for `c` -> confirmation with two 0.25 deltas -> corrected with both credits inactive. The independent-trace test confirms again one half-life later: path strength 0.75 -> reversing the first trace leaves 0.50 from the second. The real MCP live-body regression changes only doc/wiki inline text while holding the index timestamp and keyword ranks fixed: retry keeps the same ID, changed live text gets another ID, stored fetch retains the index ID, and usage of the old live version does not mark the new version used. This is correctness evidence, not an evaluation that retrieval quality improved.\n\nCost: each successful retrieval adds one DO request and bounded source hash/activation work. Opt-in learning additionally reads up to 200 saved paths (up to two edges each). Storage grows with private traces and receipts; no automatic retention deletion is included. This global DO serializes writes. No Python process, model distribution, embedding step, or extra graph topology write is added by feedback.\n"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "docs/0-requirements.ja.md",
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/main/docs/0-requirements.ja.md",
+ "version": "sha256:905146f7c5d566a712471205cae8f5eb4234bf3f0fe0dc7589b6ac00e7cb755c",
+ "text": "# 要件仕様\n\n## 目的\n\nGitHub Webhook イベントを Cloudflare Worker で受信・永続化し、MCP プロトコル経由で AI エージェントに提供する。\nエージェントが GitHub の状態変化をリアルタイムに検知し、自律的に対応できるようにする。\n\n## 前提\n\n- GitHub Webhook は Cloudflare Worker に直接到達する(ローカルサーバー不要)\n- イベントは Durable Object 内の SQLite に永続化される\n- AI エージェントは MCP stdio トランスポート(ローカルブリッジ)または Streamable HTTP(リモート)で接続する\n- ローカルブリッジは Worker にツール呼び出しをプロキシし、SSE でリアルタイム通知を中継する\n\n## アーキテクチャ\n\n```\nGitHub --POST--> Cloudflare Worker --> TenantRegistry DO\n | 署名検証 | installation_id -> account_id\n | UUID 付与 |\n | v\n | WebhookStore DO (SQLite) [per-tenant]\n | |\n +-- /mcp (Streamable HTTP) +-- WebSocket / SSE real-time stream\n | createMcpHandler +-- REST endpoints\n | [stateless, per-request] /pending-status\n | +-- tools -> WebhookStore /pending-events\n | /webhook-events\n +-- /events (WebSocket/SSE) /event\n | +-- WebhookStore DO /mark-processed\n |\n +-- /oauth/authorize --> github.com/login/oauth/authorize\n +-- /oauth/callback <-- github.com redirect_uri (Worker-hosted)\n +-- /oauth/token (web_authorization_poll, refresh_token)\n |\n +-- /webhooks/github (POST)\n +-- TenantRegistry -> WebhookStore DO /ingest\n\n +-----------------------------+\n | Local MCP Bridge (.mcpb) |\n | stdio <- Claude Desktop/CLI |\n | -> proxy tool calls to /mcp |\n | -> WebSocket listener -> channel |\n | -> browser: /oauth/authorize |\n | -> poll: /oauth/token |\n +-----------------------------+\n```\n\nシステムは四つのコンポーネントで構成される:\n\n1. **Cloudflare Worker** — Webhook 受信、署名検証、テナントルーティング\n2. **TenantRegistry Durable Object** — installation_id → account_id マッピング管理、テナント単位クォータ管理(単一インスタンス)\n3. **WebhookStore Durable Object** — SQLite によるイベント永続化、REST/SSE エンドポイント(テナント別インスタンス: `store-{accountId}`)\n4. **ステートレス MCP ハンドラ** — MCP Streamable HTTP サーバー、ツール定義。プロトコル版 2026-07-28 でセッションが消えたため Durable Object ではなく、リクエストごとに `createMcpHandler` が組み立てる(#249)\n\nローカルブリッジ(mcp-server/)は Worker に対するプロキシであり、データを保持しない。\n\n### プロトコル版と単レーン切替(#249)\n\nWorker は MCP プロトコル版 **2026-07-28(ステートレスコア)のみ**を提供する。`createMcpHandler` に `legacy: \"reject\"` を渡しており、2025 系のリクエスト(`initialize` を含む)は、この endpoint が提供する唯一の版を名指しした unsupported-protocol-version エラーで返る。\n\nWorker のプロトコル版は、本 repo の 2 成果物(Worker と npx ブリッジ)のあいだの私的な契約である。`mcp-server/server.json` は stdio トランスポートのみを宣言しており、リモートトランスポートの宣言が無いため、第三者クライアントが Worker に直接到達する経路は存在しない。したがって Claude Desktop 側の対応状況は Worker 側移行の条件ではない。ブリッジは二面を持ち、両面は独立している:\n\n| 面 | 実装 | 版 |\n|---|---|---|\n| Claude Desktop に対してサーバー | SDK v1 stdio(`Server` クラス) | 2025 系 |\n| Worker に対してクライアント | SDK v2(`@modelcontextprotocol/client`) | 2026-07-28 に pin |\n\n**互換レーンは実装しない。** 保守されないフォールバック経路は放置されて死にコード化する。畳む条件が観測可能(旧レーンへの到達がゼロになる)であっても、観測できることと観測しに行くことは別であり、運用上その判断は忘れられる。破断の吸収はコードではなく、**minor リリース + README 注意書き**という別チャネルで行う。\n\n非対称の所在: Worker は全員共有の 1 デプロイなので一斉に飛ぶ。ブリッジは利用者ごとで、各自の MCP クライアント再起動で追随する(`@latest` 指定でも起動済みプロセスは古い版を保持する — #247 / #248)。版を固定している外部利用者は再起動しても復旧しない。**この層の切り捨てを受け入れる**というのが本決定である。\n\n\n## 機能要件\n\n### F1. Webhook 受信\n\n| ID | 要件 |\n|----|------|\n| F1.1 | `POST /webhooks/github` で GitHub Webhook ペイロードを受信する |\n| F1.2 | `X-Hub-Signature-256` ヘッダによる HMAC-SHA256 署名検証を行う |\n| F1.3 | 署名不一致時は HTTP 403 を返す |\n| F1.4 | シークレット未設定時は署名検証をスキップする |\n| F1.5 | テナント単位クォータ超過時は HTTP 429 を返す(installation イベントはクォータチェックをスキップ) |\n| F1.6 | 認証チェック順序: IP allowlist → per-IP rate limit → signature → tenant resolution → per-tenant quota → ingest |\n\n### F2. イベント永続化\n\n| ID | 要件 |\n|----|------|\n| F2.1 | イベントを UUID 付きで WebhookStore DO の SQLite に保存する |\n| F2.2 | 各イベントは id, type, payload, received_at, processed フィールドを持つ |\n| F2.3 | trigger_status, last_triggered_at フィールドを保持する(将来の trigger 機能用) |\n| F2.4 | `mark_processed` 実行時に、`processed=1` かつ `received_at` が保持期間(`PURGE_AFTER_DAYS` 日、既定 3)より古いイベントを自動削除する。処理済み死蔵行による DO ストレージ肥大を防ぐ(即時性のための補助経路。本質的な保証は F2.5 の Alarm sweep が担う) |\n| F2.5 | DO Alarm(`ctx.storage.setAlarm` + `alarm()` ハンドラ)による時間駆動の retention sweep を提供する。sweep は (a) `processed=1` かつ `received_at` が `PURGE_AFTER_DAYS` 日(既定 3)より古い処理済みイベントと、(b) `processed=0` かつ `received_at` が `UNPROCESSED_PURGE_AFTER_DAYS` 日(既定 90)より古い未処理イベントを、まとめて削除する。alarm() は実行後に次回 sweep を再スケジュールし、消費ゼロ(`mark_processed` が一度も呼ばれない放置テナント)でも周期実行(日次)される。未処理保持期間を処理済みより長く取るのは未読データ消失の安全域確保のため(3 日 / 90 日の非対称は意図的)。既知の限界(スコープ外): 時間窓は「古さ」を縛るが「量」は縛らないため、高頻度 × 無消費テナントは 90 日窓があっても Cloudflare の 1GB/DO 壁に先に到達しうる。量ベース hard cap は別 issue 候補 |\n\n**イベント構造:**\n\n```json\n{\n \"id\": \"uuid\",\n \"type\": \"issues\",\n \"payload\": {},\n \"received_at\": \"ISO8601\",\n \"processed\": false,\n \"trigger_status\": null,\n \"last_triggered_at\": null\n}\n```\n\n### F3. MCP ツール\n\nステートレス MCP ハンドラが以下のツールセットを提供する。ローカルブリッジはこれをプロキシする。\n\n| ID | ツール名 | 引数 | 戻り値 | 要件 |\n|----|---------|------|--------|------|\n| F3.1 | `get_pending_status` | なし | pending_count, latest_received_at, types | 未処理イベントの軽量スナップショットを返す |\n| F3.2 | `list_pending_events` | limit (1-100, default 20) | サマリー配列 | 未処理イベントのメタデータ一覧を返す(ペイロード含まず) |\n| F3.3 | `get_event` | event_id | 完全イベント or error | UUID 指定で完全なペイロードを返す |\n| F3.4 | `get_webhook_events` | limit (1-100, default 20) | 未処理イベント配列 | 未処理イベントをフルペイロード付きで返す |\n| F3.5 | `mark_processed` | event_id **または** event_ids (1-100) | 単数形: success, event_id, purged/バッチ形: success, marked, failed, results (id 単位の判定), purged | イベントを処理済みにマークし、保持期間超過の処理済みイベントを自動削除する。`purged` は今回削除された件数 |\n\n**F3.1 ローカルブリッジ整形:** ローカルブリッジは `get_pending_status` の戻り値を Claude Code UserPromptSubmit hook の decision JSON shape (`hookSpecificOutput.hookEventName=\"UserPromptSubmit\"` + `additionalContext` に pending_count / types / latest_received_at の自然文要約) にラップして返す。これは `type: \"mcp_tool\"` UserPromptSubmit hook 経由の呼び出しで戻り値が AI 文脈に注入されるための要件であり、手動 tool 呼び出し時も同 shape で返る。リモート (Worker + DO) 側の戻り値構造は変更しない。\n\n**F3.1 空状態の silent (empty silent, #221):** `pending_count == 0` の場合はラップせずリモート戻り値をそのまま返す。Claude Code 側で decision schema に一致しない JSON は silent discard されるため、hook 経由の呼び出しで `additionalContext` に何も注入されず、毎ターン空 reminder のノイズが消える。手動 tool 呼び出しではリモートの raw payload (`{pending_count: 0, types: {}, latest_received_at: null}`) が返り、AI は内容を直接判定できる。\n\n**F3.5 バッチ形 (#245):** `mark_processed` が 1 呼び出しにつき 1 イベントしか受けなかったため、自己操作 1 回で 6〜10 件生じる到達確認イベントの消費が呼び出し回数に比例していた。`event_ids: string[]` を追加し、id を明示列挙したまま往復だけ畳む(filter 一括消費は外部イベントを誤って消しうるため採らない)。\n\n- **後方互換:** 既存の単数 `event_id` 呼び出しは戻り値の形も含めて不変。存在しない id でも `success: true` を返す旧挙動を維持する。\n- **id 単位の判定:** バッチ形は id ごとに `{event_id, success, error?}` を返す。tool 表層で `error` が取る値は `not found`(どの store にも無い)のみ。空の id は store に届く前に tool schema が弾くため、誤解を招く `not found` に化けることはない。\n- **部分失敗:** 1 件の失敗で全体を落とさない。成功した id のマークは確定済みで、呼び出し側は失敗した id だけ再送すればよい。部分失敗は tool error ではなく本文で報告する。\n- **マルチアカウント:** イベントは 1 つの store にのみ存在するため、アクセス可能な全 store に同じバッチを投げ、**いずれかの store が一致した id を成功**とする。全 store が取り逃した id のみ失敗。\n- **purge 回数:** 保持期間 purge はバッチ 1 回につき 1 回のみ走る(id ごとではない)。\n- **MCP proxy:** 静的スキーマ (`mcp-server/server/index.js` / `local-mcp/src/index.ts`) の更新が要る。新 param は再接続では反映されず npm 再公開が必要。\n\n**イベントサマリー構造:**\n\n```json\n{\n \"id\": \"uuid\",\n \"type\": \"issues\",\n \"received_at\": \"ISO8601\",\n \"processed\": false,\n \"trigger_status\": null,\n \"last_triggered_at\": null,\n \"action\": \"opened\",\n \"repo\": \"owner/repo\",\n \"sender\": \"username\",\n \"number\": 123,\n \"title\": \"Issue title\",\n \"url\": \"https://github.com/...\"\n}\n```\n\n### F4. SSE リアルタイムイベント配信\n\n| ID | 要件 |\n|----|------|\n| F4.1 | `GET /events` で SSE ストリームを提供する |\n| F4.2 | Webhook ingest 時に接続中の全 SSE クライアントにイベントサマリーをブロードキャストする |\n| F4.3 | 30 秒間隔でハートビートを送信する |\n| F4.4 | クライアント切断時にクリーンアップする |\n\n### F5. チャンネル通知(ローカルブリッジ)\n\n| ID | 要件 |\n|----|------|\n| F5.1 | ローカルブリッジが Claude Code の `claude/channel` experimental capability を宣言する |\n| F5.2 | Worker の SSE エンドポイントに接続し、新規イベント検出時に `notifications/claude/channel` を送信する |\n| F5.3 | 通知内容はイベントサマリー(type, repo, action, title, sender)を含む |\n| F5.4 | `meta` フィールドに `chat_id`, `message_id`, `user`, `ts` を付与する |\n| F5.5 | `WEBHOOK_CHANNEL=0` 環境変数でチャンネル通知を無効化できる(デフォルト: 有効) |\n| F5.6 | チャンネル通知は one-way(読み取り専用)で、reply tool は提供しない |\n\n### F6. 推奨ポーリングフロー\n\n| ステップ | 操作 |\n|---------|------|\n| 1 | `get_pending_status()` を 60 秒間隔でポーリング |\n| 2 | `pending_count > 0` なら `list_pending_events()` でサマリー取得 |\n| 3 | フルペイロードが必要なイベントのみ `get_event(event_id)` で取得 |\n| 4 | 処理完了後 `mark_processed` でマーク。複数件をまとめて処理した場合は `event_ids` で 1 呼び出しに畳む |\n\n### F7. OAuth 認証(Worker-hosted web OAuth)\n\nWorker は GitHub の web OAuth flow をホストする独自実装を備える(v0.11.0 の device authorization grant は v0.11.1 で撤去。GitHub 標準のログイン + 2FA UX に回帰しつつ、v0.10.x の chronic auth loop 原因である localhost callback 依存と refresh rotation desync を構造的に解消する)。\n\n| ID | 要件 |\n|----|------|\n| F7.1 | `GET /.well-known/oauth-authorization-server` で RFC 8414 メタデータを返す(`authorization_endpoint` / `token_endpoint` / `grant_types_supported=[urn:ietf:params:oauth:grant-type:web_authorization_poll, refresh_token]`) |\n| F7.2 | `POST /oauth/register` で RFC 7591 dynamic client registration を行う(public client、secret 発行なし) |\n| F7.3 | `GET /oauth/authorize?client_id=&state=[&scope=...]` で `web_auth_state:{state}` レコードを `pending` として作成し、`redirect_uri=https:///oauth/callback` を固定して `https://github.com/login/oauth/authorize` に 302 リダイレクトする |\n| F7.4 | `GET /oauth/callback?code=&state=` で GitHub authorization code を confidential client として access token に交換し、`fetchGitHubProps()` で user profile + installations を取得、Worker 独自 bearer token pair を発行して `web_auth_state` を `approved` に遷移させる。ユーザにはタブを閉じるよう案内する HTML を返す |\n| F7.5 | `POST /oauth/token` で `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` を処理する。`pending` → `400 authorization_pending`、`approved` → `200` で bearer pair を返し state レコードを消費、`denied` → `400 access_denied`、期限切れ → `400 expired_token`(RFC 8628 §3.5 のエラー形式を再利用) |\n| F7.6 | `POST /oauth/token` で `grant_type=refresh_token` を処理し、access token と refresh token を rotate する(ブリッジ側 RC1 修正と組み合わせて desync を解消) |\n| F7.7 | 保護対象 API ルート(`/mcp`, `/events`)は `Authorization: Bearer ` ヘッダによる独自 token 検証 middleware で認可する |\n| F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 |\n| F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) |\n| F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) |\n| F7.11 | ローカルブリッジは refresh の `invalid_grant` 後、共有 tokens file を直ちに再読込し、最大 1 秒の待機予算内で 50 ms 間隔に再読込する。この予算はファイル更新待ちの期限であり、既存の refresh 通信の所要時間上限は変更しない。新しい refresh_token の世代を見つけたら、有効な access token(期限なし、または期限まで 5 分超)があれば採用し、それ以外は refresh を最大 2 回試す。同じ世代は再試行しない。最初の refresh と回復処理は両 access-token 取得経路で単一のプロセス内 promise を共有し、成功・失敗の双方で解放する。更新なし・欠損・不正なファイル・本物の失効は有界に既存の全面認証/背景認証へ戻り、別種の refresh エラーでは再読込待機をしない。正常 refresh と有効なキャッシュには待機を追加しない。プロセス間 file lock とブラウザ認証共有化は対象外(#258。#242 の過去原因の確定を意味しない) |\n\n**Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):**\n\nMCP 2026-07-28 の deprecated レジストリ 6 件のうち本 repo に該当するのは Dynamic Client Registration(F7.2)のみ。移行先は Client ID Metadata Documents。最短撤去は 2027-07-28 以降の最初のリビジョンであり、しかも「最短撤去」は撤去が可能になる時点にすぎず、実際の撤去は Core Maintainer 判断でそれ以降にずれうる。仕様からの撤去は SDK に削除義務を課さない。\n\n本移行では撤去しない。理由は相乗りの前提が成立しないこと — 本移行は OAuth の実装に一切触れておらず(ブリッジ側は transport の `authProvider` に繋ぎ直しただけ)、DCR 撤去は両面の認証設計変更になる。プロトコル切替と同一リリースに載せると、利用者が再起動して降りてくる先で破断面が二つ同時に開く。\n\n該当しないもの: Roots / Sampling / Logging / `includeContext`(未使用)、HTTP+SSE トランスポート(Streamable HTTP のみを提供しているため未露出)。\n\n**GitHub App 前提条件:**\n\n- 使用する upstream endpoint: `https://github.com/login/oauth/authorize`(web), `POST https://github.com/login/oauth/access_token`\n- GitHub App の設定で `redirect_uri = https:///oauth/callback` を登録する必要がある(smgjp.com プレビュー + self-host 例示)\n\n## 非機能要件\n\n### N1. セキュリティ\n\n| ID | 要件 |\n|----|------|\n| N1.1 | Webhook シークレットは Cloudflare Worker の Secret(`GITHUB_WEBHOOK_SECRET`)で管理する |\n| N1.2 | HMAC-SHA256 による署名検証でスプーフィングを防止する |\n| N1.3 | ローカルブリッジは stdio トランスポートを使用し、ネットワーク露出しない |\n| N1.4 | Webhook エンドポイントは多層防御で DDoS/課金攻撃を防止する: IP allowlist → per-IP rate limit → signature → tenant resolution → per-tenant quota |\n| N1.5 | GitHub IP allowlist(api.github.com/meta の hooks フィールド)で非 GitHub IP を最外層でブロックする(github-ip.ts) |\n| N1.6 | Per-IP rate limit はインメモリ sliding window で Worker isolate 内に実装する(rate-limit.ts) |\n| N1.7 | Per-tenant quota は TenantRegistry DO で atomic check-and-increment により管理し、単一テナントの無制限ストレージ消費を防止する |\n| N1.8 | Cloudflare WAF カスタムルールによる外部 IP ブロックを推奨する(Worker 到達前にブロックし CPU 課金を削減) |\n\n### N2. 構成\n\n| ID | 項目 | ソース | デフォルト |\n|----|------|--------|-----------|\n| N2.1 | Worker URL | `WEBHOOK_WORKER_URL` 環境変数 | なし(必須) |\n| N2.2 | シークレット | Cloudflare Secret `GITHUB_WEBHOOK_SECRET` | なし(検証スキップ) |\n| N2.3 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) |\n| N2.4 | カスタムドメイン | `github-webhook.smgjp.com` | Cloudflare Worker のカスタムドメインとして設定済み |\n| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + Worker-hosted web OAuth で認証を処理する |\n| N2.6 | プレビューインスタンス | `preview` 環境 | 本番と同一構成の検証用インスタンス |\n| N2.7 | 処理済みイベント保持期間 | `PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `3`(日)。`mark_processed` 時および DO Alarm sweep 時に保持期間超過の処理済みイベントを削除。`0` で処理済みを即削除 |\n| N2.8 | 未処理イベント保持期間 | `UNPROCESSED_PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `90`(日)。DO Alarm sweep 時に保持期間超過の未処理イベントを削除。`0` で未処理を即削除。処理済み(3 日)より長いのは未読データ消失の安全域(非対称は意図的) |\n\n### N3. 制約\n\n| ID | 制約 |\n|----|------|\n| N3.1 | WebhookStore DO はテナント別インスタンス(`idFromName(\"store-{accountId}\")`)で動作する。MCP 側にテナント別インスタンスは無い(2026-07-28 でセッションが消え、リクエストごとの props から store 名を解決する)。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する |\n| N3.2 | SSE 接続は DO のメモリ内で管理される(DO eviction 時に切断) |\n| N3.3 | Worker とブリッジのあいだにセッションは無い(2026-07-28 ステートレスコア、#249)。ブリッジが再利用するのは MCP クライアントとその transport であり、切断のコストは再接続一回に閉じる |\n| N3.5 | Worker は 2026-07-28 のみを提供し、2025 系リクエスト(`initialize` を含む)を `legacy: \"reject\"` で拒否する。互換レーンは持たない(#249 決定 1)。ブリッジのクライアント面も同じ版に pin する |\n| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、MCP ツールが複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーからも参照できる。accessible_account_ids はリクエストごとの props から読む |\n\n## CI/CD\n\n### テスト(CI)\n\n| トリガー | ジョブ | 内容 |\n|---------|--------|------|\n| PR to main / push to main | test | Node.js syntax check |\n\n### リリース(CD)\n\n| トリガー | ジョブ | 内容 |\n|---------|--------|------|\n| release published | build-mcpb | `mcpb pack` で .mcpb 生成 |\n| release published | attach-mcpb | `gh release upload` で .mcpb をリリースに添付(build-mcpb 後) |\n| release published | npm-publish | npm レジストリに公開 |\n\nリリースフロー:\n1. AI が `gh release create` でリリースを作成する(PAT 経由で release イベントが発火する)\n2. Release published イベントで CD ワークフローが発火: .mcpb 生成 → .mcpb リリース添付 → npm publish\n3. npm publish 時にリリースタグ名から自動でバージョンを同期する(package.json の手動更新不要)\n4. プレリリースタグ(`-` を含む)は `next` dist-tag で公開、正式リリースは `latest` で公開\n\n### 配送鎖と完了条件\n\n```\nmerge → gh release create → CD (npm-publish) → registry の latest 更新\n → 利用側 MCP クライアントの再起動 → npx が新版を解決 → 利用側に到達\n```\n\n後半 2 段は CD の外側にあり、リポジトリ側からは実行できない。npx がパッケージのバージョンを解決するの\nはプロセス起動時の一度きりであり(クライアント設定で `@latest` を指定していても同じ)、すでに起動して\nいるプロセスは registry がどう変わっても起動時のバージョンを保持し続ける(v0.11.9 のリリース直後に実\n測: registry が 0.11.9 を返している間、稼働中のクライアントは 0.11.8 のままだった。0.11.9 に移ったの\nは Claude Desktop を再起動した時点である)。\n\nしたがって **「registry が新版を返す」はリリース完了の判定基準にならない**。特にプロキシの静的ツール\nスキーマを変更したリリースは、利用側プロセスが再起動して初めて成果が現れる。リリース完了報告を\nregistry の確認で締めると、届いていない状態を届いたと報告することになる。\n\nregistry の確認には `--prefer-online` を付ける。npm CLI は registry のメタデータをキャッシュするため、\npublish 直後の `npm view github-webhook-mcp version` は旧版を返しうる(同じく v0.11.9 で実測)。\n\n```bash\nnpm view github-webhook-mcp version --prefer-online\n```\n\nnpx のキャッシュ解決挙動そのものは本リポジトリの管理外であり、規定できるのは\n「再起動が要る」という事実と上記の確認手段までとする。\n\n## 依存関係\n\n### Cloudflare Worker\n\n| パッケージ | 用途 |\n|-----------|------|\n| agents | Cloudflare Agents SDK (`createMcpHandler` — Worker 向けステートレス MCP ハンドラ) |\n| @modelcontextprotocol/server | MCP SDK v2 サーバー(プロトコル版 2026-07-28。`agents` が非 optional peer として exact 2.0.0 を要求するため exact 固定) |\n| zod | スキーマバリデーション |\n\nOAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み。v0.11.1 で Worker-hosted web OAuth に切り替え(device authorization grant は撤去)。\n\n### ローカルブリッジ (mcp-server/)\n\n| パッケージ | 用途 |\n|-----------|------|\n| @modelcontextprotocol/sdk | MCP SDK v1(Claude Desktop に対するサーバー面。`Server` クラス直接使用) |\n| @modelcontextprotocol/client | MCP SDK v2 クライアント(Worker に対するクライアント面。版 2026-07-28 に pin) |\n| eventsource | SSE クライアント |\n\nNode.js >= 18.0.0 が必要。\n\n## ファイル構成\n\n| パス | 用途 |\n|-----|------|\n| `worker/src/index.ts` | Cloudflare Worker エントリポイント |\n| `worker/src/mcp.ts` | MCP サーバー factory(ツール定義。リクエストごとに生成、テナントは props から解決) |\n| `worker/src/retired-do.ts` | 退役した WebhookMcpAgent クラス(過去 migration 制約のためだけに残す) |\n| `worker/src/store.ts` | WebhookStore DO(SQLite + SSE、テナント別インスタンス) |\n| `worker/src/tenant.ts` | TenantRegistry DO(installation-account マッピング、クォータ管理) |\n| `worker/src/oauth.ts` | Worker-hosted web OAuth 自前実装(metadata / register / authorize / callback / token / 独自 token 検証 middleware) |\n| `worker/src/oauth-store.ts` | OAuth KV schema helper(client / web_auth_state / token / refresh / grant レコード操作) |\n| `worker/wrangler.toml` | Worker デプロイ設定 |\n| `shared/src/types.ts` | 共有型定義 |\n| `shared/src/summarize.ts` | イベントサマリー生成 |\n| `local-mcp/src/index.ts` | ローカルブリッジ(TypeScript、開発用) |\n| `mcp-server/server/index.js` | ローカルブリッジ(JS、.mcpb 配布用) |\n| `mcp-server/manifest.json` | MCPB マニフェスト |\n| `mcp-server/package.json` | npm パッケージ定義 |\n"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "docs/0-requirements.md",
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/main/docs/0-requirements.md",
+ "version": "sha256:0bcd2a072539fc801df6690784e0127cf1b9df4a8dd50122378a8b1224584fe6",
+ "text": "# github-webhook-mcp Requirements Specification\n\n## Purpose\n\nGitHub Webhook イベントを Cloudflare Worker で受信・永続化し、MCP プロトコル経由で AI エージェントに提供する。\nエージェントが GitHub の状態変化をリアルタイムに検知し、自律的に対応できるようにする。\n\n## Premise\n\n- GitHub Webhook は Cloudflare Worker に直接到達する(ローカルサーバー不要)\n- イベントは Durable Object 内の SQLite に永続化される\n- AI エージェントは MCP stdio トランスポート(ローカルブリッジ)または Streamable HTTP(リモート)で接続する\n- ローカルブリッジは Worker にツール呼び出しをプロキシし、WebSocket でリアルタイム通知を中継する\n\n## Architecture\n\n```\nGitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO\n │ 署名検証 │ installation_id → account_id\n │ UUID 付与 │\n │ ▼\n │ WebhookStore DO (SQLite) [per-tenant]\n │ │\n ├── /mcp (Streamable HTTP) ├── WebSocket / SSE real-time stream\n │ createMcpHandler └── REST endpoints\n │ [stateless, per-request] /pending-status\n │ └── tools → WebhookStore /pending-events\n │ /webhook-events\n ├── /events (WebSocket/SSE) /event\n │ └── WebhookStore DO /mark-processed\n │\n ├── /oauth/authorize ──▶ github.com/login/oauth/authorize\n ├── /oauth/callback ◀── github.com redirect_uri (Worker-hosted)\n ├── /oauth/token (web_authorization_poll, refresh_token)\n │\n └── /webhooks/github (POST)\n └── TenantRegistry → WebhookStore DO /ingest\n\n ┌─────────────────────────────┐\n │ Local MCP Bridge (.mcpb) │\n │ stdio ← Claude Desktop/CLI │\n │ → proxy tool calls to /mcp │\n │ → WebSocket listener → channel │\n │ → browser: /oauth/authorize │\n │ → poll: /oauth/token │\n └─────────────────────────────┘\n```\n\nシステムは四つのコンポーネントで構成される:\n\n1. **Cloudflare Worker** — webhook 受信、署名検証、テナントルーティング\n2. **TenantRegistry Durable Object** — installation_id → account_id マッピング管理、テナント単位クォータ管理(単一インスタンス)\n3. **WebhookStore Durable Object** — SQLite によるイベント永続化、REST/WebSocket/SSE エンドポイント(テナント別インスタンス: `store-{accountId}`)\n4. **ステートレス MCP ハンドラ** — MCP Streamable HTTP サーバー、ツール定義。プロトコル版 2026-07-28 でセッションが消えたため Durable Object ではなく、リクエストごとに `createMcpHandler` が組み立てる(#249)\n\nローカルブリッジ(mcp-server/)は Worker に対するプロキシであり、データを保持しない。\n\n### プロトコル版と単レーン切替(#249)\n\nWorker は MCP プロトコル版 **2026-07-28(ステートレスコア)のみ**を提供する。`createMcpHandler` に `legacy: \"reject\"` を渡しており、2025 系のリクエスト(`initialize` を含む)は、この endpoint が提供する唯一の版を名指しした unsupported-protocol-version エラーで返る。\n\nWorker のプロトコル版は、本 repo の 2 成果物(Worker と npx ブリッジ)のあいだの私的な契約である。`mcp-server/server.json` は stdio トランスポートのみを宣言しており、リモートトランスポートの宣言が無いため、第三者クライアントが Worker に直接到達する経路は存在しない。したがって Claude Desktop 側の対応状況は Worker 側移行の条件ではない。ブリッジは二面を持ち、両面は独立している:\n\n| 面 | 実装 | 版 |\n|---|---|---|\n| Claude Desktop に対してサーバー | SDK v1 stdio(`Server` クラス) | 2025 系 |\n| Worker に対してクライアント | SDK v2(`@modelcontextprotocol/client`) | 2026-07-28 に pin |\n\n**互換レーンは実装しない。** 保守されないフォールバック経路は放置されて死にコード化する。畳む条件が観測可能(旧レーンへの到達がゼロになる)であっても、観測できることと観測しに行くことは別であり、運用上その判断は忘れられる。破断の吸収はコードではなく、**minor リリース + README 注意書き**という別チャネルで行う。\n\n非対称の所在: Worker は全員共有の 1 デプロイなので一斉に飛ぶ。ブリッジは利用者ごとで、各自の MCP クライアント再起動で追随する(`@latest` 指定でも起動済みプロセスは古い版を保持する — #247 / #248)。版を固定している外部利用者は再起動しても復旧しない。**この層の切り捨てを受け入れる**というのが本決定である。\n\n\n## Functional Requirements\n\n### F1. Webhook 受信\n\n| ID | 要件 |\n|----|------|\n| F1.1 | `POST /webhooks/github` で GitHub Webhook ペイロードを受信する |\n| F1.2 | `X-Hub-Signature-256` ヘッダによる HMAC-SHA256 署名検証を行う |\n| F1.3 | 署名不一致時は HTTP 403 を返す |\n| F1.4 | シークレット未設定時は署名検証をスキップする |\n| F1.5 | テナント単位クォータ超過時は HTTP 429 を返す(installation イベントはクォータチェックをスキップ) |\n| F1.6 | 認証チェック順序: IP allowlist → per-IP rate limit → signature → tenant resolution → per-tenant quota → ingest |\n\n### F2. イベント永続化\n\n| ID | 要件 |\n|----|------|\n| F2.1 | イベントを UUID 付きで WebhookStore DO の SQLite に保存する |\n| F2.2 | 各イベントは id, type, payload, received_at, processed フィールドを持つ |\n| F2.3 | trigger_status, last_triggered_at フィールドを保持する(将来の trigger 機能用) |\n| F2.4 | `mark_processed` 実行時に、`processed=1` かつ `received_at` が保持期間(`PURGE_AFTER_DAYS` 日、既定 3)より古いイベントを自動削除する。処理済み死蔵行による DO ストレージ肥大を防ぐ(即時性のための補助経路。本質的な保証は F2.5 の Alarm sweep が担う) |\n| F2.5 | DO Alarm(`ctx.storage.setAlarm` + `alarm()` ハンドラ)による時間駆動の retention sweep を提供する。sweep は (a) `processed=1` かつ `received_at` が `PURGE_AFTER_DAYS` 日(既定 3)より古い処理済みイベントと、(b) `processed=0` かつ `received_at` が `UNPROCESSED_PURGE_AFTER_DAYS` 日(既定 90)より古い未処理イベントを、まとめて削除する。alarm() は実行後に次回 sweep を再スケジュールし、消費ゼロ(`mark_processed` が一度も呼ばれない放置テナント)でも周期実行(日次)される。未処理保持期間を処理済みより長く取るのは未読データ消失の安全域確保のため(3 日 / 90 日の非対称は意図的)。既知の限界(スコープ外): 時間窓は「古さ」を縛るが「量」は縛らないため、高頻度 × 無消費テナントは 90 日窓があっても Cloudflare の 1GB/DO 壁に先に到達しうる。量ベース hard cap は別 issue 候補 |\n\n**イベント構造:**\n\n```json\n{\n \"id\": \"uuid\",\n \"type\": \"issues\",\n \"payload\": {},\n \"received_at\": \"ISO8601\",\n \"processed\": false,\n \"trigger_status\": null,\n \"last_triggered_at\": null\n}\n```\n\n### F3. MCP ツール\n\nステートレス MCP ハンドラが以下のツールセットを提供する。ローカルブリッジはこれをプロキシする。\n\n| ID | ツール名 | 引数 | 戻り値 | 要件 |\n|----|---------|------|--------|------|\n| F3.1 | `get_pending_status` | なし | pending_count, latest_received_at, types | 未処理イベントの軽量スナップショットを返す |\n| F3.2 | `list_pending_events` | limit (1-100, default 20) | サマリー配列 | 未処理イベントのメタデータ一覧を返す(ペイロード含まず) |\n| F3.3 | `get_event` | event_id | 完全イベント or error | UUID 指定で完全なペイロードを返す |\n| F3.4 | `get_webhook_events` | limit (1-100, default 20) | 未処理イベント配列 | 未処理イベントをフルペイロード付きで返す |\n| F3.5 | `mark_processed` | event_id **または** event_ids (1-100) | 単数形: success, event_id, purged/バッチ形: success, marked, failed, results (id 単位の判定), purged | イベントを処理済みにマークし、保持期間超過の処理済みイベントを自動削除する。`purged` は今回削除された件数 |\n\n**F3.1 Local bridge shaping:** The local bridge wraps `get_pending_status` results into the Claude Code UserPromptSubmit hook decision JSON shape (`hookSpecificOutput.hookEventName=\"UserPromptSubmit\"` plus a natural-language summary of pending_count / types / latest_received_at in `additionalContext`). This is required so values returned via `type: \"mcp_tool\"` UserPromptSubmit hooks reach the AI prompt context; manual tool calls receive the same shape. The remote (Worker + DO) return contract is unchanged.\n\n**F3.1 Empty silent (#221):** When `pending_count == 0`, the local bridge returns the remote payload untouched (no wrap). Claude Code silently discards JSON that does not match a decision schema, so hook callers receive nothing in `additionalContext` — eliminating the per-turn empty-reminder noise. Manual tool callers see the raw payload (`{pending_count: 0, types: {}, latest_received_at: null}`) and can interpret it directly.\n\n**F3.5 Batch form (#245):** `mark_processed` accepted exactly one event per call, so consuming the 6-10 self-operation acknowledgement events a single PR generates cost one round trip each. `event_ids: string[]` is added so the round trips collapse while the caller still enumerates ids explicitly (filter-based bulk consumption is deliberately not offered: it can silently consume external events).\n\n- **Backward compatibility:** the existing singular `event_id` call is unchanged, response shape included. It still answers `success: true` for an id that was never ingested.\n- **Per-id verdict:** the batch form returns `{event_id, success, error?}` per id. `not found` (no store held that id) is the only value `error` takes at the tool surface — an empty id is rejected by the tool schema before it reaches a store, so it never turns into a misleading `not found`.\n- **Partial failure:** one failing id does not fail the call. Marks for the successful ids are already committed, so the caller retries only the failed ids. Partial failure is reported in the body, not as a tool error.\n- **Multi-account:** an event lives in exactly one store, so the same batch goes to every accessible store and an id marked by **any** store counts as marked. Only an id missed by every store failed.\n- **Purge cadence:** the retention purge runs once per batch call, not once per id.\n- **MCP proxy:** the static schemas (`mcp-server/server/index.js` / `local-mcp/src/index.ts`) must be updated. A new param is not picked up by reconnecting; the npm package has to be republished.\n\n**イベントサマリー構造:**\n\n```json\n{\n \"id\": \"uuid\",\n \"type\": \"issues\",\n \"received_at\": \"ISO8601\",\n \"processed\": false,\n \"trigger_status\": null,\n \"last_triggered_at\": null,\n \"action\": \"opened\",\n \"repo\": \"owner/repo\",\n \"sender\": \"username\",\n \"number\": 123,\n \"title\": \"Issue title\",\n \"url\": \"https://github.com/...\"\n}\n```\n\n### F4. リアルタイムイベント配信(WebSocket / SSE)\n\n| ID | 要件 |\n|----|------|\n| F4.1 | `GET /events` で WebSocket および SSE ストリームを提供する(Upgrade ヘッダで切り替え) |\n| F4.2 | webhook ingest 時に接続中の全クライアントにイベントサマリーをブロードキャストする |\n| F4.3 | 30 秒間隔でハートビート(WebSocket: ping、SSE: heartbeat コメント)を送信する |\n| F4.4 | クライアント切断時にクリーンアップする |\n\n### F5. チャンネル通知(ローカルブリッジ)\n\n| ID | 要件 |\n|----|------|\n| F5.1 | ローカルブリッジが Claude Code の `claude/channel` experimental capability を宣言する |\n| F5.2 | Worker の WebSocket エンドポイントに接続し、新規イベント検出時に `notifications/claude/channel` を送信する |\n| F5.3 | 通知内容はイベントサマリー(type, repo, action, title, sender)を含む |\n| F5.4 | `meta` フィールドに `chat_id`, `message_id`, `user`, `ts` を付与する |\n| F5.5 | `WEBHOOK_CHANNEL=0` 環境変数でチャンネル通知を無効化できる(デフォルト: 有効) |\n| F5.6 | チャンネル通知は one-way(読み取り専用)で、reply tool は提供しない |\n\n### F6. 推奨ポーリングフロー\n\n| ステップ | 操作 |\n|---------|------|\n| 1 | `get_pending_status()` を 60 秒間隔でポーリング |\n| 2 | `pending_count > 0` なら `list_pending_events()` でサマリー取得 |\n| 3 | フルペイロードが必要なイベントのみ `get_event(event_id)` で取得 |\n| 4 | 処理完了後 `mark_processed` でマーク。複数件をまとめて処理した場合は `event_ids` で 1 呼び出しに畳む |\n\n### F7. OAuth 認証(Worker-hosted web OAuth)\n\nWorker は GitHub の web OAuth flow をホストする独自実装を備える(v0.11.0 の device authorization grant は v0.11.1 で撤去。GitHub 標準のログイン + 2FA UX に回帰しつつ、v0.10.x の chronic auth loop 原因である localhost callback 依存と refresh rotation desync を構造的に解消する)。\n\n| ID | 要件 |\n|----|------|\n| F7.1 | `GET /.well-known/oauth-authorization-server` で RFC 8414 メタデータを返す(`authorization_endpoint` / `token_endpoint` / `grant_types_supported=[urn:ietf:params:oauth:grant-type:web_authorization_poll, refresh_token]`) |\n| F7.2 | `POST /oauth/register` で RFC 7591 dynamic client registration を行う(public client、secret 発行なし) |\n| F7.3 | `GET /oauth/authorize?client_id=&state=[&scope=...]` で `web_auth_state:{state}` レコードを `pending` として作成し、`redirect_uri=https:///oauth/callback` を固定して `https://github.com/login/oauth/authorize` に 302 リダイレクトする |\n| F7.4 | `GET /oauth/callback?code=&state=` で GitHub authorization code を confidential client として access token に交換し、`fetchGitHubProps()` で user profile + installations を取得、Worker 独自 bearer token pair を発行して `web_auth_state` を `approved` に遷移させる。ユーザにはタブを閉じるよう案内する HTML を返す |\n| F7.5 | `POST /oauth/token` で `grant_type=urn:ietf:params:oauth:grant-type:web_authorization_poll` を処理する。`pending` → `400 authorization_pending`、`approved` → `200` で bearer pair を返し state レコードを消費、`denied` → `400 access_denied`、期限切れ → `400 expired_token`(RFC 8628 §3.5 のエラー形式を再利用) |\n| F7.6 | `POST /oauth/token` で `grant_type=refresh_token` を処理し、access token と refresh token を rotate する(ブリッジ側 RC1 修正と組み合わせて desync を解消) |\n| F7.7 | 保護対象 API ルート(`/mcp`, `/events`)は `Authorization: Bearer ` ヘッダによる独自 token 検証 middleware で認可する |\n| F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 |\n| F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) |\n| F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) |\n| F7.11 | ローカルブリッジは refresh の `invalid_grant` 後、共有 tokens file を直ちに再読込し、最大 1 秒の待機予算内で 50 ms 間隔に再読込する。この予算はファイル更新待ちの期限であり、既存の refresh 通信の所要時間上限は変更しない。新しい refresh_token の世代を見つけたら、有効な access token(期限なし、または期限まで 5 分超)があれば採用し、それ以外は refresh を最大 2 回試す。同じ世代は再試行しない。最初の refresh と回復処理は両 access-token 取得経路で単一のプロセス内 promise を共有し、成功・失敗の双方で解放する。更新なし・欠損・不正なファイル・本物の失効は有界に既存の全面認証/背景認証へ戻り、別種の refresh エラーでは再読込待機をしない。正常 refresh と有効なキャッシュには待機を追加しない。プロセス間 file lock とブラウザ認証共有化は対象外(#258。#242 の過去原因の確定を意味しない) |\n\n**Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):**\n\nMCP 2026-07-28 の deprecated レジストリ 6 件のうち本 repo に該当するのは Dynamic Client Registration(F7.2)のみ。移行先は Client ID Metadata Documents。最短撤去は 2027-07-28 以降の最初のリビジョンであり、しかも「最短撤去」は撤去が可能になる時点にすぎず、実際の撤去は Core Maintainer 判断でそれ以降にずれうる。仕様からの撤去は SDK に削除義務を課さない。\n\n本移行では撤去しない。理由は相乗りの前提が成立しないこと — 本移行は OAuth の実装に一切触れておらず(ブリッジ側は transport の `authProvider` に繋ぎ直しただけ)、DCR 撤去は両面の認証設計変更になる。プロトコル切替と同一リリースに載せると、利用者が再起動して降りてくる先で破断面が二つ同時に開く。\n\n該当しないもの: Roots / Sampling / Logging / `includeContext`(未使用)、HTTP+SSE トランスポート(Streamable HTTP のみを提供しているため未露出)。\n\n**GitHub App 前提条件:**\n\n- 使用する upstream endpoint: `https://github.com/login/oauth/authorize`(web), `POST https://github.com/login/oauth/access_token`\n- GitHub App の設定で `redirect_uri = https:///oauth/callback` を登録する必要がある(smgjp.com プレビュー + self-host 例示)\n\n## Non-Functional Requirements\n\n### N1. セキュリティ\n\n| ID | 要件 |\n|----|------|\n| N1.1 | Webhook シークレットは Cloudflare Worker の Secret(`GITHUB_WEBHOOK_SECRET`)で管理する |\n| N1.2 | HMAC-SHA256 による署名検証でスプーフィングを防止する |\n| N1.3 | ローカルブリッジは stdio トランスポートを使用し、ネットワーク露出しない |\n| N1.4 | Webhook エンドポイントは多層防御で DDoS/課金攻撃を防止する: IP allowlist → per-IP rate limit → signature → tenant resolution → per-tenant quota |\n| N1.5 | GitHub IP allowlist(api.github.com/meta の hooks フィールド)で非 GitHub IP を最外層でブロックする(github-ip.ts) |\n| N1.6 | Per-IP rate limit はインメモリ sliding window で Worker isolate 内に実装する(rate-limit.ts) |\n| N1.7 | Per-tenant quota は TenantRegistry DO で atomic check-and-increment により管理し、単一テナントの無制限ストレージ消費を防止する |\n| N1.8 | Cloudflare WAF カスタムルールによる外部 IP ブロックを推奨する(Worker 到達前にブロックし CPU 課金を削減) |\n\n### N2. 構成\n\n| ID | 項目 | ソース | デフォルト |\n|----|------|--------|-----------|\n| N2.1 | Worker URL | `WEBHOOK_WORKER_URL` 環境変数 | なし(必須) |\n| N2.2 | シークレット | Cloudflare Secret `GITHUB_WEBHOOK_SECRET` | なし(検証スキップ) |\n| N2.3 | チャンネル通知の有効/無効 | `WEBHOOK_CHANNEL` | 有効(`0` で無効) |\n| N2.4 | カスタムドメイン | `github-webhook.smgjp.com` | Cloudflare Worker のカスタムドメインとして設定済み |\n| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + Worker-hosted web OAuth で認証を処理する |\n| N2.6 | プレビューインスタンス | `preview` 環境 | 本番と同一構成の検証用インスタンス |\n| N2.7 | 処理済みイベント保持期間 | `PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `3`(日)。`mark_processed` 時および DO Alarm sweep 時に保持期間超過の処理済みイベントを削除。`0` で処理済みを即削除 |\n| N2.8 | 未処理イベント保持期間 | `UNPROCESSED_PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `90`(日)。DO Alarm sweep 時に保持期間超過の未処理イベントを削除。`0` で未処理を即削除。処理済み(3 日)より長いのは未読データ消失の安全域(非対称は意図的) |\n\n**GitHub Webhook 購読イベント:**\n\n| イベント | 状態 |\n|---------|------|\n| Discussion | 有効 |\n| Discussion comment | 有効 |\n| Issue comment | 有効 |\n| Pull request review | 有効 |\n| Pull request review comment | 有効 |\n| Pull request review thread | 有効 |\n| Workflow run | 有効 |\n| Issues | 削除済み(未購読) |\n| Pull request | 削除済み(未購読) |\n| Sub issues | 削除済み(未購読) |\n\n### N3. 制約\n\n| ID | 制約 |\n|----|------|\n| N3.1 | WebhookStore DO はテナント別インスタンス(`idFromName(\"store-{accountId}\")`)で動作する。MCP 側にテナント別インスタンスは無い(2026-07-28 でセッションが消え、リクエストごとの props から store 名を解決する)。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する |\n| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、MCP ツールが複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーからも参照できる。accessible_account_ids はリクエストごとの props から読む |\n| N3.2 | WebSocket / SSE 接続は DO のメモリ内で管理される(DO eviction 時に切断) |\n| N3.3 | Worker とブリッジのあいだにセッションは無い(2026-07-28 ステートレスコア、#249)。ブリッジが再利用するのは MCP クライアントとその transport であり、切断のコストは再接続一回に閉じる |\n| N3.5 | Worker は 2026-07-28 のみを提供し、2025 系リクエスト(`initialize` を含む)を `legacy: \"reject\"` で拒否する。互換レーンは持たない(#249 決定 1)。ブリッジのクライアント面も同じ版に pin する |\n\n## Dependencies\n\n### Cloudflare Worker\n\n| パッケージ | 用途 |\n|-----------|------|\n| agents | Cloudflare Agents SDK (`createMcpHandler` — Worker 向けステートレス MCP ハンドラ) |\n| @modelcontextprotocol/server | MCP SDK v2 サーバー(プロトコル版 2026-07-28。`agents` が非 optional peer として exact 2.0.0 を要求するため exact 固定) |\n| zod | スキーマバリデーション |\n\nOAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み。v0.11.1 で Worker-hosted web OAuth に切り替え(device authorization grant は撤去)。\n\n### ローカルブリッジ (mcp-server/)\n\n| パッケージ | 用途 |\n|-----------|------|\n| @modelcontextprotocol/sdk | MCP SDK v1(Claude Desktop に対するサーバー面。`Server` クラス直接使用) |\n| @modelcontextprotocol/client | MCP SDK v2 クライアント(Worker に対するクライアント面。版 2026-07-28 に pin) |\n\nNode.js >= 18.0.0 が必要。\n\n## CI/CD\n\n### テスト(CI)\n\n| トリガー | ジョブ | 内容 |\n|---------|--------|------|\n| PR to main / push to main | test | Node.js syntax check |\n\n### デプロイ(CD)\n\n| トリガー | ジョブ | 内容 |\n|---------|--------|------|\n| release published | build-mcpb | `mcpb pack` で .mcpb 生成 |\n| release published | attach-mcpb | `gh release upload` で .mcpb をリリースに添付(build-mcpb 後) |\n| release published | npm-publish | npm レジストリに公開 |\n\nリリースフロー:\n1. AI が `gh release create` でリリースを作成する(PAT 経由で release イベントが発火する)\n2. Release published イベントで CD ワークフローが発火: .mcpb 生成 → .mcpb リリース添付 → npm publish\n3. npm publish 時にリリースタグ名から自動でバージョンを同期する(package.json の手動更新不要)\n4. プレリリースタグ(`-` を含む)は `next` dist-tag で公開、正式リリースは `latest` で公開\n\n注意: GITHUB_TOKEN で作成されたリリースは release イベントを発火しない(GitHub Actions の制限)。\nそのため AI が PAT 認証済み gh CLI でリリースを作成する運用とする。\n\nmanifest.json のバージョンも一致させる。\n\n### 配送鎖と完了条件\n\n```\nmerge → gh release create → CD (npm-publish) → registry の latest 更新\n → 利用側 MCP クライアントの再起動 → npx が新版を解決 → 利用側に到達\n```\n\n後半 2 段は CD の外側にあり、リポジトリ側からは実行できない。npx がパッケージのバージョンを解決するの\nはプロセス起動時の一度きりであり(クライアント設定で `@latest` を指定していても同じ)、すでに起動して\nいるプロセスは registry がどう変わっても起動時のバージョンを保持し続ける(v0.11.9 のリリース直後に実\n測: registry が 0.11.9 を返している間、稼働中のクライアントは 0.11.8 のままだった。0.11.9 に移ったの\nは Claude Desktop を再起動した時点である)。\n\nしたがって **「registry が新版を返す」はリリース完了の判定基準にならない**。特にプロキシの静的ツール\nスキーマを変更したリリースは、利用側プロセスが再起動して初めて成果が現れる。リリース完了報告を\nregistry の確認で締めると、届いていない状態を届いたと報告することになる。\n\nregistry の確認には `--prefer-online` を付ける。npm CLI は registry のメタデータをキャッシュするため、\npublish 直後の `npm view github-webhook-mcp version` は旧版を返しうる(同じく v0.11.9 で実測)。\n\n```bash\nnpm view github-webhook-mcp version --prefer-online\n```\n\nnpx のキャッシュ解決挙動そのものは本リポジトリの管理外であり、規定できるのは\n「再起動が要る」という事実と上記の確認手段までとする。\n\n## Distribution Channels\n\n| チャネル | 用途 | ステータス | 要件 |\n|---------|------|-----------|------|\n| Anthropic Desktop Extensions Directory | Claude Desktop ローカル mcpb 配布 | 申請済み(2026-04-02) | 公開 GitHub リポジトリ、Node.js、manifest.json、推奨 MIT ライセンス |\n| Anthropic Remote MCP Server Directory | リモート MCP サーバー配布 | 未申請 | OAuth 2.0、HTTPS、ツール安全性アノテーション |\n| npm レジストリ | CLI / Codex 向け npx 配布 | 稼働中 | npm publish(CD で自動公開) |\n| MCP Community Registry (registry.modelcontextprotocol.io) | コミュニティ発見チャネル | 将来 | 未定 |\n\n申請フォーム:\n- Desktop Extensions: [MCPB Desktop Extensions Submission Form](https://forms.gle/mcpb-desktop-extensions)\n- Remote MCP Server: [Remote MCP Server Submission Form](https://forms.gle/remote-mcp-server)\n\n各チャネルの申請経緯と詳細ステータスは tips issue #175 を参照。\n\n## Infrastructure\n\n| コンポーネント | 用途 |\n|---------------|------|\n| Cloudflare Worker | webhook 受信 + MCP サーバー |\n| Cloudflare Durable Objects | イベント永続化 (SQLite) + WebSocket/SSE |\n| GitHub Webhook | イベント送信元 |\n| MCPB | Claude Desktop 向けローカルブリッジ配布 |\n| npx | CLI/Codex 向けローカルブリッジ配布 |\n\n## Files\n\n| パス | 用途 |\n|-----|------|\n| `worker/src/index.ts` | Cloudflare Worker エントリポイント |\n| `worker/src/mcp.ts` | MCP サーバー factory(ツール定義。リクエストごとに生成、テナントは props から解決) |\n| `worker/src/retired-do.ts` | 退役した WebhookMcpAgent クラス(過去 migration 制約のためだけに残す) |\n| `worker/src/store.ts` | WebhookStore DO(SQLite + SSE、テナント別インスタンス) |\n| `worker/src/tenant.ts` | TenantRegistry DO(installation-account マッピング、クォータ管理) |\n| `worker/src/oauth.ts` | Worker-hosted web OAuth 自前実装(metadata / register / authorize / callback / token / 独自 token 検証 middleware) |\n| `worker/src/oauth-store.ts` | OAuth KV schema helper(client / web_auth_state / token / refresh / grant レコード操作) |\n| `worker/wrangler.toml` | Worker デプロイ設定 |\n| `shared/src/types.ts` | 共有型定義 |\n| `shared/src/summarize.ts` | イベントサマリー生成 |\n| `local-mcp/src/index.ts` | ローカルブリッジ(TypeScript、開発用) |\n| `mcp-server/server/index.js` | ローカルブリッジ(JS、.mcpb 配布用) |\n| `mcp-server/manifest.json` | MCPB マニフェスト |\n| `mcp-server/package.json` | npm パッケージ定義 |\n"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "docs/_Sidebar.md",
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/main/docs/_Sidebar.md",
+ "version": "sha256:a7dd46d4e83426198e588f194c229d430a7832fbac5955f50e79bf7527a709d9",
+ "text": "**github-webhook-mcp**\n\n- [[Home]]\n- Installation: [[EN|installation]] / [[JA|installation.ja]]\n- Requirements: [[EN|0-requirements]] / [[JA|0-requirements.ja]]\n"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/2-feedback-memory.ja.md",
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/main/docs/2-feedback-memory.ja.md",
+ "version": "sha256:359e9af4f291b732d6b09b106bea6f1c5a4c4e40c9c651a836b626774ca8e847",
+ "text": "# 非公開の検索 feedback memory\n\n言語: [English](2-feedback-memory.md) | 日本語\n\nIssue #259 の4機能である検索履歴、資料の明示的利用、時間減衰する活性度、取り消せる関係強化を同じ trace 契約で提供する。保存対象は query、検索設定、返した資料の provenance と明示判断。会話全体、資料本文やタイトル、credential、認証 props は複製しない。\n\n## 資料と trace の識別\n\n成功した `search`(search / scan / stored-content fetch / 0件)は UTC trace を保存してから `trace_id`、`timestamp`、`feedback_available:true` を返す。資料と `same_entity.others` には `source_id`、`provenance`、現在の `activation` を追加する。request は query/filter/control、settings は mode、有効な scan 窓、fusion/rerank 実施結果と両軸を保存する。\n\nprovenance は `{repo,type,identity,version,content_source}`。identity は issue/PR number、release tag、doc/wiki path、diff の `[commit_sha,file_path]`、GitHub comment/review ID。`content_source:\"index\"` の version は取得した `updated_at`(doc/wiki は索引 snapshot の timestamp)。live inline doc/wiki 本文は `content_source:\"github_live\"` と `content_version:\"sha256:<返した UTF-8 本文の hash>\"` を持ち、この hash を provenance の version と source ID に使う。`index_updated_at` は索引 timestamp を補足 audit として保存し、live 版は定義しない。同じ live 本文は索引 timestamp が変わっても同じ版、本文が変われば索引 timestamp が固定でも別 source_id。stored-content fetch と graph 本文は索引コピーで、index provenance を維持する。memory に保存するのは hash/provenance のみで、live 本文は複製しない。mode、vector handle、任意の補足 field は ID の hash に含めない。\n\n`source_id` は実体の特定版を識別する。同じ実体・同じ取得版は vector ID が移行しても同じ ID、更新版は別 ID になる。activation は版単位。mention strength は `[repo,src_slug,dst_slug,edge_kind]` 単位で、vector ID や資料版から独立している。reindex は principal 別の学習状態を消さず、元の mention topology を保持する。消えた mention は探索できず、過去 trace の path は監査と取消しのため残る。\n\nprincipal は検証済み MCP OAuth props の numeric GitHub user ID からサーバーが決める。同じ GitHub ユーザーの client 間で共有し、caller 指定 identity は信頼しない。別ユーザーには `unknown_trace` / `unknown_confirmation` を返す。DO の内部 API は公開 route を持たない。\n\n`memory_history {limit:10}` は新しい trace から一覧を返し、`next_cursor` で次ページへ進む。`memory_history {trace_id,limit:10}` は資料、利用段階、現在 activation、検索設定と古い順の receipt audit を返す。この詳細側 cursor は**同じ trace 内の receipt**用で、一覧側とは用途が異なる。cursor は principal と、詳細側では trace にも所属する。\n\nlimit は1..50、trace の資料 snapshot は300件、保存入力は300,000文字、query は4096文字、metadata string は256文字、usage batch は100 entry、reason/key は1000/128文字まで。SQL は owner/cursor index と有界 page を使う。全履歴をまとめて読む API はない。\n\n未成立・失敗を成功 trace として保存しない。一部 scan source、sparse retrieval、graph expansion の失敗と memory の原子的書込失敗では検索結果を返せるが、`memory_unavailable:true`、`feedback_available:false` とし、**trace_id を発行しない**。`memory_error` はそれぞれ `retrieval_incomplete` / `memory_save_failed`。拒否 error は段階違反、key conflict、confirmation 等の安全な code を返し、query/handle を error や log へ転記しない。検索の error log は固定文言にする。\n\n### 資料単位の部分記録(Issue #263)\n\ncanonical identity または live version が未解決の資料は、検索結果・順序・本文を維持して、その資料だけ保存から除外する。正常資料があれば既存 transaction で原子的に保存し `trace_id` と全体の `feedback_available:true` を返す。未解決 row は `feedback_available:false`、`memory_exclusion_reason:canonical_identity_unavailable` または `live_version_unavailable` を持ち、source_id/provenance/activation を付けない。`results`、`graph_results`、両軸の `same_entity.others` に適用する。\n\n応答の `memory_recording` は `status:complete|partial|unresolved`、`recorded_sources`(保存した distinct source 数)、`excluded_sources`(除外した返却 row 数)、`exclusions:[{location,reason}]` を返す。同じ未解決資料が複数箇所にあれば各 row を数える。location は `results[0].same_entity.others[1]` 等の返却位置のみ。本文・タイトル・vector ID を除外記録へコピーしない。同じ object を `settings.memory_recording` として保存し、history 一覧・詳細の両方で再読できる。古い trace の settings にこの field が無い場合は従来の記録。\n\n全資料未解決は `status:unresolved`、`memory_error:all_sources_unresolved`、memory_unavailable/feedback不可、trace無し。正常な0件検索は `status:complete`、件数0の空 trace を保存する。識別以外の例外とDB書込失敗は資料単位の除外として扱わない。\n\ngraph path の起点・中間・終点は実際に探索した vector ID 列から索引 metadata を読む。返却枠外・dangling・identity 未解決の中間も検証し、repo と directional mention の両端 slug を照合する。確認不可の path は保存 snapshot で `path:[]` とし、返却 graph_path は維持する。終点自体が正常なら利用段階は記録可能だが `graph_feedback_available:false` と `graph_feedback_exclusion_reason:unverifiable_graph_path` を返し、confirmed は `no_graph_path` で非適用。`excluded_graph_paths` / `graph_exclusions` を memory_recording に保存する。確認できた他の path の強化・receipt・取消しは継続する。内部 node snapshot は返却・settings・history に含めない。graph metadata 読取は返却 path 当たり最大3 node の集合に限定し、保存形式の移行は不要。\n\n## 利用段階と retry\n\n`record_source_use {trace_id,idempotency_key,uses:[{source_id,stage}]}` で段階を記録する。\n\n| stage | 意味 | usage activation 加算 |\n|---|---|---:|\n| selected | 調査対象として選んだ | 0.2 |\n| validated | 正確な出典を確認し利用可と判断した | 0.5 |\n| used | 回答・判断に実際に使用した | 1.0 |\n\n段階は一つずつ進める。caller が各判断を済ませた後、同じ資料の3段階を同じ batch に含めてもよい。trace 所属、順序、owner、全 entry を一つの SQLite `transactionSync` で確認し、拒否時は usage・activation・receipt 全てを rollback する。既に完了した段階の再送は no-op。検索自体を used や confirmed と解釈しない。\n\nkey は principal 全体で mutation tool / trace をまたいで一意。同じ parsed payload の retry は同 receipt、同 key の異なる payload は `idempotency_conflict`。JSON object の field 順序は同一性に影響せず、array 順序は影響する。並行更新は DO と transaction で直列化する。新しい操作には新しい key を使う。\n\n## 活性度と学習の反映\n\n`MEMORY_POLICY` の半減期は3600秒。読取・更新時に `value * 0.5^(max(0,elapsed_ms)/3600000)` で計算する。clock 逆行は elapsed=0、更新 timestamp は逆行させない。cron は不要。\n\n成功 trace ごとに distinct な返却資料へ `retrieved` を0.1加算。`usage` は上記 stage の加算量を使う。**各 channel 上限10、total 上限20**で、有限・非負。応答は両 channel、total、UTC updated_at を返す。取消しは関係の寄与を取り消し、過去の usage 記録や activation は消さない。\n\n検索履歴は既定で書き込み、search の `readOnlyHint:false`。keyword の fusion / score / rank / rerank は維持する。`graph_expand:false` が既定。`use_memory:false` は従来の graph 順序・上限を保つ。\n\nsearch mode の `use_memory:true` は現在の graph 候補を最大200件読み、hop 昇順、同 hop 内だけ減衰後の path strength 合計降順で並べ、同点は元順序を保つ。その後 `min(top_k*2,30)` 件へ絞るので、従来の返却枠外の強化済み候補も選べる。初期 strength=0 では従来と同じ順序。両軸を融合せず、relevance score を追加しない。opt-in の graph row に付く `learned_strength` は寄与の合計で、関連度ではない。scan/fetch はこの並べ替え指定を無視する。\n\n## confirmed と因果的取消し\n\n`record_outcome {trace_id,idempotency_key,outcome:\"confirmed\",source_ids,reason}` は同 trace の used 済み資料を要求する。探索時に保存した実在する1〜2本の directional mention edge だけを強化し、origin→終点の shortcut、lexical/zero-hop edge は作らない。usage だけでは関係を強化しない。\n\n独立 trace は保存 edge ごとに0.25を加算し、減衰後 strength の上限は5。同 trace は同じ edge に一度しか寄与できず、資料間の path 重複、再確認、retry、取消し後も二重加算しない。実 delta は残り headroom(0の場合もある)。receipt / ledger は saved path、delta、timestamp、reason、active/reversed を保持する。重複、上限、lexical、取消し済みは非適用理由を返す。\n\n`corrected` / `rolled_back` は**同 trace の confirmation_id を一つ明示**し、source_ids を渡さない。取消しはその receipt の寄与だけを inactive にし、現在まで減衰した delta だけを減算する。後続の独立寄与を残し、weight 全体を reset しない。未知・曖昧な confirmation は原子的に拒否。元 receipt と別の取消し receipt を監査に保持する。取消された trace は同 edge の credit を再取得できない。\n\n## 移行・deploy・artifact\n\nこの変更では version/tag/release を公開しない。新しい Worker binding / DO class migration は不要。既存 SQLite `IssueStore` に独立 `memory_*` table を追加し、既存 issue table / watermark は保持する。\n\n**D1 migration 0008 を Worker deploy、または自動 deploy を起こす branch への merge より先に適用する。** `comment_id` / `review_id` を追加し、0は未解決の旧行を表す。運用段階の具体的コマンドは以下(credential は出力しない)。\n\n```powershell\n# read-only の事前確認\nnpx wrangler d1 migrations list github-rag-fts --remote\nnpx wrangler d1 execute github-rag-fts --remote --command \"PRAGMA table_info(search_docs)\"\n# 本番変更の許可後、Worker deployment 前に適用\nnpx wrangler d1 migrations apply github-rag-fts --remote\n# 両列を確認後、review 済み Worker commit を deploy\nnpx wrangler d1 execute github-rag-fts --remote --command \"PRAGMA table_info(search_docs)\"\n# 0008後の未解決件数(read-only)\nnpx wrangler d1 execute github-rag-fts --remote --command \"SELECT type,COUNT(*) AS unresolved FROM search_docs WHERE (type IN ('issue_comment','pr_review_comment') AND comment_id=0) OR (type='pr_review' AND review_id=0) GROUP BY type\"\n```\n\ndeploy 後の認可された `POST /admin/backfill-source-identities?repo=owner/repo&limit=50[&cursor=TYPE:ID]` は、既存 DO の canonical event ID から過去の unchanged 行を補修する。既存管理 credential は非公開 header で渡し、URL / artifact / shell history に token を載せない。next_cursor を渡して done:true まで進める。limit は1..100、1 page は最大3つの有界 DO read と一つの原子的 D1 batch。再開・再実行は安全で、embedding / GitHub 本文再取得 / index reset は不要。\n\n通常の unchanged ingest も hash skip の前に ID を補修する。DO に canonical event が無い行は未解決のままなので、別の再取り込み前に欠損を調べる。未解決 source 自体は feedback 不可とし、正常 source は上記の部分記録を適用する。backfill は private trace/credit を変更しない。旧ID欠落と残存1件の調査、再現した削除経路の修正は [調査記録](3-unresolved-source-investigation.ja.md) を参照。\n\nbridge には `server/search-schema.json` と `server/memory-tools.json` を同梱する。`node scripts/generate-tool-contracts.mjs` で Worker Zod contract から生成し、`node scripts/check-schema-drift.mjs` が全 tool の実 protocol と nested 入出力 schema / bounds / defaults / annotations の完全一致を確認する。npm / mcpb の両 artifact にこの JSON が必要。stdio client の tool discovery には更新 bridge の公開が必要。公開 version と publication は後続の運用段階で決める。\n\n## 合成 lifecycle と検証\n\n`npx vitest run --config vitest.workers.config.ts src/memory-e2e.workers.test.ts src/memory.workers.test.ts` で、synthetic principal と local in-process SQLite/D1 のみを使う。\n\n実演は `syntheticneedle` 検索 → path `a->b->c` → `c` の selected/validated/used → 2 edge に各0.25の confirmed → corrected で両寄与 inactive。独立 trace の固定時刻テストでは半減期後に次の確認を加えると path strength=0.75、最初の寄与を取り消すと次の寄与0.50が残る。実 MCP の live 本文回帰では索引 timestamp と keyword 順位を固定し、doc/wiki の inline 本文のみ変更する。同じ本文の retry は同 ID、変更本文は別 ID、stored fetch は index ID を保ち、旧 live 版の usage は新しい版に引き継がれない。正しさの検証であり、検索品質向上の評価ではない。\n\n成功検索には DO request を一つと、有界な source hash / activation 処理が加わる。学習 opt-in は最大200 path、各最大2 edge を追加で読む。storage は trace/receipt とともに増え、自動 retention deletion は実装しない。global DO が書込を直列化する。feedback に Python 常駐、モデル配布、追加 embedding、mention topology の書換えは不要。\n"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/4-abstention-replay.ja.md",
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/main/docs/4-abstention-replay.ja.md",
+ "version": "sha256:cac7b8f42ca4d280a2b745f26a56130559a104c66c11c9cc1bab01d64af851ff",
+ "text": "# Offline Abstention Replay\n\nIssue #275 の固定 capture 比較用の要件・入力契約。追加 dependency、モデル呼出し、サービス/DB/認証/既定検索の変更を伴わない。既存の探索資料を再計算する道具であり、拒否閾値の採用、108問いの固定試験、blind reader 評価を完了したものではない。\n\n## 実行\n\nNode 22 で repository root から実行する。出力は入力と別のパスを指定する。\n\n```powershell\nnode scripts/abstention-replay.mjs experiments/abstention-replay/fixture.json experiments/abstention-replay/annotation.json report.json\nnode --test scripts/abstention-replay.test.mjs\n```\n\n同じ入力 bytes では同じ JSON を生成する。時刻を足さず、schema/protocol/fixture/annotation/runner の SHA-256 を結果に保存する。元 capture は編集しない。CLI は引数誤り・契約違反を exit 1 とし、入力自身および既存symlink/hardlinkの入力別名への出力を拒否する。\n\n## 入力契約\n\n`experiments/abstention-replay/schema.json` は fixture と annotation の JSON Schema。CLI は dependency なしの validator で、unknown field、非有限値、query/candidate の重複、annotation の参照と全件被覆も検査する。query category/language は入力の層別軸。entity は独立資料の同一性を表す公開キーで、同一issue・コメント・派生本文を別 entity と数えないよう実験担当が決める。private trace/source ID、credential、principal、activation、feedback receipt のための field は許可しない。文字列本文そのものの秘密検出は行わないため、公開入力の内容監査も必要。\n\n- retrieval: `complete` / `partial` / `unknown`。正常な0件、取得障害、未確認を区別する。\n- sparse: state=`complete` / `failed` / `not_run` / `unknown`、scope=`filtered_pool` / `pre_filter`。正常時のみ非負整数countを持つ。他はnull。正常な pre_filter 0件ならfiltered候補も空だが、pre_filter正数はfiltered候補の存在を証明しない。\n- dense: 同じfilter済み全候補poolの最大値 `max` と `pool_complete`。top-kからの推測は不可。未確認はfalse/null。ゼロは実測値として扱い、欠損の代用にしない。\n- rerank: 資料ごとに `kind=model`(実モデルで測れた値)/ `synthetic`(サービスが合成した値)/ `missing`(null)。`rerank_pool_complete=true` は全poolの候補と実測が入力に揃ったという入力作成側の保証。単なる `rerank_applied=true` では保証できない。\n- graph_entities: 独立した関係軸。keyword候補拒否時も削除しない。scan/fetchのreplay入力はこの意味検索契約に入れない。\n\nfixtureは6件・18行の既存development captureを公開fieldだけのallowlistで転記したもの。保持した点数は返却行のrerank_scoreで、候補pool全体の測定保証ではない。元captureのsource_commitは参照したsource SHA、稼働code/index固定の証明ではない。sparse候補数はpre_filter。dense最大と全rerank poolは不明。既に観測済みの資料で、holdoutではない。\n\nannotationはscoreと独立して、query_id/candidate_idごとに `support`(支持)/ `contradiction`(明示反証)/ `partial`(部分的根拠)/ `irrelevant`(無関係)/ `unknown`(未判定)を付ける。unknown以外はcitation(引用箇所と固定版の出典)とconditions(問いへの適用条件)を必須とする。CLIは引用の真偽を自動採点しない。captureに根拠本文がないため、同梱annotationは全件unknown。#275の資料にも当時の明示記述を監査せずgoldを付けない。moon質問は全コーパス不存在が未確認で、確定unanswerableではない。\n\n## 比較規約\n\n固定gridは 0, 0.001, 0.005, 0.01, 0.05, 0.1, 0.2, 0.5, 0.9。比較は厳密な `< tau`。閾値最適化は行わない。\n\n- baseline: keyword packetを保持。\n- A: 正常なfiltered sparse候補が存在すれば保持。空なら完全なdense pool最大値がtau未満のとき拒否。pre_filter候補の正数、sparse障害/未実行、空時のdense最大不明、取得不完全は判定不能として保持。\n- B: 全poolの実測rerank最大値がtau未満ならkeyword検索全体を拒否。未測定、候補欠落、合成点数、空pool、取得不完全は判定不能として保持。\n- D: 同じ完全pool内でentityごとの最大値を取り、上位2独立entityの絶対差がtau未満なら全keyword packetを拒否。独立entityが2件未満なら判定不能。同点は差0。重複行は独立資料として数えない。\n- C: B信号を添付して資料を保持するreader用packet。点数を正しさの確率とは呼ばない。reader効果は未測定。\n\n`returned_packet_diagnostics` は返却されたtop-k行だけでtop/gap/gridを再計算する別scope。hidden poolや候補欠落を埋めず、全検索B/Dの判定へ流用しない。6件のtau=0.01で返却packetのBは探索3件を保持、訂正問い2件とmoon問いを拒否するが、gold保持率や一般性能には換算しない。全poolのA/B/Dはこのfixtureでは判定不能6/6、保持6/6。正常sparse候補ありで素朴なAの発火が0だった既存観測も、filtered pool検証済みという意味ではない。\n\nEはembedding/距離/bandwidth/候補pool等の指標が未固定なので実装しない。rerank合計による代用もしない。\n\n## 指標と解釈\n\nall/category/language/category-languageごとに件数を出す。各rateは numerator/denominator/rateを持ち、分母0はnull(0%とはしない)。発火率と判定不能率の分母はquery数。支持/反証/部分的根拠/無関係/未判定ごとにcandidate保持率と失った件数を出す。無関係保持率がcandidate-levelの無関係返却率。誤拒否率の分母は少なくとも一件のgold有用資料(支持・反証・部分的根拠)があるquery数、分子はそれを全keyword packet拒否で失ったquery数。\n\n同一entity重複のまとめはD信号のみ。candidateの損失分母は入力行数でありentity数ではない。graph有用性はkeyword指標へ混ぜない。query全体の無根拠断定、根拠不足の適切な明示、読む側の前提訂正、再検索costは未測定。同梱gold未判定では有用性/無関係指標の分母は0になる。\n\n合成controlは欠損、合成点数、部分取得、tau境界、同点、同一entity、低点の反証資料とC保持、分母0、入力上書き拒否を検査する。合成control成功を検索品質の証拠としない。\n\n## 残る評価\n\n別段階で固定コーパスの引用監査、意図/資料系列による調整・未使用分割、108問いを出発点とする比較、条件名を伏せた同じreader/model/予算/指示でのbaseline/B/C比較が必要。害と支持/反証の取り逃しは別件数で報告する。PR mergeだけで#275のreader比較義務は完了しない。改善または不採用の理由をissueへ記録するまで開いた状態に保つ。現時点で既定動作は維持する。\n"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "README.md",
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/main/README.md",
+ "version": "sha256:10b1ce65da5b661f6ef689dedfe5066f175b5d0690103485e847ff649ef50e20",
+ "text": "# github-webhook-mcp\n\nReal-time GitHub webhook notifications for Claude via Cloudflare Worker + Durable Object.\n\n## Architecture\n\n```\nGitHub ──POST──▶ Cloudflare Worker ──▶ Durable Object (SQLite)\n │\n ├── MCP tools (Streamable HTTP)\n ├── WebSocket real-time stream\n │\n ┌────────────────┘\n │\n Desktop / Codex: .mcpb local bridge ──▶ polling via MCP tools\n Claude Code CLI: .mcpb local bridge ──▶ WebSocket → channel notifications\n```\n\n- **Cloudflare Worker** receives GitHub webhooks, verifies signatures, stores events in a Durable Object with SQLite.\n- **Local MCP bridge** (.mcpb) proxies tool calls to the Worker and optionally connects via WebSocket for real-time channel notifications.\n- No local webhook receiver or tunnel required.\n\n## Breaking change: MCP protocol revision 2026-07-28\n\nFrom this release the Worker serves **MCP protocol revision 2026-07-28 only**. It keeps no compatibility lane for the previous revision.\n\n- **Bridge versions older than this release stop working.** They open a session with `initialize`, which the Worker no longer answers. The failure is quiet: the bridge does not crash, it returns the protocol error as tool output text.\n- **Real-time channel notifications keep arriving, which hides the breakage.** The `/events` stream is not MCP and is unaffected, so a stale bridge still pushes event summaries while every tool call — including `mark_processed` — fails. The pending queue stops being cleared even though notifications look healthy.\n- **Restart the MCP client to pick up the new bridge.** The bridge is launched with `npx`, and `@latest` is resolved at process start — an already-running Claude Desktop, Claude Code, or Codex keeps the copy it started with, however new the published version is. Quit it fully and reopen.\n- **Pinning the bridge version leaves you stuck.** If your MCP client config pins a version older than this release, restarting does not help; remove the pin (or move it forward) first.\n\nThe Worker and the bridge ship together, so a bridge from this release or later needs no configuration change.\n\n## Prerequisites\n\n| Component | Required |\n|-----------|----------|\n| **Node.js 18+** | MCP server |\n| **Cloudflare account** | Worker deployment (self-hosting) |\n\n## Getting Started\n\n### 1. Install the GitHub App\n\nInstall the **GitHub Webhook MCP** app on your GitHub organization or account:\n\n1. Visit the [GitHub App installation page](https://github.com/apps/liplus-webhook-mcp)\n2. Select the organization or account to install on\n3. Choose which repositories to grant access to (or all repositories)\n4. Approve the requested permissions\n\n> **Note:** When the app requests new permissions after an update, you must approve them in your GitHub notification or the app's installation settings. Webhooks will not be delivered until permissions are accepted.\n\n> **Important:** Do not create a separate repository webhook for the same endpoint. The GitHub App handles all webhook delivery — a repository webhook would cause duplicate or malformed requests.\n\n### 2. Set up the MCP client\n\nContinue to the [Installation guide](https://github.com/Liplus-Project/github-webhook-mcp/wiki/Installation) to connect your AI assistant to the webhook service.\n\n## Installation\n\nSee the [Installation wiki page](https://github.com/Liplus-Project/github-webhook-mcp/wiki/Installation) for the full setup guide, including:\n\n- **Quick Start** with the preview instance\n- **MCP Client Setup** for Claude Desktop, Claude Code CLI, and Codex\n- **Self-Hosting Guide** for Cloudflare Workers deployment\n\n## Updating\n\nA published release does not reach a running client on its own. npx resolves the package version\nonce, when the process starts — including when the client config pins `@latest` — so an MCP client\nthat is already running keeps the version it started with no matter what the registry serves.\n**Restart the MCP client (Claude Desktop, Claude Code, Codex) to pick up a new release.** The\nrestart is what moves the client onto the new version.\n\nCheck what the registry actually has with `--prefer-online`. The npm CLI caches registry metadata,\nso a bare `npm view` can still report the previous version shortly after a publish:\n\n```bash\nnpm view github-webhook-mcp version --prefer-online\n```\n\n## Usage Examples\n\n### Example 1: Check pending webhook status\n\n**User prompt:**\n> \"Are there any new GitHub notifications?\"\n\n**Expected output:**\nThe AI calls `get_pending_status` and returns a summary:\n\n```\nYou have 3 pending webhook events:\n- 2 push events\n- 1 pull_request event\n```\n\n### Example 2: Inspect a specific event\n\n**User prompt:**\n> \"Show me the details of the latest pull request event.\"\n\n**Expected output:**\nThe AI calls `list_pending_events` to find the PR event, then `get_event` with the event ID to retrieve the full payload:\n\n```\nPR #42 \"Fix login timeout\" was opened by @alice in repo acme/web-app\n Branch: fix/login-timeout → main\n Status: open\n Changed files: 3\n```\n\n### Example 3: Process events after review\n\n**User prompt:**\n> \"I've reviewed all the push notifications, mark them as done.\"\n\n**Expected output:**\nThe AI calls `list_pending_events` to find push events, then clears them with a single\n`mark_processed({ event_ids: [...] })` call:\n\n```\nMarked 2 push events as processed:\n- Push to main by @bob (3 commits)\n- Push to develop by @alice (1 commit)\n```\n\n### Example 4: Monitor CI status via webhooks\n\n**User prompt:**\n> \"Did the CI checks pass on my latest PR?\"\n\n**Expected output:**\nThe AI calls `list_pending_events` to find `check_run` events related to the PR, then `get_event` for details:\n\n```\nCI results for PR #42 \"Fix login timeout\":\n- build (ubuntu-latest): ✓ passed\n- lint: ✓ passed\n- test (node-18): ✓ passed\nAll checks passed.\n```\n\n## MCP Tools\n\n| Tool | Description |\n|------|-------------|\n| `get_pending_status` | Lightweight snapshot of pending event counts by type |\n| `list_pending_events` | Summaries of pending events (no full payloads) |\n| `get_event` | Full payload for a single event by ID |\n| `get_webhook_events` | Full payloads for all pending events |\n| `mark_processed` | Mark events as processed (`event_id` for one, `event_ids` for a batch) |\n\n## Event Retention\n\nStored events are purged automatically to bound Durable Object storage. The Worker\nruns a time-based sweep on a Durable Object Alarm (daily), so cleanup happens even\nfor tenants that never call `mark_processed`:\n\n| Event class | Retention window | Env var | Default |\n|-------------|------------------|---------|---------|\n| Processed (`mark_processed` called) | older than the window is deleted | `PURGE_AFTER_DAYS` | `3` days |\n| Unprocessed (never marked) | older than the window is deleted | `UNPROCESSED_PURGE_AFTER_DAYS` | `90` days |\n\n- The longer window for unprocessed events is intentional: unprocessed means\n user-unseen, so the safety margin before dropping is wide (the 3-day vs 90-day\n asymmetry is by design).\n- The sweep runs via a Durable Object Alarm on a daily cadence and reschedules\n itself, so it fires independently of consumption. Processed events are also\n purged immediately on `mark_processed` for promptness; the Alarm sweep is the\n guarantee that covers abandoned tenants.\n- Both windows are configurable in `worker/wrangler.toml` (`[vars]`). Setting a\n value to `0` purges that class immediately on sweep.\n- Known limitation: the windows bound event *age*, not *volume*. A high-rate,\n never-consumed tenant can still reach Cloudflare's 1 GB-per-DO ceiling before\n the 90-day window applies. A volume-based hard cap is tracked separately.\n\n## Monorepo Structure\n\n```\nworker/ — Cloudflare Worker + Durable Objects\nlocal-mcp/ — Local stdio MCP bridge (TypeScript, dev)\nmcp-server/ — .mcpb package for Claude Desktop\nshared/ — Shared types and utilities\n```\n\n## Privacy Policy\n\nEvents are stored in a Cloudflare Durable Object (edge storage). The local MCP bridge proxies tool calls to the Worker and does not store event data locally.\n\n- Extension privacy policy: https://smgjp.com/privacy-policy-github-webhook-mcp/\n\n## Support\n\n- GitHub Issues: https://github.com/Liplus-Project/github-webhook-mcp/issues\n- [Wiki](https://github.com/Liplus-Project/github-webhook-mcp/wiki) (EN / JA)\n- Requirements: [docs/0-requirements.md](docs/0-requirements.md)\n\n## Related\n\n- [Liplus-Project/liplus-language](https://github.com/Liplus-Project/liplus-language) — Li+ language specification\n"
+ }
+ ],
+ "queries": [
+ {
+ "id": "ngr_tail",
+ "repo": "Liplus-Project/neuron-graph-rag",
+ "text": "structured advisory warning",
+ "target": "docs/requirements.md",
+ "kind": "tail"
+ },
+ {
+ "id": "rag_tail",
+ "repo": "Liplus-Project/github-rag-mcp",
+ "text": "Deterministic slug-match",
+ "target": "docs/0-requirements.md",
+ "kind": "tail"
+ },
+ {
+ "id": "hook_tail",
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "text": "0` で未処理を即削除",
+ "target": "docs/0-requirements.ja.md",
+ "kind": "tail"
+ },
+ {
+ "id": "ngr_prefix",
+ "repo": "Liplus-Project/neuron-graph-rag",
+ "text": "judgment relation",
+ "target": "docs/requirements.md",
+ "kind": "prefix-control"
+ },
+ {
+ "id": "rag_prefix",
+ "repo": "Liplus-Project/github-rag-mcp",
+ "text": "hybrid retrieval",
+ "target": "docs/0-requirements.md",
+ "kind": "prefix-control"
+ },
+ {
+ "id": "hook_prefix",
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "text": "retention sweep",
+ "target": "docs/0-requirements.ja.md",
+ "kind": "prefix-control"
+ },
+ {
+ "id": "negative",
+ "repo": null,
+ "text": "quartzmonochromezz2789",
+ "target": null,
+ "kind": "absent-from-this-fixture-only"
+ }
+ ]
+}
diff --git a/experiments/document-tail/protocol.json b/experiments/document-tail/protocol.json
new file mode 100644
index 0000000..4a2bd8c
--- /dev/null
+++ b/experiments/document-tail/protocol.json
@@ -0,0 +1,41 @@
+{
+ "scope": "Isolated offline development candidate-discovery pilot, 11 public document fixtures / 7 previously declared queries; not holdout or frozen evaluation",
+ "queries_origin": "Fixed public fixture.json; seven predeclared queries and target labels retained from the 2026-10-13 pilot",
+ "source_sha": "da23db3f077a2975e11fc4ab16cfab1556098045",
+ "algorithm": {
+ "prefix": "Exact current prepareEmbeddingInput: (path + two newline characters + body).slice(0,8000), JavaScript UTF16 units",
+ "full": "Same path + two newlines + body, no clipping",
+ "chunks": "Same full sequence, fixed nonoverlap <=8000 UTF16 units, boundary moved back one unit when it bisects a surrogate pair; path only in first chunk, no overlap/no repeated title",
+ "segmentation": "Byte-exact pinned source-src-segment.ts, imported using Node TypeScript stripping; escapeFtsQuery extracted from pinned source with TypeScript annotations removed",
+ "tokenizer": "porter unicode61 remove_diacritics 2, external-content single natural FTS5 column; no code rows",
+ "rank": "Strict AND tier0 before relaxed OR tier1, bm25 ascending; exhaust branches, dedup document entity using best row, top3 scoring; deterministic rowid tie break",
+ "label_use": "Target paths used only after ranking; retrieval uses query text and optional exact repository filter",
+ "timing": "One warm query then31 repeated local queries; median includes snippet/highlight window extraction, sorting and dedup; DB build includes insertion/commit but excludes integrity check; preparation separate",
+ "negative": "Only literal absence from this fixture, never production unanswerability"
+ },
+ "variations_from_production": [
+ "Local SQLite/Python and Node ICU are not Cloudflare D1/workerd parity proof",
+ "Exhaustive branch collection instead of production per-branch topK/outer cap to prevent chunk rows consuming entity slots; prefix/full cap-equivalence audit separately",
+ "Added rowid tie break; production equal-score ordering unspecified",
+ "No dense, graph, RRF, crossencoder, abstention or end-to-end packet assessment"
+ ],
+ "unknowns": [
+ "Broader corpus/false-positive relevance annotation",
+ "Phrase/token split at chunk boundaries, no overlap",
+ "Deletion/replacement/provenance contracts and unchanged-hash backfill",
+ "Full sparse source vs embedding prefix vs rerank evidence slice/API fetch-source semantics",
+ "D1 size/statement/CPU constraints and runtime segmentation compatibility",
+ "Chunk source offsets, stable IDs/entity dedup and rerank/return window provenance"
+ ],
+ "fixture_sha256": "b6c44c1d505e1c3a5235495c20f071d0c49f2c93204deda07b31a952a817fea3",
+ "source_hashes": {
+ "source-migrations-0006_fts5_segmented_nat_index.sql": "3e2b53ffc1e947e1987141bd8feb7fcd9adbcf508244bc31d91d0ab636f38d59",
+ "source-src-fts.ts": "fd54209ecb0e3b6875a663dcb8eafb351f3d88b7305dc095ffcf6c5cffe3a0c7",
+ "source-src-pipeline-embed-doc.ts": "f1f8ec2a1efd4bb44921551801168c75aa359dcb75a75380d2a8a1836ff45947",
+ "source-src-pipeline-embedding.ts": "0e717d0bc0a457f298a00402cd379b977ff3276a84e04595ea0bb22a52471cf3",
+ "source-src-pipeline-hash.ts": "ab8595fa440f7c2a86ecac0e8684116a378e3a41722e90d7529d81a25e629956",
+ "source-src-rerank.ts": "e9693945311759e896fec6f6ce7d5eb575d77135546987366bdd1044aefc8148",
+ "source-src-segment.ts": "e5268f5d338d933ba0818d02b38648b848548ca1925266381f26bb3b5c33592a"
+ },
+ "provenance_sha256": "29c2f7b9c712dd02794790e1c9af3c7af889f0dad8b112a4943527518c4c78f8"
+}
diff --git a/experiments/document-tail/provenance.json b/experiments/document-tail/provenance.json
new file mode 100644
index 0000000..cb90db0
--- /dev/null
+++ b/experiments/document-tail/provenance.json
@@ -0,0 +1,101 @@
+[
+ {
+ "repo": "Liplus-Project/neuron-graph-rag",
+ "path": "docs/requirements.md",
+ "commit": "e51627243b7d30915bf0b5b8fd6e6c1704b8e2b8",
+ "blob_sha": "2fe74a27d8e1d0ab913b2f3c797ef70eb94adde5",
+ "content_sha256": "4e3bf59075021a65f46e2f2e9f09dfc654e7e0a489682b701fa80dad758eb277",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/neuron-graph-rag/blob/e51627243b7d30915bf0b5b8fd6e6c1704b8e2b8/docs/requirements.md"
+ },
+ {
+ "repo": "Liplus-Project/neuron-graph-rag",
+ "path": "docs/specifications/runtime-wheel.md",
+ "commit": "e51627243b7d30915bf0b5b8fd6e6c1704b8e2b8",
+ "blob_sha": "35efe37ae74afa699402a3eced6a21e23ce372dd",
+ "content_sha256": "5e51153c9e8c75fefaadaa3d3b4b67bbafca8a70d690cd75ce922d4cf285328e",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/neuron-graph-rag/blob/e51627243b7d30915bf0b5b8fd6e6c1704b8e2b8/docs/specifications/runtime-wheel.md"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/0-requirements.md",
+ "commit": "da23db3f077a2975e11fc4ab16cfab1556098045",
+ "blob_sha": "9a58ecc8b1fbf0096bd90886f475f0a6df595503",
+ "content_sha256": "2fbb9191ea766fe7bd09eed5dc3f0d88aefdac0f26e6e72d5f90e7631f91f29a",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/da23db3f077a2975e11fc4ab16cfab1556098045/docs/0-requirements.md"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/0-requirements.ja.md",
+ "commit": "da23db3f077a2975e11fc4ab16cfab1556098045",
+ "blob_sha": "987c92065275e73793e86cc80213c401e13b6b24",
+ "content_sha256": "86ab0352e8b15b04679cb045f56b6da1b3d0e21535c287bcdc7f42e7b6156420",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/da23db3f077a2975e11fc4ab16cfab1556098045/docs/0-requirements.ja.md"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/2-feedback-memory.md",
+ "commit": "da23db3f077a2975e11fc4ab16cfab1556098045",
+ "blob_sha": "8597a23b9facbbbd8888df46cf063db89f312885",
+ "content_sha256": "02dc1d8f0c95eea85db64dd23f2e2d491a399262316e8535dbb59d21ba750c1b",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/da23db3f077a2975e11fc4ab16cfab1556098045/docs/2-feedback-memory.md"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "docs/0-requirements.ja.md",
+ "commit": "347d8604a600e086ff0789bb01f49448c64cad41",
+ "blob_sha": "fb8311e564cd3e69f9860a7e6bbc5130ed546583",
+ "content_sha256": "905146f7c5d566a712471205cae8f5eb4234bf3f0fe0dc7589b6ac00e7cb755c",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/347d8604a600e086ff0789bb01f49448c64cad41/docs/0-requirements.ja.md"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "docs/0-requirements.md",
+ "commit": "347d8604a600e086ff0789bb01f49448c64cad41",
+ "blob_sha": "965a20dcf03273056a78fe23df18b45e904b519d",
+ "content_sha256": "0bcd2a072539fc801df6690784e0127cf1b9df4a8dd50122378a8b1224584fe6",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/347d8604a600e086ff0789bb01f49448c64cad41/docs/0-requirements.md"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "docs/_Sidebar.md",
+ "commit": "347d8604a600e086ff0789bb01f49448c64cad41",
+ "blob_sha": "d8a18c50a87d0a9403ad994c380c2cc55050dd80",
+ "content_sha256": "a7dd46d4e83426198e588f194c229d430a7832fbac5955f50e79bf7527a709d9",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/347d8604a600e086ff0789bb01f49448c64cad41/docs/_Sidebar.md"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/2-feedback-memory.ja.md",
+ "commit": "da23db3f077a2975e11fc4ab16cfab1556098045",
+ "blob_sha": "522fd9cb952e2756be6bec796ac2b51722cc1d93",
+ "content_sha256": "359e9af4f291b732d6b09b106bea6f1c5a4c4e40c9c651a836b626774ca8e847",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/da23db3f077a2975e11fc4ab16cfab1556098045/docs/2-feedback-memory.ja.md"
+ },
+ {
+ "repo": "Liplus-Project/github-rag-mcp",
+ "path": "docs/4-abstention-replay.ja.md",
+ "commit": "da23db3f077a2975e11fc4ab16cfab1556098045",
+ "blob_sha": "bf01b842970baad23a2092b60752d82e48cc00c5",
+ "content_sha256": "cac7b8f42ca4d280a2b745f26a56130559a104c66c11c9cc1bab01d64af851ff",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-rag-mcp/blob/da23db3f077a2975e11fc4ab16cfab1556098045/docs/4-abstention-replay.ja.md"
+ },
+ {
+ "repo": "Liplus-Project/github-webhook-mcp",
+ "path": "README.md",
+ "commit": "347d8604a600e086ff0789bb01f49448c64cad41",
+ "blob_sha": "d7f80a07e0cfeddce7bb6ed907bb35fd7fc95186",
+ "content_sha256": "10b1ce65da5b661f6ef689dedfe5066f175b5d0690103485e847ff649ef50e20",
+ "matches_live_capture": true,
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp/blob/347d8604a600e086ff0789bb01f49448c64cad41/README.md"
+ }
+]
diff --git a/experiments/document-tail/source-migrations-0006_fts5_segmented_nat_index.sql b/experiments/document-tail/source-migrations-0006_fts5_segmented_nat_index.sql
new file mode 100644
index 0000000..cd03ceb
--- /dev/null
+++ b/experiments/document-tail/source-migrations-0006_fts5_segmented_nat_index.sql
@@ -0,0 +1,96 @@
+-- Give the natural-language FTS5 index a pre-segmented content column so Japanese
+-- phrase queries produce BM25 candidates (issue #180 fact 1 / #182).
+--
+-- Layer = L4 Operations (sparse retrieval surface)
+--
+-- Problem: `unicode61` only breaks on non-alphanumeric characters, so a run of
+-- kana/kanji is indexed as a single token and a Japanese phrase query matches
+-- nothing. D1 cannot load a custom tokenizer (`CREATE VIRTUAL TABLE ... tokenize =
+-- 'icu'` fails with `no such tokenizer: icu`), so the word boundaries have to be
+-- inserted before the text reaches SQLite. The Worker does that with
+-- `Intl.Segmenter` (src/segment.ts) on both the ingest and the query side.
+--
+-- Shape of the fix:
+-- - `search_docs.content` keeps the RAW text. It feeds the reranker and stays the
+-- external content of the code (trigram) index, which already matches Japanese
+-- substrings and is not part of this change.
+-- - `search_docs.content_fts` holds the segmented text, and a new v3 generation of
+-- the nat index takes that column as its external content.
+--
+-- Why a new generation instead of editing v2: an external-content FTS5 index must be
+-- able to reproduce, at delete time, the exact text it indexed. Repointing the live
+-- v2 index at a different column would make every pre-existing shadow row
+-- unreproducible and end in SQLITE_CORRUPT_VTAB (the #117 / #135 / migration 0005
+-- failure mode). v2 is therefore left byte-for-byte as it is and simply stops being
+-- written to and queried, exactly as 0005 did with v1.
+--
+-- The code side stays on `search_docs_code_fts_v2` with the raw `content` column;
+-- only the nat branch advances a generation.
+--
+-- `content_fts` is initialized here with a copy of the raw content, so the v3 index
+-- is never empty and English rows are already correct (segmentation is a no-op for
+-- text with no CJK). Japanese rows are re-segmented afterwards by
+-- `POST /admin/backfill-fts-segments`, which cannot run in SQL because the
+-- segmentation only exists in JS.
+
+ALTER TABLE search_docs ADD COLUMN content_fts TEXT NOT NULL DEFAULT '';
+
+-- Drop the v2 sync triggers BEFORE the bulk UPDATE below. The UPDATE does not touch
+-- `content`, so both v2 indexes stay consistent with their views without the
+-- delete/insert churn a fired trigger would cause on every row.
+DROP TRIGGER IF EXISTS trg_search_docs_ai;
+DROP TRIGGER IF EXISTS trg_search_docs_ad;
+DROP TRIGGER IF EXISTS trg_search_docs_au;
+
+UPDATE search_docs SET content_fts = content;
+
+CREATE VIEW IF NOT EXISTS search_docs_nat_content_v3 AS
+ SELECT rowid, content_fts AS content
+ FROM search_docs
+ WHERE tokenizer_kind = 'nat';
+
+-- Same tokenizer string as v2 on purpose. The Japanese fix is upstream of the
+-- tokenizer (the text arrives pre-split), and keeping `porter unicode61` identical is
+-- what guarantees English matching does not regress.
+CREATE VIRTUAL TABLE IF NOT EXISTS search_docs_nat_fts_v3 USING fts5 (
+ content,
+ tokenize = 'porter unicode61 remove_diacritics 2',
+ content = 'search_docs_nat_content_v3',
+ content_rowid = 'rowid'
+);
+
+INSERT INTO search_docs_nat_fts_v3(rowid, content)
+ SELECT rowid, content_fts
+ FROM search_docs
+ WHERE tokenizer_kind = 'nat';
+
+-- v3 generation triggers. Every branch stays guarded by `tokenizer_kind` so a row is
+-- inserted into and deleted from exactly one index, and each delete command replays
+-- the exact column that index holds: `content_fts` for nat, `content` for code.
+CREATE TRIGGER trg_search_docs_ai AFTER INSERT ON search_docs
+BEGIN
+ INSERT INTO search_docs_nat_fts_v3(rowid, content)
+ SELECT new.rowid, new.content_fts WHERE new.tokenizer_kind = 'nat';
+ INSERT INTO search_docs_code_fts_v2(rowid, content)
+ SELECT new.rowid, new.content WHERE new.tokenizer_kind = 'code';
+END;
+
+CREATE TRIGGER trg_search_docs_ad AFTER DELETE ON search_docs
+BEGIN
+ INSERT INTO search_docs_nat_fts_v3(search_docs_nat_fts_v3, rowid, content)
+ SELECT 'delete', old.rowid, old.content_fts WHERE old.tokenizer_kind = 'nat';
+ INSERT INTO search_docs_code_fts_v2(search_docs_code_fts_v2, rowid, content)
+ SELECT 'delete', old.rowid, old.content WHERE old.tokenizer_kind = 'code';
+END;
+
+CREATE TRIGGER trg_search_docs_au AFTER UPDATE ON search_docs
+BEGIN
+ INSERT INTO search_docs_nat_fts_v3(search_docs_nat_fts_v3, rowid, content)
+ SELECT 'delete', old.rowid, old.content_fts WHERE old.tokenizer_kind = 'nat';
+ INSERT INTO search_docs_code_fts_v2(search_docs_code_fts_v2, rowid, content)
+ SELECT 'delete', old.rowid, old.content WHERE old.tokenizer_kind = 'code';
+ INSERT INTO search_docs_nat_fts_v3(rowid, content)
+ SELECT new.rowid, new.content_fts WHERE new.tokenizer_kind = 'nat';
+ INSERT INTO search_docs_code_fts_v2(rowid, content)
+ SELECT new.rowid, new.content WHERE new.tokenizer_kind = 'code';
+END;
diff --git a/experiments/document-tail/source-src-fts.ts b/experiments/document-tail/source-src-fts.ts
new file mode 100644
index 0000000..f5f6710
--- /dev/null
+++ b/experiments/document-tail/source-src-fts.ts
@@ -0,0 +1,656 @@
+/**
+ * D1 FTS5 sparse retrieval layer — BM25 side of hybrid search.
+ *
+ * Layer = L4 Operations (sparse retrieval surface)
+ *
+ * Responsibilities:
+ * - Index tokenizable content into the D1 FTS5 virtual tables (`search_docs_nat_fts_v3`,
+ * `search_docs_code_fts_v2`) via the `search_docs` content-owner table.
+ * - Query FTS5 by a natural language or code-oriented query using BM25 ranking.
+ * - Delete rows when the canonical surface is removed (issue/PR/release/doc).
+ *
+ * Notes:
+ * - FTS5 is SQLite's built-in full-text search extension. Cloudflare D1 is pre-compiled
+ * with FTS5, so no extension loading is required; the `fts5` module name must be lowercase.
+ * - We keep two FTS5 virtual tables with different tokenizers:
+ * - `search_docs_nat_fts_v3` — porter + unicode61 for natural-language surfaces
+ * - `search_docs_code_fts_v2` — trigram for code / SHA / identifier surfaces (diffs)
+ * Rows are written to exactly one based on `tokenizer_kind`.
+ * - The nat index takes its text from `search_docs.content_fts`, which holds the
+ * `Intl.Segmenter`-segmented form of the content (see ./segment.ts). `unicode61`
+ * cannot split Japanese on its own, so the word boundaries are inserted before the
+ * text reaches SQLite — on the ingest side AND on the query side, symmetrically
+ * (issue #180 fact 1 / #182). The code index keeps using the raw `content`: trigram
+ * already substring-matches Japanese.
+ * - BM25 is invoked via the `bm25()` auxiliary function. Smaller value = better.
+ * We convert to a rank (1..N) for RRF fusion in the retrieval path.
+ */
+
+import { segmentForFts } from "./segment.js";
+import { pathPrefixRange } from "./path-prefix.js";
+import type { DiffFileStatus, VectorMetadata } from "./types.js";
+
+/** Which FTS5 virtual table a row is indexed in. */
+export type TokenizerKind = "nat" | "code";
+
+/** Return the appropriate tokenizer kind for a given surface type. */
+export function tokenizerKindForType(
+ type: VectorMetadata["type"],
+): TokenizerKind {
+ return type === "diff" ? "code" : "nat";
+}
+
+/**
+ * Row payload for upserting a tokenizable document into D1 FTS5.
+ * vector_id mirrors the deterministic Vectorize vector ID so RRF fusion
+ * can join sparse and dense hits without an extra round-trip.
+ */
+export interface FtsUpsertRow {
+ vectorId: string;
+ repo: string;
+ type: VectorMetadata["type"];
+ state: string;
+ labels: string; // comma-separated, mirrors VectorMetadata.labels
+ milestone: string;
+ assignees: string; // comma-separated, mirrors VectorMetadata.assignees
+ updatedAt: string;
+ number?: number;
+ tagName?: string;
+ docPath?: string;
+ commitSha?: string;
+ filePath?: string;
+ fileStatus?: DiffFileStatus | "";
+ commitDate?: string;
+ commitAuthor?: string;
+ commentId?: number;
+ reviewId?: number;
+ content: string; // tokenizable text (title+body or commit msg + path + patch)
+}
+
+/**
+ * How the tokens of one MATCH string are combined.
+ *
+ * - `all` — implicit AND (FTS5 default for juxtaposed phrases): a document must
+ * contain every token. Precise, but the constraint tightens with every extra
+ * token, so long queries fall off a cliff to zero hits (issue #186).
+ * - `any` — explicit OR: a document needs one token. Recall-shaped; BM25 then
+ * ranks documents that cover more (and rarer) query terms above the rest.
+ */
+export type FtsMatchMode = "all" | "any";
+
+/**
+ * Escape a query term for FTS5 MATCH syntax (wrap each token in double quotes).
+ * `mode` selects the boolean connective between tokens; see FtsMatchMode.
+ */
+export function escapeFtsQuery(raw: string, mode: FtsMatchMode = "all"): string {
+ // Split on whitespace, drop empties, quote each token.
+ // Double quotes inside are escaped by doubling them per FTS5 syntax.
+ const tokens = raw
+ .split(/\s+/)
+ .map((t) => t.trim())
+ .filter((t) => t.length > 0);
+ if (tokens.length === 0) return "";
+ const quoted = tokens.map((t) => `"${t.replace(/"/g, '""')}"`);
+ return quoted.join(mode === "any" ? " OR " : " ");
+}
+
+/**
+ * Text to index in / match against the natural-language FTS5 index for a given
+ * tokenizer kind. Single source for the ingest side (`content_fts`) and the query
+ * side (nat MATCH string) so the two can never drift apart — an asymmetric
+ * segmentation silently loses recall instead of failing.
+ *
+ * `code` rows are indexed raw: the trigram tokenizer needs the original characters.
+ */
+export function ftsIndexText(text: string, kind: TokenizerKind): string {
+ return kind === "nat" ? segmentForFts(text) : text;
+}
+
+/**
+ * Upsert a single row into D1 search_docs. Triggers on the table mirror the content
+ * into the matching FTS5 virtual table. Safe to call repeatedly for the same vector_id.
+ */
+export async function upsertFtsRow(
+ db: D1Database,
+ row: FtsUpsertRow,
+): Promise {
+ const tokenizerKind: TokenizerKind = tokenizerKindForType(row.type);
+ const now = new Date().toISOString();
+
+ await db
+ .prepare(
+ `INSERT INTO search_docs (
+ vector_id, repo, type, state, labels, milestone, assignees, updated_at,
+ number, tag_name, doc_path, commit_sha, file_path, file_status,
+ commit_date, commit_author, comment_id, review_id, tokenizer_kind, content, content_fts, indexed_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (vector_id) DO UPDATE SET
+ repo = excluded.repo,
+ type = excluded.type,
+ state = excluded.state,
+ labels = excluded.labels,
+ milestone = excluded.milestone,
+ assignees = excluded.assignees,
+ updated_at = excluded.updated_at,
+ number = excluded.number,
+ tag_name = excluded.tag_name,
+ doc_path = excluded.doc_path,
+ commit_sha = excluded.commit_sha,
+ file_path = excluded.file_path,
+ file_status = excluded.file_status,
+ commit_date = excluded.commit_date,
+ commit_author = excluded.commit_author,
+ comment_id = excluded.comment_id,
+ review_id = excluded.review_id,
+ tokenizer_kind = excluded.tokenizer_kind,
+ content = excluded.content,
+ content_fts = excluded.content_fts,
+ indexed_at = excluded.indexed_at`,
+ )
+ .bind(
+ row.vectorId,
+ row.repo,
+ row.type,
+ row.state,
+ row.labels,
+ row.milestone,
+ row.assignees,
+ row.updatedAt,
+ row.number ?? 0,
+ row.tagName ?? "",
+ row.docPath ?? "",
+ row.commitSha ?? "",
+ row.filePath ?? "",
+ row.fileStatus ?? "",
+ row.commitDate ?? "",
+ row.commitAuthor ?? "",
+ row.commentId ?? 0,
+ row.reviewId ?? 0,
+ tokenizerKind,
+ row.content,
+ ftsIndexText(row.content, tokenizerKind),
+ now,
+ )
+ .run();
+}
+
+/** Outcome of one `backfillNatSegments` batch. */
+export interface SegmentBackfillResult {
+ /** Rows examined in this batch. */
+ scanned: number;
+ /** Rows whose `content_fts` actually changed (and were rewritten). */
+ updated: number;
+ /** Pass back as `cursor` to continue; `null` once the scan is exhausted. */
+ nextCursor: number | null;
+}
+
+/**
+ * Re-segment one batch of natural-language rows into `content_fts`.
+ *
+ * Migration 0006 seeds `content_fts` with a copy of the raw content because the
+ * segmentation only exists in JS. This walks the nat rows and rewrites the ones whose
+ * stored form differs from the current segmentation; the UPDATE trigger re-syncs the
+ * v3 FTS index per row.
+ *
+ * Batched on purpose: one bulk UPDATE would fire the trigger for every row in a single
+ * statement and blow both the D1 statement budget and the Worker subrequest budget.
+ * Each call issues at most two D1 operations (one SELECT, one batched UPDATE).
+ *
+ * Resumable and idempotent: progress is a plain `rowid` cursor, and a row whose
+ * `content_fts` already matches is skipped, so re-running an interrupted backfill from
+ * the beginning converges without rewriting the work that already landed.
+ */
+export async function backfillNatSegments(
+ db: D1Database,
+ opts: { limit: number; cursor?: number; repo?: string },
+): Promise {
+ const cursor = opts.cursor ?? 0;
+ const selectParams: (string | number)[] = [cursor];
+ let repoSql = "";
+ if (opts.repo) {
+ repoSql = " AND repo = ?";
+ selectParams.push(opts.repo);
+ }
+ selectParams.push(opts.limit);
+
+ const res = await db
+ .prepare(
+ `SELECT rowid AS rid, content, content_fts
+ FROM search_docs
+ WHERE tokenizer_kind = 'nat' AND rowid > ?${repoSql}
+ ORDER BY rowid
+ LIMIT ?`,
+ )
+ .bind(...selectParams)
+ .all<{ rid: number; content: string; content_fts: string }>();
+
+ const rows = res.results ?? [];
+ if (rows.length === 0) return { scanned: 0, updated: 0, nextCursor: null };
+
+ const update = db.prepare(`UPDATE search_docs SET content_fts = ? WHERE rowid = ?`);
+ const pending: D1PreparedStatement[] = [];
+ for (const r of rows) {
+ const segmented = ftsIndexText(String(r.content ?? ""), "nat");
+ if (segmented !== String(r.content_fts ?? "")) {
+ pending.push(update.bind(segmented, Number(r.rid)));
+ }
+ }
+ if (pending.length > 0) await db.batch(pending);
+
+ return {
+ scanned: rows.length,
+ updated: pending.length,
+ // A short page means the scan reached the end of the table.
+ nextCursor: rows.length < opts.limit ? null : Number(rows[rows.length - 1].rid),
+ };
+}
+
+/** Delete a single row by its vector_id. FTS5 rows are removed via the delete trigger. */
+export async function deleteFtsRow(
+ db: D1Database,
+ vectorId: string,
+): Promise {
+ await db
+ .prepare(`DELETE FROM search_docs WHERE vector_id = ?`)
+ .bind(vectorId)
+ .run();
+}
+
+/**
+ * True when at least one indexed row carries this exact `repo` value.
+ *
+ * Existence probe behind the `filters_unmatched` observability field (issue #219).
+ * The `repo` filter is an exact match on the full slug (`owner/repo`) — a bare
+ * repository name matches no row, and the resulting empty candidate set is shaped
+ * exactly like a genuine zero-hit search. The caller runs this probe only on that
+ * ambiguous shape, so the extra D1 read never sits on the hot path.
+ *
+ * `LIMIT 1` on the indexed `repo` column, no FTS5 involvement: the query stops at
+ * the first matching row instead of counting the population.
+ */
+export async function repoHasIndexedRows(
+ db: D1Database,
+ repo: string,
+): Promise {
+ const row = await db
+ .prepare(`SELECT 1 AS present FROM search_docs WHERE repo = ? LIMIT 1`)
+ .bind(repo)
+ .first<{ present: number }>();
+ return row != null;
+}
+
+/** True when a repository contains at least one indexed doc under the prefix. */
+export async function docPathPrefixHasIndexedRows(
+ db: D1Database,
+ pathPrefix: string,
+ repo?: string,
+): Promise {
+ const { lower, upper } = pathPrefixRange(pathPrefix);
+ const repoSql = repo ? " AND repo = ?" : "";
+ const params = repo ? [lower, upper, repo] : [lower, upper];
+ const row = await db
+ .prepare(
+ `SELECT 1 AS present FROM search_docs ` +
+ `WHERE type = 'doc' AND doc_path >= ? AND doc_path < ?${repoSql} LIMIT 1`,
+ )
+ .bind(...params)
+ .first<{ present: number }>();
+ return row != null;
+}
+
+/**
+ * Names of the applied filters whose selected population is empty (issue #219).
+ *
+ * Answers the question the candidate count cannot: "did the search find nothing,
+ * or did the filter select nothing?" Both produce `count: 0`, and a caller doing
+ * multi-step agentic search reads a zero as a normal intermediate result
+ * ("nothing down this angle") and moves on — so a mis-specified filter is
+ * consumed as a false negative instead of being noticed.
+ *
+ * Rules the shape of the check:
+ * - probe only when `candidateCount === 0`. A non-empty candidate set proves
+ * every applied filter matched, so the extra D1 read stays off the hot path.
+ * - a probe failure is not a finding. An unreachable D1 yields an empty list
+ * (the safer direction: never assert a mismatch that was not observed).
+ *
+ * `repo` is probed first. `pathPrefix` is then probed inside that repository only
+ * when the repository exists, so a bad repo does not falsely blame a path that may
+ * be valid elsewhere. `state` / `type` are enum-constrained, and the remaining
+ * filters do not have a comparable near-miss form.
+ */
+export async function detectUnmatchedFilters(
+ db: D1Database,
+ filters: { repo?: string; pathPrefix?: string },
+ candidateCount: number,
+): Promise {
+ const unmatched: string[] = [];
+ if (candidateCount > 0) return unmatched;
+ let repoMatched = true;
+ if (filters.repo) {
+ try {
+ repoMatched = await repoHasIndexedRows(db, filters.repo);
+ if (!repoMatched) unmatched.push("repo");
+ } catch (err) {
+ repoMatched = false;
+ console.error("detectUnmatchedFilters: repo probe failed:");
+ }
+ }
+ if (filters.pathPrefix && repoMatched) {
+ try {
+ if (!(await docPathPrefixHasIndexedRows(db, filters.pathPrefix, filters.repo))) {
+ unmatched.push("path_prefix");
+ }
+ } catch (err) {
+ console.error("detectUnmatchedFilters: path_prefix probe failed:");
+ }
+ }
+ return unmatched;
+}
+
+/** Hit returned by FTS5 BM25 query. `score` is the raw bm25() value (lower = better). */
+export interface FtsHit {
+ vectorId: string;
+ repo: string;
+ type: string;
+ state: string;
+ labels: string;
+ milestone: string;
+ assignees: string;
+ updatedAt: string;
+ number: number;
+ tagName: string;
+ docPath: string;
+ commitSha: string;
+ filePath: string;
+ fileStatus: string;
+ commitDate: string;
+ commitAuthor: string;
+ commentId?: number;
+ reviewId?: number;
+ content: string;
+ score: number;
+}
+
+/** Filter parameters accepted by the sparse retrieval path. Mirrors the dense side. */
+export interface FtsFilter {
+ repo?: string;
+ type?: VectorMetadata["type"];
+ state?: "open" | "closed" | "published" | "active";
+ milestone?: string;
+ pathPrefix?: string;
+}
+
+/**
+ * Query both FTS5 virtual tables and return the top-N hits by BM25 score.
+ *
+ * The query is escaped and run against both tokenizers (`nat`, `code`). The nat side
+ * contributes two tiers — strict (every token) and relaxed (any token) — so that a
+ * long query cannot fall to zero sparse hits; strict hits always sort ahead of relaxed
+ * ones. See the tiering note on the SQL below for why (issue #186). The result sets are
+ * combined, deduplicated and capped at `topK` before the caller applies RRF.
+ *
+ * Additional filters (`repo`, `type`, `state`, `milestone`) are expressed as
+ * SQL WHERE predicates on the joined `search_docs` table, matching the
+ * pre-filter capability of the Vectorize side.
+ */
+export async function queryFts(
+ db: D1Database,
+ query: string,
+ topK: number,
+ filter?: FtsFilter,
+): Promise {
+ // Each branch gets its own MATCH string. The nat index stores segmented text
+ // (`content_fts`), so the query must be segmented the same way or a Japanese phrase
+ // matches nothing; the code index stores raw text, so it must NOT be segmented or
+ // the inserted spaces would break the trigram substring match.
+ const natText = ftsIndexText(query, "nat");
+ const matchNat = escapeFtsQuery(natText, "all");
+ const matchNatAny = escapeFtsQuery(natText, "any");
+ const matchCode = escapeFtsQuery(ftsIndexText(query, "code"), "all");
+ // Segmentation only inserts/removes whitespace, so the two are empty together —
+ // an empty or whitespace-only query. Never bind "" as a MATCH argument.
+ if (matchNat === "" || matchCode === "") return [];
+ // Single-token queries have identical strict/relaxed forms; skip the extra arm.
+ const relaxNat = matchNatAny !== matchNat;
+
+ // Build dynamic WHERE clause for metadata filters. Shared by both branches; only
+ // the leading MATCH parameter differs, so it is bound per branch below.
+ const whereClauses: string[] = [];
+ const params: (string | number)[] = [];
+ if (filter?.repo) {
+ whereClauses.push("d.repo = ?");
+ params.push(filter.repo);
+ }
+ if (filter?.type) {
+ whereClauses.push("d.type = ?");
+ params.push(filter.type);
+ }
+ if (filter?.state) {
+ whereClauses.push("d.state = ?");
+ params.push(filter.state);
+ }
+ if (filter?.milestone) {
+ whereClauses.push("d.milestone = ?");
+ params.push(filter.milestone);
+ }
+ if (filter?.pathPrefix) {
+ const { lower, upper } = pathPrefixRange(filter.pathPrefix);
+ whereClauses.push("d.doc_path >= ? AND d.doc_path < ?");
+ params.push(lower, upper);
+ }
+ const whereSql =
+ whereClauses.length > 0 ? ` AND ${whereClauses.join(" AND ")}` : "";
+
+ // UNION ALL arms so each tokenizer contributes hits. The nat arms read the v3 index
+ // created by migration 0006 (external content = the segmented `content_fts` column);
+ // the code arm stays on the v2 trigram index created by migration 0005 (external
+ // content = the raw `content` column). Superseded generations (nat v2, and both v1
+ // tables) are intentionally left untouched — they may hold invalid delete entries,
+ // and rewriting them is what corrupts an external-content index.
+ //
+ // `bm25()` returns negative values in D1's FTS5 (larger-magnitude negative = better
+ // match), so ORDER BY score ASC is best-first regardless of sign.
+ //
+ // Per-branch `ORDER BY score ASC LIMIT ?` must live inside a subquery because
+ // SQLite's compound SELECT (UNION ALL) forbids LIMIT directly on its arms —
+ // D1 rejects such queries with `D1_ERROR: LIMIT clause should come after
+ // UNION ALL not before` (observed via Workers Observability, 2026-04-24).
+ //
+ // Tiering (issue #186). A conjunctive MATCH gets monotonically harder to satisfy as
+ // the query grows: measured on production, 3 tokens -> 3 hits, ~12 -> 2, ~17 -> 0.
+ // The cliff is language-independent (Japanese only reaches it sooner because
+ // segmentation turns particles into their own tokens), so the fix is too: the nat
+ // side runs BOTH a strict (all-tokens) and a relaxed (any-token) MATCH, tagged with
+ // a `tier` column, and the outer ORDER BY sorts by tier first. Consequences:
+ // - strict hits keep exactly the ranks they have today; the relaxed arm can only
+ // append below them, never displace one. Short queries do not regress.
+ // - the relaxed arm becomes visible only insofar as the strict arm under-fills
+ // topK — which is precisely the failure mode. No length threshold is needed.
+ // - relaxed-tier precision is carried by BM25 itself: IDF weighting pushes
+ // documents covering more (and rarer) query terms up, and drives high-frequency
+ // function words (`の` / `は` / `the` / `of`) toward zero contribution. That is the
+ // language-independent form of the "strip particles" idea, done by the ranker.
+ // Both tiers live in ONE statement on purpose: the "retry with OR when AND returns
+ // nothing" shape would be simpler to write but costs a second D1 round-trip per
+ // query, and would leave the measured mid-band (~12 tokens, 2 weak hits) unhelped.
+ // Cost accepted: the relaxed arm makes FTS5 walk the posting lists of every query
+ // token, including very common ones, so a long query reads more rows than before.
+ // If that ever bites, the next lever is IDF-aware term pruning via an `fts5vocab`
+ // table — not a hand-maintained stopword list.
+ //
+ // The code (trigram) arm is deliberately left strict: it is out of scope for #186,
+ // and single-token substring matching does not have the same cliff.
+ const natArm = (tier: number) => `
+ SELECT * FROM (
+ SELECT d.vector_id AS vector_id, d.repo, d.type, d.state, d.labels,
+ d.milestone, d.assignees, d.updated_at,
+ d.number, d.tag_name, d.doc_path, d.commit_sha, d.file_path, d.file_status,
+ d.commit_date, d.commit_author, d.comment_id, d.review_id, d.content,
+ bm25(search_docs_nat_fts_v3) AS score, ${tier} AS tier
+ FROM search_docs_nat_fts_v3 f
+ JOIN search_docs d ON d.rowid = f.rowid
+ WHERE search_docs_nat_fts_v3 MATCH ?${whereSql}
+ ORDER BY score ASC
+ LIMIT ?
+ )`;
+
+ const codeArm = `
+ SELECT * FROM (
+ SELECT d.vector_id AS vector_id, d.repo, d.type, d.state, d.labels,
+ d.milestone, d.assignees, d.updated_at,
+ d.number, d.tag_name, d.doc_path, d.commit_sha, d.file_path, d.file_status,
+ d.commit_date, d.commit_author, d.comment_id, d.review_id, d.content,
+ bm25(search_docs_code_fts_v2) AS score, 0 AS tier
+ FROM search_docs_code_fts_v2 f
+ JOIN search_docs d ON d.rowid = f.rowid
+ WHERE search_docs_code_fts_v2 MATCH ?${whereSql}
+ ORDER BY score ASC
+ LIMIT ?
+ )`;
+
+ // Each arm binds its own MATCH string first, then the shared filter params, then its
+ // own topK cap; the outer LIMIT is bound last.
+ const arms = [natArm(0), codeArm];
+ const bindArgs: (string | number)[] = [
+ matchNat,
+ ...params,
+ topK,
+ matchCode,
+ ...params,
+ topK,
+ ];
+ if (relaxNat) {
+ arms.push(natArm(1));
+ bindArgs.push(matchNatAny, ...params, topK);
+ }
+ // Overfetch when the relaxed arm is present: a document can be returned by both nat
+ // tiers, and those duplicates are collapsed below rather than eating result slots.
+ bindArgs.push(relaxNat ? topK * 2 : topK);
+
+ const sql = `
+ SELECT * FROM (${arms.join("\n UNION ALL")}
+ )
+ ORDER BY tier ASC, score ASC
+ LIMIT ?
+ `;
+
+ const stmt = db.prepare(sql).bind(...bindArgs);
+ const result = await stmt.all>();
+ const rows = result.results ?? [];
+
+ // Deduplicate by vector_id, keeping the first (best tier, then best BM25) occurrence,
+ // then cap at topK. Without the relaxed arm this is a no-op: a row is indexed in
+ // exactly one FTS table, so the nat and code arms can never return the same id.
+ const seen = new Set();
+ const hits: FtsHit[] = [];
+ for (const r of rows) {
+ const vectorId = String(r.vector_id ?? "");
+ if (seen.has(vectorId)) continue;
+ seen.add(vectorId);
+ hits.push({
+ vectorId,
+ repo: String(r.repo ?? ""),
+ type: String(r.type ?? ""),
+ state: String(r.state ?? ""),
+ labels: String(r.labels ?? ""),
+ milestone: String(r.milestone ?? ""),
+ assignees: String(r.assignees ?? ""),
+ updatedAt: String(r.updated_at ?? ""),
+ number: Number(r.number ?? 0),
+ tagName: String(r.tag_name ?? ""),
+ docPath: String(r.doc_path ?? ""),
+ commitSha: String(r.commit_sha ?? ""),
+ filePath: String(r.file_path ?? ""),
+ fileStatus: String(r.file_status ?? ""),
+ commitDate: String(r.commit_date ?? ""),
+ commitAuthor: String(r.commit_author ?? ""),
+ commentId: Number(r.comment_id ?? 0),
+ reviewId: Number(r.review_id ?? 0),
+ content: String(r.content ?? ""),
+ score: Number(r.score ?? 0),
+ });
+ if (hits.length >= topK) break;
+ }
+ return hits;
+}
+
+/**
+ * Convert a list of hits (already ordered best-first) to a rank map keyed by vector_id.
+ * Used by RRF fusion — rank 1 is the best hit.
+ */
+export function toRankMap(
+ hits: Array,
+): Map {
+ const ranks = new Map();
+ for (let i = 0; i < hits.length; i++) {
+ const id = hits[i].vectorId;
+ if (!ranks.has(id)) {
+ ranks.set(id, i + 1);
+ }
+ }
+ return ranks;
+}
+
+/**
+ * Reciprocal Rank Fusion.
+ *
+ * Standard RRF formula:
+ * score(d) = sum_over_rankers ( 1 / (k + rank(d)) )
+ *
+ * k = 60 is the canonical value from Cormack et al. (2009) and is the de-facto
+ * default in production hybrid retrieval systems (Elasticsearch, Vespa, Milvus).
+ *
+ * Higher fused score = better hit. Hits that appear in only one ranker still get
+ * partial credit from that ranker's contribution.
+ */
+export interface RrfInput {
+ /** ranker name → rank map (vectorId → 1-based rank). */
+ rankers: Map>;
+ /** RRF constant, default 60. */
+ k?: number;
+}
+
+export function reciprocalRankFusion(
+ input: RrfInput,
+): Array<{ vectorId: string; fusedScore: number; contributions: Record }> {
+ const k = input.k ?? 60;
+ const totals = new Map();
+ const contributions = new Map>();
+
+ // Collect the union of ids across rankers.
+ const allIds = new Set();
+ for (const [, ranks] of input.rankers) {
+ for (const id of ranks.keys()) {
+ allIds.add(id);
+ }
+ }
+
+ for (const id of allIds) {
+ let total = 0;
+ const perRanker: Record = {};
+ for (const [name, ranks] of input.rankers) {
+ const r = ranks.get(id);
+ if (r !== undefined) {
+ total += 1 / (k + r);
+ perRanker[name] = r;
+ } else {
+ perRanker[name] = null;
+ }
+ }
+ totals.set(id, total);
+ contributions.set(id, perRanker);
+ }
+
+ return [...totals.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .map(([vectorId, fusedScore]) => ({
+ vectorId,
+ fusedScore,
+ contributions: contributions.get(vectorId) ?? {},
+ }));
+}
+
+/** Repair canonical event IDs even when unchanged content skips embedding. */
+export async function repairSourceEventIdentity(db: D1Database, vectorId: string, kind: 'comment' | 'review', id: number): Promise {
+ await db.prepare(`UPDATE search_docs SET ${kind === 'comment' ? 'comment_id' : 'review_id'}=? WHERE vector_id=?`).bind(id, vectorId).run();
+}
diff --git a/experiments/document-tail/source-src-pipeline-embed-doc.ts b/experiments/document-tail/source-src-pipeline-embed-doc.ts
new file mode 100644
index 0000000..5cad10c
--- /dev/null
+++ b/experiments/document-tail/source-src-pipeline-embed-doc.ts
@@ -0,0 +1,232 @@
+/**
+ * Doc + wiki page embedding + upsert pipelines.
+ *
+ * Hosts both `processAndUpsertDoc` (repo docs, blob-SHA change detection done
+ * by caller) and `processAndUpsertWikiDoc` (wiki pages, SHA-256 content hash
+ * computed in-flow). Both surfaces share the same metadata shape and FTS5
+ * column reuse pattern.
+ */
+
+import type { Env, DocRecord, WikiDocRecord } from "../types.js";
+import { upsertFtsRow } from "../fts.js";
+import { indexWikiEdges } from "../graph.js";
+import { prepareEmbeddingInput, sha256Hex } from "./hash.js";
+import { generateEmbedding } from "./embedding.js";
+import { docVectorId, wikiDocVectorId } from "./vector-id.js";
+import type { UpsertResult } from "./types.js";
+
+/**
+ * Process and upsert a single doc: embed content and upsert to Vectorize + Store.
+ *
+ * Unlike issues/releases, docs use blob SHA for change detection (handled by the caller).
+ * This function always generates an embedding — the caller is responsible for determining
+ * whether the doc content has changed.
+ *
+ * @param env - Worker env bindings (AI, VECTORIZE)
+ * @param storeStub - Durable Object stub for IssueStore
+ * @param repo - Repository in "owner/repo" format
+ * @param path - File path within the repo (e.g. "docs/0-requirements.md")
+ * @param content - Decoded file content
+ * @param blobSha - Git blob SHA for this version of the file
+ * @returns UpsertResult indicating what happened
+ */
+export async function processAndUpsertDoc(
+ env: Env,
+ storeStub: DurableObjectStub,
+ repo: string,
+ path: string,
+ content: string,
+ blobSha: string,
+): Promise {
+ const now = new Date().toISOString();
+
+ try {
+ // Generate embedding (use path as title, content as body)
+ const embeddingInput = prepareEmbeddingInput(path, content);
+ const embedding = await generateEmbedding(env.AI, embeddingInput);
+
+ const metadata: Record = {
+ repo,
+ number: 0,
+ type: "doc",
+ state: "active",
+ labels: "",
+ milestone: "",
+ assignees: "",
+ updated_at: now,
+ doc_path: path,
+ };
+
+ // Upsert vector into Vectorize
+ const dvid = await docVectorId(repo, path);
+ await env.VECTORIZE.upsert([
+ {
+ id: dvid,
+ values: embedding,
+ metadata,
+ },
+ ]);
+
+ // Mirror into D1 FTS5 sparse index.
+ try {
+ await upsertFtsRow(env.DB_FTS, {
+ vectorId: dvid,
+ repo,
+ type: "doc",
+ state: "active",
+ labels: "",
+ milestone: "",
+ assignees: "",
+ updatedAt: now,
+ docPath: path,
+ content: embeddingInput,
+ });
+ } catch (ftsErr) {
+ console.error(
+ `Failed to upsert FTS5 row for doc ${repo}/${path}:`,
+ ftsErr instanceof Error ? ftsErr.message : String(ftsErr),
+ );
+ }
+
+ // Upsert doc record into store
+ const record: DocRecord = {
+ repo,
+ path,
+ blobSha,
+ updatedAt: now,
+ };
+
+ await storeStub.fetch(
+ new Request("http://store/upsert-doc", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(record),
+ }),
+ );
+
+ return { embedded: true, skippedUnchanged: false, metadataUpdated: false, failed: false };
+ } catch (err) {
+ console.error(
+ `Failed to embed doc ${repo}/${path}:`,
+ err instanceof Error ? err.message : String(err),
+ );
+ return { embedded: false, skippedUnchanged: false, metadataUpdated: false, failed: true };
+ }
+}
+
+// ── Wiki doc surface ─────────────────────────────────────────
+
+/**
+ * Embed and upsert a single wiki page.
+ *
+ * Mirrors `processAndUpsertDoc` but writes vector / FTS / store records under
+ * the `wiki_doc` type. Vector ID prefix `"w:"` keeps wiki rows in their own
+ * namespace so they never collide with repo doc rows even when page name and
+ * doc path coincide.
+ *
+ * @param env Worker env bindings (AI, VECTORIZE, DB_FTS)
+ * @param storeStub Durable Object stub for IssueStore
+ * @param repo Repository in "owner/repo" format (the wiki belongs to {repo}.wiki)
+ * @param pageName GitHub Wiki page slug (dash-separated, e.g., "Home" or "Foo-Bar")
+ * @param extension Markup file extension that serves the page (e.g., "md", "markdown", "org")
+ * @param content Raw markup content fetched from raw.githubusercontent.com/wiki
+ * @returns UpsertResult indicating what happened
+ */
+export async function processAndUpsertWikiDoc(
+ env: Env,
+ storeStub: DurableObjectStub,
+ repo: string,
+ pageName: string,
+ extension: string,
+ content: string,
+): Promise {
+ const now = new Date().toISOString();
+
+ try {
+ // Generate embedding (use page name as title surrogate, content as body)
+ const embeddingInput = prepareEmbeddingInput(pageName, content);
+ const embedding = await generateEmbedding(env.AI, embeddingInput);
+
+ const metadata: Record = {
+ repo,
+ number: 0,
+ type: "wiki_doc",
+ state: "active",
+ labels: "",
+ milestone: "",
+ assignees: "",
+ updated_at: now,
+ wiki_path: pageName,
+ wiki_extension: extension,
+ };
+
+ const wvid = await wikiDocVectorId(repo, pageName);
+ await env.VECTORIZE.upsert([
+ {
+ id: wvid,
+ values: embedding,
+ metadata,
+ },
+ ]);
+
+ // Mirror into D1 FTS5. We reuse the existing `doc_path` column to store
+ // the wiki page slug — semantically the same kind of "where did this come
+ // from" field, distinguished by the row's `type='wiki_doc'`.
+ try {
+ await upsertFtsRow(env.DB_FTS, {
+ vectorId: wvid,
+ repo,
+ type: "wiki_doc",
+ state: "active",
+ labels: "",
+ milestone: "",
+ assignees: "",
+ updatedAt: now,
+ docPath: pageName,
+ content: embeddingInput,
+ });
+ } catch (ftsErr) {
+ console.error(
+ `Failed to upsert FTS5 row for wiki ${repo}/${pageName}:`,
+ ftsErr instanceof Error ? ftsErr.message : String(ftsErr),
+ );
+ }
+
+ // Extract + index graph "mention" edges to other wiki pages in this repo.
+ // Additive: the default retrieval path never reads these. Failures here must
+ // not break indexing of the page itself.
+ try {
+ await indexWikiEdges(env.DB_FTS, repo, pageName, wvid, content);
+ } catch (edgeErr) {
+ console.error(
+ `Failed to index graph edges for wiki ${repo}/${pageName}:`,
+ edgeErr instanceof Error ? edgeErr.message : String(edgeErr),
+ );
+ }
+
+ const contentHash = await sha256Hex(content);
+ const record: WikiDocRecord = {
+ repo,
+ pageName,
+ extension,
+ contentHash,
+ updatedAt: now,
+ };
+
+ await storeStub.fetch(
+ new Request("http://store/upsert-wiki-doc", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(record),
+ }),
+ );
+
+ return { embedded: true, skippedUnchanged: false, metadataUpdated: false, failed: false };
+ } catch (err) {
+ console.error(
+ `Failed to embed wiki doc ${repo}/${pageName}:`,
+ err instanceof Error ? err.message : String(err),
+ );
+ return { embedded: false, skippedUnchanged: false, metadataUpdated: false, failed: true };
+ }
+}
diff --git a/experiments/document-tail/source-src-pipeline-embedding.ts b/experiments/document-tail/source-src-pipeline-embedding.ts
new file mode 100644
index 0000000..a041c9b
--- /dev/null
+++ b/experiments/document-tail/source-src-pipeline-embedding.ts
@@ -0,0 +1,248 @@
+/**
+ * Workers AI embedding wrappers and batching constants.
+ *
+ * Wraps the BGE-M3 model behind `generateEmbedding` (single text) and
+ * `generateEmbeddingBatch` (multi-text). Callers must chunk inputs with
+ * `planEmbeddingBatches`; the helper does not split internally.
+ */
+
+/** Characters one embedding input is truncated to.
+ *
+ * A character cap, and only that. It was written against bge-m3's documented
+ * 8192-token per-input window on the reading that a token spans a character, which
+ * byte fallback breaks the same way it broke the batch budget below: 8000
+ * characters of Japanese can decompose into far more tokens than that window holds.
+ * Whether the per-input axis needs a byte cap of its own is a separate question
+ * from the batch charge this file bounds — the per-input window is enforced by the
+ * model, while the batch charge is what the endpoint rejects outright. The two do
+ * meet in one place: this cap is what puts a ceiling on the longest input a batch
+ * can hold, and that ceiling is what guarantees a batch of at least 2. */
+export const MAX_EMBEDDING_INPUT_CHARS = 8000;
+
+/**
+ * Context the Workers AI endpoint accepts for one batched embed call. Not the
+ * per-input maximum bge-m3 documents (8192) — the endpoint charges the call as a
+ * whole and names the figure it rejected:
+ *
+ * 3030: Max context reached 85920 tokens but model supports only 60000
+ *
+ * What that figure counts is `input count × the longest input`, not the sum of the
+ * inputs. Four rejections divide exactly by their input counts, and two of them are
+ * the same commit sent at different counts:
+ *
+ * 18 inputs -> 60678 = 18 × 3371
+ * 17 inputs -> 64413 = 17 × 3789
+ * 20 inputs -> 85920 = 20 × 4296
+ * 16 inputs -> 68736 = 16 × 4296
+ *
+ * The last pair is what settles it: dropping 4 inputs left the per-input quotient
+ * unchanged at 4296, which a true sum could only do if the 4 dropped inputs were
+ * identical in length. The endpoint pads a batch to its longest member and charges
+ * every slot at that width.
+ *
+ * Unpublished, so this is read off the errors rather than a docs page.
+ */
+export const WORKERS_AI_BATCH_CONTEXT_LIMIT = 60000;
+
+/**
+ * Tokens one input of a batch costs beyond what its own bytes account for.
+ *
+ * Two are the sentinels the model wraps around each input (`` … ``). The
+ * third is the SentencePiece word-boundary marker: the tokenizer prefixes the text
+ * with `▁`, and where that marker does not merge into the first piece it is emitted
+ * as a token of its own, consuming no byte of the input. Three tokens per input is
+ * therefore what the byte count below cannot see.
+ *
+ * Charged per input rather than once per batch because the batch's input count is
+ * not bounded anywhere: a batch of many tiny inputs is where a single flat reserve
+ * comes up short, and each of those inputs carries its own three.
+ */
+export const TOKEN_OVERHEAD_PER_INPUT = 3;
+
+/**
+ * UTF-8 bytes a string occupies.
+ *
+ * Counted off the UTF-16 code units rather than through `TextEncoder` so that
+ * measuring an input allocates no copy of it. A lone surrogate is charged 3 — the
+ * width of the U+FFFD that `TextEncoder` substitutes for it — so the count agrees
+ * with the encoder on ill-formed strings too, which matters because the truncation
+ * in `prepareDiffEmbeddingInput` cuts on a code-unit boundary and can leave one.
+ */
+export function utf8ByteLength(text: string): number {
+ let bytes = 0;
+ for (let i = 0; i < text.length; i++) {
+ const code = text.charCodeAt(i);
+ if (code < 0x80) {
+ bytes += 1;
+ } else if (code < 0x800) {
+ bytes += 2;
+ } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
+ const low = text.charCodeAt(i + 1);
+ if (low >= 0xdc00 && low <= 0xdfff) {
+ // A well-formed pair is one code point above the BMP: 4 bytes for 2 units.
+ bytes += 4;
+ i++;
+ } else {
+ bytes += 3;
+ }
+ } else {
+ bytes += 3;
+ }
+ }
+ return bytes;
+}
+
+/**
+ * What one input occupies in a padded slot of a batch, in UTF-8 bytes plus its
+ * per-input token overhead.
+ *
+ * Bytes because they *dominate* the token count rather than approximating it: the
+ * finest split any of these tokenizers can make is one token per byte of the UTF-8
+ * input — that is what byte fallback is — so for any input
+ *
+ * tokens(input) <= utf8ByteLength(input) + TOKEN_OVERHEAD_PER_INPUT
+ *
+ * No calibration and no per-payload measurement, which is the property two earlier
+ * per-input measures were reaching for and missed: an estimated token count ran
+ * about 2.1x optimistic on diff patches (#241), and a character count rested on "a
+ * token spans at least one character", which byte fallback breaks — a character
+ * outside the vocabulary is decomposed into its UTF-8 bytes, so one 3-byte Japanese
+ * character can cost 3 tokens (#244). Bytes sit below that decomposition, so no
+ * payload can be the one this underestimates the way those two were. What is left
+ * assumed is that the tokenizer's NFKC normalization does not expand the input in
+ * bytes, which holds except for compatibility characters that decompose into several
+ * (Arabic ligatures, CJK square abbreviations) — an input would have to be made
+ * predominantly of one rare block, where the two retired measures broke on ordinary
+ * diff text.
+ *
+ * This is the per-input axis only. What the endpoint charges for a batch is this
+ * measure taken over the *longest* input and multiplied by the input count; see
+ * `planEmbeddingBatches`.
+ */
+function embeddingInputCost(text: string): number {
+ return utf8ByteLength(text) + TOKEN_OVERHEAD_PER_INPUT;
+}
+
+/** Half-open `[start, end)` index range over a caller's input array. */
+export interface EmbeddingBatchRange {
+ start: number;
+ end: number;
+}
+
+/**
+ * Split embedding inputs into batches the endpoint will accept:
+ *
+ * (end - start) × max(embeddingInputCost(input) over the range) <= budget
+ *
+ * The bound is on `count × longest`, not on the sum. Three budgets before this one
+ * bounded the sum — by estimated tokens (#236), by characters (#242), by UTF-8 bytes
+ * (#244) — and the endpoint kept rejecting the same batches, because the sum does
+ * not appear in what it charges: it pads every slot of a batch to the longest input
+ * and bills the padded width across the whole count (see
+ * `WORKERS_AI_BATCH_CONTEXT_LIMIT` for the four measurements that show it). Tighten
+ * a sum-axis bound however far and it still binds the wrong quantity.
+ *
+ * Two properties follow that a sum axis does not have. The largest input sets the
+ * unit price for the whole batch, so a single large input among small ones is
+ * charged as if all of them were large; and grouping inputs of similar size is
+ * therefore what makes a batch efficient. Inputs are nonetheless taken in the order
+ * given, without sorting: the caller slices its own parallel arrays (files,
+ * metadata) by the returned boundaries, and the position-for-position
+ * correspondence between inputs and returned vectors is what the upsert depends on.
+ * Sorting would buy fewer calls at the cost of an index-set return type and a
+ * permutation the caller has to carry — and it buys nothing against the worst case
+ * the poller's subrequest budget is sized on, which is set by `MAX_EMBEDDING_INPUT_CHARS`
+ * rather than by the packing.
+ *
+ * Equality with the budget is admissible: the rejections name charges strictly above
+ * the ceiling (`Max context reached 60678 tokens but model supports only 60000`), so
+ * 60000 is a supported charge and not the first rejected one. No margin is held under
+ * it either — every extra batch costs two subrequests (the AI call and its
+ * `VECTORIZE.upsert`) against an invocation budget this worker already overruns, and
+ * a bound that holds by construction has nothing left for a margin to buy.
+ *
+ * Contract:
+ * - order is preserved, ranges are contiguous, and every input falls in exactly one
+ * - no returned range is empty
+ * - an input whose own cost already exceeds the budget occupies a range of
+ * one. Cutting it down further belongs to the truncation axis
+ * (`MAX_EMBEDDING_INPUT_CHARS`), and dropping it would lose a file from the index.
+ * - `MAX_EMBEDDING_INPUT_CHARS` truncates one input to 8000 characters, which is at
+ * most 24000 bytes (3 per UTF-16 code unit is the widest UTF-8 gets; a surrogate
+ * pair is 4 bytes across 2 units), so `2 × 24003 = 48006` fits the default budget
+ * and a batch holds at least 2 inputs however large each patch is. That floor is
+ * what the poller's per-file subrequest estimate rests on, and it is unchanged
+ * from the byte-sum budget this replaces.
+ */
+export function planEmbeddingBatches(
+ inputs: string[],
+ budget: number = WORKERS_AI_BATCH_CONTEXT_LIMIT,
+): EmbeddingBatchRange[] {
+ const ranges: EmbeddingBatchRange[] = [];
+ let start = 0;
+ let longest = 0;
+
+ for (let i = 0; i < inputs.length; i++) {
+ const cost = embeddingInputCost(inputs[i]);
+ const widest = Math.max(longest, cost);
+ // Close the open range before an input that would overrun the budget — either
+ // by its own width or by widening the padding under every input already in the
+ // range. The `i > start` guard is what keeps an oversized input in a batch of
+ // its own instead of closing an empty range and looping on it forever.
+ if (i > start && (i - start + 1) * widest > budget) {
+ ranges.push({ start, end: i });
+ start = i;
+ longest = cost;
+ } else {
+ longest = widest;
+ }
+ }
+
+ if (inputs.length > start) ranges.push({ start, end: inputs.length });
+ return ranges;
+}
+
+/**
+ * Generate embedding for a text input using Workers AI BGE-M3.
+ * Returns 1024-dimensional float array.
+ */
+export async function generateEmbedding(
+ ai: Ai,
+ text: string,
+): Promise {
+ const result = await ai.run("@cf/baai/bge-m3", {
+ text: [text],
+ });
+
+ // Workers AI returns { data: [{ values: number[] }] } or similar
+ const vectors = (result as { data: Array }).data;
+ if (!vectors || vectors.length === 0) {
+ throw new Error("Workers AI returned no embedding vectors");
+ }
+ return vectors[0];
+}
+
+/**
+ * Generate embeddings for multiple text inputs in one batched Workers AI call.
+ * Input order is preserved in the returned array.
+ *
+ * Workers AI does not publish a hard limit on the size of one embed call, so
+ * callers must chunk with `planEmbeddingBatches` before invoking this function.
+ * Throws if the returned vector count does not match the input count.
+ */
+export async function generateEmbeddingBatch(
+ ai: Ai,
+ texts: string[],
+): Promise {
+ if (texts.length === 0) return [];
+
+ const result = await ai.run("@cf/baai/bge-m3", { text: texts });
+ const vectors = (result as { data: Array }).data;
+
+ if (!vectors || vectors.length !== texts.length) {
+ throw new Error(
+ `Workers AI returned ${vectors?.length ?? 0} vectors for ${texts.length} inputs`,
+ );
+ }
+ return vectors;
+}
diff --git a/experiments/document-tail/source-src-pipeline-hash.ts b/experiments/document-tail/source-src-pipeline-hash.ts
new file mode 100644
index 0000000..4c17496
--- /dev/null
+++ b/experiments/document-tail/source-src-pipeline-hash.ts
@@ -0,0 +1,96 @@
+/**
+ * Hashing and embedding-input preparation helpers.
+ *
+ * Houses the SHA-256 body hashers used for change detection, the per-surface
+ * input formatters (issue/PR, diff, comment), and the public URL-safe base64
+ * encoder. The byte-mode base64 helper used internally by vector-id sits
+ * alongside `stableVectorId` to keep its only call site local.
+ */
+
+import { MAX_EMBEDDING_INPUT_CHARS } from "./embedding.js";
+
+/**
+ * Compute SHA-256 hash of title + body for change detection.
+ * Returns hex-encoded hash string.
+ */
+export async function computeBodyHash(title: string, body: string): Promise {
+ const input = title + "\n\n" + body;
+ const data = new TextEncoder().encode(input);
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+ const hashArray = new Uint8Array(hashBuffer);
+ return Array.from(hashArray)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+/**
+ * Prepare embedding input text from issue title and body.
+ * Concatenates title + "\n\n" + body, truncated to MAX_EMBEDDING_INPUT_CHARS.
+ */
+export function prepareEmbeddingInput(title: string, body: string | null): string {
+ const text = title + "\n\n" + (body ?? "");
+ if (text.length <= MAX_EMBEDDING_INPUT_CHARS) return text;
+ return text.slice(0, MAX_EMBEDDING_INPUT_CHARS);
+}
+
+/**
+ * Encode an arbitrary string to URL-safe base64 (RFC 4648 §5) without padding.
+ * Retained because `stableVectorId` uses it to encode the SHA-256 digest.
+ */
+export function base64UrlEncode(input: string): string {
+ // Encode UTF-8 -> binary string -> base64 via btoa
+ const utf8Bytes = new TextEncoder().encode(input);
+ let binary = "";
+ for (let i = 0; i < utf8Bytes.length; i++) {
+ binary += String.fromCharCode(utf8Bytes[i]);
+ }
+ return btoa(binary)
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/g, "");
+}
+
+/**
+ * Compute SHA-256 over UTF-8 bytes of the wiki content. Used as the change
+ * detection signal in lieu of git blob SHAs (the wiki git protocol is not
+ * exposed via REST, so we hash content directly).
+ */
+export async function sha256Hex(text: string): Promise {
+ const bytes = new TextEncoder().encode(text);
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ const hex = Array.from(new Uint8Array(digest))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ return hex;
+}
+
+/**
+ * Build the embedding input for a single file-in-commit.
+ * Format: "{commitMessage}\n\n{filePath}\n\n{patch}", truncated to MAX_EMBEDDING_INPUT_CHARS.
+ * The file path is included inline so semantic search can match against it
+ * even when the patch body alone does not mention it.
+ */
+export function prepareDiffEmbeddingInput(
+ commitMessage: string,
+ filePath: string,
+ patch: string,
+): string {
+ const text = `${commitMessage}\n\n${filePath}\n\n${patch}`;
+ if (text.length <= MAX_EMBEDDING_INPUT_CHARS) return text;
+ return text.slice(0, MAX_EMBEDDING_INPUT_CHARS);
+}
+
+/**
+ * Build the embedding input for a comment / review body.
+ * Format: "{author}\n\n{body}", truncated to MAX_EMBEDDING_INPUT_CHARS.
+ * The author prefix supplies speaker context so the dense embedding can
+ * distinguish the same body authored by different reviewers.
+ */
+export function prepareCommentEmbeddingInput(
+ author: string,
+ body: string,
+): string {
+ const text = `${author}\n\n${body}`;
+ if (text.length <= MAX_EMBEDDING_INPUT_CHARS) return text;
+ return text.slice(0, MAX_EMBEDDING_INPUT_CHARS);
+}
diff --git a/experiments/document-tail/source-src-rerank.ts b/experiments/document-tail/source-src-rerank.ts
new file mode 100644
index 0000000..b0f6018
--- /dev/null
+++ b/experiments/document-tail/source-src-rerank.ts
@@ -0,0 +1,217 @@
+/**
+ * Cross-encoder reranker — Workers AI `@cf/baai/bge-reranker-base`.
+ *
+ * Layer = L4 Operations (3rd retrieval tier on top of hybrid dense+sparse+RRF)
+ *
+ * Responsibilities:
+ * - Send a (query, candidate contents) batch to bge-reranker-base.
+ * - Truncate (query + candidate content) pairs to fit the model's 512-token
+ * context window using a conservative character-based estimate.
+ * - Map the model's per-candidate relevance scores back onto the input order.
+ * - Degrade gracefully when the AI binding errors or returns an unexpected
+ * shape — callers should keep the pre-rerank order intact in that case.
+ *
+ * Notes on the model:
+ * - Cloudflare exposes bge-reranker-base as a cross-encoder. Request shape:
+ * { query: string, contexts: Array<{ text: string }>, top_k?: number }
+ * Response shape (per docs as of 2026-04):
+ * { response: Array<{ id: number, score: number }> }
+ * `id` is the 0-based index into the input `contexts` array.
+ * `result.usage` (token accounting) is not documented; we read it
+ * defensively for observability but do not depend on it.
+ * - bge-reranker-base is English-centric. Multilingual content (e.g. Japanese
+ * issue bodies in this project) may see degraded ranking quality. Tracked
+ * as a known limitation in issue #91; out of scope for this layer.
+ */
+
+import type { Env } from "./types.js";
+
+/**
+ * Conservative character budget for one (query + content) pair.
+ *
+ * bge-reranker-base inherits BAAI's 512-token context window. We do not have
+ * a tokenizer in Workers, so we approximate with characters.
+ *
+ * Empirical baseline:
+ * - English: ~4 chars/token
+ * - Japanese / mixed CJK: ~2 chars/token
+ * We pick 1700 chars total budget (≈ 425 tokens at ~4 chars/token, or
+ * ≈ 850 tokens-of-CJK at ~2 chars/token — over budget, but the truncate
+ * still meaningfully shortens long bodies). The query slot reserves up to
+ * 200 chars; the rest is for content.
+ *
+ * The constant is intentionally conservative: better to under-feed than to
+ * have the model silently truncate input, because the silent truncate would
+ * always cut from the tail (losing the most-recent commit/issue context).
+ */
+const MAX_PAIR_CHARS = 1700;
+const MAX_QUERY_CHARS = 200;
+
+/** Hard ceiling on candidates sent to the reranker per call. */
+export const RERANK_MAX_CANDIDATES = 50;
+
+/**
+ * Truncate one (query, content) pair so total chars fit MAX_PAIR_CHARS.
+ *
+ * The query is truncated first to MAX_QUERY_CHARS, then the content is
+ * truncated to fit the remaining budget. This biases the budget toward
+ * candidate content, which is where the relevance signal lives for
+ * cross-encoders.
+ */
+export function truncatePair(query: string, content: string): { query: string; content: string } {
+ const q = query.length > MAX_QUERY_CHARS ? query.slice(0, MAX_QUERY_CHARS) : query;
+ const remaining = MAX_PAIR_CHARS - q.length;
+ // Reserve at least a few hundred chars for content even if the query is huge.
+ const contentBudget = Math.max(remaining, MAX_PAIR_CHARS - MAX_QUERY_CHARS);
+ const c = content.length > contentBudget ? content.slice(0, contentBudget) : content;
+ return { query: q, content: c };
+}
+
+/** Input candidate for reranking. `content` is the text shown to the cross-encoder. */
+export interface RerankCandidate {
+ /** Stable identifier owned by the caller (e.g. Vectorize vector_id). Returned as-is. */
+ id: string;
+ /** Tokenizable text used by the cross-encoder. Empty string is allowed but contributes no signal. */
+ content: string;
+}
+
+/** Per-candidate reranker output. `score` is the cross-encoder relevance score (higher = better). */
+export interface RerankResult {
+ id: string;
+ score: number;
+}
+
+/**
+ * Possible shapes returned by the AI binding for bge-reranker-base.
+ *
+ * Cloudflare's documented shape is `{ response: Array<{ id, score }> }` but
+ * Workers AI has historically returned bare arrays for some models, so we
+ * accept both. Unknown shapes fall through to the graceful fallback path.
+ */
+type RerankerResponse =
+ | { response: Array<{ id: number; score: number }>; usage?: unknown }
+ | Array<{ id: number; score: number }>;
+
+/**
+ * Whether a `rerankCandidates` result counts as "rerank actually applied".
+ *
+ * Encodes the three-outcome contract documented on `rerankCandidates`:
+ * - `null` -> false (call errored / malformed response)
+ * - `[]` -> false (no candidate had scorable content; nothing was scored)
+ * - non-empty -> true
+ *
+ * Exported so the telemetry decision has one definition and one test surface.
+ * The pre-#172 caller used a bare truthiness check, which reported
+ * `rerank_applied: true` for the `[]` case — true telemetry with zero scores.
+ *
+ * Declared as a type predicate so a `true` branch also narrows away `null` for
+ * the caller that then reads the scores.
+ */
+export function rerankWasApplied(result: RerankResult[] | null): result is RerankResult[] {
+ return result !== null && result.length > 0;
+}
+
+/**
+ * Rerank candidates with bge-reranker-base.
+ *
+ * Behavior:
+ * - Returns a new array sorted by reranker score, descending. Length is
+ * `min(nonEmpty.length, topK ?? nonEmpty.length, RERANK_MAX_CANDIDATES_after_clamp)`,
+ * where `nonEmpty` is the input with empty / whitespace-only content removed.
+ * - Candidates with empty content are silently excluded — Workers AI rejects
+ * any `contexts[].text` shorter than one character with error 5006.
+ * - Empty input (or all-empty content) returns an empty array immediately (no AI call).
+ * Return-contract note: an empty array means "nothing was scored", which is
+ * NOT the same as "rerank applied". Callers must distinguish three outcomes —
+ * `null` (error, keep pre-rerank order), `[]` (no scorable content, keep
+ * pre-rerank order), and a non-empty array (applied). Treating `[]` as
+ * applied is the defect fixed in issue #172.
+ * - A single non-empty candidate returns a passthrough with a synthesized
+ * score of 1, to avoid burning a Workers AI call on a one-element list.
+ * - Any thrown error from `env.AI.run` or any malformed response returns
+ * `null` so the caller can keep the original (pre-rerank) order.
+ * The intent is "rerank is best-effort improvement, never a blocker".
+ *
+ * The caller is expected to have already capped `candidates.length` to a
+ * reasonable overfetch size (issue #91 default: top_k × 5, max 50). We
+ * additionally clamp here as a defensive lower bound — if a caller forgets,
+ * we still bound the AI cost.
+ */
+export async function rerankCandidates(
+ env: Env,
+ query: string,
+ candidates: RerankCandidate[],
+ topK?: number,
+): Promise {
+ // Drop candidates whose content is empty or whitespace-only. Workers AI's
+ // `@cf/baai/bge-reranker-base` rejects requests where any `contexts[].text`
+ // is shorter than one character with error 5006 ("Length of
+ // '/contexts/N/text' must be >= 1 not met"). Passing the call through
+ // unfiltered would make the entire rerank return null and leave the caller
+ // with the pre-rerank order even for candidates that do have signal.
+ const nonEmptyCandidates = candidates.filter((c) => c.content.trim() !== "");
+
+ if (nonEmptyCandidates.length === 0) return [];
+ if (nonEmptyCandidates.length === 1) {
+ return [{ id: nonEmptyCandidates[0].id, score: 1 }];
+ }
+
+ // Defensive clamp on candidate count.
+ const trimmedCandidates = nonEmptyCandidates.slice(0, RERANK_MAX_CANDIDATES);
+
+ // Build (query, contexts) payload with per-pair truncation. The query is
+ // shared across all pairs; we still truncate it once up front.
+ const truncatedQuery =
+ query.length > MAX_QUERY_CHARS ? query.slice(0, MAX_QUERY_CHARS) : query;
+ const contexts = trimmedCandidates.map((c) => {
+ const { content } = truncatePair(truncatedQuery, c.content);
+ return { text: content };
+ });
+
+ // The AI binding's typing is intentionally loose; we cast through unknown
+ // and validate the shape ourselves.
+ let raw: RerankerResponse;
+ try {
+ raw = (await env.AI.run("@cf/baai/bge-reranker-base", {
+ query: truncatedQuery,
+ contexts,
+ // Ask for all candidates back; we sort and trim ourselves so the caller
+ // can apply their own topK after post-filtering.
+ top_k: trimmedCandidates.length,
+ })) as unknown as RerankerResponse;
+ } catch (err) {
+ console.error("rerankCandidates: bge-reranker-base call failed:");
+ return null;
+ }
+
+ // Normalize response to Array<{ id, score }>.
+ let scored: Array<{ id: number; score: number }>;
+ if (Array.isArray(raw)) {
+ scored = raw;
+ } else if (raw && Array.isArray(raw.response)) {
+ scored = raw.response;
+ } else {
+ console.error(
+ "rerankCandidates: unexpected response shape from bge-reranker-base; falling back to pre-rerank order",
+ );
+ return null;
+ }
+
+ // Map id (0-based input index) back to the caller's stable id, drop any
+ // out-of-range indices defensively.
+ const results: RerankResult[] = [];
+ for (const s of scored) {
+ if (typeof s.id !== "number" || typeof s.score !== "number") continue;
+ if (s.id < 0 || s.id >= trimmedCandidates.length) continue;
+ results.push({ id: trimmedCandidates[s.id].id, score: s.score });
+ }
+
+ // Sort by score descending. The model usually returns sorted, but we do
+ // not depend on that — explicit sort keeps the contract local.
+ results.sort((a, b) => b.score - a.score);
+
+ if (typeof topK === "number" && topK > 0 && results.length > topK) {
+ return results.slice(0, topK);
+ }
+ return results;
+}
diff --git a/experiments/document-tail/source-src-segment.ts b/experiments/document-tail/source-src-segment.ts
new file mode 100644
index 0000000..839e323
--- /dev/null
+++ b/experiments/document-tail/source-src-segment.ts
@@ -0,0 +1,81 @@
+/**
+ * Word segmentation for the FTS5 natural-language (BM25) index.
+ *
+ * Layer = L4 Operations (sparse retrieval surface)
+ *
+ * Why this exists (issue #180 fact 1 / #182):
+ * FTS5's `unicode61` tokenizer splits on non-alphanumeric characters only. Japanese
+ * text has no such separators, so a whole run of kana/kanji between two punctuation
+ * marks becomes ONE token. A Japanese phrase query then matches nothing unless it is
+ * byte-identical to that run, which is why `sparse_candidates` was 0 for Japanese
+ * phrases while English queries worked. D1 cannot load a custom tokenizer (`icu` is
+ * not compiled in), so the split has to happen before the text reaches SQLite.
+ *
+ * `Intl.Segmenter` is available in workerd and in Node, ships with the runtime, and
+ * needs no dependency. We run it on both sides of the index:
+ * - ingest side: `search_docs.content_fts` stores the segmented text,
+ * - query side: the nat MATCH string is built from the segmented query.
+ * Because the same segmenter runs on both sides, its mistakes are symmetric.
+ * Katakana words in particular are over-split, which costs precision but not recall:
+ * the same wrong split is applied to the query.
+ *
+ * `search_docs.content` keeps the raw text — it feeds the reranker and the code
+ * (trigram) index, both of which want the original.
+ */
+
+/**
+ * Scripts written without word separators, for which `unicode61` alone produces
+ * one giant token: CJK punctuation/symbols, hiragana, katakana, CJK ideographs
+ * (incl. extension A and compatibility), and halfwidth katakana.
+ *
+ * Text with none of these is left untouched — see `segmentForFts`.
+ */
+const UNSPACED_SCRIPT_RE =
+ /[ -〿-ゟ゠-ヿ㐀-䶿一-鿿豈-・-゚]/u;
+
+/**
+ * Lazily constructed, module-level singleton. Constructing a segmenter is the
+ * expensive part; `segment()` itself is cheap.
+ *
+ * There is deliberately no "runtime lacks Intl.Segmenter" fallback. A silent
+ * fallback would still be symmetric across ingest and query, so it would not throw —
+ * it would quietly reinstate the #180 bug for a whole generation of rows. A loud
+ * failure is the observable one.
+ */
+let segmenter: Intl.Segmenter | undefined;
+
+function getSegmenter(): Intl.Segmenter {
+ if (segmenter === undefined) {
+ segmenter = new Intl.Segmenter("ja", { granularity: "word" });
+ }
+ return segmenter;
+}
+
+/** True when `text` contains a script that `unicode61` cannot split on its own. */
+export function needsSegmentation(text: string): boolean {
+ return UNSPACED_SCRIPT_RE.test(text);
+}
+
+/**
+ * Insert word boundaries as spaces so `unicode61` can tokenize the text.
+ *
+ * Text without an unspaced script is returned verbatim. That fast path is not just
+ * an optimization: it is the guarantee that English behaviour cannot regress, since
+ * the stored/queried string stays byte-identical to what the current generation
+ * indexes. (Mixed text does go through the segmenter, but ICU never breaks inside an
+ * alphanumeric run, so the ASCII words inside it survive as whole segments and still
+ * match an unsegmented English query.)
+ *
+ * Whitespace-only segments are dropped and the rest joined with a single space, so
+ * the function is idempotent: segmenting already-segmented text returns it unchanged.
+ */
+export function segmentForFts(text: string): string {
+ if (!needsSegmentation(text)) return text;
+
+ const out: string[] = [];
+ for (const { segment } of getSegmenter().segment(text)) {
+ const trimmed = segment.trim();
+ if (trimmed !== "") out.push(trimmed);
+ }
+ return out.join(" ");
+}
diff --git a/scripts/document-tail-headings.mjs b/scripts/document-tail-headings.mjs
new file mode 100644
index 0000000..001fa85
--- /dev/null
+++ b/scripts/document-tail-headings.mjs
@@ -0,0 +1,62 @@
+export function sections(text) {
+ const lines = [];
+ let offset = 0;
+ for (const line of text.split(/(?<=\n)/)) {
+ lines.push({ text: line.replace(/\r?\n$/, ""), offset });
+ offset += line.length;
+ }
+ const headings = [];
+ let fence = null,
+ priorEligible = false;
+ for (let i = 0; i < lines.length; i++) {
+ const { text: t, offset } = lines[i];
+ const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(t);
+ if (fence) {
+ if (
+ marker &&
+ marker[1][0] === fence.char &&
+ marker[1].length >= fence.len &&
+ marker[2].trim() === ""
+ )
+ fence = null;
+ priorEligible = false;
+ continue;
+ }
+ if (marker && (marker[1][0] !== "`" || !marker[2].includes("`"))) {
+ fence = { char: marker[1][0], len: marker[1].length };
+ priorEligible = false;
+ continue;
+ }
+ const atx = /^ {0,3}(#{1,6})(?:\s+|$)(.*)$/.exec(t);
+ if (atx) {
+ headings.push({
+ offset,
+ level: atx[1].length,
+ title: atx[2].replace(/\s+#+\s*$/, ""),
+ });
+ priorEligible = false;
+ continue;
+ }
+ const setext = /^ {0,3}(=+|-+)\s*$/.exec(t);
+ if (setext && priorEligible) {
+ const prev = lines[i - 1];
+ headings.push({
+ offset: prev.offset,
+ level: setext[1][0] === "=" ? 1 : 2,
+ title: prev.text.trim(),
+ });
+ priorEligible = false;
+ continue;
+ }
+ priorEligible =
+ t.trim() !== "" &&
+ !/^ {4}|^\t|^ {0,3}>|^ {0,3}(?:[-+*]|\d+[.)])\s/.test(t);
+ }
+ const starts = [{ offset: 0, level: 0, title: "preamble" }, ...headings];
+ return starts.map((s, i) => ({
+ ...s,
+ end: i + 1 < starts.length ? starts[i + 1].offset : text.length,
+ length_utf16:
+ (i + 1 < starts.length ? starts[i + 1].offset : text.length) - s.offset,
+ }));
+}
diff --git a/scripts/document-tail-prepare.mjs b/scripts/document-tail-prepare.mjs
new file mode 100644
index 0000000..8f59178
--- /dev/null
+++ b/scripts/document-tail-prepare.mjs
@@ -0,0 +1,166 @@
+import fs from "node:fs";
+import path from "node:path";
+import crypto from "node:crypto";
+import { pathToFileURL } from "node:url";
+import { performance } from "node:perf_hooks";
+export const hash = (x) => crypto.createHash("sha256").update(x).digest("hex");
+export function chunkBounds(raw) {
+ const bounds = [];
+ for (let pos = 0; pos < raw.length;) {
+ let end = Math.min(pos + 8000, raw.length);
+ if (
+ end < raw.length &&
+ /[\uD800-\uDBFF]/u.test(raw[end - 1]) &&
+ /[\uDC00-\uDFFF]/u.test(raw[end])
+ )
+ end--;
+ bounds.push([pos, end]);
+ pos = end;
+ }
+ return bounds;
+}
+export async function prepare(dir, out) {
+ const protocol = JSON.parse(
+ fs.readFileSync(path.join(dir, "protocol.json"), "utf8"),
+ );
+ const inputBytes = fs.readFileSync(path.join(dir, "fixture.json"));
+ if (hash(inputBytes) !== protocol.fixture_sha256)
+ throw Error("Fixture hash mismatch");
+ for (const [file, digest] of Object.entries(protocol.source_hashes))
+ if (hash(fs.readFileSync(path.join(dir, file))) !== digest)
+ throw Error("Source hash mismatch: " + file);
+ const fixture = JSON.parse(inputBytes);
+ const provenanceBytes = fs.readFileSync(path.join(dir, "provenance.json"));
+ if (hash(provenanceBytes) !== protocol.provenance_sha256)
+ throw Error("Provenance hash mismatch");
+ const provenance = JSON.parse(provenanceBytes);
+ if (provenance.length !== fixture.docs.length)
+ throw Error("Provenance coverage mismatch");
+ for (const d of fixture.docs) {
+ const source = provenance.find(
+ (s) => s.repo === d.repo && s.path === d.path,
+ );
+ if (!source || hash(d.text) !== source.content_sha256)
+ throw Error("Provenance body hash mismatch");
+ const bytes = Buffer.from(d.text);
+ const blob = crypto
+ .createHash("sha1")
+ .update(Buffer.from(`blob ${bytes.length}\0`))
+ .update(bytes)
+ .digest("hex");
+ if (blob !== source.blob_sha) throw Error("Provenance blob mismatch");
+ }
+
+ const { segmentForFts } = await import(
+ pathToFileURL(path.join(dir, "source-src-segment.ts"))
+ );
+ const fts = fs.readFileSync(path.join(dir, "source-src-fts.ts"), "utf8");
+ const literal = fts.slice(
+ fts.indexOf("export function escapeFtsQuery"),
+ fts.indexOf("\n/**", fts.indexOf("export function escapeFtsQuery")),
+ );
+ const executable = literal
+ .replace('raw: string, mode: FtsMatchMode = "all"', 'raw, mode = "all"')
+ .replace("): string {", ") {");
+ const { escapeFtsQuery } = await import(
+ "data:text/javascript," + encodeURIComponent(executable)
+ );
+ const start = performance.now(),
+ rows = { prefix: [], full: [], chunks: [] },
+ inventory = [];
+ for (const d of fixture.docs) {
+ const raw = d.path + "\n\n" + d.text,
+ entity = d.repo + ":" + d.path;
+ const add = (v, text, offset, chunk) =>
+ rows[v].push({
+ entity,
+ repo: d.repo,
+ path: d.path,
+ chunk,
+ offset,
+ raw: text,
+ segmented: segmentForFts(text),
+ });
+ add("prefix", raw.slice(0, 8000), 0, 0);
+ add("full", raw, 0, 0);
+ const bounds = chunkBounds(raw);
+ for (const [chunk, [pos, end]] of bounds.entries())
+ add("chunks", raw.slice(pos, end), pos, chunk);
+ inventory.push({
+ entity,
+ body_utf16: d.text.length,
+ body_utf8_bytes: Buffer.byteLength(d.text),
+ body_sha256: hash(d.text),
+ declared_version: d.version,
+ version_hash_matches: d.version === "sha256:" + hash(d.text),
+ full_utf16: raw.length,
+ prefix_utf16: Math.min(8000, raw.length),
+ chunk_bounds_utf16: bounds,
+ });
+ }
+ if (!inventory.every((d) => d.version_hash_matches))
+ throw Error("Body version hash mismatch");
+ const queries = fixture.queries.map((q) => ({
+ ...q,
+ segmented: segmentForFts(q.text),
+ match_all: escapeFtsQuery(segmentForFts(q.text), "all"),
+ match_any: escapeFtsQuery(segmentForFts(q.text), "any"),
+ }));
+ const literalAudits = queries.map((q) => {
+ if (!q.target)
+ return {
+ id: q.id,
+ absent_from_all_bodies: fixture.docs.every(
+ (d) => !d.text.includes(q.text),
+ ),
+ absent_from_all_index_sources: fixture.docs.every(
+ (d) => !(d.path + "\n\n" + d.text).includes(q.text),
+ ),
+ };
+ const d = fixture.docs.find(
+ (d) => d.repo === q.repo && d.path === q.target,
+ ),
+ raw = d.path + "\n\n" + d.text,
+ offset = d.text.indexOf(q.text),
+ idx = raw.indexOf(q.text);
+ return {
+ id: q.id,
+ body_offset_utf16: offset,
+ body_offset_utf8_bytes:
+ offset < 0 ? null : Buffer.byteLength(d.text.slice(0, offset)),
+ index_offset_utf16: idx,
+ index_offset_utf8_bytes:
+ idx < 0 ? null : Buffer.byteLength(raw.slice(0, idx)),
+ literal_present: offset >= 0,
+ prefix_literal_present: raw.slice(0, 8000).includes(q.text),
+ original_excerpt:
+ offset < 0
+ ? null
+ : d.text.slice(
+ Math.max(0, offset - 80),
+ offset + q.text.length + 100,
+ ),
+ };
+ });
+ const prep = {
+ sha: protocol.source_sha,
+ sources: protocol.source_hashes,
+ input_sha256: hash(inputBytes),
+ input_bytes: inputBytes.length,
+ fixture_sha256: hash(inputBytes),
+ body_utf16_total: inventory.reduce((s, d) => s + d.body_utf16, 0),
+ body_utf8_bytes_total: inventory.reduce((s, d) => s + d.body_utf8_bytes, 0),
+ node_versions: process.versions,
+ segmentation_prepare_elapsed_ms: performance.now() - start,
+ inventory,
+ literalAudits,
+ rows,
+ queries,
+ };
+ fs.writeFileSync(
+ path.join(out, "prepared.json"),
+ JSON.stringify(prep, null, 2) + "\n",
+ );
+}
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
+ await prepare(path.resolve(process.argv[2]), path.resolve(process.argv[3]));
diff --git a/scripts/document-tail-replay.py b/scripts/document-tail-replay.py
new file mode 100644
index 0000000..f56bca8
--- /dev/null
+++ b/scripts/document-tail-replay.py
@@ -0,0 +1,83 @@
+"""Opt-in offline comparison; creates only a new caller-named output directory."""
+import argparse, json, sqlite3, subprocess, sys
+from pathlib import Path
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--out', required=True, type=Path)
+ args = parser.parse_args()
+ # Keep the leaf unresolved: mkdir must reject existing dangling symlinks too.
+ R = args.out.absolute()
+ R.mkdir(parents=True, exist_ok=False)
+ scripts = Path(__file__).resolve().parent
+ fixture_dir = scripts.parent / 'experiments' / 'document-tail'
+ subprocess.run(['node', '--experimental-strip-types', str(scripts / 'document-tail-prepare.mjs'), str(fixture_dir), str(R)], check=True)
+ subprocess.run([sys.executable, str(scripts / 'document-tail-search.py'), str(R)], check=True, stdout=subprocess.DEVNULL)
+ subprocess.run(['node', str(scripts / 'document-tail-sections.mjs'), str(R), str(fixture_dir)], check=True, stdout=subprocess.DEVNULL)
+ r = json.loads((R / 'results.json').read_text(encoding='utf8'))
+ p = json.loads((R / 'prepared.json').read_text(encoding='utf8'))
+ sections = json.loads((R / 'sections.json').read_text(encoding='utf8'))
+ audit = []
+ for v in ['prefix', 'full']:
+ db = sqlite3.connect(R / (v + '.sqlite'))
+ for q in p['queries']:
+ rows = []
+ for tier, key in [(0, 'match_all'), (1, 'match_any')]:
+ if tier and q['match_any'] == q['match_all']:
+ continue
+ sql = 'SELECT d.rowid,d.entity,bm25(search_docs_nat_fts_v3) FROM search_docs_nat_fts_v3 JOIN search_docs d ON d.rowid=search_docs_nat_fts_v3.rowid WHERE search_docs_nat_fts_v3 MATCH ?'
+ args = [q[key]]
+ if q['repo']:
+ sql += ' AND d.repo=?'
+ args.append(q['repo'])
+ sql += ' ORDER BY bm25(search_docs_nat_fts_v3),d.rowid LIMIT 3'
+ rows.extend(((tier, score, rid, entity) for rid, entity, score in db.execute(sql, args)))
+ rows.sort()
+ rows = rows[:6 if q['match_any'] != q['match_all'] else 3]
+ ids = []
+ for _, _, _, entity in rows:
+ if entity not in ids:
+ ids.append(entity)
+ if len(ids) == 3:
+ break
+ exhaustive = [h['entity'] for h in next((x for x in r['results'][v] if x['id'] == q['id']))['top3']]
+ audit.append({'variant': v, 'query_id': q['id'], 'production_nat_topK3_cap_entities': ids, 'exhaustive_entity_top3': exhaustive, 'same': ids == exhaustive})
+ db.close()
+ assert all((a['same'] for a in audit))
+ for full in p['rows']['full']:
+ chunks = [x for x in p['rows']['chunks'] if x['entity'] == full['entity']]
+ assert ''.join((x['raw'] for x in chunks)) == full['raw']
+ assert all((len(x['raw'].encode('utf-16-le')) // 2 <= 8000 for x in chunks))
+ assert all((x['version_hash_matches'] for x in r['inventory']))
+ r['heading_section_analysis'] = sections
+ r['production_nat_cap_equivalence_audit'] = audit
+ r['verification'] = {'all_doc_version_sha256_matches': True, 'all_chunk_join_and_width_checks_pass': True, 'source_hashes_verified': True}
+ for qs in r['results'].values():
+ for q in qs:
+ if q['id'] == 'ngr_prefix':
+ q['interpretation_label'] = 'general-query control; individual matching terms in prefix'
+ (R / 'results.json').write_text(json.dumps(r, ensure_ascii=False, indent=2) + '\n', encoding='utf8')
+ fixture = json.loads((fixture_dir / 'fixture.json').read_text(encoding='utf8'))
+ lexical = []
+ for v, qs in r['results'].items():
+ for q in qs:
+ eligible = [d['repo'] + ':' + d['path'] for d in fixture['docs'] if (q['repo'] is None or d['repo'] == q['repo']) and q['query'] in d['text']]
+ first = next((i + 1 for i, h in enumerate(q['all_candidates']) if h['entity'] in eligible), None)
+ lexical.append({'variant': v, 'query_id': q['id'], 'kind': q['kind'], 'exact_literal_source_entities': eligible, 'first_eligible_entity_rank': first, 'any_exact_literal_source_entity_hit1': first == 1 if eligible else None, 'any_exact_literal_source_entity_hit3': first is not None and first <= 3 if eligible else None, 'top1_winning_index_row_contains_exact_literal': q['query'] in next((row['raw'] for row in p['rows'][v] if q['top3'] and row['entity'] == q['top3'][0]['entity'] and (row['chunk'] == q['top3'][0]['chunk'])), '')})
+ r['exact_literal_source_eligibility'] = lexical
+ (R / 'results.json').write_text(json.dumps(r, ensure_ascii=False, indent=2) + '\n', encoding='utf8')
+ tails = {}
+ for variant, qs in r['results'].items():
+ named = [q for q in qs if q['kind'] == 'tail']
+ eligible = [q for q in lexical if q['variant'] == variant and q['kind'] == 'tail']
+ tails[variant] = {'n': len(named), 'named_target_hit1': sum((q['hit_at_1'] for q in named)), 'named_target_hit3': sum((q['hit_at_3'] for q in named)), 'literal_source_entity_hit1': sum((q['any_exact_literal_source_entity_hit1'] for q in eligible)), 'literal_source_entity_hit3': sum((q['any_exact_literal_source_entity_hit3'] for q in eligible)), 'top1_index_row_literal': sum((q['top1_winning_index_row_contains_exact_literal'] for q in eligible))}
+ r['tail_summary'] = tails
+ (R / 'results.json').write_text(json.dumps(r, ensure_ascii=False, indent=2) + '\n', encoding='utf8')
+ lines = ['# Document tail retrieval comparison', '', '固定公開fixtureの sparse-only 再計算。named target と exact literal source は別指標であり、意味的正答率ではない。embedding / reranking / reader は実行しない。', '', '| Arm | Named @1/@3 | Literal source entity @1/@3 | Top1 row literal |', '|---|---|---|---|']
+ for variant, t in tails.items():
+ lines.append(f"| {variant} | {t['named_target_hit1']}/{t['n']}, {t['named_target_hit3']}/{t['n']} | {t['literal_source_entity_hit1']}/{t['n']}, {t['literal_source_entity_hit3']}/{t['n']} | {t['top1_index_row_literal']}/{t['n']} |")
+ lines += ['', '全候補、snippet、静的rerank予算、見出し節長、cap監査、実行環境、bytes/rows、build/query時間は results.json / sections.json に保存。時間・サイズは当該ローカル環境の観測値。production capとchunkの全列挙/dedupは同一ではない。', '', 'ngr_prefix は general-query control(各tokenがprefix内に存在)であり phrase-in-prefix control ではない。', '', 'Webhook chunk の別docにも同じ literal があり、named target の順位差を品質失敗とは判定しない。']
+ (R / 'REPORT.md').write_text('\n'.join(lines) + '\n', encoding='utf8')
+ print(json.dumps({'output': str(R), 'tail_summary': tails}, ensure_ascii=False))
+if __name__ == '__main__':
+ main()
diff --git a/scripts/document-tail-search.py b/scripts/document-tail-search.py
new file mode 100644
index 0000000..863bd43
--- /dev/null
+++ b/scripts/document-tail-search.py
@@ -0,0 +1,87 @@
+import json, sqlite3, time, statistics, platform, sys, hashlib
+from pathlib import Path
+ROOT = Path(sys.argv[1]).resolve()
+p = json.loads((ROOT / 'prepared.json').read_text(encoding='utf8'))
+results = {}
+sizes = {}
+times = {}
+for variant, rows in p['rows'].items():
+ dbpath = ROOT / (variant + '.sqlite')
+ if dbpath.exists():
+ raise RuntimeError('Refuse to overwrite existing database: ' + str(dbpath))
+ t = time.perf_counter()
+ db = sqlite3.connect(dbpath)
+ db.executescript("CREATE TABLE search_docs(entity TEXT,repo TEXT,path TEXT,chunk INTEGER,offset INTEGER,content TEXT,content_fts TEXT); CREATE VIEW search_docs_nat_content_v3 AS SELECT rowid,content_fts AS content FROM search_docs; CREATE VIRTUAL TABLE search_docs_nat_fts_v3 USING fts5(content, tokenize='porter unicode61 remove_diacritics 2',content='search_docs_nat_content_v3',content_rowid='rowid');")
+ for row in rows:
+ cur = db.execute('INSERT INTO search_docs VALUES (?,?,?,?,?,?,?)', tuple((row[k] for k in ['entity', 'repo', 'path', 'chunk', 'offset', 'raw', 'segmented'])))
+ db.execute('INSERT INTO search_docs_nat_fts_v3(rowid,content) VALUES (?,?)', (cur.lastrowid, row['segmented']))
+ db.commit()
+ build_ms = (time.perf_counter() - t) * 1000
+ db.execute("INSERT INTO search_docs_nat_fts_v3(search_docs_nat_fts_v3,rank) VALUES ('integrity-check',1)")
+ db.commit()
+ sizes[variant] = {'db_bytes': dbpath.stat().st_size, 'rows': len(rows), 'indexed_raw_utf8_bytes': sum((len(r['raw'].encode('utf8')) for r in rows)), 'indexed_segmented_utf8_bytes': sum((len(r['segmented'].encode('utf8')) for r in rows)), 'build_elapsed_ms': build_ms}
+
+ def search(q):
+ hits = []
+ for tier, key in [(0, 'match_all'), (1, 'match_any')]:
+ if tier and q['match_all'] == q['match_any']:
+ continue
+ sql = "SELECT d.rowid,d.entity,d.repo,d.path,d.chunk,d.offset,bm25(search_docs_nat_fts_v3),snippet(search_docs_nat_fts_v3,0,'[',']',' … ',40),highlight(search_docs_nat_fts_v3,0,'[',']') FROM search_docs_nat_fts_v3 JOIN search_docs d ON d.rowid=search_docs_nat_fts_v3.rowid WHERE search_docs_nat_fts_v3 MATCH ?"
+ args = [q[key]]
+ if q['repo']:
+ sql += ' AND d.repo=?'
+ args.append(q['repo'])
+ sql += ' ORDER BY bm25(search_docs_nat_fts_v3),d.rowid'
+ for rid, entity, repo, path, chunk, offset, score, snippet, highlight in db.execute(sql, args):
+ windows = []
+ pos = 0
+ while len(windows) < 8:
+ start = highlight.find('[', pos)
+ if start < 0:
+ break
+ end = highlight.find(']', start)
+ windows.append(highlight[max(0, start - 60):end + 61])
+ pos = end + 1
+ hits.append({'rowid': rid, 'entity': entity, 'repo': repo, 'path': path, 'chunk': chunk, 'offset_utf16': offset, 'tier': tier, 'bm25': score, 'snippet': snippet, 'match_windows': windows})
+ hits.sort(key=lambda h: (h['tier'], h['bm25'], h['rowid']))
+ seen = set()
+ unique = []
+ for h in hits:
+ if h['entity'] in seen:
+ continue
+ seen.add(h['entity'])
+ unique.append(h)
+ return (unique, hits)
+ variant_results = []
+ for q in p['queries']:
+ unique, allhits = search(q)
+ sample = []
+ for _ in range(31):
+ t = time.perf_counter()
+ search(q)
+ sample.append((time.perf_counter() - t) * 1000)
+ target = None if not q['target'] else q['repo'] + ':' + q['target']
+ rank = next((i + 1 for i, h in enumerate(unique) if h['entity'] == target), None)
+ variant_results.append({'id': q['id'], 'kind': q['kind'], 'repo': q['repo'], 'query': q['text'], 'match_all': q['match_all'], 'match_any': q['match_any'], 'target': target, 'target_rank': rank, 'hit_at_1': rank == 1 if target else None, 'hit_at_3': rank is not None and rank <= 3 if target else None, 'candidate_entities': len(unique), 'candidate_rows_before_entity_dedup': len(allhits), 'top3': unique[:3], 'all_candidates': unique, 'median_query_elapsed_ms': statistics.median(sample), 'query_samples_ms': sample})
+ results[variant] = variant_results
+ db.close()
+summary = {}
+for variant, qs in results.items():
+ summary[variant] = {}
+ for repo in sorted(set((q['repo'] for q in qs if q['target']))):
+ summary[variant][repo] = {}
+ for kind in ['tail', 'prefix-control', 'all-positive']:
+ cohort = [q for q in qs if q['repo'] == repo and q['target'] and (kind == 'all-positive' or q['kind'] == kind)]
+ summary[variant][repo][kind] = {'n': len(cohort), 'hit1': sum((q['hit_at_1'] for q in cohort)), 'hit3': sum((q['hit_at_3'] for q in cohort))}
+controls = []
+for base in results['prefix']:
+ if base['kind'] != 'prefix-control':
+ continue
+ baseids = [h['entity'] for h in base['all_candidates']]
+ for variant in ['full', 'chunks']:
+ new = next((q for q in results[variant] if q['id'] == base['id']))
+ newids = [h['entity'] for h in new['all_candidates']]
+ controls.append({'id': base['id'], 'variant': variant, 'target_rank_before': base['target_rank'], 'target_rank_after': new['target_rank'], 'new_candidates': [x for x in newids if x not in baseids], 'lost_candidates': [x for x in baseids if x not in newids], 'top3_before': [h['entity'] for h in base['top3']], 'top3_after': [h['entity'] for h in new['top3']], 'nongold_top3_before': [h['entity'] for h in base['top3'] if h['entity'] != base['target']], 'nongold_top3_after': [h['entity'] for h in new['top3'] if h['entity'] != new['target']]})
+out = {'scope': 'Candidate discovery only; sparse natural FTS5, no embeddings/reranking, no frozen evaluation, development corpus only', 'environment': {'python': sys.version, 'sqlite': sqlite3.sqlite_version, 'platform': platform.platform(), 'processor': platform.processor(), 'node_versions': p['node_versions'], 'sqlite_compile_options': [x[0] for x in sqlite3.connect(':memory:').execute('PRAGMA compile_options')]}, 'source_sha': p['sha'], 'source_hashes': p['sources'], 'input_sha256': p['input_sha256'], 'fixture_sha256': p['fixture_sha256'], 'input_bytes': p['input_bytes'], 'body_utf16_total': p['body_utf16_total'], 'body_utf8_bytes_total': p['body_utf8_bytes_total'], 'segmentation_prepare_elapsed_ms': p['segmentation_prepare_elapsed_ms'], 'storage': sizes, 'inventory': p['inventory'], 'literal_audits': p['literalAudits'], 'summary': summary, 'prefix_control_changes': controls, 'results': results}
+(ROOT / 'results.json').write_text(json.dumps(out, ensure_ascii=False, indent=2) + '\n', encoding='utf8')
+print(json.dumps({'storage': sizes, 'summary': summary, 'controls': controls}, ensure_ascii=False))
diff --git a/scripts/document-tail-sections.mjs b/scripts/document-tail-sections.mjs
new file mode 100644
index 0000000..97690e7
--- /dev/null
+++ b/scripts/document-tail-sections.mjs
@@ -0,0 +1,120 @@
+import fs from "node:fs";
+import path from "node:path";
+import crypto from "node:crypto";
+const R = path.resolve(process.argv[2]);
+const fixtureDir = path.resolve(process.argv[3]);
+const fixture = JSON.parse(
+ fs.readFileSync(path.join(fixtureDir, "fixture.json"), "utf8"),
+ ),
+ prepared = JSON.parse(fs.readFileSync(path.join(R, "prepared.json"), "utf8")),
+ results = JSON.parse(fs.readFileSync(path.join(R, "results.json"), "utf8"));
+const source = fs.readFileSync(path.join(fixtureDir, "source-src-rerank.ts"));
+import { sections } from "./document-tail-headings.mjs";
+const docs = fixture.docs.map((d) => ({ ...d, sections: sections(d.text) }));
+const rootSet = new Set(
+ fixture.queries
+ .filter((q) => q.kind === "tail")
+ .map((q) => q.repo + ":" + q.target),
+);
+function dist(ds) {
+ const ss = ds
+ .flatMap((d) =>
+ d.sections.filter((s) => s.length_utf16 > 0).map((s) => s.length_utf16),
+ )
+ .sort((a, b) => a - b),
+ pct = (p) => ss[Math.max(0, Math.ceil(p * ss.length) - 1)];
+ return {
+ documents: ds.length,
+ nonempty_sections_including_preamble: ss.length,
+ empty_preambles: ds.filter((d) => d.sections[0].length_utf16 === 0).length,
+ sum_section_utf16: ss.reduce((a, b) => a + b, 0),
+ at_most_1500: {
+ count: ss.filter((n) => n <= 1500).length,
+ denominator: ss.length,
+ fraction: ss.filter((n) => n <= 1500).length / ss.length,
+ },
+ at_most_1700: {
+ count: ss.filter((n) => n <= 1700).length,
+ denominator: ss.length,
+ fraction: ss.filter((n) => n <= 1700).length / ss.length,
+ },
+ p50: pct(0.5),
+ p75: pct(0.75),
+ p90: pct(0.9),
+ p95: pct(0.95),
+ max: Math.max(...ss),
+ };
+}
+const targets = fixture.queries
+ .filter((q) => q.target)
+ .map((q) => {
+ const d = docs.find((d) => d.repo === q.repo && d.path === q.target),
+ raw = d.path + "\n\n" + d.text,
+ off = d.text.indexOf(q.text),
+ s = d.sections.find((s) => off >= s.offset && off < s.end),
+ section = d.text.slice(s.offset, s.end),
+ budget = 1700 - Math.min(q.text.length, 200),
+ target = results.results.chunks
+ .find((x) => x.id === q.id)
+ .all_candidates.find((h) => h.entity === q.repo + ":" + q.target),
+ winning = prepared.rows.chunks.find(
+ (x) =>
+ x.entity === q.repo + ":" + q.target && x.chunk === target?.chunk,
+ ),
+ literalChunk = prepared.rows.chunks.find(
+ (x) => x.entity === q.repo + ":" + q.target && x.raw.includes(q.text),
+ ),
+ sectionInput = d.path + "\n\n" + section;
+ return {
+ id: q.id,
+ label:
+ q.id === "ngr_prefix"
+ ? "general-query control (individual matching terms in prefix)"
+ : q.kind,
+ query_utf16: q.text.length,
+ content_budget_utf16: budget,
+ body_phrase_offset_utf16: off,
+ section_level: s.level,
+ section_title: s.title,
+ section_start_body_utf16: s.offset,
+ section_length_utf16: s.length_utf16,
+ phrase_offset_in_section_utf16: off - s.offset,
+ phrase_offset_with_path_title_utf16: off - s.offset + d.path.length + 2,
+ whole_full_prefix_literal_visible: raw.slice(0, budget).includes(q.text),
+ winning8000chunk: target?.chunk ?? null,
+ winning8000chunk_prefix_literal_visible: winning
+ ? winning.raw.slice(0, budget).includes(q.text)
+ : null,
+ literal8000chunk: literalChunk?.chunk ?? null,
+ phrase_offset_in_literal8000chunk: literalChunk
+ ? literalChunk.raw.indexOf(q.text)
+ : null,
+ literal8000chunk_prefix_visible: literalChunk
+ ? literalChunk.raw.slice(0, budget).includes(q.text)
+ : null,
+ headingsection_with_path_title_prefix_literal_visible: sectionInput
+ .slice(0, budget)
+ .includes(q.text),
+ };
+ });
+const out = {
+ definition:
+ "Adjacent ATX h1-h6 or single-line setext h1/h2 boundaries outside fenced backtick/tilde code; includes heading line/markup, ends immediately before next heading, no parent-child aggregation. Nonempty preamble included, empty preamble separately counted/excluded from fractions. Indented code/blockquote/list setext candidates excluded; no full CommonMark parser claim. UTF16 units, nearest-rank percentiles. Root3 are manually supplied tail-target docs, all11 are complete fixture.",
+ rerank_source_sha256: crypto
+ .createHash("sha256")
+ .update(source)
+ .digest("hex"),
+ root3: dist(docs.filter((d) => rootSet.has(d.repo + ":" + d.path))),
+ all11: dist(docs),
+ targets,
+ per_document: docs.map((d) => ({
+ repo: d.repo,
+ path: d.path,
+ sections: d.sections,
+ })),
+};
+fs.writeFileSync(
+ path.join(R, "sections.json"),
+ JSON.stringify(out, null, 2) + "\n",
+);
+console.log(JSON.stringify({ root3: out.root3, all11: out.all11, targets }));
diff --git a/scripts/document-tail.test.mjs b/scripts/document-tail.test.mjs
new file mode 100644
index 0000000..4546cbe
--- /dev/null
+++ b/scripts/document-tail.test.mjs
@@ -0,0 +1,48 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { chunkBounds, prepare } from "./document-tail-prepare.mjs";
+import { sections } from "./document-tail-headings.mjs";
+
+const fixture = path.resolve("experiments/document-tail");
+test("UTF16 chunk boundary preserves surrogate pairs and exact reconstruction", () => {
+ const raw = "x".repeat(7999) + "😀" + "y".repeat(8000);
+ const bounds = chunkBounds(raw);
+ assert.equal(bounds[0][1], 7999);
+ assert.equal(bounds.map(([a, b]) => raw.slice(a, b)).join(""), raw);
+ assert.ok(bounds.every(([a, b]) => b - a <= 8000));
+});
+test("heading parser excludes fenced ATX/setext and retains actual headings", () => {
+ const text =
+ "# One\n```md\n# Fake\nFake\n----\n```\n~~~\n## Fake too\n~~~\nTwo\n===\nbody\n";
+ assert.deepEqual(
+ sections(text)
+ .filter((s) => s.level)
+ .map((s) => s.title),
+ ["One", "Two"],
+ );
+});
+test("hash verification rejects fixture/source tampering before indexing", async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tail-hash-"));
+ try {
+ fs.cpSync(fixture, path.join(tmp, "fixture"), { recursive: true });
+ fs.appendFileSync(path.join(tmp, "fixture", "fixture.json"), " ");
+ await assert.rejects(
+ prepare(path.join(tmp, "fixture"), tmp),
+ /Fixture hash mismatch/,
+ );
+ fs.copyFileSync(
+ path.join(fixture, "fixture.json"),
+ path.join(tmp, "fixture", "fixture.json"),
+ );
+ fs.appendFileSync(path.join(tmp, "fixture", "source-src-segment.ts"), " ");
+ await assert.rejects(
+ prepare(path.join(tmp, "fixture"), tmp),
+ /Source hash mismatch/,
+ );
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+});
diff --git a/scripts/document_tail_test.py b/scripts/document_tail_test.py
new file mode 100644
index 0000000..fe0f752
--- /dev/null
+++ b/scripts/document_tail_test.py
@@ -0,0 +1,82 @@
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+SCRIPTS = Path(__file__).resolve().parent
+
+
+class DocumentTailContract(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.temp = tempfile.TemporaryDirectory(prefix='tail-contract-')
+ cls.root = Path(cls.temp.name)
+ cls.out = cls.root / 'fresh'
+ subprocess.run([sys.executable, str(SCRIPTS / 'document-tail-replay.py'), '--out', str(cls.out)], check=True, stdout=subprocess.DEVNULL)
+ cls.result = json.loads((cls.out / 'results.json').read_text(encoding='utf8'))
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.temp.cleanup()
+
+ def test_fixed_fixture_ranks_and_static_evidence(self):
+ r = self.result
+ self.assertEqual(len(r['inventory']), 11)
+ self.assertEqual(len(r['results']['full']), 7)
+ for arm, named1, named3, literal1, rowliteral in [('prefix', 0, 1, 1, 0), ('full', 3, 3, 3, 3), ('chunks', 2, 3, 3, 3)]:
+ s = r['tail_summary'][arm]
+ self.assertEqual((s['named_target_hit1'], s['named_target_hit3'], s['literal_source_entity_hit1'], s['top1_index_row_literal']), (named1, named3, literal1, rowliteral))
+ self.assertTrue(all(a['same'] for a in r['production_nat_cap_equivalence_audit']))
+ section = r['heading_section_analysis']
+ self.assertEqual(section['root3']['nonempty_sections_including_preamble'], 74)
+ self.assertEqual(section['root3']['at_most_1700']['count'], 59)
+ tails = [t for t in section['targets'] if t['label'] == 'tail']
+ self.assertEqual(sum(t['headingsection_with_path_title_prefix_literal_visible'] for t in tails), 2)
+ self.assertFalse(any(t['whole_full_prefix_literal_visible'] or t['winning8000chunk_prefix_literal_visible'] for t in tails))
+ for arm in r['results'].values():
+ negatives = [q for q in arm if q['kind'] == 'absent-from-this-fixture-only']
+ self.assertEqual(len(negatives), 1)
+ self.assertTrue(all(q['candidate_entities'] == 0 for q in negatives))
+ self.assertTrue(all(a['absent_from_all_bodies'] for a in r['literal_audits'] if 'absent_from_all_bodies' in a))
+
+ def test_existing_output_is_not_overwritten(self):
+ marker = self.out / 'marker'
+ marker.write_text('keep')
+ digest = (self.out / 'results.json').read_bytes()
+ result = subprocess.run([sys.executable, str(SCRIPTS / 'document-tail-replay.py'), '--out', str(self.out)], capture_output=True)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(marker.read_text(), 'keep')
+ self.assertEqual((self.out / 'results.json').read_bytes(), digest)
+
+ def test_dangling_output_symlink_does_not_create_target(self):
+ link = self.root / 'dangling-output'
+ target = self.root / 'must-not-be-created'
+ try:
+ link.symlink_to(target, target_is_directory=True)
+ except (OSError, NotImplementedError) as error:
+ self.skipTest('Symlink creation unavailable: ' + str(error))
+ result = subprocess.run([sys.executable, str(SCRIPTS / 'document-tail-replay.py'), '--out', str(link)], capture_output=True)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertTrue(link.is_symlink())
+ self.assertFalse(target.exists())
+
+ def test_targets_do_not_affect_ranking(self):
+ prepared = json.loads((self.out / 'prepared.json').read_text(encoding='utf8'))
+ changed = self.root / 'changed-labels'
+ changed.mkdir()
+ for q in prepared['queries']:
+ if q['target']: q['target'] = 'unrelated-public-path.md'
+ (changed / 'prepared.json').write_text(json.dumps(prepared, ensure_ascii=False), encoding='utf8')
+ subprocess.run([sys.executable, str(SCRIPTS / 'document-tail-search.py'), str(changed)], check=True, stdout=subprocess.DEVNULL)
+ after = json.loads((changed / 'results.json').read_text(encoding='utf8'))
+ for arm, queries in self.result['results'].items():
+ for before, new in zip(queries, after['results'][arm]):
+ self.assertEqual(before['all_candidates'], new['all_candidates'])
+ self.assertEqual(before['top3'], new['top3'])
+ self.assertIsNone(new['target_rank'])
+
+
+if __name__ == '__main__':
+ unittest.main()