Skip to content

SPDD 2026-07-05: outcome evaluators, policy compiler, and spec coverage for batch 20-24#43594

Merged
pelikhan merged 8 commits into
mainfrom
copilot/spdd-daily-spec-work-plan-2026-07-05
Jul 5, 2026
Merged

SPDD 2026-07-05: outcome evaluators, policy compiler, and spec coverage for batch 20-24#43594
pelikhan merged 8 commits into
mainfrom
copilot/spdd-daily-spec-work-plan-2026-07-05

Conversation

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Addresses the P0/P1/P2 gaps identified in the batch 20-24 SPDD plan: missing Go evaluators falling back to evalGenericSticky, unimplemented PolicyCompiler, and spec coverage holes across OTel, replace-label, MCP fixtures, and intent-attribution.

Go: outcome evaluators (pkg/cli/)

  • evalDispatchWorkflow: queries actions/runs/{run_id} via GitHub API; maps status/conclusion to OutcomeAccepted/OutcomeRejected/OutcomePending/OutcomeIgnored. Uses int64 for run IDs (not float64) to avoid precision loss above 2⁵³.
  • evalUpdateDiscussion: pending stub; full evaluation requires GraphQL (same pattern as evalCloseDiscussion).
  • Both registered in outcomeEvaluators map.
case status == "completed" && conclusion == "success":
    report.Result = OutcomeAccepted
case status == "completed" && (conclusion == "failure" || conclusion == "timed_out" || conclusion == "cancelled"):
    report.Result = OutcomeRejected
    report.Detail = "workflow run completed with " + conclusion

Go: policy compiler (pkg/intent/)

  • New ExecutionPolicy, PolicyRule, PolicyCondition, PolicyCompiler types.
  • mergePolicy() implements more-restrictive-wins: DeniedTools/RequiredChecks union, AllowedTools intersection, HumanApprovalRequired OR, AutoMergeAllowed AND, MaxAttempts min.
  • 3 unit tests covering org → repo → intent precedence (all passing).
  • PolicyCondition.Matches documents the flat-label dimension caveat.

Spec: safe-output-outcome-evaluation.md

  • Updated dispatch_workflow and update_discussion rows to reference new evaluators.
  • Added replace_label to both the Default Acceptance Map and Implementation Status tables.

Spec: otel-observability-spec.md §17

  • Added subsection 17.1.1 with 7 test ID stubs (T-OT-001–T-OT-007) covering Level 1: compiler config, endpoint normalization, OTLP export, trace context, local mirrors, header masking.

Spec: intent-attribution-agent-governance.md

  • RFC 2119 Conformance section: attribution-resolution order, ambiguous-root handling, fail-closed behavior.
  • .github/intent-policy.json schema documentation with field tables and full example.
  • Authorizer.AuthorizeTool implementation audit: documents all ExecutionPolicy fields as not yet wired to runtime enforcement — policy today is advisory only.

Compliance fixtures

  • specs/replace-label-compliance/ (new): rl-001-glob-semantics.yaml and rl-003-blocklist-ordering.yaml, 4 scenarios each.
  • specs/github-mcp-access-control-compliance/: 4 new fixture stubs covering §5.3 (empty repos), §6.1 (tool-name filter), §7.2 (blocked users), §10 (combined filters). README gains error code reference table (-32001 through -32008).

Copilot AI linked an issue Jul 5, 2026 that may be closed by this pull request
11 tasks
…e fixtures

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Update compliance fixtures for MCP Access Control SPDD 2026-07-05: outcome evaluators, policy compiler, and spec coverage for batch 20-24 Jul 5, 2026
Copilot AI requested a review from pelikhan July 5, 2026 17:07
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Hey @copilot-swe-agent 👋 — thanks for tackling the batch 20-24 gaps! The outcome evaluators and policy compiler work here are solid, and the compliance fixtures are a welcome addition. A few thoughts that could help reviewers move through this faster:

  • Consider splitting the PR — this batch bundles six distinct functional areas: CLI outcome evaluators (pkg/cli/), intent policy compiler (pkg/intent/), OTel spec stubs, replace-label compliance fixtures, MCP access-control fixtures, and intent-attribution governance docs. Each of these is independently reviewable and mergeable. Smaller, focused PRs reduce review surface and make bisecting regressions easier.
  • Note on policy advisory status — the PR body helpfully documents that all ExecutionPolicy fields are "not yet wired" to runtime enforcement. It may be worth a // TODO comment in pkg/intent/policy.go at the AuthorizeTool call site so the gap doesn't get lost after merge.

If you'd like to split this into more focused units, here's a ready-to-use prompt:

Split PR #43594 into focused, independently-mergeable PRs. Suggested groupings:
1. Go implementation: pkg/cli/outcome_eval_workflow.go (evalDispatchWorkflow + evalUpdateDiscussion stubs) and the outcomeEvaluators registration in outcome_eval.go.
2. Go implementation: pkg/intent/policy.go + pkg/intent/policy_test.go (PolicyCompiler, ExecutionPolicy, mergePolicy).
3. Spec + fixtures: specs/replace-label-compliance/ new directory and specs/safe-output-outcome-evaluation.md updates.
4. Spec + fixtures: specs/github-mcp-access-control-compliance/ new fixtures and README error-code table.
5. Spec updates: specs/intent-attribution-agent-governance.md RFC 2119 section and specs/otel-observability-spec.md §17.1.1 stubs.
For each split, open a new PR against the same base branch, carry over the relevant section of the PR body, and ensure tests travel with their corresponding implementation files.

Generated by ✅ Contribution Check · 373.1 AIC · ⌖ 21 AIC · ⊞ 6.2K ·

@pelikhan pelikhan marked this pull request as ready for review July 5, 2026 17:53
Copilot AI review requested due to automatic review settings July 5, 2026 17:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR advances SPDD batch 20–24 coverage by adding missing Go outcome evaluators, introducing a first-pass intent execution policy compiler, and extending several specs/fixture sets (safe-output outcomes, replace-label compliance, GitHub MCP access-control compliance, and OTel observability test stubs).

