diff --git a/backend/app.ts b/backend/app.ts index 3cc717fe..a237ce28 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -18,6 +18,10 @@ import { teamsRoutes } from './api/http/teams.routes'; // Foundation Update routes import daisRoutes from './http/dais.routes'; import assignmentRoutes from './http/assignment.routes'; +// Governance Engine routes +import governanceRoutes from './http/governance.routes'; +import auditRoutes from './http/audit.routes'; +import incidentsRoutes from './http/incidents.routes'; const app = express(); @@ -39,6 +43,11 @@ app.use('/api/v1', agentsRoutes); app.use('/api/v1/dais', daisRoutes); app.use('/api/v1/assignments', assignmentRoutes); +// Governance Engine routes +app.use('/api/v1/governance', governanceRoutes); +app.use('/api/v1/audit', auditRoutes); +app.use('/api/v1/incidents', incidentsRoutes); + // Health check app.get('/health', (req, res) => { res.json({ status: 'ok' }); diff --git a/backend/domain/governance/types.ts b/backend/domain/governance/types.ts new file mode 100644 index 00000000..e1e0d34e --- /dev/null +++ b/backend/domain/governance/types.ts @@ -0,0 +1,328 @@ +/** + * Governance Types + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Implements the 8-level agent hierarchy and permission system + */ + +// ============================================================================ +// GOVERNANCE LEVELS (0-7) +// ============================================================================ + +export const AGENT_LEVELS = { + GUEST: 0, + PERSONAL: 1, + MEMBER: 2, + WORKER: 3, + CORE_TEAM: 4, + ORCHESTRATOR: 5, + DISTRICT_LEAD: 6, + CITY_GOVERNANCE: 7, +} as const; + +export type AgentLevelNum = typeof AGENT_LEVELS[keyof typeof AGENT_LEVELS]; + +export type AgentGovLevel = + | 'guest' + | 'personal' + | 'member' + | 'worker' + | 'core_team' + | 'orchestrator' + | 'district_lead' + | 'city_governance'; + +export const GOV_LEVEL_TO_NUM: Record = { + guest: 0, + personal: 1, + member: 2, + worker: 3, + core_team: 4, + orchestrator: 5, + district_lead: 6, + city_governance: 7, +}; + +export const NUM_TO_GOV_LEVEL: Record = { + 0: 'guest', + 1: 'personal', + 2: 'member', + 3: 'worker', + 4: 'core_team', + 5: 'orchestrator', + 6: 'district_lead', + 7: 'city_governance', +}; + +// ============================================================================ +// AGENT STATUS +// ============================================================================ + +export type AgentStatus = 'active' | 'suspended' | 'revoked'; + +// ============================================================================ +// GOVERNANCE POWERS +// ============================================================================ + +export type GovernancePower = + | 'administrative' // Create/close DAO/Nodes/Rooms + | 'moderation' // Ban, mute, moderate rooms + | 'execution' // Execute tasks on behalf of DAO + | 'infrastructure' // Control node resources, deploy + | 'identity' // Issue/revoke DAIS keys + | 'protocol' // Change system rules (City only) + | 'district'; // Manage subordinate DAOs + +export interface PowerMatrix { + level: AgentGovLevel; + powers: GovernancePower[]; +} + +export const POWER_MATRIX: PowerMatrix[] = [ + { level: 'guest', powers: [] }, + { level: 'personal', powers: ['execution'] }, + { level: 'member', powers: ['execution'] }, + { level: 'worker', powers: ['execution', 'moderation'] }, + { level: 'core_team', powers: ['administrative', 'moderation', 'execution', 'infrastructure'] }, + { level: 'orchestrator', powers: ['administrative', 'moderation', 'execution', 'infrastructure', 'identity'] }, + { level: 'district_lead', powers: ['administrative', 'moderation', 'execution', 'infrastructure', 'identity', 'district'] }, + { level: 'city_governance', powers: ['administrative', 'moderation', 'execution', 'infrastructure', 'identity', 'protocol', 'district'] }, +]; + +// ============================================================================ +// PERMISSIONS +// ============================================================================ + +export type PermissionAction = 'read' | 'write' | 'moderate' | 'admin' | 'superadmin'; +export type TargetType = 'room' | 'microdao' | 'node' | 'district' | 'city'; + +export interface Permission { + id: string; + daisId: string; + targetType: TargetType; + targetId: string; + action: PermissionAction; + grantedBy: string; + expiresAt?: Date; + metadata: Record; + createdAt: Date; +} + +export interface CheckPermissionRequest { + daisId: string; + targetType: TargetType; + targetId: string; + action: PermissionAction; +} + +export interface GrantPermissionRequest { + daisId: string; + targetType: TargetType; + targetId: string; + action: PermissionAction; + grantedBy: string; + expiresAt?: Date; + metadata?: Record; +} + +// ============================================================================ +// GOVERNANCE ACTIONS +// ============================================================================ + +export interface PromoteAgentRequest { + actorId: string; // Who is performing the action + targetId: string; // Agent being promoted + newLevel: AgentGovLevel; + scope: GovernanceScope; + reason?: string; +} + +export interface RevokeAgentRequest { + actorId: string; + targetId: string; + reason: string; + scope: GovernanceScope; + revocationType: RevocationType; +} + +export interface AssignAgentRequest { + actorId: string; + targetId: string; + scopeType: 'microdao' | 'district' | 'city'; + scopeId: string; + role: string; +} + +export type GovernanceScope = 'city' | `district:${string}` | `microdao:${string}` | `node:${string}`; + +// ============================================================================ +// REVOCATION +// ============================================================================ + +export type RevocationType = 'soft' | 'hard' | 'shadow'; + +export interface RevocationEffect { + daisKeysInvalidated: boolean; + walletSigningDisabled: boolean; + roomAccessRevoked: boolean; + nodePrivilegesRemoved: boolean; + assignmentsTerminated: boolean; +} + +export interface AgentRevocation { + id: string; + agentId: string; + daisId?: string; + revokedBy: string; + revocationType: RevocationType; + reason: string; + scope: GovernanceScope; + keysInvalidated: boolean; + walletDisabled: boolean; + roomAccessRevoked: boolean; + nodePrivilegesRemoved: boolean; + assignmentsTerminated: boolean; + reversible: boolean; + reversedAt?: Date; + reversedBy?: string; + createdAt: Date; +} + +// ============================================================================ +// INCIDENTS & ESCALATION +// ============================================================================ + +export type IncidentStatus = 'open' | 'in_progress' | 'resolved' | 'closed'; +export type IncidentPriority = 'low' | 'medium' | 'high' | 'critical'; +export type EscalationLevel = 'microdao' | 'district' | 'city'; +export type TargetScopeType = 'city' | 'district' | 'microdao' | 'room' | 'node' | 'agent'; + +export interface Incident { + id: string; + createdByDaisId: string; + targetScopeType: TargetScopeType; + targetScopeId: string; + status: IncidentStatus; + priority: IncidentPriority; + assignedToDaisId?: string; + escalationLevel: EscalationLevel; + title: string; + description?: string; + resolution?: string; + metadata: Record; + createdAt: Date; + updatedAt: Date; + resolvedAt?: Date; + closedAt?: Date; +} + +export interface CreateIncidentRequest { + createdByDaisId: string; + targetScopeType: TargetScopeType; + targetScopeId: string; + priority?: IncidentPriority; + title: string; + description?: string; + metadata?: Record; +} + +export interface AssignIncidentRequest { + incidentId: string; + assignedToDaisId: string; + actorDaisId: string; +} + +export interface EscalateIncidentRequest { + incidentId: string; + newLevel: EscalationLevel; + actorDaisId: string; + reason?: string; +} + +export interface ResolveIncidentRequest { + incidentId: string; + resolution: string; + actorDaisId: string; +} + +export interface IncidentHistory { + id: string; + incidentId: string; + action: 'created' | 'assigned' | 'escalated' | 'resolved' | 'closed' | 'comment'; + actorDaisId: string; + oldValue?: Record; + newValue?: Record; + comment?: string; + createdAt: Date; +} + +// ============================================================================ +// AUDIT EVENTS +// ============================================================================ + +export type GovernanceEventType = + | 'agent.promoted' + | 'agent.demoted' + | 'agent.revoked' + | 'agent.reinstated' + | 'agent.assigned' + | 'agent.unassigned' + | 'permission.granted' + | 'permission.revoked' + | 'incident.created' + | 'incident.assigned' + | 'incident.escalated' + | 'incident.resolved' + | 'incident.closed' + | 'microdao.created' + | 'district.created' + | 'node.registered' + | 'room.created' + | 'room.published_to_city'; + +export interface GovernanceEvent { + id: string; + eventType: GovernanceEventType; + subject: string; + actorId: string; + targetId: string; + scope: GovernanceScope; + payload: Record; + version: string; + status: 'pending' | 'published' | 'failed'; + createdAt: Date; + publishedAt?: Date; +} + +export interface AuditEventFilter { + eventType?: GovernanceEventType; + actorId?: string; + targetId?: string; + scope?: GovernanceScope; + createdAtFrom?: Date; + createdAtTo?: Date; + limit?: number; + offset?: number; +} + +// ============================================================================ +// GOVERNANCE CONTEXT +// ============================================================================ + +export interface GovernanceContext { + actorDaisId: string; + actorAgentId: string; + actorLevel: AgentGovLevel; + actorPowers: GovernancePower[]; + currentScope: GovernanceScope; + microdaoId?: string; + districtId?: string; +} + +export interface CanDoResult { + allowed: boolean; + reason?: string; + requiredLevel?: AgentGovLevel; + requiredPower?: GovernancePower; +} + diff --git a/backend/http/audit.routes.ts b/backend/http/audit.routes.ts new file mode 100644 index 00000000..7cad462b --- /dev/null +++ b/backend/http/audit.routes.ts @@ -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; + diff --git a/backend/http/governance.routes.ts b/backend/http/governance.routes.ts new file mode 100644 index 00000000..ec4a94ac --- /dev/null +++ b/backend/http/governance.routes.ts @@ -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; + diff --git a/backend/http/incidents.routes.ts b/backend/http/incidents.routes.ts new file mode 100644 index 00000000..961cc223 --- /dev/null +++ b/backend/http/incidents.routes.ts @@ -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; + diff --git a/backend/services/governance/audit.service.ts b/backend/services/governance/audit.service.ts new file mode 100644 index 00000000..dfaf372e --- /dev/null +++ b/backend/services/governance/audit.service.ts @@ -0,0 +1,291 @@ +/** + * Audit Service + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Provides access to governance events for audit and analysis + */ + +import { db } from '../../infra/db/client'; +import { logger } from '../../infra/logger/logger'; +import { + GovernanceEvent, + GovernanceEventType, + AuditEventFilter, + GovernanceScope, +} from '../../domain/governance/types'; + +export interface AuditEventRow { + id: string; + event_type: GovernanceEventType; + subject: string; + actor_id: string; + target_id: string; + scope: GovernanceScope; + payload: Record; + version: string; + status: 'pending' | 'published' | 'failed'; + created_at: Date; + published_at?: Date; +} + +export class AuditService { + /** + * Get audit events with filters + */ + async getEvents(filter: AuditEventFilter): Promise<{ + events: GovernanceEvent[]; + total: number; + }> { + const conditions: string[] = []; + const params: (string | Date | number)[] = []; + let paramIndex = 1; + + if (filter.eventType) { + conditions.push(`event_type = $${paramIndex++}`); + params.push(filter.eventType); + } + + if (filter.actorId) { + conditions.push(`actor_id = $${paramIndex++}`); + params.push(filter.actorId); + } + + if (filter.targetId) { + conditions.push(`target_id = $${paramIndex++}`); + params.push(filter.targetId); + } + + if (filter.scope) { + conditions.push(`scope = $${paramIndex++}`); + params.push(filter.scope); + } + + if (filter.createdAtFrom) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(filter.createdAtFrom); + } + + if (filter.createdAtTo) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(filter.createdAtTo); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM event_outbox ${whereClause}`, + params + ); + + const total = parseInt(countResult.rows[0].count, 10); + + // Get events + const limit = filter.limit || 50; + const offset = filter.offset || 0; + + params.push(limit, offset); + + const result = await db.query( + `SELECT id, event_type, subject, actor_id, target_id, scope, payload, version, status, created_at, published_at + FROM event_outbox + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + params + ); + + const events: GovernanceEvent[] = result.rows.map(row => ({ + id: row.id, + eventType: row.event_type, + subject: row.subject, + actorId: row.actor_id, + targetId: row.target_id, + scope: row.scope, + payload: row.payload, + version: row.version, + status: row.status, + createdAt: row.created_at, + publishedAt: row.published_at, + })); + + return { events, total }; + } + + /** + * Get single event by ID + */ + async getEvent(eventId: string): Promise { + const result = await db.query( + `SELECT id, event_type, subject, actor_id, target_id, scope, payload, version, status, created_at, published_at + FROM event_outbox + WHERE id = $1`, + [eventId] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + return { + id: row.id, + eventType: row.event_type, + subject: row.subject, + actorId: row.actor_id, + targetId: row.target_id, + scope: row.scope, + payload: row.payload, + version: row.version, + status: row.status, + createdAt: row.created_at, + publishedAt: row.published_at, + }; + } + + /** + * Get events by actor + */ + async getEventsByActor(actorId: string, limit = 50): Promise { + const result = await this.getEvents({ actorId, limit }); + return result.events; + } + + /** + * Get events by target + */ + async getEventsByTarget(targetId: string, limit = 50): Promise { + const result = await this.getEvents({ targetId, limit }); + return result.events; + } + + /** + * Get events by scope + */ + async getEventsByScope(scope: GovernanceScope, limit = 50): Promise { + const result = await this.getEvents({ scope, limit }); + return result.events; + } + + /** + * Get event statistics + */ + async getEventStats( + fromDate?: Date, + toDate?: Date + ): Promise<{ + totalEvents: number; + eventsByType: Record; + eventsByDay: Array<{ date: string; count: number }>; + topActors: Array<{ actorId: string; count: number }>; + }> { + const conditions: string[] = []; + const params: (string | Date)[] = []; + let paramIndex = 1; + + if (fromDate) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(fromDate); + } + + if (toDate) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(toDate); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Total events + const totalResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM event_outbox ${whereClause}`, + params + ); + + const totalEvents = parseInt(totalResult.rows[0].count, 10); + + // Events by type + const byTypeResult = await db.query<{ event_type: string; count: string }>( + `SELECT event_type, COUNT(*) as count + FROM event_outbox ${whereClause} + GROUP BY event_type`, + params + ); + + const eventsByType: Record = {}; + byTypeResult.rows.forEach(row => { + eventsByType[row.event_type] = parseInt(row.count, 10); + }); + + // Events by day + const byDayResult = await db.query<{ date: string; count: string }>( + `SELECT DATE(created_at) as date, COUNT(*) as count + FROM event_outbox ${whereClause} + GROUP BY DATE(created_at) + ORDER BY date DESC + LIMIT 30`, + params + ); + + const eventsByDay = byDayResult.rows.map(row => ({ + date: row.date, + count: parseInt(row.count, 10), + })); + + // Top actors + const topActorsResult = await db.query<{ actor_id: string; count: string }>( + `SELECT actor_id, COUNT(*) as count + FROM event_outbox ${whereClause} AND actor_id IS NOT NULL + GROUP BY actor_id + ORDER BY count DESC + LIMIT 10`, + params + ); + + const topActors = topActorsResult.rows.map(row => ({ + actorId: row.actor_id, + count: parseInt(row.count, 10), + })); + + return { + totalEvents, + eventsByType, + eventsByDay, + topActors, + }; + } + + /** + * Get governance events for specific entity + */ + async getEntityHistory( + entityType: 'agent' | 'microdao' | 'district' | 'node' | 'room', + entityId: string, + limit = 50 + ): Promise { + const result = await db.query( + `SELECT id, event_type, subject, actor_id, target_id, scope, payload, version, status, created_at, published_at + FROM event_outbox + WHERE target_id = $1 OR actor_id = $1 OR scope LIKE $2 + ORDER BY created_at DESC + LIMIT $3`, + [entityId, `%${entityId}%`, limit] + ); + + return result.rows.map(row => ({ + id: row.id, + eventType: row.event_type, + subject: row.subject, + actorId: row.actor_id, + targetId: row.target_id, + scope: row.scope, + payload: row.payload, + version: row.version, + status: row.status, + createdAt: row.created_at, + publishedAt: row.published_at, + })); + } +} + +export const auditService = new AuditService(); + diff --git a/backend/services/governance/governance.service.ts b/backend/services/governance/governance.service.ts new file mode 100644 index 00000000..b91da418 --- /dev/null +++ b/backend/services/governance/governance.service.ts @@ -0,0 +1,316 @@ +/** + * Governance Service + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Handles agent promotion, demotion, and role management + */ + +import { db } from '../../infra/db/client'; +import { logger } from '../../infra/logger/logger'; +import { v4 as uuidv4 } from 'uuid'; +import { + AgentGovLevel, + AgentStatus, + PromoteAgentRequest, + GovernanceEvent, + GovernanceEventType, + GovernanceScope, + GOV_LEVEL_TO_NUM, +} from '../../domain/governance/types'; +import { permissionEngine, buildContext } from './permissions'; + +export class GovernanceService { + /** + * Promote an agent to a new level + */ + async promoteAgent(request: PromoteAgentRequest): Promise<{ success: boolean; error?: string }> { + const context = await buildContext(request.actorId, request.scope); + + // Check permission + const canPromote = await permissionEngine.canPromoteAgent( + context, + request.targetId, + request.newLevel + ); + + if (!canPromote.allowed) { + logger.warn(`Promotion denied: ${request.actorId} → ${request.targetId}`, { + reason: canPromote.reason, + }); + return { success: false, error: canPromote.reason }; + } + + try { + // Get current level for event + const currentLevel = await permissionEngine.getAgentLevel(request.targetId); + + // Update agent level + await db.query( + `UPDATE agents + SET gov_level = $1, updated_at = now() + WHERE id = $2`, + [request.newLevel, request.targetId] + ); + + // If promoting to orchestrator, update DAIS trust level + if (request.newLevel === 'orchestrator' || request.newLevel === 'district_lead' || request.newLevel === 'city_governance') { + await db.query( + `UPDATE dais_identities + SET trust_level = 'orchestrator', updated_at = now() + WHERE id = (SELECT dais_identity_id FROM agents WHERE id = $1)`, + [request.targetId] + ); + } + + // Log governance event + await this.logEvent('agent.promoted', request.actorId, request.targetId, request.scope, { + previousLevel: currentLevel, + newLevel: request.newLevel, + reason: request.reason, + }); + + logger.info(`Agent promoted: ${request.targetId} → ${request.newLevel}`, { + actorId: request.actorId, + previousLevel: currentLevel, + }); + + return { success: true }; + } catch (error) { + logger.error('Failed to promote agent', error); + throw error; + } + } + + /** + * Demote an agent to a lower level + */ + async demoteAgent( + actorId: string, + targetId: string, + newLevel: AgentGovLevel, + scope: GovernanceScope, + reason?: string + ): Promise<{ success: boolean; error?: string }> { + const context = await buildContext(actorId, scope); + const targetLevel = await permissionEngine.getAgentLevel(targetId); + const actorLevelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + const targetLevelNum = GOV_LEVEL_TO_NUM[targetLevel]; + const newLevelNum = GOV_LEVEL_TO_NUM[newLevel]; + + // Verify demotion is valid + if (targetLevelNum >= actorLevelNum) { + return { success: false, error: 'Cannot demote agent at same or higher level' }; + } + + if (newLevelNum >= targetLevelNum) { + return { success: false, error: 'New level must be lower than current level' }; + } + + if (actorLevelNum < 4) { + return { success: false, error: 'Only Core-team and above can demote agents' }; + } + + try { + await db.query( + `UPDATE agents + SET gov_level = $1, updated_at = now() + WHERE id = $2`, + [newLevel, targetId] + ); + + // Update DAIS trust level if demoted from orchestrator + if (['orchestrator', 'district_lead', 'city_governance'].includes(targetLevel)) { + await db.query( + `UPDATE dais_identities + SET trust_level = 'verified', updated_at = now() + WHERE id = (SELECT dais_identity_id FROM agents WHERE id = $1)`, + [targetId] + ); + } + + await this.logEvent('agent.demoted', actorId, targetId, scope, { + previousLevel: targetLevel, + newLevel, + reason, + }); + + logger.info(`Agent demoted: ${targetId} → ${newLevel}`, { actorId }); + + return { success: true }; + } catch (error) { + logger.error('Failed to demote agent', error); + throw error; + } + } + + /** + * Get agent roles and permissions + */ + async getAgentRoles(agentId: string): Promise<{ + level: AgentGovLevel; + status: AgentStatus; + powers: string[]; + assignments: Array<{ + microdaoId: string; + role: string; + scope: string; + }>; + }> { + const level = await permissionEngine.getAgentLevel(agentId); + const powers = permissionEngine.getPowersForLevel(level); + + const agentData = await db.query<{ status: AgentStatus }>( + `SELECT status FROM agents WHERE id = $1`, + [agentId] + ); + + const assignments = await db.query<{ + target_microdao_id: string; + role: string; + scope: string; + }>( + `SELECT target_microdao_id, role, scope + FROM agent_assignments + WHERE agent_id = $1 AND end_ts IS NULL`, + [agentId] + ); + + return { + level, + status: agentData.rows[0]?.status || 'active', + powers, + assignments: assignments.rows.map(a => ({ + microdaoId: a.target_microdao_id, + role: a.role, + scope: a.scope, + })), + }; + } + + /** + * Get agents by governance level + */ + async getAgentsByLevel(level: AgentGovLevel, limit = 50): Promise> { + const result = await db.query<{ + id: string; + name: string; + gov_level: AgentGovLevel; + status: AgentStatus; + home_microdao_id: string; + }>( + `SELECT id, name, gov_level, status, home_microdao_id + FROM agents + WHERE gov_level = $1 AND status = 'active' + ORDER BY updated_at DESC + LIMIT $2`, + [level, limit] + ); + + return result.rows.map(r => ({ + id: r.id, + name: r.name, + level: r.gov_level, + status: r.status, + homeMicrodaoId: r.home_microdao_id, + })); + } + + /** + * Get city governance agents + */ + async getCityGovernanceAgents(): Promise> { + const result = await db.query<{ + id: string; + name: string; + }>( + `SELECT id, name FROM agents + WHERE gov_level = 'city_governance' AND status = 'active'` + ); + + const roleMap: Record = { + daarwizz: 'Mayor', + dario: 'Community', + daria: 'Tech Governance', + }; + + return result.rows.map(r => ({ + id: r.id, + name: r.name, + role: roleMap[r.id] || 'City Agent', + })); + } + + /** + * Get district lead agents + */ + async getDistrictLeadAgents(districtId?: string): Promise> { + let query = ` + SELECT a.id as agent_id, a.name as agent_name, + m.id as district_id, m.name as district_name + FROM agents a + JOIN microdaos m ON m.primary_orchestrator_agent_id = a.id + WHERE m.dao_type = 'district' AND a.status = 'active' + `; + + const params: string[] = []; + + if (districtId) { + query += ` AND m.id = $1`; + params.push(districtId); + } + + const result = await db.query<{ + agent_id: string; + agent_name: string; + district_id: string; + district_name: string; + }>(query, params); + + return result.rows.map(r => ({ + agentId: r.agent_id, + agentName: r.agent_name, + districtId: r.district_id, + districtName: r.district_name, + })); + } + + /** + * Log governance event to event_outbox + */ + async logEvent( + eventType: GovernanceEventType, + actorId: string, + targetId: string, + scope: GovernanceScope, + payload: Record + ): Promise { + const eventId = uuidv4(); + const subject = `dagion.governance.${eventType}`; + + await db.query( + `INSERT INTO event_outbox (id, event_type, subject, actor_id, target_id, scope, payload, version) + VALUES ($1, $2, $3, $4, $5, $6, $7, '1.0')`, + [eventId, eventType, subject, actorId, targetId, scope, JSON.stringify(payload)] + ); + + logger.debug(`Logged governance event: ${eventType}`, { eventId, actorId, targetId }); + } +} + +export const governanceService = new GovernanceService(); + diff --git a/backend/services/governance/incidents.service.ts b/backend/services/governance/incidents.service.ts new file mode 100644 index 00000000..00109d43 --- /dev/null +++ b/backend/services/governance/incidents.service.ts @@ -0,0 +1,552 @@ +/** + * Incidents Service + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Handles incident creation, escalation, and resolution + */ + +import { db } from '../../infra/db/client'; +import { logger } from '../../infra/logger/logger'; +import { v4 as uuidv4 } from 'uuid'; +import { + Incident, + IncidentStatus, + IncidentPriority, + EscalationLevel, + TargetScopeType, + IncidentHistory, + CreateIncidentRequest, + AssignIncidentRequest, + EscalateIncidentRequest, + ResolveIncidentRequest, +} from '../../domain/governance/types'; +import { governanceService } from './governance.service'; + +interface IncidentRow { + id: string; + created_by_dais_id: string; + target_scope_type: TargetScopeType; + target_scope_id: string; + status: IncidentStatus; + priority: IncidentPriority; + assigned_to_dais_id: string | null; + escalation_level: EscalationLevel; + title: string; + description: string | null; + resolution: string | null; + metadata: Record; + created_at: Date; + updated_at: Date; + resolved_at: Date | null; + closed_at: Date | null; +} + +export class IncidentsService { + /** + * Create a new incident + */ + async createIncident(request: CreateIncidentRequest): Promise { + const id = uuidv4(); + + // Determine initial escalation level based on target scope + const escalationLevel = this.determineInitialEscalation(request.targetScopeType); + + try { + const result = await db.query( + `INSERT INTO incidents ( + id, created_by_dais_id, target_scope_type, target_scope_id, + status, priority, escalation_level, title, description, metadata + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + id, + request.createdByDaisId, + request.targetScopeType, + request.targetScopeId, + 'open', + request.priority || 'medium', + escalationLevel, + request.title, + request.description || null, + JSON.stringify(request.metadata || {}), + ] + ); + + const incident = this.mapRowToIncident(result.rows[0]); + + // Log history + await this.addHistory(id, 'created', request.createdByDaisId, null, { + title: request.title, + priority: request.priority || 'medium', + escalationLevel, + }); + + // Log governance event + await governanceService.logEvent( + 'incident.created', + request.createdByDaisId, + id, + `${request.targetScopeType}:${request.targetScopeId}`, + { title: request.title, priority: request.priority } + ); + + logger.info(`Incident created: ${id}`, { title: request.title }); + + return incident; + } catch (error) { + logger.error('Failed to create incident', error); + throw error; + } + } + + /** + * Get incident by ID + */ + async getIncident(incidentId: string): Promise { + const result = await db.query( + `SELECT * FROM incidents WHERE id = $1`, + [incidentId] + ); + + if (result.rows.length === 0) { + return null; + } + + return this.mapRowToIncident(result.rows[0]); + } + + /** + * List incidents with filters + */ + async listIncidents(filters: { + status?: IncidentStatus; + priority?: IncidentPriority; + escalationLevel?: EscalationLevel; + targetScopeType?: TargetScopeType; + targetScopeId?: string; + assignedToDaisId?: string; + limit?: number; + offset?: number; + }): Promise<{ incidents: Incident[]; total: number }> { + const conditions: string[] = []; + const params: (string | number)[] = []; + let paramIndex = 1; + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.priority) { + conditions.push(`priority = $${paramIndex++}`); + params.push(filters.priority); + } + + if (filters.escalationLevel) { + conditions.push(`escalation_level = $${paramIndex++}`); + params.push(filters.escalationLevel); + } + + if (filters.targetScopeType) { + conditions.push(`target_scope_type = $${paramIndex++}`); + params.push(filters.targetScopeType); + } + + if (filters.targetScopeId) { + conditions.push(`target_scope_id = $${paramIndex++}`); + params.push(filters.targetScopeId); + } + + if (filters.assignedToDaisId) { + conditions.push(`assigned_to_dais_id = $${paramIndex++}`); + params.push(filters.assignedToDaisId); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM incidents ${whereClause}`, + params + ); + + const total = parseInt(countResult.rows[0].count, 10); + + // Get incidents + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + params.push(limit, offset); + + const result = await db.query( + `SELECT * FROM incidents ${whereClause} + ORDER BY + CASE priority + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + END, + created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + params + ); + + const incidents = result.rows.map(row => this.mapRowToIncident(row)); + + return { incidents, total }; + } + + /** + * Assign incident to an agent + */ + async assignIncident(request: AssignIncidentRequest): Promise<{ success: boolean; error?: string }> { + try { + const incident = await this.getIncident(request.incidentId); + + if (!incident) { + return { success: false, error: 'Incident not found' }; + } + + if (incident.status === 'closed') { + return { success: false, error: 'Cannot assign closed incident' }; + } + + const oldValue = { assignedToDaisId: incident.assignedToDaisId }; + + await db.query( + `UPDATE incidents + SET assigned_to_dais_id = $1, + status = 'in_progress', + updated_at = now() + WHERE id = $2`, + [request.assignedToDaisId, request.incidentId] + ); + + await this.addHistory( + request.incidentId, + 'assigned', + request.actorDaisId, + oldValue, + { assignedToDaisId: request.assignedToDaisId } + ); + + await governanceService.logEvent( + 'incident.assigned', + request.actorDaisId, + request.incidentId, + 'city', + { assignedTo: request.assignedToDaisId } + ); + + logger.info(`Incident assigned: ${request.incidentId} → ${request.assignedToDaisId}`); + + return { success: true }; + } catch (error) { + logger.error('Failed to assign incident', error); + throw error; + } + } + + /** + * Escalate incident to higher level + */ + async escalateIncident(request: EscalateIncidentRequest): Promise<{ success: boolean; error?: string }> { + try { + const incident = await this.getIncident(request.incidentId); + + if (!incident) { + return { success: false, error: 'Incident not found' }; + } + + if (incident.status === 'closed' || incident.status === 'resolved') { + return { success: false, error: 'Cannot escalate resolved/closed incident' }; + } + + const levelOrder: EscalationLevel[] = ['microdao', 'district', 'city']; + const currentIndex = levelOrder.indexOf(incident.escalationLevel); + const newIndex = levelOrder.indexOf(request.newLevel); + + if (newIndex <= currentIndex) { + return { success: false, error: 'Can only escalate to higher level' }; + } + + const oldValue = { escalationLevel: incident.escalationLevel }; + + await db.query( + `UPDATE incidents + SET escalation_level = $1, + updated_at = now() + WHERE id = $2`, + [request.newLevel, request.incidentId] + ); + + await this.addHistory( + request.incidentId, + 'escalated', + request.actorDaisId, + oldValue, + { escalationLevel: request.newLevel, reason: request.reason } + ); + + await governanceService.logEvent( + 'incident.escalated', + request.actorDaisId, + request.incidentId, + 'city', + { + fromLevel: incident.escalationLevel, + toLevel: request.newLevel, + reason: request.reason, + } + ); + + logger.info(`Incident escalated: ${request.incidentId} → ${request.newLevel}`); + + return { success: true }; + } catch (error) { + logger.error('Failed to escalate incident', error); + throw error; + } + } + + /** + * Resolve incident + */ + async resolveIncident(request: ResolveIncidentRequest): Promise<{ success: boolean; error?: string }> { + try { + const incident = await this.getIncident(request.incidentId); + + if (!incident) { + return { success: false, error: 'Incident not found' }; + } + + if (incident.status === 'closed' || incident.status === 'resolved') { + return { success: false, error: 'Incident already resolved/closed' }; + } + + await db.query( + `UPDATE incidents + SET status = 'resolved', + resolution = $1, + resolved_at = now(), + updated_at = now() + WHERE id = $2`, + [request.resolution, request.incidentId] + ); + + await this.addHistory( + request.incidentId, + 'resolved', + request.actorDaisId, + { status: incident.status }, + { status: 'resolved', resolution: request.resolution } + ); + + await governanceService.logEvent( + 'incident.resolved', + request.actorDaisId, + request.incidentId, + 'city', + { resolution: request.resolution } + ); + + logger.info(`Incident resolved: ${request.incidentId}`); + + return { success: true }; + } catch (error) { + logger.error('Failed to resolve incident', error); + throw error; + } + } + + /** + * Close incident + */ + async closeIncident( + incidentId: string, + actorDaisId: string + ): Promise<{ success: boolean; error?: string }> { + try { + const incident = await this.getIncident(incidentId); + + if (!incident) { + return { success: false, error: 'Incident not found' }; + } + + await db.query( + `UPDATE incidents + SET status = 'closed', + closed_at = now(), + updated_at = now() + WHERE id = $1`, + [incidentId] + ); + + await this.addHistory( + incidentId, + 'closed', + actorDaisId, + { status: incident.status }, + { status: 'closed' } + ); + + await governanceService.logEvent( + 'incident.closed', + actorDaisId, + incidentId, + 'city', + {} + ); + + logger.info(`Incident closed: ${incidentId}`); + + return { success: true }; + } catch (error) { + logger.error('Failed to close incident', error); + throw error; + } + } + + /** + * Add comment to incident + */ + async addComment( + incidentId: string, + actorDaisId: string, + comment: string + ): Promise<{ success: boolean; error?: string }> { + try { + await this.addHistory(incidentId, 'comment', actorDaisId, null, null, comment); + + logger.info(`Comment added to incident: ${incidentId}`); + + return { success: true }; + } catch (error) { + logger.error('Failed to add comment to incident', error); + throw error; + } + } + + /** + * Get incident history + */ + async getIncidentHistory(incidentId: string): Promise { + const result = await db.query<{ + id: string; + incident_id: string; + action: IncidentHistory['action']; + actor_dais_id: string; + old_value: Record | null; + new_value: Record | null; + comment: string | null; + created_at: Date; + }>( + `SELECT * FROM incident_history WHERE incident_id = $1 ORDER BY created_at ASC`, + [incidentId] + ); + + return result.rows.map(row => ({ + id: row.id, + incidentId: row.incident_id, + action: row.action, + actorDaisId: row.actor_dais_id, + oldValue: row.old_value || undefined, + newValue: row.new_value || undefined, + comment: row.comment || undefined, + createdAt: row.created_at, + })); + } + + /** + * Get open incidents count by escalation level + */ + async getOpenIncidentsCount(): Promise<{ + microdao: number; + district: number; + city: number; + total: number; + }> { + const result = await db.query<{ escalation_level: EscalationLevel; count: string }>( + `SELECT escalation_level, COUNT(*) as count + FROM incidents + WHERE status IN ('open', 'in_progress') + GROUP BY escalation_level` + ); + + const counts = { microdao: 0, district: 0, city: 0, total: 0 }; + + result.rows.forEach(row => { + counts[row.escalation_level] = parseInt(row.count, 10); + counts.total += parseInt(row.count, 10); + }); + + return counts; + } + + /** + * Determine initial escalation level based on target scope + */ + private determineInitialEscalation(targetScopeType: TargetScopeType): EscalationLevel { + switch (targetScopeType) { + case 'city': + return 'city'; + case 'district': + return 'district'; + default: + return 'microdao'; + } + } + + /** + * Add entry to incident history + */ + private async addHistory( + incidentId: string, + action: IncidentHistory['action'], + actorDaisId: string, + oldValue: Record | null, + newValue: Record | null, + comment?: string + ): Promise { + await db.query( + `INSERT INTO incident_history (incident_id, action, actor_dais_id, old_value, new_value, comment) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + incidentId, + action, + actorDaisId, + oldValue ? JSON.stringify(oldValue) : null, + newValue ? JSON.stringify(newValue) : null, + comment || null, + ] + ); + } + + /** + * Map database row to Incident type + */ + private mapRowToIncident(row: IncidentRow): Incident { + return { + id: row.id, + createdByDaisId: row.created_by_dais_id, + targetScopeType: row.target_scope_type, + targetScopeId: row.target_scope_id, + status: row.status, + priority: row.priority, + assignedToDaisId: row.assigned_to_dais_id || undefined, + escalationLevel: row.escalation_level, + title: row.title, + description: row.description || undefined, + resolution: row.resolution || undefined, + metadata: row.metadata, + createdAt: row.created_at, + updatedAt: row.updated_at, + resolvedAt: row.resolved_at || undefined, + closedAt: row.closed_at || undefined, + }; + } +} + +export const incidentsService = new IncidentsService(); + diff --git a/backend/services/governance/permissions.ts b/backend/services/governance/permissions.ts new file mode 100644 index 00000000..239b2ad1 --- /dev/null +++ b/backend/services/governance/permissions.ts @@ -0,0 +1,621 @@ +/** + * Permission Engine + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Implements permission checks for all governance actions + */ + +import { db } from '../../infra/db/client'; +import { logger } from '../../infra/logger/logger'; +import { + AgentGovLevel, + AgentStatus, + GovernancePower, + GovernanceScope, + GovernanceContext, + CanDoResult, + GOV_LEVEL_TO_NUM, + POWER_MATRIX, + TargetType, + PermissionAction, +} from '../../domain/governance/types'; + +// City governance agent IDs +const CITY_AGENTS = ['daarwizz', 'dario', 'daria']; + +/** + * Get agent's governance level + */ +export async function getAgentLevel(agentId: string): Promise { + const result = await db.query<{ gov_level: AgentGovLevel; status: AgentStatus }>( + `SELECT gov_level, status FROM agents WHERE id = $1`, + [agentId] + ); + + if (result.rows.length === 0) { + return 'guest'; + } + + if (result.rows[0].status === 'revoked') { + return 'guest'; + } + + return result.rows[0].gov_level || 'personal'; +} + +/** + * Get powers for a governance level + */ +export function getPowersForLevel(level: AgentGovLevel): GovernancePower[] { + const entry = POWER_MATRIX.find(p => p.level === level); + return entry?.powers || []; +} + +/** + * Build governance context for an actor + */ +export async function buildContext( + actorAgentId: string, + scope: GovernanceScope +): Promise { + const level = await getAgentLevel(actorAgentId); + const powers = getPowersForLevel(level); + + // Get DAIS ID for actor + const agent = await db.query<{ dais_identity_id: string }>( + `SELECT dais_identity_id FROM agents WHERE id = $1`, + [actorAgentId] + ); + + const daisId = agent.rows[0]?.dais_identity_id || actorAgentId; + + // Parse scope + let microdaoId: string | undefined; + let districtId: string | undefined; + + if (scope.startsWith('microdao:')) { + microdaoId = scope.replace('microdao:', ''); + } else if (scope.startsWith('district:')) { + districtId = scope.replace('district:', ''); + } + + return { + actorDaisId: daisId, + actorAgentId, + actorLevel: level, + actorPowers: powers, + currentScope: scope, + microdaoId, + districtId, + }; +} + +// ============================================================================ +// PERMISSION CHECKS +// ============================================================================ + +/** + * Check if agent can create a MicroDAO + * Only Orchestrator (Level 5+) with verified DAIS + */ +export async function canCreateMicrodao( + context: GovernanceContext +): Promise { + const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + + if (levelNum < 5) { + return { + allowed: false, + reason: 'Only Orchestrators (Level 5+) can create MicroDAO', + requiredLevel: 'orchestrator', + }; + } + + // Check DAIS trust level + const dais = await db.query<{ trust_level: string }>( + `SELECT trust_level FROM dais_identities WHERE id = $1`, + [context.actorDaisId] + ); + + if (dais.rows.length === 0 || !['orchestrator', 'operator'].includes(dais.rows[0].trust_level)) { + return { + allowed: false, + reason: 'DAIS must have orchestrator or operator trust level', + }; + } + + return { allowed: true }; +} + +/** + * Check if agent can create a District + * Only City Governance (Level 7) or approved Orchestrator + */ +export async function canCreateDistrict( + context: GovernanceContext +): Promise { + const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + + // City governance can always create + if (levelNum === 7) { + return { allowed: true }; + } + + // Orchestrator needs city approval + if (levelNum >= 5) { + // Check if there's a pending/approved district request + const approval = await db.query( + `SELECT id FROM event_outbox + WHERE event_type = 'district.creation_approved' + AND payload->>'requestedBy' = $1 + AND status = 'published' + LIMIT 1`, + [context.actorAgentId] + ); + + if (approval.rows.length > 0) { + return { allowed: true }; + } + + return { + allowed: false, + reason: 'Orchestrator needs city approval to create District', + }; + } + + return { + allowed: false, + reason: 'Only City Governance can create Districts', + requiredLevel: 'city_governance', + }; +} + +/** + * Check if agent can register a node + * Orchestrator, Core-team DevOps, Node Manager, City Infrastructure + */ +export async function canRegisterNode( + context: GovernanceContext +): Promise { + const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + + // Core-team and above can register + if (levelNum >= 4) { + return { allowed: true }; + } + + // Check for Node Manager role in assignments + const assignment = await db.query( + `SELECT id FROM agent_assignments + WHERE agent_id = $1 + AND role IN ('devops', 'node-manager') + AND end_ts IS NULL`, + [context.actorAgentId] + ); + + if (assignment.rows.length > 0) { + return { allowed: true }; + } + + return { + allowed: false, + reason: 'Only Core-team, DevOps, or Node Managers can register nodes', + requiredLevel: 'core_team', + requiredPower: 'infrastructure', + }; +} + +/** + * Check if agent can create a room + */ +export async function canCreateRoom( + context: GovernanceContext, + roomType: string +): Promise { + const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + + switch (roomType) { + case 'personal': + // Personal agents can create personal rooms + if (levelNum >= 1) return { allowed: true }; + break; + + case 'project': + // Workers can create project rooms + if (levelNum >= 3) return { allowed: true }; + break; + + case 'dao-room': + case 'dao-wide': + // Core-team can create DAO-wide rooms + if (levelNum >= 4) return { allowed: true }; + break; + + case 'front-room': + case 'portal': + // Orchestrator can create front-rooms and portals + if (levelNum >= 5) return { allowed: true }; + break; + + case 'city-room': + // City agents only + if (levelNum === 7) return { allowed: true }; + break; + + case 'district-room': + // District lead or higher + if (levelNum >= 6) return { allowed: true }; + break; + } + + return { + allowed: false, + reason: `Insufficient permissions to create ${roomType} room`, + }; +} + +/** + * Check if agent can create a front-portal in city + */ +export async function canCreateFrontPortal( + context: GovernanceContext, + targetMicrodaoId: string +): Promise { + const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + + // City agents can create any portal + if (levelNum === 7) { + return { allowed: true }; + } + + // District lead can create portals for their district + if (levelNum === 6) { + // Check if target is in their district + const district = await db.query( + `SELECT id FROM microdaos + WHERE id = $1 + AND parent_microdao_id = ( + SELECT id FROM microdaos WHERE primary_orchestrator_agent_id = $2 AND dao_type = 'district' + )`, + [targetMicrodaoId, context.actorAgentId] + ); + + if (district.rows.length > 0) { + return { allowed: true }; + } + } + + // Orchestrator can create portal for their own MicroDAO + if (levelNum === 5) { + const microdao = await db.query( + `SELECT id FROM microdaos + WHERE id = $1 AND primary_orchestrator_agent_id = $2`, + [targetMicrodaoId, context.actorAgentId] + ); + + if (microdao.rows.length > 0) { + return { allowed: true }; + } + + return { + allowed: false, + reason: 'Orchestrator can only create portal for their own MicroDAO', + }; + } + + return { + allowed: false, + reason: 'Only Orchestrators and above can create front-portals', + requiredLevel: 'orchestrator', + }; +} + +/** + * Check if actor can promote target agent + */ +export async function canPromoteAgent( + context: GovernanceContext, + targetId: string, + newLevel: AgentGovLevel +): Promise { + const actorLevelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + const newLevelNum = GOV_LEVEL_TO_NUM[newLevel]; + const targetLevel = await getAgentLevel(targetId); + const targetLevelNum = GOV_LEVEL_TO_NUM[targetLevel]; + + // Cannot promote to same or higher level than self + if (newLevelNum >= actorLevelNum) { + return { + allowed: false, + reason: 'Cannot promote agent to same or higher level than yourself', + }; + } + + // Cannot promote agent already at higher level + if (targetLevelNum >= newLevelNum) { + return { + allowed: false, + reason: 'Target agent is already at this level or higher', + }; + } + + // Only core-team and above can promote + if (actorLevelNum < 4) { + return { + allowed: false, + reason: 'Only Core-team and above can promote agents', + requiredLevel: 'core_team', + }; + } + + // Check scope - can only promote in own DAO/District + if (context.currentScope.startsWith('microdao:')) { + const microdaoId = context.currentScope.replace('microdao:', ''); + + // Check if actor is orchestrator of this DAO + const isOrchestrator = await db.query( + `SELECT id FROM microdaos + WHERE id = $1 AND primary_orchestrator_agent_id = $2`, + [microdaoId, context.actorAgentId] + ); + + // Check if target is in this DAO + const targetInDao = await db.query( + `SELECT id FROM agent_assignments + WHERE agent_id = $1 AND target_microdao_id = $2 AND end_ts IS NULL`, + [targetId, microdaoId] + ); + + if (isOrchestrator.rows.length === 0 && actorLevelNum < 6) { + return { + allowed: false, + reason: 'Only the DAO Orchestrator can promote agents in this DAO', + }; + } + + if (targetInDao.rows.length === 0 && actorLevelNum < 7) { + return { + allowed: false, + reason: 'Target agent is not a member of this DAO', + }; + } + } + + return { allowed: true }; +} + +/** + * Check if actor can revoke target agent + */ +export async function canRevokeAgent( + context: GovernanceContext, + targetId: string +): Promise { + const actorLevelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + const targetLevel = await getAgentLevel(targetId); + const targetLevelNum = GOV_LEVEL_TO_NUM[targetLevel]; + + // Cannot revoke same or higher level + if (targetLevelNum >= actorLevelNum) { + return { + allowed: false, + reason: 'Cannot revoke agent at same or higher level', + }; + } + + // Must have identity power + if (!context.actorPowers.includes('identity')) { + return { + allowed: false, + reason: 'Requires identity power to revoke agents', + requiredPower: 'identity', + }; + } + + // City governance can revoke anyone + if (actorLevelNum === 7) { + return { allowed: true }; + } + + // District lead can revoke in their district + if (actorLevelNum === 6) { + // Check if target is in actor's district + const inDistrict = await db.query( + `SELECT a.id FROM agents a + JOIN agent_assignments aa ON a.id = aa.agent_id + JOIN microdaos m ON aa.target_microdao_id = m.id + WHERE a.id = $1 + AND m.parent_microdao_id = ( + SELECT id FROM microdaos WHERE primary_orchestrator_agent_id = $2 AND dao_type = 'district' + )`, + [targetId, context.actorAgentId] + ); + + if (inDistrict.rows.length > 0) { + return { allowed: true }; + } + } + + // Orchestrator can revoke in their DAO + if (actorLevelNum === 5 && context.currentScope.startsWith('microdao:')) { + const microdaoId = context.currentScope.replace('microdao:', ''); + + const isOrchestrator = await db.query( + `SELECT id FROM microdaos + WHERE id = $1 AND primary_orchestrator_agent_id = $2`, + [microdaoId, context.actorAgentId] + ); + + const targetInDao = await db.query( + `SELECT id FROM agent_assignments + WHERE agent_id = $1 AND target_microdao_id = $2 AND end_ts IS NULL`, + [targetId, microdaoId] + ); + + if (isOrchestrator.rows.length > 0 && targetInDao.rows.length > 0) { + return { allowed: true }; + } + } + + return { + allowed: false, + reason: 'Insufficient permissions to revoke this agent', + }; +} + +/** + * Check if actor can moderate a room + */ +export async function canModerateRoom( + context: GovernanceContext, + roomId: string +): Promise { + const levelNum = GOV_LEVEL_TO_NUM[context.actorLevel]; + + // Must have moderation power + if (!context.actorPowers.includes('moderation')) { + return { + allowed: false, + reason: 'Requires moderation power', + requiredPower: 'moderation', + }; + } + + // Get room info + const room = await db.query<{ + owner_type: string; + owner_id: string; + type: string; + space_scope: string; + }>( + `SELECT owner_type, owner_id, type, space_scope FROM rooms WHERE id = $1`, + [roomId] + ); + + if (room.rows.length === 0) { + return { allowed: false, reason: 'Room not found' }; + } + + const roomData = room.rows[0]; + + // City governance can moderate any room + if (levelNum === 7) { + return { allowed: true }; + } + + // City rooms - only city agents + if (roomData.type === 'city-room') { + return { + allowed: false, + reason: 'Only City Governance can moderate city rooms', + requiredLevel: 'city_governance', + }; + } + + // District rooms - district lead or higher + if (roomData.type === 'district-room') { + if (levelNum >= 6) { + // Check if actor is lead of this district + const isLead = await db.query( + `SELECT id FROM microdaos + WHERE id = $1 AND primary_orchestrator_agent_id = $2 AND dao_type = 'district'`, + [roomData.owner_id, context.actorAgentId] + ); + + if (isLead.rows.length > 0) { + return { allowed: true }; + } + } + + return { + allowed: false, + reason: 'Only District Lead can moderate this room', + requiredLevel: 'district_lead', + }; + } + + // DAO rooms - check if actor has role in this DAO + if (roomData.space_scope === 'microdao') { + const assignment = await db.query( + `SELECT role FROM agent_assignments + WHERE agent_id = $1 AND target_microdao_id = $2 AND end_ts IS NULL`, + [context.actorAgentId, roomData.owner_id] + ); + + if (assignment.rows.length > 0) { + return { allowed: true }; + } + + return { + allowed: false, + reason: 'Must be a member of this DAO to moderate its rooms', + }; + } + + return { allowed: true }; +} + +/** + * Check explicit permission in database + */ +export async function hasExplicitPermission( + daisId: string, + targetType: TargetType, + targetId: string, + action: PermissionAction +): Promise { + const result = await db.query( + `SELECT id FROM permissions + WHERE dais_id = $1 + AND target_type = $2 + AND target_id = $3 + AND action = $4 + AND (expires_at IS NULL OR expires_at > now())`, + [daisId, targetType, targetId, action] + ); + + return result.rows.length > 0; +} + +/** + * Check if agent is a city governance agent + */ +export function isCityAgent(agentId: string): boolean { + return CITY_AGENTS.includes(agentId); +} + +/** + * Log permission check for audit + */ +export async function logPermissionCheck( + actorId: string, + action: string, + targetId: string, + result: CanDoResult +): Promise { + logger.info(`Permission check: ${actorId} → ${action} → ${targetId}: ${result.allowed ? 'ALLOWED' : 'DENIED'}`, { + actorId, + action, + targetId, + allowed: result.allowed, + reason: result.reason, + }); +} + +export const permissionEngine = { + getAgentLevel, + getPowersForLevel, + buildContext, + canCreateMicrodao, + canCreateDistrict, + canRegisterNode, + canCreateRoom, + canCreateFrontPortal, + canPromoteAgent, + canRevokeAgent, + canModerateRoom, + hasExplicitPermission, + isCityAgent, + logPermissionCheck, +}; + diff --git a/backend/services/governance/revocation.service.ts b/backend/services/governance/revocation.service.ts new file mode 100644 index 00000000..9d836ccc --- /dev/null +++ b/backend/services/governance/revocation.service.ts @@ -0,0 +1,350 @@ +/** + * Revocation Service + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Handles agent revocation, suspension, and reinstatement + */ + +import { db } from '../../infra/db/client'; +import { logger } from '../../infra/logger/logger'; +import { v4 as uuidv4 } from 'uuid'; +import { + RevokeAgentRequest, + RevocationType, + RevocationEffect, + AgentRevocation, + GovernanceScope, +} from '../../domain/governance/types'; +import { permissionEngine, buildContext } from './permissions'; +import { governanceService } from './governance.service'; + +export class RevocationService { + /** + * Revoke an agent + */ + async revokeAgent(request: RevokeAgentRequest): Promise<{ + success: boolean; + revocationId?: string; + error?: string; + }> { + const context = await buildContext(request.actorId, request.scope); + + // Check permission + const canRevoke = await permissionEngine.canRevokeAgent(context, request.targetId); + + if (!canRevoke.allowed) { + logger.warn(`Revocation denied: ${request.actorId} → ${request.targetId}`, { + reason: canRevoke.reason, + }); + return { success: false, error: canRevoke.reason }; + } + + try { + const revocationId = uuidv4(); + + // Get target agent DAIS ID + const agent = await db.query<{ dais_identity_id: string }>( + `SELECT dais_identity_id FROM agents WHERE id = $1`, + [request.targetId] + ); + + const daisId = agent.rows[0]?.dais_identity_id; + + // Determine what to revoke based on type + const effect = this.getRevocationEffect(request.revocationType); + + // Start transaction + await db.query('BEGIN'); + + try { + // Update agent status + const status = request.revocationType === 'shadow' ? 'active' : 'revoked'; + + await db.query( + `UPDATE agents + SET status = $1, + revoked_at = now(), + revoked_by = $2, + revocation_reason = $3, + revocation_type = $4, + updated_at = now() + WHERE id = $5`, + [status, request.actorId, request.reason, request.revocationType, request.targetId] + ); + + // Invalidate DAIS keys if applicable + if (effect.daisKeysInvalidated && daisId) { + await db.query( + `UPDATE dais_keys + SET revoked = true, + revoked_at = now(), + revoked_by = $1, + revoked_reason = $2 + WHERE dais_id = $3 AND revoked = false`, + [request.actorId, request.reason, daisId] + ); + } + + // Terminate assignments if applicable + if (effect.assignmentsTerminated) { + await db.query( + `UPDATE agent_assignments + SET end_ts = now() + WHERE agent_id = $1 AND end_ts IS NULL`, + [request.targetId] + ); + } + + // Create revocation record + await db.query( + `INSERT INTO agent_revocations ( + id, agent_id, dais_id, revoked_by, revocation_type, reason, scope, + keys_invalidated, wallet_disabled, room_access_revoked, + node_privileges_removed, assignments_terminated, reversible + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + [ + revocationId, + request.targetId, + daisId, + request.actorId, + request.revocationType, + request.reason, + request.scope, + effect.daisKeysInvalidated, + effect.walletSigningDisabled, + effect.roomAccessRevoked, + effect.nodePrivilegesRemoved, + effect.assignmentsTerminated, + request.revocationType !== 'hard', + ] + ); + + await db.query('COMMIT'); + + // Log governance event + await governanceService.logEvent('agent.revoked', request.actorId, request.targetId, request.scope, { + revocationId, + revocationType: request.revocationType, + reason: request.reason, + effect, + }); + + logger.info(`Agent revoked: ${request.targetId}`, { + actorId: request.actorId, + revocationType: request.revocationType, + }); + + return { success: true, revocationId }; + } catch (error) { + await db.query('ROLLBACK'); + throw error; + } + } catch (error) { + logger.error('Failed to revoke agent', error); + throw error; + } + } + + /** + * Suspend an agent temporarily + */ + async suspendAgent( + actorId: string, + targetId: string, + reason: string, + scope: GovernanceScope, + durationHours?: number + ): Promise<{ success: boolean; error?: string }> { + const context = await buildContext(actorId, scope); + + // Check permission + const canRevoke = await permissionEngine.canRevokeAgent(context, targetId); + if (!canRevoke.allowed) { + return { success: false, error: canRevoke.reason }; + } + + try { + await db.query( + `UPDATE agents + SET status = 'suspended', + revoked_at = now(), + revoked_by = $1, + revocation_reason = $2, + revocation_type = 'soft', + updated_at = now() + WHERE id = $3`, + [actorId, reason, targetId] + ); + + await governanceService.logEvent('agent.revoked', actorId, targetId, scope, { + action: 'suspended', + reason, + durationHours, + }); + + logger.info(`Agent suspended: ${targetId}`, { actorId, durationHours }); + + return { success: true }; + } catch (error) { + logger.error('Failed to suspend agent', error); + throw error; + } + } + + /** + * Reinstate a revoked/suspended agent + */ + async reinstateAgent( + actorId: string, + targetId: string, + scope: GovernanceScope, + reason?: string + ): Promise<{ success: boolean; error?: string }> { + const context = await buildContext(actorId, scope); + + // Check actor has sufficient level + if (context.actorLevel !== 'city_governance' && context.actorLevel !== 'district_lead') { + return { success: false, error: 'Only City Governance or District Lead can reinstate agents' }; + } + + try { + // Check if revocation is reversible + const revocation = await db.query<{ reversible: boolean; revocation_type: RevocationType }>( + `SELECT reversible, revocation_type FROM agent_revocations + WHERE agent_id = $1 AND reversed_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [targetId] + ); + + if (revocation.rows.length === 0) { + return { success: false, error: 'No active revocation found' }; + } + + if (!revocation.rows[0].reversible) { + return { success: false, error: 'This revocation is not reversible (hard revocation)' }; + } + + await db.query('BEGIN'); + + try { + // Restore agent status + await db.query( + `UPDATE agents + SET status = 'active', + revoked_at = NULL, + revoked_by = NULL, + revocation_reason = NULL, + revocation_type = NULL, + updated_at = now() + WHERE id = $1`, + [targetId] + ); + + // Mark revocation as reversed + await db.query( + `UPDATE agent_revocations + SET reversed_at = now(), reversed_by = $1 + WHERE agent_id = $2 AND reversed_at IS NULL`, + [actorId, targetId] + ); + + await db.query('COMMIT'); + + await governanceService.logEvent('agent.reinstated', actorId, targetId, scope, { + reason, + }); + + logger.info(`Agent reinstated: ${targetId}`, { actorId }); + + return { success: true }; + } catch (error) { + await db.query('ROLLBACK'); + throw error; + } + } catch (error) { + logger.error('Failed to reinstate agent', error); + throw error; + } + } + + /** + * Revoke DAIS keys + */ + async revokeDaisKeys( + actorId: string, + daisId: string, + reason: string + ): Promise<{ success: boolean; keysRevoked: number; error?: string }> { + try { + // Get actor level + const actorLevel = await permissionEngine.getAgentLevel(actorId); + + // Only City Governance, District Lead, or Orchestrator can revoke keys + if (!['city_governance', 'district_lead', 'orchestrator'].includes(actorLevel)) { + return { success: false, keysRevoked: 0, error: 'Insufficient permissions to revoke keys' }; + } + + const result = await db.query( + `UPDATE dais_keys + SET revoked = true, revoked_at = now(), revoked_by = $1, revoked_reason = $2 + WHERE dais_id = $3 AND revoked = false + RETURNING id`, + [actorId, reason, daisId] + ); + + logger.info(`DAIS keys revoked: ${daisId}`, { actorId, count: result.rowCount }); + + return { success: true, keysRevoked: result.rowCount || 0 }; + } catch (error) { + logger.error('Failed to revoke DAIS keys', error); + throw error; + } + } + + /** + * Get revocation history for an agent + */ + async getRevocationHistory(agentId: string): Promise { + const result = await db.query( + `SELECT * FROM agent_revocations WHERE agent_id = $1 ORDER BY created_at DESC`, + [agentId] + ); + + return result.rows; + } + + /** + * Get revocation effect based on type + */ + private getRevocationEffect(type: RevocationType): RevocationEffect { + switch (type) { + case 'hard': + return { + daisKeysInvalidated: true, + walletSigningDisabled: true, + roomAccessRevoked: true, + nodePrivilegesRemoved: true, + assignmentsTerminated: true, + }; + case 'soft': + return { + daisKeysInvalidated: false, + walletSigningDisabled: true, + roomAccessRevoked: true, + nodePrivilegesRemoved: true, + assignmentsTerminated: true, + }; + case 'shadow': + return { + daisKeysInvalidated: false, + walletSigningDisabled: false, + roomAccessRevoked: false, + nodePrivilegesRemoved: false, + assignmentsTerminated: false, + }; + } + } +} + +export const revocationService = new RevocationService(); + diff --git a/docs/tasks/TASK_PHASE_GOVERNANCE_ENGINE.md b/docs/tasks/TASK_PHASE_GOVERNANCE_ENGINE.md new file mode 100644 index 00000000..d88aa328 --- /dev/null +++ b/docs/tasks/TASK_PHASE_GOVERNANCE_ENGINE.md @@ -0,0 +1,405 @@ +# TASK_PHASE_GOVERNANCE_ENGINE.md + +## DAARION.city — Governance Engine, Revocation, Audit & Escalation (MVP) + +**Ціль:** +Реалізувати повноцінний Governance Engine згідно з: + +- `Agent_Governance_Protocol_v1.md` +- `DAIS_Layer_Architecture_v1.md` +- `Agents_Interface_Architecture_v1.md` +- `Nodes_Interface_Architecture_v1.md` +- `District_Interface_Architecture_v1.md` +- `MicroDAO_Interface_Architecture_v1.md` +- `Rooms_Layer_Architecture_v1.md` + +та підключити його до існуючих: + +- DAIS API (`/api/v1/dais/*`) +- Assignments Layer (`/api/v1/assignments/*`) +- Event Outbox (`event_outbox`) + +--- + +## 1. Загальний опис задачі + +Побудувати **Governance Engine**, який: + +1. Інтерпретує ролі та рівні агентів (guest → city governance). +2. Застосовує permissions до всіх ключових дій (create microdao, create node, create room, promote/revoke agent). +3. Надає API для: + - призначення ролей, + - промоції, + - ревокації, + - перевірки доступу. +4. Привʼязує всі governance-події до `event_outbox` для аудиту. +5. Створює базовий Governance UI: + - City Governance Panel, + - District Governance Panel, + - MicroDAO Governance Panel. +6. Додає просту систему ескалації інцидентів (tickets + escalation flow). +7. Додає Audit Dashboard (читання `event_outbox` + фільтри). + +--- + +## 2. BACKEND — Governance Engine + +### 2.1. Модель ролей і рівнів + +Орієнтуватися на `Agent_Governance_Protocol_v1.md`. + +Реалізувати enum/константи: + +- `AGENT_LEVEL_GUEST = 0` +- `AGENT_LEVEL_PERSONAL = 1` +- `AGENT_LEVEL_MEMBER = 2` +- `AGENT_LEVEL_WORKER = 3` +- `AGENT_LEVEL_CORE_TEAM = 4` +- `AGENT_LEVEL_ORCHESTRATOR = 5` +- `AGENT_LEVEL_DISTRICT_LEAD = 6` +- `AGENT_LEVEL_CITY_GOV = 7` + +Зберігати рівень: + +- або в таблиці `agents.agent_role_level`, +- або в окремій таблиці ролей (якщо вже є — узгодити). + +### 2.2. Permission Engine + +Створити модуль, умовно: + +- `governance/permissions.py` (або відповідний файл у вашому стеку) + +Функції: + +- `can_create_microdao(agent, context)` +- `can_create_district(agent, context)` +- `can_register_node(agent, context)` +- `can_create_room(agent, context)` +- `can_create_front_portal(agent, context)` +- `can_promote_agent(actor, target, scope)` +- `can_revoke_agent(actor, target, scope)` +- `can_moderate_room(actor, room)` +- `can_moderate_city_room(actor, room)` + +Усередині — логіка з `Agent_Governance_Protocol_v1.md`: + +- тільки Orchestrator може створювати MicroDAO; +- District Lead / City Governance — створюють District; +- Node Manager / Orchestrator / Core-team DevOps — реєструють ноди; +- Core-team / Orchestrator — створюють DAO-wide rooms; +- DARIO / DARIA / DAARWIZZ — міські кімнати; +- City Governance — глобальна ескалація та revocation. + +### 2.3. Governance API + +Створити ендпоїнти (REST або GraphQL — згідно існуючого стилю): + +- `POST /api/v1/governance/agent/promote` + - body: `{ actor_id, target_id, new_level, scope }` + - перевірити `can_promote_agent` + - оновити роль/рівень + - записати подію в `event_outbox` + +- `POST /api/v1/governance/agent/revoke` + - body: `{ actor_id, target_id, reason, scope }` + - перевірити `can_revoke_agent` + - позначити агента як revoked / заблокувати ключі + - записати подію `agent.revoked` в `event_outbox` + +- `POST /api/v1/governance/agent/assign` + - body: `{ actor_id, target_id, scope_type, scope_id, role }` + - перевірити, чи actor має право на assign у цьому scope + - створити/оновити запис у Assignments Layer + - подія `agent.assigned` + +Додатково (якщо ще немає): + +- `GET /api/v1/governance/agent/:id/roles` +- `GET /api/v1/governance/agent/:id/permissions` + +### 2.4. Інтеграція з Assignments Layer + +При промоції / assign: + +- створювати/оновлювати записи в `assignments` (таблиця, яка вже є в схемі). +- використовувати `scope`: + - `city` + - `district:{id}` + - `microdao:{id}` + - `node:{id}` + +Переконатися, що Assignments однозначно відповідають ролям з Governance Protocol. + +--- + +## 3. BACKEND — Revocation Engine + +### 3.1. DAIS Key Revocation + +На основі `DAIS_Layer_Architecture_v1.md`: + +- Додати метод для revocation ключів: + - позначити записи в `dais_keys` як `revoked=true` + - викидати помилки при спробі використання revoked-ключа. + +Створити API: + +- `POST /api/v1/dais/keys/revoke` + - body: `{ actor_id, dais_id, reason }` + - тільки City Governance / District Lead / Orchestrator (залежно від scope). + +### 3.2. Agent State + +Додати атрибут (якщо його ще нема) у `agents`: + +- `status` = `active | suspended | revoked` + +При revocation: + +- `status = revoked` +- заблокувати виконання критичних дій цим агентом (через Permission Engine). + +--- + +## 4. BACKEND — Audit & Event Outbox + +### 4.1. Події, які обовʼязково логувати + +Через `event_outbox`: + +- `agent.promoted` +- `agent.revoked` +- `agent.assigned` +- `microdao.created` +- `district.created` +- `node.registered` +- `room.created` +- `room.published_to_city` +- `incident.escalated` + +Створити helper: + +- `governance/log_event.py` або подібний модуль: + - `log_governance_event(type, actor_id, target_id, payload)` + +### 4.2. Audit API + +- `GET /api/v1/audit/events` + - фільтри: `type`, `actor_id`, `target_id`, `scope`, `created_at_from/to` +- `GET /api/v1/audit/events/:id` + +--- + +## 5. BACKEND — Escalation System (MVP) + +### 5.1. Таблиця `incidents` + +Створити таблицю: + +- `id` +- `created_by_dais_id` +- `target_scope_type` (`city|district|microdao|room|node|agent`) +- `target_scope_id` +- `status` (`open|in_progress|resolved|closed`) +- `priority` (`low|medium|high|critical`) +- `assigned_to_dais_id` (опціонально) +- `escalation_level` (`microdao|district|city`) +- `title` +- `description` +- `created_at` +- `updated_at` + +### 5.2. API + +- `POST /api/v1/incidents/create` +- `POST /api/v1/incidents/:id/assign` +- `POST /api/v1/incidents/:id/escalate` +- `POST /api/v1/incidents/:id/resolve` +- `GET /api/v1/incidents` +- `GET /api/v1/incidents/:id` + +Логувати у `event_outbox` події: + +- `incident.created` +- `incident.assigned` +- `incident.escalated` +- `incident.resolved` + +Ескалація: + +- MicroDAO core-team → District Lead → City Governance (DAARWIZZ/DARIA). + +--- + +## 6. FRONTEND — Governance UI + +### 6.1. City Governance Panel + +Сторінка (наприклад): + +- `/governance/city` + +Показує: + +- список city-agentів (DARIO, DARIA, DAARWIZZ, інші) +- список Districts +- список відкритих інцидентів з escalation_level=`city` +- панель дій: + - promote/revoke agent (global / city scope) + - approve new district + - view audit events (фільтр по city) + +### 6.2. District Governance Panel + +Сторінка: + +- `/governance/district/[id]` + +Показує: + +- District Lead Agent +- core-team district-рівня +- підлеглі MicroDAO +- інциденти з escalation_level=`district` +- панель: + - призначення/зняття ролей в межах district + - view audit за district scope + +### 6.3. MicroDAO Governance Panel + +Сторінка: + +- `/governance/microdao/[id]` або вкладка в `/microdao/[id]` + +Показує: + +- Orchestrator +- Core-team +- Workers / Members +- відкриті інциденти для цього DAO +- панель: + - promote/demote agents у межах DAO + - revoke (локально або з ескалацією) + - audit DAO-подій + +--- + +## 7. FRONTEND — Agent Governance Views + +У `AgentCabinet`: + +- блок "Ролі та повноваження": + - рівень агента (guest/personal/member/…) + - ролі у MicroDAO + - ролі у District + - участь на City рівні +- блок "Дії" (видимий, якщо actor має права): + - promote + - revoke + - assign + +--- + +## 8. FRONTEND — Audit Dashboard + +Сторінка: + +- `/audit` + +Функціонал: + +- список подій з `event_outbox` +- фільтри: тип, actor, target, scope, період +- детальний перегляд події +- базова візуалізація (наприклад, кількість governance-подій за період) + +--- + +## 9. FRONTEND — Incidents / Escalation UI + +Сторінки: + +- `/incidents` +- `/incidents/[id]` + +Можливості: + +- створити інцидент із будь-якого контексту (room, microdao, district, node, agent): + - кнопка "Поскаржитись / Report" +- перегляд: + - статус, + - масштаби, + - виконавець, + - історія змін, +- дії (для тих, хто має права): + - assign to agent, + - escalate, + - resolve. + +--- + +## 10. Тести та перевірка + +Cursor повинен: + +- додати unit-тести для Permission Engine, +- додати інтеграційні тести для: + - promote, + - revoke, + - assign, + - incident escalation, +- протестувати audit-лог (event_outbox) для ключових governance-дій. + +--- + +## 11. Очікуваний результат TASK-фази + +Після завершення цієї фази: + +1. **Усі ключові дії в системі підконтрольні Governance Engine.** +2. **Агенти не можуть перевищувати свої повноваження.** +3. **Є чіткі API для промоції, ревокації, призначення ролей.** +4. **Всі governance-події логуються в event_outbox.** +5. **Є UI для City/District/MicroDAO-governance.** +6. **Є Incidents/Escalation система для проблем і конфліктів.** +7. **Є Audit Dashboard для аналізу історії рішень.** + +Це створює **надійний керований фундамент** для подальшого розвитку DAARION.city. + +--- + +## 12. Checklist + +### Backend ✅ +- [x] Migration: `agents.agent_role_level`, `agents.status` fields +- [x] Migration: `incidents` table +- [x] Domain types: `governance/types.ts` +- [x] Permission Engine: `governance/permissions.ts` +- [x] Governance Service: `governance/governance.service.ts` +- [x] Revocation Service: `governance/revocation.service.ts` +- [x] Audit Service: `governance/audit.service.ts` +- [x] Incidents Service: `governance/incidents.service.ts` +- [x] Governance Routes: `governance.routes.ts` +- [x] Audit Routes: `audit.routes.ts` +- [x] Incidents Routes: `incidents.routes.ts` +- [x] Event logging helper + +### Frontend ✅ +- [x] API clients: `governance.ts`, `audit.ts`, `incidents.ts` +- [x] Types: `governance.ts` +- [x] City Governance Panel +- [x] Governance Level Badge component +- [x] Audit Dashboard +- [x] Incidents List & Detail +- [ ] District Governance Panel (TODO: окрема фаза) +- [ ] MicroDAO Governance Panel (TODO: окрема фаза) +- [ ] Agent Governance Views in AgentCabinet (TODO: інтеграція) +- [ ] Report Button component (TODO: інтеграція) + +### Tests +- [ ] Permission Engine unit tests +- [ ] Governance API integration tests +- [ ] Revocation flow tests +- [ ] Incidents escalation tests + diff --git a/migrations/032_governance_engine.sql b/migrations/032_governance_engine.sql new file mode 100644 index 00000000..aedfbcc9 --- /dev/null +++ b/migrations/032_governance_engine.sql @@ -0,0 +1,257 @@ +-- Migration 032: Governance Engine +-- Purpose: Implement Governance Layer for DAARION.city +-- Based on: docs/foundation/Agent_Governance_Protocol_v1.md +-- Date: 2025-11-29 +-- Status: MVP Feature + +-- ============================================================================ +-- 0. ENUM TYPES +-- ============================================================================ + +-- Agent governance level (0-7 hierarchy) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'agent_gov_level') THEN + CREATE TYPE agent_gov_level AS ENUM ( + 'guest', -- Level 0 + 'personal', -- Level 1 + 'member', -- Level 2 + 'worker', -- Level 3 + 'core_team', -- Level 4 + 'orchestrator', -- Level 5 + 'district_lead', -- Level 6 + 'city_governance' -- Level 7 + ); + END IF; +END $$; + +-- Agent status +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'agent_status') THEN + CREATE TYPE agent_status AS ENUM ('active', 'suspended', 'revoked'); + END IF; +END $$; + +-- Revocation type +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'revocation_type') THEN + CREATE TYPE revocation_type AS ENUM ('soft', 'hard', 'shadow'); + END IF; +END $$; + +-- Incident status +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'incident_status') THEN + CREATE TYPE incident_status AS ENUM ('open', 'in_progress', 'resolved', 'closed'); + END IF; +END $$; + +-- Incident priority +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'incident_priority') THEN + CREATE TYPE incident_priority AS ENUM ('low', 'medium', 'high', 'critical'); + END IF; +END $$; + +-- Incident escalation level +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'escalation_level') THEN + CREATE TYPE escalation_level AS ENUM ('microdao', 'district', 'city'); + END IF; +END $$; + +-- Target scope type for incidents +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'target_scope_type') THEN + CREATE TYPE target_scope_type AS ENUM ('city', 'district', 'microdao', 'room', 'node', 'agent'); + END IF; +END $$; + +-- Permission action +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN + CREATE TYPE permission_action AS ENUM ('read', 'write', 'moderate', 'admin', 'superadmin'); + END IF; +END $$; + +-- ============================================================================ +-- 1. AGENTS TABLE UPDATE - Governance Fields +-- ============================================================================ + +-- Add governance fields to agents +ALTER TABLE agents ADD COLUMN IF NOT EXISTS gov_level agent_gov_level DEFAULT 'personal'; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS status agent_status DEFAULT 'active'; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS revoked_by TEXT; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS revocation_reason TEXT; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS revocation_type revocation_type; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_agents_gov_level ON agents(gov_level); +CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status); +CREATE INDEX IF NOT EXISTS idx_agents_revoked ON agents(revoked_at) WHERE revoked_at IS NOT NULL; + +-- Migrate existing agent_role to gov_level +UPDATE agents +SET gov_level = 'orchestrator'::agent_gov_level +WHERE agent_role = 'orchestrator' AND gov_level = 'personal'; + +-- Set city governance agents +UPDATE agents +SET gov_level = 'city_governance'::agent_gov_level +WHERE id IN ('daarwizz', 'dario', 'daria'); + +-- ============================================================================ +-- 2. INCIDENTS TABLE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_dais_id TEXT NOT NULL, + target_scope_type target_scope_type NOT NULL, + target_scope_id TEXT NOT NULL, + status incident_status NOT NULL DEFAULT 'open', + priority incident_priority NOT NULL DEFAULT 'medium', + assigned_to_dais_id TEXT, + escalation_level escalation_level NOT NULL DEFAULT 'microdao', + title TEXT NOT NULL, + description TEXT, + resolution TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_incidents_status ON incidents(status); +CREATE INDEX IF NOT EXISTS idx_incidents_priority ON incidents(priority); +CREATE INDEX IF NOT EXISTS idx_incidents_escalation ON incidents(escalation_level); +CREATE INDEX IF NOT EXISTS idx_incidents_created_by ON incidents(created_by_dais_id); +CREATE INDEX IF NOT EXISTS idx_incidents_assigned ON incidents(assigned_to_dais_id); +CREATE INDEX IF NOT EXISTS idx_incidents_target ON incidents(target_scope_type, target_scope_id); +CREATE INDEX IF NOT EXISTS idx_incidents_open ON incidents(status, priority) WHERE status IN ('open', 'in_progress'); + +-- ============================================================================ +-- 3. INCIDENT HISTORY TABLE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS incident_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + action TEXT NOT NULL, -- created, assigned, escalated, resolved, closed, comment + actor_dais_id TEXT NOT NULL, + old_value JSONB, + new_value JSONB, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_incident_history_incident ON incident_history(incident_id); +CREATE INDEX IF NOT EXISTS idx_incident_history_actor ON incident_history(actor_dais_id); + +-- ============================================================================ +-- 4. PERMISSIONS TABLE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dais_id TEXT NOT NULL, + target_type TEXT NOT NULL, -- room, microdao, node, district, city + target_id TEXT NOT NULL, + action permission_action NOT NULL, + granted_by TEXT NOT NULL, + expires_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(dais_id, target_type, target_id, action) +); + +CREATE INDEX IF NOT EXISTS idx_permissions_dais ON permissions(dais_id); +CREATE INDEX IF NOT EXISTS idx_permissions_target ON permissions(target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_permissions_valid ON permissions(dais_id) + WHERE expires_at IS NULL OR expires_at > now(); + +-- ============================================================================ +-- 5. REVOCATIONS TABLE (Audit trail for revocations) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS agent_revocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id TEXT NOT NULL REFERENCES agents(id), + dais_id TEXT, + revoked_by TEXT NOT NULL, + revocation_type revocation_type NOT NULL, + reason TEXT NOT NULL, + scope TEXT NOT NULL, -- city, district:, microdao: + keys_invalidated BOOLEAN NOT NULL DEFAULT true, + wallet_disabled BOOLEAN NOT NULL DEFAULT true, + room_access_revoked BOOLEAN NOT NULL DEFAULT true, + node_privileges_removed BOOLEAN NOT NULL DEFAULT true, + assignments_terminated BOOLEAN NOT NULL DEFAULT true, + reversible BOOLEAN NOT NULL DEFAULT true, + reversed_at TIMESTAMPTZ, + reversed_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_revocations_agent ON agent_revocations(agent_id); +CREATE INDEX IF NOT EXISTS idx_revocations_dais ON agent_revocations(dais_id); +CREATE INDEX IF NOT EXISTS idx_revocations_type ON agent_revocations(revocation_type); + +-- ============================================================================ +-- 6. UPDATE EVENT_OUTBOX WITH NEW GOVERNANCE EVENTS +-- ============================================================================ + +-- Add actor_id column to event_outbox for audit +ALTER TABLE event_outbox ADD COLUMN IF NOT EXISTS actor_id TEXT; +ALTER TABLE event_outbox ADD COLUMN IF NOT EXISTS target_id TEXT; +ALTER TABLE event_outbox ADD COLUMN IF NOT EXISTS scope TEXT; + +CREATE INDEX IF NOT EXISTS idx_outbox_actor ON event_outbox(actor_id); +CREATE INDEX IF NOT EXISTS idx_outbox_target ON event_outbox(target_id); +CREATE INDEX IF NOT EXISTS idx_outbox_scope ON event_outbox(scope); + +-- ============================================================================ +-- 7. DAIS KEYS - Add revoked flag +-- ============================================================================ + +ALTER TABLE dais_keys ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT false; +ALTER TABLE dais_keys ADD COLUMN IF NOT EXISTS revoked_reason TEXT; +ALTER TABLE dais_keys ADD COLUMN IF NOT EXISTS revoked_by TEXT; + +-- Migrate existing revoked_at to revoked +UPDATE dais_keys SET revoked = true WHERE revoked_at IS NOT NULL AND revoked = false; + +-- ============================================================================ +-- 8. COMMENTS +-- ============================================================================ + +COMMENT ON TABLE incidents IS 'Incident tracking for governance escalation'; +COMMENT ON TABLE incident_history IS 'Audit trail for incident changes'; +COMMENT ON TABLE permissions IS 'Explicit permissions for DAIS identities'; +COMMENT ON TABLE agent_revocations IS 'Audit trail for agent revocations'; + +COMMENT ON COLUMN agents.gov_level IS 'Governance level: 0=guest to 7=city_governance'; +COMMENT ON COLUMN agents.status IS 'Agent status: active, suspended, or revoked'; +COMMENT ON COLUMN agents.revoked_at IS 'Timestamp when agent was revoked'; +COMMENT ON COLUMN agents.revocation_type IS 'Type of revocation: soft, hard, or shadow'; + +COMMENT ON COLUMN incidents.escalation_level IS 'Current escalation: microdao → district → city'; +COMMENT ON COLUMN incidents.target_scope_type IS 'What the incident is about: city, district, microdao, room, node, agent'; + +-- ============================================================================ +-- 9. SEED DATA - City Governance Agents +-- ============================================================================ + +-- Ensure city governance agents have correct level +UPDATE agents +SET gov_level = 'city_governance'::agent_gov_level, + status = 'active'::agent_status +WHERE id IN ('daarwizz', 'dario', 'daria'); + +-- ============================================================================ +-- DONE +-- ============================================================================ + +SELECT 'Migration 032 completed: Governance Engine' as result; + diff --git a/src/api/audit.ts b/src/api/audit.ts new file mode 100644 index 00000000..d1293150 --- /dev/null +++ b/src/api/audit.ts @@ -0,0 +1,82 @@ +/** + * Audit API Client + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + */ + +import { apiClient } from './client'; +import type { + GovernanceEvent, + GovernanceEventType, + GovernanceScope, + AuditEventFilter, + AuditStats, +} from '../types/governance'; + +// ============================================================================ +// AUDIT EVENTS +// ============================================================================ + +export async function getAuditEvents(filter: AuditEventFilter = {}): Promise<{ + events: GovernanceEvent[]; + total: number; +}> { + const response = await apiClient.get('/audit/events', { params: filter }); + return response.data; +} + +export async function getAuditEvent(eventId: string): Promise { + const response = await apiClient.get(`/audit/events/${eventId}`); + return response.data; +} + +export async function getEventsByActor(actorId: string, limit?: number): Promise { + const response = await apiClient.get(`/audit/actor/${actorId}`, { + params: limit ? { limit } : undefined, + }); + return response.data; +} + +export async function getEventsByTarget(targetId: string, limit?: number): Promise { + const response = await apiClient.get(`/audit/target/${targetId}`, { + params: limit ? { limit } : undefined, + }); + return response.data; +} + +export async function getEventsByScope(scope: GovernanceScope, limit?: number): Promise { + const response = await apiClient.get(`/audit/scope/${scope}`, { + params: limit ? { limit } : undefined, + }); + return response.data; +} + +export async function getAuditStats(fromDate?: Date, toDate?: Date): Promise { + const params: Record = {}; + if (fromDate) params.fromDate = fromDate.toISOString(); + if (toDate) params.toDate = toDate.toISOString(); + + const response = await apiClient.get('/audit/stats', { params }); + return response.data; +} + +export async function getEntityHistory( + entityType: 'agent' | 'microdao' | 'district' | 'node' | 'room', + entityId: string, + limit?: number +): Promise { + const response = await apiClient.get(`/audit/entity/${entityType}/${entityId}`, { + params: limit ? { limit } : undefined, + }); + return response.data; +} + +export const auditApi = { + getAuditEvents, + getAuditEvent, + getEventsByActor, + getEventsByTarget, + getEventsByScope, + getAuditStats, + getEntityHistory, +}; + diff --git a/src/api/governance.ts b/src/api/governance.ts new file mode 100644 index 00000000..83b0bb78 --- /dev/null +++ b/src/api/governance.ts @@ -0,0 +1,187 @@ +/** + * Governance API Client + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + */ + +import { apiClient } from './client'; +import type { + AgentGovLevel, + AgentRolesResponse, + GovernanceScope, + RevocationType, + AgentRevocation, + GovernancePower, +} from '../types/governance'; + +// ============================================================================ +// AGENT PROMOTION/DEMOTION +// ============================================================================ + +export async function promoteAgent(params: { + actorId: string; + targetId: string; + newLevel: AgentGovLevel; + scope: GovernanceScope; + reason?: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post('/governance/agent/promote', params); + return response.data; +} + +export async function demoteAgent(params: { + actorId: string; + targetId: string; + newLevel: AgentGovLevel; + scope: GovernanceScope; + reason?: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post('/governance/agent/demote', params); + return response.data; +} + +// ============================================================================ +// AGENT REVOCATION +// ============================================================================ + +export async function revokeAgent(params: { + actorId: string; + targetId: string; + reason: string; + scope: GovernanceScope; + revocationType?: RevocationType; +}): Promise<{ success: boolean; revocationId?: string; error?: string }> { + const response = await apiClient.post('/governance/agent/revoke', params); + return response.data; +} + +export async function suspendAgent(params: { + actorId: string; + targetId: string; + reason: string; + scope: GovernanceScope; + durationHours?: number; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post('/governance/agent/suspend', params); + return response.data; +} + +export async function reinstateAgent(params: { + actorId: string; + targetId: string; + scope: GovernanceScope; + reason?: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post('/governance/agent/reinstate', params); + return response.data; +} + +export async function getRevocationHistory(agentId: string): Promise { + const response = await apiClient.get(`/governance/agent/${agentId}/revocations`); + return response.data; +} + +// ============================================================================ +// AGENT ROLES & PERMISSIONS +// ============================================================================ + +export async function getAgentRoles(agentId: string): Promise { + const response = await apiClient.get(`/governance/agent/${agentId}/roles`); + return response.data; +} + +export async function getAgentPermissions( + agentId: string, + targetType?: string, + targetId?: string, + action?: string +): Promise<{ level: AgentGovLevel; powers: GovernancePower[] } | { hasPermission: boolean }> { + const params: Record = {}; + if (targetType) params.targetType = targetType; + if (targetId) params.targetId = targetId; + if (action) params.action = action; + + const response = await apiClient.get(`/governance/agent/${agentId}/permissions`, { params }); + return response.data; +} + +// ============================================================================ +// PERMISSION CHECKS +// ============================================================================ + +export async function checkPermission(params: { + actorId: string; + action: string; + targetId?: string; + scope?: GovernanceScope; + roomType?: string; +}): Promise<{ allowed: boolean; reason?: string; requiredLevel?: AgentGovLevel; requiredPower?: GovernancePower }> { + const response = await apiClient.post('/governance/check', params); + return response.data; +} + +// ============================================================================ +// GOVERNANCE AGENTS +// ============================================================================ + +export async function getCityGovernanceAgents(): Promise> { + const response = await apiClient.get('/governance/agents/city'); + return response.data; +} + +export async function getDistrictLeadAgents(districtId?: string): Promise> { + const response = await apiClient.get('/governance/agents/district-leads', { + params: districtId ? { districtId } : undefined, + }); + return response.data; +} + +export async function getAgentsByLevel( + level: AgentGovLevel, + limit?: number +): Promise> { + const response = await apiClient.get(`/governance/agents/by-level/${level}`, { + params: limit ? { limit } : undefined, + }); + return response.data; +} + +// ============================================================================ +// DAIS KEY REVOCATION +// ============================================================================ + +export async function revokeDaisKeys(params: { + actorId: string; + daisId: string; + reason: string; +}): Promise<{ success: boolean; keysRevoked?: number; error?: string }> { + const response = await apiClient.post('/governance/dais/keys/revoke', params); + return response.data; +} + +export const governanceApi = { + promoteAgent, + demoteAgent, + revokeAgent, + suspendAgent, + reinstateAgent, + getRevocationHistory, + getAgentRoles, + getAgentPermissions, + checkPermission, + getCityGovernanceAgents, + getDistrictLeadAgents, + getAgentsByLevel, + revokeDaisKeys, +}; + diff --git a/src/api/incidents.ts b/src/api/incidents.ts new file mode 100644 index 00000000..8f546ca5 --- /dev/null +++ b/src/api/incidents.ts @@ -0,0 +1,143 @@ +/** + * Incidents API Client + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + */ + +import { apiClient } from './client'; +import type { + Incident, + IncidentHistory, + IncidentStatus, + IncidentPriority, + EscalationLevel, + TargetScopeType, + IncidentsCount, +} from '../types/governance'; + +// ============================================================================ +// INCIDENTS CRUD +// ============================================================================ + +export async function createIncident(params: { + createdByDaisId: string; + targetScopeType: TargetScopeType; + targetScopeId: string; + priority?: IncidentPriority; + title: string; + description?: string; + metadata?: Record; +}): Promise { + const response = await apiClient.post('/incidents', params); + return response.data; +} + +export async function getIncident(incidentId: string): Promise { + const response = await apiClient.get(`/incidents/${incidentId}`); + return response.data; +} + +export async function listIncidents(filters: { + status?: IncidentStatus; + priority?: IncidentPriority; + escalationLevel?: EscalationLevel; + targetScopeType?: TargetScopeType; + targetScopeId?: string; + assignedToDaisId?: string; + limit?: number; + offset?: number; +} = {}): Promise<{ incidents: Incident[]; total: number }> { + const response = await apiClient.get('/incidents', { params: filters }); + return response.data; +} + +export async function getIncidentsCount(): Promise { + const response = await apiClient.get('/incidents/count'); + return response.data; +} + +// ============================================================================ +// INCIDENT ACTIONS +// ============================================================================ + +export async function assignIncident(params: { + incidentId: string; + assignedToDaisId: string; + actorDaisId: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post(`/incidents/${params.incidentId}/assign`, { + assignedToDaisId: params.assignedToDaisId, + actorDaisId: params.actorDaisId, + }); + return response.data; +} + +export async function escalateIncident(params: { + incidentId: string; + newLevel: EscalationLevel; + actorDaisId: string; + reason?: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post(`/incidents/${params.incidentId}/escalate`, { + newLevel: params.newLevel, + actorDaisId: params.actorDaisId, + reason: params.reason, + }); + return response.data; +} + +export async function resolveIncident(params: { + incidentId: string; + resolution: string; + actorDaisId: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post(`/incidents/${params.incidentId}/resolve`, { + resolution: params.resolution, + actorDaisId: params.actorDaisId, + }); + return response.data; +} + +export async function closeIncident(params: { + incidentId: string; + actorDaisId: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post(`/incidents/${params.incidentId}/close`, { + actorDaisId: params.actorDaisId, + }); + return response.data; +} + +export async function addIncidentComment(params: { + incidentId: string; + actorDaisId: string; + comment: string; +}): Promise<{ success: boolean; error?: string }> { + const response = await apiClient.post(`/incidents/${params.incidentId}/comment`, { + actorDaisId: params.actorDaisId, + comment: params.comment, + }); + return response.data; +} + +// ============================================================================ +// INCIDENT HISTORY +// ============================================================================ + +export async function getIncidentHistory(incidentId: string): Promise { + const response = await apiClient.get(`/incidents/${incidentId}/history`); + return response.data; +} + +export const incidentsApi = { + createIncident, + getIncident, + listIncidents, + getIncidentsCount, + assignIncident, + escalateIncident, + resolveIncident, + closeIncident, + addIncidentComment, + getIncidentHistory, +}; + diff --git a/src/features/governance/components/AuditDashboard.tsx b/src/features/governance/components/AuditDashboard.tsx new file mode 100644 index 00000000..8b38197c --- /dev/null +++ b/src/features/governance/components/AuditDashboard.tsx @@ -0,0 +1,307 @@ +/** + * Audit Dashboard + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Shows governance events from event_outbox with filters and statistics + */ + +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { auditApi } from '../../../api/audit'; +import type { GovernanceEvent, GovernanceEventType, AuditEventFilter } from '../../../types/governance'; + +const EVENT_TYPE_LABELS: Partial> = { + 'agent.promoted': '⬆️ Просування агента', + 'agent.demoted': '⬇️ Зниження агента', + 'agent.revoked': '🚫 Відкликання агента', + 'agent.reinstated': '✅ Відновлення агента', + 'agent.assigned': '📋 Призначення', + 'incident.created': '⚠️ Створено інцидент', + 'incident.escalated': '📈 Ескалація', + 'incident.resolved': '✅ Вирішено', + 'microdao.created': '🏢 Створено MicroDAO', + 'district.created': '🏘️ Створено District', + 'node.registered': '🖥️ Зареєстровано Node', + 'room.created': '💬 Створено Room', +}; + +export const AuditDashboard: React.FC = () => { + const [filter, setFilter] = useState({ + limit: 50, + offset: 0, + }); + + const [selectedEvent, setSelectedEvent] = useState(null); + + // Fetch events + const { data: eventsData, isLoading: loadingEvents } = useQuery({ + queryKey: ['audit', 'events', filter], + queryFn: () => auditApi.getAuditEvents(filter), + }); + + // Fetch stats + const { data: stats, isLoading: loadingStats } = useQuery({ + queryKey: ['audit', 'stats'], + queryFn: () => auditApi.getAuditStats(), + }); + + const handleFilterChange = (key: keyof AuditEventFilter, value: string | undefined) => { + setFilter(prev => ({ + ...prev, + [key]: value || undefined, + offset: 0, // Reset pagination on filter change + })); + }; + + return ( +
+ {/* Header */} +
+

