Status: v1 Phase 1(契約層のみ。TASK-0874 で確定) 位置づけ: 1 回の ai-loop run が終端に達したときに発行する run 単位の証跡 artifact(RunEvidence / 以下
EV)のフィールド契約正本。 関連正本:c3-prime-contract.md(C-3’ 出力 artifact・本契約の供給元)/delivery-state-machine.md(delivery 層の状態語彙)/decision-table.md(3 値 terminal state)/rollout-policy.md(判定基盤 carve-out) schema:run-evidence.schema.json生成者:scripts/ai-loop/run_evidence.py(決定論 producer)/ 受理者:scripts/ai-loop/run_evidence_verify.py消費者(いずれも未実装): #869 shadow mode(to_shadow_candidate_input())/ #811 promotion provenance(to_promotion_provenance())
本契約 v1 は Phase 1 = 契約層のみである。以下は Phase 1 の既定であり、 下流 PBI(#869 shadow mode / #811 promotion provenance)の plan 確定時に見直す前提で置いている。 見直しは §9 versioning policy の手続きに従う。
| ID | Phase 1 の既定 | 見直しの契機 |
|---|---|---|
| U-4 | 非終端 run は EV を発行しない(4 値目 IN_PROGRESS を作らない) |
#869 が「失敗パターンも学習源」として非終端 run を要求した場合 |
| U-8 | adapter IF は source_run_ids + baseline_version の最小 2 フィールドから開始し、それ以外は下流が埋める(§8) |
#869 が候補契約フィールドを確定した時点 |
| U-10 | Phase 1 の producer 出力は全件 partial(known-unavailable allowlist を置かない) |
下流が「complete な run のみ学習・promotion 対象」を採り、Phase 1 の全 run が使えないと判明した場合 |
| U-12 | blocked_by[] は fail-closed(キー欠落 = 判定不能 → BLOCKED。明示 [] のときのみ非 BLOCKED) |
#811 が blocked_by[] の供給元を確定した時点 |
| U-9 | fixture 9 / 10(paired replay / canary rollback)は #874 の最小定義。routing_decisions[] の item schema は定義しない |
#869 / #811 / #868 が別定義を採った場合は追従する |
本 PBI の完了は issue #874 の close 条件充足を意味しない。#874 の DoD は 「#869 shadow mode の統合 test」「#811 promotion provenance test」「効果測定」を要求しており、 いずれも本契約の下流にある。#874 は本 PBI 完了後も OPEN のまま残す。
EV の位置づけと不変条件EV は arbiter record(docs/working/ai-loop-runs/*.json)の後継ではなく上位 artifact である。arbiter record は入力ソースの 1 つとして参照するだけで、置き換えも移行も行わない。EV は 1 run 1 ファイル・拡張子は .json 固定。.jsonl にしてはならない(§7 privacy 参照)。json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True) + "\n"(plan_package.serialize_c3_prime() と byte 互換)。EV を生成する(§3)。本表が producer が出力するキーの全集合であり、schema の properties と 1:1 で対応する。
本表に無いキーを producer が出力してはならず、schema の properties に本表に無いキーを登録してもならない。
required は本表の 1〜20(issue #874 verbatim の 20 フィールド)+ 21 schema_version = 21 件。
22〜24 は optional(additionalProperties: false の下で properties への登録は必須)。
escalationを登録し忘れたときの実害:additionalProperties: falseの下でescalationがproperties未登録だと、privacy 違反や未知kindを検知したEV— 最も検証が必要なEV— だけが reject される。
| # | field | 型 | required | 供給元 | 取得不能時 |
|---|---|---|---|---|---|
| 1 | run_id |
string(非空) | ✅ | arbiter record 14 キー世代の run.run_id / 無ければ --run-id 注入 |
fail-closed |
| 2 | task_id |
string ^TASK-[0-9]{4}$ |
✅ | approvals/c3.json の task_id。task_dir 名に束縛 |
fail-closed |
| 3 | started_at |
string(ISO 8601 UTC) | ✅ | --started-at 注入 |
fail-closed |
| 4 | completed_at |
string(ISO 8601 UTC) | ✅ | --now 注入 |
fail-closed |
| 5 | repository |
string(owner 除去済み repo 名) | ✅ | --repository 注入。producer は git remote を呼ばない |
fail-closed |
| 6 | source_sha |
string ^[0-9a-f]{7,40}$ |
✅ | approvals/c3.json の source_sha |
fail-closed |
| 7 | final_head_sha |
string ^[0-9a-f]{7,40}$ | "unavailable" |
✅ | record.jsonl の kind=merge_ready entry の record.head_sha / 無ければ最終 kind=state entry の head_sha |
§5 マトリクス |
| 8 | plan_hash |
string ^sha256:[0-9a-f]{64}$ |
✅ | approvals/c3.json の plan_hash |
fail-closed |
| 9 | c3_prime_decision_ref |
object {path, plan_package_hash} |
✅ | approvals/c3.json への repo 相対参照 + plan_package_hash。§4 全規則の再検証を通過した場合のみ(§6) |
fail-closed |
| 10 | harness_version |
object {plugin_version, cli_version, corpus_hash} |
✅ | 注入(3 値すべて)。§4 参照 | fail-closed |
| 11 | routing_decisions |
array | "unavailable" |
✅ | #868 未実装(供給元なし) | Phase 1 固定 "unavailable" |
| 12 | ci_outcomes |
array | "unavailable" |
✅ | record.jsonl の kind=merge_ready の record.check_summary のみ(kind=state の reasons は observation へ回す — 件数照合を壊さないため混ぜない) |
§5 マトリクス |
| 13 | review_findings |
array | "unavailable" |
✅ | record.jsonl の record.review_disposition + kind=receipt かつ action_kind=repair_review の finding_type |
§5 マトリクス |
| 14 | repair_rounds |
integer(>= 0)| "unavailable" |
✅ | delivery._completed_rounds(entries, pr) の戻り値(再実装せず import) |
PR 番号が解決できなければ "unavailable"(§3 の警告) |
| 15 | replan_count |
integer | "unavailable" |
✅ | 供給元が main に存在しない | Phase 1 固定 "unavailable" |
| 16 | human_interventions |
array | "unavailable" |
✅ | arbiter record の decision=HUMAN_ESCALATED + record.jsonl の kind=state かつ state=HUMAN_ESCALATED + kind=notice entry |
"unavailable" |
| 17 | terminal_state |
string enum(3 値) | ✅ | §4 の正規化マッピング | 非終端は発行しない |
| 18 | quality_metrics |
object | "unavailable" |
✅ | 当該 run の events だけで閉じる指標のみ(§3 の許可指標) | "unavailable" |
| 19 | cost_metrics |
object | "unavailable" |
✅ | docs/working/_metrics/events.ndjson は .gitignore 対象で参照不能 |
Phase 1 固定 "unavailable" |
| 20 | evidence_refs |
array of string(repo 相対パスのみ) | ✅ | 注入値または record.jsonl 由来のみ(ディスク走査で列挙しない) |
空配列可 |
| 21 | schema_version |
string | ✅ | 本契約の版(§9) | fail-closed |
| 22 | observation |
string | — | 観測事実(何が起きたか) | 空文字可 |
| 23 | cause_hypothesis |
string | null | — | 推定(なぜ起きたか)。注入されたときのみ格納する | null |
| 24 | escalation |
array of object {kind, detail} |
— | 握り潰さずに記録すべき異常(未知 kind entry / privacy 違反入力 / 分類不能 record / 検査未実行 harness_drift_unchecked・§4-1) |
空配列 |
evidence_status は EV に格納しないevidence_status(complete / partial)は 受理器が導出する判定語彙であり、record に格納しない。
根拠は trust boundary(c3-prime-contract.md §7「decision 値を無検証で信頼してはならない」の転写)
— 生成側が自分の証跡の完全性を自己申告できる構造にしない。これにより required は 21 に閉じる。
terminal_state と evidence_status は直交するterminal_state=MERGE_READY かつ evidence_status=partial は正常な状態であり、
「run は終端に達した / だが証跡の一部が Phase 1 では取得不能」を意味する。
evidence_status を terminal_state の緩和・強化に使ってはならない。
observation と cause_hypothesis の分離(AC-5)observation = 観測事実のみ。events から機械的に導出できる範囲に限る。cause_hypothesis = 推定。producer は自動生成しない。注入されなければ null を出力する。docs/working/TASK-XXXX/approvals/c3.jsondocs/working/TASK-XXXX/delivery/record.jsonldocs/working/ai-loop-runs/*.json(arbiter record)⚠️ 1 に含まれる範囲の明確化(producer 実装で顕在化): producer は §6 の fail-closed 再検証で
c3prime_verify.main()を経由するため、同関数が読む 同一task_dir配下の Plan Package 6 artifact(pbi-input.md/plan.md/todo.md/test-cases.md/review-self.md/review-external.md)も 実際には open される。これは「c3-prime 束縛の再検証に必要な読み取り」であり ソース 1 の一部として扱う(task_dirの外へは出ない)。 入力ソース allowlist の本質は「task_dirとruns_dirの外を読まない」ことにある。
transcript / session log / hidden CoT / 環境変数 / ネットワーク / 外部プロセスは読まない
(delivery.py の「純判定器: ネットワーク・外部プロセスを一切呼ばない」原則の転写)。
これは AC-6 の「要求しない」側の担保であり、出力側の禁止キー検査(§7)とは別の防御線である。
run コンテキストの注入値 5 つ(当初の「5 つで全数」はこの 5 つを指す):
| 注入値 | 用途 | 未注入時の既定 |
|---|---|---|
--now |
completed_at |
エラー(fail-closed) |
--started-at |
started_at |
エラー(fail-closed) |
--repository |
repository |
エラー(fail-closed) |
--run-id |
run_id |
エラー(fail-closed) |
--pr-number |
record から解決した PR 番号との cross-check 専用 |
cross-check を行わない(repair_rounds の値は変えない) |
harness / 任意フィールドの注入値(§2 の供給元「注入」に対応。producer 実装で追補):
| 注入値 | 用途 | 未注入時の既定 |
|---|---|---|
--harness-version |
harness_version(object 3 値) |
エラー(fail-closed。§2 #10 と一致) |
--harness-version-end |
run 終了時の harness を開始時と byte 比較(AC-12) | drift 検査を実行せず escalation に harness_drift_unchecked を積む(受理器は partial 理由として列挙し complete にしない。§4-1) |
--routing-decisions |
routing_decisions の明示供給([] を含む) |
"unavailable"(§5-1 (a)) |
--observation |
observation の上書き |
events から機械導出 |
--cause-hypothesis |
cause_hypothesis |
null(自動生成しない / AC-5) |
--evidence-ref(repeat 可) |
evidence_refs[] への追加 |
c3.json / record.jsonl の 2 参照のみ |
⚠️
--pr-numberは「解決経路」ではなく「cross-check」である(producer 実装で確定): 受理器はkind=merge_readyentry のrecord.pr_numberからしか PR を再解決できない (§6-2 の再計算照合)。注入値だけを根拠にrepair_roundsを実値化すると 受理側が再計算で照合できず、「生成側の自己申告を信頼する」構造になる(§2-1 の trust boundary と矛盾)。 したがって producer は PR 番号をkind=merge_readyentry からのみ解決し、--pr-numberは record 由来の値との不一致検出(fail-closed)にのみ使う。 解決できない場合はrepair_rounds/ci_outcomes/review_findingsを"unavailable"に倒す。⚠️
--pr-numberを0に倒してはならない(fail-open 経路): 実測でdelivery._pr_receipts(entries, pr)はe.get("pr_number") == prで絞るため、pr=Noneではpr_numberを持たない entry だけが残り、_completed_rounds()はmax(rounds, default=0)により 例外ではなく0(= 修理 0 回)を黙って返す。 実在の一次証跡(TASK-0917 の e2erecord.jsonl)で_completed_rounds(entries, 940) = 1/_completed_rounds(entries, None) = 0を実測確認した。 PR 番号はkind=merge_readyentry のrecord.pr_numberから解決し、 解決できなければ"unavailable"に倒す(--pr-numberの役割は上表の cross-check)。
now() を直接参照しない。すべての timestamp は注入。producer のソースに datetime.now / time.time / utcnow が 0 件であること。c3_contract.canonical_hash() を import 再利用する(独自 hash 実装を作らない)。quality_metrics{} は当該 run の events だけで閉じる指標のみ。Phase 1 の許可指標は次の 2 つに限る。
first_pass: 当該 run の round_index == 1 の record の decision が AUTO_APPROVED かrounds: 当該 run の round 数decision_counts / round_distribution / hotl_health / first_pass_rate)を格納してはならない。これらは metrics.py の collect(runs_dir) が runs_dir 配下の全 record を横断集計した値であり、EV に載せると arbiter record が 1 件増えるだけで過去 run の EV の byte が変わる(AC-2 と golden byte 比較が後日 CI で原因不明に赤くなる)。metrics.py は import しない(不変対象への依存を増やさない)。上記 2 指標の導出規則のみ転写する。runs_dir 配下の走査は当該 run_id の record に絞る(quality_metrics だけの話ではない)。
escalation へ積む privacy 走査(scan_input_privacy)を corpus 全件に掛けると、
無関係な run の record が owner / login 等を 1 つ持つだけで escalation が伸び、
同一入力・同一注入値の EV の byte が変わる(実測で再現。AC-2 の破れ)。
human_interventions が run.run_id != run_id で絞っているのと同一の述語を使う。
絞り込みは検出の放棄ではない: 当該 run の record に account キーがあれば従来どおり記録する。evidence_refs[] をディスク走査で列挙しない。列挙は注入値または record.jsonl 由来の参照のみ。ディスク走査するとファイルの増減で同一 events から異なる EV が出る。--out <path> を指定した場合のみファイルへ書き、拡張子が .json でなければ reject する。kind entry の扱いdelivery.assess() が生成しない kind(実在例: kind=notice・executor.py 由来)を 握り潰さない。
escalation に「未知 kind」と該当 kind 値を記録したうえで処理を続ける。黙って無視して正常終了してはならない。
terminal_state 正規化マッピング(D3)issue #874 の 3 値は 2 つの状態機械の和集合であり、この 3 値をそのまま出す層は main に存在しない(実測)。
| 層 | 実測語彙 |
|---|---|
delivery.py |
STATES = 7 値(CHECKS_FAILED / CONFLICT / MERGE_READY / MERGE_READY_CANDIDATE / REVIEW_REPAIR / WAITING_FOR_CHECKS / WAITING_FOR_REVIEW)+ EXITS = 2 値(EXEC_RETURN / HUMAN_ESCALATED)。TERMINAL = "MERGE_READY" のみが終端 |
c3_contract.py |
VALID_DECISIONS = 3 値(AUTO_APPROVED / HUMAN_ESCALATED / BLOCKED) |
| issue #874 | MERGE_READY / HUMAN_ESCALATED / BLOCKED |
⇒ BLOCKED は delivery 層に存在せず、MERGE_READY は c3-prime 層に存在しない。
採用する正規化マッピング(本表を契約 doc / schema / producer が同一表として持つ):
terminal_state |
由来 | 条件 |
|---|---|---|
BLOCKED |
c3-prime 層 | c3.json の decision == "BLOCKED"(= exec に到達していない) |
HUMAN_ESCALATED |
両層 | c3.json.decision == "HUMAN_ESCALATED" または record.jsonl の最終 kind=state の state == "HUMAN_ESCALATED" |
MERGE_READY |
delivery 層 | record.jsonl に kind=merge_ready entry が物理的に存在する(delivery.assess() が state = "MERGE_READY" を刻む唯一の経路) |
| (発行しない) | — | 上記いずれにも該当しない = 非終端 7 状態(WAITING_FOR_CHECKS / WAITING_FOR_REVIEW / CHECKS_FAILED / CONFLICT / REVIEW_REPAIR / MERGE_READY_CANDIDATE / EXEC_RETURN) |
⚠️
MERGE_READY_CANDIDATEをMERGE_READYに丸めてはならない。 最終kind=stateを見て判定すると未収束 run が #869 の学習母集団と #811 の promotion 入力に混入する。 判定条件はkind=merge_readyentry の物理存在のみである。
harness_version(AC-12)harness_version は単一文字列にしない。実測で候補が 3 つあり値が一致しないため(bin/plangate = 0.2.0 /
plugin = 8.18.0 / LoopSpec 派生 hash = run ごとに変動)、object 3 値とする。
| key | 内容 |
|---|---|
plugin_version |
plugin / release 版 |
cli_version |
bin/plangate の版 |
corpus_hash |
判定基盤 corpus(§10 の carve-out ①②③)のファイル内容 hash を canonical_hash() で束ねた値 |
AC-12(active run 中に harness version が変化しない)は 3 値すべてについて、run 開始時注入値と 終了時の値の byte 一致で検証する。1 つでも不一致なら fail-closed(警告に降格しない)。
--harness-version-end 未注入)の扱いdrift 検査は --harness-version-end の注入が前提であり、未注入なら検査そのものが走らない。
これを黙って通すと、受理器も下流も 「検査して同一だった EV」と「検査していない EV」を区別できない。
baseline_version を EV から取り mixed_baseline で reject する下流(§8-1)にとっては、
run 中に harness が入れ替わった EV が「同一 baseline」として学習母集団に入る経路になる。
したがって producer は未注入時に escalation へ次を積む(検査が欠けていること自体を証跡に残す):
{"kind": "harness_drift_unchecked", "detail": "--harness-version-end 未注入のため AC-12 の drift 検査を実行していない"}
受理器は harness_drift_unchecked を partial 理由として stderr に列挙し、
他フィールドがすべて available でも exit 0(complete)を返さない(§6-4)。
これにより AC-12 は「caller が --harness-version-end を渡す善意」ではなく受理器側で担保される。
注入を必須(fail-closed)にしなかった理由:
--harness-versionと同様に必須化すると、 終了時 harness を取得できない経路(run が異常終了した後の事後発行等)でEVが 一切発行できなくなり、最も証跡が必要な run の記録が消える。escalation記録は fail-closed の思想(黙って通さない)を保ちつつ証跡を残す。escalationに積まれたkindは「検査した結果の記録」と「検査していないことの記録」を区別する: 前者(unknown_record_kind/privacy_*)はcompleteを妨げず、後者のみ partial に落とす。
terminal_state × フィールドの必須 / unavailable マトリクス| field | MERGE_READY |
HUMAN_ESCALATED |
BLOCKED |
|---|---|---|---|
final_head_sha |
必須(欠落 = fail-closed) | 必須 | unavailable |
ci_outcomes |
必須 | 取得できれば必須 / 無ければ unavailable |
unavailable |
review_findings |
必須 | 取得できれば必須 / 無ければ unavailable |
unavailable |
repair_rounds |
必須(PR 番号解決不能なら unavailable) |
同左 | unavailable |
quality_metrics |
必須 | repair_rounds に従属(不能なら unavailable) |
unavailable |
routing_decisions / replan_count / cost_metrics |
unavailable |
unavailable |
unavailable |
quality_metricsがrepair_roundsに従属する理由(producer 実装で確定): Phase 1 の許可指標first_pass/roundsはいずれも当該 run の round 数から導出する。repair_roundsがunavailableの run では round 数が取得不能であり、{"first_pass": false, "rounds": 0}と埋めるとunavailableを0で埋めることになる (本契約が最も避ける fail-open)。したがってquality_metrics全体を"unavailable"に倒す。
BLOCKEDが特別な理由:BLOCKEDはc3.json.decision == "BLOCKED"(= exec に到達していない)で 発行される終端であり、delivery/record.jsonl自体が存在しない。したがって delivery 層由来の 4 フィールドが構造的に取得不能になる。 空文字・ダミー sha・0で埋めてはならない(EVには自己 hash が無いため tampered 検出も効かない)。 逆に missing 扱いで reject してもならない(BLOCKEDrun の証跡が一切残らなくなる)。
| 分類 | 対象 | 件数 |
|---|---|---|
| (a) Phase 1 固定 | routing_decisions / replan_count / cost_metrics |
3(terminal_state に依存しない) |
(b) terminal_state 依存 |
final_head_sha / ci_outcomes / review_findings / repair_rounds / quality_metrics |
最大 5(BLOCKED で 5 件・他は 0〜5 件) |
⇒ BLOCKED run の unavailable は (a)3 + (b)5 = 8 件(producer 実装で確定。
当初の「7 件」は quality_metrics の従属を数えていなかった)。
HUMAN_ESCALATED で kind=merge_ready entry が無い run は
ci_outcomes / review_findings / repair_rounds / quality_metrics が unavailable で 3 + 4 = 7 件。
⇒ Phase 1 の producer 出力は必ず unavailable を含み、受理器は必ず partial を返す。
Phase 1 で evidence_status=complete(exit 0)は構造的に発生しない。
partial の理由は上記 2 分類(+ §4-1 の未検証 escalation)にまたがるため、曖昧化しない担保は
「理由が 1 種類であること」ではなく「理由が機械可読に全数列挙されること」に置く
(stderr に unavailable:<field> / unverified:<kind> の形式で全数出力する)。
受理器の exit 0 経路が死にコード化しないことは、unit test が合成した「全フィールド available な EV」
で担保する(fixture では 0 の経路を一度も通らないため)。
run_evidence_verify.py <ev.json> <task_dir>
姉妹受理器 c3prime_verify.py <task_dir> [expected_sha] と同型の task_dir 束縛にする。
⚠️
EV単体入力にしてはならない。sha256:+ 64 hex の形式を保ったplan_hashの 1 文字改変は 形式上は正当であり(EVに自己 hash が無い)検出できず、known-unavailable により exit 11(partial) で返る。partial は「ready 扱いしない」だけで拒否ではないため、改竄された provenance が promotion まで到達しうる。
EV フィールド |
照合先 |
|---|---|
task_id |
task_dir のディレクトリ名(c3prime_verify.py の task_dir.name != task_id 束縛と同型) |
plan_hash / source_sha |
<task_dir>/approvals/c3.json の同名フィールド |
c3_prime_decision_ref |
<task_dir>/approvals/c3.json への repo 相対参照として解決可能か |
final_head_sha / ci_outcomes / review_findings / repair_rounds |
<task_dir>/delivery/record.jsonl の再導出値(run_evidence.derive_delivery_fields() を import。delivery.load_entries() の entry_id 再計算照合と同型) |
quality_metrics |
上の repair_rounds 再導出値から run_evidence.derive_quality_metrics() で再導出 |
terminal_state |
c3.json の decision + record.jsonl から §4 の正規化マッピングで再導出(run_evidence.derive_terminal_state()) |
| 全フィールドの値 | §7 の privacy 検査(run_evidence.check_output_privacy() を import)+ schema の type / enum / pattern / minLength / minimum |
⚠️
final_head_sha/repair_roundsの 2 つだけを照合すると穴が空く(実装後の敵対レビューで顕在化):ci_outcomes(CI 失敗 → success)/review_findings(レビュー指摘 → dismissed)/quality_metrics(修理 1 回 →first_pass=true)/terminal_state(非終端 →MERGE_READY)を 書き換えたEVがcomplete(exit 0)で受理された。いずれもmr_record/entriesは 受理器が既に読んでいるため 追加 I/O ゼロで再導出できる。⚠️ 再導出は producer の純関数を import して行い、受理器側で再実装しない。 別実装を持つと producer と受理器が静かに drift し、「片方だけが正しい」状態が test 緑のまま成立する。入力は
task_dir配下の実ファイルのみでありEVの申告値を使わないため、trust boundary(§2-1)は保たれる。
terminal_state の enum は受理器が強制するschema の enum: ["MERGE_READY", "HUMAN_ESCALATED", "BLOCKED"] は、受理器が
required / 許可キー集合を キー名としてしか取り出さない実装では
どの層からも強制されない(.github/workflows/schema-validate.yml は
docs/schemas/** を対象外・§10-2)。したがって受理器は schema の
type / enum / pattern / minLength / minimum を subset validator で機械強制する。
MERGE_READY → BLOCKED)は
enum を通るため、§4 マッピングによる再導出値との一致まで要求する。
evidence_hashをEV自身に持たせる自己完結型は採らない。requiredが 21 → 22 になり §9 versioning policy に波及するうえ、「生成側が自分の証跡の完全性を自己申告する」構造となり §2-1 の trust boundary 方針と矛盾するため。
受理器は run-evidence.schema.json を読み、
required と許可キー集合を schema から導出する(ハードコードしない)。
c3prime_verify.py の unknown = [k for k in data if k not in ALLOWED_KEYS and not k.startswith("_")] の転写)^_ 注釈キーのみ許容。ただし値が string でなければ reject(if k.startswith("_") and not isinstance(v, str): return _fail(...) と同型)これを怠ると schema と受理器が乖離したまま全 TC が緑になり、Phase 2 で schemas/ へ昇格した瞬間に
既存 EV が一斉 reject される(§10 の「1 回の HO patch で昇格」が破綻する)。
| exit | c3prime_verify.py(既存) |
run_evidence_verify.py(本 PBI) |
|---|---|---|
0 |
c3-prime として受理 | EV として受理(evidence_status=complete・全束縛整合) |
1 |
検証 NG(fail-closed・理由を stderr) | 同左 |
2 |
(未使用) | 起動不能(schema を読めない = 検査そのものが実行できていない) |
10 |
legacy(approval_kind キーが物理的に無い) |
legacy(EV ではなく 9 キー / 14 キー arbiter record を渡された) |
11 |
(未使用) | partial(必須フィールドは揃うが unavailable または未検証 escalation(harness_drift_unchecked・§4-1)を含む = ready 扱いしない) |
2(起動不能)を1(NG)に混ぜてはならない(実装後の敵対レビューで顕在化): 受理器は schema を repo レイアウト(<repo>/docs/schemas/)と plugin の bundled レイアウト(<skill>/schemas/)の順に探索する。同梱が漏れると導入先で 常に検証 NG に見え、「改竄兆候」と「schema 同梱漏れ」を呼び出し側が区別できない。2は additive な追加であり0/1/10/11の意味論は変更していない(§9)。
10 の意味は両受理器で同一にする。同一ディレクトリの 2 受理器で 10 の意味が割れると、
将来 rc を共通ハンドラで扱った時点で legacy を partial と誤読する経路が生まれる。
⚠️ 消費側の強度は 2 箇所で異なる(実測):
delivery.pyはif rc == 10:の厳密比較だが、bin/plangateの_plangate_c3_dispatch後段はif [ "$_c3_rc" = "0" ] … elif [ "$_c3_rc" = "1" ] … elseという catch-all(値を判定せず 0/1 以外をすべて legacy にフォールバック)である。 ⇒ 本受理器の rc を_plangate_c3_dispatch経路へ流してはならない(11を流すと catch-all が legacy と誤読する)。
c3prime_verify rc==0 を要求する」と §4 マッピングの矛盾(確定済み)内部矛盾(TASK-0874 exec 前半で実測により顕在化・producer 実装で下記のとおり確定)。
c3prime_verify.main([_, task_dir, expected_sha]) を呼び rc==0 を要求する」と規定している。BLOCKED を c3.json.decision == "BLOCKED" から、HUMAN_ESCALATED を c3.json.decision == "HUMAN_ESCALATED" から導出すると規定している。この 2 つは同時に成立しない。実測(静的に決定的):
c3prime_verify.py の唯一の return 0 は関数末尾(L166)にあり、その手前の
if decision != "AUTO_APPROVED": return _fail(...)(L108・_fail は必ず 1 を返す)は無条件である。
⇒ rc == 0 は decision == "AUTO_APPROVED" を含意する。
したがって「rc==0 を要求」を文字どおり実装すると、terminal_state が BLOCKED / HUMAN_ESCALATED の
EV は構造的に 1 件も発行できず、fixture 4 / 5 と TC-58 が実装不能になる。
本契約の解釈(producer 実装時に確定させる方針): producer が要求するのは
§4 の構造・束縛規則(task_id 束縛 / plan_hash / artifact_hashes / plan_package_hash /
reviewer snapshot 整合)の再検証が通ることであり、decision の値そのものは検証結果ではなく
terminal_state の供給元として扱う。すなわち:
rc == 0 → decision == "AUTO_APPROVED"(delivery 層の判定へ進む)rc == 1 かつ理由が decision 値のみに起因する場合 → decision を §4 マッピングの入力として採用するrc == 1 かつ理由が 束縛不整合(hash / artifact / reviewer)→ fail-closed(EV を発行しない)rc == 10(legacy) → EV を発行しないdecision を無検証で信頼しないという §7 trust boundary は維持される(束縛検証は全数実施し、
decision の値だけを別扱いする)。
⚠️ この解釈だけでは束縛検証に穴が空く(実装で顕在化・是正済み):
c3prime_verifyはdecision != "AUTO_APPROVED"の時点でreturnするため、 その後段の検証(source_sha形式 /plan_hash/artifact_hashes/plan_package_hash/ reviewer snapshot 三つ組)が一度も実行されない。 「decision-only NG は続行」とだけ実装すると、decision=BLOCKEDのc3.json経由で 改竄されたplan_hashが素通りする。 ⇒ producer は decision-only NG のとき、c3_contractの同一プリミティブを import して (sha256_of_file/canonical_hash/check_snapshot_trio/ARTIFACTS) 後段の束縛を再検証する。検証ロジックを再実装せず、検証の総量も減らさない。 本経路は変異注入(後段再検証の削除)で kill されることを unit test で実証している。
EV の出力に以下のキーが 1 つも現れてはならない(producer 側で機械検査する)。
file_path / file_paths / stack_trace / stacktrace / command_output / stdout / stderr /
raw_response / raw_request / api_key / user_prompt / system_prompt / prompt_text / absolute_path
⚠️ 本一覧は契約 doc(
.md)側に置く。schema のpropertiesに禁止キー名を登録してはならない。 実測で EH-8(scripts/hooks/check-metrics-privacy.sh)はgrep -E '("file_path"|…)[[:space:]]*:'であり、"file_path":の形(JSON キー)だけが BLOCK 対象である(配列要素{"forbidden": ["file_path"]}は BLOCK されない)。 正しい制約は「JSON に書けば必ず BLOCK」ではなく 「propertiesのキーとして書くと BLOCK」。
EH-8 はキー名の grep のみで値を一切見ない(実測: {"file": "/var/folders/xx/tmpABC/foo.json"} は
PLANGATE_HOOK_STRICT=1 でも PASS)。したがって producer 側で以下を検査する。
^/ または /Users/ を含む文字列)が 0 件であること(evidence_refs 限定にしない)evidence_refs[] は repo 相対パスのみ(/ 始まりは reject)⚠️ 「producer 側の検査が唯一の防御線」にしてはならない(実装後の敵対レビューで是正): trust boundary(§2-1)の設計としては逆であり、producer を通さず手書きした
EV(owner 付きrepository/ 絶対パスのevidence_refs/escalation[].detail/@handleを含むobservation)が受理器を素通りした。check_output_privacy()は純関数なので 受理器も同じ関数を import して掛ける (再実装しない)。owner 付きrepositoryは schema のpattern: ^[^/]+$側でも捕捉する。 producer 側の検査は「入力を還元して出力を作る」責務、受理側の検査は「どこから来たEVでも privacy 違反を ready 扱いしない」責務であり、両方が必要である。
metrics.py の skipped は {"file": str(path), "reason": …} を記録し file は絶対パスになりうるが、
キー名が file(file_path ではない)ため EH-8 では捕捉できず素通りする。
転写先では repo 相対パスへ正規化するか EV に載せない。
repository と PR / コメント参照の還元(U-5)| 対象 | 保存する形 | 保存しない形 |
|---|---|---|
repository |
owner 除去済み repo 名(例: plangate) |
s977043/plangate |
| PR 参照 | PR 番号(例: 940) |
https://github.com/s977043/PlanGate/pull/940 |
| コメント参照 | コメント ID(例: 5140067809) |
…/pull/940#issuecomment-5140067809 |
⇒ 値レベルで github.com を含む URL・owner 名を保存しない。下流は番号から URL を再構成する。
これにより「account 識別子 0 件」と AC-11 の improvement_refs[](PR / commit の追跡)が両立する。
.json に固定する理由EH-8 の走査対象は case "$f" in *.json|*.ndjson) であり、*.jsonl と *.md は素通りする(実測)。
.json に固定することで禁止キー検査が hook 層で自動的に効く。
⚠️ ただし
tests/fixtures/配下だけは CI の自動強制が効かない(実測):.github/workflows/metrics-privacy.ymlの scan 対象決定はgit diff --name-only … | grep -E '\.(json|ndjson)$' | grep -v '^tests/fixtures/'でtests/fixtures/を明示除外しており、.claude/settings.example.jsonの hooks にも EH-8 は存在しない。 ⇒ 本 PBI が commit する golden fixture に対しては、tests/extras/の ta スクリプトの中からPLANGATE_HOOK_STRICT=1 PLANGATE_HOOK_FILES="<fixture パス>" sh scripts/hooks/check-metrics-privacy.shを実走させて回帰保護を持たせる(ta スクリプトはtests/run-tests.shの glob source 経由で CI job に必ず乗る)。
再実装しない。#869 の clustering も #811 の promotion decision table も本契約では作らず、 provenance の橋渡しのみを担う。
to_shadow_candidate_input()(#869 / AC-7 / AC-8)EV 側 |
candidate 側 | 備考 |
|---|---|---|
run_id[] |
source_run_ids |
#869 issue 本文の実測綴り |
harness_version |
baseline_version |
⚠️ baseline_harness_version という綴りは repo にも issue にも 0 件のため使わない |
observation |
observed_pattern |
— |
cause_hypothesis |
cause_hypothesis |
— |
insufficient_evidence を返す。EV 群の harness_version が混在する場合は candidate を生成せず mixed_baseline で reject する(baseline が定義できない run 群から候補を作らない)。EV 以外の I/O を持たない(AC-7「#869 が RunEvidence のみから shadow candidate を生成できる」の構造保証)。source_run_ids / baseline_version)が Phase 1 の最小契約であり、それ以外のフィールドは下流(#869)が確定・追加する境界とする(§0 U-8)。to_promotion_provenance()(#811 / AC-11 / AC-13)返す dict のキー(#811 Trust Ledger の実測綴り):
candidate_id / decision / promoted_to / evidence_count / canary_scope / rollback_count / improvement_refs
evidence_count == len(source_run_ids)improvement_refs[] は PR 番号と commit SHA([0-9a-f]{7,40})を保持し、source_run_ids と双方向に辿れること(§7-3 の還元形で保持する)AC-13 fail-closed(blocked_by[]):
| 入力の状態 | 判定 |
|---|---|
blocked_by が非空 |
BLOCKED |
blocked_by キーが物理的に存在しない(未注入) |
BLOCKED(判定不能は安全側へ倒す) |
blocked_by が list 以外(null / "" / 0 / {} 等) |
BLOCKED(型が違えば判定不能) |
blocked_by == [] を明示注入 |
非 BLOCKED(promotion 判定へ進む) |
⚠️ 判定は「キーの有無 + 真偽値」ではなく
isinstance(x, list)で行う(実装後の敵対レビューで是正):"blocked_by" not in candidate or bool(blocked_by)と書くと、キーは存在するが falsy (null/""/0/{})のときに非BLOCKEDへ倒れる。実測でdecision=PROMOTEDかつblocked_by="unavailable"— 「promotion 承認済み」と「阻害要因は取得不能」を同時に主張する 出力が生成された。nullは JSON 往復で最も出やすい値であり、本契約が最も避ける 「unavailableを ready 扱い」そのものである。返り値側がlist(blocked_by) if isinstance(blocked_by, list) else "unavailable"と型を見ているのだから、 判定側も同じ述語を使う(同一関数内の非対称を残さない)。「非空なら BLOCKED」だけを実装すると fail-open する: 誰も
blocked_byを埋めない限り常に非BLOCKEDになる。 「未解決なし」と解釈するのは 明示的に[]を注入した場合のみ(unavailableと空配列を区別する本契約の原則と同型)。 issue 番号をハードコードしてはならない(実測で #862 は CLOSED / #866 は OPEN であり、番号は CLOSE 時に stale 化する)。 誰がblocked_by[]を埋めるかは #811 の plan 確定時に見直す(§0 U-12)。
candidate から派生した improvement TASK の記述子は
plan_package_required == True / c3_prime_required == True / merge_by_ai == False を持ち、
通常ゲートを迂回するフラグ(skip_c3 / auto_merge 等)を持ってはならない。
EV は schema_version を required に持つ(optional にしない)。version 不明の record を受理すると versioning policy が機械的に無効化されるため。required の追加 / 型変更 / §4 正規化マッピングの変更 / exit code 意味論の変更)は
#872 / #873 / #874 の 3 issue 合意 + plan Replan を要する(c3-prime-contract.md §8 と同一規則)。⚠️ 前例との非対称を明記する: 構造前例として参照する
schemas/c3-prime.schema.jsonはschema_versionをpropertiesにもrequiredにも持たない(実測)。 一方schemas/*.schema.json28 本のうち 9 本がschema_versionをrequiredに持ち、propertiesに持ちながらrequiredから外している schema は 0 本である。 この非対称を明記しないと、schemas/への昇格レビューで「c3-prime に合わせてschema_versionを落とす」 是正が入り、versioning policy が事後的に無効化されうる。
docs/schemas/ → schemas/)Phase 1 では schema を docs/schemas/run-evidence.schema.json に置く。
$id は昇格後の URL https://github.com/s977043/plangate/schemas/run-evidence.schema.json で
先に固定しておき、昇格を git mv 1 手に収める($id の変更も不要)。
| 観点 | schemas/ |
docs/schemas/ |
|---|---|---|
| EH-3 の Hardening Override | 対象。ただし実効パターンは schemas/*.schema.json(1 階層・.schema.json 拡張子のみ) |
対象外 |
ho-paths.md の HO 表 |
schemas/**(全階層・全ファイル)と記載 ⇒ EH-3 の実効パターンより広い |
対象外 |
rollout-policy.md §2 判定基盤 carve-out ①②③ |
対象外 | 対象外(①scripts/ai-loop/** ②docs/workflows/ai-loop/**・docs/ai/ai-loop/** ③.agents/skills/ai-loop-cycle/**・.claude/skills/ai-loop-cycle/** のいずれにも含まれない) |
本契約 doc 自身は carve-out ② に含まれる(
docs/workflows/ai-loop/**)。 したがって本 doc を変更する ai-loop 自走は escalate 固定(auto-approve 不可)である。 一方 schema 本体(docs/schemas/)は carve-out に含まれない。この非対称は昇格時に解消される。
.github/workflows/schema-validate.yml の trigger paths は docs/working/**/*.json と schemas/**/*.json であり、docs/schemas/** を含まない。.github/workflows/metrics-privacy.yml の trigger paths は **/*.json であり docs/schemas/ も走査される(tests/fixtures/ のみが scan から除外される)。⇒ 「docs/schemas/ は CI に一切乗らない」と読むのは誤り。乗らないのは schema 検証 CI だけである。
| 層 | Phase 1 の状態 |
|---|---|
① 受理器(run_evidence_verify.py) |
強制する(§6-2-bis の subset validator。type / enum / pattern / minLength / minimum / required / additionalProperties / items / anyOf / $ref) |
② schema-validate.yml |
未強制。trigger paths が docs/schemas/** を含まない。.github/workflows/*.yml は Hardening Override 対象(Human-owned)のため本 PBI では変更しない |
| ③ jsonschema 参照実装との突合 | jsonschema 導入環境でのみ実行(未導入環境は skip)。subset validator が jsonschema と同判定であることを golden fixture 6 件 + 変異 6 件で照合する |
②③ は schema を schemas/ へ昇格する時点(§10)で解消する。昇格時に
schemas/**/*.json の trigger に自動的に乗り、tests/run-tests.sh 経路でも
jsonschema が導入された job から実行できる。それまでの実効的な強制は ① が担う。
c3-prime-contract.md §4 / §7 / §8delivery-state-machine.mdrollout-policy.md §2