feat(governance): Governance Engine MVP implementation

- Backend:
  - Migration 032: agent_gov_level, status, incidents, permissions tables
  - Domain types for governance layer
  - Permission Engine with all governance checks
  - Governance Service (promote/demote/roles)
  - Revocation Service (revoke/suspend/reinstate)
  - Audit Service (events filtering and stats)
  - Incidents Service (create/assign/escalate/resolve)
  - REST API routes for governance, audit, incidents

- Frontend:
  - TypeScript types for governance
  - API clients for governance, audit, incidents
  - GovernanceLevelBadge component
  - CityGovernancePanel component
  - AuditDashboard component
  - IncidentsList component with detail modal

Based on: Agent_Governance_Protocol_v1.md
This commit is contained in:
Apple
2025-11-29 16:02:06 -08:00
parent 2627205663
commit e233d32ae7
20 changed files with 5837 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
/**
* Audit Routes
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
*/
import { Router, Request, Response } from 'express';
import { auditService } from '../services/governance/audit.service';
import { GovernanceEventType, GovernanceScope } from '../domain/governance/types';
import { logger } from '../infra/logger/logger';
const router = Router();
/**
* GET /api/v1/audit/events
* Get audit events with filters
*/
router.get('/events', async (req: Request, res: Response) => {
try {
const {
eventType,
actorId,
targetId,
scope,
createdAtFrom,
createdAtTo,
limit,
offset,
} = req.query;
const result = await auditService.getEvents({
eventType: eventType as GovernanceEventType | undefined,
actorId: actorId as string | undefined,
targetId: targetId as string | undefined,
scope: scope as GovernanceScope | undefined,
createdAtFrom: createdAtFrom ? new Date(createdAtFrom as string) : undefined,
createdAtTo: createdAtTo ? new Date(createdAtTo as string) : undefined,
limit: limit ? parseInt(limit as string, 10) : 50,
offset: offset ? parseInt(offset as string, 10) : 0,
});
res.json(result);
} catch (error) {
logger.error('Error getting audit events', error);
res.status(500).json({ error: 'Failed to get audit events' });
}
});
/**
* GET /api/v1/audit/events/:id
* Get single audit event
*/
router.get('/events/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const event = await auditService.getEvent(id);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
res.json(event);
} catch (error) {
logger.error('Error getting audit event', error);
res.status(500).json({ error: 'Failed to get audit event' });
}
});
/**
* GET /api/v1/audit/actor/:actorId
* Get events by actor
*/
router.get('/actor/:actorId', async (req: Request, res: Response) => {
try {
const { actorId } = req.params;
const { limit } = req.query;
const events = await auditService.getEventsByActor(
actorId,
limit ? parseInt(limit as string, 10) : 50
);
res.json(events);
} catch (error) {
logger.error('Error getting events by actor', error);
res.status(500).json({ error: 'Failed to get events by actor' });
}
});
/**
* GET /api/v1/audit/target/:targetId
* Get events by target
*/
router.get('/target/:targetId', async (req: Request, res: Response) => {
try {
const { targetId } = req.params;
const { limit } = req.query;
const events = await auditService.getEventsByTarget(
targetId,
limit ? parseInt(limit as string, 10) : 50
);
res.json(events);
} catch (error) {
logger.error('Error getting events by target', error);
res.status(500).json({ error: 'Failed to get events by target' });
}
});
/**
* GET /api/v1/audit/scope/:scope
* Get events by scope
*/
router.get('/scope/:scope', async (req: Request, res: Response) => {
try {
const { scope } = req.params;
const { limit } = req.query;
const events = await auditService.getEventsByScope(
scope as GovernanceScope,
limit ? parseInt(limit as string, 10) : 50
);
res.json(events);
} catch (error) {
logger.error('Error getting events by scope', error);
res.status(500).json({ error: 'Failed to get events by scope' });
}
});
/**
* GET /api/v1/audit/stats
* Get event statistics
*/
router.get('/stats', async (req: Request, res: Response) => {
try {
const { fromDate, toDate } = req.query;
const stats = await auditService.getEventStats(
fromDate ? new Date(fromDate as string) : undefined,
toDate ? new Date(toDate as string) : undefined
);
res.json(stats);
} catch (error) {
logger.error('Error getting audit stats', error);
res.status(500).json({ error: 'Failed to get audit stats' });
}
});
/**
* GET /api/v1/audit/entity/:entityType/:entityId
* Get governance history for specific entity
*/
router.get('/entity/:entityType/:entityId', async (req: Request, res: Response) => {
try {
const { entityType, entityId } = req.params;
const { limit } = req.query;
if (!['agent', 'microdao', 'district', 'node', 'room'].includes(entityType)) {
return res.status(400).json({ error: 'Invalid entity type' });
}
const events = await auditService.getEntityHistory(
entityType as 'agent' | 'microdao' | 'district' | 'node' | 'room',
entityId,
limit ? parseInt(limit as string, 10) : 50
);
res.json(events);
} catch (error) {
logger.error('Error getting entity history', error);
res.status(500).json({ error: 'Failed to get entity history' });
}
});
export default router;

