From 37c6d3bdf244bd9c5b5c5ba53d50c4dfa61527db Mon Sep 17 00:00:00 2001 From: screenleon Date: Wed, 15 Jul 2026 12:05:02 +0900 Subject: [PATCH 1/5] CC-490 fail closed across memory maintenance --- BACKLOG.md | 2 +- CHANGELOG.md | 2 +- .../script-variable-consumers.tsv | 2 + docs/memory-system.md | 12 ++- scripts/lib/memory-dir.sh | 1 + scripts/lib/memory.sh | 19 ++++- scripts/lib/pmctl-context.sh | 4 + scripts/lib/pmctl-memory.sh | 57 +++++++++++--- scripts/test-pmctl-context.sh | 27 +++++++ scripts/test-pmctl-memory.sh | 76 ++++++++++++++----- 10 files changed, 165 insertions(+), 37 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index f3745d4d..2e4968d4 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -399,7 +399,7 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed **Immediate safety note**: 在本票落地前,`dispatch.memory_dir` 只能視為 single-repo/single-purpose config;多 repo 操作不得把它當成安全的 machine-wide default。測試一律用 `PM_DISPATCH_CONFIG_FILE` 隔離,不得讀取 operator live config。 -**Implementation (2026-07-14, in progress)**: config schema 改為 `memory.projects..dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。isolated regression 已覆蓋零 cross-project append;live config 已遷移為 pm-dispatch scoped mapping,JapanJob read-only probe 回到自身 legacy memory,config lint 0 issues。full-suite 與 gate 完成前本票維持 active。 +**Implementation (2026-07-15, gate pending)**: config schema 已改為 `memory.projects..dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。完善稽核另補上原實作未封閉的 compatibility fallback:`memory dir`、doctor、shard、rebuild-summary、direct `context --source memory` 與 installer/migrator discovery 現在共用 explicit-selection validity helper;invalid matched/env target 不得讀寫 legacy store,doctor 以 `resolution_issues` 回報來源與原因。isolated regression 已擴為三個 project key、project-scoped 四 host continuity、maintenance/context 零 fallback write;live read-only probe 證明 pm-dispatch 走 scoped config,JapanJob 與 qa-testing-rules 各回自身 legacy,config lint 0 issues。direct-impact suites 6/6 通過(memory 75、context 115、guards 301);authoritative full suite 與 gate 完成前本票維持 active。 **Acceptance**: 三 repo resolve 的 project key 與 memory dir 對應正確;跨 host 同 repo continuity 不退化;invalid matched config fail closed;unmatched repo 不使用 pm-dispatch memory;doctor/config diagnostics、memory/pm/guard regressions與 live migration evidence 全綠。 diff --git a/CHANGELOG.md b/CHANGELOG.md index f57cbca2..b72133fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Added -- **Project-scoped canonical memory config (CC-490).** Replaces the unsafe machine-wide `dispatch.memory_dir` selector with `memory.projects..dir`. `pmctl memory config set|migrate|lint` manages mappings atomically, diagnoses deprecated global config, and keeps unmatched repositories on their own legacy discovery or unavailable path. Strict resolution fails closed for invalid matched entries and legacy-global config; regression coverage proves an unmatched append cannot mutate another project's canonical store. +- **Project-scoped canonical memory config (CC-490).** Replaces the unsafe machine-wide `dispatch.memory_dir` selector with `memory.projects..dir`. `pmctl memory config set|migrate|lint` manages mappings atomically, diagnoses deprecated global config, and keeps unmatched repositories on their own legacy discovery or unavailable path. Invalid explicit selections now fail closed across resolve, context indexing, doctor, and mutating maintenance commands; regression coverage proves an unmatched append or invalid maintenance operation cannot mutate another project's canonical store. - **Memory substrate cross-tool location seam (CC-412, PR#352).** `find_memory_dir` now honors an explicit override with precedence `PM_MEMORY_DIR` env > `dispatch.memory_dir` config > `CLAUDE_CONFIG_DIR` convention — behavior is byte-identical to before when neither override is set. The injection layering is now documented in `docs/memory-system.md`: the portable core is the `pmctl context --source memory` retrieval API; injection is a per-tool adapter concern (Claude keeps its existing hook; codex/opencode/future hosts call the retrieval API directly). diff --git a/docs/architecture/script-variable-consumers.tsv b/docs/architecture/script-variable-consumers.tsv index 1f5dc877..8212cc05 100644 --- a/docs/architecture/script-variable-consumers.tsv +++ b/docs/architecture/script-variable-consumers.tsv @@ -142,6 +142,7 @@ PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR scripts/lib/pmctl-memory-config.sh production PM_CFG_* PM_CFG_LEGACY_MEMORY_DIR_INVALID scripts/lib/pmctl-config.sh production PM_CFG_* PM_CFG_LIFECYCLE scripts/lib/pmctl-config.sh production PM_CFG_* PM_CFG_LIFECYCLE scripts/lib/pmctl-dispatch.sh production +PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/memory.sh production PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/pmctl-config.sh production PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/pmctl-memory-config.sh production PM_CFG_* PM_CFG_MEMORY_CONFIG_STATUS scripts/lib/pmctl-memory.sh production @@ -149,6 +150,7 @@ PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/memory.sh production PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/pmctl-config.sh production PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/pmctl-memory-config.sh production PM_CFG_* PM_CFG_MEMORY_DIR scripts/lib/pmctl-memory.sh production +PM_CFG_* PM_CFG_MEMORY_DIR_INVALID scripts/lib/memory.sh production PM_CFG_* PM_CFG_MEMORY_DIR_INVALID scripts/lib/pmctl-config.sh production PM_CFG_* PM_CFG_MEMORY_DIR_INVALID scripts/lib/pmctl-memory.sh production PM_CFG_* PM_CFG_TIMEOUT adapters/claude/dispatch.sh production diff --git a/docs/memory-system.md b/docs/memory-system.md index 41a180d6..1e40b9f0 100644 --- a/docs/memory-system.md +++ b/docs/memory-system.md @@ -126,6 +126,10 @@ Report fields (ordered): `memory_dir`, `entry_count`, `memory_bytes`, `topics`/`priority`/`status`/`updated_at`/`repo_refs`; `repo_refs` may be `[]` but the key must exist), `issues_count`. `--json` emits a single object carrying `schema_version: 1`. Exit codes: `0` healthy, `1` issues found, `2` usage error. +When explicit canonical resolution is invalid, the report remains read-only and +returns one additive `resolution_issues` entry with the rejected source and +reason; it never inspects a legacy fallback as though that were the selected +project store. `cards_missing_fields` lists any not-yet-migrated card still lacking a required field — useful for spot-checking coverage even though write-time enforcement is @@ -205,9 +209,11 @@ after success is a no-op. Tests must still isolate operator config with `PM_DISPATCH_CONFIG_FILE`; they must never assume `~/.pm-dispatch/config` is absent. -An override is only honored when the target directory already exists; an -unset or nonexistent override falls through to the next tier, so existing -installs see byte-identical resolution with no environment changes. +An unset override falls through to the next tier. Once an environment override +or matching project-scoped config entry is present, however, an invalid or +missing target fails closed for `pmctl memory`, direct memory-context indexing, +and installer/migrator discovery. Mutating maintenance commands such as `shard` +and `rebuild-summary` therefore cannot redirect writes into a legacy store. At a **host-switch boundary**, use the stricter diagnostic contract instead of relying on that compatibility fallback: diff --git a/scripts/lib/memory-dir.sh b/scripts/lib/memory-dir.sh index 6500b0cc..eae13696 100644 --- a/scripts/lib/memory-dir.sh +++ b/scripts/lib/memory-dir.sh @@ -26,6 +26,7 @@ find_memory_dir() { local cwd="$1" project_key="" declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$cwd" 2>/dev/null || true)" declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key" + _pm_memory_explicit_selection_invalid && return 1 local override if override="$(_pm_memory_dir_override)"; then printf '%s' "$override"; return 0 diff --git a/scripts/lib/memory.sh b/scripts/lib/memory.sh index e7a66b97..d77cd0c3 100644 --- a/scripts/lib/memory.sh +++ b/scripts/lib/memory.sh @@ -13,8 +13,9 @@ encode_path() { # over PM_CFG_MEMORY_DIR (~/.pm-dispatch/config `dispatch.memory_dir`, only # populated when a caller has already run pm_config_load — hooks never do, # so this stays a plain variable read with zero file I/O on the hook path). -# Silently falls through (does not use the override) when the directory does -# not exist, matching the existing CLAUDE_ROUTING_LOG_DIR convention. +# This low-level selector ignores unavailable targets. Canonical CLI callers +# first use _pm_memory_explicit_selection_invalid so an explicit selection +# cannot fall through; legacy-only callers retain their discovery convention. _pm_memory_dir_override() { local d="${PM_MEMORY_DIR:-}" if [[ -n "$d" && -d "$d" ]]; then printf '%s' "$d"; return 0; fi @@ -23,6 +24,20 @@ _pm_memory_dir_override() { return 1 } +# Return success when an explicitly selected canonical-memory target is invalid. +# Callers that have loaded project config use this before compatibility discovery +# so one shared rule prevents fallback into another store. +_pm_memory_explicit_selection_invalid() { + if [[ -n "${PM_MEMORY_DIR:-}" && ( "${PM_MEMORY_DIR}" != /* || ! -d "${PM_MEMORY_DIR}" ) ]]; then + return 0 + fi + [[ "${PM_CFG_MEMORY_DIR_INVALID:-0}" -eq 0 ]] || return 0 + if [[ "${PM_CFG_MEMORY_CONFIG_STATUS:-none}" == "matched" && ! -d "${PM_CFG_MEMORY_DIR:-}" ]]; then + return 0 + fi + return 1 +} + find_memory_dir() { local override if override="$(_pm_memory_dir_override)"; then diff --git a/scripts/lib/pmctl-context.sh b/scripts/lib/pmctl-context.sh index 3c65e4b2..1a5a5707 100755 --- a/scripts/lib/pmctl-context.sh +++ b/scripts/lib/pmctl-context.sh @@ -80,6 +80,10 @@ _ctx_resolve_memory_dir() { declare -F find_memory_dir >/dev/null 2>&1 || return 1 declare -F pm_config_project_key >/dev/null 2>&1 && project_key="$(pm_config_project_key "$cwd" 2>/dev/null || true)" declare -F pm_config_load >/dev/null 2>&1 && pm_config_load "$project_key" + # Never let direct context commands turn an invalid explicit selector into a + # write beside another repository's legacy memory. Host entrypoints already + # use the strict resolver; this closes the standalone context path as well. + _pm_memory_explicit_selection_invalid && return 3 find_memory_dir "$cwd" 2>/dev/null } diff --git a/scripts/lib/pmctl-memory.sh b/scripts/lib/pmctl-memory.sh index 5d9fab99..586ad33c 100644 --- a/scripts/lib/pmctl-memory.sh +++ b/scripts/lib/pmctl-memory.sh @@ -63,6 +63,10 @@ pmctl_memory_dir() { local cwd="${1:-$PWD}" local mem_dir _pmctl_memory_load_config_override "$cwd" + if _pm_memory_explicit_selection_invalid; then + printf 'pmctl memory dir: explicit canonical memory selection is invalid\n' >&2 + return 3 + fi mem_dir="$(find_memory_dir "$cwd")" || return 1 printf '%s\n' "$mem_dir" } @@ -107,6 +111,24 @@ _pmctl_memory_emit_resolution() { fi } +# Resolve a canonical memory directory for mutating maintenance commands. These +# commands must not use find_memory_dir's compatibility fallback: an invalid +# explicit selection could otherwise redirect writes into a legacy store. +_pmctl_memory_require_resolved_dir() { + local repo_root="$1" operation="$2" resolution rc=0 reason + resolution="$(pmctl_memory_resolve --repo-root "$repo_root" --allow-non-git --json)" || rc=$? + if [[ "$rc" -ne 0 ]]; then + if [[ "$rc" -eq 1 ]]; then + printf 'pmctl memory %s: no memory directory found\n' "$operation" >&2 + return 1 + fi + reason="$(jq -r '.reason // .status // "unavailable"' <<<"$resolution" 2>/dev/null || printf 'unavailable')" + printf 'pmctl memory %s: canonical memory resolution failed: %s\n' "$operation" "$reason" >&2 + return "$rc" + fi + jq -er '.memory_dir // empty' <<<"$resolution" +} + # Strict, diagnostic resolver used at cross-host boundaries. Unlike the legacy # find_memory_dir API, an explicit but unavailable override is an error: host # switching must never appear successful while silently selecting other memory. @@ -495,10 +517,25 @@ pmctl_memory_doctor() { esac done - local mem_dir="" - _pmctl_memory_load_config_override "$repo_root" - if ! mem_dir="$(find_memory_dir "$repo_root")"; then - mem_dir="" + local mem_dir="" resolution="" resolution_rc=0 resolution_reason="" resolution_source="none" + resolution="$(pmctl_memory_resolve --repo-root "$repo_root" --allow-non-git --json)" || resolution_rc=$? + if [[ "$resolution_rc" -eq 0 ]]; then + mem_dir="$(jq -r '.memory_dir // empty' <<<"$resolution")" + elif [[ "$resolution_rc" -eq 3 ]]; then + resolution_reason="$(jq -r '.reason // "invalid explicit canonical memory configuration"' <<<"$resolution")" + resolution_source="$(jq -r '.resolution_source // "none"' <<<"$resolution")" + if [[ "$json" -eq 1 ]]; then + jq -cn --arg source "$resolution_source" --arg reason "$resolution_reason" \ + '{schema_version:1,memory_dir:"",entry_count:0,memory_bytes:0,episodes_bytes:0,shard_count:0,dead_links:[],orphan_cards:[],duplicate_hooks:[],stale_repo_refs:[],cards_missing_fields:[],resolution_issues:[{source:$source,reason:$reason}],issues_count:1}' + else + printf 'memory_dir: (invalid explicit configuration)\n' + printf 'resolution_issues:\n - %s: %s\n' "$resolution_source" "$resolution_reason" + printf 'issues_count: 1\n' + fi + return 1 + elif [[ "$resolution_rc" -eq 2 ]]; then + printf 'pmctl memory doctor: unable to resolve repository root\n' >&2 + return 2 fi # No memory dir → nothing to check; report an empty, healthy result. @@ -758,9 +795,9 @@ pmctl_memory_shard() { esac done - local mem_dir="" - _pmctl_memory_load_config_override "$repo_root" - mem_dir="$(find_memory_dir "$repo_root")" || { printf 'pmctl memory shard: no memory directory found\n' >&2; return 1; } + local mem_dir="" resolve_rc=0 + mem_dir="$(_pmctl_memory_require_resolved_dir "$repo_root" shard)" || resolve_rc=$? + [[ "$resolve_rc" -eq 0 ]] || return "$resolve_rc" local ep="$mem_dir/episodes.jsonl" [[ -f "$ep" ]] || { printf 'pmctl memory shard: no episodes.jsonl found\n'; return 0; } @@ -822,9 +859,9 @@ pmctl_memory_rebuild_summary() { esac done - local mem_dir="" - _pmctl_memory_load_config_override "$repo_root" - mem_dir="$(find_memory_dir "$repo_root")" || { printf 'pmctl memory rebuild-summary: no memory directory found\n' >&2; return 1; } + local mem_dir="" resolve_rc=0 + mem_dir="$(_pmctl_memory_require_resolved_dir "$repo_root" rebuild-summary)" || resolve_rc=$? + [[ "$resolve_rc" -eq 0 ]] || return "$resolve_rc" local ep="$mem_dir/episodes.jsonl" [[ -f "$ep" ]] || { printf 'pmctl memory rebuild-summary: no episodes.jsonl found\n'; return 0; } diff --git a/scripts/test-pmctl-context.sh b/scripts/test-pmctl-context.sh index f85b1a59..1eb6805b 100755 --- a/scripts/test-pmctl-context.sh +++ b/scripts/test-pmctl-context.sh @@ -2483,6 +2483,32 @@ MD fi } +# Behavior: a matched but unavailable config path cannot select legacy memory. +# Steps: expose both a missing scoped target and a populated legacy store, then +# query and index while asserting that no legacy context DB is created. +case_context_memory_invalid_config_no_legacy_fallback() { + local name="pmctl context memory source: invalid matched config never falls back to legacy memory" + should_run "$name" || return 0 + local repo="$tmp_root/mem-invalid-repo" claude="$tmp_root/mem-invalid-claude" + local config="$tmp_root/mem-invalid.conf" missing="$tmp_root/mem-invalid-missing" legacy + local query_out="$tmp_root/mem-invalid-query.out" index_status=0 + mkdir -p "$repo" + legacy="$(make_fixture_memory "$repo" "$claude")" + write_project_memory_config "$config" "$repo" "$missing" + + PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$claude" "$PMCTL" context query \ + "$repo" --source memory codex > "$query_out" 2>/dev/null || true + PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$claude" "$PMCTL" context index \ + "$repo" --source memory >/dev/null 2>&1 || index_status=$? + + if [[ "$index_status" -eq 1 ]] && grep -q '# no hits' "$query_out" \ + && [[ ! -e "$legacy/.pm-dispatch/context.db" ]]; then + pass "$name" + else + fail "$name" "index=$index_status query=$(<"$query_out") legacy_db=$([[ -e "$legacy/.pm-dispatch/context.db" ]] && printf yes || printf no)" + fi +} + case_context_query_source_memory_domain_rejected() { local name="pmctl context query: --domain with --source memory is rejected (exit 2)" should_run "$name" || return 0 @@ -3340,6 +3366,7 @@ case_context_query_source_all_merges case_context_query_source_repo_excludes_memory case_context_query_source_memory_no_dir_graceful case_context_query_source_memory_config_override +case_context_memory_invalid_config_no_legacy_fallback case_context_query_source_memory_domain_rejected case_context_query_source_invalid_rejected case_context_index_source_memory_builds_db diff --git a/scripts/test-pmctl-memory.sh b/scripts/test-pmctl-memory.sh index 6a629d0e..be46b93c 100755 --- a/scripts/test-pmctl-memory.sh +++ b/scripts/test-pmctl-memory.sh @@ -982,8 +982,8 @@ case_memory_dir_pm_memory_dir_outranks_config() { pass "$name" } -case_memory_dir_malformed_config_memory_dir_falls_through() { - local name="pmctl memory dir: malformed project-scoped path warns and falls through to legacy resolution" +case_memory_dir_malformed_config_memory_dir_fails_closed() { + local name="pmctl memory dir: malformed project-scoped path fails closed without legacy resolution" should_run "$name" || return 0 local cfg="$tmp_root/pmbad-cfg" repo="$tmp_root/pmbad-repo" fakehome="$tmp_root/pmbad-home" @@ -995,9 +995,9 @@ case_memory_dir_malformed_config_memory_dir_falls_through() { err="$tmp_root/pmbad.err" out="$(PM_DISPATCH_CONFIG_FILE="$fakehome/.pm-dispatch/config" CLAUDE_CONFIG_DIR="$cfg" "$PMCTL" memory dir "$repo" 2>"$err")" || status=$? - if ! assert_exit "$name" "$status" 0; then return 0; fi - if [[ "$out" != "$mdir" ]]; then - fail "$name" "expected fallback to legacy dir '$mdir' (malformed override must be ignored), got '$out'" + if ! assert_exit "$name" "$status" 3; then return 0; fi + if [[ -n "$out" ]]; then + fail "$name" "expected no resolved directory, got '$out' instead of rejecting legacy '$mdir'" return 0 fi if ! grep -q 'malformed value for memory.projects.' "$err"; then @@ -1744,17 +1744,19 @@ case_memory_doctor_shard_count() { pass "$name" } -# Behavior: every host appends through the same strict canonical episode path. -# Steps: append one marker per host to an env-selected memory dir and assert JSONL/provenance. +# Behavior: every host appends through the same project-scoped canonical path. +# Steps: append one marker per host through one scoped config and assert JSONL/provenance. case_memory_append_episode_cross_host_contract() { local name="pmctl memory append-episode: Claude Codex OpenCode and generic share canonical path" should_run "$name" || return 0 - local repo="$tmp_root/append-cross-host-repo" mdir="$tmp_root/append-cross-host-memory" host out status=0 + local repo="$tmp_root/append-cross-host-repo" mdir="$tmp_root/append-cross-host-memory" + local config="$tmp_root/append-cross-host.conf" host out status=0 mkdir -p "$repo" "$mdir" git -C "$repo" init -q + write_project_memory_config "$config" "$repo" "$mdir" for host in claude codex opencode generic; do - out="$(PM_MEMORY_DIR="$mdir" "$PMCTL" memory append-episode --repo-root "$repo" --host "$host" \ - --session-id "session-$host" --date "2026-07-13T00:00:00Z" --summary "cc483-${host}-canonical-marker" --json 2>/dev/null)" || status=$? + out="$(PM_DISPATCH_CONFIG_FILE="$config" "$PMCTL" memory append-episode --repo-root "$repo" --host "$host" \ + --session-id "session-$host" --date "2026-07-13T00:00:00Z" --summary "${host}-canonical-marker" --json 2>/dev/null)" || status=$? if [[ "$status" -ne 0 ]] || ! jq -e --arg host "$host" --arg mdir "$mdir" \ '.provider == "pmctl" and .authority == "canonical" and .writer_host == $host and .memory_dir == $mdir and .episode.writer_host == $host' <<<"$out" >/dev/null; then fail "$name" "host=$host status=$status out=$out"; return 0 @@ -1930,28 +1932,61 @@ case_memory_append_episode_refuses_symlink_lock_dir() { case_memory_config_project_isolation() { local name="pmctl memory config: project mapping prevents cross-project resolve and append" should_run "$name" || return 0 - local repo_a="$tmp_root/config-project-a" repo_b="$tmp_root/config-project-b" - local mem_a="$tmp_root/config-memory-a" cfg="$tmp_root/project-config" empty_claude="$tmp_root/project-empty-claude" + local repo_a="$tmp_root/config-project-a" repo_b="$tmp_root/config-project-b" repo_c="$tmp_root/config-project-c" + local mem_a="$tmp_root/config-memory-a" mem_b="$tmp_root/config-memory-b" + local cfg="$tmp_root/project-config" empty_claude="$tmp_root/project-empty-claude" local before after out status=0 - mkdir -p "$repo_a" "$repo_b" "$mem_a" "$empty_claude/projects" - git -C "$repo_a" init -q; git -C "$repo_b" init -q + mkdir -p "$repo_a" "$repo_b" "$repo_c" "$mem_a" "$mem_b" "$empty_claude/projects" + git -C "$repo_a" init -q; git -C "$repo_b" init -q; git -C "$repo_c" init -q printf 'sentinel = keep\n' > "$cfg" PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config set --repo-root "$repo_a" --memory-dir "$mem_a" --json > "$tmp_root/config-set.json" - before="$(find "$mem_a" -type f -printf '%P:%s:%T@\n' | sort)" - out="$(PM_DISPATCH_CONFIG_FILE="$cfg" CLAUDE_CONFIG_DIR="$empty_claude" "$PMCTL" memory resolve --repo-root "$repo_b" --json 2>/dev/null)" || status=$? + PM_DISPATCH_CONFIG_FILE="$cfg" "$PMCTL" memory config set --repo-root "$repo_b" --memory-dir "$mem_b" --json >/dev/null + before="$(find "$mem_a" "$mem_b" -type f -printf '%p:%s:%T@\n' | sort)" + out="$(PM_DISPATCH_CONFIG_FILE="$cfg" CLAUDE_CONFIG_DIR="$empty_claude" "$PMCTL" memory resolve --repo-root "$repo_c" --json 2>/dev/null)" || status=$? PM_DISPATCH_CONFIG_FILE="$cfg" CLAUDE_CONFIG_DIR="$empty_claude" "$PMCTL" memory append-episode \ - --repo-root "$repo_b" --host codex --summary cross-project-must-fail >/dev/null 2>&1 || true - after="$(find "$mem_a" -type f -printf '%P:%s:%T@\n' | sort)" + --repo-root "$repo_c" --host codex --summary cross-project-must-fail >/dev/null 2>&1 || true + after="$(find "$mem_a" "$mem_b" -type f -printf '%p:%s:%T@\n' | sort)" if [[ "$status" -eq 1 && "$before" == "$after" ]] \ && jq -e '.status == "unavailable" and .resolution_source == "none"' <<<"$out" >/dev/null \ && grep -q '^sentinel = keep$' "$cfg" \ - && grep -q "^memory.projects.$(memory_fixture_project_key "$repo_a").dir = $mem_a$" "$cfg"; then + && grep -q "^memory.projects.$(memory_fixture_project_key "$repo_a").dir = $mem_a$" "$cfg" \ + && grep -q "^memory.projects.$(memory_fixture_project_key "$repo_b").dir = $mem_b$" "$cfg"; then pass "$name" else fail "$name" "status=$status out=$out before=[$before] after=[$after] config=$(<"$cfg")" fi } +# Behavior: every maintenance surface rejects an invalid matched mapping rather +# than reading or writing the repository's otherwise discoverable legacy store. +# Steps: configure a missing scoped target over a populated legacy store, invoke +# doctor, dir, shard, and rebuild-summary, then assert diagnostics and zero writes. +case_memory_config_invalid_maintenance_no_fallback() { + local name="pmctl memory config: invalid matched mapping blocks doctor and maintenance fallback" + should_run "$name" || return 0 + local repo="$tmp_root/config-maint-repo" claude="$tmp_root/config-maint-claude" + local config="$tmp_root/config-maint.conf" missing="$tmp_root/config-maint-missing" legacy + local doctor="$tmp_root/config-maint-doctor.json" status_doctor=0 status_dir=0 status_shard=0 status_rebuild=0 + mkdir -p "$repo"; git -C "$repo" init -q + legacy="$(make_fixture_memory "$claude" "$repo")" + printf '{"date":"2026-01-01","summary":"legacy sentinel"}\n' > "$legacy/episodes.jsonl" + write_project_memory_config "$config" "$repo" "$missing" + + PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$claude" "$PMCTL" memory doctor --repo-root "$repo" --json > "$doctor" 2>/dev/null || status_doctor=$? + PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$claude" "$PMCTL" memory dir "$repo" >/dev/null 2>&1 || status_dir=$? + PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$claude" "$PMCTL" memory shard --repo-root "$repo" >/dev/null 2>&1 || status_shard=$? + PM_DISPATCH_CONFIG_FILE="$config" CLAUDE_CONFIG_DIR="$claude" "$PMCTL" memory rebuild-summary --repo-root "$repo" >/dev/null 2>&1 || status_rebuild=$? + + if [[ "$status_doctor" -eq 1 && "$status_dir" -eq 3 && "$status_shard" -eq 3 && "$status_rebuild" -eq 3 ]] \ + && jq -e '.issues_count == 1 and .resolution_issues[0].source == "config" and (.resolution_issues[0].reason | contains("does not exist"))' "$doctor" >/dev/null \ + && [[ ! -e "$legacy/episodes.summary.md" ]] \ + && [[ -z "$(find "$legacy" -maxdepth 1 -name 'episodes.????-??.jsonl' -print -quit)" ]]; then + pass "$name" + else + fail "$name" "doctor=$status_doctor dir=$status_dir shard=$status_shard rebuild=$status_rebuild report=$(<"$doctor")" + fi +} + # Behavior: deprecated global config is diagnostic and fail-closed until one # explicit repository migration atomically replaces it with a scoped entry. case_memory_config_legacy_migration() { @@ -2051,6 +2086,7 @@ case_memory_append_episode_refuses_symlink case_memory_append_episode_symlink_swap_race case_memory_append_episode_refuses_symlink_lock_dir case_memory_config_project_isolation +case_memory_config_invalid_maintenance_no_fallback case_memory_config_legacy_migration case_memory_config_lint_and_matched_fail_closed case_memory_config_linked_worktree_identity @@ -2062,7 +2098,7 @@ case_memory_dir_pm_memory_dir_env_override case_memory_dir_pm_memory_dir_unset_byte_identical case_memory_dir_config_dispatch_memory_dir_override case_memory_dir_pm_memory_dir_outranks_config -case_memory_dir_malformed_config_memory_dir_falls_through +case_memory_dir_malformed_config_memory_dir_fails_closed case_memory_dir_no_mutation case_memory_doctor_clean_fixture case_memory_doctor_dead_link From 92d0efef668bfd263ff32cd38f5bef5227f329c1 Mon Sep 17 00:00:00 2001 From: screenleon Date: Wed, 15 Jul 2026 12:24:12 +0900 Subject: [PATCH 2/5] test: cover memory discovery fail-closed consumers --- scripts/test-doctor.sh | 34 +++++++++++++++++++++++++++++ scripts/test-migrate-routing-log.sh | 31 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/scripts/test-doctor.sh b/scripts/test-doctor.sh index 0e131e28..ea49b8ad 100755 --- a/scripts/test-doctor.sh +++ b/scripts/test-doctor.sh @@ -8,6 +8,8 @@ DOCTOR="$REPO_ROOT/scripts/doctor.sh" # shellcheck source=scripts/lib/test-harness.sh . "$SCRIPT_DIR/lib/test-harness.sh" th_init "$@" +# shellcheck source=scripts/lib/test-memory-config-fixtures.sh +. "$SCRIPT_DIR/lib/test-memory-config-fixtures.sh" # check_codex/check_claude FAIL when an executor CLI is present but # unauthenticated. The many stub-claude/codex tests below model a HEALTHY @@ -551,6 +553,37 @@ case_doctor_repo_from_different_cwd() { fi } +case_doctor_invalid_project_memory_does_not_fallback() { + # Verifies that an unavailable project-scoped memory selection does not let + # doctor report a populated legacy Claude memory directory as healthy. + # + # Steps: + # 1. Populate the legacy memory directory for REPO_ROOT. + # 2. Configure REPO_ROOT to use a missing project-scoped memory directory. + # 3. Run doctor and assert memory-dir warns without reporting the legacy path. + local name="doctor-invalid-project-memory-does-not-fallback" + should_run "$name" || return 0 + local home="$tmp_root/home-invalid-project-memory" config="$tmp_root/invalid-project-memory.config" + local missing="$tmp_root/missing-project-memory" legacy out status=0 path encoded + write_full_settings "$home" + write_manifest "$home" + # shellcheck source=scripts/lib/memory.sh + . "$REPO_ROOT/scripts/lib/memory.sh" + encoded="$(encode_path "$REPO_ROOT")" + legacy="$home/.claude/projects/$encoded/memory" + mkdir -p "$legacy" + write_project_memory_config "$config" "$REPO_ROOT" "$missing" + path="$(make_stub_bin "$tmp_root/bin-invalid-project-memory" claude codex)" + + out="$(PM_DISPATCH_CONFIG_FILE="$config" HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" \ + bash "$DOCTOR" --no-color --repo "$REPO_ROOT" 2>&1)" || status=$? + if [[ "$out" == *"[WARN] memory-dir"* && "$out" != *"memory directory exists: $legacy"* ]]; then + pass "$name" + else + fail "$name" "expected fail-closed memory-dir warning; status=$status out=$out" + fi +} + case_doctor_frontmatter_lint_ok() { # Verifies that the frontmatter-lint check passes on the real repo's agents/. # @@ -2082,6 +2115,7 @@ case_doctor_quiet_no_ok_lines case_doctor_jq_missing_exits_1 case_doctor_warn_only_exits_0 case_doctor_repo_from_different_cwd +case_doctor_invalid_project_memory_does_not_fallback case_doctor_frontmatter_lint_ok case_doctor_help_exits_0 case_doctor_unknown_flag diff --git a/scripts/test-migrate-routing-log.sh b/scripts/test-migrate-routing-log.sh index ab7aebcc..2a28cf33 100755 --- a/scripts/test-migrate-routing-log.sh +++ b/scripts/test-migrate-routing-log.sh @@ -8,6 +8,8 @@ MIGRATOR="$SCRIPT_DIR/migrate-routing-log.sh" # shellcheck source=scripts/lib/test-harness.sh . "$SCRIPT_DIR/lib/test-harness.sh" th_init "$@" +# shellcheck source=scripts/lib/test-memory-config-fixtures.sh +. "$SCRIPT_DIR/lib/test-memory-config-fixtures.sh" # Migrator fixtures must not inherit the operator's project memory config. export PM_DISPATCH_CONFIG_FILE="$tmp_root/no-operator-config" @@ -256,6 +258,34 @@ test_pm_memory_dir_outranks_routing_log_dir() { fi } +# Behavior: An unavailable project-scoped memory selection prevents migration +# from falling back to a populated legacy Claude memory directory. +# Steps: +# 1. Populate a legacy routing_log.md for an isolated project path. +# 2. Configure that project to use a missing project-scoped memory directory. +# 3. Verify migration fails and leaves the legacy file and backup state untouched. +test_invalid_project_memory_does_not_fallback() { + local name="migrate: invalid project memory does not fall back to legacy" + should_run "$name" || return 0 + local repo="$tmp_root/m9-repo" home="$tmp_root/m9-home" config="$tmp_root/m9.config" + local missing="$tmp_root/m9-missing" legacy encoded out status=0 + mkdir -p "$repo" + encoded="-$(printf '%s' "${repo#/}" | tr '/' '-')" + legacy="$home/.claude/projects/$encoded/memory/routing_log.md" + write_fixture "$legacy" + cp "$legacy" "$legacy.before" + write_project_memory_config "$config" "$repo" "$missing" + + out="$(PM_DISPATCH_CONFIG_FILE="$config" HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" \ + "$MIGRATOR" --cwd "$repo" 2>&1)" || status=$? + if [[ "$status" -ne 0 && "$out" == *"failed to discover"* \ + && cmp -s "$legacy" "$legacy.before" && [[ ! -e "$legacy.bak" ]]; then + pass "$name" + else + fail "$name" "status=$status out=$out" + fi +} + test_fresh_migrates test_idempotent test_existing_backup_aborts @@ -264,5 +294,6 @@ test_legacy_integrity test_routing_log_dir_override test_pm_memory_dir_override test_pm_memory_dir_outranks_routing_log_dir +test_invalid_project_memory_does_not_fallback th_summary From 9952de206b4bd70b311ec345b9ebfdd72c064961 Mon Sep 17 00:00:00 2001 From: screenleon Date: Wed, 15 Jul 2026 12:29:33 +0900 Subject: [PATCH 3/5] test: correct fail-closed consumer assertions --- scripts/test-doctor.sh | 3 ++- scripts/test-migrate-routing-log.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/test-doctor.sh b/scripts/test-doctor.sh index ea49b8ad..6b1ff522 100755 --- a/scripts/test-doctor.sh +++ b/scripts/test-doctor.sh @@ -577,7 +577,8 @@ case_doctor_invalid_project_memory_does_not_fallback() { out="$(PM_DISPATCH_CONFIG_FILE="$config" HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" \ bash "$DOCTOR" --no-color --repo "$REPO_ROOT" 2>&1)" || status=$? - if [[ "$out" == *"[WARN] memory-dir"* && "$out" != *"memory directory exists: $legacy"* ]]; then + if [[ "$out" == *"[WARN] no memory directory for current path"* \ + && "$out" != *"memory directory exists: $legacy"* ]]; then pass "$name" else fail "$name" "expected fail-closed memory-dir warning; status=$status out=$out" diff --git a/scripts/test-migrate-routing-log.sh b/scripts/test-migrate-routing-log.sh index 2a28cf33..1fef2025 100755 --- a/scripts/test-migrate-routing-log.sh +++ b/scripts/test-migrate-routing-log.sh @@ -278,7 +278,7 @@ test_invalid_project_memory_does_not_fallback() { out="$(PM_DISPATCH_CONFIG_FILE="$config" HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" \ "$MIGRATOR" --cwd "$repo" 2>&1)" || status=$? - if [[ "$status" -ne 0 && "$out" == *"failed to discover"* \ + if [[ "$status" -ne 0 && "$out" == *"failed to discover"* ]] \ && cmp -s "$legacy" "$legacy.before" && [[ ! -e "$legacy.bak" ]]; then pass "$name" else From ba2179ff52bde86a9ed06f01ec7dda006926a6c2 Mon Sep 17 00:00:00 2001 From: screenleon Date: Wed, 15 Jul 2026 13:34:31 +0900 Subject: [PATCH 4/5] docs: close CC-490 backlog entry --- BACKLOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 2e4968d4..beec867c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,7 +23,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-464 | 🟢 someday | `pmctl ticket draft --from `:隨手筆記→結構化 backlog 票草稿;依賴 CC-286(prefix-generic next-id,⏸ deferred 尚未排程);review-first 邊界獨立設計,CC-054 僅供鬆散參照非直接前例(2026-07-07 openyida 跨專案分析) | ux/process | 2026-07-07 | — | P3 | — | | CC-486 | ⏸ deferred | direct-impact test planner mapping 提前退出:changed path 含 `agents/*.md`/`commands/*.md` 時 `map_path` 呼叫未註冊的 `lint-frontmatter`,`add_suite` 回傳 1 並在 `set -e` 下無輸出終止,導致 `run-tests.sh --base ... --list` exit 1 | ops/test | 2026-07-13 | feedback:2026-07-13 | P2 | hygiene | | CC-489 | 🔵 active | `scripts/` domain ownership 重整:先收斂 module ABI、變數/config ownership 與 side-effect boundary,再將 host/runtime/ops/test entrypoints 以 manifest/registry 與相容 shim 分批遷移 | arch | 2026-07-14 | feedback:2026-07-15 | P2 | design | -| CC-490 | 🔵 active | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design | +| CC-490 | ✅ done | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design | | CC-491 | 🔵 active | PR-gate pre-flight 機械式 evidence contract:傳遞 command、selected suites、逐項結果與 tree fingerprint,讓 reviewer reuse 已驗證結果並禁止無條件重跑 | ops/gate | 2026-07-14 | feedback:2026-07-14 | P1 | design | | CC-493 | 🟢 someday | Prompt→Skill→Command→Harness 升級規則文件化:可測試的分類判準(何時停在 prompt、何時升為 skill、何時做成 command、何時需要 harness-level hook/guard/state),並盤點 `commands/`/`skills/`/`agents/` 現況對照分類(2026-07-15 CC-489 三方 multi-model synthesis) | process/docs | 2026-07-15 | feedback:2026-07-15 | P2 | design | | CC-494 | 🟢 someday | design: executor 局部設計裁量權 envelope——在 dispatch brief / executor contract 定義「可自行處理的局部設計」與「必須 halt 回報 PM」的邊界(例如新增 schema 欄位 `design_latitude`/`architectural_conflicts`);三方 multi-model synthesis 2:1 分歧(codex/fable 認為現行邊界過度僵硬需要新機制,opencode 認為現行 `isolation_level`/executor 欄位已足夠彈性),本票僅追蹤決策、不預設結論(2026-07-15) | schema/process | 2026-07-15 | feedback:2026-07-15 | P3 | design | @@ -385,7 +385,7 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed --- -## CC-490 — project-scoped explicit memory config,避免跨 repo canonical bleed 🔵 active +## CC-490 — project-scoped explicit memory config,避免跨 repo canonical bleed ✅ 2026-07-15 **Problem**: `~/.pm-dispatch/config` 的 `dispatch.memory_dir` 是全域單值,但 resolver 對每個 repo 都無條件套用。CC-488 gate R1 後的 live read-only probe 已確認:pm-dispatch、JapanJob、qa-testing-rules 雖有不同 stable `project_key`,三者目前都回報 `resolution_source=config` 並解析到 pm-dispatch 的 canonical memory dir。任何其他 repo 的 canonical append 因此可能成功但寫入錯誤專案,屬靜默 cross-project data bleed;同一問題亦使本機 fixture 在未隔離 config 時誤寫 live store。 @@ -399,10 +399,12 @@ _Terminal_ (CC-378: swept OUT to `BACKLOG-ARCHIVE.md` by `scripts/archive-closed **Immediate safety note**: 在本票落地前,`dispatch.memory_dir` 只能視為 single-repo/single-purpose config;多 repo 操作不得把它當成安全的 machine-wide default。測試一律用 `PM_DISPATCH_CONFIG_FILE` 隔離,不得讀取 operator live config。 -**Implementation (2026-07-15, gate pending)**: config schema 已改為 `memory.projects..dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。完善稽核另補上原實作未封閉的 compatibility fallback:`memory dir`、doctor、shard、rebuild-summary、direct `context --source memory` 與 installer/migrator discovery 現在共用 explicit-selection validity helper;invalid matched/env target 不得讀寫 legacy store,doctor 以 `resolution_issues` 回報來源與原因。isolated regression 已擴為三個 project key、project-scoped 四 host continuity、maintenance/context 零 fallback write;live read-only probe 證明 pm-dispatch 走 scoped config,JapanJob 與 qa-testing-rules 各回自身 legacy,config lint 0 issues。direct-impact suites 6/6 通過(memory 75、context 115、guards 301);authoritative full suite 與 gate 完成前本票維持 active。 +**Implementation (2026-07-15)**: config schema 已改為 `memory.projects..dir`;`pmctl memory config set|migrate|lint` 提供原子管理、legacy-global 診斷與可重複 migration。strict resolver 對舊 global key 回報 `config-legacy-global` / exit 3,matched invalid path 維持 fail closed,unmatched repo 回到自身 legacy 或 unavailable。完善稽核另補上原實作未封閉的 compatibility fallback:`memory dir`、doctor、shard、rebuild-summary、direct `context --source memory` 與 installer/migrator discovery 現在共用 explicit-selection validity helper;invalid matched/env target 不得讀寫 legacy store,doctor 以 `resolution_issues` 回報來源與原因。isolated regression 已擴為三個 project key、project-scoped 四 host continuity、maintenance/context 零 fallback write;live read-only probe 證明 pm-dispatch 走 scoped config,JapanJob 與 qa-testing-rules 各回自身 legacy,config lint 0 issues。Claude standard gate `gate-20260715-032942-90cd0b` 使用明確 `--test-cmd` 後 GO(critic approve、qa-tester pass、architecture-reviewer approve);gate 後 authoritative full suite 79 passed、0 failed、0 skipped。 **Acceptance**: 三 repo resolve 的 project key 與 memory dir 對應正確;跨 host 同 repo continuity 不退化;invalid matched config fail closed;unmatched repo 不使用 pm-dispatch memory;doctor/config diagnostics、memory/pm/guard regressions與 live migration evidence 全綠。 +**See**: pr:#406 + **Source**: CC-488 Claude full-tier gate R1 risk/QA findings;2026-07-14 live probe confirmed JapanJob (`01a9ed...`) and qa-testing-rules (`100334...`) both resolved to pm-dispatch memory through the global config value. --- From c533929744c034fdf961f90f0883e479b0df3a17 Mon Sep 17 00:00:00 2001 From: screenleon Date: Wed, 15 Jul 2026 13:35:13 +0900 Subject: [PATCH 5/5] docs: link CC-490 backlog row to PR --- BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BACKLOG.md b/BACKLOG.md index beec867c..004fdf27 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,7 +23,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-464 | 🟢 someday | `pmctl ticket draft --from `:隨手筆記→結構化 backlog 票草稿;依賴 CC-286(prefix-generic next-id,⏸ deferred 尚未排程);review-first 邊界獨立設計,CC-054 僅供鬆散參照非直接前例(2026-07-07 openyida 跨專案分析) | ux/process | 2026-07-07 | — | P3 | — | | CC-486 | ⏸ deferred | direct-impact test planner mapping 提前退出:changed path 含 `agents/*.md`/`commands/*.md` 時 `map_path` 呼叫未註冊的 `lint-frontmatter`,`add_suite` 回傳 1 並在 `set -e` 下無輸出終止,導致 `run-tests.sh --base ... --list` exit 1 | ops/test | 2026-07-13 | feedback:2026-07-13 | P2 | hygiene | | CC-489 | 🔵 active | `scripts/` domain ownership 重整:先收斂 module ABI、變數/config ownership 與 side-effect boundary,再將 host/runtime/ops/test entrypoints 以 manifest/registry 與相容 shim 分批遷移 | arch | 2026-07-14 | feedback:2026-07-15 | P2 | design | -| CC-490 | ✅ done | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | feedback:2026-07-14 | P1 | design | +| CC-490 | ✅ done | project-scoped explicit memory config:取代全域單值 `dispatch.memory_dir`,避免多 repo 靜默共用 pm-dispatch canonical store | arch/memory | 2026-07-14 | pr:#406 | P1 | design | | CC-491 | 🔵 active | PR-gate pre-flight 機械式 evidence contract:傳遞 command、selected suites、逐項結果與 tree fingerprint,讓 reviewer reuse 已驗證結果並禁止無條件重跑 | ops/gate | 2026-07-14 | feedback:2026-07-14 | P1 | design | | CC-493 | 🟢 someday | Prompt→Skill→Command→Harness 升級規則文件化:可測試的分類判準(何時停在 prompt、何時升為 skill、何時做成 command、何時需要 harness-level hook/guard/state),並盤點 `commands/`/`skills/`/`agents/` 現況對照分類(2026-07-15 CC-489 三方 multi-model synthesis) | process/docs | 2026-07-15 | feedback:2026-07-15 | P2 | design | | CC-494 | 🟢 someday | design: executor 局部設計裁量權 envelope——在 dispatch brief / executor contract 定義「可自行處理的局部設計」與「必須 halt 回報 PM」的邊界(例如新增 schema 欄位 `design_latitude`/`architectural_conflicts`);三方 multi-model synthesis 2:1 分歧(codex/fable 認為現行邊界過度僵硬需要新機制,opencode 認為現行 `isolation_level`/executor 欄位已足夠彈性),本票僅追蹤決策、不預設結論(2026-07-15) | schema/process | 2026-07-15 | feedback:2026-07-15 | P3 | design |