[object Object]

← back to Cli Printing Press

fix(cli): gate publishing on Phase 5 proof (#558)

7a1438182b97a2707840b3d7813aa758626c69b5 · 2026-05-03 20:10:54 -0700 · Trevin Chow

* fix(cli): gate publishing on Phase 5 proof

* fix(cli): honor quick Phase 5 threshold

Files touched

Diff

commit 7a1438182b97a2707840b3d7813aa758626c69b5
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 3 20:10:54 2026 -0700

    fix(cli): gate publishing on Phase 5 proof (#558)
    
    * fix(cli): gate publishing on Phase 5 proof
    
    * fix(cli): honor quick Phase 5 threshold
---
 internal/cli/library_test.go           |   8 ++
 internal/cli/lock_test.go              |  16 +++
 internal/cli/publish.go                |  42 +++++++
 internal/cli/publish_test.go           |  57 ++++++++-
 internal/pipeline/climanifest.go       |  15 ++-
 internal/pipeline/lock.go              |  30 +++++
 internal/pipeline/lock_test.go         |  39 ++++++
 internal/pipeline/phase5_gate.go       | 216 +++++++++++++++++++++++++++++++++
 internal/pipeline/phase5_gate_test.go  | 212 ++++++++++++++++++++++++++++++++
 skills/printing-press-publish/SKILL.md |   2 +
 skills/printing-press/SKILL.md         |  68 +++++++++--
 11 files changed, 693 insertions(+), 12 deletions(-)

diff --git a/internal/cli/library_test.go b/internal/cli/library_test.go
index 5ae8ecdd..0990d0e8 100644
--- a/internal/cli/library_test.go
+++ b/internal/cli/library_test.go
@@ -32,6 +32,14 @@ func writeTestManifest(t *testing.T, dir string, m pipeline.CLIManifest) {
 	require.NoError(t, os.WriteFile(filepath.Join(dir, pipeline.CLIManifestFilename), data, 0o644))
 }
 
+func writeTestPhase5GateMarker(t *testing.T, proofsDir, name string, marker pipeline.Phase5GateMarker) {
+	t.Helper()
+	require.NoError(t, os.MkdirAll(proofsDir, 0o755))
+	data, err := json.MarshalIndent(marker, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(proofsDir, name), data, 0o644))
+}
+
 func TestLibraryListJSONWithManifests(t *testing.T) {
 	home := setLibraryTestEnv(t)
 	libDir := filepath.Join(home, "library")
diff --git a/internal/cli/lock_test.go b/internal/cli/lock_test.go
index 9a0964bc..bddb1867 100644
--- a/internal/cli/lock_test.go
+++ b/internal/cli/lock_test.go
@@ -164,6 +164,7 @@ func TestLockPromote_Success(t *testing.T) {
 	// Create state file for the run.
 	state := pipeline.NewStateWithRun("test", workDir, runID, "test-scope")
 	require.NoError(t, state.Save())
+	writeLockPhase5Pass(t, state)
 
 	// Acquire lock.
 	_, code := runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "test-scope")
@@ -183,6 +184,21 @@ func TestLockPromote_Success(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func writeLockPhase5Pass(t *testing.T, state *pipeline.PipelineState) {
+	t.Helper()
+	writeTestPhase5GateMarker(t, state.ProofsDir(), pipeline.Phase5AcceptanceFilename, pipeline.Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       state.APIName,
+		RunID:         state.RunID,
+		Status:        "pass",
+		Level:         "full",
+		MatrixSize:    1,
+		TestsPassed:   1,
+		TestsFailed:   0,
+		AuthContext:   pipeline.Phase5AuthContext{Type: "none"},
+	})
+}
+
 func TestLockHelpOutput(t *testing.T) {
 	cmd := newLockCmd()
 	buf := new(bytes.Buffer)
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 4e1bdad1..a44ff106 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -524,6 +524,12 @@ func runValidation(dir string) ValidateResult {
 		result.Checks = append(result.Checks, CheckResult{Name: "transcendence", Passed: true})
 	}
 
+	phase5Check := checkPhase5Gate(dir, manifest)
+	if !phase5Check.Passed {
+		allPassed = false
+	}
+	result.Checks = append(result.Checks, phase5Check)
+
 	cliName := result.CLIName
 	if cliName == "" {
 		cliName = filepath.Base(dir)
@@ -635,6 +641,42 @@ func runValidation(dir string) ValidateResult {
 	return result
 }
 
+func checkPhase5Gate(dir string, manifest pipeline.CLIManifest) CheckResult {
+	if manifest.APIName == "" || manifest.CLIName == "" {
+		return CheckResult{Name: "phase5", Passed: false, Error: "manifest unavailable"}
+	}
+	if manifest.RunID == "" {
+		return CheckResult{Name: "phase5", Passed: false, Error: "manifest missing run_id; cannot locate Phase 5 gate proof"}
+	}
+
+	proofsDir := phase5ProofsDir(dir, manifest)
+	result := pipeline.ValidatePhase5Gate(proofsDir, manifest)
+	if !result.Passed {
+		return CheckResult{Name: "phase5", Passed: false, Error: result.Detail}
+	}
+	return CheckResult{Name: "phase5", Passed: true}
+}
+
+func phase5ProofsDir(dir string, manifest pipeline.CLIManifest) string {
+	runID := manifest.RunID
+	candidates := []string{
+		filepath.Join(dir, ".manuscripts", runID, "proofs"),
+	}
+	msRoot := pipeline.PublishedManuscriptsRoot()
+	if manifest.CLIName != "" {
+		candidates = append(candidates, filepath.Join(msRoot, manifest.CLIName, runID, "proofs"))
+	}
+	if manifest.APIName != "" {
+		candidates = append(candidates, filepath.Join(msRoot, manifest.APIName, runID, "proofs"))
+	}
+	for _, candidate := range candidates {
+		if info, err := os.Stat(candidate); err == nil && info.IsDir() {
+			return candidate
+		}
+	}
+	return candidates[0]
+}
+
 func checkVerifySkill(dir string) CheckResult {
 	run, err := runVerifySkillScript(dir, nil, false, false)
 	if err != nil {
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index ecf07096..2fd7da14 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -150,13 +150,47 @@ func TestPublishValidateJSONHasAllChecks(t *testing.T) {
 	}
 
 	// All checks should be present (they may fail in test env, but must exist)
-	expectedChecks := []string{"manifest", "transcendence", "go mod tidy", "go vet", "go build", "--help", "--version", "verify-skill", "manuscripts"}
+	expectedChecks := []string{"manifest", "transcendence", "phase5", "go mod tidy", "go vet", "go build", "--help", "--version", "verify-skill", "manuscripts"}
 	for _, name := range expectedChecks {
 		assert.True(t, checkNames[name], "should have %q check", name)
 	}
 	assert.Len(t, result.Checks, len(expectedChecks), "should have exactly the expected checks")
 }
 
+func TestPublishValidateFailsWithoutPhase5Marker(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	writePublishableTestCLI(t, cliDir)
+	writeTestManifest(t, cliDir, pipeline.CLIManifest{
+		SchemaVersion: 1,
+		APIName:       "test",
+		CLIName:       "test-pp-cli",
+		RunID:         "run-missing-phase5",
+		AuthType:      "api_key",
+	})
+
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.Error(t, err)
+
+	var result ValidateResult
+	require.NoError(t, json.Unmarshal([]byte(output), &result))
+	assert.False(t, result.Passed)
+
+	var phase5Check *CheckResult
+	for i := range result.Checks {
+		if result.Checks[i].Name == "phase5" {
+			phase5Check = &result.Checks[i]
+			break
+		}
+	}
+	require.NotNil(t, phase5Check)
+	assert.False(t, phase5Check.Passed)
+	assert.Contains(t, phase5Check.Error, "missing")
+}
+
 func TestPublishValidateRequiresTranscendenceFeatures(t *testing.T) {
 	home := setLibraryTestEnv(t)
 	cliDir := filepath.Join(home, "library", "test-pp-cli")
@@ -826,5 +860,26 @@ func newInsightCmd() *cobra.Command {
 		SchemaVersion: 1,
 		APIName:       "test",
 		CLIName:       "test-pp-cli",
+		RunID:         "20260301-000000",
+		AuthType:      "none",
+	})
+	writePublishablePhase5Pass(t)
+}
+
+func writePublishablePhase5Pass(t *testing.T) {
+	t.Helper()
+	home := os.Getenv("PRINTING_PRESS_HOME")
+	require.NotEmpty(t, home)
+	proofsDir := filepath.Join(home, "manuscripts", "test", "20260301-000000", "proofs")
+	writeTestPhase5GateMarker(t, proofsDir, pipeline.Phase5AcceptanceFilename, pipeline.Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "20260301-000000",
+		Status:        "pass",
+		Level:         "full",
+		MatrixSize:    1,
+		TestsPassed:   1,
+		TestsFailed:   0,
+		AuthContext:   pipeline.Phase5AuthContext{Type: "none"},
 	})
 }
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 19e2b0b8..c9a0ddb1 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -91,15 +91,24 @@ type NovelFeatureManifest struct {
 // bundle builder, which can't store the CLI binary name in manifest.json
 // (Claude Desktop's MCPB v0.3 validator rejects unknown top-level keys).
 func ReadCLIBinaryName(dir string) string {
-	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	m, err := ReadCLIManifest(dir)
 	if err != nil {
 		return ""
 	}
+	return m.CLIName
+}
+
+// ReadCLIManifest decodes dir/.printing-press.json.
+func ReadCLIManifest(dir string) (CLIManifest, error) {
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	if err != nil {
+		return CLIManifest{}, err
+	}
 	var m CLIManifest
 	if err := json.Unmarshal(data, &m); err != nil {
-		return ""
+		return CLIManifest{}, err
 	}
-	return m.CLIName
+	return m, nil
 }
 
 // RefreshCLIManifestFromSpec rereads dir/.printing-press.json, overlays the
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 5626597e..fd5c639b 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -226,6 +226,9 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
 	if len(entries) == 0 {
 		return fmt.Errorf("working directory is empty: %s", workingDir)
 	}
+	if err := validatePhase5GateForPromote(workingDir, state); err != nil {
+		return err
+	}
 
 	slug := naming.TrimCLISuffix(cliName)
 	libraryDir := filepath.Join(PublishedLibraryRoot(), slug)
@@ -321,6 +324,33 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
 	}
 }
 
+func validatePhase5GateForPromote(workingDir string, state *PipelineState) error {
+	if state == nil || state.RunID == "" {
+		return nil
+	}
+
+	manifest := CLIManifest{
+		APIName: state.APIName,
+		CLIName: naming.CLI(state.APIName),
+		RunID:   state.RunID,
+	}
+	if existing, err := ReadCLIManifest(workingDir); err == nil {
+		if existing.APIName != "" {
+			manifest.APIName = existing.APIName
+		}
+		if existing.CLIName != "" {
+			manifest.CLIName = existing.CLIName
+		}
+		manifest.AuthType = existing.AuthType
+	}
+
+	result := ValidatePhase5Gate(state.ProofsDir(), manifest)
+	if result.Passed {
+		return nil
+	}
+	return fmt.Errorf("phase5 gate failed: %s", result.Detail)
+}
+
 // IsStale returns true if the lock's UpdatedAt is older than StaleLockThreshold.
 func IsStale(lock *LockState) bool {
 	return time.Since(lock.UpdatedAt) > StaleLockThreshold
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index e50d4a0e..f27e0169 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -328,6 +328,7 @@ func TestPromoteWorkingCLI(t *testing.T) {
 
 	// Create minimal state.
 	state := NewStateWithRun("test", workDir, "run-001", "test-scope")
+	writePhase5PassForState(t, state, "none")
 
 	err = PromoteWorkingCLI("test-pp-cli", workDir, state)
 	require.NoError(t, err)
@@ -369,6 +370,7 @@ func TestPromoteWorkingCLI_ReplacesExistingLibrary(t *testing.T) {
 	require.NoError(t, err)
 
 	state := NewStateWithRun("test", workDir, "run-002", "test-scope")
+	writePhase5PassForState(t, state, "none")
 
 	err = PromoteWorkingCLI("test-pp-cli", workDir, state)
 	require.NoError(t, err)
@@ -445,6 +447,7 @@ func TestPromoteWorkingCLI_RetryRestoresBackupBeforeFailure(t *testing.T) {
 	require.NoError(t, os.Symlink(outside, filepath.Join(workDir, "bad-link.txt")))
 
 	state := NewStateWithRun("test", workDir, "run-004", "test-scope")
+	writePhase5PassForState(t, state, "none")
 
 	err := PromoteWorkingCLI("test-pp-cli", workDir, state)
 	require.Error(t, err)
@@ -473,6 +476,7 @@ func TestPromoteWorkingCLI_ReleasesLockWhenStateSaveFails(t *testing.T) {
 	require.NoError(t, err)
 
 	state := NewStateWithRun("test", workDir, "run-005", "test-scope")
+	writePhase5PassForState(t, state, "none")
 
 	// Force state.Save() to fail after the library swap succeeds.
 	require.NoError(t, os.MkdirAll(filepath.Dir(state.PipelineDir()), 0o755))
@@ -493,6 +497,26 @@ func TestPromoteWorkingCLI_ReleasesLockWhenStateSaveFails(t *testing.T) {
 	assert.True(t, os.IsNotExist(err))
 }
 
+func TestPromoteWorkingCLI_RequiresPhase5GateForRunstatePromote(t *testing.T) {
+	tmp := t.TempDir()
+	t.Setenv("PRINTING_PRESS_HOME", tmp)
+	t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+	t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+	workDir := filepath.Join(tmp, "working", "test-pp-cli")
+	require.NoError(t, os.MkdirAll(workDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+	state := NewStateWithRun("test", workDir, "run-no-phase5", "test-scope")
+	err := PromoteWorkingCLI("test-pp-cli", workDir, state)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "phase5")
+
+	_, statErr := os.Stat(filepath.Join(PublishedLibraryRoot(), "test"))
+	assert.ErrorIs(t, statErr, os.ErrNotExist)
+}
+
 func TestPromoteWorkingCLI_MinimalStateNoRunstate(t *testing.T) {
 	tmp := t.TempDir()
 	t.Setenv("PRINTING_PRESS_HOME", tmp)
@@ -537,6 +561,21 @@ func TestIsStale(t *testing.T) {
 	assert.True(t, IsStale(boundary))
 }
 
+func writePhase5PassForState(t *testing.T, state *PipelineState, authType string) {
+	t.Helper()
+	writePhase5GateMarker(t, state.ProofsDir(), Phase5AcceptanceFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       state.APIName,
+		RunID:         state.RunID,
+		Status:        "pass",
+		Level:         "full",
+		MatrixSize:    1,
+		TestsPassed:   1,
+		TestsFailed:   0,
+		AuthContext:   Phase5AuthContext{Type: authType},
+	})
+}
+
 func TestConcurrentAcquire(t *testing.T) {
 	setupLockTest(t)
 
diff --git a/internal/pipeline/phase5_gate.go b/internal/pipeline/phase5_gate.go
new file mode 100644
index 00000000..a02014dd
--- /dev/null
+++ b/internal/pipeline/phase5_gate.go
@@ -0,0 +1,216 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+const (
+	Phase5AcceptanceFilename = "phase5-acceptance.json"
+	Phase5SkipFilename       = "phase5-skip.json"
+)
+
+type Phase5AuthContext struct {
+	Type                    string `json:"type,omitempty"`
+	APIKeyAvailable         bool   `json:"api_key_available,omitempty"`
+	BrowserSessionAvailable bool   `json:"browser_session_available,omitempty"`
+}
+
+type Phase5GateMarker struct {
+	SchemaVersion int               `json:"schema_version"`
+	APIName       string            `json:"api_name,omitempty"`
+	RunID         string            `json:"run_id,omitempty"`
+	Status        string            `json:"status"`
+	Level         string            `json:"level,omitempty"`
+	MatrixSize    int               `json:"matrix_size,omitempty"`
+	TestsPassed   int               `json:"tests_passed,omitempty"`
+	TestsFailed   int               `json:"tests_failed,omitempty"`
+	AuthContext   Phase5AuthContext `json:"auth_context,omitzero"`
+	SkipReason    string            `json:"skip_reason,omitempty"`
+}
+
+type Phase5GateValidation struct {
+	Passed     bool
+	Status     string
+	MarkerPath string
+	Detail     string
+}
+
+func ValidatePhase5Gate(proofsDir string, manifest CLIManifest) Phase5GateValidation {
+	if strings.TrimSpace(proofsDir) == "" {
+		return Phase5GateValidation{Detail: "phase5 proofs directory is empty"}
+	}
+
+	if result, ok := validatePhase5MarkerFile(filepath.Join(proofsDir, Phase5AcceptanceFilename), manifest, false); ok {
+		return result
+	}
+	if result, ok := validatePhase5MarkerFile(filepath.Join(proofsDir, Phase5SkipFilename), manifest, true); ok {
+		return result
+	}
+
+	return Phase5GateValidation{
+		Detail: fmt.Sprintf("missing %s or %s in %s", Phase5AcceptanceFilename, Phase5SkipFilename, proofsDir),
+	}
+}
+
+func validatePhase5MarkerFile(path string, manifest CLIManifest, skipFile bool) (Phase5GateValidation, bool) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return Phase5GateValidation{}, false
+		}
+		return Phase5GateValidation{MarkerPath: path, Detail: fmt.Sprintf("reading phase5 marker: %v", err)}, true
+	}
+
+	var marker Phase5GateMarker
+	if err := json.Unmarshal(data, &marker); err != nil {
+		return Phase5GateValidation{MarkerPath: path, Detail: fmt.Sprintf("parsing phase5 marker: %v", err)}, true
+	}
+
+	result := validatePhase5Marker(marker, manifest, skipFile)
+	result.MarkerPath = path
+	return result, true
+}
+
+func validatePhase5Marker(marker Phase5GateMarker, manifest CLIManifest, skipFile bool) Phase5GateValidation {
+	status := strings.ToLower(strings.TrimSpace(marker.Status))
+	result := Phase5GateValidation{Status: status}
+
+	if marker.SchemaVersion != 1 {
+		result.Detail = fmt.Sprintf("unsupported phase5 marker schema_version %d", marker.SchemaVersion)
+		return result
+	}
+	if marker.APIName != "" && manifest.APIName != "" && marker.APIName != manifest.APIName {
+		result.Detail = fmt.Sprintf("phase5 marker api_name %q does not match manifest api_name %q", marker.APIName, manifest.APIName)
+		return result
+	}
+	if marker.RunID != "" && manifest.RunID != "" && marker.RunID != manifest.RunID {
+		result.Detail = fmt.Sprintf("phase5 marker run_id %q does not match manifest run_id %q", marker.RunID, manifest.RunID)
+		return result
+	}
+
+	switch status {
+	case "pass":
+		if skipFile {
+			result.Detail = fmt.Sprintf("%s must use status skip, got pass", Phase5SkipFilename)
+			return result
+		}
+		if detail := validatePhase5PassMarker(marker); detail != "" {
+			result.Detail = detail
+			return result
+		}
+		if ok, detail := phase5AcceptancePassed(marker); !ok {
+			result.Detail = detail
+			return result
+		}
+		result.Passed = true
+		return result
+	case "fail":
+		result.Detail = "phase5 gate status is fail"
+		return result
+	case "skip":
+		if !skipFile {
+			result.Detail = fmt.Sprintf("%s must use status pass or fail, got skip", Phase5AcceptanceFilename)
+			return result
+		}
+		if detail := validatePhase5SkipMarker(marker); detail != "" {
+			result.Detail = detail
+			return result
+		}
+		if ok, detail := phase5SkipAllowed(marker, manifest); !ok {
+			result.Detail = detail
+			return result
+		}
+		result.Passed = true
+		return result
+	default:
+		result.Detail = fmt.Sprintf("unknown phase5 gate status %q", marker.Status)
+		return result
+	}
+}
+
+func validatePhase5PassMarker(marker Phase5GateMarker) string {
+	switch {
+	case strings.TrimSpace(marker.APIName) == "":
+		return "phase5 acceptance marker missing api_name"
+	case strings.TrimSpace(marker.RunID) == "":
+		return "phase5 acceptance marker missing run_id"
+	case phase5Level(marker) == "":
+		return "phase5 acceptance marker missing level"
+	case marker.MatrixSize <= 0:
+		return "phase5 acceptance marker missing matrix_size"
+	case marker.TestsPassed <= 0:
+		return "phase5 acceptance marker missing tests_passed"
+	default:
+		return ""
+	}
+}
+
+func phase5AcceptancePassed(marker Phase5GateMarker) (bool, string) {
+	level := phase5Level(marker)
+	switch level {
+	case "quick":
+		if marker.MatrixSize != 6 {
+			return false, fmt.Sprintf("phase5 quick acceptance expected matrix_size 6, got %d", marker.MatrixSize)
+		}
+		if marker.TestsPassed < 5 {
+			return false, fmt.Sprintf("phase5 quick acceptance requires at least 5/6 tests passed, got %d/6", marker.TestsPassed)
+		}
+		return true, ""
+	case "full":
+		if marker.TestsFailed != 0 {
+			return false, fmt.Sprintf("phase5 full acceptance has %d failed tests", marker.TestsFailed)
+		}
+		if marker.TestsPassed != marker.MatrixSize {
+			return false, fmt.Sprintf("phase5 full acceptance requires all %d tests passed, got %d", marker.MatrixSize, marker.TestsPassed)
+		}
+		return true, ""
+	default:
+		return false, fmt.Sprintf("unknown phase5 acceptance level %q", marker.Level)
+	}
+}
+
+func phase5Level(marker Phase5GateMarker) string {
+	return strings.ToLower(strings.TrimSpace(marker.Level))
+}
+
+func validatePhase5SkipMarker(marker Phase5GateMarker) string {
+	switch {
+	case strings.TrimSpace(marker.APIName) == "":
+		return "phase5 skip marker missing api_name"
+	case strings.TrimSpace(marker.RunID) == "":
+		return "phase5 skip marker missing run_id"
+	case strings.TrimSpace(marker.SkipReason) == "":
+		return "phase5 skip marker missing skip_reason"
+	default:
+		return ""
+	}
+}
+
+func phase5SkipAllowed(marker Phase5GateMarker, manifest CLIManifest) (bool, string) {
+	authType := strings.ToLower(strings.TrimSpace(manifest.AuthType))
+	markerAuthType := strings.ToLower(strings.TrimSpace(marker.AuthContext.Type))
+	if authType == "" {
+		authType = markerAuthType
+	} else if markerAuthType != "" && markerAuthType != authType {
+		return false, fmt.Sprintf("phase5 skip marker auth type %q does not match manifest auth type %q", marker.AuthContext.Type, manifest.AuthType)
+	}
+	if authType == "" || authType == "none" {
+		return false, "no-auth APIs require a phase5 pass marker, not a skip marker"
+	}
+	if marker.AuthContext.APIKeyAvailable {
+		return false, "phase5 skip claims an API key was available"
+	}
+	if authRequiresCredential(authType) {
+		return true, ""
+	}
+	switch authType {
+	case "cookie", "composed", "session_handshake":
+		return false, "browser-session auth APIs require phase5 acceptance; missing API key is not a valid skip"
+	default:
+		return false, fmt.Sprintf("phase5 skip not allowed for auth type %q", authType)
+	}
+}
diff --git a/internal/pipeline/phase5_gate_test.go b/internal/pipeline/phase5_gate_test.go
new file mode 100644
index 00000000..cd2ea5f9
--- /dev/null
+++ b/internal/pipeline/phase5_gate_test.go
@@ -0,0 +1,212 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func writePhase5GateMarker(t *testing.T, proofsDir, name string, marker Phase5GateMarker) {
+	t.Helper()
+	require.NoError(t, os.MkdirAll(proofsDir, 0o755))
+	data, err := json.MarshalIndent(marker, "", "  ")
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(proofsDir, name), data, 0o644))
+}
+
+func TestValidatePhase5Gate_PassMarker(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+	writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "pass",
+		Level:         "full",
+		MatrixSize:    3,
+		TestsPassed:   3,
+		TestsFailed:   0,
+		AuthContext:   Phase5AuthContext{Type: "none"},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.True(t, result.Passed, result.Detail)
+	assert.Equal(t, "pass", result.Status)
+	assert.Equal(t, filepath.Join(proofsDir, Phase5AcceptanceFilename), result.MarkerPath)
+}
+
+func TestValidatePhase5Gate_QuickPassAllowsOneNonBlockingMiss(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+	writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "pass",
+		Level:         "quick",
+		MatrixSize:    6,
+		TestsPassed:   5,
+		TestsFailed:   1,
+		AuthContext:   Phase5AuthContext{Type: "none"},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.True(t, result.Passed, result.Detail)
+	assert.Equal(t, "pass", result.Status)
+}
+
+func TestValidatePhase5Gate_QuickPassRequiresFiveOfSix(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+	writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "pass",
+		Level:         "quick",
+		MatrixSize:    6,
+		TestsPassed:   4,
+		TestsFailed:   2,
+		AuthContext:   Phase5AuthContext{Type: "none"},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "5/6")
+}
+
+func TestValidatePhase5Gate_FullPassRejectsFailures(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+	writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "pass",
+		Level:         "full",
+		MatrixSize:    6,
+		TestsPassed:   5,
+		TestsFailed:   1,
+		AuthContext:   Phase5AuthContext{Type: "none"},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "full")
+}
+
+func TestValidatePhase5Gate_NoAuthRequiresPassMarker(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+	writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "skip",
+		Level:         "none",
+		SkipReason:    "auth_required_no_credential",
+		AuthContext:   Phase5AuthContext{Type: "none"},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "no-auth")
+}
+
+func TestValidatePhase5Gate_APIKeyMissingSkipAllowed(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "api_key"}
+	writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "skip",
+		Level:         "none",
+		SkipReason:    "auth_required_no_credential",
+		AuthContext:   Phase5AuthContext{Type: "api_key", APIKeyAvailable: false},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.True(t, result.Passed, result.Detail)
+	assert.Equal(t, "skip", result.Status)
+}
+
+func TestValidatePhase5Gate_CookieAuthNotSkippedByMissingAPIKey(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "cookie"}
+	writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "skip",
+		Level:         "none",
+		SkipReason:    "auth_required_no_credential",
+		AuthContext:   Phase5AuthContext{Type: "cookie", APIKeyAvailable: false},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "browser-session")
+}
+
+func TestValidatePhase5Gate_SkipCannotOverrideManifestAuthType(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "cookie"}
+	writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		RunID:         "run-1",
+		Status:        "skip",
+		Level:         "none",
+		SkipReason:    "auth_required_no_credential",
+		AuthContext:   Phase5AuthContext{Type: "api_key", APIKeyAvailable: false},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "does not match")
+}
+
+func TestValidatePhase5Gate_PassMarkerRequiresIdentityAndTestCount(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+	writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		Status:        "pass",
+		Level:         "full",
+		MatrixSize:    1,
+		TestsPassed:   1,
+		AuthContext:   Phase5AuthContext{Type: "none"},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "api_name")
+}
+
+func TestValidatePhase5Gate_SkipMarkerRequiresIdentity(t *testing.T) {
+	proofsDir := t.TempDir()
+	manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "api_key"}
+	writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+		SchemaVersion: 1,
+		APIName:       "test",
+		Status:        "skip",
+		Level:         "none",
+		SkipReason:    "auth_required_no_credential",
+		AuthContext:   Phase5AuthContext{Type: "api_key", APIKeyAvailable: false},
+	})
+
+	result := ValidatePhase5Gate(proofsDir, manifest)
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "run_id")
+}
+
+func TestValidatePhase5Gate_MissingMarkerFails(t *testing.T) {
+	result := ValidatePhase5Gate(t.TempDir(), CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "api_key"})
+	require.False(t, result.Passed)
+	assert.Contains(t, result.Detail, "missing")
+}
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index 88e75682..b868cb27 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -159,6 +159,7 @@ Parse the JSON result. Display each check result to the user:
 ```
 Validating <api-slug>...
   manifest        PASS
+  phase5          PASS
   go mod tidy     PASS
   go vet          PASS
   go build        PASS
@@ -729,6 +730,7 @@ $ <cli-name> --help
 | Check | Result |
 |-------|--------|
 | Manifest | PASS/FAIL |
+| Phase 5 | PASS/FAIL |
 | go mod tidy | PASS/FAIL |
 | go vet | PASS/FAIL |
 | go build | PASS/FAIL |
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 415e5399..3d4b8244 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2381,8 +2381,9 @@ When a test fails, fix it immediately — do not accumulate failures. Tag each f
 
 ### Step 4: Report and gate
 
-Write a structured acceptance report. This report is **required** — Phase 5.6
-checks for it before promoting.
+Write a structured acceptance report and a machine-readable gate marker. The
+JSON marker is **required** — Phase 5.6 and `publish validate` check for it
+before promoting or publishing.
 
 ```
 Acceptance Report: <api>