View File

@@ -0,0 +1,386 @@
/**
* Governance Routes
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
*/
import { Router, Request, Response } from 'express';
import { governanceService } from '../services/governance/governance.service';
import { revocationService } from '../services/governance/revocation.service';
import { permissionEngine, buildContext } from '../services/governance/permissions';
import { AgentGovLevel, GovernanceScope, RevocationType } from '../domain/governance/types';
import { logger } from '../infra/logger/logger';
const router = Router();
// ============================================================================
// AGENT PROMOTION/DEMOTION
// ============================================================================
/**
* POST /api/v1/governance/agent/promote
* Promote an agent to a higher level
*/
router.post('/agent/promote', async (req: Request, res: Response) => {
try {
const { actorId, targetId, newLevel, scope, reason } = req.body;
if (!actorId || !targetId || !newLevel || !scope) {
return res.status(400).json({ error: 'Missing required fields: actorId, targetId, newLevel, scope' });
}
const result = await governanceService.promoteAgent({
actorId,
targetId,
newLevel: newLevel as AgentGovLevel,
scope: scope as GovernanceScope,
reason,
});
if (!result.success) {
return res.status(403).json({ error: result.error });
}
res.json({ success: true, message: `Agent ${targetId} promoted to ${newLevel}` });
} catch (error) {
logger.error('Error promoting agent', error);
res.status(500).json({ error: 'Failed to promote agent' });
}
});
/**
* POST /api/v1/governance/agent/demote
* Demote an agent to a lower level
*/
router.post('/agent/demote', async (req: Request, res: Response) => {
try {
const { actorId, targetId, newLevel, scope, reason } = req.body;
if (!actorId || !targetId || !newLevel || !scope) {
return res.status(400).json({ error: 'Missing required fields' });
}
const result = await governanceService.demoteAgent(
actorId,
targetId,
newLevel as AgentGovLevel,
scope as GovernanceScope,
reason
);
if (!result.success) {
return res.status(403).json({ error: result.error });
}
res.json({ success: true, message: `Agent ${targetId} demoted to ${newLevel}` });
} catch (error) {
logger.error('Error demoting agent', error);
res.status(500).json({ error: 'Failed to demote agent' });
}
});
// ============================================================================
// AGENT REVOCATION
// ============================================================================
/**
* POST /api/v1/governance/agent/revoke
* Revoke an agent
*/
router.post('/agent/revoke', async (req: Request, res: Response) => {
try {
const { actorId, targetId, reason, scope, revocationType } = req.body;
if (!actorId || !targetId || !reason || !scope) {
return res.status(400).json({ error: 'Missing required fields: actorId, targetId, reason, scope' });
}
const result = await revocationService.revokeAgent({
actorId,
targetId,
reason,
scope: scope as GovernanceScope,
revocationType: (revocationType || 'soft') as RevocationType,
});
if (!result.success) {
return res.status(403).json({ error: result.error });
}
res.json({
success: true,
revocationId: result.revocationId,
message: `Agent ${targetId} revoked`
});
} catch (error) {
logger.error('Error revoking agent', error);
res.status(500).json({ error: 'Failed to revoke agent' });
}
});
/**
* POST /api/v1/governance/agent/suspend
* Temporarily suspend an agent
*/
router.post('/agent/suspend', async (req: Request, res: Response) => {
try {
const { actorId, targetId, reason, scope, durationHours } = req.body;
if (!actorId || !targetId || !reason || !scope) {
return res.status(400).json({ error: 'Missing required fields' });
}
const result = await revocationService.suspendAgent(
actorId,
targetId,
reason,
scope as GovernanceScope,
durationHours
);
if (!result.success) {
return res.status(403).json({ error: result.error });
}
res.json({ success: true, message: `Agent ${targetId} suspended` });
} catch (error) {
logger.error('Error suspending agent', error);
res.status(500).json({ error: 'Failed to suspend agent' });
}
});
/**
* POST /api/v1/governance/agent/reinstate
* Reinstate a revoked/suspended agent
*/
router.post('/agent/reinstate', async (req: Request, res: Response) => {
try {
const { actorId, targetId, scope, reason } = req.body;
if (!actorId || !targetId || !scope) {
return res.status(400).json({ error: 'Missing required fields' });
}
const result = await revocationService.reinstateAgent(
actorId,
targetId,
scope as GovernanceScope,
reason
);
if (!result.success) {
return res.status(403).json({ error: result.error });
}
res.json({ success: true, message: `Agent ${targetId} reinstated` });
} catch (error) {
logger.error('Error reinstating agent', error);
res.status(500).json({ error: 'Failed to reinstate agent' });
}
});
// ============================================================================
// AGENT ROLES & PERMISSIONS
// ============================================================================
/**
* GET /api/v1/governance/agent/:id/roles
* Get agent roles and permissions
*/
router.get('/agent/:id/roles', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const roles = await governanceService.getAgentRoles(id);
res.json(roles);
} catch (error) {
logger.error('Error getting agent roles', error);
res.status(500).json({ error: 'Failed to get agent roles' });
}
});
/**
* GET /api/v1/governance/agent/:id/permissions
* Get agent permissions for a specific target
*/
router.get('/agent/:id/permissions', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { targetType, targetId, action } = req.query;
if (targetType && targetId && action) {
// Get DAIS ID for agent
const context = await buildContext(id, 'city');
const hasPermission = await permissionEngine.hasExplicitPermission(
context.actorDaisId,
targetType as any,
targetId as string,
action as any
);
return res.json({ hasPermission });
}
// Return general permissions
const level = await permissionEngine.getAgentLevel(id);
const powers = permissionEngine.getPowersForLevel(level);
res.json({ level, powers });
} catch (error) {
logger.error('Error getting agent permissions', error);
res.status(500).json({ error: 'Failed to get agent permissions' });
}
});
// ============================================================================
// PERMISSION CHECKS
// ============================================================================
/**
* POST /api/v1/governance/check
* Check if an agent can perform an action
*/
router.post('/check', async (req: Request, res: Response) => {
try {
const { actorId, action, targetId, scope, roomType } = req.body;
if (!actorId || !action) {
return res.status(400).json({ error: 'Missing required fields: actorId, action' });
}
const context = await buildContext(actorId, (scope || 'city') as GovernanceScope);
let result;
switch (action) {
case 'create_microdao':
result = await permissionEngine.canCreateMicrodao(context);
break;
case 'create_district':
result = await permissionEngine.canCreateDistrict(context);
break;
case 'register_node':
result = await permissionEngine.canRegisterNode(context);
break;
case 'create_room':
result = await permissionEngine.canCreateRoom(context, roomType || 'dao-room');
break;
case 'create_front_portal':
result = await permissionEngine.canCreateFrontPortal(context, targetId);
break;
case 'moderate_room':
result = await permissionEngine.canModerateRoom(context, targetId);
break;
default:
return res.status(400).json({ error: `Unknown action: ${action}` });
}
res.json(result);
} catch (error) {
logger.error('Error checking permission', error);
res.status(500).json({ error: 'Failed to check permission' });
}
});
// ============================================================================
// GOVERNANCE AGENTS LISTS
// ============================================================================
/**
* GET /api/v1/governance/agents/city
* Get city governance agents
*/
router.get('/agents/city', async (_req: Request, res: Response) => {
try {
const agents = await governanceService.getCityGovernanceAgents();
res.json(agents);
} catch (error) {
logger.error('Error getting city governance agents', error);
res.status(500).json({ error: 'Failed to get city governance agents' });
}
});
/**
* GET /api/v1/governance/agents/district-leads
* Get district lead agents
*/
router.get('/agents/district-leads', async (req: Request, res: Response) => {
try {
const { districtId } = req.query;
const agents = await governanceService.getDistrictLeadAgents(districtId as string);
res.json(agents);
} catch (error) {
logger.error('Error getting district lead agents', error);
res.status(500).json({ error: 'Failed to get district lead agents' });
}
});
/**
* GET /api/v1/governance/agents/by-level/:level
* Get agents by governance level
*/
router.get('/agents/by-level/:level', async (req: Request, res: Response) => {
try {
const { level } = req.params;
const { limit } = req.query;
const agents = await governanceService.getAgentsByLevel(
level as AgentGovLevel,
limit ? parseInt(limit as string, 10) : 50
);
res.json(agents);
} catch (error) {
logger.error('Error getting agents by level', error);
res.status(500).json({ error: 'Failed to get agents by level' });
}
});
// ============================================================================
// DAIS KEY REVOCATION
// ============================================================================
/**
* POST /api/v1/governance/dais/keys/revoke
* Revoke DAIS keys
*/
router.post('/dais/keys/revoke', async (req: Request, res: Response) => {
try {
const { actorId, daisId, reason } = req.body;
if (!actorId || !daisId || !reason) {
return res.status(400).json({ error: 'Missing required fields: actorId, daisId, reason' });
}
const result = await revocationService.revokeDaisKeys(actorId, daisId, reason);
if (!result.success) {
return res.status(403).json({ error: result.error });
}
res.json({
success: true,
keysRevoked: result.keysRevoked,
message: `${result.keysRevoked} keys revoked for DAIS ${daisId}`
});
} catch (error) {
logger.error('Error revoking DAIS keys', error);
res.status(500).json({ error: 'Failed to revoke DAIS keys' });
}
});
/**
* GET /api/v1/governance/agent/:id/revocations
* Get revocation history for an agent
*/
router.get('/agent/:id/revocations', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const history = await revocationService.getRevocationHistory(id);
res.json(history);
} catch (error) {
logger.error('Error getting revocation history', error);
res.status(500).json({ error: 'Failed to get revocation history' });
}
});
export default router;

