ADR-0052 — glossary SSoT 再指定: contract = edit-SSoT / vocabulary.yaml = 生成 projection / canonical glossary.html = build-orchestrates-pack の提示層

Status: accepted · Date: 2026-07-16 · folio v1.2.0 · glossary の SSoT を 3 層に再指定する — glossary contract YAML = 唯一の edit-SSoT / vocabulary.yaml = そこからの生成 projection (runtime SSoT) / canonical glossary.html = folio build が pack pipeline へ委譲して生成する提示層。 ADR-0036 が置いた「vocabulary.yaml を上流で author する」前提と、 ADR-0051 の SSoT-first 転換で contract 側に生まれた正本が二重保持 (手動同鏡) になっていた矛盾を、 導出で解消する。 手動同鏡は人間規律に依存し実際に drift した (治癒 = folio-7ts2) ため、 機械導出 + projection stale gate へ置換する。 委譲は 3 消費点が単一 dispatcher を呼ぶ lockstep で、 前提崩れは内部 emitter へ fallback せず hard-fail する (fallback は提示層を素朴版へ silent revert させ gate を恒真 PASS 化するため)。 Layer 1 consumer の内部 emitter 経路は不変。 ADR-0036 §2.1/§2.4/§2.5 を部分 supersede する (frozen 本文は不触)。 user 承認 = bd folio-wg2l の flip land 承認と同時 1 回 (constitution 無改訂ゆえ P-10 Amendment 不要)。 ★2026-07-16 user 本文承認済 — status = accepted (bd folio-wg2l の flip land 承認と同時 1 回)。

§1. Context

ADR-0036 は glossary の SSoT モデルを「folio が vocabulary.yaml に canonical 定義を上流で author し、 そこから hover tooltip と glossary.html の 2 面を derive する」と定めた。 当時 vocabulary.yaml は唯一の編集起点であり、 単一 SSoT からの derive という構造は自己整合していた。

その後 ADR-0050 の SSoT-first 転換で、 glossary は generator contract (folio-glossary.glossary.yaml) を持つ pack になった。 contract は各語に formal_def (正式定義) を持ち、 ADR-0051 の語彙では正本 (edit-SSoT) である。 ここで定義本文は vocabulary.yamldefinition と contract の formal_def二重保持されることになった — vocabulary.yaml は folio fix の tooltip populate 等が読む runtime SSoT ゆえ撤去できないからである。

folio-9inj はこの二重保持を「手動同鏡 (片側編集禁止 MUST)」という規律で塞ぎ、 集合完全一致 + 定義逐語一致を機械強制した。 しかしこれは導出ではなく規律であり、 2 つの構造的な綻びを残した。 第一に、 vocabulary.yaml ⊃ contract の drift が実際に発生した — vocab 側にだけ 3 語を足した編集が contract を stale のまま残し、 治癒 ticket folio-7ts2 が 33→36 語の収録で塞いだ (folio-7ts2 は drift の *治癒* であって発生源ではない)。 第二に、 同鏡 gate は集合と定義本文しか見ないため、 語順・domain・header・整形といった面は誰も検査していなかった。 二重保持を人間規律で守る限り、 「片側だけ編集する」という操作が物理的に可能なまま残る。

同時に、 提示層にも取り残しがあった。 folio 自身の design-intent/glossary.htmlbin/folio の内部 emitter が出力する定義リスト (<dt>/<dd> の平坦な列) のままで、 glossary-pack が生成するリッチな dual-audience 提示層 (domain 区分・平易定義・機械層 term レコード) は成果物として存在しながらcanonical には成っていなかった。 folio の北極星 (非エンジニアが読める文書) に対して、 folio 自身の用語集だけが旧型で取り残されている状態である。 これは ADR-0049 / vision で確立した「out-of-scan canonical を pack pipeline の生成物にする」型を glossary へ適用すれば解消する。

§2. Decision

§2.1 SSoT の 3 層再指定

glossary の SSoT を次の 3 層に再指定する。 (1) glossary contract YAML = 唯一の edit-SSoT — 語の追加・削除・定義改訂はここだけを編集する (ADR-0051 の「正本 (edit-SSoT)」であり、 P-11 の隔離先 harness に居住し続ける)。 (2) design-intent/vocabulary.yaml = contract からの生成 projection — 全域関数 (canonicalcanonical / domaindomain / definitionformal_def) で機械導出する runtime SSoT。 folio fix の tooltip populate・folio validate の glossary gate・Layer 1 consumer 向けの内部 derive がこれを読む役割は不変で、 変わるのは誰が書くかだけである (人間 → projection script)。 (3) design-intent/glossary.html = canonical WHAT (提示層) — folio build が pack pipeline へ委譲して生成する (§2.2)。

