Skip to content

Refactor safe-output dispatch logic to use a canonical handler descriptor table#32489

Merged
pelikhan merged 7 commits into
mainfrom
copilot/refactor-safe-outputs-dispatch-tables
May 16, 2026
Merged

Refactor safe-output dispatch logic to use a canonical handler descriptor table#32489
pelikhan merged 7 commits into
mainfrom
copilot/refactor-safe-outputs-dispatch-tables

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 15, 2026

Safe-output handler behavior was spread across multiple parallel switch/if tables, making every new handler a multi-file shotgun edit with high drift risk (conflict detection, merge behavior, and permission derivation could diverge). This change introduces one canonical descriptor source and routes key dispatch paths through it.

  • Canonical handler metadata

    • Added safe_output_handlers.go with a single safeOutputHandlers descriptor list (Key, aliases, struct field, tool name, constructor, permission builder, builtin marker).
    • Added shared lookup/reflection helpers for field read/write and generic merge.
  • Dispatch-site consolidation

    • hasSafeOutputType now resolves by descriptor key + reflected field check (including alias support).
    • SafeOutputsConfigFromKeys now constructs config via descriptor constructors instead of a large switch.
    • mergeSafeOutputConfig now performs descriptor-driven field merge for standard handlers, while preserving existing special-case semantics (e.g., protected-files set merges, auto-default override behavior).
  • Permission derivation consolidation

    • ComputePermissionsForSafeOutputs now iterates descriptor permission builders for handler-managed permissions, reducing repeated per-handler branches while preserving per-handler semantics.
  • Existing hot-path behavior preserved

    • hasAnySafeOutputEnabled / hasNonBuiltinSafeOutputsEnabled remain direct nil-check cascades (no reflection on compile hot path).
    • safeOutputFieldMapping is now derived from descriptor metadata.
  • Parity guardrails

    • Updated TestHasSafeOutputTypeNewKeys to validate legacy key parity against the descriptor table and ensure constructor/field type compatibility.
func hasSafeOutputType(config *SafeOutputsConfig, key string) bool {
	if config == nil {
		return false
	}
	handler, ok := getSafeOutputHandlerByKey(key)
	if !ok {
		return false
	}
	return hasSafeOutputFieldSet(config, handler.StructField)
}

Copilot AI and others added 2 commits May 15, 2026 22:38
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Refactor parallel dispatch tables for safe outputs to reduce duplication Refactor safe-output dispatch logic to use a canonical handler descriptor table May 15, 2026
Copilot AI requested a review from pelikhan May 15, 2026 22:43
@pelikhan pelikhan marked this pull request as ready for review May 15, 2026 22:43
Copilot AI review requested due to automatic review settings May 15, 2026 22:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Consolidates safe-output handler metadata (key, struct field, tool name, constructor, permission builder, builtin marker) into a single descriptor table and routes existing dispatch paths through it via reflection helpers, replacing several parallel switch/if cascades.

Changes:

  • Adds safe_output_handlers.go with a canonical safeOutputHandlers descriptor list plus shared lookup/reflection helpers.
  • Rewrites hasSafeOutputType, SafeOutputsConfigFromKeys, mergeSafeOutputConfig, and ComputePermissionsForSafeOutputs to drive off the descriptor table while preserving special-case merge/permission semantics.
  • Replaces the hand-maintained safeOutputFieldMapping map with a descriptor-derived builder and updates TestHasSafeOutputTypeNewKeys to validate legacy switch keys against the descriptor table.
Show a summary per file
File Description
pkg/workflow/safe_output_handlers.go New canonical handler descriptor table and reflection helpers.
pkg/workflow/imports.go hasSafeOutputType and mergeSafeOutputConfig now drive off the descriptor table; special-case merges retained.
pkg/workflow/safe_outputs_permissions.go ComputePermissionsForSafeOutputs iterates descriptor permission builders; SafeOutputsConfigFromKeys uses descriptor constructors.
pkg/workflow/safe_outputs_state.go safeOutputFieldMapping now derived from descriptors; maintenance NOTE replaced with new descriptor-centric note.
pkg/workflow/safe_outputs_fix_test.go Test rewritten as a descriptor-parity check against the legacy switch key set.

Copilot's findings

Tip

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

Comments suppressed due to low confidence (1)

