Repository navigation
SPDD 2026-07-05: outcome evaluators, policy compiler, and spec coverage for batch 20-24 #43594
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
1501d80
84bb096
7813df4
e2c15eb
12d7449
12d7ebb
3315d2c
c3f8ad4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "math" | ||
|
|
||
| "github.com/github/gh-aw/pkg/logger" | ||
| ) | ||
|
|
||
| var outcomeEvalWorkflowLog = logger.New("cli:outcome_eval_workflow") | ||
| var workflowOutcomeGHAPIGet = ghAPIGet | ||
|
|
||
| // evalDispatchWorkflow checks whether a dispatched workflow run completed successfully. | ||
| // It looks for a run_id in the item metadata and queries the workflow run status. | ||
| // Spec: specs/safe-output-outcome-evaluation.md §20 | ||
| func evalDispatchWorkflow(item CreatedItemReport, repoOverride string) OutcomeReport { | ||
| repo := resolveItemRepo(item, repoOverride) | ||
| outcomeEvalWorkflowLog.Printf("Evaluating dispatch_workflow: repo=%s, url=%s", repo, item.URL) | ||
|
|
||
| report := OutcomeReport{ | ||
| Type: item.Type, | ||
| ObjectURL: item.URL, | ||
| Repo: repo, | ||
| } | ||
|
|
||
| // Extract run_id from metadata if available. | ||
| // JSON numbers unmarshal as float64; convert carefully to int64 to avoid | ||
| // precision loss for large GitHub run IDs (which can exceed 2^32). | ||
| var runID int64 | ||
| if item.Metadata != nil { | ||
| if v, ok := item.Metadata["run_id"]; ok { | ||
| switch id := v.(type) { | ||
| case float64: | ||
| if id > 0 && id <= math.MaxInt64 && id == math.Trunc(id) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness:
💡 Suggested fixGitHub run IDs fit comfortably within float64's exact integer range (253 ≈ 9×1015). Use that as the upper bound: const maxExactFloat64Int = 1 << 53 // 9007199254740992
case float64:
if id > 0 && id <= maxExactFloat64Int && id == math.Trunc(id) {
runID = int64(id)
}This is consistent with how the JSON spec itself handles integers and eliminates any possibility of overflow.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| runID = int64(id) | ||
| } | ||
| case int: | ||
| runID = int64(id) | ||
| case int64: | ||
| runID = id | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if runID <= 0 { | ||
| // No run ID available — workflow may not have been dispatched or ID not captured | ||
| report.Result = OutcomePending | ||
| report.Detail = "no run ID available; dispatch may still be queued" | ||
| return report | ||
| } | ||
|
|
||
| data, err := workflowOutcomeGHAPIGet(fmt.Sprintf("actions/runs/%d", runID), repo) | ||
| if err != nil { | ||
| report.Result = OutcomeError | ||
| report.EvalError = err.Error() | ||
| return report | ||
| } | ||
|
|
||
| status, _ := data["status"].(string) | ||
| conclusion, _ := data["conclusion"].(string) | ||
| outcomeEvalWorkflowLog.Printf("dispatch_workflow run %d: status=%s, conclusion=%s", runID, status, conclusion) | ||
|
|
||
| switch { | ||
| case status == "completed" && conclusion == "success": | ||
| report.Result = OutcomeAccepted | ||
| report.Detail = "workflow run completed with success" | ||
| case status == "completed" && (conclusion == "failure" || conclusion == "timed_out" || conclusion == "cancelled"): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness: The GitHub Actions API documents at least six non-success conclusions: 💡 Suggested fixcase status == "completed" && conclusion == "action_required":
report.Result = OutcomePending
report.Detail = "workflow run requires a manual action to continue"
case status == "completed" && (conclusion == "skipped" || conclusion == "neutral"):
report.Result = OutcomeIgnored
report.Detail = "workflow run completed with " + conclusion
case status == "completed":
// Unknown future conclusion: treat as ignored but log clearly
report.Result = OutcomeIgnored
report.Detail = "workflow run completed with unrecognised conclusion: " + conclusion
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| report.Result = OutcomeRejected | ||
| report.Detail = "workflow run completed with " + conclusion | ||
| case status == "completed": | ||
| report.Result = OutcomeIgnored | ||
| report.Detail = "workflow run completed with " + conclusion | ||
| default: | ||
| report.Result = OutcomePending | ||
| report.Detail = "workflow run status: " + status | ||
| } | ||
| return report | ||
|
Comment on lines
+18
to
+88
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maintainability: This function always returns 💡 What to do insteadDo not register a stub evaluator in the production dispatch map. Pick one of:
Also add a TODO with a linked issue number so this doesn't silently persist.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| } | ||
|
|
||
| // evalUpdateDiscussion checks whether a discussion edit stuck. | ||
| // Full evaluation requires GraphQL; this evaluator returns pending until implemented. | ||
| // Spec: specs/safe-output-outcome-evaluation.md §12 | ||
| func evalUpdateDiscussion(item CreatedItemReport, repoOverride string) OutcomeReport { | ||
| return OutcomeReport{ | ||
| Type: item.Type, | ||
| ObjectURL: item.URL, | ||
| Repo: resolveItemRepo(item, repoOverride), | ||
| Result: OutcomePending, | ||
| Detail: "discussion update check requires GraphQL (not yet implemented)", | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] A permanently-pending evaluator degrades into a hidden infinite wait: callers that poll until non-pending will loop forever, and the "partial" status in the spec table is now misleading — it behaves identically to the old 💡 Suggested improvementUntil GraphQL is implemented, return func evalUpdateDiscussion(item CreatedItemReport, repoOverride string) OutcomeReport {
return OutcomeReport{
Type: item.Type,
ObjectURL: item.URL,
Repo: resolveItemRepo(item, repoOverride),
Result: OutcomeIgnored, // Not OutcomePending — this is a structural gap, not a transient state
Detail: "discussion update evaluation requires GraphQL (not yet implemented; treated as ignored)",
}
}Alternatively, add a @copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test coverage: This function has multiple independently testable paths — multi-type 💡 Minimum test cases needed
The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| package intent | ||
|
|
||
| import "slices" | ||
|
|
||
| // autonomyOrder maps autonomy level names to their restrictiveness rank. | ||
| // Higher rank means more restrictive. | ||
| var autonomyOrder = map[string]int{ | ||
| "propose_only": 3, | ||
| "supervised": 2, | ||
| "bounded": 1, | ||
| "": 0, | ||
| } | ||
|
|
||
| // writeScopeOrder maps write_scope values to their restrictiveness rank. | ||
| // Higher rank means more restrictive. | ||
| var writeScopeOrder = map[string]int{ | ||
| "none": 3, | ||
| "feature_branch": 2, | ||
| "any_branch": 1, | ||
| "": 0, | ||
| } | ||
|
|
||
| // ExecutionPolicy governs what an agent may do for a given intent. | ||
| type ExecutionPolicy struct { | ||
| Autonomy string `json:"autonomy"` | ||
|
|
||
| AllowedTools []string `json:"allowed_tools,omitempty"` | ||
| DeniedTools []string `json:"denied_tools,omitempty"` | ||
|
|
||
| WriteScope string `json:"write_scope"` | ||
|
|
||
| RequiredChecks []string `json:"required_checks,omitempty"` | ||
|
|
||
| HumanApprovalRequired bool `json:"human_approval_required"` | ||
| AutoMergeAllowed bool `json:"auto_merge_allowed"` | ||
|
|
||
| MaxAttempts int `json:"max_attempts"` | ||
|
|
||
| RuleIDs []string `json:"rule_ids,omitempty"` | ||
| } | ||
|
|
||
| // RepositoryContext carries repository-level context used when matching policy rules. | ||
| type RepositoryContext struct { | ||
| Owner string `json:"owner,omitempty"` | ||
| Name string `json:"name,omitempty"` | ||
| Visibility string `json:"visibility,omitempty"` // "public" or "private" | ||
| Org string `json:"org,omitempty"` | ||
| } | ||
|
|
||
| // PolicyRule pairs a match condition with a policy fragment to apply. | ||
| type PolicyRule struct { | ||
| ID string `json:"id"` | ||
| Scope string `json:"scope,omitempty"` // "organization", "repository", "intent", or "workflow" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] The spec and the 💡 Suggested improvementValidate or sort by scope rank in var scopeOrder = map[string]int{
"organization": 0,
"repository": 1,
"intent": 2,
"workflow": 3,
"": 4, // unset = lowest precedence
}
// In Compile(), add:
for i := 1; i < len(c.Rules); i++ {
if scopeOrder[c.Rules[i-1].Scope] > scopeOrder[c.Rules[i].Scope] {
return ExecutionPolicy{}, fmt.Errorf(
"rule %q (%s) must appear before rule %q (%s)",
c.Rules[i].ID, c.Rules[i].Scope,
c.Rules[i-1].ID, c.Rules[i-1].Scope,
)
}
}At a minimum, add a test that demonstrates mis-ordered rules do or do not produce different compiled output, to document the intended behavior. @copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| When PolicyCondition `json:"when"` | ||
| Set ExecutionPolicy `json:"set"` | ||
| } | ||
|
|
||
| // PolicyCondition describes when a rule applies. | ||
| type PolicyCondition struct { | ||
| Domain string `json:"domain,omitempty"` | ||
| Priority string `json:"priority,omitempty"` | ||
| Risk string `json:"risk,omitempty"` | ||
| Org string `json:"org,omitempty"` | ||
| } | ||
|
|
||
| // Matches returns true when the condition is satisfied by the given intent and repository. | ||
| // Labels are matched as flat strings. If a record carries labels like ["security","p1","critical"], | ||
| // the Domain/Priority/Risk fields each check for the presence of their value anywhere in that | ||
| // slice. Callers must ensure label values are unique across dimensions (e.g. no priority value | ||
| // that could collide with a domain value) to avoid false positives. | ||
| func (c PolicyCondition) Matches(record IntentRecord, repo RepositoryContext) bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] The comment documents the caveat but puts the enforcement burden entirely on the caller. In a security-sensitive policy compiler, this should be a hard invariant enforced at rule-load time, not a documentation note. 💡 Suggested improvementAdd a func (c *PolicyCompiler) Validate(labels map[string]LabelDescriptor) error {
// Check that no label value appears in more than one dimension.
valueToEm := map[string]string{}
for name, desc := range labels {
key := desc.Value
if existing, ok := valueToEm[key]; ok {
return fmt.Errorf(
"label %q and %q share value %q across dimensions; PolicyCondition.Matches would be ambiguous",
existing, name, key,
)
}
valueToEm[key] = name
}
return nil
}Alternatively, store labels keyed by dimension (e.g., @copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged. The flat-label caveat is documented in the |
||
| if c.Domain != "" && !slices.Contains(record.Labels, c.Domain) { | ||
| return false | ||
| } | ||
| if c.Priority != "" && !slices.Contains(record.Labels, c.Priority) { | ||
| return false | ||
| } | ||
| if c.Risk != "" && !slices.Contains(record.Labels, c.Risk) { | ||
| return false | ||
| } | ||
| if c.Org != "" && c.Org != repo.Org && c.Org != repo.Owner { | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| // PolicyCompiler compiles a set of rules into an ExecutionPolicy for a given intent. | ||
| // Rules are applied in declaration order; each merged step preserves stricter | ||
| // higher-precedence constraints. | ||
| type PolicyCompiler struct { | ||
| Rules []PolicyRule | ||
| } | ||
|
|
||
| // Compile returns the most restrictive policy produced by merging all matching rules. | ||
| // The base is always the safe default policy. Rules are processed in order so that | ||
| // earlier (higher-precedence) rules cannot be weakened by later (lower-precedence) rules. | ||
| func (c *PolicyCompiler) Compile(record IntentRecord, repo RepositoryContext) ExecutionPolicy { | ||
| policy := safestDefaultPolicy() | ||
|
|
||
| for _, rule := range c.Rules { | ||
| if rule.When.Matches(record, repo) { | ||
| policy = mergePolicy(policy, rule.Set) | ||
| policy.RuleIDs = append(policy.RuleIDs, rule.ID) | ||
| } | ||
|
Comment on lines
+126
to
+139
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| } | ||
|
|
||
| return policy | ||
| } | ||
|
|
||
| // safestDefaultPolicy returns the most restrictive ExecutionPolicy baseline. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness:
💡 OptionsOption A — Enforce scope ordering: func scopeRank(s string) int {
switch s {
case "organization": return 0
case "repository": return 1
case "intent": return 2
case "workflow": return 3
default: return 4
}
}
// stable sort before the merge loop
slices.SortStableFunc(rules, func(a, b PolicyRule) int {
return cmp.Compare(scopeRank(a.Scope), scopeRank(b.Scope))
})Option B — Remove the field if ordering by caller-provided slice position is intentional, and update the documentation and JSON schema accordingly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| // Unknown or ambiguous intent must not grant elevated authority. | ||
| func safestDefaultPolicy() ExecutionPolicy { | ||
| return ExecutionPolicy{ | ||
| Autonomy: "propose_only", | ||
| WriteScope: "none", | ||
| HumanApprovalRequired: true, | ||
| AutoMergeAllowed: false, | ||
| MaxAttempts: 1, | ||
| } | ||
| } | ||
|
|
||
| // mergePolicy merges a new policy fragment into an existing accumulated policy, | ||
| // always preserving the more restrictive value for each field. | ||
| // | ||
| // The existing policy represents already-accumulated (higher-precedence) constraints. | ||
| // The incoming fragment represents a lower-precedence rule's desired settings. | ||
| // The result must never be less restrictive than the existing policy. | ||
| func mergePolicy(existing, incoming ExecutionPolicy) ExecutionPolicy { | ||
| result := existing | ||
|
|
||
| // Autonomy: keep the more restrictive level. | ||
| if autonomyOrder[incoming.Autonomy] > autonomyOrder[existing.Autonomy] { | ||
| result.Autonomy = incoming.Autonomy | ||
| } | ||
|
|
||
| // WriteScope: keep the more restrictive scope. | ||
| if writeScopeOrder[incoming.WriteScope] > writeScopeOrder[existing.WriteScope] { | ||
| result.WriteScope = incoming.WriteScope | ||
| } | ||
|
|
||
| // HumanApprovalRequired: true is more restrictive; use OR. | ||
| if incoming.HumanApprovalRequired { | ||
| result.HumanApprovalRequired = true | ||
| } | ||
|
|
||
| // AutoMergeAllowed: false is more restrictive; use AND. | ||
| if !incoming.AutoMergeAllowed { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correctness: The condition 💡 Suggested fixUse a pointer or a wrapper type to distinguish "explicitly false" from "unset": type TriBool int8
const (
TriUnset TriBool = iota
TriTrue
TriFalse
)
type ExecutionPolicy struct {
// ...
AutoMergeAllowed TriBool `json:"auto_merge_allowed,omitempty"`
}Or, simpler: use The same risk applies to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| result.AutoMergeAllowed = false | ||
| } | ||
|
|
||
| // MaxAttempts: lower is more restrictive; keep the minimum of both if both are set. | ||
| if incoming.MaxAttempts > 0 { | ||
| if result.MaxAttempts == 0 || incoming.MaxAttempts < result.MaxAttempts { | ||
| result.MaxAttempts = incoming.MaxAttempts | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] The test on line 87 asserts 💡 DiscussionTwo design options:
Option 1 is more flexible; option 2 is simpler and may be intentional. Either way, the current test does not distinguish these paths, so the behavior should be made explicit. @copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| } | ||
|
|
||
| // RequiredChecks: union of both lists (adding checks is always more restrictive). | ||
| for _, check := range incoming.RequiredChecks { | ||
| if !slices.Contains(result.RequiredChecks, check) { | ||
| result.RequiredChecks = append(result.RequiredChecks, check) | ||
| } | ||
| } | ||
|
|
||
| // DeniedTools: union of both lists (denying more tools is always more restrictive). | ||
| for _, tool := range incoming.DeniedTools { | ||
| if !slices.Contains(result.DeniedTools, tool) { | ||
| result.DeniedTools = append(result.DeniedTools, tool) | ||
| } | ||
| } | ||
|
|
||
| // AllowedTools: if neither restricts tools, stay unrestricted. | ||
| // If the existing policy restricts, keep its restriction (higher-precedence wins). | ||
| // If only the incoming policy restricts, adopt that restriction. | ||
| // If both restrict, use the intersection (more restrictive). | ||
| if len(existing.AllowedTools) == 0 && len(incoming.AllowedTools) > 0 { | ||
| result.AllowedTools = slices.Clone(incoming.AllowedTools) | ||
| } else if len(existing.AllowedTools) > 0 && len(incoming.AllowedTools) > 0 { | ||
| var intersection []string | ||
| for _, tool := range existing.AllowedTools { | ||
| if slices.Contains(incoming.AllowedTools, tool) { | ||
| intersection = append(intersection, tool) | ||
| } | ||
| } | ||
| result.AllowedTools = intersection | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security: empty intersection is treated as unrestricted — two disjoint When 💡 Suggested fixWhen both sides restrict tools but share nothing, the agent must be allowed zero tools. Use a non-nil empty slice as the sentinel: } else if len(existing.AllowedTools) > 0 && len(incoming.AllowedTools) > 0 {
intersection := []string{} // non-nil empty = "no tools allowed"
for _, tool := range existing.AllowedTools {
if slices.Contains(incoming.AllowedTools, tool) {
intersection = append(intersection, tool)
}
}
result.AllowedTools = intersection
}All callers must consistently treat
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] Empty When two rules have disjoint tool sets, 💡 Suggested fix + regression testUse intersection := make([]string, 0) // non-nil: empty == deny-all, not unrestricted
for _, tool := range existing.AllowedTools {
if slices.Contains(incoming.AllowedTools, tool) {
intersection = append(intersection, tool)
}
}
result.AllowedTools = intersectionAdd a test to lock this in: func TestPolicyCompilerDisjointAllowedToolsDeniesAll(t *testing.T) {
compiler := &intent.PolicyCompiler{
Rules: []intent.PolicyRule{
{ID: "r1", When: intent.PolicyCondition{}, Set: intent.ExecutionPolicy{
AllowedTools: []string{"issue_read"},
}},
{ID: "r2", When: intent.PolicyCondition{}, Set: intent.ExecutionPolicy{
AllowedTools: []string{"create_pr"},
}},
},
}
policy := compiler.Compile(intent.IntentRecord{}, intent.RepositoryContext{})
require.NotNil(t, policy.AllowedTools,
"disjoint AllowedTools must produce non-nil empty slice (deny-all), not nil (unrestricted)")
assert.Empty(t, policy.AllowedTools)
}@copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| // If only existing restricts, result.AllowedTools already has the existing value. | ||
|
|
||
| return result | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd]
evalDispatchWorkflowhas no accompanying tests — theworkflowOutcomeGHAPIGetseam is defined but never exercised.Given the float64→int64 conversion logic and the 5-case status/conclusion switch, there are at least 8 meaningful test scenarios (nil metadata, run_id=0, API error, each conclusion branch, unknown status). Without tests, the next person to touch this code has no safety net.
💡 Minimum test coverage suggestions
Use
workflowOutcomeGHAPIGetas the injection point (same pattern asghAPIGetstubs in otheroutcome_eval_*.gotest files).@copilot please address this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in
e2c15eb.pkg/cli/outcome_eval_workflow_test.goexercisesworkflowOutcomeGHAPIGetvia the package-level var seam across 9 test cases covering all code paths.