これにより folio-9inj の「手動同鏡」は導出に置換される。 同鏡は「2 つの正本が一致していることを人間が保つ」規律だったが、 projection では正本は 1 つだけで、 他方は毎回そこから作り直される派生物である。 片側編集は禁止事項ではなく意味を持たない操作になる (次の再生成で消える)。 これは double-SSoT を「導出」で構造保証する DITA / literate programming の prior art と同型で、 folio が既に index.html・chrome・glossary の tooltip で採っている型の水平展開である。

§2.2 build-orchestrates-pack (委譲の型)

folio build は folio self-host root において、 glossary.html の生成を generator の pack pipeline (assemble-glossary.sh + inject-prose.sh) へ委譲する。 CLI は「何を生成するか」を所有し続け、 「どう組み立てるか」は pack が持つ — この関係を build-orchestrates-pack と呼ぶ。 Layer 1 consumer root では従来どおり内部 emitter が vocabulary.yaml から derive する (consumer は generator を持たないため。 ADR-0036 §2.1 の ship→derive はこの経路で不変)。

委譲の設計には 3 つの不変条件を課す。 (a) 単一述語による self-host 検出: 「sibling generator に grandfather manifest と contract dir が両実在する」という gate (s) spec-contract-pairing と同一の述語を共通 helper へ抽出し、 全消費点がそれを呼ぶ。 marker の有無・vocab の非空・環境変数を self-host 信号に代用しない (代用は 2 つの「self-host とは何か」を作り、 必ず drift する)。 (b) 3 消費点の lockstep: build の書込 path・build --check の対称 gate・validate の nav-regen-drift gate は、 いずれも単一の emit dispatcher だけを呼ぶ。 これは可読性のためではなく構造的な要請である — 書込 path だけが旧経路に残ると、 folio build 1 回でリッチな提示層が素朴版へ silent revert し、 以後 gate は「committed (素朴) == emit (素朴)」で恒真 PASS になって劣化を隠す。 (c) 前提崩れは hard-fail: 委譲入力の不在、 contract の graph 前方照会 edge や doc_id の欠落、 pipeline 失敗、 出力の前提崩れ (空・構造マーカー欠如・marker 欠如・下限サイズ割れ) は全て hard-fail であり、 内部 emitter へ fallback しない。 fallback は (b) と同じ silent revert を招く。

提示層に必要な folio scanner 互換 head (生成 marker・doc-type・status・相対 @id) は、 contract の canonical_page block 由来の条件 emit とする。 同 block を持たない contract (generator の demo suite 等) の出力には marker を付けない — 無条件に付けると demo 成果物が「folio build の生成物」という虚偽の出自を表示し、 「marker 持ち page だけを drift 検査する」という marker keystone の設計自体を汚染するからである。 scanner 側は無改修とする。

§2.3 gate の転換

projection stale gate: verify-glossary-parity.sh の同鏡節を、 「committed vocabulary.yaml == fresh projection」の whole-file byte 比較へ置換する。 byte 一致は旧検査 (集合一致 + 定義逐語一致) を構造的に含意する上位集合であり、 加えて旧検査が見なかった header・語順・domain・整形の drift も捕捉する。 gate は既に CI の deterministic floor へ blocking 配線済ゆえ、 配線を継承する。 非循環性を守るため、 生成物 warning header は projection script 内のリテラル定数とする — committed から header を読み戻すと、 header 改竄が fresh 側へも伝播して byte 比較が恒真 PASS 化する。

nav-regen-drift の再配線: diff 対象を emit dispatcher の出力へ切替える (self-host では pack pipeline 出力)。 pack の footer は生成時刻を含み毎分変化するため、 比較前に生成時刻だけを正規化する。 正規化の scope は接頭辞付き部分文字列に厳密に限る — 裸の日付を全域置換したり行ごと削除したりすると、 版日付・承認・検証状態を潰す drift masking になる。 build --check も同一 normalize で対称化する。 term 集合の対称差診断は、 pack (見出し形) と内部 emitter (span 形) の両 shape を抽出対象にする (片方だけ対応させると他方の経路で診断が黙って空になる)。

配信 asset の同期 gate: glossary-pack は他 pack と違い style を inline せず <link rel="stylesheet" href="srs.css"> で外部参照するため、 配信 root (design-intent/) に srs.css の実体が要る。 Leg 0 は源泉 (design-system/srs.css) を残したまま配信 root へ copy を置く option 1 を採った (pack の href 無改変)。 ただし放置された copy は源泉から drift するのが本 repo の前科であり (folio-u7oc: srs.css の WCAG fix が canonical へ未伝播のまま 10 日 silent drift)、 かつ実害の質が悪い — render-gate は配信 root の copy を読んで contrast/幾何を検査するため、 copy が stale だと「古い token で緑」という vacuous-green を作り、 WCAG token drift クラスを再生産する。 したがって copy を許す前提条件として verify-asset-sync.sh (登録対の whole-file byte 比較・REGISTRY が default-block の SSoT) を併設し、 CI の deterministic floor へ blocking 配線する。 inline する canonical (vision.html) の drift は canonical-drift gate が byte に含めて捕捉するが、 外部参照される asset の中身は byte 比較の外側にあるため本 gate が唯一の番人であり、 未配線の gate は前提条件を discharge しない (gate は tree に在るだけでは gate でなく、 走って初めて gate)。