pkg/workflow/safe_output_handlers.go:495

  • The Key for the dispatch_repository handler uses underscores ("dispatch_repository"), while every other descriptor in this table uses kebab-case (e.g. "dispatch-workflow", "create-issue", "update-release"). Although the JSON schema uses the underscore form, the inconsistent style here makes lookups by getSafeOutputHandlerByKey("dispatch-repository") silently miss this entry — which matters because parseDispatchRepositoryConfig explicitly accepts both dispatch_repository and dispatch-repository as aliases. Consider either making the canonical Key "dispatch-repository" with Aliases: []string{"dispatch_repository"}, or adding Aliases: []string{"dispatch-repository"} to match the alias support that already exists at parse time. As-is, any caller routing through the descriptor with the dash form will not find this handler.
	{
		Key:         "dispatch_repository",
		StructField: "DispatchRepository",
		ToolName:    "dispatch_repository",
		NewConfig:   func() any { return &DispatchRepositoryConfig{} },
	},
  • Files reviewed: 5/5 changed files
  • Comments generated: 5

Comment on lines +416 to +436
permissions := NewPermissionsContentsReadProjectsWrite()
if current, exists := permissions.Get(PermissionIssues); !exists || current != PermissionWrite {
permissions.Set(PermissionIssues, PermissionRead)
}
return permissions
},
},
{
Key: "create-project",
StructField: "CreateProjects",
ToolName: "create_project",
NewConfig: func() any { return &CreateProjectsConfig{} },
PermissionBuilder: func(safeOutputs *SafeOutputsConfig) *Permissions {
if !isSafeOutputHandlerEnabledAndUnstaged(safeOutputs, "CreateProjects") {
return nil
}
permissions := NewPermissionsContentsReadProjectsWrite()
if current, exists := permissions.Get(PermissionIssues); !exists || current != PermissionWrite {
permissions.Set(PermissionIssues, PermissionRead)
}
return permissions
Comment thread pkg/workflow/imports.go
Comment on lines +349 to 365
// Merge each safe output type (only set if nil in result).
// Types with custom merge semantics are handled below.
specialMergeFields := map[string]bool{
"CreatePullRequests": true,
"PushToPullRequestBranch": true,
"MissingTool": true,
"MissingData": true,
"NoOp": true,
"ReportIncomplete": true,
"ThreatDetection": true,
}
if result.ClosePullRequests == nil && importedConfig.ClosePullRequests != nil {
result.ClosePullRequests = importedConfig.ClosePullRequests
}
if result.MarkPullRequestAsReadyForReview == nil && importedConfig.MarkPullRequestAsReadyForReview != nil {
result.MarkPullRequestAsReadyForReview = importedConfig.MarkPullRequestAsReadyForReview
}
if result.AddComments == nil && importedConfig.AddComments != nil {
result.AddComments = importedConfig.AddComments
for _, handler := range safeOutputHandlers {
if specialMergeFields[handler.StructField] {
continue
}
mergeSafeOutputFieldIfNil(result, importedConfig, handler.StructField)
}
Comment thread pkg/workflow/safe_outputs_fix_test.go Outdated
Comment on lines +79 to +83
constructorType := reflect.ValueOf(handler.NewConfig()).Type()
require.True(t, constructorType.AssignableTo(field.Type()),
"descriptor constructor for key %q returns %v, expected assignable to %v", key, constructorType, field.Type())
}
field.Set(reflect.New(field.Type().Elem()))
Comment on lines +137 to 145
handler, ok := getSafeOutputHandlerByKey(key)
if !ok || handler.NewConfig == nil {
continue
}
if hasSafeOutputFieldSet(config, handler.StructField) {
continue
}
_ = setSafeOutputField(config, handler.StructField, handler.NewConfig())
}
Comment on lines +16 to +24
// NOTE: Most safe output dispatch behavior is driven by safeOutputHandlers in
// safe_output_handlers.go. The two functions below intentionally remain direct
// nil-check cascades because they are hot-path checks executed on every compile.

// safeOutputFieldMapping maps SafeOutputsConfig struct field names to their tool names.
// This map is used by imports, prompt generation, and other metadata operations.
// It is NOT used for existence checks — see hasAnySafeOutputEnabled and
// hasNonBuiltinSafeOutputsEnabled for the performance-critical direct-field versions.
var safeOutputFieldMapping = map[string]string{
"CreateIssues": "create_issue",
"CreateAgentSessions": "create_agent_session",
"CreateDiscussions": "create_discussion",
"UpdateDiscussions": "update_discussion",
"CloseDiscussions": "close_discussion",
"CloseIssues": "close_issue",
"ClosePullRequests": "close_pull_request",
"AddComments": "add_comment",
"CreatePullRequests": "create_pull_request",
"CreatePullRequestReviewComments": "create_pull_request_review_comment",
"SubmitPullRequestReview": "submit_pull_request_review",
"ReplyToPullRequestReviewComment": "reply_to_pull_request_review_comment",
"ResolvePullRequestReviewThread": "resolve_pull_request_review_thread",
"CreateCodeScanningAlerts": "create_code_scanning_alert",
"AutofixCodeScanningAlert": "autofix_code_scanning_alert",
"AddLabels": "add_labels",
"RemoveLabels": "remove_labels",
"AddReviewer": "add_reviewer",
"AssignMilestone": "assign_milestone",
"AssignToAgent": "assign_to_agent",
"AssignToUser": "assign_to_user",
"UnassignFromUser": "unassign_from_user",
"UpdateIssues": "update_issue",
"UpdatePullRequests": "update_pull_request",
"MergePullRequest": "merge_pull_request",
"PushToPullRequestBranch": "push_to_pull_request_branch",
"UploadAssets": "upload_asset",
"UploadArtifact": "upload_artifact",
"UpdateRelease": "update_release",
"UpdateProjects": "update_project",
"CreateProjects": "create_project",
"CreateProjectStatusUpdates": "create_project_status_update",
"LinkSubIssue": "link_sub_issue",
"HideComment": "hide_comment",
"DispatchWorkflow": "dispatch_workflow",
"DispatchRepository": "dispatch_repository",
"CallWorkflow": "call_workflow",
"MissingTool": "missing_tool",
"MissingData": "missing_data",
"SetIssueType": "set_issue_type",
"SetIssueField": "set_issue_field",
"NoOp": "noop",
"MarkPullRequestAsReadyForReview": "mark_pull_request_as_ready_for_review",
}
var safeOutputFieldMapping = buildSafeOutputFieldMapping()
@github-actions
Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100

Excellent test quality

Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected No
🚨 Coding-guideline violations None

Test Classification Details

Test File Classification Issues Detected
TestHasSafeOutputTypeNewKeys pkg/workflow/safe_outputs_fix_test.go:19 ✅ Design None

The other three test functions (TestSafeOutputsConfigYAMLTags, TestMergeSafeOutputsMetaFieldsUnit, TestMergeProtectedFilesExcludeAsSet) were unchanged and are not scored.


Test Analysis

TestHasSafeOutputTypeNewKeys was rewritten from a flat per-key table-driven test to a reflection-based descriptor parity check. It now:

  • Iterates over all 44 previously-supported hasSafeOutputType switch keys
  • Verifies each key exists in the new canonical handler descriptor table (getSafeOutputHandlerByKey)
  • Uses reflection to validate that the referenced struct field exists on SafeOutputsConfig and is a pointer type
  • Validates the constructor return type is assignable to the field type (type-safe contract)
  • Asserts both the positive case (field set → hasSafeOutputType returns true) and negative case (field nil → returns false)

This is a high-value design test: it enforces the invariant that the refactored descriptor table is a complete replacement for the old switch statement, and would catch any future key omission.

All assertions include descriptive messages ✅. No mocks ✅. Build tag //go:build !integration on line 1 ✅.


Test Inflation Check

File Lines Added Corresponding Production File Production Lines Added Ratio
safe_outputs_fix_test.go 66 safe_output_handlers.go 671 0.10 — ✅ No inflation

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 1 modified test — unit (//go:build !integration)
  • 🟨 JavaScript: no test files changed

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). The refactored test enforces a stronger behavioral contract than the original flat-key table by additionally validating field-name parity, constructor type-safety, and bidirectional hasSafeOutputType behavior via reflection.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

References: §25944925628

🧪 Test quality analysis by Test Quality Sentinel · ● 6.8M ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

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. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). The refactored TestHasSafeOutputTypeNewKeys is a high-value design test enforcing descriptor table parity, field validity, constructor type-safety, and bidirectional hasSafeOutputType behavior.