Changes:

  • Added Go outcome evaluators for dispatch_workflow (REST run status mapping) and update_discussion (pending GraphQL), and registered them in the evaluator map.
  • Introduced ExecutionPolicy / PolicyCompiler with “more-restrictive-wins” merging, plus initial unit tests.
  • Expanded specs and added new compliance fixtures for replace-label and GitHub MCP access control; added OTel Level 1 test ID stubs.
Show a summary per file
File Description
specs/safe-output-outcome-evaluation.md Updates evaluator mapping/implementation status tables for dispatch_workflow, update_discussion, and replace_label.
specs/replace-label-compliance/rl-003-blocklist-ordering.yaml New replace-label compliance fixture covering blocklist-before-allowlist ordering.
specs/replace-label-compliance/rl-001-glob-semantics.yaml New replace-label compliance fixture covering glob semantics expectations.
specs/replace-label-compliance/README.md Documents fixture intent and schema for replace-label compliance fixtures.
specs/otel-observability-spec.md Adds Level 1 OTel compliance test ID stubs (T-OT-001–T-OT-007).
specs/intent-attribution-agent-governance.md Adds RFC2119 conformance norms, intent-policy schema documentation, and a policy enforcement audit section.
specs/github-mcp-access-control-compliance/tool-name-filter.yaml New MCP access-control compliance fixture for allowed-tools filtering.
specs/github-mcp-access-control-compliance/README.md Updates fixture list and adds an error-code reference table.
specs/github-mcp-access-control-compliance/empty-repos-block.yaml New MCP access-control fixture for empty/absent repos deny behavior.
specs/github-mcp-access-control-compliance/combined-filter-allow.yaml New MCP access-control fixture for combined-condition evaluation behavior.
specs/github-mcp-access-control-compliance/blocked-user-deny.yaml New MCP access-control fixture for unconditional deny of blocked users.
pkg/intent/policy.go Adds the new policy model, matching conditions, compiler, and merge logic.
pkg/intent/policy_test.go Adds unit tests for rule precedence and “cannot weaken constraints” behavior.
pkg/cli/outcome_eval.go Registers the new outcome evaluators in outcomeEvaluators.
pkg/cli/outcome_eval_workflow.go Implements evalDispatchWorkflow and a stub evalUpdateDiscussion evaluator.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 15/15 changed files
  • Comments generated: 4
  • Review effort level: Low

Comment thread pkg/intent/policy.go
Comment on lines +94 to +104
// 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)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb and 3315d2c. Compile() now seeds from the first matching rule's Set (not safestDefaultPolicy()) after sorting by scope precedence, so rules can express supervised/bounded autonomy, auto_merge_allowed=true, and max_attempts>1. applyFailClosedDefaults() fills in safe values only for fields no rule touched.

Comment thread pkg/intent/policy.go Outdated
Comment on lines +172 to +186
// 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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. mergePolicy now uses nil/[]string{} semantics — nil means unrestricted, []string{} (non-nil empty) means deny-all. When both sides restrict to non-overlapping sets, the intersection is []string{} (deny-all), not nil.

Comment on lines +13 to +77
// 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) {
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"):
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. Added pkg/cli/outcome_eval_workflow_test.go with 9 tests covering: no run_id, zero run_id, API error, completed/success, completed/failure+timed_out+cancelled+action_required, completed/other conclusion, in_progress/pending, and float64 overflow guard.

Comment on lines +42 to +56
## Error Code Reference

When `expected.decision` is `deny`, the fixture records the MCP JSON-RPC error code that the
implementation MUST return. The codes used in these fixtures are:

| Code | Denial Reason |
|---|---|
| `-32001` | Repository not on the allowlist (`repos` filter) |
| `-32002` | Label not on the allowlist (`allowed-labels` filter) |
| `-32003` | Label on the blocklist (`blocked-labels` filter) |
| `-32004` | User is blocked (`blocked-users` filter) |
| `-32005` | Tool name not permitted (`allowed-tools` filter) |
| `-32006` | Content integrity level below threshold (`min-integrity` filter) |
| `-32007` | Repository is private and `private-repos: false` |
| `-32008` | User role is insufficient (`role` filter) |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. The error-code table now matches the actual spec and existing fixtures: -32001 repos, -32002 roles, -32003 private-repos, -32004 blocked-users, -32005 allowed-tools, -32006 min-integrity. Removed the incorrect -32007/-32008 entries.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 3 test(s): 3 design, 0 implementation, 0 violation(s).

📊 Metrics (3 tests)
Metric Value
Analyzed 3 (Go: 3, JS: 0)
✅ Design 3 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 3 (100%)
Duplicate clusters 0
Inflation NO (1.17:1 ratio)
🚨 Violations 0

Per-test classification:

Test File Classification Issues
TestPolicyCompilerOrgConstraintsPreservedOverRepo policy_test.go:26 design_test None — Strong precedence enforcement
TestPolicyCompilerRepoConstraintsPreservedOverIntent policy_test.go:95 design_test None — Solid constraint preservation
TestPolicyCompilerNoRulesMatchReturnsSafeDefault policy_test.go:165 design_test None — Excellent fail-closed default

Verdict

Passed. 0% implementation tests (threshold: 30%).

All tests verify genuine behavioral contracts from the policy precedence spec (intent-attribution-agent-governance.md). Each assertion carries descriptive failure context. No mocking violations. Build tags compliant. Excellent test quality — these tests will catch real precedence-logic bugs.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 10.6 AIC · ⌖ 20.5 AIC · ⊞ 6.9K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%).

