Skip to content

feat: governance gate — benchmark scores enforce execution policy#65

Open
djimit wants to merge 1 commit into
mainfrom
feat/governance-gate
Open

feat: governance gate — benchmark scores enforce execution policy#65
djimit wants to merge 1 commit into
mainfrom
feat/governance-gate

Conversation

@djimit

@djimit djimit commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Pillar 1 of Closed-Loop Governance: certification with teeth. The nightly OpenMythos scores stop being a dashboard number and become a live security control in the task-execution path.

  • GovernanceGateService, consulted in ExecutionEngine.executeTask after the policy evaluation. When the executing agent/model's latest benchmark score is below GOVERNANCE_GATE_FLOOR (default 3/5), an allow decision is tightened to require_approval — the existing approval machinery (WS pause event, audit record, approval queue) handles the rest, and the approver sees the benchmark evidence as the reason.
  • The gate never loosens: policy deny/require_approval outcomes stand regardless of score.
  • Three consecutive below-floor runs with a declining trend mark the agent as a retirement candidate in the captured evidence (source: governance-gate) — auto-invoking the retirement workflow is the documented upgrade once operators trust the signal.
  • Evidence lookup order: the task's agent_idnightly:<model> via GOVERNANCE_GATE_MODEL_MAP (maps executor kinds to benchmarked models) → run-metadata subject_model. No evidence → allow: the gate acts only on measured behavior.
  • Default-off (GOVERNANCE_GATE_ENABLED=true to arm).

Also fixed

The unit-test approvals/audit_events schemas had drifted from production — the approval branch of executeTask was untestable before this PR (inserts failed on missing columns). Trued both up to the real schema, which is what let the integration tests below exist.

Validation

  • 8 unit tests (disabled/no-evidence/floor/env-floor/model-map/subject-model-fallback/retirement-flag) + 2 engine integration tests: a below-floor model turns a clean low-risk allow into awaiting_approval with governance-gate evidence; an above-floor model executes untouched
  • Full server suite: 1,223 tests green; type-check + lint clean
  • EvidenceSource union extended with 'governance-gate' (shared package, additive)

Pairs with #61 (nightly scores feed the gate) and #62 (routing) — the enforcement leg of the same loop.

🤖 Generated with Claude Code

GovernanceGateService is consulted in ExecutionEngine.executeTask after
the policy evaluation: when the executing agent/model's latest OpenMythos
score is below GOVERNANCE_GATE_FLOOR (default 3/5), an 'allow' decision
is tightened to require_approval, with the benchmark evidence captured
(source 'governance-gate') and the reason surfaced to the approver. The
gate never loosens policy outcomes. Three consecutive below-floor runs
with a declining trend mark the agent as a retirement candidate in the
verdict evidence.

Default-off (GOVERNANCE_GATE_ENABLED). Evidence lookup: the task's
agent_id, then nightly:<model> via GOVERNANCE_GATE_MODEL_MAP, then
metadata subject_model. No evidence -> allow: the gate acts only on
measured behavior.

Also trues up the test approvals/audit_events schemas to production —
the approval branch of executeTask had never been exercisable in unit
tests because the test tables were missing columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kilo-code-bot

kilo-code-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Kilo Code Review could not run — your account is out of credits.

