Add governance decision traces

This commit is contained in:
Brad Groux 2026-06-03 05:45:59 -07:00
parent 62f258052c
commit abfac7c446
40 changed files with 2292 additions and 201 deletions

View file

@ -42,12 +42,13 @@
29. [Error Learning](#error-learning)
30. [Tool Policies](#tool-policies)
31. [Traces](#traces)
32. [Audit](#audit)
33. [Maintenance Center](#maintenance-center-apiv1maintenance)
34. [Common Workflows](#common-workflows)
35. [Versioning & Deprecation](#versioning--deprecation)
36. [Rate Limits](#rate-limits)
37. [Additional Endpoint Groups](#additional-endpoint-groups)
32. [Governance Decision Traces](#governance-decision-traces-apigovernancetraces)
33. [Audit](#audit)
34. [Maintenance Center](#maintenance-center-apiv1maintenance)
35. [Common Workflows](#common-workflows)
36. [Versioning & Deprecation](#versioning--deprecation)
37. [Rate Limits](#rate-limits)
38. [Additional Endpoint Groups](#additional-endpoint-groups)
---
@ -1249,8 +1250,8 @@ POST /api/agents/permissions/check
```json
{
"allowed": true,
"level": "specialist",
"requiresApproval": false
"requiresApproval": false,
"traceId": "govtrace_1760000000000_ab12cd"
}
```
@ -1336,7 +1337,8 @@ Accepts either a task ID or ad-hoc metadata:
"agent": "codex-1",
"model": "claude-sonnet-4.5",
"rule": "high-priority-bugs",
"confidence": 0.95
"confidence": 0.95,
"traceId": "govtrace_1760000000000_ab12cd"
}
```
@ -1758,7 +1760,8 @@ POST /api/tool-policies/:role/validate
{
"role": "intern",
"tool": "deploy",
"allowed": false
"allowed": false,
"traceId": "govtrace_1760000000000_ab12cd"
}
```
@ -2185,6 +2188,66 @@ Update a specific assumption by its zero-based index.
---
### Governance Decision Traces (`/api/governance/traces`)
Inspect policy, tool-policy, agent-permission, routing, and workflow-gate decisions with evaluated rules, matched rules, remediation, and redacted raw detail.
#### List Governance Traces
```
GET /api/governance/traces
```
Query params: `kind`, `outcome`, `agent`, `taskId`, `actionType`, `startTime`, `endTime`, `limit`.
`kind` values: `policy`, `tool-policy`, `agent-permission`, `routing`, `workflow-gate`.
`outcome` values: `allowed`, `warned`, `blocked`, `approval-required`, `routed`, `fallback`, `skipped`.
**Response:** Array of trace records.
```json
[
{
"id": "govtrace_1760000000000_ab12cd",
"kind": "policy",
"outcome": "blocked",
"title": "Policy evaluation: git.push",
"summary": "Production deploy requires approval.",
"remediation": "Request approval from a lead agent.",
"subject": {
"agentId": "codex",
"taskId": "task_123",
"actionType": "git.push"
},
"evaluatedRules": [
{
"id": "policy:prod-risk",
"label": "Production risk gate",
"type": "policy",
"status": "matched",
"outcome": "blocked",
"message": "Risk score exceeded the blocking threshold."
}
],
"matchedRules": [],
"steps": [],
"redacted": true,
"createdAt": "2026-06-01T12:00:00.000Z"
}
]
```
#### Get Governance Trace
```
GET /api/governance/traces/:id
```
Returns one trace record including `raw` detail when present. All persisted trace values are redacted before write.
---
### Behavioral Drift Detection (`/api/drift`)
Track agent metric baselines and detect behavioral deviations.
@ -2355,14 +2418,15 @@ DELETE /api/policies/:id
#### Evaluate Policy
```
POST /api/policies/:id/evaluate
POST /api/policies/evaluate
```
```json
{
"agent": "TARS",
"tool": "browser",
"action": "navigate",
"project": "core",
"actionType": "tool.browser.navigate",
"riskScore": 72,
"metadata": { "url": "https://example.com" }
}
```
@ -2371,11 +2435,20 @@ POST /api/policies/:id/evaluate
```json
{
"allowed": false,
"effect": "deny",
"matchedRule": { "tool": "browser", "action": "*", "effect": "deny" },
"decision": "require-approval",
"matches": [
{
"policyId": "pol_abc123",
"auditId": "audit_xyz789"
"policyName": "Production risk gate",
"policyType": "risk-threshold",
"responseAction": "require-approval",
"message": "Risk score requires approval."
}
],
"warnings": [],
"blockedBy": [],
"approvalRequiredBy": ["pol_abc123"],
"traceId": "govtrace_1760000000000_ab12cd"
}
```

View file

@ -25,7 +25,7 @@ support bundles must follow.
## Data Classes
| Data class | Primary tables | Default retention | Export/delete policy |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Workspace identity and membership | `workspaces`, `users`, `workspace_memberships`, `workspace_invitations` | Until admin removal or workspace archival | Scoped exports include selected workspace, memberships, invitations, and member users only. |
| Tasks and task metadata | `tasks` | Active/backlog until archived or deleted; archived until cleanup | Preview linked artifacts before deletion. Included in full and scoped exports. |
| Task comments and discussion | `tasks` JSON | Follows parent task | Included with task export. Cleanup follows parent task unless comment-level delete is used. |
@ -35,7 +35,7 @@ support bundles must follow.
| Workflow definitions, runs, and scheduled snapshots | `workflow_definitions`, `workflow_acls`, `workflow_audit_events`, `workflow_runs`, `scheduled_deliverables`, `scheduled_deliverable_runs` | Definitions until deleted; runs/snapshots by admin retention | Never delete active runs or current scheduled state silently. |
| Notifications and subscriptions | `notifications`, `thread_subscriptions` | Until read/dismissed history cleanup | Preview delivered/read state, target category, source task, and age. |
| Chat and squad messages | `chat_sessions`, `chat_messages`, `squad_messages` | Until session/workspace cleanup | Preview session, task link, message count, agents, and age. |
| Audit, governance, and policy records | `activity_events`, `status_history`, `decision_records`, `feedback_records`, `scoring_profiles`, `scoring_evaluations`, `drift_alerts`, `drift_baselines`, `audit_entries`, `agent_policies`, `tool_policies` | Longer-lived audit evidence | Do not silently delete through operational cleanup. |
| Audit, governance, and policy records | `activity_events`, `status_history`, `decision_records`, `governance_decision_traces`, `feedback_records`, `scoring_profiles`, `scoring_evaluations`, `drift_alerts`, `drift_baselines`, `audit_entries`, `agent_policies`, `tool_policies` | Longer-lived audit evidence | Do not silently delete through operational cleanup. |
| Device sessions and API tokens | excluded from backup table exports | Until expiration or revocation | Revoke before deletion. Never print secret values or hashes. |
| Configuration and registries | `app_config_documents`, `managed_list_items`, `task_templates`, `prompt_templates`, `prompt_versions`, `prompt_usage` | Until changed or deleted | Full exports include them. Scoped exports exclude global app config. |
| Backups, imports, and exports | filesystem bundles and manifests | Until admin removes files | Every export includes a manifest with data classes, row counts, and redaction state. |

View file

@ -68,7 +68,26 @@ curl "http://localhost:3001/api/decisions?taskId=task_20260321_abc"
curl http://localhost:3001/api/decisions/dec_abc123
```
### 4. Update an Assumption
### 4. Review Governance Decision Traces
Policy evaluation, tool-policy validation, agent permission checks, agent
routing, and workflow gates record redacted decision traces alongside agent
decision records.
```bash
# List blocked governance traces
curl "http://localhost:3001/api/governance/traces?outcome=blocked"
# Inspect one trace
curl http://localhost:3001/api/governance/traces/govtrace_1760000000000_ab12cd
```
Use these traces when a policy blocks an action, a tool is denied, an agent lacks
permission, routing falls back to the default agent, or a workflow gate stops a
run. The Decision Audit Trail UI has a Governance Traces mode for the same
records.
### 5. Update an Assumption
After the outcome is known, mark individual assumptions as held or not held:
@ -95,18 +114,20 @@ curl -X PATCH http://localhost:3001/api/decisions/dec_abc123/assumptions/1 \
## API Endpoints
| Method | Path | Description |
| ------- | ------------------------------------------ | ------------------------------------- |
| ------- | ------------------------------------- | ------------------------------------- |
| `GET` | `/api/decisions` | List decisions (filterable) |
| `POST` | `/api/decisions` | Log a new decision |
| `GET` | `/api/decisions/:id` | Get a single decision |
| `PATCH` | `/api/decisions/:id/assumptions/:idx` | Update a specific assumption by index |
| `GET` | `/api/governance/traces` | List governance decision traces |
| `GET` | `/api/governance/traces/:id` | Get one governance decision trace |
---
## Decision Object Schema
| Field | Type | Required | Description |
| ------------- | -------- | -------- | --------------------------------------------------- |
| ------------- | -------- | -------- | ----------------------------------- |
| `decision` | string | ✅ | The decision made |
| `confidence` | number | ✅ | 01 confidence score |
| `reasoning` | string | ❌ | Why this decision was made |
@ -120,7 +141,7 @@ curl -X PATCH http://localhost:3001/api/decisions/dec_abc123/assumptions/1 \
## Query Parameters (List)
| Param | Type | Description |
| --------------- | ------ | -------------------------------------------- |
| --------------- | ------ | ----------------------------------------- |
| `agent` | string | Filter by agent name |
| `taskId` | string | Filter by task ID |
| `minConfidence` | number | Minimum confidence score (01) |

View file

@ -1011,14 +1011,16 @@ telemetry NDJSON files.
## Governance Repository Implementation
The first governance repository pass moves JSON-backed decisions, feedback,
scoring, and drift data into SQLite document tables with indexed query columns.
The domain services keep their existing APIs, validation, and analytics logic;
only the persistence backend switches when `VERITAS_STORAGE=sqlite`.
The first governance repository pass moves JSON-backed decisions, governance
decision traces, feedback, scoring, and drift data into SQLite document tables
with indexed query columns. The domain services keep their existing APIs,
validation, and analytics logic; only the persistence backend switches when
`VERITAS_STORAGE=sqlite`.
| Runtime table | Stored data |
| --------------------- | -------------------------------------------------------------------------------------- |
| ---------------------------- | -------------------------------------------------------------------------------------------- |
| `decision_records` | Complete `DecisionRecord` JSON plus agent, task, parent, confidence, and risk columns. |
| `governance_decision_traces` | Complete redacted governance trace JSON plus kind, outcome, agent, task, and action columns. |
| `feedback_records` | Complete `Feedback` JSON plus task, agent, rating, sentiment, and resolved columns. |
| `scoring_profiles` | Complete `ScoringProfile` JSON plus name and built-in metadata. |
| `scoring_evaluations` | Complete `EvaluationResult` JSON plus profile, agent, task, and score columns. |
@ -1244,6 +1246,7 @@ Manual recovery is intentionally boring:
| `.veritas-kanban/workflow-runs/*/progress.md` | `workflow_run_outputs` or retained file path | Store text content when reasonably sized; otherwise keep file path. |
| `.veritas-kanban/workflow-runs/*/step-outputs/*` | `workflow_run_outputs` | Preserve output filename and content type. |
| `.veritas-kanban/tool-policies/*.json` | `tool_policies` | Role remains the natural unique key per workspace. |
| `.veritas-kanban/governance-traces/*.json` | `governance_decision_traces` | Preserve redacted policy, permission, routing, and workflow gate explanations. |
| `.veritas-kanban/storage/policies/*.json` | `policy_profiles` | Keep full rules JSON until policy schema is formalized. |
| `.veritas-kanban/storage/drift/alerts/*` | `drift_alerts` | Preserve raw payload. |
| `.veritas-kanban/storage/drift/baselines/*` | `drift_baselines` | Preserve raw payload. |

View file

@ -31,6 +31,10 @@ operator checklist for final release verification.
password sessions must become persisted per-user sessions before
multi-user/server-mode GA, or the release must explicitly limit password
sessions to single-owner local deployments.
- [ ] Governance decision traces verify policy, tool-policy, agent-permission,
routing, and workflow-gate decisions include matched rules, remediation,
redacted raw detail, API access through `/api/governance/traces`, and UI
drilldown from the Decision Audit Trail.
- [ ] Performance/load review covers SQLite read/write paths, dashboard queries,
WebSocket fan-out, workflow run updates, and remote/mobile clients. Track
evidence and limits in

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentRoutingService } from '../services/agent-routing-service';
import type { AgentRoutingConfig, AppConfig, Task } from '@veritas-kanban/shared';
import type { AgentRoutingConfig, AppConfig } from '@veritas-kanban/shared';
// Mock ConfigService
const mockGetConfig = vi.fn();
@ -91,6 +91,31 @@ describe('AgentRoutingService', () => {
expect(result.reason).toContain('High-priority code');
});
it('returns a governance trace with evaluated and matched routing rules', async () => {
const result = await service.resolveAgentWithTrace(
{
type: 'code',
priority: 'high',
project: 'core',
},
{ taskId: 'task_1' }
);
expect(result.result.rule).toBe('code-high');
expect(result.trace).toMatchObject({
kind: 'routing',
outcome: 'routed',
subject: {
agentId: 'claude-code',
taskId: 'task_1',
actionType: 'agent.route',
project: 'core',
},
});
expect(result.trace.evaluatedRules?.map((rule) => rule.id)).toContain('routing:code-high');
expect(result.trace.matchedRules?.map((rule) => rule.id)).toEqual(['routing:code-high']);
});
it('matches medium-priority code task to second rule', async () => {
const result = await service.resolveAgent({
type: 'code',

View file

@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { GovernanceTraceService } from '../services/governance-trace-service.js';
describe('GovernanceTraceService', () => {
let testRoot: string;
let tracesDir: string;
beforeEach(async () => {
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-governance-traces-'));
tracesDir = path.join(testRoot, 'traces');
});
afterEach(async () => {
await fs.rm(testRoot, { recursive: true, force: true });
});
it('records redacted file-backed traces and filters the list', async () => {
const service = new GovernanceTraceService({ tracesDir, storageType: 'file' });
const secret = 'sk_live_1234567890abcdef';
const localPath = '/Users/bradgroux/Projects/veritas-kanban/.env';
const blocked = await service.record({
kind: 'policy',
outcome: 'blocked',
title: 'Policy decision',
summary: `Blocked ${secret} from ${localPath}`,
subject: { agentId: 'codex', taskId: 'task-1', actionType: 'git.push' },
evaluatedRules: [
{
id: 'policy:risk',
label: 'Risk gate',
type: 'policy',
status: 'matched',
outcome: 'blocked',
message: `Matched token ${secret}`,
},
],
matchedRules: [],
steps: [],
raw: { secret, localPath },
createdAt: '2026-06-01T12:00:00.000Z',
});
await service.record({
kind: 'routing',
outcome: 'routed',
title: 'Routing decision',
summary: 'Selected codex.',
subject: { agentId: 'codex', actionType: 'agent.route' },
createdAt: '2026-06-01T13:00:00.000Z',
});
expect(JSON.stringify(blocked)).not.toContain(secret);
expect(JSON.stringify(blocked)).not.toContain(localPath);
expect(blocked.redacted).toBe(true);
const policyTraces = await service.list({ kind: 'policy', agent: 'codex' });
expect(policyTraces.map((trace) => trace.id)).toEqual([blocked.id]);
await expect(service.get(blocked.id)).resolves.toMatchObject({
id: blocked.id,
outcome: 'blocked',
});
});
});

View file

@ -7,6 +7,7 @@ const {
mockToolPolicyService,
mockAgentPermissionService,
mockAgentRoutingService,
mockGovernanceTraceService,
mockTaskService,
} = vi.hoisted(() => ({
mockPolicyService: {
@ -15,6 +16,7 @@ const {
updatePolicy: vi.fn(),
deletePolicy: vi.fn(),
evaluatePolicies: vi.fn(),
evaluatePoliciesWithTrace: vi.fn(),
},
mockToolPolicyService: {
listPolicies: vi.fn(),
@ -22,6 +24,7 @@ const {
savePolicy: vi.fn(),
deletePolicy: vi.fn(),
validateToolAccess: vi.fn(),
validateToolAccessWithTrace: vi.fn(),
},
mockAgentPermissionService: {
listPermissions: vi.fn(),
@ -30,14 +33,19 @@ const {
setLevel: vi.fn(),
updatePermissions: vi.fn(),
checkPermission: vi.fn(),
checkPermissionWithTrace: vi.fn(),
requestApproval: vi.fn(),
reviewApproval: vi.fn(),
},
mockAgentRoutingService: {
resolveAgent: vi.fn(),
resolveAgentWithTrace: vi.fn(),
getRoutingConfig: vi.fn(),
updateRoutingConfig: vi.fn(),
},
mockGovernanceTraceService: {
record: vi.fn(),
},
mockTaskService: {
getTask: vi.fn(),
},
@ -69,6 +77,10 @@ vi.mock('../../services/agent-routing-service.js', () => ({
getAgentRoutingService: () => mockAgentRoutingService,
}));
vi.mock('../../services/governance-trace-service.js', () => ({
getGovernanceTraceService: () => mockGovernanceTraceService,
}));
vi.mock('../../services/task-service.js', () => ({
getTaskService: () => mockTaskService,
}));
@ -146,12 +158,30 @@ describe('admin-only governance routes', () => {
mockPolicyService.updatePolicy.mockResolvedValue(policyBody);
mockPolicyService.deletePolicy.mockResolvedValue(undefined);
mockPolicyService.evaluatePolicies.mockResolvedValue({ decision: 'allow', matches: [] });
mockPolicyService.evaluatePoliciesWithTrace.mockResolvedValue({
result: { decision: 'allow', matches: [] },
trace: {
kind: 'policy',
outcome: 'allowed',
title: 'Policy evaluation',
summary: 'Allowed.',
},
});
mockToolPolicyService.listPolicies.mockResolvedValue([toolPolicyBody]);
mockToolPolicyService.getToolPolicy.mockResolvedValue(toolPolicyBody);
mockToolPolicyService.savePolicy.mockResolvedValue(undefined);
mockToolPolicyService.deletePolicy.mockResolvedValue(undefined);
mockToolPolicyService.validateToolAccess.mockResolvedValue(true);
mockToolPolicyService.validateToolAccessWithTrace.mockResolvedValue({
allowed: true,
trace: {
kind: 'tool-policy',
outcome: 'allowed',
title: 'Tool policy',
summary: 'Allowed.',
},
});
mockAgentPermissionService.setLevel.mockResolvedValue({ agentId: 'a1', level: 'lead' });
mockAgentPermissionService.updatePermissions.mockResolvedValue({
@ -172,7 +202,17 @@ describe('admin-only governance routes', () => {
});
mockAgentRoutingService.resolveAgent.mockResolvedValue({ agent: 'codex' });
mockAgentRoutingService.resolveAgentWithTrace.mockResolvedValue({
result: { agent: 'codex' },
trace: {
kind: 'routing',
outcome: 'routed',
title: 'Agent routing',
summary: 'Routed.',
},
});
mockAgentRoutingService.updateRoutingConfig.mockResolvedValue(routingConfigBody);
mockGovernanceTraceService.record.mockResolvedValue({ id: 'govtrace_1' });
});
afterEach(() => {
@ -215,7 +255,7 @@ describe('admin-only governance routes', () => {
.send({ actionType: 'create_task', riskScore: 10 })
.expect(200);
expect(mockPolicyService.evaluatePolicies).toHaveBeenCalledWith({
expect(mockPolicyService.evaluatePoliciesWithTrace).toHaveBeenCalledWith({
actionType: 'create_task',
riskScore: 10,
preview: false,
@ -249,6 +289,7 @@ describe('admin-only governance routes', () => {
expect(mockToolPolicyService.savePolicy).not.toHaveBeenCalled();
expect(mockToolPolicyService.deletePolicy).not.toHaveBeenCalled();
expect(mockToolPolicyService.validateToolAccess).not.toHaveBeenCalled();
expect(mockToolPolicyService.validateToolAccessWithTrace).not.toHaveBeenCalled();
await request(app)
.put('/api/tool-policies/intern')
@ -335,7 +376,7 @@ describe('admin-only governance routes', () => {
.send({ type: 'feature', priority: 'medium' })
.expect(200);
expect(mockAgentRoutingService.resolveAgent).toHaveBeenCalledWith({
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith({
type: 'feature',
priority: 'medium',
project: undefined,

View file

@ -56,6 +56,44 @@ describe('PolicyService', () => {
expect(result.blockedBy).toContain('block-force-push');
});
it('explains policy evaluations with a redacted governance trace payload', async () => {
await service.createPolicy({
id: 'block-deploy',
name: 'Block Deploy',
type: 'block-action-type',
enabled: true,
scope: {
agents: ['codex'],
projects: ['core'],
actionTypes: [],
},
responseAction: 'block',
config: {
actionTypes: ['deploy.production'],
},
});
const evaluation = await service.evaluatePoliciesWithTrace({
agent: 'codex',
project: 'core',
actionType: 'deploy.production',
metadata: { path: '/Users/bradgroux/Projects/veritas-kanban/.env' },
});
expect(evaluation.result.decision).toBe('block');
expect(evaluation.trace).toMatchObject({
kind: 'policy',
outcome: 'blocked',
subject: {
agentId: 'codex',
project: 'core',
actionType: 'deploy.production',
},
});
expect(evaluation.trace.matchedRules?.map((rule) => rule.id)).toContain('block-deploy');
expect(evaluation.trace.remediation).toContain('Change the action');
});
it('updates and deletes a policy', async () => {
const created = await service.createPolicy({
id: 'manual-approval',

View file

@ -6,6 +6,7 @@ import type { AnyTelemetryEvent } from '@veritas-kanban/shared';
import { DecisionService } from '../../services/decision-service.js';
import { DriftService } from '../../services/drift-service.js';
import { FeedbackService } from '../../services/feedback-service.js';
import { GovernanceTraceService } from '../../services/governance-trace-service.js';
import { ScoringService } from '../../services/scoring-service.js';
import {
createTestSqliteDatabase,
@ -308,4 +309,42 @@ describe('SQLite governance repositories', () => {
await expect(fs.access(alertsDir)).rejects.toThrow();
await expect(fs.access(baselinesDir)).rejects.toThrow();
});
it('stores governance decision traces in SQLite', async () => {
const tracesDir = path.join(testRoot, 'storage', 'governance-traces');
const service = new GovernanceTraceService({
tracesDir,
storageType: 'sqlite',
sqliteDatabase: fixture.database,
});
const allowed = await service.record({
kind: 'agent-permission',
outcome: 'allowed',
title: 'Agent permission',
summary: 'Specialist can complete task.',
subject: { agentId: 'codex', taskId: 'task_1', actionType: 'complete_task' },
createdAt: '2026-06-01T12:00:00.000Z',
});
const blocked = await service.record({
kind: 'policy',
outcome: 'blocked',
title: 'Policy block',
summary: 'Production deploy requires approval.',
subject: { agentId: 'codex', taskId: 'task_2', actionType: 'git.push' },
createdAt: '2026-06-01T13:00:00.000Z',
});
expect((await service.list({ agent: 'codex' })).map((trace) => trace.id)).toEqual([
blocked.id,
allowed.id,
]);
expect((await service.list({ kind: 'policy' })).map((trace) => trace.id)).toEqual([blocked.id]);
await expect(service.get(blocked.id)).resolves.toMatchObject({
id: blocked.id,
outcome: 'blocked',
subject: { taskId: 'task_2' },
});
await expect(fs.access(tracesDir)).rejects.toThrow();
});
});

View file

@ -6,6 +6,7 @@ import path from 'path';
const mockRunStreamed = vi.fn();
const mockStartThread = vi.fn();
const mockResumeThread = vi.fn();
const mockRecordGovernanceTrace = vi.fn();
vi.mock('@openai/codex-sdk', () => ({
Codex: class {
@ -20,6 +21,12 @@ vi.mock('../services/tool-policy-service.js', () => ({
}),
}));
vi.mock('../services/governance-trace-service.js', () => ({
getGovernanceTraceService: () => ({
record: mockRecordGovernanceTrace,
}),
}));
import { WorkflowStepExecutor } from '../services/workflow-step-executor.js';
import type { WorkflowRun, WorkflowStep } from '../types/workflow.js';
@ -49,6 +56,7 @@ describe('WorkflowStepExecutor Codex integration', () => {
mockRunStreamed.mockResolvedValue({ events: codexEvents() });
mockStartThread.mockReturnValue({ runStreamed: mockRunStreamed });
mockResumeThread.mockReturnValue({ runStreamed: mockRunStreamed });
mockRecordGovernanceTrace.mockResolvedValue({ id: 'govtrace_1' });
});
afterEach(async () => {
@ -109,4 +117,45 @@ describe('WorkflowStepExecutor Codex integration', () => {
expect(result.output).toContain('Implemented workflow Codex step.');
expect(result.outputPath).toContain('implement.md');
});
it('records governance traces for workflow gate decisions', async () => {
const executor = new WorkflowStepExecutor(tmpDir);
const step: WorkflowStep = {
id: 'approval-gate',
name: 'Approval Gate',
type: 'gate',
condition: '{{review.decision == "approved"}}',
on_false: {
escalate_to: 'human',
escalate_message: 'Review approval is required',
},
};
const run: WorkflowRun = {
id: 'run_1234567890_gate',
workflowId: 'wf-gates',
workflowVersion: 1,
taskId: 'task_1',
status: 'running',
context: {
review: { decision: 'pending' },
},
startedAt: new Date().toISOString(),
steps: [{ stepId: 'approval-gate', status: 'running', retries: 0 }],
};
await expect(executor.executeStep(step, run)).rejects.toThrow('Review approval is required');
expect(mockRecordGovernanceTrace).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'workflow-gate',
outcome: 'approval-required',
subject: expect.objectContaining({
workflowId: 'wf-gates',
runId: 'run_1234567890_gate',
taskId: 'task_1',
stepId: 'approval-gate',
actionType: 'workflow.gate',
}),
})
);
});
});

View file

@ -14,6 +14,7 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { getAgentPermissionService } from '../services/agent-permission-service.js';
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError } from '../middleware/error-handler.js';
import { authorize } from '../middleware/auth.js';
@ -109,8 +110,9 @@ router.post(
});
const { agentId, action } = schema.parse(req.body);
const service = getAgentPermissionService();
const result = await service.checkPermission(agentId, action);
res.json(result);
const result = await service.checkPermissionWithTrace(agentId, action);
const trace = await getGovernanceTraceService().record(result.trace);
res.json({ ...result.result, traceId: trace.id });
})
);

View file

@ -9,6 +9,7 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { getAgentRoutingService } from '../services/agent-routing-service.js';
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
import { getTaskService } from '../services/task-service.js';
import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
@ -79,15 +80,16 @@ router.post(
if (!task) {
throw new NotFoundError('Task not found');
}
const result = await routing.resolveAgent(task);
return res.json(result);
const result = await routing.resolveAgentWithTrace(task, { taskId: taskIdParse.data.taskId });
const trace = await getGovernanceTraceService().record(result.trace);
return res.json({ ...result.result, traceId: trace.id });
}
// Fall back to metadata
const metaParse = routeByMetadataSchema.safeParse(req.body);
if (metaParse.success) {
const { type, priority, project, subtaskCount } = metaParse.data;
const result = await routing.resolveAgent({
const result = await routing.resolveAgentWithTrace({
type: type || 'feature',
priority: priority || 'medium',
project,
@ -100,7 +102,8 @@ router.post(
}))
: undefined,
});
return res.json(result);
const trace = await getGovernanceTraceService().record(result.trace);
return res.json({ ...result.result, traceId: trace.id });
}
throw new ValidationError('Provide either { taskId } or { type, priority, ... }');

View file

@ -0,0 +1,51 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { asyncHandler } from '../middleware/async-handler.js';
import { validate, type ValidatedRequest } from '../middleware/validate.js';
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
const router: RouterType = Router();
const traceListQuerySchema = z.object({
kind: z
.enum(['policy', 'tool-policy', 'agent-permission', 'routing', 'workflow-gate'])
.optional(),
outcome: z
.enum(['allowed', 'warned', 'blocked', 'approval-required', 'routed', 'fallback', 'skipped'])
.optional(),
agent: z.string().optional(),
taskId: z.string().optional(),
actionType: z.string().optional(),
startTime: z.string().optional(),
endTime: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
const traceIdParamsSchema = z.object({
id: z.string().min(1),
});
type TraceListQuery = z.infer<typeof traceListQuerySchema>;
type TraceIdParams = z.infer<typeof traceIdParamsSchema>;
router.get(
'/',
validate({ query: traceListQuerySchema }),
asyncHandler(async (req: ValidatedRequest<unknown, TraceListQuery>, res) => {
const query = req.validated.query as TraceListQuery | undefined;
const traces = await getGovernanceTraceService().list(query);
res.json(traces);
})
);
router.get(
'/:id',
validate({ params: traceIdParamsSchema }),
asyncHandler(async (req: ValidatedRequest<TraceIdParams>, res) => {
const params = req.validated.params as TraceIdParams;
const trace = await getGovernanceTraceService().get(params.id);
res.json(trace);
})
);
export { router as governanceTraceRoutes };

View file

@ -2,6 +2,7 @@ import { Router } from 'express';
import type { AgentPolicy, PolicyEvaluationRequest } from '@veritas-kanban/shared';
import { asyncHandler } from '../middleware/async-handler.js';
import { getPolicyService } from '../services/policy-service.js';
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
import {
policyEvaluationSchema,
policyParamsSchema,
@ -57,10 +58,11 @@ router.post(
'/evaluate',
validate({ body: policyEvaluationSchema }),
asyncHandler(async (req: ValidatedRequest<unknown, unknown, PolicyEvaluationRequest>, res) => {
const result = await policyService.evaluatePolicies(
const evaluation = await policyService.evaluatePoliciesWithTrace(
req.validated.body as PolicyEvaluationRequest
);
res.json(result);
const trace = await getGovernanceTraceService().record(evaluation.trace);
res.json({ ...evaluation.result, traceId: trace.id });
})
);

View file

@ -11,6 +11,7 @@
import express from 'express';
import { z } from 'zod';
import { getToolPolicyService } from '../services/tool-policy-service.js';
import { getGovernanceTraceService } from '../services/governance-trace-service.js';
import { createLogger } from '../lib/logger.js';
import { authorize } from '../middleware/auth.js';
@ -143,8 +144,9 @@ router.post(
asyncHandler(async (req, res) => {
const { role } = RoleParamSchema.parse(req.params);
const { tool } = z.object({ tool: z.string().min(1) }).parse(req.body);
const allowed = await toolPolicyService.validateToolAccess(role, tool);
res.json({ role, tool, allowed });
const result = await toolPolicyService.validateToolAccessWithTrace(role, tool);
const trace = await getGovernanceTraceService().record(result.trace);
res.json({ role, tool, allowed: result.allowed, traceId: trace.id });
})
);

View file

@ -113,6 +113,7 @@ import { integrationsRoutes } from '../integrations.js';
import { systemHealthRouter } from '../system-health.js';
import { transcriptRoutes } from '../transcripts.js';
import { decisionRoutes } from '../decisions.js';
import { governanceTraceRoutes } from '../governance-traces.js';
import { scoringRoutes } from '../scoring.js';
import { feedbackRoutes } from '../feedback.js';
import promptRegistryRoutes from '../prompt-registry.js';
@ -217,6 +218,7 @@ v1Router.use('/transcripts', transcriptAccess, transcriptRoutes);
v1Router.use('/scoring', scoringAccess, scoringRoutes);
v1Router.use('/system/health', workspaceAccess, systemHealthRouter);
v1Router.use('/decisions', taskAccess, decisionRoutes);
v1Router.use('/governance/traces', policyAccess, governanceTraceRoutes);
v1Router.use('/feedback', feedbackAccess, feedbackRoutes);
v1Router.use('/prompt-registry', promptRegistryAccess, promptRegistryRoutes);
v1Router.use('/sqlite', backupAccess, sqlitePortabilityRoutes);

View file

@ -13,6 +13,7 @@
import { createLogger } from '../lib/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { CreateGovernanceTraceInput } from '@veritas-kanban/shared';
import { getRuntimeDir } from '../utils/paths.js';
import { migrateLegacyFiles } from '../utils/migrate-legacy-files.js';
const DATA_DIR = getRuntimeDir();
@ -230,47 +231,83 @@ class AgentPermissionService {
allowed: boolean;
reason?: string;
requiresApproval?: boolean;
}> {
return (await this.checkPermissionWithTrace(agentId, action)).result;
}
async checkPermissionWithTrace(
agentId: string,
action: string
): Promise<{
result: {
allowed: boolean;
reason?: string;
requiresApproval?: boolean;
};
trace: CreateGovernanceTraceInput;
}> {
const config = await this.getPermissions(agentId);
let result: {
allowed: boolean;
reason?: string;
requiresApproval?: boolean;
};
switch (action) {
case 'create_task':
return config.canCreateTasks
result = config.canCreateTasks
? { allowed: true }
: { allowed: false, reason: 'Intern agents cannot create tasks', requiresApproval: true };
break;
case 'delegate':
return config.canDelegate
result = config.canDelegate
? { allowed: true }
: { allowed: false, reason: 'Only lead agents can delegate', requiresApproval: true };
break;
case 'approve':
return config.canApprove
result = config.canApprove
? { allowed: true }
: { allowed: false, reason: 'Only lead agents can approve work' };
break;
case 'complete_task':
if (config.autoComplete) {
return { allowed: true };
}
return {
result = { allowed: true };
} else {
result = {
allowed: true,
reason: 'Task will go to review instead of done',
requiresApproval: false,
};
}
break;
case 'delete_task':
return config.level === 'lead'
result =
config.level === 'lead'
? { allowed: true }
: { allowed: false, reason: 'Only lead agents can delete tasks', requiresApproval: true };
: {
allowed: false,
reason: 'Only lead agents can delete tasks',
requiresApproval: true,
};
break;
default:
// Check custom restrictions
if (config.restrictions?.some((r) => action.includes(r))) {
return { allowed: false, reason: `Action restricted for ${config.level} agents` };
result = { allowed: false, reason: `Action restricted for ${config.level} agents` };
} else {
result = { allowed: true };
}
return { allowed: true };
}
return {
result,
trace: this.buildPermissionTrace(config, action, result),
};
}
/**
@ -340,6 +377,78 @@ class AgentPermissionService {
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
private buildPermissionTrace(
config: AgentPermissionConfig,
action: string,
result: { allowed: boolean; reason?: string; requiresApproval?: boolean }
): CreateGovernanceTraceInput {
const outcome: CreateGovernanceTraceInput['outcome'] = result.allowed
? result.requiresApproval
? 'approval-required'
: 'allowed'
: result.requiresApproval
? 'approval-required'
: 'blocked';
return {
kind: 'agent-permission',
outcome,
title: `Agent permission: ${config.agentId} -> ${action}`,
summary: result.allowed
? result.reason || `${config.agentId} can perform ${action}.`
: result.reason || `${config.agentId} cannot perform ${action}.`,
remediation: result.allowed
? undefined
: result.requiresApproval
? 'Request approval from a lead agent or promote the agent permission level.'
: 'Change the agent level, remove a restriction, or delegate the action to an authorized agent.',
subject: { agentId: config.agentId, actionType: action },
evaluatedRules: [
{
id: `agent-permission:${config.level}`,
label: `${config.level} permissions`,
type: 'agent-permission',
status: 'matched',
outcome,
message: result.reason || `${config.level} permissions evaluated for ${action}.`,
details: {
level: config.level,
canCreateTasks: config.canCreateTasks,
canDelegate: config.canDelegate,
canApprove: config.canApprove,
autoComplete: config.autoComplete,
restrictions: config.restrictions ?? [],
},
},
],
matchedRules: [
{
id: `agent-permission:${config.level}`,
label: `${config.level} permissions`,
type: 'agent-permission',
status: 'matched',
outcome,
message: result.reason || `${config.level} permissions evaluated for ${action}.`,
},
],
steps: [
{
id: 'level',
label: 'Permission level',
status: 'info',
message: `${config.agentId} is configured as ${config.level}.`,
},
{
id: 'outcome',
label: 'Outcome',
status: result.allowed ? 'matched' : 'not-matched',
message: result.reason || (result.allowed ? 'Action allowed.' : 'Action blocked.'),
},
],
raw: { config, action, result },
};
}
}
// Singleton

View file

@ -11,6 +11,9 @@
import { ConfigService } from './config-service.js';
import {
DEFAULT_ROUTING_CONFIG,
type CreateGovernanceTraceInput,
type GovernanceTraceRule,
type GovernanceTraceStep,
type AgentRoutingConfig,
type RoutingRule,
type RoutingResult,
@ -21,6 +24,12 @@ import { createLogger } from '../lib/logger.js';
const log = createLogger('agent-routing');
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
interface RoutingTraceContext {
taskId?: string;
}
export class AgentRoutingService {
private configService: ConfigService;
@ -34,24 +43,51 @@ export class AgentRoutingService {
* @param task - Full task object (or partial with type/priority/project/subtasks)
* @returns RoutingResult with the selected agent, optional model, fallback, and reasoning
*/
async resolveAgent(
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>
): Promise<RoutingResult> {
async resolveAgent(task: RoutableTask): Promise<RoutingResult> {
return (await this.resolveAgentWithTrace(task)).result;
}
async resolveAgentWithTrace(
task: RoutableTask,
context: RoutingTraceContext = {}
): Promise<{ result: RoutingResult; trace: CreateGovernanceTraceInput }> {
const config = await this.configService.getConfig();
const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG;
const evaluatedRules: GovernanceTraceRule[] = [];
const steps: GovernanceTraceStep[] = [];
// If routing is disabled, return the global default
if (!routing.enabled) {
return {
const result: RoutingResult = {
agent: routing.defaultAgent || config.defaultAgent,
model: routing.defaultModel,
reason: 'Routing disabled — using default agent',
reason: 'Routing disabled, using default agent',
};
return {
result,
trace: this.buildRoutingTrace(task, context, result, {
outcome: 'skipped',
evaluatedRules,
matchedRules: [],
steps: [
{
id: 'routing-disabled',
label: 'Routing disabled',
status: 'skipped',
message: 'Agent routing is disabled in configuration.',
},
],
routing,
}),
};
}
// Evaluate rules in order (first match wins)
for (const rule of routing.rules) {
if (!rule.enabled) continue;
if (!rule.enabled) {
evaluatedRules.push(this.routingRuleTrace(rule, 'skipped', 'Rule is disabled.'));
continue;
}
if (this.matchesRule(task, rule.match)) {
// Verify the agent is actually configured and enabled
@ -60,31 +96,87 @@ export class AgentRoutingService {
a.type === rule.agent
);
if (!agentConfig?.enabled) {
log.warn(`Rule "${rule.name}" matched but agent "${rule.agent}" is disabled — skipping`);
const message = `Rule "${rule.name}" matched but agent "${rule.agent}" is disabled, skipping.`;
log.warn(message);
const skippedRule = this.routingRuleTrace(rule, 'matched', message, 'skipped');
evaluatedRules.push(skippedRule);
steps.push({
id: `rule:${rule.id}`,
label: rule.name,
status: 'skipped',
message,
});
continue;
}
log.info(
`Task [type=${task.type}, priority=${task.priority}] matched rule "${rule.name}" → ${rule.agent}${rule.model ? ` (${rule.model})` : ''}`
);
return {
const matchedRule = this.routingRuleTrace(
rule,
'matched',
`Matched rule: ${rule.name}`,
'routed'
);
evaluatedRules.push(matchedRule);
const result: RoutingResult = {
agent: rule.agent,
model: rule.model,
fallback: rule.fallback,
rule: rule.id,
reason: `Matched rule: ${rule.name}`,
};
return {
result,
trace: this.buildRoutingTrace(task, context, result, {
outcome: 'routed',
evaluatedRules,
matchedRules: [matchedRule],
steps: [
...steps,
{
id: `rule:${rule.id}`,
label: rule.name,
status: 'matched',
message: `Selected ${rule.agent}.`,
},
],
routing,
}),
};
}
evaluatedRules.push(
this.routingRuleTrace(rule, 'not-matched', 'Rule criteria did not match.')
);
}
// No rule matched — use defaults
log.info(
`Task [type=${task.type}, priority=${task.priority}] — no rules matched, using default: ${routing.defaultAgent}`
);
return {
const result: RoutingResult = {
agent: routing.defaultAgent || config.defaultAgent,
model: routing.defaultModel,
reason: 'No routing rules matched — using default agent',
reason: 'No routing rules matched, using default agent',
};
return {
result,
trace: this.buildRoutingTrace(task, context, result, {
outcome: 'fallback',
evaluatedRules,
matchedRules: [],
steps: [
...steps,
{
id: 'default-agent',
label: 'Default agent',
status: 'info',
message: `Selected default agent ${result.agent}.`,
},
],
routing,
}),
};
}
@ -222,6 +314,65 @@ export class AgentRoutingService {
}
return actual === expected;
}
private routingRuleTrace(
rule: RoutingRule,
status: GovernanceTraceRule['status'],
message: string,
outcome?: CreateGovernanceTraceInput['outcome']
): GovernanceTraceRule {
return {
id: `routing:${rule.id}`,
label: rule.name,
type: 'routing',
status,
outcome,
message,
details: {
match: rule.match,
agent: rule.agent,
model: rule.model,
fallback: rule.fallback,
enabled: rule.enabled,
},
};
}
private buildRoutingTrace(
task: RoutableTask,
context: RoutingTraceContext,
result: RoutingResult,
input: {
outcome: CreateGovernanceTraceInput['outcome'];
evaluatedRules: GovernanceTraceRule[];
matchedRules: GovernanceTraceRule[];
steps: GovernanceTraceStep[];
routing: AgentRoutingConfig;
}
): CreateGovernanceTraceInput {
return {
kind: 'routing',
outcome: input.outcome,
title: `Agent routing: ${result.agent}`,
summary: result.reason,
remediation:
input.outcome === 'fallback'
? 'Add or reorder routing rules if the default agent should not handle this task.'
: input.outcome === 'skipped'
? 'Enable agent routing to evaluate task-aware routing rules.'
: undefined,
subject: {
agentId: result.agent,
taskId: context.taskId,
actionType: 'agent.route',
project: task.project,
},
evaluatedRules: input.evaluatedRules,
matchedRules: input.matchedRules,
steps: input.steps,
raw: { task, routing: input.routing, result },
};
}
}
// Singleton

View file

@ -273,6 +273,7 @@ export const DATA_LIFECYCLE_POLICIES: readonly DataLifecyclePolicy[] = [
'activity_events',
'status_history',
'decision_records',
'governance_decision_traces',
'feedback_records',
'scoring_profiles',
'scoring_evaluations',

View file

@ -0,0 +1,161 @@
import path from 'path';
import { nanoid } from 'nanoid';
import type {
CreateGovernanceTraceInput,
GovernanceTraceListFilters,
GovernanceTraceRecord,
} from '@veritas-kanban/shared';
import { mkdir, readFile, readdir, writeFile } from '../storage/fs-helpers.js';
import { NotFoundError } from '../middleware/error-handler.js';
import { redactString } from '../lib/redact.js';
import { getRuntimeDir } from '../utils/paths.js';
import { ensureWithinBase, validatePathSegment } from '../utils/sanitize.js';
import { SqliteDatabase, type SqliteConnectionOptions } from '../storage/sqlite/database.js';
import { SqliteGovernanceTraceRepository } from '../storage/sqlite/governance-repositories.js';
export interface GovernanceTraceServiceOptions {
tracesDir?: string;
storageType?: 'file' | 'sqlite';
sqliteDatabase?: SqliteDatabase;
sqliteConnectionOptions?: SqliteConnectionOptions;
}
const MAX_LIST_LIMIT = 500;
export class GovernanceTraceService {
private readonly tracesDir: string;
private readonly repository: SqliteGovernanceTraceRepository | null = null;
private readonly sqliteDatabase: SqliteDatabase | null = null;
private readonly ownsSqliteDatabase: boolean = false;
constructor(options: GovernanceTraceServiceOptions = {}) {
this.tracesDir = options.tracesDir ?? path.join(getRuntimeDir(), 'governance-traces');
const storageType =
options.storageType ?? (process.env.VERITAS_STORAGE === 'sqlite' ? 'sqlite' : 'file');
if (storageType === 'sqlite') {
this.sqliteDatabase =
options.sqliteDatabase ?? new SqliteDatabase(options.sqliteConnectionOptions);
this.ownsSqliteDatabase = !options.sqliteDatabase;
this.sqliteDatabase.open();
this.repository = new SqliteGovernanceTraceRepository(this.sqliteDatabase);
}
}
async record(input: CreateGovernanceTraceInput): Promise<GovernanceTraceRecord> {
const createdAt = input.createdAt ?? new Date().toISOString();
const trace: GovernanceTraceRecord = this.redactValue({
id: `govtrace_${Date.now()}_${nanoid(6)}`,
kind: input.kind,
outcome: input.outcome,
title: input.title,
summary: input.summary,
remediation: input.remediation,
subject: input.subject ?? {},
evaluatedRules: input.evaluatedRules ?? [],
matchedRules: input.matchedRules ?? [],
steps: input.steps ?? [],
raw: input.raw,
redacted: true,
createdAt,
}) as GovernanceTraceRecord;
if (this.repository) {
this.repository.save(trace);
return trace;
}
await mkdir(this.tracesDir, { recursive: true });
await writeFile(this.getTracePath(trace.id), JSON.stringify(trace, null, 2), 'utf8');
return trace;
}
async get(id: string): Promise<GovernanceTraceRecord> {
if (this.repository) {
const trace = this.repository.get(id);
if (!trace) throw new NotFoundError('Governance trace not found');
return trace;
}
const tracePath = this.getTracePath(id);
const trace = JSON.parse(await readFile(tracePath, 'utf8')) as GovernanceTraceRecord;
return trace;
}
async list(filters: GovernanceTraceListFilters = {}): Promise<GovernanceTraceRecord[]> {
const normalized = {
...filters,
limit: Math.min(Math.max(filters.limit ?? 100, 1), MAX_LIST_LIMIT),
};
if (this.repository) {
return this.repository.list(normalized);
}
await mkdir(this.tracesDir, { recursive: true });
const files = await readdir(this.tracesDir);
const traces = await Promise.all(
files
.filter((file) => file.endsWith('.json'))
.map(async (file) => JSON.parse(await readFile(path.join(this.tracesDir, file), 'utf8')))
);
return (traces as GovernanceTraceRecord[])
.filter((trace) => {
const created = Date.parse(trace.createdAt);
const start = normalized.startTime ? Date.parse(normalized.startTime) : undefined;
const end = normalized.endTime ? Date.parse(normalized.endTime) : undefined;
if (normalized.kind && trace.kind !== normalized.kind) return false;
if (normalized.outcome && trace.outcome !== normalized.outcome) return false;
if (normalized.agent && trace.subject.agentId !== normalized.agent) return false;
if (normalized.taskId && trace.subject.taskId !== normalized.taskId) return false;
if (normalized.actionType && trace.subject.actionType !== normalized.actionType) {
return false;
}
if (start !== undefined && created < start) return false;
if (end !== undefined && created > end) return false;
return true;
})
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.id.localeCompare(a.id))
.slice(0, normalized.limit);
}
dispose(): void {
if (this.ownsSqliteDatabase) {
this.sqliteDatabase?.close();
}
}
private getTracePath(id: string): string {
validatePathSegment(id);
return ensureWithinBase(this.tracesDir, path.join(this.tracesDir, `${id}.json`));
}
private redactValue(value: unknown): unknown {
if (typeof value === 'string') return this.redactText(value);
if (Array.isArray(value)) return value.map((entry) => this.redactValue(entry));
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key,
this.redactValue(entry),
])
);
}
return value;
}
private redactText(value: string): string {
return redactString(value)
.replace(/\/Users\/[^/\s]+\/[^\s)]+/g, '[redacted-local-path]')
.replace(/[A-Z]:\\Users\\[^\\\s]+\\[^\s)]+/g, '[redacted-local-path]');
}
}
let singleton: GovernanceTraceService | null = null;
export function getGovernanceTraceService(): GovernanceTraceService {
singleton ??= new GovernanceTraceService();
return singleton;
}

View file

@ -1,6 +1,8 @@
import path from 'path';
import type {
AgentPolicy,
CreateGovernanceTraceInput,
GovernanceTraceRule,
PolicyEvaluationMatch,
PolicyEvaluationRequest,
PolicyEvaluationResult,
@ -234,26 +236,82 @@ export class PolicyService {
}
async evaluatePolicies(input: PolicyEvaluationRequest): Promise<PolicyEvaluationResult> {
return (await this.evaluatePoliciesWithTrace(input)).result;
}
async evaluatePoliciesWithTrace(input: PolicyEvaluationRequest): Promise<{
result: PolicyEvaluationResult;
trace: CreateGovernanceTraceInput;
}> {
await this.waitForInit();
const policies = await this.listPolicies();
const matches: PolicyEvaluationMatch[] = [];
const evaluatedRules: GovernanceTraceRule[] = [];
let decision: PolicyEvaluationResult['decision'] = 'allow';
for (const policy of policies) {
if (!policy.enabled) continue;
if (!this.scopeMatches(policy, input)) continue;
if (!policy.enabled) {
evaluatedRules.push({
id: policy.id,
label: policy.name,
type: policy.type,
status: 'skipped',
message: `${policy.name} is disabled.`,
});
continue;
}
if (!this.scopeMatches(policy, input)) {
evaluatedRules.push({
id: policy.id,
label: policy.name,
type: policy.type,
status: 'not-matched',
message: `${policy.name} did not match the actor, project, or action scope.`,
details: {
scope: policy.scope,
agent: input.agent,
project: input.project,
actionType: input.actionType,
},
});
continue;
}
const evaluation = await this.evaluatePolicy(policy, input);
if (!evaluation) continue;
if (!evaluation) {
evaluatedRules.push({
id: policy.id,
label: policy.name,
type: policy.type,
status: 'not-matched',
message: `${policy.name} was in scope but its condition did not trigger.`,
details: {
policyType: policy.type,
actionType: input.actionType,
riskScore: input.riskScore,
},
});
continue;
}
matches.push(evaluation);
evaluatedRules.push({
id: policy.id,
label: policy.name,
type: policy.type,
status: 'matched',
outcome: this.traceOutcomeForDecision(this.toDecision(evaluation.responseAction)),
message: evaluation.message,
details: evaluation.details,
});
if (RESPONSE_PRIORITY[evaluation.responseAction] > DECISION_PRIORITY[decision]) {
decision = this.toDecision(evaluation.responseAction);
}
}
return {
const result: PolicyEvaluationResult = {
decision,
matches,
warnings: matches
@ -266,6 +324,44 @@ export class PolicyService {
.filter((match) => match.responseAction === 'require-approval')
.map((match) => match.policyId),
};
const outcome = this.traceOutcomeForDecision(decision);
return {
result,
trace: {
kind: 'policy',
outcome,
title: `Policy evaluation: ${input.actionType}`,
summary: this.policyTraceSummary(result),
remediation: this.policyTraceRemediation(result),
subject: {
agentId: input.agent,
actorId: input.agent,
project: input.project,
actionType: input.actionType,
},
evaluatedRules,
matchedRules: evaluatedRules.filter((rule) => rule.status === 'matched'),
steps: [
{
id: 'scope',
label: 'Scope evaluation',
status: evaluatedRules.some((rule) => rule.status === 'matched') ? 'matched' : 'info',
message: `${evaluatedRules.length} policy rule(s) evaluated for ${input.actionType}.`,
},
{
id: 'outcome',
label: 'Outcome',
status: matches.length > 0 ? 'matched' : 'info',
message: this.policyTraceSummary(result),
},
],
raw: {
input,
result,
},
},
};
}
private async loadPoliciesFromDisk(): Promise<void> {
@ -553,6 +649,35 @@ export class PolicyService {
return 'allow';
}
private traceOutcomeForDecision(
decision: PolicyEvaluationResult['decision']
): CreateGovernanceTraceInput['outcome'] {
if (decision === 'block') return 'blocked';
if (decision === 'require-approval') return 'approval-required';
if (decision === 'warn') return 'warned';
return 'allowed';
}
private policyTraceSummary(result: PolicyEvaluationResult): string {
if (result.decision === 'allow') return 'No enabled policy blocked or warned on this action.';
if (result.decision === 'warn') return result.warnings.join(' ') || 'Policy warning matched.';
if (result.decision === 'require-approval') {
return `Approval is required by ${result.approvalRequiredBy.join(', ')}.`;
}
return `Action is blocked by ${result.blockedBy.join(', ')}.`;
}
private policyTraceRemediation(result: PolicyEvaluationResult): string | undefined {
if (result.decision === 'allow') return undefined;
if (result.decision === 'warn') {
return 'Review the matched warning before continuing or adjust the policy scope.';
}
if (result.decision === 'require-approval') {
return 'Request approval from an authorized reviewer or change the matched policy.';
}
return 'Change the action, lower the risk, or update the matched blocking policy.';
}
dispose(): void {
if (this.ownsSqliteDatabase) {
this.sqliteDatabase?.close();

View file

@ -62,6 +62,7 @@ const SQLITE_BACKUP_TABLES = [
'status_history',
'telemetry_events',
'decision_records',
'governance_decision_traces',
'feedback_records',
'scoring_profiles',
'scoring_evaluations',

View file

@ -8,6 +8,7 @@
import fs from 'fs/promises';
import path from 'path';
import type { CreateGovernanceTraceInput } from '@veritas-kanban/shared';
import type { ToolPolicy } from '../types/workflow.js';
import { ValidationError } from '../types/workflow.js';
import { getToolPoliciesDir } from '../utils/paths.js';
@ -365,7 +366,15 @@ export class ToolPolicyService {
* 3. Monitor logs for "No policy found" warnings
*/
async validateToolAccess(role: string, tool: string): Promise<boolean> {
return (await this.validateToolAccessWithTrace(role, tool)).allowed;
}
async validateToolAccessWithTrace(
role: string,
tool: string
): Promise<{ allowed: boolean; trace: CreateGovernanceTraceInput }> {
const policy = await this.getToolPolicy(role);
const normalizedRole = role.trim().toLowerCase();
if (!policy) {
// No policy defined for this role - allow all tools (fail-open pattern)
@ -374,19 +383,56 @@ export class ToolPolicyService {
{ role, tool },
'No policy found for role - allowing all tools (fail-open). Define a policy for this role to enforce restrictions.'
);
return true;
return {
allowed: true,
trace: {
kind: 'tool-policy',
outcome: 'allowed',
title: `Tool policy: ${normalizedRole} -> ${tool}`,
summary: `No tool policy exists for ${normalizedRole}; access allowed by compatibility fallback.`,
remediation:
'Define a tool policy for this role to enforce explicit allow and deny lists.',
subject: { role: normalizedRole, tool, actionType: 'tool.validate' },
evaluatedRules: [
{
id: `tool-policy:${normalizedRole}`,
label: normalizedRole,
type: 'tool-policy',
status: 'skipped',
outcome: 'allowed',
message: 'No policy found for this role.',
},
],
matchedRules: [],
steps: [
{
id: 'fallback',
label: 'Compatibility fallback',
status: 'info',
message: 'Missing role policy allows the tool for backward compatibility.',
},
],
raw: { role: normalizedRole, tool, policy: null, allowed: true },
},
};
}
// Denied list takes precedence over allowed list
if (policy.denied.includes(tool)) {
log.debug({ role, tool }, 'Tool access denied by policy');
return false;
return {
allowed: false,
trace: this.buildToolTrace(policy, tool, false, 'Tool matched the denied list.'),
};
}
// Check allowed list
// '*' means all tools allowed
if (policy.allowed.includes('*')) {
return true;
return {
allowed: true,
trace: this.buildToolTrace(policy, tool, true, 'Role policy allows all tools.'),
};
}
// Explicit allow
@ -394,7 +440,15 @@ export class ToolPolicyService {
if (!allowed) {
log.debug({ role, tool, allowedTools: policy.allowed }, 'Tool not in allowed list');
}
return allowed;
return {
allowed,
trace: this.buildToolTrace(
policy,
tool,
allowed,
allowed ? 'Tool matched the allowed list.' : 'Tool is not present in the allowed list.'
),
};
}
/**
@ -476,6 +530,75 @@ export class ToolPolicyService {
}
}
private buildToolTrace(
policy: ToolPolicy,
tool: string,
allowed: boolean,
reason: string
): CreateGovernanceTraceInput {
const role = policy.role.trim().toLowerCase();
const denied = policy.denied.includes(tool);
const outcome: CreateGovernanceTraceInput['outcome'] = allowed ? 'allowed' : 'blocked';
const matchedRules =
allowed || denied
? [
{
id: `tool-policy:${role}`,
label: role,
type: 'tool-policy',
status: 'matched' as const,
outcome,
message: reason,
},
]
: [];
return {
kind: 'tool-policy',
outcome,
title: `Tool policy: ${role} -> ${tool}`,
summary: allowed
? `${tool} is allowed for role ${role}.`
: `${tool} is denied for role ${role}.`,
remediation: allowed
? undefined
: 'Choose an allowed tool, change the agent role, or update the role tool policy.',
subject: { role, tool, actionType: 'tool.validate' },
evaluatedRules: [
{
id: `tool-policy:${role}`,
label: role,
type: 'tool-policy',
status: allowed || denied ? 'matched' : 'not-matched',
outcome,
message: reason,
details: {
allowed: policy.allowed,
denied: policy.denied,
},
},
],
matchedRules,
steps: [
{
id: 'deny-precedence',
label: 'Deny precedence',
status: denied ? 'matched' : 'not-matched',
message: denied
? 'Denied tools take precedence over allowed tools.'
: 'Tool did not match the denied list.',
},
{
id: 'allow-list',
label: 'Allow list',
status: allowed ? 'matched' : 'not-matched',
message: reason,
},
],
raw: { role, tool, policy, allowed },
};
}
/**
* Clear the cache and reload defaults (useful for tests)
*/

View file

@ -16,6 +16,7 @@ import type {
} from '../types/workflow.js';
import { getWorkflowRunsDir } from '../utils/paths.js';
import { createLogger } from '../lib/logger.js';
import { getGovernanceTraceService } from './governance-trace-service.js';
import { getToolPolicyService } from './tool-policy-service.js';
const log = createLogger('workflow-step-executor');
@ -693,6 +694,7 @@ export class WorkflowStepExecutor {
if (!conditionResult) {
// Condition not met — handle on_false policy
const policy = step.on_false;
await this.recordWorkflowGateTrace(step, run, false, policy);
if (policy?.escalate_to === 'human') {
// Block the workflow (will be handled by workflow-run-service)
@ -703,6 +705,7 @@ export class WorkflowStepExecutor {
}
// Gate passed
await this.recordWorkflowGateTrace(step, run, true, step.on_false);
const output = `Gate ${step.id} passed: ${step.condition}`;
const outputPath = await this.saveStepOutput(run.id, step.id, output);
@ -911,6 +914,98 @@ export class WorkflowStepExecutor {
return this.getNestedValue(context, cleaned);
}
private async recordWorkflowGateTrace(
step: WorkflowStep,
run: WorkflowRun,
conditionResult: boolean,
policy?: WorkflowStep['on_false']
): Promise<void> {
const outcome = conditionResult
? 'allowed'
: policy?.escalate_to === 'human'
? 'approval-required'
: 'blocked';
const message = conditionResult
? `Gate condition passed: ${step.condition}`
: `Gate condition failed: ${step.condition}`;
await getGovernanceTraceService().record({
kind: 'workflow-gate',
outcome,
title: `Workflow gate: ${step.name || step.id}`,
summary: conditionResult
? `Workflow run ${run.id} can continue past gate ${step.id}.`
: policy?.escalate_message || `Workflow run ${run.id} is blocked at gate ${step.id}.`,
remediation: conditionResult
? undefined
: policy?.escalate_to === 'human'
? 'Review the gate output and resume the workflow after human approval.'
: 'Update upstream step output or revise the gate condition before retrying the workflow.',
subject: {
workflowId: run.workflowId,
runId: run.id,
taskId: run.taskId,
stepId: step.id,
actionType: 'workflow.gate',
},
evaluatedRules: [
{
id: `workflow-gate:${step.id}`,
label: step.name || step.id,
type: 'workflow-gate',
status: conditionResult ? 'matched' : 'not-matched',
outcome,
message,
details: {
condition: step.condition,
onFalse: policy,
},
},
],
matchedRules: conditionResult
? [
{
id: `workflow-gate:${step.id}`,
label: step.name || step.id,
type: 'workflow-gate',
status: 'matched',
outcome,
message,
},
]
: [],
steps: [
{
id: 'condition',
label: 'Condition',
status: conditionResult ? 'matched' : 'not-matched',
message,
details: { expression: step.condition },
},
{
id: 'on-false',
label: 'On false policy',
status: policy ? 'info' : 'skipped',
message: policy
? `Gate failure policy escalates to ${policy.escalate_to}.`
: 'No gate failure policy configured.',
},
],
raw: {
step,
run: {
id: run.id,
workflowId: run.workflowId,
workflowVersion: run.workflowVersion,
taskId: run.taskId,
status: run.status,
currentStep: run.currentStep,
},
result: conditionResult,
},
});
}
/**
* Cleanup OpenClaw session (Phase 2 tracked in #110)
*/

View file

@ -10,6 +10,8 @@ import type {
EvaluationResult,
Feedback,
FeedbackQuery,
GovernanceTraceListFilters,
GovernanceTraceRecord,
ScoringProfile,
} from '@veritas-kanban/shared';
import type { SqliteDatabase } from './database.js';
@ -18,6 +20,10 @@ interface DecisionRow {
decision_json: string;
}
interface GovernanceTraceRow {
trace_json: string;
}
interface FeedbackRow {
feedback_json: string;
}
@ -145,6 +151,115 @@ export class SqliteDecisionRepository {
}
}
export class SqliteGovernanceTraceRepository {
constructor(private readonly database: SqliteDatabase) {}
save(trace: GovernanceTraceRecord): void {
this.database
.getConnection()
.prepare(
`
INSERT INTO governance_decision_traces (
id,
workspace_id,
kind,
outcome,
agent_id,
task_id,
action_type,
trace_json,
created_at
)
VALUES (?, 'local', ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
outcome = excluded.outcome,
agent_id = excluded.agent_id,
task_id = excluded.task_id,
action_type = excluded.action_type,
trace_json = excluded.trace_json,
created_at = excluded.created_at
`
)
.run(
trace.id,
trace.kind,
trace.outcome,
trace.subject.agentId ?? trace.subject.actorId ?? trace.subject.role ?? null,
trace.subject.taskId ?? null,
trace.subject.actionType ?? null,
JSON.stringify(trace),
trace.createdAt
);
}
get(id: string): GovernanceTraceRecord | null {
const row = this.database
.getConnection()
.prepare(
`
SELECT trace_json
FROM governance_decision_traces
WHERE workspace_id = 'local'
AND id = ?
`
)
.get(id) as GovernanceTraceRow | undefined;
return row ? (JSON.parse(row.trace_json) as GovernanceTraceRecord) : null;
}
list(filters: GovernanceTraceListFilters = {}): GovernanceTraceRecord[] {
const clauses = ["workspace_id = 'local'"];
const params: SQLInputValue[] = [];
if (filters.kind) {
clauses.push('kind = ?');
params.push(filters.kind);
}
if (filters.outcome) {
clauses.push('outcome = ?');
params.push(filters.outcome);
}
if (filters.agent) {
clauses.push('agent_id = ?');
params.push(filters.agent);
}
if (filters.taskId) {
clauses.push('task_id = ?');
params.push(filters.taskId);
}
if (filters.actionType) {
clauses.push('action_type = ?');
params.push(filters.actionType);
}
if (filters.startTime) {
clauses.push('created_at >= ?');
params.push(filters.startTime);
}
if (filters.endTime) {
clauses.push('created_at <= ?');
params.push(filters.endTime);
}
const limit = Math.min(Math.max(filters.limit ?? 100, 1), 500);
const rows = this.database
.getConnection()
.prepare(
`
SELECT trace_json
FROM governance_decision_traces
WHERE ${clauses.join(' AND ')}
ORDER BY datetime(created_at) DESC, id DESC
LIMIT ?
`
)
.all(...params, limit) as unknown as GovernanceTraceRow[];
return rows.map((row) => JSON.parse(row.trace_json) as GovernanceTraceRecord);
}
}
export class SqliteFeedbackRepository {
constructor(private readonly database: SqliteDatabase) {}

View file

@ -1078,6 +1078,38 @@ export const SQLITE_BASE_MIGRATIONS: readonly SqliteMigration[] = [
ON device_sessions(workspace_id, revoked_at, expires_at);
`,
},
{
version: 17,
name: '0017_governance_decision_traces',
up: `
CREATE TABLE IF NOT EXISTS governance_decision_traces (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
outcome TEXT NOT NULL,
agent_id TEXT,
task_id TEXT,
action_type TEXT,
trace_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_governance_traces_workspace_created
ON governance_decision_traces(workspace_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_governance_traces_kind_created
ON governance_decision_traces(kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_governance_traces_outcome_created
ON governance_decision_traces(outcome, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_governance_traces_agent_created
ON governance_decision_traces(agent_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_governance_traces_task_created
ON governance_decision_traces(task_id, created_at DESC);
`,
},
];
export function sortedMigrations(migrations: readonly SqliteMigration[]): SqliteMigration[] {

View file

@ -0,0 +1,89 @@
export type GovernanceTraceKind =
| 'policy'
| 'tool-policy'
| 'agent-permission'
| 'routing'
| 'workflow-gate';
export type GovernanceTraceOutcome =
| 'allowed'
| 'warned'
| 'blocked'
| 'approval-required'
| 'routed'
| 'fallback'
| 'skipped';
export type GovernanceTraceStepStatus = 'matched' | 'not-matched' | 'skipped' | 'info';
export interface GovernanceTraceSubject {
actorId?: string;
agentId?: string;
role?: string;
taskId?: string;
workflowId?: string;
runId?: string;
stepId?: string;
actionType?: string;
tool?: string;
project?: string;
}
export interface GovernanceTraceRule {
id: string;
label: string;
type: string;
status: GovernanceTraceStepStatus;
outcome?: GovernanceTraceOutcome;
message: string;
details?: Record<string, unknown>;
}
export interface GovernanceTraceStep {
id: string;
label: string;
status: GovernanceTraceStepStatus;
message: string;
details?: Record<string, unknown>;
}
export interface GovernanceTraceRecord {
id: string;
kind: GovernanceTraceKind;
outcome: GovernanceTraceOutcome;
title: string;
summary: string;
remediation?: string;
subject: GovernanceTraceSubject;
evaluatedRules: GovernanceTraceRule[];
matchedRules: GovernanceTraceRule[];
steps: GovernanceTraceStep[];
raw?: Record<string, unknown>;
redacted: true;
createdAt: string;
}
export interface CreateGovernanceTraceInput {
kind: GovernanceTraceKind;
outcome: GovernanceTraceOutcome;
title: string;
summary: string;
remediation?: string;
subject?: GovernanceTraceSubject;
evaluatedRules?: GovernanceTraceRule[];
matchedRules?: GovernanceTraceRule[];
steps?: GovernanceTraceStep[];
raw?: Record<string, unknown>;
createdAt?: string;
}
export interface GovernanceTraceListFilters {
kind?: GovernanceTraceKind;
outcome?: GovernanceTraceOutcome;
agent?: string;
taskId?: string;
actionType?: string;
startTime?: string;
endTime?: string;
limit?: number;
}

View file

@ -24,3 +24,4 @@ export * from './feedback.types.js';
export * from './workflow.js';
export * from './work-product.types.js';
export * from './maintenance.types.js';
export * from './governance-trace.types.js';

View file

@ -98,4 +98,5 @@ export interface PolicyEvaluationResult {
warnings: string[];
blockedBy: string[];
approvalRequiredBy: string[];
traceId?: string;
}

View file

@ -262,6 +262,7 @@ const ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
},
{ prefix: '/api/system/health', read: 'workspace:read', write: 'admin:manage' },
{ prefix: '/api/decisions', read: 'task:read', write: 'task:write' },
{ prefix: '/api/governance/traces', read: 'policy:read' },
{ prefix: '/api/feedback', read: 'report:read', write: 'comment:write' },
{
prefix: '/api/prompt-registry',

View file

@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => ({
updateScoringProfile: vi.fn(),
useDecision: vi.fn(),
useDecisions: vi.fn(),
useGovernanceTrace: vi.fn(),
useGovernanceTraces: vi.fn(),
useDriftAlerts: vi.fn(),
useDriftBaselines: vi.fn(),
useFeedbackAnalytics: vi.fn(),
@ -120,6 +122,11 @@ vi.mock('@/hooks/useDecisions', () => ({
}),
}));
vi.mock('@/hooks/useGovernanceTraces', () => ({
useGovernanceTrace: mocks.useGovernanceTrace,
useGovernanceTraces: mocks.useGovernanceTraces,
}));
vi.mock('@/hooks/useFeedback', () => ({
useCreateFeedback: () => ({ mutateAsync: mocks.createFeedback, isPending: false }),
useDeleteFeedback: () => ({ mutateAsync: mocks.deleteFeedback, isPending: false }),
@ -296,6 +303,102 @@ describe('governance surfaces Mantine migration', () => {
},
isLoading: false,
});
mocks.useGovernanceTraces.mockReturnValue({
data: [
{
id: 'govtrace-1',
kind: 'policy',
outcome: 'blocked',
title: 'Production policy block',
summary: 'Production deploy requires approval.',
remediation: 'Request approval from a lead agent.',
subject: {
agentId: 'codex',
taskId: 'task-1',
actionType: 'git.push',
},
evaluatedRules: [
{
id: 'policy:risk-prod',
label: 'Production risk gate',
type: 'policy',
status: 'matched',
outcome: 'blocked',
message: 'Risk gate matched.',
},
],
matchedRules: [
{
id: 'policy:risk-prod',
label: 'Production risk gate',
type: 'policy',
status: 'matched',
outcome: 'blocked',
message: 'Risk gate matched.',
},
],
steps: [
{
id: 'condition',
label: 'Condition',
status: 'matched',
message: 'Action matched git.push.',
},
],
raw: { actionType: 'git.push' },
redacted: true,
createdAt: now,
},
],
isLoading: false,
});
mocks.useGovernanceTrace.mockReturnValue({
data: {
id: 'govtrace-1',
kind: 'policy',
outcome: 'blocked',
title: 'Production policy block',
summary: 'Production deploy requires approval.',
remediation: 'Request approval from a lead agent.',
subject: {
agentId: 'codex',
taskId: 'task-1',
actionType: 'git.push',
},
evaluatedRules: [
{
id: 'policy:risk-prod',
label: 'Production risk gate',
type: 'policy',
status: 'matched',
outcome: 'blocked',
message: 'Risk gate matched.',
},
],
matchedRules: [
{
id: 'policy:risk-prod',
label: 'Production risk gate',
type: 'policy',
status: 'matched',
outcome: 'blocked',
message: 'Risk gate matched.',
},
],
steps: [
{
id: 'condition',
label: 'Condition',
status: 'matched',
message: 'Action matched git.push.',
},
],
raw: { actionType: 'git.push' },
redacted: true,
createdAt: now,
},
isLoading: false,
});
mocks.useFeedbackList.mockReturnValue({
data: [
{
@ -372,7 +475,8 @@ describe('governance surfaces Mantine migration', () => {
expectNoLegacySlots(baseElement);
});
it('renders drift and decision audit surfaces with direct Mantine primitives', () => {
it('renders drift and decision audit surfaces with direct Mantine primitives', async () => {
const user = userEvent.setup();
const drift = renderWithProviders(<DriftMonitor onBack={vi.fn()} />);
expect(screen.getByText('Behavioral Drift Monitor')).toBeDefined();
@ -389,6 +493,12 @@ describe('governance surfaces Mantine migration', () => {
1
);
expect(decisions.baseElement.querySelector('.mantine-TextInput-root')).toBeDefined();
expect(decisions.baseElement.querySelector('.mantine-SegmentedControl-root')).toBeDefined();
await user.click(screen.getByText('Governance Traces'));
expect(screen.getByText('Production policy block')).toBeDefined();
await user.click(screen.getByText('Production policy block'));
expect(screen.getByText('Evaluated Rules')).toBeDefined();
expect(screen.getByText('Raw Detail')).toBeDefined();
expectNoLegacySlots(decisions.baseElement);
cleanup();

View file

@ -1,9 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Search, ShieldAlert, BrainCircuit } from 'lucide-react';
import { Badge, Button, Select, TextInput } from '@mantine/core';
import { ArrowLeft, Search, ShieldAlert, BrainCircuit, Route, ShieldCheck } from 'lucide-react';
import { Badge, Button, SegmentedControl, Select, TextInput } from '@mantine/core';
import { useDecisions } from '@/hooks/useDecisions';
import type { DecisionListFilters, DecisionRecord } from '@veritas-kanban/shared';
import { useGovernanceTraces } from '@/hooks/useGovernanceTraces';
import type {
DecisionListFilters,
DecisionRecord,
GovernanceTraceKind,
GovernanceTraceListFilters,
GovernanceTraceOutcome,
GovernanceTraceRecord,
} from '@veritas-kanban/shared';
import { DecisionDetail } from './DecisionDetail';
import { GovernanceTraceDetail } from './GovernanceTraceDetail';
interface DecisionExplorerProps {
onBack: () => void;
@ -11,23 +20,59 @@ interface DecisionExplorerProps {
const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
type ExplorerMode = 'decisions' | 'governance';
const traceKindOptions: Array<{ value: 'all' | GovernanceTraceKind; label: string }> = [
{ value: 'all', label: 'All trace types' },
{ value: 'policy', label: 'Policies' },
{ value: 'tool-policy', label: 'Tool policies' },
{ value: 'agent-permission', label: 'Agent permissions' },
{ value: 'routing', label: 'Routing' },
{ value: 'workflow-gate', label: 'Workflow gates' },
];
const traceOutcomeOptions: Array<{ value: 'all' | GovernanceTraceOutcome; label: string }> = [
{ value: 'all', label: 'All outcomes' },
{ value: 'allowed', label: 'Allowed' },
{ value: 'warned', label: 'Warned' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'approval-required', label: 'Approval required' },
{ value: 'routed', label: 'Routed' },
{ value: 'fallback', label: 'Fallback' },
{ value: 'skipped', label: 'Skipped' },
];
function riskColor(riskScore: number): string {
if (riskScore >= 75) return 'red';
if (riskScore >= 40) return 'yellow';
return 'green';
}
export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
const initialSelectedId =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('decision')
: null;
function traceOutcomeColor(outcome: GovernanceTraceOutcome): string {
if (outcome === 'blocked') return 'red';
if (outcome === 'approval-required') return 'orange';
if (outcome === 'warned' || outcome === 'fallback') return 'yellow';
if (outcome === 'allowed' || outcome === 'routed') return 'green';
return 'gray';
}
export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
const initialParams =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const initialSelectedId = initialParams?.get('decision') ?? null;
const initialTraceId = initialParams?.get('trace') ?? null;
const initialMode = initialParams?.get('mode') === 'governance' ? 'governance' : 'decisions';
const initialSearch = initialParams?.get('q') ?? '';
const [mode, setMode] = useState<ExplorerMode>(initialTraceId ? 'governance' : initialMode);
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
const [selectedTraceId, setSelectedTraceId] = useState<string | null>(initialTraceId);
const [agent, setAgent] = useState('all');
const [confidenceFilter, setConfidenceFilter] = useState('all');
const [riskFilter, setRiskFilter] = useState('all');
const [search, setSearch] = useState('');
const [traceKind, setTraceKind] = useState<'all' | GovernanceTraceKind>('all');
const [traceOutcome, setTraceOutcome] = useState<'all' | GovernanceTraceOutcome>('all');
const [search, setSearch] = useState(initialSearch);
const filters = useMemo<DecisionListFilters>(() => {
const next: DecisionListFilters = {};
@ -47,7 +92,15 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
return next;
}, [agent, confidenceFilter, riskFilter]);
const traceFilters = useMemo<GovernanceTraceListFilters>(() => {
const next: GovernanceTraceListFilters = { limit: 200 };
if (traceKind !== 'all') next.kind = traceKind;
if (traceOutcome !== 'all') next.outcome = traceOutcome;
return next;
}, [traceKind, traceOutcome]);
const { data: decisions = [], isLoading } = useDecisions(filters);
const { data: traces = [], isLoading: tracesLoading } = useGovernanceTraces(traceFilters);
const filteredDecisions = useMemo(() => {
const query = search.trim().toLowerCase();
@ -60,6 +113,34 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
);
}, [decisions, search]);
const filteredTraces = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return traces;
return traces.filter((trace) =>
[
trace.id,
trace.kind,
trace.outcome,
trace.title,
trace.summary,
trace.subject.agentId,
trace.subject.actorId,
trace.subject.role,
trace.subject.taskId,
trace.subject.workflowId,
trace.subject.runId,
trace.subject.stepId,
trace.subject.actionType,
trace.subject.tool,
trace.subject.project,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query)
);
}, [traces, search]);
const agentOptions = useMemo(
() => Array.from(new Set(decisions.map((decision) => decision.agentId))).sort(),
[decisions]
@ -70,17 +151,50 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
const params = new URLSearchParams(window.location.search);
if (selectedId) {
params.set('decision', selectedId);
params.delete('trace');
} else {
params.delete('decision');
}
if (selectedTraceId) {
params.set('trace', selectedTraceId);
params.delete('decision');
} else {
params.delete('trace');
}
if (mode === 'governance') {
params.set('mode', 'governance');
} else {
params.delete('mode');
}
if (search.trim()) {
params.set('q', search.trim());
} else {
params.delete('q');
}
const query = params.toString();
window.history.replaceState({}, '', `${BASE_PATH}/decisions${query ? `?${query}` : ''}`);
}, [selectedId]);
}, [mode, search, selectedId, selectedTraceId]);
if (selectedId) {
const handleModeChange = (value: string) => {
setMode(value as ExplorerMode);
setSelectedId(null);
setSelectedTraceId(null);
setSearch('');
};
if (selectedId && mode === 'decisions') {
return <DecisionDetail decisionId={selectedId} onBack={() => setSelectedId(null)} />;
}
if (selectedTraceId && mode === 'governance') {
return (
<GovernanceTraceDetail traceId={selectedTraceId} onBack={() => setSelectedTraceId(null)} />
);
}
const activeCount = mode === 'decisions' ? filteredDecisions.length : filteredTraces.length;
const activeLabel = mode === 'decisions' ? 'decisions' : 'traces';
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
@ -92,25 +206,46 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
<div>
<h1 className="text-2xl font-bold">Decision Audit Trail</h1>
<p className="text-sm text-muted-foreground">
Review agent reasoning, assumptions, and parent decision lineage.
Review agent reasoning, policy decisions, and workflow gate outcomes.
</p>
</div>
</div>
<Badge variant="light" tt="none">
{filteredDecisions.length} decisions
{activeCount} {activeLabel}
</Badge>
</div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_220px_220px_220px]">
<SegmentedControl
value={mode}
onChange={handleModeChange}
data={[
{ value: 'decisions', label: 'Agent Decisions' },
{ value: 'governance', label: 'Governance Traces' },
]}
/>
<div
className={
mode === 'decisions'
? 'grid gap-4 lg:grid-cols-[1.2fr_220px_220px_220px]'
: 'grid gap-4 lg:grid-cols-[1.2fr_220px_220px]'
}
>
<div>
<TextInput
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search decision id, task, context..."
placeholder={
mode === 'decisions'
? 'Search decision id, task, context...'
: 'Search trace id, rule, action, subject...'
}
leftSection={<Search className="h-4 w-4 text-muted-foreground" />}
/>
</div>
{mode === 'decisions' ? (
<>
<Select
value={agent}
onChange={(value) => setAgent(value ?? 'all')}
@ -147,8 +282,31 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
placeholder="Risk"
allowDeselect={false}
/>
</>
) : (
<>
<Select
value={traceKind}
onChange={(value) => setTraceKind((value ?? 'all') as 'all' | GovernanceTraceKind)}
data={traceKindOptions}
placeholder="Trace type"
allowDeselect={false}
/>
<Select
value={traceOutcome}
onChange={(value) =>
setTraceOutcome((value ?? 'all') as 'all' | GovernanceTraceOutcome)
}
data={traceOutcomeOptions}
placeholder="Outcome"
allowDeselect={false}
/>
</>
)}
</div>
{mode === 'decisions' ? (
<div className="overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-[1.5fr_1.1fr_120px_120px_150px_180px] gap-3 border-b bg-muted/40 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span>Action</span>
@ -177,7 +335,88 @@ export function DecisionExplorer({ onBack }: DecisionExplorerProps) {
))
)}
</div>
) : (
<div className="overflow-hidden rounded-lg border bg-card">
<div className="grid grid-cols-[1.5fr_150px_150px_170px_180px] gap-3 border-b bg-muted/40 px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<span>Trace</span>
<span>Type</span>
<span>Outcome</span>
<span>Subject</span>
<span>Timestamp</span>
</div>
{tracesLoading ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
Loading governance traces...
</div>
) : filteredTraces.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No governance traces match the current filters.
</div>
) : (
filteredTraces.map((trace) => (
<GovernanceTraceRow
key={trace.id}
trace={trace}
onOpen={() => setSelectedTraceId(trace.id)}
/>
))
)}
</div>
)}
</div>
);
}
function GovernanceTraceRow({
trace,
onOpen,
}: {
trace: GovernanceTraceRecord;
onOpen: () => void;
}) {
const subject =
trace.subject.agentId ||
trace.subject.actorId ||
trace.subject.role ||
trace.subject.taskId ||
trace.subject.workflowId ||
trace.subject.runId ||
trace.subject.actionType ||
'local';
return (
<button
type="button"
onClick={onOpen}
className="grid w-full grid-cols-[1.5fr_150px_150px_170px_180px] gap-3 border-b px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-accent/40"
>
<div>
<div className="font-medium">{trace.title}</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">{trace.summary}</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Route className="h-4 w-4 text-muted-foreground" />
<span>{trace.kind}</span>
</div>
<div>
<Badge
color={traceOutcomeColor(trace.outcome)}
variant="light"
tt="none"
leftSection={<ShieldCheck className="h-3 w-3" />}
>
{trace.outcome}
</Badge>
</div>
<div className="space-y-1 text-sm">
<div className="font-medium">{subject}</div>
<div className="text-muted-foreground">{trace.subject.actionType}</div>
</div>
<div className="text-sm text-muted-foreground">
{new Date(trace.createdAt).toLocaleString()}
</div>
</button>
);
}

View file

@ -0,0 +1,207 @@
import { ArrowLeft, CheckCircle2, ShieldAlert, Route, FileJson } from 'lucide-react';
import { Badge, Button, Code, ScrollArea } from '@mantine/core';
import { useGovernanceTrace } from '@/hooks/useGovernanceTraces';
import type {
GovernanceTraceRecord,
GovernanceTraceRule,
GovernanceTraceStep,
} from '@veritas-kanban/shared';
interface GovernanceTraceDetailProps {
traceId: string;
onBack: () => void;
}
const outcomeColor: Record<GovernanceTraceRecord['outcome'], string> = {
allowed: 'green',
warned: 'yellow',
blocked: 'red',
'approval-required': 'orange',
routed: 'blue',
fallback: 'yellow',
skipped: 'gray',
};
const statusColor: Record<GovernanceTraceRule['status'], string> = {
matched: 'green',
'not-matched': 'gray',
skipped: 'gray',
info: 'blue',
};
export function GovernanceTraceDetail({ traceId, onBack }: GovernanceTraceDetailProps) {
const { data: trace, isLoading } = useGovernanceTrace(traceId);
if (isLoading) {
return (
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground">
Loading governance trace...
</div>
);
}
if (!trace) {
return (
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground">
Governance trace not found.
</div>
);
}
const subjectEntries = Object.entries(trace.subject).filter(
([, value]) => value !== undefined && value !== ''
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="subtle" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Traces
</Button>
<div className="flex flex-wrap items-center gap-2">
<Badge color="blue" variant="light" tt="none">
{trace.kind}
</Badge>
<Badge color={outcomeColor[trace.outcome]} variant="light" tt="none">
{trace.outcome}
</Badge>
{trace.redacted && (
<Badge color="gray" variant="outline" tt="none">
redacted
</Badge>
)}
</div>
</div>
<section className="rounded-lg border bg-card p-5">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md border bg-muted/50 p-2">
<ShieldAlert className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-xl font-semibold">{trace.title}</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{trace.summary}</p>
{trace.remediation && (
<div className="mt-4 rounded-md border border-dashed p-3 text-sm">
<div className="font-medium">Remediation</div>
<p className="mt-1 text-muted-foreground">{trace.remediation}</p>
</div>
)}
</div>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<section className="rounded-lg border bg-card p-5">
<div className="mb-4 flex items-center gap-2">
<Route className="h-4 w-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">Subject</h2>
</div>
{subjectEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No subject metadata recorded.</p>
) : (
<div className="space-y-2">
{subjectEntries.map(([key, value]) => (
<div key={key} className="flex items-start justify-between gap-3 text-sm">
<span className="text-muted-foreground">{key}</span>
<span className="max-w-[65%] break-words text-right font-medium">
{String(value)}
</span>
</div>
))}
</div>
)}
<div className="mt-5 text-xs text-muted-foreground">
{new Date(trace.createdAt).toLocaleString()}
</div>
</section>
<section className="rounded-lg border bg-card p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Evaluated Rules</h2>
<Badge variant="light" tt="none">
{trace.matchedRules.length} matched
</Badge>
</div>
<div className="space-y-3">
{trace.evaluatedRules.length === 0 ? (
<p className="text-sm text-muted-foreground">No rules evaluated.</p>
) : (
trace.evaluatedRules.map((rule) => <RuleRow key={rule.id} rule={rule} />)
)}
</div>
</section>
</div>
<section className="rounded-lg border bg-card p-5">
<h2 className="text-lg font-semibold">Trace Steps</h2>
<div className="mt-4 space-y-3">
{trace.steps.length === 0 ? (
<p className="text-sm text-muted-foreground">No steps recorded.</p>
) : (
trace.steps.map((step) => <StepRow key={step.id} step={step} />)
)}
</div>
</section>
<section className="rounded-lg border bg-card p-5">
<div className="mb-4 flex items-center gap-2">
<FileJson className="h-4 w-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">Raw Detail</h2>
</div>
<ScrollArea h={280} type="auto">
<Code block className="text-xs">
{JSON.stringify(trace.raw ?? trace, null, 2)}
</Code>
</ScrollArea>
</section>
</div>
);
}
function RuleRow({ rule }: { rule: GovernanceTraceRule }) {
return (
<div className="rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{rule.label}</span>
<Badge color={statusColor[rule.status]} variant="light" tt="none">
{rule.status}
</Badge>
{rule.outcome && (
<Badge color={outcomeColor[rule.outcome]} variant="outline" tt="none">
{rule.outcome}
</Badge>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{rule.message}</p>
{rule.details && (
<Code block className="mt-3 text-xs">
{JSON.stringify(rule.details, null, 2)}
</Code>
)}
</div>
);
}
function StepRow({ step }: { step: GovernanceTraceStep }) {
return (
<div className="flex gap-3 rounded-md border p-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{step.label}</span>
<Badge color={statusColor[step.status]} variant="light" tt="none">
{step.status}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{step.message}</p>
{step.details && (
<Code block className="mt-3 text-xs">
{JSON.stringify(step.details, null, 2)}
</Code>
)}
</div>
</div>
);
}

View file

@ -5,7 +5,15 @@ import type {
PolicyType,
PolicyResponseAction,
} from '@veritas-kanban/shared';
import { ArrowLeft, Edit, FlaskConical, Plus, ShieldAlert, Trash2 } from 'lucide-react';
import {
ArrowLeft,
Edit,
ExternalLink,
FlaskConical,
Plus,
ShieldAlert,
Trash2,
} from 'lucide-react';
import {
ActionIcon,
Badge,
@ -31,6 +39,8 @@ interface PolicyManagerProps {
onBack: () => void;
}
const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
type PolicyFormState = {
id: string;
name: string;
@ -996,6 +1006,18 @@ export function PolicyManager({ onBack }: PolicyManagerProps) {
<span className="text-sm text-muted-foreground">
{evaluatePolicies.data.matches.length} matching policies
</span>
{evaluatePolicies.data.traceId && (
<Button
component="a"
href={`${BASE_PATH}/decisions?trace=${encodeURIComponent(evaluatePolicies.data.traceId)}`}
size="xs"
variant="subtle"
target="_self"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open Trace
</Button>
)}
</div>
<div className="space-y-2">
{evaluatePolicies.data.matches.map((match) => (

View file

@ -52,6 +52,8 @@ import { sanitizeText } from '@/lib/sanitize';
type TimelineTabTarget = 'agent' | 'changes' | 'details' | 'review' | 'work-products';
const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
interface AgentRunTimelinePanelProps {
task: Task;
initialAttemptId?: string | null;
@ -897,6 +899,18 @@ function typeFilterLabel(type: AgentRunTimelineEventType): string {
return EVENT_LABELS[type];
}
function governanceTraceHrefForEvent(event: AgentRunTimelineEvent): string | null {
if (event.type !== 'policy' && event.type !== 'approval') return null;
const query =
safeString(event.metadata?.taskId) ??
safeString(event.metadata?.task_id) ??
safeString(event.metadata?.actionType) ??
event.title;
return `${BASE_PATH}/decisions?mode=governance&q=${encodeURIComponent(query)}`;
}
function EventRow({
event,
onOpenTab,
@ -913,6 +927,7 @@ function EventRow({
linkTarget && linkTarget !== 'external' && linkTarget !== 'workflow' && onOpenTab;
const canOpenWorkflow = linkTarget === 'workflow' && onOpenWorkflow;
const canOpenExternal = linkTarget === 'external' && event.link?.href;
const governanceTraceHref = governanceTraceHrefForEvent(event);
return (
<Paper withBorder p="sm" radius="md">
@ -954,9 +969,9 @@ function EventRow({
{metadata}
</Code>
)}
{(canOpenInternal || canOpenWorkflow || canOpenExternal) && event.link && (
{(canOpenInternal || canOpenWorkflow || canOpenExternal || governanceTraceHref) && (
<Group gap="xs">
{canOpenInternal && linkTarget && (
{canOpenInternal && linkTarget && event.link && (
<Button
size="compact-xs"
variant="subtle"
@ -965,7 +980,7 @@ function EventRow({
{event.link.label}
</Button>
)}
{canOpenWorkflow && (
{canOpenWorkflow && event.link && (
<Button
size="compact-xs"
variant="subtle"
@ -974,7 +989,7 @@ function EventRow({
{event.link.label}
</Button>
)}
{canOpenExternal && (
{canOpenExternal && event.link && (
<Button
component="a"
href={event.link.href}
@ -986,6 +1001,18 @@ function EventRow({
{event.link.label}
</Button>
)}
{governanceTraceHref && (
<Button
component="a"
href={governanceTraceHref}
rel="noopener noreferrer"
size="compact-xs"
target="_self"
variant="subtle"
>
Governance Traces
</Button>
)}
</Group>
)}
</Stack>

View file

@ -42,6 +42,7 @@ export * from './useTasks';
export * from './useTemplateForm';
export * from './useTemplates';
export * from './useDecisions';
export * from './useGovernanceTraces';
// Note: useTimeTracking also exports formatDuration - import directly to avoid conflict with useMetrics
export {
useTimeSummary,

View file

@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { GovernanceTraceListFilters } from '@veritas-kanban/shared';
export function useGovernanceTraces(filters: GovernanceTraceListFilters) {
return useQuery({
queryKey: ['governance-traces', filters],
queryFn: () => api.governanceTraces.list(filters),
});
}
export function useGovernanceTrace(id: string | null) {
return useQuery({
queryKey: ['governance-traces', id],
queryFn: () => {
if (!id) {
throw new Error('Governance trace id is required');
}
return api.governanceTraces.get(id);
},
enabled: !!id,
});
}

View file

@ -0,0 +1,34 @@
import type { GovernanceTraceListFilters, GovernanceTraceRecord } from '@veritas-kanban/shared';
import { API_BASE, handleResponse } from './helpers';
function toQuery(filters: GovernanceTraceListFilters = {}): string {
const params = new URLSearchParams();
if (filters.kind) params.set('kind', filters.kind);
if (filters.outcome) params.set('outcome', filters.outcome);
if (filters.agent) params.set('agent', filters.agent);
if (filters.taskId) params.set('taskId', filters.taskId);
if (filters.actionType) params.set('actionType', filters.actionType);
if (filters.startTime) params.set('startTime', filters.startTime);
if (filters.endTime) params.set('endTime', filters.endTime);
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
const query = params.toString();
return query ? `?${query}` : '';
}
export const governanceTracesApi = {
list: async (filters: GovernanceTraceListFilters = {}): Promise<GovernanceTraceRecord[]> => {
const response = await fetch(`${API_BASE}/governance/traces${toQuery(filters)}`, {
credentials: 'include',
});
return handleResponse<GovernanceTraceRecord[]>(response);
},
get: async (id: string): Promise<GovernanceTraceRecord> => {
const response = await fetch(`${API_BASE}/governance/traces/${encodeURIComponent(id)}`, {
credentials: 'include',
});
return handleResponse<GovernanceTraceRecord>(response);
},
};

View file

@ -13,6 +13,7 @@ import { templatesApi, taskTypesApi, sprintsApi, activityApi, attachmentsApi } f
import { timeApi, statusHistoryApi } from './time';
import { chatApi } from './chat';
import { decisionsApi } from './decisions';
import { governanceTracesApi } from './governance-traces';
import { scoringApi } from './scoring';
import { searchApi } from './search';
import { identityApi } from './identity';
@ -42,6 +43,7 @@ export const api = {
statusHistory: statusHistoryApi,
chat: chatApi,
decisions: decisionsApi,
governanceTraces: governanceTracesApi,
scoring: scoringApi,
search: searchApi,
identity: identityApi,