§2.4 prose manifest の帰属 (全 pack への一般宣言)

本 § は glossary に限らず全 pack に適用される一般宣言である。 生成 pack の入力は 2 つある: contract YAML = 機械層の edit-SSoT (構造・識別子・正式定義など機械が消費する値) と、 prose manifest = 人間層の edit-SSoT (平易な散文。 opus が著作する)。 prose manifest は contract からの導出物ではない — 平易化は意味の営みであり、 機械的に導出できない (自由文の fidelity は独立 reviewer の領分であって決定的プログラムの領分ではない)。 したがって pack は双 edit-SSoT 構造を持つ。

ADR-0050 §3 は SSoT-first 転換の帰結として「authoring の単線化 — 編集は contract YAML の 1 箇所になる」を挙げた。 本 § はこれを否定するのではなく精緻化する (refine): 機械層 normative の編集は contract YAML 単線のままで、 単線化の主張はその射程で成立し続ける。 本 § が別 edit-SSoT として切り出すのは人間層の平易 prose だけであり、 これは機械層の値ではなく「機械が導出できない意味の営み」の置き場である (両者は対象が異なる = 矛盾でなく層の精緻化)。

双 edit-SSoT は SSoT 唯一性 (P-5) と衝突しない。 両者は異なる対象の正本だからである (機械層の値 / 人間層の散文)。 両者の整合は 3 層で束縛する: (1) inject の集合一致 — HTML のスロット id 集合と manifest の key 集合が完全一致でなければ注入は fail-closed (未充填も orphan も拒否)。 (2) repro-build byte — 同一入力からの再生成が byte 一致する決定性。 (3) fidelity ceiling — 散文が contract を歪めず捏造していないかを独立 reviewer が検査する。 (1)(2) は機械が、 (3) は LLM が担う — これが folio の機械/LLM 境界の適用である。 本 § は帰属の明文化のみで、 prose の物理配置・経路・実装は一切変更しない。

§2.5 supersede の性格 (部分・frozen 本文不触)

本 ADR は以下を supersede する (部分ADR-0050 §2.5 / ADR-0026 先例に従い、 当該 ADR の他の決定は不変ゆえ dc:replaces でなく dc:references + 本 § / §5 で trace する。 frozen 本文は P-6 のもと不触で、 supersede 関係は本 ADR 側にのみ記す):

§3. Consequences

Positive

Negative

Neutral

§4. Alternatives Considered

採用可否
contract = edit-SSoT / vocabulary.yaml = 生成 projection (採用)二重保持を導出へ置換し drift クラスを構造的に消す。 runtime SSoT としての vocabulary.yaml の役割は保つため consumer 経路・tooltip 機構は無改修
手動同鏡を維持し gate を強化する不採用 — gate をいくら強めても「片側だけ編集できる」構造は残り、 検出は常に事後。 folio-7ts2 が治癒した drift は gate 不在下の実害だが、 二重保持を残す限り同型の穴 (gate が見ていない面) が再生産される (実際 header・語順・整形は無防備だった)
vocabulary.yaml を撤去し contract を直接読ませる不採用 — folio fix / validate / consumer derive が読む runtime SSoT を消すと、 consumer は generator を持たないため folio-closed 語を受け取れなくなる (ADR-0036 §2.1 の ship→derive が壊れる)。 projection なら両立する
提示層を内部 emitter の出力のまま据置く不採用 — folio 自身の用語集だけが非エンジニアに届かない旧型で残り、 self-dogfood が glossary で成立しない。 pack の成果物は既に存在しており、 canonical に成っていないだけだった
prose manifest を contract へ統合する (案A)不採用 — 平易 prose を機械層 contract に同居させ edit-SSoT を単一化する案。 8dkl の F6 裁定 (案E 採択) で却下: 平易化は機械が導出できない意味の営みゆえ、 統合しても「1 つの正本」にはならず contract 内に別性質の層が同居するだけで、 機械層 normative の単線性 (ADR-0050 §3) を濁す。 §2.4 の双 edit-SSoT + 3 層束縛 (inject 集合一致 / repro-build byte / fidelity ceiling) を採る
委譲時に前提が崩れたら内部 emitter へ fallback する不採用 — folio build 1 回で提示層が素朴版へ silent revert し、 以後 gate は「committed (素朴) == emit (素朴)」で恒真 PASS になって劣化を隠す。 hard-fail の方が loud で安全 (§2.2 (c))

§5. Trace