+ 📊 Audit Dashboard +

+

+ Перегляд governance-подій та аудит дій +

+
+ + {/* Stats Cards */} + {stats && ( +
+ + + + sum + d.count, 0)} + icon="📅" + /> +
+ )} + + {/* Filters */} +
+
+
+ + +
+ +
+ + handleFilterChange('actorId', e.target.value)} + placeholder="Фільтр по актору..." + className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm" + /> +
+ +
+ + handleFilterChange('targetId', e.target.value)} + placeholder="Фільтр по цілі..." + className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm" + /> +
+ +
+ + handleFilterChange('scope', e.target.value)} + placeholder="city, district:..., microdao:..." + className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm" + /> +
+
+
+ + {/* Events List */} +
+ {loadingEvents ? ( +
Завантаження...
+ ) : eventsData?.events.length === 0 ? ( +
Немає подій за заданими фільтрами
+ ) : ( +
+ {eventsData?.events.map((event) => ( + setSelectedEvent(event)} + /> + ))} +
+ )} + + {/* Pagination */} + {eventsData && eventsData.total > filter.limit! && ( +
+ + + {(filter.offset || 0) + 1} - {Math.min((filter.offset || 0) + filter.limit!, eventsData.total)} з {eventsData.total} + + +
+ )} +
+ + {/* Event Detail Modal */} + {selectedEvent && ( + setSelectedEvent(null)} + /> + )} +
+ ); +}; + +// Stat Card Component +const StatCard: React.FC<{ label: string; value: number; icon: string }> = ({ label, value, icon }) => ( +
+
+ {icon} + {label} +
+
{value.toLocaleString()}
+
+); + +// Event Row Component +const EventRow: React.FC<{ event: GovernanceEvent; onClick: () => void }> = ({ event, onClick }) => { + const label = EVENT_TYPE_LABELS[event.eventType] || event.eventType; + + const statusColors = { + pending: 'bg-yellow-500/20 text-yellow-400', + published: 'bg-green-500/20 text-green-400', + failed: 'bg-red-500/20 text-red-400', + }; + + return ( +
+
+
+ {label.split(' ')[0]} +
+
{label.split(' ').slice(1).join(' ')}
+
+ {event.actorId} → {event.targetId} +
+
+
+
+ + {event.status} + + + {new Date(event.createdAt).toLocaleString('uk-UA')} + +
+
+
+ ); +}; + +// Event Detail Modal +const EventDetailModal: React.FC<{ event: GovernanceEvent; onClose: () => void }> = ({ event, onClose }) => ( +
+
+
+