View File

@@ -0,0 +1,293 @@
/**
* Incidents Routes
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
*/
import { Router, Request, Response } from 'express';
import { incidentsService } from '../services/governance/incidents.service';
import {
IncidentStatus,
IncidentPriority,
EscalationLevel,
TargetScopeType,
} from '../domain/governance/types';
import { logger } from '../infra/logger/logger';
const router = Router();
/**
* POST /api/v1/incidents
* Create a new incident
*/
router.post('/', async (req: Request, res: Response) => {
try {
const {
createdByDaisId,
targetScopeType,
targetScopeId,
priority,
title,
description,
metadata,
} = req.body;
if (!createdByDaisId || !targetScopeType || !targetScopeId || !title) {
return res.status(400).json({
error: 'Missing required fields: createdByDaisId, targetScopeType, targetScopeId, title',
});
}
const incident = await incidentsService.createIncident({
createdByDaisId,
targetScopeType: targetScopeType as TargetScopeType,
targetScopeId,
priority: priority as IncidentPriority | undefined,
title,
description,
metadata,
});
res.status(201).json(incident);
} catch (error) {
logger.error('Error creating incident', error);
res.status(500).json({ error: 'Failed to create incident' });
}
});
/**
* GET /api/v1/incidents
* List incidents with filters
*/
router.get('/', async (req: Request, res: Response) => {
try {
const {
status,
priority,
escalationLevel,
targetScopeType,
targetScopeId,
assignedToDaisId,
limit,
offset,
} = req.query;
const result = await incidentsService.listIncidents({
status: status as IncidentStatus | undefined,
priority: priority as IncidentPriority | undefined,
escalationLevel: escalationLevel as EscalationLevel | undefined,
targetScopeType: targetScopeType as TargetScopeType | undefined,
targetScopeId: targetScopeId as string | undefined,
assignedToDaisId: assignedToDaisId as string | undefined,
limit: limit ? parseInt(limit as string, 10) : 50,
offset: offset ? parseInt(offset as string, 10) : 0,
});
res.json(result);
} catch (error) {
logger.error('Error listing incidents', error);
res.status(500).json({ error: 'Failed to list incidents' });
}
});
/**
* GET /api/v1/incidents/count
* Get open incidents count by level
*/
router.get('/count', async (_req: Request, res: Response) => {
try {
const counts = await incidentsService.getOpenIncidentsCount();
res.json(counts);
} catch (error) {
logger.error('Error getting incidents count', error);
res.status(500).json({ error: 'Failed to get incidents count' });
}
});
/**
* GET /api/v1/incidents/:id
* Get incident by ID
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const incident = await incidentsService.getIncident(id);
if (!incident) {
return res.status(404).json({ error: 'Incident not found' });
}
res.json(incident);
} catch (error) {
logger.error('Error getting incident', error);
res.status(500).json({ error: 'Failed to get incident' });
}
});
/**
* GET /api/v1/incidents/:id/history
* Get incident history
*/
router.get('/:id/history', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const history = await incidentsService.getIncidentHistory(id);
res.json(history);
} catch (error) {
logger.error('Error getting incident history', error);
res.status(500).json({ error: 'Failed to get incident history' });
}
});
/**
* POST /api/v1/incidents/:id/assign
* Assign incident to an agent
*/
router.post('/:id/assign', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { assignedToDaisId, actorDaisId } = req.body;
if (!assignedToDaisId || !actorDaisId) {
return res.status(400).json({
error: 'Missing required fields: assignedToDaisId, actorDaisId',
});
}
const result = await incidentsService.assignIncident({
incidentId: id,
assignedToDaisId,
actorDaisId,
});
if (!result.success) {
return res.status(400).json({ error: result.error });
}
res.json({ success: true, message: 'Incident assigned' });
} catch (error) {
logger.error('Error assigning incident', error);
res.status(500).json({ error: 'Failed to assign incident' });
}
});
/**
* POST /api/v1/incidents/:id/escalate
* Escalate incident to higher level
*/
router.post('/:id/escalate', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { newLevel, actorDaisId, reason } = req.body;
if (!newLevel || !actorDaisId) {
return res.status(400).json({
error: 'Missing required fields: newLevel, actorDaisId',
});
}
const result = await incidentsService.escalateIncident({
incidentId: id,
newLevel: newLevel as EscalationLevel,
actorDaisId,
reason,
});
if (!result.success) {
return res.status(400).json({ error: result.error });
}
res.json({ success: true, message: `Incident escalated to ${newLevel}` });
} catch (error) {
logger.error('Error escalating incident', error);
res.status(500).json({ error: 'Failed to escalate incident' });
}
});
/**
* POST /api/v1/incidents/:id/resolve
* Resolve incident
*/
router.post('/:id/resolve', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { resolution, actorDaisId } = req.body;
if (!resolution || !actorDaisId) {
return res.status(400).json({
error: 'Missing required fields: resolution, actorDaisId',
});
}
const result = await incidentsService.resolveIncident({
incidentId: id,
resolution,
actorDaisId,
});
if (!result.success) {
return res.status(400).json({ error: result.error });
}
res.json({ success: true, message: 'Incident resolved' });
} catch (error) {
logger.error('Error resolving incident', error);
res.status(500).json({ error: 'Failed to resolve incident' });
}
});
/**
* POST /api/v1/incidents/:id/close
* Close incident
*/
router.post('/:id/close', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { actorDaisId } = req.body;
if (!actorDaisId) {
return res.status(400).json({ error: 'Missing required field: actorDaisId' });
}
const result = await incidentsService.closeIncident(id, actorDaisId);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
res.json({ success: true, message: 'Incident closed' });
} catch (error) {
logger.error('Error closing incident', error);
res.status(500).json({ error: 'Failed to close incident' });
}
});
/**
* POST /api/v1/incidents/:id/comment
* Add comment to incident
*/
router.post('/:id/comment', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { actorDaisId, comment } = req.body;
if (!actorDaisId || !comment) {
return res.status(400).json({
error: 'Missing required fields: actorDaisId, comment',
});
}
const result = await incidentsService.addComment(id, actorDaisId, comment);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
res.json({ success: true, message: 'Comment added' });
} catch (error) {
logger.error('Error adding comment to incident', error);
res.status(500).json({ error: 'Failed to add comment' });
}
});
export default router;