…ns merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (505 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/43594-policy-compiler-more-restrictive-wins-merge.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer (e.g., deciders, any alternatives you ruled out earlier), refine the decision rationale, and confirm the consequences
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-43594: Policy Compiler with More-Restrictive-Wins Merge Semantics

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 43594-policy-compiler-more-restrictive-wins-merge.md for PR #43594).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 73.4 AIC · ⌖ 12.9 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — 3 correctness/security bugs in the new Go code must be fixed before merge.

### Blocking issues (3 critical/high)
  1. pkg/intent/policy.goAllowedTools disjoint intersection returns nil, silently granting unrestricted tool access (security). When two rules each restrict AllowedTools to non-overlapping sets, mergePolicy stores a nil slice, which downstream len == 0 checks treat as "no restriction". Fix: initialise intersection to []string{} (non-nil empty).

  2. pkg/cli/outcome_eval_workflow.gofloat64 <= math.MaxInt64 overflow guard is wrong (correctness). math.MaxInt64 cannot be represented exactly as float64 and rounds up; a float64 at that boundary passes the guard but int64() overflows to math.MinInt64, silently dropping real run IDs. Fix: cap at 1<<53.

  3. pkg/cli/outcome_eval_workflow.goevalUpdateDiscussion stub registered in live dispatch map (correctness/regression). Every real update_discussion item will report perpetual OutcomePending, poisoning acceptance rates and preventing alerting. This is worse than the previous evalGenericSticky fallback. Fix: don't register it until GraphQL is implemented.

### Non-blocking (high/medium) — should address before GA
  • PolicyRule.Scope is never read by Compile; the claimed org→repo→intent ordering is only a slice-ordering convention, not enforced. Tests pass only because test data happens to be ordered correctly.
  • action_required workflow conclusion is mapped to OutcomeIgnored instead of OutcomePending — silently hides workflows awaiting environment protection approval.
  • AutoMergeAllowed=false zero-value fires for every rule that doesn't mention the field, over-constraining all higher-precedence rules.
  • evalDispatchWorkflow has no unit tests despite non-trivial float64/int64 conversion and multi-branch conclusion logic.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • proxy.golang.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "proxy.golang.org"

See Network Configuration for more information.

🔎 Code quality review by PR Code Quality Reviewer · 159.8 AIC · ⌖ 7.18 AIC · ⊞ 5.4K
Comment /review to run again

Comment thread pkg/intent/policy.go Outdated
intersection = append(intersection, tool)
}
}
result.AllowedTools = intersection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: empty intersection is treated as unrestricted — two disjoint AllowedTools sets escalate to full tool access.

When existing.AllowedTools and incoming.AllowedTools are both non-empty but share no common tools, the intersection variable is never appended to and remains nil. result.AllowedTools = intersection stores a nil slice, and every downstream check (len(AllowedTools) == 0) treats nil as unrestricted. The policy that was supposed to deny all tools now permits every tool.

💡 Suggested fix

When 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 len(AllowedTools) == 0 && AllowedTools != nil as "deny all" and AllowedTools == nil as "unrestricted". Document this invariant at the ExecutionPolicy struct definition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. mergePolicy now uses nil vs []string{} to distinguish unrestricted from deny-all. Disjoint AllowedTools intersection produces []string{} (deny-all), not nil (unrestricted). Covered by TestPolicyCompilerAllowedToolsDenyAllOnEmptyIntersection.

Comment thread pkg/cli/outcome_eval_workflow.go Outdated
if v, ok := item.Metadata["run_id"]; ok {
switch id := v.(type) {
case float64:
if id > 0 && id <= math.MaxInt64 && id == math.Trunc(id) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: float64 <= math.MaxInt64 guard doesn't prevent int64 overflow for large GitHub run IDs.

math.MaxInt64 (9223372036854775807) cannot be represented exactly as float64; Go rounds it up to 9.223372036854776e18. A float64 ID equal to that rounded value passes the check (id <= math.MaxInt64 is true), but int64(id) overflows — on amd64 this yields math.MinInt64 (negative), so runID <= 0 and the function returns OutcomePending with "no run ID available", silently dropping a real dispatched run.

💡 Suggested fix

GitHub 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. The guard now uses maxSafeFloat64Int = 1 << 53 (2^53, the largest integer representable exactly as float64) instead of math.MaxInt64. Test TestEvalDispatchWorkflowFloat64OverflowGuard verifies values above 2^53 are rejected.

Comment thread pkg/intent/policy.go
return policy
}

// safestDefaultPolicy returns the most restrictive ExecutionPolicy baseline.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: PolicyRule.Scope is declared but never read — the claimed org→repo→intent precedence is only a convention, not enforced by code.

PolicyCompiler.Compile iterates c.Rules in declaration order without inspecting rule.Scope. The tests pass only because the test author happened to put org rules first in the slice. Any caller that inserts rules in a different order will silently get wrong precedence. The Scope field is misleading — it implies the compiler enforces ordering, but it doesn't.

💡 Options

Option A — Enforce scope ordering:
Before merging, sort rules by scope rank (organization=0, repository=1, intent=2, workflow=3) so declaration order doesn't matter:

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. Compile() now sorts matched rules by scopePriorityOrder (org=4 > repo=3 > intent=2 > workflow=1 > unscoped=0) using slices.SortStableFunc before seeding the policy. TestPolicyCompilerScopeOrderingEnforced verifies rules declared in reverse order still compile with org values winning.

Comment thread pkg/cli/outcome_eval_workflow.go Outdated
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"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: action_required conclusion is mapped to OutcomeIgnored but it represents a blocking, non-terminal state.

The GitHub Actions API documents at least six non-success conclusions: failure, cancelled, timed_out, action_required, neutral, skipped. The code handles the first three as OutcomeRejected and success as OutcomeAccepted, but the catch-all case status == "completed" maps every other conclusion — including action_required — to OutcomeIgnored. A workflow awaiting environment protection approval or a required reviewer sits in completed/action_required; mapping it to Ignored makes the monitoring system think the workflow is finished and doesn't need attention, when human intervention is required.

💡 Suggested fix
case 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. action_required is now mapped to OutcomeRejected (blocking, requires manual intervention — does not self-resolve), joining failure, timed_out, and cancelled. Covered by TestEvalDispatchWorkflowActionRequired.

report.Result = OutcomePending
report.Detail = "workflow run status: " + status
}
return report

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainability: evalUpdateDiscussion is a permanent stub wired into the live evaluator dispatch map, silently poisoning outcome metrics.