Деталі події

+ +
+ +
+
+
+
ID
+
{event.id}
+
+
+
Тип
+
{event.eventType}
+
+
+
Actor
+
{event.actorId}
+
+
+
Target
+
{event.targetId}
+
+
+
Scope
+
{event.scope}
+
+
+
Статус
+
{event.status}
+
+
+
Створено
+
{new Date(event.createdAt).toLocaleString('uk-UA')}
+
+ {event.publishedAt && ( +
+
Опубліковано
+
{new Date(event.publishedAt).toLocaleString('uk-UA')}
+
+ )} +
+ +
+
Payload
+
+            {JSON.stringify(event.payload, null, 2)}
+          
+
+
+
+
+); + +export default AuditDashboard; + diff --git a/src/features/governance/components/CityGovernancePanel.tsx b/src/features/governance/components/CityGovernancePanel.tsx new file mode 100644 index 00000000..83e7bd5b --- /dev/null +++ b/src/features/governance/components/CityGovernancePanel.tsx @@ -0,0 +1,246 @@ +/** + * City Governance Panel + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Shows city-level governance: DAARWIZZ, DARIO, DARIA, districts, city incidents + */ + +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { governanceApi } from '../../../api/governance'; +import { incidentsApi } from '../../../api/incidents'; +import { GovernanceLevelBadge } from './GovernanceLevelBadge'; +import type { Incident } from '../../../types/governance'; + +interface CityGovernancePanelProps { + actorId?: string; // Current user's agent ID for actions +} + +export const CityGovernancePanel: React.FC = ({ actorId }) => { + const [activeTab, setActiveTab] = useState<'agents' | 'districts' | 'incidents'>('agents'); + + // Fetch city governance agents + const { data: cityAgents, isLoading: loadingAgents } = useQuery({ + queryKey: ['governance', 'city-agents'], + queryFn: () => governanceApi.getCityGovernanceAgents(), + }); + + // Fetch district leads + const { data: districtLeads, isLoading: loadingDistricts } = useQuery({ + queryKey: ['governance', 'district-leads'], + queryFn: () => governanceApi.getDistrictLeadAgents(), + }); + + // Fetch city-level incidents + const { data: incidentsData, isLoading: loadingIncidents } = useQuery({ + queryKey: ['incidents', 'city'], + queryFn: () => incidentsApi.listIncidents({ escalationLevel: 'city', status: 'open' }), + }); + + // Fetch incidents count + const { data: incidentsCount } = useQuery({ + queryKey: ['incidents', 'count'], + queryFn: () => incidentsApi.getIncidentsCount(), + }); + + return ( +
+ {/* Header */} +
+
+
+

+ 🏛️ City Governance +

+

+ DAARION.city — управління на рівні міста +

+
+ + {/* Incidents Counter */} + {incidentsCount && incidentsCount.city > 0 && ( +
+
{incidentsCount.city}
+
відкритих інцидентів
+
+ )} +
+
+ + {/* Tabs */} +
+ + + +
+ + {/* Content */} +
+ {/* City Agents Tab */} + {activeTab === 'agents' && ( +
+ {loadingAgents ? ( +
Завантаження...
+ ) : ( +
+ {cityAgents?.map((agent) => ( +
+
+
+ {agent.id === 'daarwizz' && '🧙'} + {agent.id === 'dario' && '👋'} + {agent.id === 'daria' && '⚙️'} +
+
+
{agent.name}
+
{agent.role}
+
+
+ +
+ ))} +
+ )} +
+ )} + + {/* Districts Tab */} + {activeTab === 'districts' && ( +
+ {loadingDistricts ? ( +
Завантаження...
+ ) : districtLeads?.length === 0 ? ( +
Немає активних дистриктів
+ ) : ( +
+ {districtLeads?.map((district) => ( +
+
+
+
{district.districtName}
+
+ Lead: {district.agentName} +
+
+ +
+
+ ))} +
+ )} +
+ )} + + {/* Incidents Tab */} + {activeTab === 'incidents' && ( +
+ {loadingIncidents ? ( +
Завантаження...
+ ) : incidentsData?.incidents.length === 0 ? ( +
+ ✅ Немає відкритих інцидентів на рівні City +
+ ) : ( +
+ {incidentsData?.incidents.map((incident: Incident) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +}; + +// Incident Card Component +const IncidentCard: React.FC<{ incident: Incident }> = ({ incident }) => { + const priorityColors = { + low: 'border-gray-500 bg-gray-500/10', + medium: 'border-yellow-500 bg-yellow-500/10', + high: 'border-orange-500 bg-orange-500/10', + critical: 'border-red-500 bg-red-500/10', + }; + + const priorityLabels = { + low: 'Низький', + medium: 'Середній', + high: 'Високий', + critical: 'Критичний', + }; + + return ( +
+
+
+
{incident.title}
+ {incident.description && ( +
+ {incident.description} +
+ )} +
+ 📍 {incident.targetScopeType}: {incident.targetScopeId} + ⏰ {new Date(incident.createdAt).toLocaleDateString('uk-UA')} +
+
+
+ + {priorityLabels[incident.priority]} + + + {incident.status === 'open' ? '🔴 Відкрито' : '🟡 В роботі'} + +
+
+
+ ); +}; + +export default CityGovernancePanel; + diff --git a/src/features/governance/components/GovernanceLevelBadge.tsx b/src/features/governance/components/GovernanceLevelBadge.tsx new file mode 100644 index 00000000..eaf436b1 --- /dev/null +++ b/src/features/governance/components/GovernanceLevelBadge.tsx @@ -0,0 +1,72 @@ +/** + * Governance Level Badge + * Displays agent governance level with color coding + */ + +import React from 'react'; +import type { AgentGovLevel, AgentStatus } from '../../../types/governance'; +import { GOV_LEVEL_LABELS, GOV_LEVEL_COLORS, AGENT_STATUS_LABELS } from '../../../types/governance'; + +interface GovernanceLevelBadgeProps { + level: AgentGovLevel; + status?: AgentStatus; + showLabel?: boolean; + size?: 'sm' | 'md' | 'lg'; +} + +const sizeClasses = { + sm: 'text-xs px-2 py-0.5', + md: 'text-sm px-3 py-1', + lg: 'text-base px-4 py-1.5', +}; + +const colorClasses: Record = { + gray: 'bg-gray-100 text-gray-700 border-gray-300', + blue: 'bg-blue-100 text-blue-700 border-blue-300', + green: 'bg-green-100 text-green-700 border-green-300', + yellow: 'bg-yellow-100 text-yellow-700 border-yellow-300', + orange: 'bg-orange-100 text-orange-700 border-orange-300', + purple: 'bg-purple-100 text-purple-700 border-purple-300', + pink: 'bg-pink-100 text-pink-700 border-pink-300', + red: 'bg-red-100 text-red-700 border-red-300', +}; + +export const GovernanceLevelBadge: React.FC = ({ + level, + status, + showLabel = true, + size = 'md', +}) => { + const color = GOV_LEVEL_COLORS[level] || 'gray'; + const label = GOV_LEVEL_LABELS[level] || level; + + const isRevoked = status === 'revoked'; + const isSuspended = status === 'suspended'; + + return ( +
+ + {showLabel && label} + + + {status && status !== 'active' && ( + + {AGENT_STATUS_LABELS[status]} + + )} +
+ ); +}; + +export default GovernanceLevelBadge; + diff --git a/src/features/governance/components/IncidentsList.tsx b/src/features/governance/components/IncidentsList.tsx new file mode 100644 index 00000000..aa95d242 --- /dev/null +++ b/src/features/governance/components/IncidentsList.tsx @@ -0,0 +1,519 @@ +/** + * Incidents List + * Based on: docs/foundation/Agent_Governance_Protocol_v1.md + * + * Shows incidents with filters and actions + */ + +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { incidentsApi } from '../../../api/incidents'; +import type { + Incident, + IncidentStatus, + IncidentPriority, + EscalationLevel, + IncidentHistory, +} from '../../../types/governance'; +import { + INCIDENT_STATUS_LABELS, + INCIDENT_PRIORITY_LABELS, + INCIDENT_PRIORITY_COLORS, + ESCALATION_LABELS, +} from '../../../types/governance'; + +interface IncidentsListProps { + defaultFilter?: { + status?: IncidentStatus; + escalationLevel?: EscalationLevel; + targetScopeId?: string; + }; + actorDaisId?: string; + showCreateButton?: boolean; +} + +export const IncidentsList: React.FC = ({ + defaultFilter = {}, + actorDaisId, + showCreateButton = false, +}) => { + const queryClient = useQueryClient(); + const [filter, setFilter] = useState({ + status: defaultFilter.status, + escalationLevel: defaultFilter.escalationLevel, + targetScopeId: defaultFilter.targetScopeId, + limit: 20, + offset: 0, + }); + + const [selectedIncident, setSelectedIncident] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + + // Fetch incidents + const { data, isLoading } = useQuery({ + queryKey: ['incidents', filter], + queryFn: () => incidentsApi.listIncidents(filter), + }); + + // Escalate mutation + const escalateMutation = useMutation({ + mutationFn: (params: { incidentId: string; newLevel: EscalationLevel }) => + incidentsApi.escalateIncident({ + ...params, + actorDaisId: actorDaisId!, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['incidents'] }); + }, + }); + + // Resolve mutation + const resolveMutation = useMutation({ + mutationFn: (params: { incidentId: string; resolution: string }) => + incidentsApi.resolveIncident({ + ...params, + actorDaisId: actorDaisId!, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['incidents'] }); + setSelectedIncident(null); + }, + }); + + return ( +
+ {/* Header */} +
+
+
+

+ ⚠️ Incidents +

+

+ Управління інцидентами та ескалація +

+
+ + {showCreateButton && actorDaisId && ( + + )} +
+
+ + {/* Filters */} +
+ + + +
+ + {/* List */} +
+ {isLoading ? ( +
Завантаження...
+ ) : data?.incidents.length === 0 ? ( +
+ ✅ Немає інцидентів за заданими фільтрами +
+ ) : ( +
+ {data?.incidents.map((incident) => ( + setSelectedIncident(incident)} + onEscalate={actorDaisId ? (level) => escalateMutation.mutate({ incidentId: incident.id, newLevel: level }) : undefined} + /> + ))} +
+ )} + + {/* Pagination */} + {data && data.total > filter.limit && ( +
+ + + {filter.offset + 1} - {Math.min(filter.offset + filter.limit, data.total)} з {data.total} + + +
+ )} +
+ + {/* Detail Modal */} + {selectedIncident && ( + setSelectedIncident(null)} + onResolve={(resolution) => resolveMutation.mutate({ incidentId: selectedIncident.id, resolution })} + /> + )} + + {/* Create Modal */} + {showCreateModal && actorDaisId && ( + setShowCreateModal(false)} + onSuccess={() => { + setShowCreateModal(false); + queryClient.invalidateQueries({ queryKey: ['incidents'] }); + }} + /> + )} +
+ ); +}; + +// Incident Card +const IncidentCard: React.FC<{ + incident: Incident; + onSelect: () => void; + onEscalate?: (level: EscalationLevel) => void; +}> = ({ incident, onSelect, onEscalate }) => { + const priorityBg = { + low: 'border-gray-500/30 bg-gray-500/10', + medium: 'border-yellow-500/30 bg-yellow-500/10', + high: 'border-orange-500/30 bg-orange-500/10', + critical: 'border-red-500/30 bg-red-500/10 animate-pulse', + }; + + const nextLevel: Record = { + microdao: 'district', + district: 'city', + city: null, + }; + + return ( +
+
+
+
+ + {INCIDENT_PRIORITY_LABELS[incident.priority]} + + + {INCIDENT_STATUS_LABELS[incident.status]} + + + {ESCALATION_LABELS[incident.escalationLevel]} + +
+ +
{incident.title}
+ + {incident.description && ( +
+ {incident.description} +
+ )} + +
+ 📍 {incident.targetScopeType}: {incident.targetScopeId} + ⏰ {new Date(incident.createdAt).toLocaleDateString('uk-UA')} + {incident.assignedToDaisId && ( + 👤 {incident.assignedToDaisId} + )} +
+
+ + {/* Actions */} + {onEscalate && nextLevel[incident.escalationLevel] && incident.status !== 'resolved' && incident.status !== 'closed' && ( + + )} +
+
+ ); +}; + +// Incident Detail Modal +const IncidentDetailModal: React.FC<{ + incident: Incident; + actorDaisId?: string; + onClose: () => void; + onResolve: (resolution: string) => void; +}> = ({ incident, actorDaisId, onClose, onResolve }) => { + const [resolution, setResolution] = useState(''); + + // Fetch history + const { data: history } = useQuery({ + queryKey: ['incident', incident.id, 'history'], + queryFn: () => incidentsApi.getIncidentHistory(incident.id), + }); + + const handleResolve = () => { + if (resolution.trim()) { + onResolve(resolution); + } + }; + + return ( +
+
+
+