@github-actions
Copy link
Copy Markdown
Contributor

Commit pushed: 9c817a9

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · ● 6.4M

@github-actions
Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

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

AI has analyzed the PR diff and generated a draft ADR to help you get started:

📄 Draft ADR: docs/adr/32489-canonical-descriptor-table-for-safe-output-dispatch.md

The draft has been committed to this branch (status: Draft). It captures the descriptor-table approach, the parallel-table problem it solves, two real alternatives (keep parallel tables; code-generate from a schema), the preserved special-case semantics, and an RFC 2119 normative section covering descriptor source-of-truth, dispatch-site routing, hot-path preservation, and parity guardrails.

What to do next

  1. Review the draft ADR committed to your branch — it was generated from the PR body and the new safe_output_handlers.go file.
  2. Complete the missing sections — verify the context paragraph matches your motivation, refine the alternatives if you considered others (e.g., a sub-package or an interface-per-handler approach), and confirm the consequences reflect the trade-offs you actually accept (notably: reflection cost, descriptor centralization, and the dual generic/special-case mental model).
  3. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-32489: Canonical Descriptor Table for Safe-Output Dispatch

  4. Flip the status from Draft to Proposed (or Accepted) once you're satisfied with the content.

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the recorded 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. A descriptor-table dispatch is exactly the kind of structural decision future contributors need recorded — without it, the next person who sees the reflection helpers will wonder whether they should add another parallel switch instead.