@@ -2421,6 +2422,58 @@ Write:
 
 `$PROOFS_DIR/<stamp>-fix-<api>-pp-cli-acceptance.md`
 
+For `Gate: PASS`, also write:
+
+`$PROOFS_DIR/phase5-acceptance.json`
+
+```json
+{
+  "schema_version": 1,
+  "api_name": "<api>",
+  "run_id": "<run-id>",
+  "status": "pass",
+  "level": "quick|full",
+  "matrix_size": 42,
+  "tests_passed": 42,
+  "tests_failed": 0,
+  "auth_context": {
+    "type": "none|api_key|bearer_token|cookie|composed|session_handshake",
+    "api_key_available": true,
+    "browser_session_available": false
+  }
+}
+```
+
+For `level: "quick"`, `tests_failed` may be `1` only when the Quick Check
+threshold still passed (`matrix_size: 6`, `tests_passed >= 5`) and the miss was
+not auth or sync related. For `level: "full"`, `tests_failed` must be `0`.
+
+If Phase 5 is legitimately skipped because the API requires API-key or bearer
+auth and no credential was available, write:
+
+`$PROOFS_DIR/phase5-skip.json`
+
+```json
+{
+  "schema_version": 1,
+  "api_name": "<api>",
+  "run_id": "<run-id>",
+  "status": "skip",
+  "level": "none",
+  "skip_reason": "auth_required_no_credential",
+  "auth_context": {
+    "type": "api_key|bearer_token|oauth2",
+    "api_key_available": false,
+    "browser_session_available": false
+  }
+}
+```
+
+Do **not** write a skip marker for `auth.type: none`. No-auth APIs are testable
+and require `phase5-acceptance.json`. Do **not** use missing API key as the skip
+reason for cookie, composed, or session-handshake auth; those require browser
+session proof or a hold decision.
+
 ## Phase 5.5: Polish
 
 **Always runs.** Invoke the `printing-press-polish` skill to run diagnostics, fix quality issues, and return a delta. The polish skill carries `context: fork` in its frontmatter, so its diagnostic-fix-rediagnose loop runs in a forked context — diagnostic spam, fix iterations, and re-audits stay scoped to the polish session and don't pollute this generation flow. The skill is autonomous — no user input needed. The goal is to ship the best CLI possible, not the fastest.