This function always returns OutcomePending. Once wired into outcomeEvaluators, every real update_discussion item will be stuck in perpetual pending status, inflating the pending count, deflating acceptance rates, and never triggering retry or alert logic. Before this PR, update_discussion fell back to evalGenericSticky which at least provided a real existence check. This is a regression in evaluation fidelity.

💡 What to do instead

Do not register a stub evaluator in the production dispatch map. Pick one of:

  1. Don't register it yet: leave update_discussion out of outcomeEvaluators and let it fall back to evalGenericSticky as before until the GraphQL implementation exists.
  2. Register it under a feature flag: if os.Getenv("GH_AW_EVAL_DISCUSSION") == "1" { outcomeEvaluators["update_discussion"] = evalUpdateDiscussion }
  3. Use OutcomeUnknown/OutcomeIgnored with a clear reason string if you want the spec compliance benefit without metrics pollution, and update the spec table accordingly.

Also add a TODO with a linked issue number so this doesn't silently persist.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. evalUpdateDiscussion now returns OutcomeIgnored (not OutcomePending) with detail "discussion update check requires GraphQL (not yet implemented); outcome is advisory only". OutcomeIgnored is excluded from acceptance/rejection rate calculations, so it doesn't poison metrics. The function remains registered so callers get an explicit advisory result rather than falling through to evalGenericSticky's OutcomeUnknown path.

Result: OutcomePending,
Detail: "discussion update check requires GraphQL (not yet implemented)",
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage: evalDispatchWorkflow has zero unit tests for its non-trivial logic.

This function has multiple independently testable paths — multi-type run_id extraction, float64→int64 conversion with overflow guard, an injectable HTTP call (workflowOutcomeGHAPIGet), and a 4-branch conclusion switch — none of which are covered by any test in pkg/cli. Compare with evalSubmitPullRequestReview or evalCloseDiscussion, which each have dedicated test files.

💡 Minimum test cases needed
  • run_id present as float64, int, int64
  • run_id absent (nil metadata)
  • run_id present but not a valid type (unknown type → runID stays 0)
  • API returns error → OutcomeError
  • status="completed", conclusion="success"OutcomeAccepted
  • status="completed", conclusion="failure"OutcomeRejected
  • status="completed", conclusion="timed_out"OutcomeRejected
  • status="completed", conclusion="cancelled"OutcomeRejected
  • status="completed", conclusion="action_required" (see separate comment)
  • status="in_progress"OutcomePending

The workflowOutcomeGHAPIGet var already supports injection; use it to stub the API in tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. Added pkg/cli/outcome_eval_workflow_test.go with 9 tests exercising workflowOutcomeGHAPIGet via stub injection, covering all status/conclusion combinations, float64 type handling, and the overflow guard.

Comment thread pkg/intent/policy.go Outdated
}

// AutoMergeAllowed: false is more restrictive; use AND.
if !incoming.AutoMergeAllowed {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: AutoMergeAllowed=false zero-value silently over-constrains — every rule that doesn't mention auto-merge disables it.

The condition if !incoming.AutoMergeAllowed { result.AutoMergeAllowed = false } fires whenever the field is false, which is Go's zero value. A rule fragment that only wants to set AllowedTools without expressing any opinion on auto-merge will unconditionally lock AutoMergeAllowed = false for all higher-precedence rules. In the current test suite all rules explicitly set this field, so the bug is hidden, but any real rule omitting it will disable auto-merge globally.

💡 Suggested fix

Use 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 *bool and skip the merge if incoming.AutoMergeAllowed == nil.

The same risk applies to HumanApprovalRequired, but there defaulting to false is safe (it only grants less restriction), so it is less urgent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. AutoMergeAllowed changed to *bool: nil = unset (no preference), false = explicit denial, true = explicit grant. mergePolicy only applies the AND step when at least one side is non-nil, so rules that don't mention AutoMergeAllowed no longer silently disable it.

Comment thread pkg/intent/policy_test.go
"ambiguous intent with no matching rules must produce max_attempts=1")
assert.Empty(t, policy.RuleIDs,
"no applied rule IDs should be recorded when no rules match")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: the AllowedTools disjoint-intersection bug path is not covered.

All three tests use rules with overlapping AllowedTools or rules that don't specify AllowedTools at all. The critical case — two rules each specifying a non-overlapping AllowedTools list — is untested. This is exactly the code path that returns a nil slice and silently grants full tool access (see comment on policy.go:185).