📋 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., 32489-...md for PR #32489).

References:

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · ● 6.4M ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /improve-codebase-architecture and /zoom-out as the primary skills (large refactor introducing a new canonical module) with /tdd for test coverage concerns.


Key Themes

1. Internal drift not fully eliminated (/improve-codebase-architecture)
The most important issue: each PermissionBuilder closure repeats the StructField string (isSafeOutputHandlerEnabledAndUnstaged(safeOutputs, "CreateIssues")) instead of referencing handler.StructField. The descriptor was designed to be the single source of truth, but the permission guard introduces a second copy of the same string for every handler. Renaming a StructField would require updating both places — the same drift the PR set out to remove.

2. Two Builtin truths remain (/zoom-out)
hasNonBuiltinSafeOutputsEnabled is a hardcoded nil-check cascade that must stay in sync with the Builtin: true flags in the descriptor table. The Builtin field is stored but currently unused by the hot-path function. The old NOTE comment that explicitly listed both remaining manual-update locations was removed — which is a contributor experience regression.

3. Reflection primitives lack direct tests (/tdd)
setSafeOutputField, hasSafeOutputFieldSet, and mergeSafeOutputFieldIfNil are the load-bearing new code, but they have no unit tests. Silent false returns on unknown field names are the main risk; the parity test catches descriptor mismatches but doesn't cover the helper behavior itself. The discarded _ = setSafeOutputField(...) return amplifies this concern.

4. NewConfig func() any weakens the interface (/improve-codebase-architecture)
Returning any means the descriptor type relation between NewConfig and StructField is only validated at test time, not compile time. A comment or interface documenting the invariant would help future contributors.


Positive Highlights

  • Excellent problem framing in the PR description — the motivation (shotgun edits, drift risk) is clearly articulated and the solution follows directly from it.
  • Hot-path preservation is correct — keeping hasAnySafeOutputEnabled and hasNonBuiltinSafeOutputsEnabled as direct nil-check cascades is the right performance call; the reasoning is documented.
  • safeOutputHandlersByKey map built at package init — avoids repeated linear scans; clean.
  • The parity test is a great idea — validating all legacy keys against the descriptor table at test time is exactly the right guardrail for this kind of refactor.
  • Net deletion of ~200 lines — the reduction in duplicated switch arms is the core value and it delivers cleanly.

Verdict

The architecture direction is sound and the PR delivers real value. The issues above are mostly refinements rather than blockers — the most impactful fix would be eliminating the repeated StructField string in PermissionBuilder closures and restoring the explicit NOTE for the two remaining manual-update locations. Happy to approve once those are addressed.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · ● 14.6M

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments

Copilot AI and others added 2 commits May 15, 2026 23:07
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 15, 2026

@copilot review all comments

Reviewed and addressed the actionable review threads in commits e9b37be and 3a5c7bf.

Implemented:

  • Simplified CreateProjects / UpdateProjects permission builders (removed dead conditional, set issues: read directly).
  • Added guardrail logging when SafeOutputsConfigFromKeys fails to set a descriptor field.
  • Updated TestHasSafeOutputTypeNewKeys to exercise descriptor constructors through setSafeOutputField and assert stored value parity.
  • Added import-merge coverage for descriptor-driven fields (unassign-from-user, dispatch_repository).
  • Added descriptor-driven hot-path parity coverage in safe_outputs_state_test (with explicit exclusion for auto-default policy controls).

Validation run:

  • make build
  • make lint
  • make test-unit
  • Targeted workflow tests for updated areas
  • parallel_validation (Code Review + CodeQL)

@github-actions
Copy link
Copy Markdown
Contributor

@copilot review all comments

Please resolve the open review threads, especially the parity test gap for hasAnySafeOutputEnabled / hasNonBuiltinSafeOutputsEnabled, and then rerun checks.

Generated by 👨‍🍳 PR Sous Chef ·

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot merge main and recompile

…-outputs-dispatch-tables

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 15, 2026

@copilot merge main and recompile

Merged origin/main into this branch in commit 83335afa24 and recompiled successfully (make build). Also ran targeted safe-output workflow tests: go test ./pkg/workflow -run 'SafeOutput|safe_output|HasSafeOutput|ComputePermissionsForSafeOutputs'.

@pelikhan pelikhan merged commit c6dfe20 into main May 16, 2026
@pelikhan pelikhan deleted the copilot/refactor-safe-outputs-dispatch-tables branch May 16, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor: 8 parallel dispatch tables keyed by safe-output type cause shotgun surgery

3 participants