@@ -2458,13 +2511,12 @@ Write the polish skill's full response to:
 
 ### Acceptance gate check
 
-Before promoting, verify the acceptance artifact exists when an API key was
-available during this run:
+Before promoting, verify the Phase 5 JSON gate marker:
 
-- If `$PROOFS_DIR/*-acceptance.md` exists with `Gate: PASS` → proceed to promote.
-- If `$PROOFS_DIR/*-acceptance.md` exists with `Gate: FAIL` → CLI is on hold. Do NOT promote. Proceed to Archive Manuscripts.
-- If no acceptance artifact exists AND an API key was available → Phase 5 was skipped. Go back and run it. Do NOT promote without it.
-- If no acceptance artifact exists AND no API key was available → acceptable. Proceed to promote (the CLI was verified mechanically only).
+- If `$PROOFS_DIR/phase5-acceptance.json` exists with `status: "pass"` → proceed to promote.
+- If `$PROOFS_DIR/phase5-acceptance.json` exists with `status: "fail"` → CLI is on hold. Do NOT promote. Proceed to Archive Manuscripts.
+- If `$PROOFS_DIR/phase5-skip.json` exists and the auth-aware skip is valid → proceed to promote.
+- If neither JSON marker exists → Phase 5 was skipped or not recorded. Go back and run it, or write the valid skip marker. Do NOT promote without one.
 
 ### Promote to Library
 

← aae2af9f feat(skills): retro files parent issue + per-WU sub-issues (  ·  back to Cli Printing Press  ·  feat(cli): add live dogfood matrix runner (#559) e973d5c5 →