Деталі інциденту

+ +
+ +
+ {/* Info */} +
+

{incident.title}

+ {incident.description && ( +

{incident.description}

+ )} +
+ + {/* Status badges */} +
+ + {INCIDENT_PRIORITY_LABELS[incident.priority]} + + + {ESCALATION_LABELS[incident.escalationLevel]} + + + {INCIDENT_STATUS_LABELS[incident.status]} + +
+ + {/* Meta */} +
+
+
Створено
+
{new Date(incident.createdAt).toLocaleString('uk-UA')}
+
+
+
Ціль
+
{incident.targetScopeType}: {incident.targetScopeId}
+
+ {incident.assignedToDaisId && ( +
+
Призначено
+
{incident.assignedToDaisId}
+
+ )} + {incident.resolution && ( +
+
Рішення
+
{incident.resolution}
+
+ )} +
+ + {/* History */} + {history && history.length > 0 && ( +
+

Історія

+
+ {history.map((h) => ( +
+
+ {h.action} + + {new Date(h.createdAt).toLocaleString('uk-UA')} + +
+ {h.comment &&
{h.comment}
} +
+ ))} +
+
+ )} + + {/* Resolve form */} + {actorDaisId && incident.status !== 'resolved' && incident.status !== 'closed' && ( +
+

Вирішити інцидент

+