Add credits or switch to a free model to enable reviews on this change.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the GovernanceGateService to tighten task execution policy decisions to require approval when an agent's benchmark score falls below a specified floor. It also updates the database schema for audit events and approvals, adds corresponding unit tests, and registers a new evidence source. Feedback on these changes suggests optimizing database queries in GovernanceGateService by filtering directly in SQL and avoiding redundant calls, decoupling evidence capturing from decision tightening so that benchmark evidence is always recorded, and refining the retirement candidate logic to verify a true persistent decline across three runs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +66 to +86
private recentRuns(agentId: string | null, subjectModel: string | null, limit = 3): RunRow[] {
const rows = this.db.prepare(`
SELECT agent_id, overall_score, finished_at, metadata
FROM openmythos_eval_runs
WHERE status = 'completed'
ORDER BY finished_at DESC
`).all() as RunRow[];

const matches = rows.filter((row) => {
if (agentId && row.agent_id === agentId) return true;
if (subjectModel) {
if (row.agent_id === `nightly:${subjectModel}`) return true;
try {
const metadata = JSON.parse(row.metadata || '{}') as { subject_model?: string };
if (metadata.subject_model === subjectModel) return true;
} catch { /* skip malformed */ }
}
return false;
});
return matches.slice(0, limit);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The recentRuns method currently queries all rows from the openmythos_eval_runs table where status = 'completed' and filters them in-memory. If the database grows large, this will cause significant performance degradation and high memory usage on every task execution.

We should optimize this by filtering directly in the SQL query using the agent_id index or narrowing down the rows using a LIKE filter before parsing JSON metadata.

  private recentRuns(agentId: string | null, subjectModel: string | null, limit = 3): RunRow[] {
    if (agentId) {
      return this.db.prepare(
        "SELECT agent_id, overall_score, finished_at, metadata " +
        "FROM openmythos_eval_runs " +
        "WHERE status = 'completed' AND agent_id = ? " +
        "ORDER BY finished_at DESC " +
        "LIMIT ?"
      ).all(agentId, limit) as RunRow[];
    }

    if (subjectModel) {
      const rows = this.db.prepare(
        "SELECT agent_id, overall_score, finished_at, metadata " +
        "FROM openmythos_eval_runs " +
        "WHERE status = 'completed' " +
        "AND (agent_id = ? OR metadata LIKE ?) " +
        "ORDER BY finished_at DESC"
      ).all("nightly:" + subjectModel, "%" + subjectModel + "%") as RunRow[];

      const matches = rows.filter((row) => {
        if (row.agent_id === "nightly:" + subjectModel) return true;
        try {
          const metadata = JSON.parse(row.metadata || "{}") as { subject_model?: string };
          if (metadata.subject_model === subjectModel) return true;
        } catch { /* skip malformed */ }
        return false;
      });
      return matches.slice(0, limit);
    }

    return [];
  }

Comment on lines +171 to +189
if (gateVerdict.action === 'require_approval' && evaluation.decision === 'allow') {
evaluation = { ...evaluation, decision: 'require_approval', explanation: gateVerdict.reason };
this.evidenceService.captureEvidence({
task_id: taskId,
evidence_type: EvidenceType.POLICY_DECISION,
severity: EvidenceSeverity.WARNING,
title: 'Governance gate tightened execution to require approval',
summary: gateVerdict.reason,
details: {
agentKey: gateVerdict.agentKey,
score: gateVerdict.score,
floor: gateVerdict.floor,
trend: gateVerdict.trend,
retirement_candidate: gateVerdict.flagRetirement,
executorKind,
},
source: 'governance-gate',
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The governance gate evidence is currently only captured if evaluation.decision === 'allow'. If the policy decision is already 'require_approval' (or 'deny'), the governance gate check is skipped entirely, and no evidence is captured.

This means the operator/approver will not see the benchmark evidence or the retirement candidate flag when reviewing the task, even though the model is below the floor.

We should decouple the decision tightening from the evidence capturing so that governance gate evidence is always captured when the model is below the floor, regardless of the initial policy decision.

    if (gateVerdict.action === 'require_approval') {
      if (evaluation.decision === 'allow') {
        evaluation = { ...evaluation, decision: 'require_approval', explanation: gateVerdict.reason };
      }
      this.evidenceService.captureEvidence({
        task_id: taskId,
        evidence_type: EvidenceType.POLICY_DECISION,
        severity: EvidenceSeverity.WARNING,
        title: 'Governance gate tightened execution to require approval',
        summary: gateVerdict.reason,
        details: { 
          agentKey: gateVerdict.agentKey,
          score: gateVerdict.score,
          floor: gateVerdict.floor,
          trend: gateVerdict.trend,
          retirement_candidate: gateVerdict.flagRetirement,
          executorKind,
        },
        source: 'governance-gate',
      });
    }

Comment on lines +97 to +101
const runs = task.agent_id
? this.recentRuns(task.agent_id, null).length > 0
? this.recentRuns(task.agent_id, null)
: this.recentRuns(null, subjectModel)
: this.recentRuns(null, subjectModel);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When task.agent_id is provided, recentRuns is called twice (once to check if the length is greater than 0, and once to retrieve the runs). This results in redundant database queries. We can optimize this by fetching the runs once and reusing the result.

    const agentRuns = task.agent_id ? this.recentRuns(task.agent_id, null) : [];
    const runs = agentRuns.length > 0 ? agentRuns : this.recentRuns(null, subjectModel);

};
}

const flagRetirement = runs.length >= 3 && runs.every((run) => run.overall_score < floor) && trend === 'declining';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The flagRetirement logic currently checks if trend === 'declining'. However, trend is only calculated by comparing the two most recent runs (runs[0] and runs[1]). If the middle run (runs[1]) was a temporary peak but the oldest run (runs[2]) was actually lower, this is not a persistent decline across all three runs.

To ensure a true persistent decline, we should verify that the scores are strictly decreasing across all three runs (e.g., runs[0].overall_score < runs[1].overall_score && runs[1].overall_score < runs[2].overall_score).

    const flagRetirement = runs.length >= 3 &&
      runs.every((run) => run.overall_score < floor) &&
      runs[0].overall_score < runs[1].overall_score &&
      runs[1].overall_score < runs[2].overall_score;

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1553498698

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

this.persistRiskAssessment(taskId, assessment, `${parsedTask.title}: ${parsedTask.description}`);

// Governance gate: benchmark evidence can only TIGHTEN the policy decision.
const gateVerdict = this.governanceGate.assess(parsedTask, executorKind);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-run the gate after selecting a fallback executor

When the requested executor's circuit is open, executeTask later reassigns executorKind to a fallback before starting the session. Because the governance gate is evaluated here against the original executor, a request that passes/no-evidence for opencode can fail over to claude/codex/gemini whose mapped benchmark score is below the floor and still execute without approval. This bypasses the new enforcement during circuit-breaker failover; move or repeat the gate after any fallback executor is selected.

Useful? React with 👍 / 👎.

Comment on lines +77 to +80
if (row.agent_id === `nightly:${subjectModel}`) return true;
try {
const metadata = JSON.parse(row.metadata || '{}') as { subject_model?: string };
if (metadata.subject_model === subjectModel) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve nightly evidence precedence over metadata fallback

When both a canonical nightly:<model> run and another completed run with matching metadata.subject_model exist, this combined predicate lets the newest row win regardless of source. That violates the documented lookup order above (nightly:<model> before metadata fallback) and can require or skip approval based on an ad-hoc validation run instead of the nightly score. Query the nightly key first and only scan subject_model metadata when no nightly evidence exists.

Useful? React with 👍 / 👎.

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.

1 participant