From d21a440aad32f38d392e32cc99bc5db5fa3b499b Mon Sep 17 00:00:00 2001 From: screenleon Date: Thu, 30 Jul 2026 14:56:33 +0900 Subject: [PATCH] CC-528: Accept generic Gate results for publish --- BACKLOG.md | 115 ++++++++ DECISIONS.md | 39 +++ MILESTONES.md | 2 + cli/commands.tsv | 2 +- cli/pmctl | 3 + commands/ship.md | 29 +- core/schema/gate-verification.schema.json | 6 + docs/review-model.md | 35 ++- runtime/lib/gate-result-verify.sh | 37 ++- runtime/lib/pmctl-ship.sh | 75 ++++-- tests/shell/test-core-schemas.sh | 32 ++- tests/shell/test-pmctl-gate.sh | 175 ++++++++++-- tests/shell/test-pmctl-ship.sh | 309 +++++++++++++++++++++- 13 files changed, 798 insertions(+), 61 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 48bc78e6..f3b395f9 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -47,6 +47,8 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-525 | 🔵 active | copy-mode verifier fallback 的 generated provenance 必須指向實際 generator,並由 parity ratchet 防止再次漂移 | ops/test | 2026-07-28 | feedback:2026-07-28 | P3 | hygiene | | CC-526 | 🔵 active | reviewer override file 的 symlink trust-boundary hardening 與相容性契約 | security/gate | 2026-07-28 | feedback:2026-07-28 | P2 | hygiene | | CC-527 | 🔵 active | targeted gate CLI 拆分 pass、reviewer coverage 與 tier,避免 full targeted 語意重疊 | ux/gate | 2026-07-28 | feedback:2026-07-28 | P2 | design | +| CC-528 | 🔵 active | publish policy compatibility:generic 為可接受 baseline、maintainer 為 preferred,並允許 ship 驗證既有 current-tree Gate artifact | release/gate | 2026-07-30 | feedback:2026-07-30 | P1 | design | +| CC-529 | 🔵 active | publish assurance observability:在 ship 成功輸出、PR body 與 finish marker 保留 embedded policy 與 baseline/preferred satisfaction | release/gate | 2026-07-30 | feedback:2026-07-30 | P2 | hygiene | | CC-465 | 🔵 active | memory/context 關鍵詞管線 CJK 支援:抽出共用零依賴斷詞 lib,取代三處各自 ASCII-only 抽詞;工作序列起點(465→467→468→466)(2026-07-07 記憶系統深入分析) | memory | 2026-07-07 | feedback:2026-07-07 | P2 | retrieval | | CC-466 | ⏸ deferred | 記憶卡片生命週期閉環:expires_at 執行 + 關窗式 supersede + usage sidecar 休眠偵測 + doctor→distill 接線;僅在 CC-467 證明 stale/dormant card 已形成實際問題時啟動 | memory | 2026-07-07 | feedback:2026-07-07 | P2 | retrieval | | CC-467 | 🔵 active | `pmctl memory stats`:注入效益可視化(唯讀聚合器)——注入 bytes/卡片命中分佈/從未命中卡/episode 填寫率,回答「記憶有跟沒有差在哪」;排在 CC-466 之前(2026-07-07;業界僅離線 recall 評測,無 per-injection 遙測) | DX/memory | 2026-07-07 | — | P2 | retrieval | @@ -2531,6 +2533,119 @@ inheritance 依賴 [[CC-515]];maintainer consumer 接線由 [[CC-517]] 使用 --- +## CC-528 — publish policy compatibility:generic baseline + maintainer preferred 🔵 active + +**Problem**: `generic` 與 `maintainer` 是 Gate consumer policy,不是權限或身分; +但 shared verifier 目前以 policy 名稱完全相等判斷 applicability,並把 `publish` +直接映射成 `maintainer`。結果是 valid/current 的 generic GO 被標成 +`consumer_policy_mismatch`,即使它已通過完整 diff classification、risk signals、 +minimum tier、required reviewer coverage 與 dispatch evidence。`pmctl ship finish` +又只能重新執行 maintainer Gate,無法驗證呼叫者已有的 current-tree generic GO, +把「maintainer 是較佳發布保證」誤作「maintainer 是唯一可接受身分」。 + +**Requirement**: + +1. 定義 policy compatibility:`generic` 是最低可接受 baseline,`maintainer` 是其 + stronger policy;explicit `maintainer` consumer 仍嚴格要求 maintainer, + `generic` consumer 可接受 generic 或 maintainer,`publish` consumer 接受兩者並 + 以 maintainer 為 preferred。`embedded` consumer 繼續驗 artifact 自己宣告的 policy。 +2. `policy_applicable` 除 required/embedded policy 外,必須輸出 + `preferred_policy` 與 `policy_satisfaction: baseline|preferred`。未達 preferred + 只能降為 baseline 訊號,不得讓 applicability fail;低於 required minimum 才以 + 穩定 reason code fail。 +3. `pmctl ship finish` 預設仍執行 maintainer policy;新增明確 + `--gate-result ` 讓 caller 沿用既有 initial result。Supplied artifact + 必須以 publish consumer 通過 artifact validity、current subject、policy + applicability、canonical dispatch authorization 與 scope evidence;targeted + artifact 在 [[CC-517]] closure path 交付前不得單獨授權 publish,且所有 result + 仍受 gate 前後 + HEAD/dirty guards 與 current-tree authoritative full-suite 約束。 +4. `publish` 的 direct current-tree review path 不要求 remediation closure;這是 + [[CC-511]] 已決定的第一種 review authorization。Primary review 經 remediation + 後以 closure 授權 final tree 的第二條 path 仍由 [[CC-517]]/CC-511 Phase B + 實作,本票不得假裝已交付。 +5. CLI 必須拒絕 `--gate-result` 與只對新 Gate 有意義的 `--reviewers` 混用;relative + artifact path 以 `--cd` worktree 為基準。不得自動掃描或猜測 latest result。 +6. 回歸覆蓋 generic→generic、maintainer→generic、generic→maintainer、 + generic→publish baseline、maintainer→publish preferred,以及 supplied + valid/stale/invalid Gate result 對 ship publish boundary 的行為。 + +**Done-when**: valid/current generic GO 可作 publish baseline,maintainer GO 以 +machine-readable preferred 狀態呈現;explicit maintainer verification 仍不接受 +generic;`ship finish --gate-result` 可沿用指定 artifact 且不放寬其他發布軸。 + +**Non-goals**: 不降低 generic risk-based floor;不改 maintainer initial 五 reviewer +coverage或 mode recommendation;不實作 remediation ledger/targeted confirmation; +不自動選擇 result;不把 Gate GO、full-suite PASS、publish authorization 或 merge +authorization重新合併成單一座標。 + +**Dependencies**: 延伸 [[CC-513]] policy resolver、[[CC-515]] shared verifier與 +[[CC-518]] scope manifest;是 [[CC-517]]/CC-511 Phase B 前的 policy compatibility +clarification。P1,排入 v0.11.0 delivery assurance correctness。 + +**Cross-link**: [[CC-511]]、[[CC-513]]、[[CC-515]]、[[CC-517]]、[[CC-518]]、 +[[CC-529]]。 + +--- + +## CC-529 — publish assurance observability:baseline/preferred 可追溯 🔵 active + +**Framing**: 本票只延伸 [[CC-528]] 已建立的 publish policy compatibility, +讓成功發布保留「哪一種 producer policy、以 baseline 或 preferred 滿足 publish」 +的 machine-readable audit trail。Shared verifier 仍是 policy applicability 的唯一 +owner,`pmctl ship finish` 只能呈現 verifier 已驗證的 axes;不得從 tier、reviewer +數量、mode 或 human `Final: GO` 反推 assurance strength,也不得藉本票改寫 +generic/maintainer policy、publication floor 或 [[CC-517]] remediation closure。 + +**Problem**: [[CC-528]] 讓 generic current-tree initial GO 可作 publish baseline, +maintainer GO 則是 preferred;但 successful `pmctl ship finish` stdout、PR body 的 +Gate section 與 `.pm-dispatch-ship-finish.json` marker 目前只保留 `Final: GO` 與 +result path。兩種不同 assurance strength 最後留下相同的 publication record, +操作者與 incident review 無法事後判斷該次發布是 generic baseline 或 maintainer +preferred。CLI usage 雖已列出 `--gate-result`,也缺少回歸測試防止 help synopsis +與 parser mutual-exclusion 契約再次漂移。 + +**Requirement**: + +1. `pmctl ship finish` 只能從已通過 shared verifier 的 structured + `policy_applicable` axis 取得 `embedded_policy`、`required_policy`、 + `preferred_policy` 與 `policy_satisfaction`;缺欄位、未知值或 verifier + assessment 不完整時 fail closed,不以 result prose 或 Gate coverage 猜值。 +2. Fresh maintainer Gate 與 supplied generic/maintainer result 的成功路徑,都要在 + stdout summary、PR body Gate section 及 `.pm-dispatch-ship-finish.json` marker + 明確保留 producer policy 與 `baseline|preferred` satisfaction。三個 surface + 必須來自同一份已驗證 assessment,不得各自重算。 +3. Marker 變更採 additive、versioned 或明確 backward-compatible contract;既有 + 不含新欄位的 marker 仍可由 status/list reader 安全讀取,但新 writer 不得在 + assessment 可用時省略 assurance fields。不得把 baseline 顯示成 failure, + 也不得把 generic 誤標為 maintainer。 +4. PR body 與 human stdout 必須讓操作者一眼區分 baseline/preferred,同時保留 + result artifact path 供完整 verifier 重播;不得只加入模糊的「policy checked」 + 文字。 +5. Public help regression 必須斷言 `pmctl ship finish --help`/usage 包含 + `--gate-result`、`--full-result`,並保留 `--gate-result` 與 `--reviewers` + mutual-exclusion 的表達;parser 行為測試仍是獨立 oracle。 +6. Deterministic tests 覆蓋 supplied generic baseline、supplied/fresh maintainer + preferred、缺失/malformed assurance fields、舊 marker reader compatibility, + 以及 stdout/PR body/marker 三個 surface 的值一致性。 + +**Done-when**: 任一成功 ship publication 都能只靠 stdout、PR body 或 finish +marker 回答 embedded producer policy 與 publish satisfaction,三者與 shared +verifier 完全一致;舊 marker 保持可讀,help synopsis 與 parser contract 有回歸 +鎖定。 + +**Non-goals**: 不改 generic/maintainer reviewer floor、tier、mode 或 compatibility +ordering;不新增 Gate、publish authorization 或 workflow engine;不實作 dashboard、 +scheduled audit 或歷史 marker backfill;不把 [[CC-517]] remediation closure 併入。 + +**Dependencies**: 延伸 [[CC-528]] policy compatibility 與 [[CC-515]] shared +verifier,沿用 [[CC-511]] publish marker/PR boundary。P2,排入 v0.11.0 delivery +assurance observability。 + +**Cross-link**: [[CC-511]]、[[CC-513]]、[[CC-515]]、[[CC-517]]、[[CC-528]]。 + +--- + ## CC-508 — 所有間接 dispatch 的 parent-operation control plane ✅ 2026-07-25 **Problem**: `pmctl gate run`、`pmctl ship --parallel`/adapter 路徑、`pmctl task dispatch` 與任何未來 producer 都可能以一個 parent operation 間接啟動一或多個 detached dispatch;但產品控制面主要只暴露個別 `pmctl dispatch cancel `。parent ID 與其子 run 沒有強制、可查的 ownership relation,也沒有一致的 producer-level cancel surface。當任一 producer 卡住、選錯 executor 或需中止時,操作者無法透過 pmctl 取消整個 operation;直接對 supervisor PID 操作會繞過 run state、sentinel 與 cancel-vs-complete 單一終態契約,並可能留下無法判定的 stale operation。 diff --git a/DECISIONS.md b/DECISIONS.md index c1995627..e00238db 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -7,6 +7,45 @@ H2 標題格式:## YYYY-MM-DD: <短描述> 與 BACKLOG closure 對應的 entry,內文首行寫:Closes: BACKLOG.md#-NNN --> +## 2026-07-30: publish-accepts-generic-baseline-and-prefers-maintainer + +Relates: CC-511, CC-513, CC-515, CC-517, CC-528 + +**Context**: `generic` 與 `maintainer` 是 Gate consumer policy;前者依完整 diff +classification 與 risk signals 建立 reviewer floor,後者在 initial pass 固定增加五個 +reviewer dimensions。Shared verifier 卻把 producer policy 名稱當成必須完全相等的 +身分,並將 publish consumer 直接等同 maintainer。這讓 valid/current generic GO +即使已滿足其 risk-based floor,仍因名稱不等而無法作為發布 baseline;`ship finish` +也只能重跑 maintainer,無法驗證 caller 已明確指定的 current-tree result。Preference +因而被誤寫為唯一合法性,且較強的 maintainer artifact 也不能滿足 generic consumer。 + +**Decision**: Policy applicability 使用有方向的 compatibility:generic 是 baseline, +maintainer 是 stronger policy。Explicit maintainer consumer 仍嚴格要求 maintainer; +generic consumer 接受 generic 或 maintainer;publish consumer 的 minimum 是 generic、 +preferred 是 maintainer。Verifier 同時輸出 required、preferred、embedded policy 與 +`baseline|preferred` satisfaction;未達 preference 不使 applicability fail。Repo-owned +`ship finish` 預設仍產生 maintainer Gate,但接受明確 `--gate-result`,以 publish +consumer 驗證既有 current-tree initial result後再走同一 full-suite 與 publication +guards。Direct current-tree initial review 是 publish authorization 的第一條路,不 +需要 remediation closure;targeted result 單獨不能取代 initial review。 + +**Alternatives considered**: (a) 維持 exact-name equality——否決,consumer policy +不是權限身分,也會反向拒絕 stronger evidence。(b) 讓 ship 永遠重跑 maintainer—— +否決,會把 preferred 變成 mandatory 並重複已完成的 generic review。(c) 自動掃描 +latest Gate result——否決,latest 不代表 caller intent 或 current subject,容易誤用 +stale/不同 scope artifact。(d) 同時實作 primary-remediation closure publish—— +否決,該 lifecycle、ledger 與 targeted confirmation 仍由 CC-517/CC-511 Phase B +擁有,不應混入 compatibility slice。 + +**Constraints introduced**: `--gate-result` 必須明確指定且 relative path 以 ship +worktree 為基準;不得與只影響新 Gate 的 reviewer selection 混用。Supplied result +不得繞過 artifact integrity、canonical dispatch authorization、scope manifest、 +subject freshness、branch/HEAD/dirty guards 或 current-tree authoritative full +suite。Maintainer initial 五 reviewer coverage與 mode recommendation不變。Publish +接受 generic GO 不代表自動 merge,也不表示 targeted remediation closure 已交付。 + +--- + ## 2026-07-29: structured-reviewer-verdicts-replace-markdown-heading-parsing Relates: CC-519 diff --git a/MILESTONES.md b/MILESTONES.md index 421de194..4f6dbb48 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -128,6 +128,8 @@ | 票 | 摘要 | 狀態 | |----|------|------| +| CC-528 | publish policy compatibility:generic current-tree initial GO 為 baseline、maintainer 為 preferred;ship 可驗證明確 supplied result | 🔵 | +| CC-529 | publish assurance observability:ship stdout、PR body、finish marker 保留 producer policy 與 baseline/preferred satisfaction | 🔵 | | CC-517 | `/ship` primary review→local/targeted/split remediation closure→final affected/full tests;不虛稱 final-tree GO | 🔵 | | CC-511 Phase B | final-tree review或 primary-review closure authorization + current-tree full PASS → publish | 🔵 | diff --git a/cli/commands.tsv b/cli/commands.tsv index 0033d3c4..d26e0080 100644 --- a/cli/commands.tsv +++ b/cli/commands.tsv @@ -20,7 +20,7 @@ worktree remove Remove a managed worktree. pmctl worktree remove [options] experimental false true --worktree, --adapter, --from, --isolation, --model, --auto-pack, --cd pmctl ship CC-NNN --adapter codex ship prepare Prepare a ticket delivery run. pmctl ship prepare [--cd ] experimental false true --cd pmctl ship prepare CC-NNN -ship finish Finish a prepared ticket delivery run. pmctl ship finish [options] experimental false true --cd, --reviewers, --full-result pmctl ship finish CC-NNN +ship finish Finish a prepared ticket delivery run. pmctl ship finish [options] experimental false true --cd, --reviewers, --gate-result, --full-result pmctl ship finish CC-NNN ship --parallel Run multiple ticket delivery workflows. pmctl ship --parallel ... [options] experimental false true --from, --adapter, --isolation, --model, --auto-pack, --cd pmctl ship --parallel CC-NNN CC-MMM ship status Show and refresh a parallel ship run. pmctl ship status [--cd ] [--json] experimental true true --cd, --json pmctl ship status --json ship list List and refresh completed parallel ship runs. pmctl ship list [--cd ] [--json] experimental true true --cd, --json pmctl ship list --json diff --git a/cli/pmctl b/cli/pmctl index 537455c3..77768b17 100755 --- a/cli/pmctl +++ b/cli/pmctl @@ -208,6 +208,9 @@ case "$cmd/$sub" in case "$1" in --cd) _ship_cd="${2:-}"; shift 2 ;; --reviewers) _ship_rest+=(--reviewers "${2:-}"); shift 2 ;; + --gate-result) + [[ $# -ge 2 && -n "$2" && "$2" != --* ]] || pmctl_die "ship finish: --gate-result requires an artifact path" + _ship_rest+=(--gate-result "$2"); shift 2 ;; --full-result) [[ $# -ge 2 && -n "$2" && "$2" != --* ]] || pmctl_die "ship finish: --full-result requires an artifact path" _ship_rest+=(--full-result "$2"); shift 2 ;; diff --git a/commands/ship.md b/commands/ship.md index c6364c2e..5b674541 100644 --- a/commands/ship.md +++ b/commands/ship.md @@ -139,14 +139,18 @@ resolve to the selected gate model. Mode is user-owned: append the literal the gate auto-selects the policy recommendation. Preserve the user's explicit choice on targeted re-runs unless the user changes it. -Run `pmctl gate run --executor --policy maintainer --cd "" --lifecycle foreground` +Prefer `pmctl gate run --executor --policy maintainer --cd "" --lifecycle foreground` (substitute `` with the literal absolute working directory, not `"$PWD"` — a shell-variable expansion makes the command unanalyzable statically and forces a manual approval every time even though a bare `pmctl ...` invocation matches allowlisted `Bash(pmctl:*)`-style permission rules). The `/pr-gate` command is the orchestration wrapper around this exact invocation — either entry point is acceptable, but the underlying gate call -is always this one, never `bash runtime/bin/pr-gate.sh` directly. +is always this one, never `bash runtime/bin/pr-gate.sh` directly. Maintainer is +the preferred initial publication policy because it fixes coverage at all five +reviewer dimensions. A valid current-tree initial GO already produced by +`/pr-gate` with `--policy generic` is also an acceptable publication baseline; +do not rerun it only to change the policy label. `--lifecycle foreground` is required here: the default `--lifecycle detached` returns only a `gate_id` immediately and the gate keeps running in the background — reading `Final:` right after that call would read a stale or @@ -155,11 +159,12 @@ nothing else for the main thread to do while it waits, so there is no reason to pay the detached/`pmctl gate wait` two-call complexity that `/pr-gate` uses to keep the main thread free for other work; run `foreground`, parse its literal `result: `, then run -`pmctl gate verify --cd "" --consumer maintainer --json`. +`pmctl gate verify --cd "" --consumer publish --json`. The result file's `Final: GO|NO-GO` remains the review verdict, but it is not freshness or continuation authorization by itself. A GO may continue only when the named-consumer verification exits 0 and reports `artifact_valid`, -`subject_current`, and `policy_applicable` all `pass`. +`subject_current`, and `policy_applicable` all `pass`. The policy axis reports +generic as `baseline` or maintainer as `preferred`; both are accepted here. - **GO + all three axes pass** → go to Step 3.5. - **GO + any axis not pass** → stop before full-suite or publication work. @@ -213,6 +218,22 @@ than using that primitive, run `bash tests/bin/run-all-tests.sh` once and verify its authoritative result artifact. This is the final repo-wide regression check, not an iteration tool. +When the initial Gate result still covers the exact current tree, reuse it +explicitly: + +```bash +pmctl ship finish --cd "" \ + --gate-result "" +``` + +Relative Gate-result paths resolve against ``. Finish verifies the +artifact with the `publish` consumer and still enforces branch, HEAD, dirty +tree, canonical dispatch, scope, and authoritative full-suite boundaries. If +no `--gate-result` is supplied, finish produces a fresh preferred maintainer +Gate. Never pass a targeted result as standalone publication authorization: +until the structured remediation-closure path is available, a remediated tree +must either receive a fresh current-tree initial Gate or stop before publish. + If the full suite finds a diff-caused failure, fix it, rerun affected tests, apply the same refactor/reuse recheck threshold, and return to the targeted gate path for every reviewer territory the fix touched. After GO, run the full diff --git a/core/schema/gate-verification.schema.json b/core/schema/gate-verification.schema.json index 6049af72..42575b8c 100644 --- a/core/schema/gate-verification.schema.json +++ b/core/schema/gate-verification.schema.json @@ -126,8 +126,14 @@ "required_policy": { "enum": ["generic", "maintainer"] }, + "preferred_policy": { + "enum": ["generic", "maintainer"] + }, "embedded_policy": { "enum": ["generic", "maintainer"] + }, + "policy_satisfaction": { + "enum": ["baseline", "preferred"] } }, "additionalProperties": false diff --git a/docs/review-model.md b/docs/review-model.md index edd6dbae..bfbc9b41 100644 --- a/docs/review-model.md +++ b/docs/review-model.md @@ -181,6 +181,23 @@ pass fixes coverage at all five reviewer dimensions while preserving the independently resolved tier and mode. Targeted passes under either consumer remain scoped to the requested remediation reviewers. +Producer policy names are assurance strategies, not identities. Applicability +uses this compatibility contract: + +| Verifier consumer | Minimum policy | Preferred policy | Accepted producer policy | +|---|---|---|---| +| `embedded` | artifact policy | artifact policy | exact artifact policy | +| `generic` | `generic` | `generic` | `generic` or `maintainer` | +| `maintainer` | `maintainer` | `maintainer` | `maintainer` only | +| `publish` | `generic` | `maintainer` | `generic` or `maintainer` | + +The policy axis records `required_policy`, `preferred_policy`, +`embedded_policy`, and `policy_satisfaction`. A generic initial GO therefore +satisfies publish as `baseline`; a maintainer initial GO satisfies it as +`preferred`. Missing a preference is not an applicability failure. Falling +below the required minimum remains a failure. Publish compatibility does not +change either producer's tier, reviewer floor, or mode recommendation. + Any requested tier or coverage below the policy floor fails before reviewer dispatch. A tier/coverage downgrade is accepted only through an explicitly supplied `gate_policy_override_v1` JSON file bound to the exact scope fingerprint @@ -282,8 +299,8 @@ returns three independent axes: - `artifact_valid`: result/sidecar schema, digest, and content parity; - `subject_current`: repository, base, head, and tree freshness; -- `policy_applicable`: resolved coordinates, review evidence, and any - consumer-required closure evidence. +- `policy_applicable`: resolved coordinates, review evidence, consumer + minimum/preference, and required authorization evidence. Every axis includes stable reason codes. A copied artifact can remain valid while lacking canonical dispatch authorization; a base advance or tree change @@ -294,6 +311,13 @@ passing `--consumer` is authorizing and requires all three axes to pass. `gate wait` and `ship finish` consume this shared assessment rather than grepping `Final: GO`. +For the direct current-tree publication path, `publish` accepts a current +initial generic result as baseline and a current initial maintainer result as +preferred. A targeted result cannot authorize publication by itself. The +separate primary-review plus remediation-closure path must bind the final tree +and any required targeted confirmations before it can become an alternative +publication authorization. + Historical v3 envelopes that record `scope_manifest: unavailable` remain artifact-readable under default inspection, but they do not satisfy a named consumer after the scope-manifest producer is installed. @@ -311,8 +335,11 @@ and reports the drift reason instead of silently authorizing a different tree. Legacy result v1/v2 artifacts remain readable under their historical contracts. Result v3 is required for selected-reviewer protocol evidence. No gate result version is publication authorization by itself; `ship finish` -requires a current, applicable gate-assurance v3 -assessment. +requires a current, applicable gate-assurance v3 assessment plus the +authoritative current-tree full-suite evidence and publication guards. +Without `--gate-result`, finish produces a fresh preferred maintainer result. +With an explicit `--gate-result `, it reuses that result only after +publish-consumer verification; it never guesses a latest artifact. The producer publishes the sidecar before atomically replacing the self-contained staging v1 result with the bound result that references it, so diff --git a/runtime/lib/gate-result-verify.sh b/runtime/lib/gate-result-verify.sh index 1ab516a7..a3599e69 100644 --- a/runtime/lib/gate-result-verify.sh +++ b/runtime/lib/gate-result-verify.sh @@ -982,6 +982,16 @@ _gate_assurance_linked_evidence_verify() { gate_policy_applicability_assess() { local assurance_file="$1" consumer="$2" authorization_status="$3" local authorization_reason="${4:-dispatch_authorization_unavailable}" + case "$consumer" in + embedded|generic|maintainer|publish) ;; + *) + jq -nc '{ + status:"fail", + reason_codes:["consumer_policy_unknown"] + }' + return 0 + ;; + esac local kind kind="$(jq -r '.kind // empty' "$assurance_file" 2>/dev/null)" if [[ "$kind" != gate_assurance_v3 ]]; then @@ -995,15 +1005,25 @@ gate_policy_applicability_assess() { --arg consumer "$consumer" \ --arg authorization_status "$authorization_status" \ --arg authorization_reason "$authorization_reason" ' + def policy_rank: + if . == "generic" then 1 + elif . == "maintainer" then 2 + else 0 + end; $assurance[0] as $a | (if $consumer == "embedded" then $a.policy.consumer_policy - elif $consumer == "publish" then "maintainer" - else $consumer end) as $required_policy | + elif $consumer == "maintainer" then "maintainer" + else "generic" end) as $required_policy | + (if $consumer == "publish" then "maintainer" + else $required_policy end) as $preferred_policy | + ($a.policy.consumer_policy | policy_rank) as $embedded_rank | + ($required_policy | policy_rank) as $required_rank | + ($preferred_policy | policy_rank) as $preferred_rank | ([ if $a.result.final != "GO" then "verdict_not_go" else empty end, if ($a | has("policy") | not) then "policy_resolution_unavailable" else empty end, - if $a.policy.consumer_policy != $required_policy - then "consumer_policy_mismatch" else empty end, + if $embedded_rank < $required_rank + then "consumer_policy_below_minimum" else empty end, if $a.policy.enforcement.status != "pass" then "policy_enforcement_failed" else empty end, if $a.coordinates.independence.evidence_status != "verified" @@ -1015,15 +1035,18 @@ gate_policy_applicability_assess() { then "scope_manifest_unavailable" else empty end, if $authorization_status != "verified" then $authorization_reason else empty end, - if $consumer == "publish" and $a.evidence.closure.status != "verified" - then "closure_evidence_unavailable" else empty end + if $consumer == "publish" and $a.coordinates.pass.resolved != "initial" + then "publish_initial_review_required" else empty end ] | unique) as $reasons | { status:(if ($reasons | length) == 0 then "pass" else "fail" end), reason_codes:$reasons, consumer:$consumer, required_policy:$required_policy, - embedded_policy:$a.policy.consumer_policy + preferred_policy:$preferred_policy, + embedded_policy:$a.policy.consumer_policy, + policy_satisfaction: + (if $embedded_rank >= $preferred_rank then "preferred" else "baseline" end) }' } diff --git a/runtime/lib/pmctl-ship.sh b/runtime/lib/pmctl-ship.sh index c9d7cb7f..9c6a3586 100644 --- a/runtime/lib/pmctl-ship.sh +++ b/runtime/lib/pmctl-ship.sh @@ -32,7 +32,7 @@ pmctl_ship_usage() { printf ' Start a manual ship lane. Bare: in the current worktree (alias: prepare). --worktree: isolated worktree, no dispatch. --adapter: dispatch (implies --worktree).\n' >&2 printf ' After implementation, run: pmctl ship finish \n' >&2 printf ' pmctl ship prepare [--cd ]\n' >&2 - printf ' pmctl ship finish [--cd ] [--reviewers ] [--full-result ]\n' >&2 + printf ' pmctl ship finish [--cd ] [--reviewers | --gate-result ] [--full-result ]\n' >&2 printf ' pmctl ship --parallel [...] [--from ] [--adapter ] [--isolation ] [--model ] [--cd ]\n' >&2 printf ' pmctl ship status [--cd ] [--json]\n' >&2 printf ' pmctl ship list [--cd ] [--json]\n' >&2 @@ -118,21 +118,26 @@ pmctl_ship_verify_full_suite() { printf 'pmctl ship finish: verified current-tree authoritative full-suite PASS: %s\n' "$result_file" >&2 } -# pmctl_ship_finish [--reviewers ] [--full-result ] -# Runs ONE gate round in work_dir. GO: push + open PR, print the PR URL. -# NO-GO: print the verdict/result path and exit 1 -- the caller (agent) is -# expected to fix findings and call `finish` again, same loop discipline as -# `/ship` Step 3, just with the gate-invoke/read/push/PR mechanics scripted. +# pmctl_ship_finish +# [--reviewers | --gate-result ] +# [--full-result ] +# By default, runs ONE maintainer-policy gate round in work_dir. A caller may +# instead supply one explicit, current-tree gate result; finish never guesses or +# scans for a "latest" result. An accepted GO continues to the full-suite and +# publication boundaries. NO-GO prints the verdict/result path and exits 1. pmctl_ship_finish() { local repo_root="${1:-}" work_dir="${2:-}" ticket_id="${3:-}" shift 3 || true [[ -n "$work_dir" ]] || work_dir="$repo_root" - local reviewers="" full_result="" args=("$@") i=0 + local reviewers="" gate_result="" full_result="" args=("$@") i=0 while [[ $i -lt ${#args[@]} ]]; do case "${args[$i]}" in --reviewers) [[ -n "${args[$((i+1))]:-}" ]] || { printf 'pmctl ship finish: --reviewers requires a value\n' >&2; return 2; } reviewers="${args[$((i+1))]}"; i=$((i+2)) ;; + --gate-result) + [[ -n "${args[$((i+1))]:-}" ]] || { printf 'pmctl ship finish: --gate-result requires an artifact path\n' >&2; return 2; } + gate_result="${args[$((i+1))]}"; i=$((i+2)) ;; --full-result) [[ -n "${args[$((i+1))]:-}" ]] || { printf 'pmctl ship finish: --full-result requires an artifact path\n' >&2; return 2; } full_result="${args[$((i+1))]}"; i=$((i+2)) ;; @@ -145,7 +150,11 @@ pmctl_ship_finish() { printf 'pmctl ship finish: is required\n' >&2 return 2 fi - if ! declare -F pmctl_gate_run >/dev/null; then + if [[ -n "$gate_result" && -n "$reviewers" ]]; then + printf 'pmctl ship finish: --gate-result cannot be combined with --reviewers; reviewers only apply to a new Gate run\n' >&2 + return 2 + fi + if [[ -z "$gate_result" ]] && ! declare -F pmctl_gate_run >/dev/null; then printf 'pmctl ship finish: pmctl gate unavailable\n' >&2 return 2 fi @@ -187,22 +196,37 @@ pmctl_ship_finish() { local pre_gate_head pre_gate_head="$(git -C "$work_dir" rev-parse HEAD 2>/dev/null)" - # This repo-owned finish path requests the maintainer consumer policy. The - # generic `pmctl gate run` default remains risk-based and composable. - local gate_args=(--executor codex --policy maintainer --cd "$work_dir" --lifecycle foreground) - [[ -n "$reviewers" ]] && gate_args+=(--reviewers "$reviewers") - local gate_out gate_status=0 - gate_out="$(pmctl_gate_run "$repo_root" "${gate_args[@]}" 2>&1)" || gate_status=$? - printf '%s\n' "$gate_out" + local result_path gate_out="" gate_status=0 + if [[ -n "$gate_result" ]]; then + if [[ "$gate_result" == /* ]]; then + result_path="$gate_result" + else + result_path="$work_dir/$gate_result" + fi + printf 'pmctl ship finish: verifying supplied Gate result: %s\n' "$result_path" + else + # Maintainer remains the preferred producer policy for a fresh ship Gate. + # Generic remains the public Gate default and is accepted as a publish + # baseline when the caller supplies its exact current-tree artifact. + local gate_args=(--executor codex --policy maintainer --cd "$work_dir" --lifecycle foreground) + [[ -n "$reviewers" ]] && gate_args+=(--reviewers "$reviewers") + gate_out="$(pmctl_gate_run "$repo_root" "${gate_args[@]}" 2>&1)" || gate_status=$? + printf '%s\n' "$gate_out" + result_path="$(printf '%s\n' "$gate_out" | grep -m1 '^result: ' | sed 's/^result: *//')" + fi # Source of truth is the shared three-axis gate assessment, not the captured # exit code, stdout prose, or a local grep of `Final:`. pr-gate.sh prints the # result path; the verifier binds it to the current repository subject and - # the maintainer consumer policy before publication can continue. - local result_path final_verdict gate_verification gate_verification_status=0 - result_path="$(printf '%s\n' "$gate_out" | grep -m1 '^result: ' | sed 's/^result: *//')" + # publish consumer policy before publication can continue. + local final_verdict gate_verification gate_verification_status=0 if [[ -z "$result_path" || ! -f "$result_path" ]]; then - printf 'pmctl ship finish: could not locate gate result file (gate exit %s) -- see output above\n' "$gate_status" >&2 + if [[ -n "$gate_result" ]]; then + printf 'pmctl ship finish: supplied --gate-result artifact not found: %s\n' \ + "$result_path" >&2 + else + printf 'pmctl ship finish: could not locate gate result file (gate exit %s) -- see output above\n' "$gate_status" >&2 + fi return 1 fi if ! declare -F pmctl_gate_verify >/dev/null 2>&1; then @@ -211,7 +235,7 @@ pmctl_ship_finish() { fi gate_verification="$( pmctl_gate_verify "$repo_root" "$result_path" --cd "$work_dir" \ - --consumer maintainer --json + --consumer publish --json )" || gate_verification_status=$? if ! jq -e '.kind == "gate_verification_v1"' \ <<<"$gate_verification" >/dev/null 2>&1; then @@ -224,7 +248,7 @@ pmctl_ship_finish() { return 1 fi if [[ "$gate_verification_status" -ne 0 ]]; then - printf 'pmctl ship finish: GO artifact is invalid, stale, or not applicable to maintainer policy; refusing publication: %s\n' \ + printf 'pmctl ship finish: GO artifact is invalid, stale, or below the publish policy baseline; refusing publication: %s\n' \ "$(jq -c '.axes' <<<"$gate_verification")" >&2 return 1 fi @@ -246,8 +270,13 @@ pmctl_ship_finish() { local post_gate_head post_gate_head="$(git -C "$work_dir" rev-parse HEAD 2>/dev/null)" if [[ -z "$pre_gate_head" || "$post_gate_head" != "$pre_gate_head" ]]; then - printf 'pmctl ship finish: GO, but HEAD moved during the gate run (%s -> %s) -- refusing to push/PR a commit the gate never reviewed. Re-run finish against the current HEAD.\n' \ - "${pre_gate_head:-unknown}" "${post_gate_head:-unknown}" >&2 + if [[ -n "$gate_result" ]]; then + printf 'pmctl ship finish: GO, but HEAD moved while verifying supplied --gate-result (%s -> %s) -- refusing to push/PR a commit the artifact does not cover. Supply a Gate result for the current HEAD.\n' \ + "${pre_gate_head:-unknown}" "${post_gate_head:-unknown}" >&2 + else + printf 'pmctl ship finish: GO, but HEAD moved during the gate run (%s -> %s) -- refusing to push/PR a commit the gate never reviewed. Re-run finish against the current HEAD.\n' \ + "${pre_gate_head:-unknown}" "${post_gate_head:-unknown}" >&2 + fi return 1 fi diff --git a/tests/shell/test-core-schemas.sh b/tests/shell/test-core-schemas.sh index 7e5bec38..809966c2 100755 --- a/tests/shell/test-core-schemas.sh +++ b/tests/shell/test-core-schemas.sh @@ -1220,7 +1220,10 @@ _gate_verification_valid_instance() { status:"pass", reason_codes:[], consumer:"maintainer", - embedded_policy:"maintainer" + required_policy:"maintainer", + preferred_policy:"maintainer", + embedded_policy:"maintainer", + policy_satisfaction:"preferred" } } }' @@ -1351,6 +1354,32 @@ case_gate_verification_invalid_consumer_rejected() { rm -f "$tmpf" } +# Behavior: gate verification rejects out-of-contract policy preference and +# satisfaction values. +# Steps: mutate each new enum field in the canonical fixture, validate it +# against the schema, and require both invalid instances to be rejected. +case_gate_verification_invalid_policy_satisfaction_fields_rejected() { + local name="gate-verification: policy preference and satisfaction enums are enforced" + should_run "$name" || return 0 + local schema_file="$CORE_DIR/schema/gate-verification.schema.json" + local field tmpf accepted=() + for field in preferred_policy policy_satisfaction; do + tmpf="$(mktemp "/tmp/gate-verification-${field}-XXXXXX.json")" + _gate_verification_valid_instance | + jq --arg field "$field" \ + '.axes.policy_applicable[$field] = "outside-contract"' > "$tmpf" + if jsonschema -i "$tmpf" "$schema_file" >/dev/null 2>&1; then + accepted+=("$field") + fi + rm -f "$tmpf" + done + if [[ "${#accepted[@]}" -eq 0 ]]; then + pass "$name" + else + fail "$name" "schema accepted invalid values for: ${accepted[*]}" + fi +} + case_gate_assurance_invalid_outcome_rejected() { local name="gate-assurance: unknown dispatch status is rejected" should_run "$name" || return 0 @@ -1639,6 +1668,7 @@ case_gate_assurance_v3_evidence_path_rejected case_gate_verification_valid_instance case_gate_verification_duplicate_reason_rejected case_gate_verification_invalid_consumer_rejected +case_gate_verification_invalid_policy_satisfaction_fields_rejected case_gate_assurance_invalid_outcome_rejected case_gate_assurance_non_user_policy_approver_rejected case_gate_reviewer_result_valid_instance diff --git a/tests/shell/test-pmctl-gate.sh b/tests/shell/test-pmctl-gate.sh index f92f3ff9..41842eb6 100755 --- a/tests/shell/test-pmctl-gate.sh +++ b/tests/shell/test-pmctl-gate.sh @@ -1046,7 +1046,9 @@ case_verify_v3_three_axes_current() { .axes.artifact_valid.status == "pass" and .axes.subject_current.status == "pass" and .axes.policy_applicable.status == "pass" and - .axes.policy_applicable.required_policy == "generic" + .axes.policy_applicable.required_policy == "generic" and + .axes.policy_applicable.preferred_policy == "generic" and + .axes.policy_applicable.policy_satisfaction == "preferred" ' <<<"$out" >/dev/null; then pass "$name" else @@ -1119,8 +1121,8 @@ case_verify_v3_policy_reason_codes() { dispatch) jq '.dispatch.outcomes[0].run_id = null' "$sidecar" > "$fixture" ;; - closure) - jq '.policy.consumer_policy = "maintainer"' "$sidecar" > "$fixture" + targeted) + jq '.coordinates.pass.resolved = "targeted"' "$sidecar" > "$fixture" ;; scope) jq '.evidence.scope_manifest = { @@ -1140,13 +1142,6 @@ case_verify_v3_policy_reason_codes() { fail "$name" "$mutation axis=$axis" return fi - if [[ "$consumer" == publish ]] && ! jq -e ' - .consumer == "publish" and .required_policy == "maintainer" and - .embedded_policy == "maintainer" - ' <<<"$axis" >/dev/null; then - fail "$name" "publish policy mapping is not self-describing: $axis" - return - fi done <<'CASES' verdict|verdict_not_go|generic no-policy|policy_resolution_unavailable|generic @@ -1154,11 +1149,152 @@ enforcement|policy_enforcement_failed|generic independence|review_independence_unverified|generic dispatch|review_dispatch_evidence_incomplete|generic scope|scope_manifest_unavailable|generic -closure|closure_evidence_unavailable|publish +targeted|publish_initial_review_required|publish CASES pass "$name" } +# Behavior: consumer policies form a directional compatibility relation: +# publish accepts generic as baseline, while explicit maintainer stays strict. +# Steps: assess generic- and maintainer-produced fixtures through embedded, +# generic, publish, and maintainer consumers, then assert every policy axis. +case_verify_v3_policy_compatibility_matrix() { + local name="gate/verify: policy compatibility keeps generic baseline and maintainer preference distinct" + should_run "$name" || return 0 + local result sidecar maintainer embedded_axis generic_axis publish_axis strict_axis + local stronger_embedded_axis + local stronger_generic_axis stronger_publish_axis stronger_strict_axis + result="$(_gate_verify_result_path v3-policy-compatibility)" + sidecar="${result}.assurance.json" + maintainer="$tmp_root/v3-policy-compatibility-maintainer.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + + embedded_axis="$(gate_policy_applicability_assess "$sidecar" embedded verified)" + generic_axis="$(gate_policy_applicability_assess "$sidecar" generic verified)" + publish_axis="$(gate_policy_applicability_assess "$sidecar" publish verified)" + strict_axis="$(gate_policy_applicability_assess "$sidecar" maintainer verified)" + jq '.policy.consumer_policy = "maintainer"' "$sidecar" > "$maintainer" + stronger_embedded_axis="$( + gate_policy_applicability_assess "$maintainer" embedded verified + )" + stronger_generic_axis="$( + gate_policy_applicability_assess "$maintainer" generic verified + )" + stronger_publish_axis="$( + gate_policy_applicability_assess "$maintainer" publish verified + )" + stronger_strict_axis="$( + gate_policy_applicability_assess "$maintainer" maintainer verified + )" + + if jq -e ' + .status == "pass" and + .required_policy == "generic" and + .preferred_policy == "generic" and + .embedded_policy == "generic" and + .policy_satisfaction == "preferred" + ' <<<"$embedded_axis" >/dev/null \ + && jq -e ' + .status == "pass" and + .required_policy == "generic" and + .preferred_policy == "generic" and + .embedded_policy == "generic" and + .policy_satisfaction == "preferred" + ' <<<"$generic_axis" >/dev/null \ + && jq -e ' + .status == "pass" and + .required_policy == "generic" and + .preferred_policy == "maintainer" and + .embedded_policy == "generic" and + .policy_satisfaction == "baseline" + ' <<<"$publish_axis" >/dev/null \ + && jq -e ' + .status == "fail" and + .required_policy == "maintainer" and + (.reason_codes | index("consumer_policy_below_minimum")) != null + ' <<<"$strict_axis" >/dev/null \ + && jq -e ' + .status == "pass" and + .required_policy == "maintainer" and + .preferred_policy == "maintainer" and + .embedded_policy == "maintainer" and + .policy_satisfaction == "preferred" + ' <<<"$stronger_embedded_axis" >/dev/null \ + && jq -e ' + .status == "pass" and .required_policy == "generic" and + .embedded_policy == "maintainer" + ' <<<"$stronger_generic_axis" >/dev/null \ + && jq -e ' + .status == "pass" and .required_policy == "generic" and + .preferred_policy == "maintainer" and + .policy_satisfaction == "preferred" + ' <<<"$stronger_publish_axis" >/dev/null \ + && jq -e ' + .status == "pass" and .required_policy == "maintainer" and + .embedded_policy == "maintainer" + ' <<<"$stronger_strict_axis" >/dev/null; then + pass "$name" + else + fail "$name" "embedded=$embedded_axis generic=$generic_axis publish=$publish_axis strict=$strict_axis stronger-embedded=$stronger_embedded_axis stronger-generic=$stronger_generic_axis stronger-publish=$stronger_publish_axis stronger-strict=$stronger_strict_axis" + fi +} + +# Behavior: the shared applicability seam fails closed when a caller bypasses +# CLI enum validation with a new or misspelled consumer. +# Steps: assess a valid assurance with an unknown consumer and require only the +# explicit fail status and consumer_policy_unknown reason. +case_verify_v3_unknown_policy_consumer_fails_closed() { + local name="gate/verify: unknown policy consumer fails closed" + should_run "$name" || return 0 + local result sidecar axis + result="$(_gate_verify_result_path v3-policy-unknown-consumer)" + sidecar="${result}.assurance.json" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + axis="$( + gate_policy_applicability_assess "$sidecar" deployment verified + )" + if jq -e ' + .status == "fail" and + .reason_codes == ["consumer_policy_unknown"] and + (has("required_policy") | not) and + (has("preferred_policy") | not) + ' <<<"$axis" >/dev/null; then + pass "$name" + else + fail "$name" "axis=$axis" + fi +} + +# Behavior: the full verifier authorizes a valid/current generic result as the +# direct publish baseline without reporting it as maintainer-preferred. +# Steps: verify a canonical generic artifact for the publish consumer and +# assert all three axes plus baseline policy satisfaction. +case_verify_v3_generic_is_publish_baseline() { + local name="gate/verify: current generic artifact passes publish as baseline" + should_run "$name" || return 0 + local result out code + result="$(_gate_verify_result_path v3-generic-publish)" + _mk_gate_result_v3_verified "$result" "$_GATE_VERIFY_REPO" + set +e + out="$(_run_canonical_gate_verify "$result" --consumer publish --json 2>&1)" + code=$? + set -e + if [[ "$code" -eq 0 ]] \ + && jq -e ' + .axes.artifact_valid.status == "pass" and + .axes.subject_current.status == "pass" and + .axes.policy_applicable.status == "pass" and + .axes.policy_applicable.required_policy == "generic" and + .axes.policy_applicable.preferred_policy == "maintainer" and + .axes.policy_applicable.embedded_policy == "generic" and + .axes.policy_applicable.policy_satisfaction == "baseline" + ' <<<"$out" >/dev/null; then + pass "$name" + else + fail "$name" "code=$code out=$out" + fi +} + case_verify_v3_dirty_drift_is_stale_not_invalid() { # Behavior: post-finalization working-tree drift keeps result/sidecar integrity # valid but makes the committed-head subject stale. @@ -1399,10 +1535,12 @@ case_verify_v3_copy_replay_is_valid_but_not_authorizing() { fi } -case_verify_v3_valid_but_policy_insufficient() { - # Behavior: a generic-policy artifact remains valid/current but cannot satisfy - # the stronger maintainer consumer. - local name="gate/verify: v3 generic artifact is insufficient for maintainer consumer" +# Behavior: a generic-policy artifact remains valid and current but cannot +# satisfy an explicitly requested strict maintainer consumer. +# Steps: verify a canonical generic artifact for the maintainer consumer and +# require only the policy axis to fail with the below-minimum reason. +case_verify_v3_valid_but_policy_below_maintainer_minimum() { + local name="gate/verify: v3 generic artifact is below explicit maintainer minimum" should_run "$name" || return 0 local result out code result="$(_gate_verify_result_path v3-policy-insufficient)" @@ -1416,7 +1554,7 @@ case_verify_v3_valid_but_policy_insufficient() { .axes.artifact_valid.status == "pass" and .axes.subject_current.status == "pass" and (.axes.policy_applicable.reason_codes | - index("consumer_policy_mismatch")) != null + index("consumer_policy_below_minimum")) != null ' <<<"$out" >/dev/null; then pass "$name" else @@ -2640,6 +2778,9 @@ case_verify_v2_canonical_authorization case_verify_v3_three_axes_current case_verify_v3_producer_drift_reason_codes case_verify_v3_policy_reason_codes +case_verify_v3_policy_compatibility_matrix +case_verify_v3_unknown_policy_consumer_fails_closed +case_verify_v3_generic_is_publish_baseline case_verify_v3_dirty_drift_is_stale_not_invalid case_verify_v3_head_moved_is_stale case_verify_v3_base_advanced_is_stale @@ -2647,7 +2788,7 @@ case_verify_v3_fixed_ref_ignores_working_tree case_verify_v3_linked_worktree_path_is_current case_verify_v3_different_repo_same_content_is_stale case_verify_v3_copy_replay_is_valid_but_not_authorizing -case_verify_v3_valid_but_policy_insufficient +case_verify_v3_valid_but_policy_below_maintainer_minimum case_verify_v3_scope_policy_signal_mismatch_is_invalid case_verify_v3_incomplete_scope_manifest_is_invalid case_verify_v3_scope_cross_field_mutations_are_invalid diff --git a/tests/shell/test-pmctl-ship.sh b/tests/shell/test-pmctl-ship.sh index fe041134..3e08ca82 100755 --- a/tests/shell/test-pmctl-ship.sh +++ b/tests/shell/test-pmctl-ship.sh @@ -201,8 +201,13 @@ run_finish_with_fake_gate() { shift 3 bash -c ' repo_root="$1"; work_dir="$2"; ticket_id="$3"; verdict="$4"; shift 4 + artifact_status="${PM_TEST_GATE_ARTIFACT_STATUS:-pass}" subject_status="${PM_TEST_GATE_SUBJECT_STATUS:-pass}" + policy_status="${PM_TEST_GATE_POLICY_STATUS:-pass}" pmctl_gate_run() { + if [[ -n "${PM_TEST_GATE_RUN_MARKER:-}" ]]; then + : > "$PM_TEST_GATE_RUN_MARKER" + fi local result_file result_file="$(mktemp)" printf "Final: %s\n" "$verdict" > "$result_file" @@ -210,22 +215,34 @@ run_finish_with_fake_gate() { [[ "$verdict" == "GO" ]] } pmctl_gate_verify() { + if [[ -n "${PM_TEST_GATE_VERIFY_ARGV:-}" ]]; then + printf "%s\n" "$@" > "$PM_TEST_GATE_VERIFY_ARGV" + fi jq -n \ --arg verdict "$verdict" \ + --arg artifact_status "$artifact_status" \ --arg subject_status "$subject_status" \ + --arg policy_status "$policy_status" \ '"'"'{ kind:"gate_verification_v1", verdict:$verdict, axes:{ - artifact_valid:{status:"pass",reason_codes:[]}, + artifact_valid:{ + status:$artifact_status, + reason_codes:(if $artifact_status == "pass" then [] else ["artifact_integrity_failed"] end) + }, subject_current:{ status:$subject_status, reason_codes:(if $subject_status == "pass" then [] else ["tree_drift"] end) }, - policy_applicable:{status:"pass",reason_codes:[]} + policy_applicable:{ + status:$policy_status, + reason_codes:(if $policy_status == "pass" then [] else ["consumer_policy_below_minimum"] end) + } } }'"'"' - [[ "$verdict" == "GO" && "$subject_status" == "pass" ]] + [[ "$verdict" == "GO" && "$artifact_status" == "pass" \ + && "$subject_status" == "pass" && "$policy_status" == "pass" ]] } . "$repo_root/runtime/lib/pmctl-ship.sh" pmctl_ship_finish "$repo_root" "$work_dir" "$ticket_id" "$@" @@ -1072,7 +1089,7 @@ case_finish_go_stale_subject_does_not_push() { git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ 2>/dev/null && pushed=1 if [[ "$status" -eq 1 ]] \ - && grep -q "invalid, stale, or not applicable" "$err" \ + && grep -q "invalid, stale, or below the publish policy baseline" "$err" \ && [[ "$pushed" -eq 0 ]]; then pass "$name" else @@ -1080,6 +1097,168 @@ case_finish_go_stale_subject_does_not_push() { fi } +# Behavior: an explicit current-tree Gate result may satisfy the publish review +# boundary without spending a second Gate round. +# Steps: supply an absolute artifact, record verifier and Gate invocations, and +# require publish verification, no new Gate, full-suite evidence, and a push. +case_finish_valid_supplied_gate_result_publishes_without_new_gate() { + local name="ship finish: valid absolute supplied Gate result is verified for publish without a new Gate" + should_run "$name" || return 0 + local work out err marker verify_argv status=0 + work="$tmp_root/work-finish-gate-supplied" + marker="$tmp_root/finish-gate-supplied-run" + verify_argv="$tmp_root/finish-gate-supplied-verify-argv" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + mkdir -p "$work/evidence" + printf 'Final: GO\n' > "$work/evidence/gate.md" + git -C "$work" add evidence/gate.md + git -C "$work" commit -q -m supplied-gate-result + local gh_bin="$tmp_root/fake-gh-gate-supplied-bin" + install_fake_gh "$gh_bin" "https://example.invalid/pr/gate-supplied" + out="$tmp_root/out-finish-gate-supplied" + err="$tmp_root/err-finish-gate-supplied" + PM_TEST_GATE_RUN_MARKER="$marker" \ + PM_TEST_GATE_VERIFY_ARGV="$verify_argv" \ + PATH="$gh_bin:$PATH" \ + run_finish_with_fake_gate "$work" "CC-9001" "GO" \ + --gate-result "$work/evidence/gate.md" > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 0 && "$pushed" -eq 1 && ! -e "$marker" ]] \ + && grep -Fxq "$work/evidence/gate.md" "$verify_argv" \ + && grep -Fxq -- '--consumer' "$verify_argv" \ + && grep -Fxq 'publish' "$verify_argv"; then + pass "$name" + else + fail "$name" "status=$status pushed=$pushed gate_called=$([[ -e "$marker" ]] && echo yes || echo no) verify=$(cat "$verify_argv" 2>/dev/null) stderr=$(cat "$err")" + fi +} + +# Behavior: a missing caller-supplied artifact is reported as a path error, not +# as a Gate process that exited successfully without writing a result. +# Steps: supply a missing relative artifact and require exit 1, its resolved +# path in stderr, no fresh Gate invocation, and no pushed branch. +case_finish_missing_supplied_gate_result_reports_artifact_path() { + local name="ship finish: missing supplied Gate result reports the artifact path" + should_run "$name" || return 0 + local work out err marker status=0 + work="$tmp_root/work-finish-gate-missing" + marker="$tmp_root/finish-gate-missing-run" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + local gh_bin="$tmp_root/fake-gh-gate-missing-bin" + install_fake_gh "$gh_bin" "https://example.invalid/pr/gate-missing" + out="$tmp_root/out-finish-gate-missing" + err="$tmp_root/err-finish-gate-missing" + PM_TEST_GATE_RUN_MARKER="$marker" PATH="$gh_bin:$PATH" \ + run_finish_with_fake_gate "$work" "CC-9001" "GO" \ + --gate-result evidence/missing.md > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 1 && "$pushed" -eq 0 && ! -e "$marker" ]] \ + && grep -Fq \ + "supplied --gate-result artifact not found: $work/evidence/missing.md" \ + "$err" \ + && ! grep -Fq 'gate exit 0' "$err"; then + pass "$name" + else + fail "$name" "status=$status pushed=$pushed gate_called=$([[ -e "$marker" ]] && echo yes || echo no) stderr=$(cat "$err")" + fi +} + +# Behavior: supplying an artifact bypasses only Gate dispatch, never the +# current-subject check. +# Steps: make the verifier report subject drift for a supplied result and +# require publication refusal with no pushed branch. +case_finish_stale_supplied_gate_result_refuses_publish() { + local name="ship finish: stale supplied Gate result refuses publication" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-gate-stale" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + printf 'Final: GO\n' > "$work/gate.md" + git -C "$work" add gate.md + git -C "$work" commit -q -m stale-gate-result + local gh_bin="$tmp_root/fake-gh-gate-stale-bin" + install_fake_gh "$gh_bin" "https://example.invalid/pr/gate-stale" + out="$tmp_root/out-finish-gate-stale" + err="$tmp_root/err-finish-gate-stale" + PM_TEST_GATE_SUBJECT_STATUS=fail PATH="$gh_bin:$PATH" \ + run_finish_with_fake_gate "$work" "CC-9001" "GO" \ + --gate-result gate.md > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 1 && "$pushed" -eq 0 ]] \ + && grep -q "invalid, stale, or below the publish policy baseline" "$err"; then + pass "$name" + else + fail "$name" "status=$status pushed=$pushed stderr=$(cat "$err")" + fi +} + +# Behavior: a caller-supplied result remains subject to artifact-integrity +# verification before any full suite or remote mutation. +# Steps: make the verifier reject a supplied artifact and require exit 1, +# the shared verification diagnostic, and no pushed branch. +case_finish_invalid_supplied_gate_result_refuses_publish() { + local name="ship finish: invalid supplied Gate result refuses publication" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-gate-invalid" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + printf 'tampered\n' > "$work/gate.md" + git -C "$work" add gate.md + git -C "$work" commit -q -m invalid-gate-result + local gh_bin="$tmp_root/fake-gh-gate-invalid-bin" + install_fake_gh "$gh_bin" "https://example.invalid/pr/gate-invalid" + out="$tmp_root/out-finish-gate-invalid" + err="$tmp_root/err-finish-gate-invalid" + PM_TEST_GATE_ARTIFACT_STATUS=fail PATH="$gh_bin:$PATH" \ + run_finish_with_fake_gate "$work" "CC-9001" "GO" \ + --gate-result gate.md > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 1 && "$pushed" -eq 0 ]] \ + && grep -q "invalid, stale, or below the publish policy baseline" "$err"; then + pass "$name" + else + fail "$name" "status=$status pushed=$pushed stderr=$(cat "$err")" + fi +} + +# Behavior: reviewer selection is rejected when finish is told to reuse an +# already-produced Gate artifact. +# Steps: combine --gate-result with --reviewers through the public CLI and +# require the mutual-exclusion usage error before side effects. +case_finish_gate_result_rejects_reviewers() { + local name="ship finish: supplied Gate result rejects reviewers" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-gate-reviewers" + make_work_repo "$work" "CC-9001" + out="$tmp_root/out-finish-gate-reviewers" + err="$tmp_root/err-finish-gate-reviewers" + "$PMCTL" ship finish CC-9001 --cd "$work" \ + --gate-result result.md --reviewers critic > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 2 ]] \ + && grep -q -- '--gate-result cannot be combined with --reviewers' "$err"; then + pass "$name" + else + fail "$name" "status=$status stderr=$(cat "$err")" + fi +} + case_finish_go_dirty_tree_refuses_push() { local name="ship finish: GO with an uncommitted (dirty) tree refuses to push -- committed-diff guard" should_run "$name" || return 0 @@ -1146,6 +1325,62 @@ case_finish_go_head_moved_refuses_push() { fi } +# Behavior: HEAD drift while verifying a supplied Gate artifact refuses +# publication without claiming that a fresh Gate ran. +# Steps: commit during the verifier stub, then require the supplied-artifact +# drift diagnostic, no fresh-Gate wording, and no pushed branch. +case_finish_supplied_gate_result_head_moved_refuses_push() { + local name="ship finish: supplied Gate result reports HEAD drift without claiming a Gate ran" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-supplied-headmoved" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + printf 'Final: GO\n' > "$work/gate.md" + git -C "$work" add gate.md + git -C "$work" commit -q -m supplied-gate-result + local gh_bin="$tmp_root/fake-gh-supplied-headmoved-bin" + install_fake_gh "$gh_bin" "https://example.invalid/pr/supplied-headmoved" + out="$tmp_root/out-finish-supplied-headmoved" + err="$tmp_root/err-finish-supplied-headmoved" + PATH="$gh_bin:$PATH" bash -c ' + repo_root="$1"; work_dir="$2"; ticket_id="$3" + pmctl_gate_run() { + printf "unexpected Gate run\n" >&2 + return 99 + } + pmctl_gate_verify() { + printf "sneaky\n" > "$work_dir/sneaky.txt" + git -C "$work_dir" add sneaky.txt + git -C "$work_dir" commit -q -m sneaky + jq -n '"'"'{ + kind:"gate_verification_v1", + verdict:"GO", + axes:{ + artifact_valid:{status:"pass",reason_codes:[]}, + subject_current:{status:"pass",reason_codes:[]}, + policy_applicable:{status:"pass",reason_codes:[]} + } + }'"'"' + } + . "$repo_root/runtime/lib/pmctl-ship.sh" + pmctl_ship_finish "$repo_root" "$work_dir" "$ticket_id" \ + --gate-result "$work_dir/gate.md" + ' _ "$REPO_ROOT" "$work" "CC-9001" > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 1 && "$pushed" -eq 0 ]] \ + && grep -q "HEAD moved while verifying supplied --gate-result" "$err" \ + && ! grep -q "HEAD moved during the gate run" "$err" \ + && ! grep -q "unexpected Gate run" "$err"; then + pass "$name" + else + fail "$name" "status=$status pushed=$pushed stderr=$(cat "$err")" + fi +} + case_finish_gh_missing_refuses_before_gate_or_push() { local name="ship finish: gh unavailable refuses before the gate even runs -- no push, no gate round spent" should_run "$name" || return 0 @@ -1400,6 +1635,64 @@ case_finish_cli_forwards_full_result_option() { fi } +# Behavior: the public CLI recognizes --gate-result and reports its +# finish-specific missing-value diagnostic. +# Steps: invoke the real CLI without an artifact value and require the +# --gate-result parser error rather than an unknown-option failure. +case_finish_cli_forwards_gate_result_option() { + local name="ship finish CLI: --gate-result is forwarded to the finish contract" + should_run "$name" || return 0 + local work out err status=0 + work="$tmp_root/work-finish-cli-gate-result" + make_work_repo "$work" "CC-9001" + out="$tmp_root/out-finish-cli-gate-result" + err="$tmp_root/err-finish-cli-gate-result" + "$PMCTL" ship finish CC-9001 --gate-result --cd "$work" \ + > "$out" 2> "$err" || status=$? + if [[ "$status" -eq 2 ]] \ + && grep -q -- '--gate-result requires an artifact path' "$err"; then + pass "$name" + else + fail "$name" "status=$status stderr=$(cat "$err")" + fi +} + +# Behavior: a valid public --gate-result invocation reaches the real finish +# path without invoking the fixture Gate producer. +# Steps: pass a committed relative artifact through a CLI fixture and require +# the resolved verification message plus a successfully pushed branch. +case_finish_cli_valid_gate_result_publishes() { + local name="ship finish CLI: valid --gate-result reaches publish verification" + should_run "$name" || return 0 + local work product out err status=0 + work="$tmp_root/work-finish-cli-gate-success" + product="$tmp_root/product-cli-gate-success" + make_work_repo "$work" "CC-9001" + checkout_ticket_branch "$work" "CC-9001" + add_bare_origin "$work" + mkdir -p "$work/evidence" + printf 'Final: GO\n' > "$work/evidence/gate.md" + git -C "$work" add evidence/gate.md + git -C "$work" commit -q -m cli-supplied-gate-result + make_cli_fixture_with_fake_gate "$product" + local gh_bin="$tmp_root/fake-gh-cli-gate-success-bin" + install_fake_gh "$gh_bin" "https://example.invalid/pr/cli-gate-success" + out="$tmp_root/out-finish-cli-gate-success" + err="$tmp_root/err-finish-cli-gate-success" + PATH="$gh_bin:$PATH" \ + "$product/cli/pmctl" ship finish CC-9001 --cd "$work" \ + --gate-result evidence/gate.md > "$out" 2> "$err" || status=$? + local pushed=0 + git -C "$work.bare-origin.git" show-ref --quiet feat/CC-9001 \ + 2>/dev/null && pushed=1 + if [[ "$status" -eq 0 && "$pushed" -eq 1 ]] \ + && grep -q "verifying supplied Gate result: $work/evidence/gate.md" "$out"; then + pass "$name" + else + fail "$name" "status=$status pushed=$pushed stderr=$(cat "$err")" + fi +} + case_finish_cli_valid_full_result_publishes() { # Behavior: a valid public --full-result invocation reaches the real finish verifier and publish path. # Steps: invoke a minimal real CLI/runtime fixture with a post-load fake gate, then require resolved verification and push. @@ -1993,8 +2286,14 @@ case_finish_missing_result_file case_finish_missing_shared_verifier_refuses_publish case_finish_malformed_shared_assessment_refuses_publish case_finish_go_stale_subject_does_not_push +case_finish_valid_supplied_gate_result_publishes_without_new_gate +case_finish_missing_supplied_gate_result_reports_artifact_path +case_finish_stale_supplied_gate_result_refuses_publish +case_finish_invalid_supplied_gate_result_refuses_publish +case_finish_gate_result_rejects_reviewers case_finish_go_dirty_tree_refuses_push case_finish_go_head_moved_refuses_push +case_finish_supplied_gate_result_head_moved_refuses_push case_finish_gh_missing_refuses_before_gate_or_push case_finish_wrong_branch_refuses_before_gate_or_push case_finish_go_pushes_and_opens_pr @@ -2004,6 +2303,8 @@ case_finish_failed_full_suite_refuses_publish case_finish_post_suite_head_drift_refuses_publish case_finish_valid_supplied_full_result_publishes case_finish_cli_forwards_full_result_option +case_finish_cli_forwards_gate_result_option +case_finish_cli_valid_gate_result_publishes case_finish_cli_valid_full_result_publishes case_finish_gh_pr_create_runtime_failure_writes_pushed_pr_failed_marker case_status_reports_partial_for_pushed_pr_failed