💡 Minimum test to add
func TestPolicyCompilerAllowedToolsDisjointIntersectionDeniesAllTools(t *testing.T) {
    compiler := &intent.PolicyCompiler{
        Rules: []intent.PolicyRule{
            {
                ID:  "rule-a",
                Set: intent.ExecutionPolicy{AllowedTools: []string{"read_issue"}},
            },
            {
                ID:  "rule-b",
                Set: intent.ExecutionPolicy{AllowedTools: []string{"push_branch"}},
            },
        },
    }
    policy := compiler.Compile(intent.IntentRecord{}, intent.RepositoryContext{})
    // Disjoint sets: no tool should be allowed, NOT unrestricted
    assert.NotNil(t, policy.AllowedTools,
        "disjoint AllowedTools intersection must be empty (not nil/unrestricted)")
    assert.Empty(t, policy.AllowedTools,
        "disjoint AllowedTools intersection must deny all tools")
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. Added TestPolicyCompilerAllowedToolsDenyAllOnEmptyIntersection which asserts disjoint AllowedTools sets produce a non-nil empty slice (deny-all), not nil (unrestricted).

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Summary: SPDD 2026-07-05 Batch 20-24

Requesting changes on 9 issues across correctness, test coverage, and spec completeness. One is a security-correctness bug.

🔴 Must-fix (correctness / security)
# File Issue
1 pkg/intent/policy.go:185 AllowedTools intersection produces nil when two rules have disjoint sets — nil is treated as "unrestricted" instead of "deny-all". Use make([]string, 0) to preserve the security intent.
2 pkg/cli/outcome_eval_workflow.go:90 evalUpdateDiscussion returns OutcomePending forever — a structural limitation, not a transient state. Use OutcomeIgnored until GraphQL is implemented to prevent infinite polling.
🟡 Should-fix (test coverage gaps)
# File Issue
3 pkg/intent/policy_test.go:87 MaxAttempts <= 2 is vacuous; safestDefaultPolicy() returns 1, so the assertion always passes. Use assert.Equal with the exact expected value.
4 pkg/cli/outcome_eval_workflow.go:16 evalDispatchWorkflow has no unit tests. The workflowOutcomeGHAPIGet seam is ready; ~8 scenarios need coverage.
5 specs/github-mcp-access-control-compliance/combined-filter-allow.yaml:16 T-GH-82 claims any single condition failure → deny, but role and visibility failure scenarios are absent.
🟡 Should-fix (design clarity)
# File Issue
6 pkg/intent/policy.go:53 PolicyRule.Scope is a convention with no enforcement — mis-ordered rules compile silently and weaken the policy. Consider validation or sorting.
7 pkg/intent/policy.go:71 PolicyCondition.Matches flat-label collision is documented but not guarded; add a Validate() step or dimension-keyed labels.
8 pkg/intent/policy.go:155 MaxAttempts semantics for safestDefaultPolicy() baseline vs rule-settable values needs to be made explicit.
9 specs/intent-attribution-agent-governance.md:910 Compile() doc should explicitly state which fields are advisory-only, so consumers don't inadvertently trust unenforced constraints.

@copilot please address the review comments above.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 144.9 AIC · ⌖ 6.95 AIC · ⊞ 6.7K ·
Comment /matt to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /codebase-design, /tdd, /diagnosing-bugs, and /grill-with-docs — requesting changes on correctness and coverage gaps.

📋 Key Themes & Highlights

Key Issues

  1. Security bug — AllowedTools empty intersection returns nil (unrestricted) instead of an empty slice (deny-all). Disjoint rule sets silently grant full tool access. (policy.go:185)
  2. Vacuous test assertionsMaxAttempts <= 2 passes trivially because safestDefaultPolicy() already returns 1. Tests don't verify what they claim. (policy_test.go:87, :157)
  3. PolicyRule.Scope is a silent convention, not enforced. Mis-ordered rules compile without error and produce a weakened policy. (policy.go:53)
  4. evalUpdateDiscussion returns OutcomePending permanently — a structural limitation masquerading as a transient state, potentially causing infinite polling. (outcome_eval_workflow.go:90)
  5. No tests for evalDispatchWorkflow — the workflowOutcomeGHAPIGet seam exists but is never exercised. (outcome_eval_workflow.go:16)
  6. PolicyCondition.Matches flat-label collision is documented but not guarded at load time; a validation step would close this. (policy.go:71)
  7. Combined-filter fixture under-covers T-GH-82 — role and visibility failure scenarios are missing. (combined-filter-allow.yaml:16)
  8. Advisory-only policy gap needs code-level surfacingCompile() doc should warn that only Autonomy is wired to runtime enforcement. (intent-attribution-agent-governance.md:910)

Positive Highlights

  • evalDispatchWorkflow correctly handles float64→int64 precision for large GitHub run IDs — good defensive coding.
  • safestDefaultPolicy() provides a solid fail-closed baseline; the "more restrictive wins" merge design is sound.
  • ✅ Policy test naming is excellent: test names read as specifications, and Arrange/Act/Assert structure is clear throughout.
  • ✅ RFC 2119 conformance section in the governance spec is a strong addition, especially the fail-closed norms and ambiguous-root handling.
  • intent-policy.json schema documentation is thorough and well-organized.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 144.9 AIC · ⌖ 6.95 AIC · ⊞ 6.7K
Comment /matt to run again

Comment thread pkg/intent/policy.go Outdated
intersection = append(intersection, tool)
}
}
result.AllowedTools = intersection

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] Empty AllowedTools intersection reverts to unrestricted instead of deny-all, inverting the security posture.

When two rules have disjoint tool sets, intersection is nil. Setting result.AllowedTools = nil makes the policy look like "allow any tool" — the exact opposite of what two explicit restrictions should produce.

💡 Suggested fix + regression test

Use make([]string, 0) to produce a non-nil empty slice so the deny-all intent is preserved:

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 = intersection

Add 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb. Intersection now initialises to nil (unrestricted when neither side restricts), adopts restriction when one side restricts, and produces []string{} (deny-all) for disjoint sets. Covered by TestPolicyCompilerAllowedToolsDenyAllOnEmptyIntersection.

Comment thread pkg/intent/policy.go
if incoming.MaxAttempts > 0 {
if result.MaxAttempts == 0 || incoming.MaxAttempts < result.MaxAttempts {
result.MaxAttempts = incoming.MaxAttempts
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] safestDefaultPolicy() sets MaxAttempts: 1, so an org rule with MaxAttempts: 2 is silently ignored — the min-wins logic always prefers the safe default.

The test on line 87 asserts <= 2, which passes trivially because the default (1) is already lower. This means the org rule's intent (allow up to 2 retries) is never applied and cannot be tested.

💡 Discussion

Two design options:

  1. Zero means unset: treat MaxAttempts: 0 in safestDefaultPolicy() as "not yet decided", and only enforce a hard ceiling via a separate maxAttemptsHardCap constant. Rules can then raise from 0 to their desired value, and the min-wins merge correctly limits escalation.

  2. Always validate in isolation: document explicitly that safestDefaultPolicy() already imposes MaxAttempts: 1 and that rules cannot raise it beyond 1 at the global baseline — and update the test to assert == 1 (not <= 2) so the constraint is visible.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e2c15eb and 3315d2c. Compile() seeds from the first matching rule (after scope-priority sort), not from safestDefaultPolicy(). An org rule with MaxAttempts: 2 is now used as the baseline; subsequent lower-precedence rules can only lower it. TestPolicyCompilerRulesCanGrantLessRestrictiveThanSafeDefault verifies this.

Comment thread pkg/intent/policy.go
// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] PolicyCondition.Matches uses cross-dimension label search — a label value shared between dimensions (e.g., "critical" as both a risk level and a priority level) will cause rule mis-fires.

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 improvement

Add a Validate() method on PolicyCompiler (or validate in a constructor) that checks label uniqueness across dimensions, returning an error for collisions:

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., record.DomainLabels, record.PriorityLabels) so each Matches check is dimension-scoped without relying on uniqueness guarantees.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The flat-label caveat is documented in the Matches godoc comment. A load-time validation step is tracked as a follow-up — beyond the scope of this batch. Callers must ensure label values are unique across dimensions.

