2026-07-18。プロバイダの json.json → stringify → decode[unknown] → http.json round-trip 事故
(後述の落とし穴 §1)を除去した際に、JSON にまつわる3つの別物のドメインと、その間の全変換境界を
実装から棚卸しした総まとめ。以後「この値はいまどのドメインにいるか」「この境界は予約キーをどう扱うか」
はここを参照する。実装の SoT: typescript/types/src/wire.ts(予約キーとエスケープ)、
typescript/runtime/src/runtime/value/codec.ts(値 ↔ wire)、
typescript/runtime/src/runtime/engine/json-value.ts(json.json ↔ 値/文書)、
haskell/compiler/stdlib/prelude/json.ktr(言語表面)、haskell/compiler/src/Katari/Schema.hs(スキーマ)。
| ドメイン | 実体 | $ キーの意味 |
file の姿 |
|---|---|---|---|
(i) json.json |
純 JSON 文書の直和(json_null … json_object(entries: record[json]))。内部的には prelude.json.json_* を ctor に持つ data 値の木 |
ただのエントリ。parse / stringify はキーを一切解釈しない(literal) |
存在できない(7形しかない)。json.encode(file) は $ref エントリを持つ json_object(handle の文書化)になる |
| (ii) 生の Katari 値 | record / array / スカラー / file(= slim blob ref)/ agent / closure / tool / data(ctor 付き record)。engine のタグ付き Value モデル |
キーは素のまま。file は record ではない(kind: "ref")ので、literal "$ref" キーを持つ record と file 値は内部表現で曖昧性なし |
{ kind: "ref", semanticKind, blobId } — identity のみ、バイト列は blob store |
| (iii) wire JSON | 値コーデック(valueToJson / jsonToValue)が作る bare Json。reactor 間・FFI・API・永続 envelope の形 |
予約単一 $ 判別キー: $constructor / $ref / $agent / $closure / $tool / $redacted。ユーザ record の $ 始まりキーは先頭 $ を倍加($$)して退避 |
{ "$ref": blobId, "semanticKind": … } handle オブジェクト |
wire の要点(wire.ts 冒頭に明文): data 値のフィールドは value の下にネストするので判別キーと衝突せず、
bare record は $$ エスケープにより単一 $ キーを発行できない — オブジェクト variant は本物の直和。
$redacted だけは片道(redact ポリシーの吸い込み口、decode は拒否)。
曖昧性が生じるのは wire JSON 上だけであり、そこはコーデックのエスケープが守る。値平面では
literal "$ref" キーを持つ record と file 値は型が違うので混ざりようがない — だから「wire を経由せず
値を直接組み立てる」ことが常に安全側。
| 境界 | 方向 | 予約キーの扱い | file の扱い | 実装 |
|---|---|---|---|---|
json.parse / json.stringify |
text ↔ (ii) | literal — キーは常に書かれたまま(escape なし)。parse は $constructor もただのエントリとして読む |
stringify は TOTAL: 文書は key-for-key で往復、非文書は正準 wire form に描く(file → $ref handle 文書、data → $constructor+value ネスト、blob 化 string leaf は本文化) |
interop-prims.ts + json-value.ts |
json.encode[T] |
(ii) → (i) | wire 規約を畳み込む: data は $constructor+value ネスト、record の $ キーは $$ 化 |
file → $ref エントリを持つ json_object(handle の文書) |
json-value.ts encodeValue |
json.decode[T] |
(i) → (ii) | wire 読み: $constructor 再タグ、$ref → file 復元、$agent/$closure/$tool → callable 復元、$$ → unescape。その後 T のスキーマで別パス検証(decode_error) |
$ref → 本物の file 値 |
json-value.ts treeToValue + validation.ts conformValue |
json.stringify(非文書値)/ json.parse_as |
融合(encode∘literal-write / parse+検証) | stringify が旧 to_text を吸収して total 化(2026-07-19)— 値を wire 文書化してから literal write + unescape。parse_as は literal lift 後に別パス検証 |
同上 | interop-prims.ts |
| schema boundary 検証 | (ii) を JSONSchema に照合 | 検証のみ・書き換えなし(コーデックと分離が法則)。file ref は $ref 参照スキーマを満たす |
照合のみ | value/validation.ts |
| http.json materializer | wire(reveal 済) → HTTP 文書 | $$ を素の $ に unescape して出力(2026-07-18 修正)。単一 $ の $ref handle は base64 化なので両者は衝突しない |
$ref handle → その位置で base64。blob 化 string leaf → text |
external/http-body.ts materializeJsonTree(reactor 側は http-reactor.ts が valueToJson(…, "reveal")) |
| FFI port | wire ↔ handler の JS 値 | decode で unescape / encode で再エスケープ。$ref → KatariFile/KatariString、$constructor → KatariData、$agent/$closure/$tool → KatariAgent。$redacted は throw |
handle ラッパ(バイトはオンデマンド download) | port/src/values.ts(reactor 側 ffi-reactor.ts は reveal で送出) |
| run 引数 / 結果(API) | wire → (ii) / (ii) → wire | 引数は wire 読み({$ref} を渡せば file になる)、不正 handle は 400。結果は redact(private subtree → $redacted) |
引数の $ref → file 復元 |
facade.ts |
reflection.get_metadata |
JSONSchema → (i) | schemaToJson の文書を literal lift(jsonValueFromJson)。file 引数のスキーマは literal "$ref" プロパティキーを含む文書になる(Schema.hs fileReferenceSchema) |
n/a | interop-prims.ts + Schema.hs |
mcp tool 引数(minted tool / mcp.call) |
(i) → MCP server 文書 | jsonValueToJson の literal 歩き — キー不変 |
blob 化 string leaf は本文化 | mcp-reactor.ts |
| mcp direct reply | server 文書 → (ii) | まず literal tree で conform(T = json.json / unknown はそのまま)、外れたら wire 読み + conform(typed T の $ref は本物の file に) |
typed T なら file 復元 | mcp-reactor.ts decodeDirectReply |
| mcp listing → toolbox | listing 文書 → tool 値 | jsonToValue(wire 読み)→ valueToJson(再エスケープ)→ jsonToSchema(typed subset 化: 未知キーワード $defs / $schema / JSON-pointer $ref は落ちる) |
— | mcp-reactor.ts mintToolbox + schema-json.ts |
model の tool_call.args(ai loop) |
(i) → (ii) | decode_args = json.decode[unknown] — 意図的に wire 読み。モデルが replay した {$ref} は本物の file になって tool に渡る |
$ref → file 復元 |
katari-packages/ai ai.ktr |
| プロバイダの request body(現行) | (i) の部分木 → (ii) | ai.json_to_value = literal 平坦化(キー再解釈なし)。file はツリーに値として直接置く |
file 値のまま(base64 は送信境界) | katari-packages/ai ai.ktr / anthropic.ktr / gemini.ktr |
法則(2026-07-02 addendum の再確認): 値 ↔ wire は total・schema 非依存の bijection、検証は別パス。
ただし bijection の向きに注意 — decode(encode(x)) == x は全 x で成立するが、外部産の文書に対する
encode(decode(d)) == d は成立しない(単一 $ キーは decode で素通りし、encode で $$ 化される)。
decode[unknown]round-trip 事故(今回の筆頭)。 旧プロバイダは request body をjson.jsonで組み立ててからjson.decode[unknown]で値ツリーへ落としていた。decode は wire 読みなので、 文書に正当な literal$キーがあると壊れる:properties: { "$ref": {…} }(file 引数を取る tool の スキーマ —view_imageがまさにこれ)はwireKindOfが file handle と誤認して "expected a string leaf" の decode_error に;{"$ref": "#/$defs/x"}(JSON-pointer)は文字列なので無言で blobId"#/$defs/x"の file 値に化ける;予約でない単一$キー($schema/$defs)は record キーとして 素通りした後valueToJsonで$$schema/$$defsに化けて wire に出る。対策は「wire を経由しない」: 値ツリーを直接組み立てる(record リテラル + キーワードキーはrecord.set+ file はそのまま置く)、json.jsonの部分木(get_metadata のスキーマ、モデルの args の echo)はai.json_to_valueの literal 平坦化で継ぐ。json.jsonラッパもstringify/decodeも不要になり、ai.step_errorからjson.decode_errorが消えた。$$エスケープ — 最終姿(2026-07-19 の json 表面再設計で決着)。$始まりの record キーは 盲目全単射(valueToJson/jsonToValue)の wire では常に$$…に倍化される。存在理由はただ一つ: schema なし全単射の全域性を保ちつつ、$refhandle の AI 可読性(モデルが{"$ref": id}を 会話から素直に replay できる)を維持するため — value-plane の$キーと wire 判別キーを衝突させない 唯一の手段。この$$は今やcodec の永続化バイト列の中だけに生きる純粋な内部技巧であり、 ユーザー・モデル・consumer のどの出口面にも現れない。すべての出口面が unescape する: FFI port(decode)、http.json / mcp 引数の materializer(送信時)、json.stringify(非文書値を wire form で描くとき — total 化で旧to_textを吸収、2026-07-19)。json.parse/stringifyの 文書経路 /parse_asはキー verbatim(escape 不関与)。新しい出口面を作るときは unescape を必ず入れること。escapeRecordKeyは単射 (value→wire は 1:1 で round-trip 保証)、unescapeRecordKeyは左逆写像だが単一$キーを 「外部産の literal キー」として温存する意図的な非単射(外部文書の$defs/$schemaを守る) — これがencode(decode(d)) == dが外部産文書で不成立になる非対称の源(値発の round-trip は常に無傷)。json.jsonに file を置けない理由。jsonは 7 形の閉じた直和で、file はその形を持たない。json.encode(value = f)は file をhandle の文書($refエントリの json_object)へ落とすので、 それを stringify しても送れるのは handle であってバイト列ではない。バイト列を送りたければhttp.jsonの値ツリーに file 値をそのまま置く([[2026-07-18-http-file-body]] — 実体化は送信境界、 値平面・DB・trace には handle しか乗らない)。- handle を送りたいときは明示 stringify。
http.jsonツリー内の file 値は必ず base64 化される (それが REST 慣行への約束)。$refhandle 自体を文書として送りたい稀なケースはjson.stringify(value = a_file)を string として埋める(http.ktrのjsonvariant docs に明文)。 - typed JSONSchema は外部スキーマに対して lossy。
jsonToSchema(schema-json.ts)は既知 キーワードだけ拾う subset なので、外部 MCP server のスキーマの$defs/$schema/ JSON-pointer$refは toolbox mint(tool 値のinputSchema)の時点で落ちる。さらに listing の取り込みはjsonToValue(wire 読み)→valueToJson(再エスケープ)を経るため、単一$キーは$$化して から subset 化される。コンパイラ由来のスキーマ(Schema.hs)は$defsを使わない(data は inline 展開、再帰は open schema で切る)ので自家製スキーマは無傷 — 痛むのは外部産のみ。 - 同じ
json.jsonでも「意図」で変換が分かれる。 モデルのtool_call.argsは wire 読み (decode_args— モデルが会話から replay した{$ref}は本物の file になって tool に渡るべき)、 request body への echo は literal 平坦化(ai.json_to_value— サーバには書かれたままの文書を 返すべき)。どちらか一方に統一しようとすると必ずどちらかの意図が壊れる。 - 数値の縮退。 bare JSON には数が 1 種しかないので、text / wire 境界は
Number.isIntegerでinteger/numberに割る(1.0は1で round-trip)。json.jsonの木の中ではjson_integer/json_numberが区別を保持する。 json_stringは blob 化 string を抱えうる。stringifyは本文化し、treeToValueはそのまま保つ(inline でも ref でも string は string)。$redactedは片道。 redact ポリシー(user-facing 境界のデフォルト)が private subtree を{"$redacted": true}に潰す。これはコーデックの外の吸い込み口で、decode / FFI port とも復元は throw — redact 済み文書を再投入したら大声で死ぬのが正しい。