Repo: resolveItemRepo(item, repoOverride),
Result: OutcomePending,
Detail: "discussion update check requires GraphQL (not yet implemented)",
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] evalUpdateDiscussion permanently returns OutcomePending, which will cause the outcome evaluator to retry indefinitely with no eventual resolution.

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 evalGenericSticky fallback.

💡 Suggested improvement

Until GraphQL is implemented, return OutcomeIgnored with a clear Detail message, or add a MaxPollDuration guard in the evaluation loop. OutcomePending should only be used for genuinely transient states, not structural limitations:

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 TODO(graphql): #<tracking-issue> comment and a test that asserts the result is OutcomeIgnored so any future change is clearly visible.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. evalUpdateDiscussion now returns OutcomeIgnored with an explicit detail message. OutcomeIgnored is excluded from acceptance/rejection rate calculations, preventing infinite retry and metric poisoning.

// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] evalDispatchWorkflow has no accompanying tests — the workflowOutcomeGHAPIGet seam 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
func TestEvalDispatchWorkflow_NoRunID_ReturnsPending(t *testing.T) { ... }
func TestEvalDispatchWorkflow_APIError_ReturnsOutcomeError(t *testing.T) { ... }
func TestEvalDispatchWorkflow_Success_ReturnsAccepted(t *testing.T) { ... }
func TestEvalDispatchWorkflow_Failure_ReturnsRejected(t *testing.T) { ... }
func TestEvalDispatchWorkflow_Cancelled_ReturnsRejected(t *testing.T) { ... }
func TestEvalDispatchWorkflow_UnknownConclusion_ReturnsIgnored(t *testing.T) { ... }
func TestEvalDispatchWorkflow_InProgress_ReturnsPending(t *testing.T) { ... }
func TestEvalDispatchWorkflow_Float64RunID_NoPrecisionLoss(t *testing.T) { ... }

Use workflowOutcomeGHAPIGet as the injection point (same pattern as ghAPIGet stubs in other outcome_eval_*.go test files).

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

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.go exercises workflowOutcomeGHAPIGet via the package-level var seam across 9 test cases covering all code paths.

| `MaxAttempts` | **Not wired** | Not enforced at the orchestrator level. |
| `RuleIDs` | **Provenance only** | Recorded in the policy for auditing; not used to gate execution. |

**Risk**: Policy constraints defined in `.github/intent-policy.json` (or the equivalent `rules` array) have no runtime effect until the orchestrator is wired to call `AuthorizeTool` and enforce `WriteScope`, `HumanApprovalRequired`, and `RequiredChecks`. Any policy compiled by `PolicyCompiler.Compile()` today is purely advisory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The spec acknowledges that all ExecutionPolicy fields except Autonomy are unconnected to the runtime — but the new PolicyCompiler Go code has no corresponding guard, warning, or feature flag to prevent it from silently providing false security assurance.

Shipping a PolicyCompiler that compiles valid-looking policies while enforcing nothing is a correctness hazard: callers might depend on DeniedTools or HumanApprovalRequired without realizing those constraints have no runtime effect.

💡 Options to surface the gap at the code level
  1. Add a //nolint:unused // policy enforcement not yet wired comment on ExecutionPolicy — forces explicit acknowledgment on each use.
  2. Add a runtime-advisory note in Compile() doc:
    // Compile returns a policy for the given intent.
    // WARNING: As of this implementation, only Autonomy is wired to runtime enforcement.
    // DeniedTools, AllowedTools, WriteScope, HumanApprovalRequired, AutoMergeAllowed,
    // RequiredChecks, and MaxAttempts are advisory only.
    // See specs/intent-attribution-agent-governance.md §AuthorizeTool Audit for status.
  3. Add a Feature field or wrapper so callers can check enforcement status before acting on compiled policies.

Option 2 is the least effort and makes the limitation visible to any future consumer.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. ExecutionPolicy and PolicyCompiler doc comments now carry an explicit WARNING: advisory only note. The governance spec documents all unconnected fields as "not yet wired to runtime enforcement".

Comment thread pkg/intent/policy_test.go Outdated
"organization required check 'security-scan' must not be removed by repository rule")

// MaxAttempts must not be increased beyond the org limit.
assert.LessOrEqual(t, policy.MaxAttempts, 2,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] assert.LessOrEqual(t, policy.MaxAttempts, 2) is a vacuous test — safestDefaultPolicy() starts at MaxAttempts: 1, which is always <= 2.

The assertion passes regardless of whether the org rule's MaxAttempts: 2 was actually applied or ignored. It gives no signal about whether the merge behavior is correct.

💡 Sharper assertion

If the intent is that MaxAttempts cannot be raised above the safe default:

assert.Equal(t, 1, policy.MaxAttempts,
    "org rule MaxAttempts=2 must be capped by the safe default of 1 — cannot raise above baseline")

If the intent is that the org rule's value (2) overrides the default (1) and the repo rule (10) cannot raise it further:

assert.Equal(t, 2, policy.MaxAttempts,
    "org rule MaxAttempts=2 must be applied; repo rule's MaxAttempts=10 must not increase it")

Either way, assert.Equal makes the intended invariant explicit and forces the test to fail if the merge logic changes.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. The assertion was changed from LessOrEqual(t, policy.MaxAttempts, 2) to Equal(t, 2, policy.MaxAttempts) so it verifies the actual compiled value, not a vacuous upper bound.

spec_refs:
- "§10 — Combined conditions require all to be satisfied for allow"
- "§10.1 — T-GH-081: request satisfying all conditions is allowed"
- "§10.2 — T-GH-082: request failing any single condition is denied"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] T-GH-82 claims "failure of any single condition MUST produce a deny" but only two of the four configured conditions (repo, integrity) are tested in failure scenarios — role failure and private-repos visibility failure are not covered.

This leaves §10 combined-condition evaluation under-specified: an implementation could fail to check role or visibility in the combined path and all three fixture scenarios would still pass.

💡 Suggested additional scenarios

Add scenario_id: combined-filter-allow-D (role fails → denied with -32008) and scenario_id: combined-filter-allow-E (visibility fails → denied with -32007) to make T-GH-82 complete:

# --- Scenario D: role condition fails → denied ---
- scenario_id: "combined-filter-allow-D"
  description: "Request with insufficient role is denied even when repo, integrity, and visibility match"
  input:
    tool_config:
      repos: ["github/gh-aw"]
      roles: ["write"]
      private-repos: true
      min-integrity: "approved"
    request:
      repository: "github/gh-aw"
      user_role: "read"
      is_private: true
      content_integrity: "approved"
  expected:
    decision: deny
    error_code: -32008
    reason: "user role is insufficient"

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. Added scenarios D (role failure → -32002) and E (private-repo visibility failure → -32003) to combined-filter-allow.yaml, completing T-GH-82's "failure of any single condition MUST deny" coverage.

Comment thread pkg/intent/policy.go
// 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] PolicyRule.Scope is documented as a "hint for evaluation ordering" but has no effect on rule application — Compile() processes rules in declaration order regardless of scope value.

The spec and the intent-policy.json schema both state "Rules MUST be listed from highest to lowest precedence", which places the ordering burden entirely on the JSON author. A mis-ordered policy file (e.g., intent rule before organization rule) compiles silently and produces a weakened policy that passes all tests.

💡 Suggested improvement

Validate or sort by scope rank in Compile() (or a separate Validate() step) before processing:

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3315d2c. Compile() now explicitly sorts matched rules by scopePriorityOrder using slices.SortStableFunc, enforcing org > repo > intent > workflow precedence regardless of declaration order. TestPolicyCompilerScopeOrderingEnforced verifies the invariant.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🤖 PR Triage — Run §28750744185

Field Value
Category feature
Risk 🔴 High
Score 71 / 100
Action 🚀 fast_track

Score breakdown: Impact 38 + Urgency 19 + Quality 14

Rationale: Large SPDD batch (16 files, +1282/-6) adding Go outcome evaluators, a new PolicyCompiler, and spec coverage. CI passing (4/5 SUCCESS). High priority: fills P0/P1/P2 gaps identified in the plan. High risk due to scope (new interfaces, API integration). Bot reviewer flagged concerns; a dismissed review from github-actions should be followed up. Needs human validation of evaluator logic and PolicyCompiler correctness.

Recommended next step: Human fast-track review — focus on evaluator edge cases and PolicyCompiler interface contracts.

Generated by 🔧 PR Triage Agent · 101.7 AIC · ⌖ 12.4 AIC · ⊞ 5.5K ·

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address unresolved review feedback from github-actions if still applicable, ensure the branch is up to date, and rerun checks once ready.

Generated by PR Sous Chef: https://github.com/github/gh-aw/actions/runs/28751420498

Generated by 👨‍🍳 PR Sous Chef · 22.2 AIC · ⌖ 8.8 AIC · ⊞ 3.7K ·
Comment /souschef to run again

Copilot AI and others added 2 commits July 5, 2026 19:31
…, AllowedTools deny-all, workflow evaluator tests, error code table

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
…mment, improve AllowedTools switch comment, combine deny-all assertion, annotate Labels usage

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address unresolved review feedback from github-actions if still applicable, ensure the branch is up to date, and rerun checks once ready.

Generated by P...

Done. Addressed all four unresolved review threads from @copilot-pull-request-reviewer:

  1. PolicyCompiler.Compile() baseline (pkg/intent/policy.go:94-104): The compiler no longer seeds from safestDefaultPolicy(). It now collects matching rules, uses the first rule's Set as the baseline (allowing rules to express supervised autonomy, auto_merge_allowed=true, max_attempts>1, etc.), merges subsequent rules with more-restrictive-wins, then calls applyFailClosedDefaults() to fill in safe values for any field no rule touched. No-match case still returns safestDefaultPolicy() directly. Two new regression tests added: TestPolicyCompilerRulesCanGrantLessRestrictiveThanSafeDefault and TestPolicyCompilerAllowedToolsDenyAllOnEmptyIntersection.

  2. AllowedTools empty-intersection security bug (pkg/intent/policy.go:172-186): Fixed to use nil/[]string{} semantics — nil = unrestricted, []string{} (non-nil empty) = deny-all. When both sides restrict but their intersection is empty, the result is now []string{} (deny-all), not nil (unrestricted).

  3. Unit tests for evalDispatchWorkflow (pkg/cli/outcome_eval_workflow.go:13-77): Added outcome_eval_workflow_test.go with 9 tests covering: no run_id, zero run_id, API error, completed/success (OutcomeAccepted), completed/failure+timed_out+cancelled (OutcomeRejected), completed/other conclusion (OutcomeIgnored), in_progress (OutcomePending), and int64 run_id handling.

  4. Error-code table (specs/github-mcp-access-control-compliance/README.md:42-56): Corrected to match the actual spec and existing fixtures — -32001 repos, -32002 roles, -32003 private-repos, -32004 blocked-users, -32005 allowed-tools, -32006 min-integrity. Removed the incorrect -32007/-32008 entries.

Commits: fix(policy,tests):... and fix(policy): address code review...

CI note: make lint and make test-unit pass locally except for the pre-existing TestPRSousChefWorkflowAddCommentTargetContract failure (present in the base commit before this branch). CI will need a human re-trigger after these pushes.

Copilot AI requested a review from gh-aw-bot July 5, 2026 19:34
@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address unresolved review feedback from github-actions, ensure the branch is up to date, and rerun checks once ready.

Generated by PR Sous Chef: https://github.com/github/gh-aw/actions/runs/28752519141

Generated by 👨‍🍳 PR Sous Chef · 11.5 AIC · ⌖ 4.41 AIC · ⊞ 3.7K ·
Comment /souschef to run again

Copilot AI and others added 3 commits July 5, 2026 19:57
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
…ns, scope ordering, AutoMergeAllowed *bool, tests

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
…est variable

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address unresolved review feedback from github-actions, ensure the branch is up to date, and rerun checks once ready.

Generated by PR Sous Chef: https:/...

Done. All non-outdated review threads from the first and second passes have been addressed. Here's a summary of the fixes pushed in commits fix: address second-round review feedback and fix: improve scopePriorityOrder comments:

pkg/cli/outcome_eval_workflow.go

  • Float64 overflow guard: replaced id <= math.MaxInt64 (unrepresentable as float64) with id <= maxSafeFloat64Int (2^53 = max losslessly representable integer). Test: TestEvalDispatchWorkflowFloat64OverflowGuard.
  • action_required conclusion: mapped to OutcomeRejected (blocking, requires human intervention — not self-resolving). Test: TestEvalDispatchWorkflowActionRequired.
  • evalUpdateDiscussion: changed return from OutcomePendingOutcomeIgnored to prevent infinite retry loops.

pkg/intent/policy.go

  • AutoMergeAllowed changed to *bool: nil = unset (rule didn't express a preference), false = explicit denial, true = explicit grant. The merge logic only applies the AND step when at least one side is non-nil, so a rule that doesn't mention AutoMergeAllowed no longer silently disables it.
  • Scope ordering in Compile(): matched rules are now sorted by scopePriorityOrder (org=4 > repo=3 > intent=2 > workflow=1 > unscoped=0) before merging, so declaration order no longer determines precedence.
  • safestDefaultPolicy and mergePolicy updated for *bool semantics.
  • Added advisory-only warning to ExecutionPolicy and PolicyCompiler doc comments.

pkg/intent/policy_test.go

  • MaxAttempts assertion changed from LessOrEqual(t, policy.MaxAttempts, 2)Equal(t, 2, policy.MaxAttempts) to be non-vacuous.
  • New test TestPolicyCompilerScopeOrderingEnforced: rules declared in reverse precedence order (intent, repo, org) must still compile with org's values winning.

specs/github-mcp-access-control-compliance/combined-filter-allow.yaml

  • Added scenarios D (role failure → deny, -32002) and E (private-repo visibility failure → deny, -32003) to complete T-Proposal: Multi step workflows #82 combined-condition coverage.

Pre-existing failure: TestPRSousChefWorkflowAddCommentTargetContract was failing before this branch and is unrelated to these changes. CI needs a human re-trigger.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address the remaining unresolved review feedback, and post a concise maintainer-ready summary once done.

Unresolved review feedback to address:

Run: https://github.com/github/gh-aw/actions/runs/28754210553

Generated by 👨‍🍳 PR Sous Chef · 8.32 AIC · ⌖ 4.05 AIC · ⊞ 3.7K ·
Comment /souschef to run again

Copilot AI commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address the remaining unresolved review feedback, and post a concise maintainer-ready summary once done.

Unresolved review feedback to address:...

Done. Here is the maintainer-ready summary:


  • ✅ Reviews — All 21 unresolved review threads (4 from copilot-pull-request-reviewer, 17 from github-actions[bot]) have been replied to. Every blocking issue from the REQUEST_CHANGES review was fixed in prior commits (e2c15eb, 3315d2c, c3f8ad4):

    • AllowedTools disjoint intersection → deny-all ([]string{}), not nil (unrestricted)
    • float64 <= math.MaxInt64 overflow guard → maxSafeFloat64Int = 1<<53
    • evalUpdateDiscussion perpetual pending → returns OutcomeIgnored with advisory detail
    • action_required → mapped to OutcomeRejected
    • AutoMergeAllowed bool → *bool (nil = unset, false = explicit deny, true = explicit grant)
    • Scope ordering → enforced via slices.SortStableFunc on scopePriorityOrder
    • evalDispatchWorkflow unit tests → 9 tests in outcome_eval_workflow_test.go
    • Error code table → corrected to match spec (-32001 through -32006)
    • Vacuous MaxAttempts <= 2 test → changed to Equal(t, 2, ...)
    • T-Proposal: Multi step workflows #82 combined-conditions coverage → added scenarios D and E
  • ✅ Checks (local) — make fmt passes. make test-unit passes for all packages except the pre-existing TestPRSousChefWorkflowAddCommentTargetContract failure in pkg/cli, which predates this branch and is unrelated to these changes.

  • ❓ Checks (CI) — stale after agent push; needs human re-trigger. Prior CI run 28753394155 showed all checks passing on the latest commits.

  • ✅ Mergeable — branch is not behind and has no conflicts (no commits to the base since this branch was cut).

Actions taken this run: Replied to all 21 unresolved review threads; no code changes were needed (all fixes were already committed).

Hand-off: CI must be re-triggered by a maintainer (close/reopen PR, workflow_dispatch, or a push) before merge.

Still needed: Human approval to merge.

@pelikhan pelikhan merged commit 4576cc0 into main Jul 5, 2026
30 checks passed
@pelikhan pelikhan deleted the copilot/spdd-daily-spec-work-plan-2026-07-05 branch July 5, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[spdd] Daily spec work plan - 2026-07-05

4 participants