feat(governance): Governance Engine MVP implementation
- Backend: - Migration 032: agent_gov_level, status, incidents, permissions tables - Domain types for governance layer - Permission Engine with all governance checks - Governance Service (promote/demote/roles) - Revocation Service (revoke/suspend/reinstate) - Audit Service (events filtering and stats) - Incidents Service (create/assign/escalate/resolve) - REST API routes for governance, audit, incidents - Frontend: - TypeScript types for governance - API clients for governance, audit, incidents - GovernanceLevelBadge component - CityGovernancePanel component - AuditDashboard component - IncidentsList component with detail modal Based on: Agent_Governance_Protocol_v1.md
This commit is contained in:
@@ -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' });
|
||||
|
||||
328
backend/domain/governance/types.ts
Normal file
328
backend/domain/governance/types.ts
Normal file
@@ -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<AgentGovLevel, AgentLevelNum> = {
|
||||
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<AgentLevelNum, AgentGovLevel> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
newValue?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
178
backend/http/audit.routes.ts
Normal file
178
backend/http/audit.routes.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Audit Routes
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { auditService } from '../services/governance/audit.service';
|
||||
import { GovernanceEventType, GovernanceScope } from '../domain/governance/types';
|
||||
import { logger } from '../infra/logger/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/events
|
||||
* Get audit events with filters
|
||||
*/
|
||||
router.get('/events', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
eventType,
|
||||
actorId,
|
||||
targetId,
|
||||
scope,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
limit,
|
||||
offset,
|
||||
} = req.query;
|
||||
|
||||
const result = await auditService.getEvents({
|
||||
eventType: eventType as GovernanceEventType | undefined,
|
||||
actorId: actorId as string | undefined,
|
||||
targetId: targetId as string | undefined,
|
||||
scope: scope as GovernanceScope | undefined,
|
||||
createdAtFrom: createdAtFrom ? new Date(createdAtFrom as string) : undefined,
|
||||
createdAtTo: createdAtTo ? new Date(createdAtTo as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||
offset: offset ? parseInt(offset as string, 10) : 0,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error getting audit events', error);
|
||||
res.status(500).json({ error: 'Failed to get audit events' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/events/:id
|
||||
* Get single audit event
|
||||
*/
|
||||
router.get('/events/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const event = await auditService.getEvent(id);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
res.json(event);
|
||||
} catch (error) {
|
||||
logger.error('Error getting audit event', error);
|
||||
res.status(500).json({ error: 'Failed to get audit event' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/actor/:actorId
|
||||
* Get events by actor
|
||||
*/
|
||||
router.get('/actor/:actorId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId } = req.params;
|
||||
const { limit } = req.query;
|
||||
|
||||
const events = await auditService.getEventsByActor(
|
||||
actorId,
|
||||
limit ? parseInt(limit as string, 10) : 50
|
||||
);
|
||||
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
logger.error('Error getting events by actor', error);
|
||||
res.status(500).json({ error: 'Failed to get events by actor' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/target/:targetId
|
||||
* Get events by target
|
||||
*/
|
||||
router.get('/target/:targetId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { targetId } = req.params;
|
||||
const { limit } = req.query;
|
||||
|
||||
const events = await auditService.getEventsByTarget(
|
||||
targetId,
|
||||
limit ? parseInt(limit as string, 10) : 50
|
||||
);
|
||||
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
logger.error('Error getting events by target', error);
|
||||
res.status(500).json({ error: 'Failed to get events by target' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/scope/:scope
|
||||
* Get events by scope
|
||||
*/
|
||||
router.get('/scope/:scope', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { scope } = req.params;
|
||||
const { limit } = req.query;
|
||||
|
||||
const events = await auditService.getEventsByScope(
|
||||
scope as GovernanceScope,
|
||||
limit ? parseInt(limit as string, 10) : 50
|
||||
);
|
||||
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
logger.error('Error getting events by scope', error);
|
||||
res.status(500).json({ error: 'Failed to get events by scope' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/stats
|
||||
* Get event statistics
|
||||
*/
|
||||
router.get('/stats', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { fromDate, toDate } = req.query;
|
||||
|
||||
const stats = await auditService.getEventStats(
|
||||
fromDate ? new Date(fromDate as string) : undefined,
|
||||
toDate ? new Date(toDate as string) : undefined
|
||||
);
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
logger.error('Error getting audit stats', error);
|
||||
res.status(500).json({ error: 'Failed to get audit stats' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/entity/:entityType/:entityId
|
||||
* Get governance history for specific entity
|
||||
*/
|
||||
router.get('/entity/:entityType/:entityId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { entityType, entityId } = req.params;
|
||||
const { limit } = req.query;
|
||||
|
||||
if (!['agent', 'microdao', 'district', 'node', 'room'].includes(entityType)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type' });
|
||||
}
|
||||
|
||||
const events = await auditService.getEntityHistory(
|
||||
entityType as 'agent' | 'microdao' | 'district' | 'node' | 'room',
|
||||
entityId,
|
||||
limit ? parseInt(limit as string, 10) : 50
|
||||
);
|
||||
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
logger.error('Error getting entity history', error);
|
||||
res.status(500).json({ error: 'Failed to get entity history' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
386
backend/http/governance.routes.ts
Normal file
386
backend/http/governance.routes.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Governance Routes
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { governanceService } from '../services/governance/governance.service';
|
||||
import { revocationService } from '../services/governance/revocation.service';
|
||||
import { permissionEngine, buildContext } from '../services/governance/permissions';
|
||||
import { AgentGovLevel, GovernanceScope, RevocationType } from '../domain/governance/types';
|
||||
import { logger } from '../infra/logger/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============================================================================
|
||||
// AGENT PROMOTION/DEMOTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/agent/promote
|
||||
* Promote an agent to a higher level
|
||||
*/
|
||||
router.post('/agent/promote', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, targetId, newLevel, scope, reason } = req.body;
|
||||
|
||||
if (!actorId || !targetId || !newLevel || !scope) {
|
||||
return res.status(400).json({ error: 'Missing required fields: actorId, targetId, newLevel, scope' });
|
||||
}
|
||||
|
||||
const result = await governanceService.promoteAgent({
|
||||
actorId,
|
||||
targetId,
|
||||
newLevel: newLevel as AgentGovLevel,
|
||||
scope: scope as GovernanceScope,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(403).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Agent ${targetId} promoted to ${newLevel}` });
|
||||
} catch (error) {
|
||||
logger.error('Error promoting agent', error);
|
||||
res.status(500).json({ error: 'Failed to promote agent' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/agent/demote
|
||||
* Demote an agent to a lower level
|
||||
*/
|
||||
router.post('/agent/demote', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, targetId, newLevel, scope, reason } = req.body;
|
||||
|
||||
if (!actorId || !targetId || !newLevel || !scope) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const result = await governanceService.demoteAgent(
|
||||
actorId,
|
||||
targetId,
|
||||
newLevel as AgentGovLevel,
|
||||
scope as GovernanceScope,
|
||||
reason
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(403).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Agent ${targetId} demoted to ${newLevel}` });
|
||||
} catch (error) {
|
||||
logger.error('Error demoting agent', error);
|
||||
res.status(500).json({ error: 'Failed to demote agent' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AGENT REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/agent/revoke
|
||||
* Revoke an agent
|
||||
*/
|
||||
router.post('/agent/revoke', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, targetId, reason, scope, revocationType } = req.body;
|
||||
|
||||
if (!actorId || !targetId || !reason || !scope) {
|
||||
return res.status(400).json({ error: 'Missing required fields: actorId, targetId, reason, scope' });
|
||||
}
|
||||
|
||||
const result = await revocationService.revokeAgent({
|
||||
actorId,
|
||||
targetId,
|
||||
reason,
|
||||
scope: scope as GovernanceScope,
|
||||
revocationType: (revocationType || 'soft') as RevocationType,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(403).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
revocationId: result.revocationId,
|
||||
message: `Agent ${targetId} revoked`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error revoking agent', error);
|
||||
res.status(500).json({ error: 'Failed to revoke agent' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/agent/suspend
|
||||
* Temporarily suspend an agent
|
||||
*/
|
||||
router.post('/agent/suspend', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, targetId, reason, scope, durationHours } = req.body;
|
||||
|
||||
if (!actorId || !targetId || !reason || !scope) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const result = await revocationService.suspendAgent(
|
||||
actorId,
|
||||
targetId,
|
||||
reason,
|
||||
scope as GovernanceScope,
|
||||
durationHours
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(403).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Agent ${targetId} suspended` });
|
||||
} catch (error) {
|
||||
logger.error('Error suspending agent', error);
|
||||
res.status(500).json({ error: 'Failed to suspend agent' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/agent/reinstate
|
||||
* Reinstate a revoked/suspended agent
|
||||
*/
|
||||
router.post('/agent/reinstate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, targetId, scope, reason } = req.body;
|
||||
|
||||
if (!actorId || !targetId || !scope) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const result = await revocationService.reinstateAgent(
|
||||
actorId,
|
||||
targetId,
|
||||
scope as GovernanceScope,
|
||||
reason
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(403).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Agent ${targetId} reinstated` });
|
||||
} catch (error) {
|
||||
logger.error('Error reinstating agent', error);
|
||||
res.status(500).json({ error: 'Failed to reinstate agent' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AGENT ROLES & PERMISSIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/v1/governance/agent/:id/roles
|
||||
* Get agent roles and permissions
|
||||
*/
|
||||
router.get('/agent/:id/roles', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const roles = await governanceService.getAgentRoles(id);
|
||||
res.json(roles);
|
||||
} catch (error) {
|
||||
logger.error('Error getting agent roles', error);
|
||||
res.status(500).json({ error: 'Failed to get agent roles' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/governance/agent/:id/permissions
|
||||
* Get agent permissions for a specific target
|
||||
*/
|
||||
router.get('/agent/:id/permissions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { targetType, targetId, action } = req.query;
|
||||
|
||||
if (targetType && targetId && action) {
|
||||
// Get DAIS ID for agent
|
||||
const context = await buildContext(id, 'city');
|
||||
const hasPermission = await permissionEngine.hasExplicitPermission(
|
||||
context.actorDaisId,
|
||||
targetType as any,
|
||||
targetId as string,
|
||||
action as any
|
||||
);
|
||||
|
||||
return res.json({ hasPermission });
|
||||
}
|
||||
|
||||
// Return general permissions
|
||||
const level = await permissionEngine.getAgentLevel(id);
|
||||
const powers = permissionEngine.getPowersForLevel(level);
|
||||
|
||||
res.json({ level, powers });
|
||||
} catch (error) {
|
||||
logger.error('Error getting agent permissions', error);
|
||||
res.status(500).json({ error: 'Failed to get agent permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PERMISSION CHECKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/check
|
||||
* Check if an agent can perform an action
|
||||
*/
|
||||
router.post('/check', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, action, targetId, scope, roomType } = req.body;
|
||||
|
||||
if (!actorId || !action) {
|
||||
return res.status(400).json({ error: 'Missing required fields: actorId, action' });
|
||||
}
|
||||
|
||||
const context = await buildContext(actorId, (scope || 'city') as GovernanceScope);
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case 'create_microdao':
|
||||
result = await permissionEngine.canCreateMicrodao(context);
|
||||
break;
|
||||
case 'create_district':
|
||||
result = await permissionEngine.canCreateDistrict(context);
|
||||
break;
|
||||
case 'register_node':
|
||||
result = await permissionEngine.canRegisterNode(context);
|
||||
break;
|
||||
case 'create_room':
|
||||
result = await permissionEngine.canCreateRoom(context, roomType || 'dao-room');
|
||||
break;
|
||||
case 'create_front_portal':
|
||||
result = await permissionEngine.canCreateFrontPortal(context, targetId);
|
||||
break;
|
||||
case 'moderate_room':
|
||||
result = await permissionEngine.canModerateRoom(context, targetId);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: `Unknown action: ${action}` });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error checking permission', error);
|
||||
res.status(500).json({ error: 'Failed to check permission' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE AGENTS LISTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/v1/governance/agents/city
|
||||
* Get city governance agents
|
||||
*/
|
||||
router.get('/agents/city', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const agents = await governanceService.getCityGovernanceAgents();
|
||||
res.json(agents);
|
||||
} catch (error) {
|
||||
logger.error('Error getting city governance agents', error);
|
||||
res.status(500).json({ error: 'Failed to get city governance agents' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/governance/agents/district-leads
|
||||
* Get district lead agents
|
||||
*/
|
||||
router.get('/agents/district-leads', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { districtId } = req.query;
|
||||
const agents = await governanceService.getDistrictLeadAgents(districtId as string);
|
||||
res.json(agents);
|
||||
} catch (error) {
|
||||
logger.error('Error getting district lead agents', error);
|
||||
res.status(500).json({ error: 'Failed to get district lead agents' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/governance/agents/by-level/:level
|
||||
* Get agents by governance level
|
||||
*/
|
||||
router.get('/agents/by-level/:level', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { level } = req.params;
|
||||
const { limit } = req.query;
|
||||
|
||||
const agents = await governanceService.getAgentsByLevel(
|
||||
level as AgentGovLevel,
|
||||
limit ? parseInt(limit as string, 10) : 50
|
||||
);
|
||||
|
||||
res.json(agents);
|
||||
} catch (error) {
|
||||
logger.error('Error getting agents by level', error);
|
||||
res.status(500).json({ error: 'Failed to get agents by level' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// DAIS KEY REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/v1/governance/dais/keys/revoke
|
||||
* Revoke DAIS keys
|
||||
*/
|
||||
router.post('/dais/keys/revoke', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { actorId, daisId, reason } = req.body;
|
||||
|
||||
if (!actorId || !daisId || !reason) {
|
||||
return res.status(400).json({ error: 'Missing required fields: actorId, daisId, reason' });
|
||||
}
|
||||
|
||||
const result = await revocationService.revokeDaisKeys(actorId, daisId, reason);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(403).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
keysRevoked: result.keysRevoked,
|
||||
message: `${result.keysRevoked} keys revoked for DAIS ${daisId}`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error revoking DAIS keys', error);
|
||||
res.status(500).json({ error: 'Failed to revoke DAIS keys' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/governance/agent/:id/revocations
|
||||
* Get revocation history for an agent
|
||||
*/
|
||||
router.get('/agent/:id/revocations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const history = await revocationService.getRevocationHistory(id);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
logger.error('Error getting revocation history', error);
|
||||
res.status(500).json({ error: 'Failed to get revocation history' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
293
backend/http/incidents.routes.ts
Normal file
293
backend/http/incidents.routes.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Incidents Routes
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { incidentsService } from '../services/governance/incidents.service';
|
||||
import {
|
||||
IncidentStatus,
|
||||
IncidentPriority,
|
||||
EscalationLevel,
|
||||
TargetScopeType,
|
||||
} from '../domain/governance/types';
|
||||
import { logger } from '../infra/logger/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/v1/incidents
|
||||
* Create a new incident
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
createdByDaisId,
|
||||
targetScopeType,
|
||||
targetScopeId,
|
||||
priority,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
} = req.body;
|
||||
|
||||
if (!createdByDaisId || !targetScopeType || !targetScopeId || !title) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: createdByDaisId, targetScopeType, targetScopeId, title',
|
||||
});
|
||||
}
|
||||
|
||||
const incident = await incidentsService.createIncident({
|
||||
createdByDaisId,
|
||||
targetScopeType: targetScopeType as TargetScopeType,
|
||||
targetScopeId,
|
||||
priority: priority as IncidentPriority | undefined,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
});
|
||||
|
||||
res.status(201).json(incident);
|
||||
} catch (error) {
|
||||
logger.error('Error creating incident', error);
|
||||
res.status(500).json({ error: 'Failed to create incident' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/incidents
|
||||
* List incidents with filters
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
status,
|
||||
priority,
|
||||
escalationLevel,
|
||||
targetScopeType,
|
||||
targetScopeId,
|
||||
assignedToDaisId,
|
||||
limit,
|
||||
offset,
|
||||
} = req.query;
|
||||
|
||||
const result = await incidentsService.listIncidents({
|
||||
status: status as IncidentStatus | undefined,
|
||||
priority: priority as IncidentPriority | undefined,
|
||||
escalationLevel: escalationLevel as EscalationLevel | undefined,
|
||||
targetScopeType: targetScopeType as TargetScopeType | undefined,
|
||||
targetScopeId: targetScopeId as string | undefined,
|
||||
assignedToDaisId: assignedToDaisId as string | undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||
offset: offset ? parseInt(offset as string, 10) : 0,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error listing incidents', error);
|
||||
res.status(500).json({ error: 'Failed to list incidents' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/incidents/count
|
||||
* Get open incidents count by level
|
||||
*/
|
||||
router.get('/count', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const counts = await incidentsService.getOpenIncidentsCount();
|
||||
res.json(counts);
|
||||
} catch (error) {
|
||||
logger.error('Error getting incidents count', error);
|
||||
res.status(500).json({ error: 'Failed to get incidents count' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/incidents/:id
|
||||
* Get incident by ID
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const incident = await incidentsService.getIncident(id);
|
||||
|
||||
if (!incident) {
|
||||
return res.status(404).json({ error: 'Incident not found' });
|
||||
}
|
||||
|
||||
res.json(incident);
|
||||
} catch (error) {
|
||||
logger.error('Error getting incident', error);
|
||||
res.status(500).json({ error: 'Failed to get incident' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/incidents/:id/history
|
||||
* Get incident history
|
||||
*/
|
||||
router.get('/:id/history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const history = await incidentsService.getIncidentHistory(id);
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
logger.error('Error getting incident history', error);
|
||||
res.status(500).json({ error: 'Failed to get incident history' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/incidents/:id/assign
|
||||
* Assign incident to an agent
|
||||
*/
|
||||
router.post('/:id/assign', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { assignedToDaisId, actorDaisId } = req.body;
|
||||
|
||||
if (!assignedToDaisId || !actorDaisId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: assignedToDaisId, actorDaisId',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await incidentsService.assignIncident({
|
||||
incidentId: id,
|
||||
assignedToDaisId,
|
||||
actorDaisId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Incident assigned' });
|
||||
} catch (error) {
|
||||
logger.error('Error assigning incident', error);
|
||||
res.status(500).json({ error: 'Failed to assign incident' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/incidents/:id/escalate
|
||||
* Escalate incident to higher level
|
||||
*/
|
||||
router.post('/:id/escalate', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { newLevel, actorDaisId, reason } = req.body;
|
||||
|
||||
if (!newLevel || !actorDaisId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: newLevel, actorDaisId',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await incidentsService.escalateIncident({
|
||||
incidentId: id,
|
||||
newLevel: newLevel as EscalationLevel,
|
||||
actorDaisId,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Incident escalated to ${newLevel}` });
|
||||
} catch (error) {
|
||||
logger.error('Error escalating incident', error);
|
||||
res.status(500).json({ error: 'Failed to escalate incident' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/incidents/:id/resolve
|
||||
* Resolve incident
|
||||
*/
|
||||
router.post('/:id/resolve', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { resolution, actorDaisId } = req.body;
|
||||
|
||||
if (!resolution || !actorDaisId) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: resolution, actorDaisId',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await incidentsService.resolveIncident({
|
||||
incidentId: id,
|
||||
resolution,
|
||||
actorDaisId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Incident resolved' });
|
||||
} catch (error) {
|
||||
logger.error('Error resolving incident', error);
|
||||
res.status(500).json({ error: 'Failed to resolve incident' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/incidents/:id/close
|
||||
* Close incident
|
||||
*/
|
||||
router.post('/:id/close', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { actorDaisId } = req.body;
|
||||
|
||||
if (!actorDaisId) {
|
||||
return res.status(400).json({ error: 'Missing required field: actorDaisId' });
|
||||
}
|
||||
|
||||
const result = await incidentsService.closeIncident(id, actorDaisId);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Incident closed' });
|
||||
} catch (error) {
|
||||
logger.error('Error closing incident', error);
|
||||
res.status(500).json({ error: 'Failed to close incident' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/incidents/:id/comment
|
||||
* Add comment to incident
|
||||
*/
|
||||
router.post('/:id/comment', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { actorDaisId, comment } = req.body;
|
||||
|
||||
if (!actorDaisId || !comment) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: actorDaisId, comment',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await incidentsService.addComment(id, actorDaisId, comment);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Comment added' });
|
||||
} catch (error) {
|
||||
logger.error('Error adding comment to incident', error);
|
||||
res.status(500).json({ error: 'Failed to add comment' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
291
backend/services/governance/audit.service.ts
Normal file
291
backend/services/governance/audit.service.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<AuditEventRow>(
|
||||
`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<GovernanceEvent | null> {
|
||||
const result = await db.query<AuditEventRow>(
|
||||
`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<GovernanceEvent[]> {
|
||||
const result = await this.getEvents({ actorId, limit });
|
||||
return result.events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by target
|
||||
*/
|
||||
async getEventsByTarget(targetId: string, limit = 50): Promise<GovernanceEvent[]> {
|
||||
const result = await this.getEvents({ targetId, limit });
|
||||
return result.events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by scope
|
||||
*/
|
||||
async getEventsByScope(scope: GovernanceScope, limit = 50): Promise<GovernanceEvent[]> {
|
||||
const result = await this.getEvents({ scope, limit });
|
||||
return result.events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event statistics
|
||||
*/
|
||||
async getEventStats(
|
||||
fromDate?: Date,
|
||||
toDate?: Date
|
||||
): Promise<{
|
||||
totalEvents: number;
|
||||
eventsByType: Record<string, number>;
|
||||
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<string, number> = {};
|
||||
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<GovernanceEvent[]> {
|
||||
const result = await db.query<AuditEventRow>(
|
||||
`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();
|
||||
|
||||
316
backend/services/governance/governance.service.ts
Normal file
316
backend/services/governance/governance.service.ts
Normal file
@@ -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<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
homeMicrodaoId?: string;
|
||||
}>> {
|
||||
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<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}>> {
|
||||
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<string, string> = {
|
||||
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<Array<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
districtId: string;
|
||||
districtName: string;
|
||||
}>> {
|
||||
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<string, unknown>
|
||||
): Promise<void> {
|
||||
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();
|
||||
|
||||
552
backend/services/governance/incidents.service.ts
Normal file
552
backend/services/governance/incidents.service.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<Incident> {
|
||||
const id = uuidv4();
|
||||
|
||||
// Determine initial escalation level based on target scope
|
||||
const escalationLevel = this.determineInitialEscalation(request.targetScopeType);
|
||||
|
||||
try {
|
||||
const result = await db.query<IncidentRow>(
|
||||
`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<Incident | null> {
|
||||
const result = await db.query<IncidentRow>(
|
||||
`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<IncidentRow>(
|
||||
`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<IncidentHistory[]> {
|
||||
const result = await db.query<{
|
||||
id: string;
|
||||
incident_id: string;
|
||||
action: IncidentHistory['action'];
|
||||
actor_dais_id: string;
|
||||
old_value: Record<string, unknown> | null;
|
||||
new_value: Record<string, unknown> | 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<string, unknown> | null,
|
||||
newValue: Record<string, unknown> | null,
|
||||
comment?: string
|
||||
): Promise<void> {
|
||||
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();
|
||||
|
||||
621
backend/services/governance/permissions.ts
Normal file
621
backend/services/governance/permissions.ts
Normal file
@@ -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<AgentGovLevel> {
|
||||
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<GovernanceContext> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<CanDoResult> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
|
||||
350
backend/services/governance/revocation.service.ts
Normal file
350
backend/services/governance/revocation.service.ts
Normal file
@@ -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<AgentRevocation[]> {
|
||||
const result = await db.query<AgentRevocation>(
|
||||
`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();
|
||||
|
||||
405
docs/tasks/TASK_PHASE_GOVERNANCE_ENGINE.md
Normal file
405
docs/tasks/TASK_PHASE_GOVERNANCE_ENGINE.md
Normal file
@@ -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
|
||||
|
||||
257
migrations/032_governance_engine.sql
Normal file
257
migrations/032_governance_engine.sql
Normal file
@@ -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:<id>, microdao:<id>
|
||||
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;
|
||||
|
||||
82
src/api/audit.ts
Normal file
82
src/api/audit.ts
Normal file
@@ -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<GovernanceEvent | null> {
|
||||
const response = await apiClient.get(`/audit/events/${eventId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEventsByActor(actorId: string, limit?: number): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/actor/${actorId}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEventsByTarget(targetId: string, limit?: number): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/target/${targetId}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getEventsByScope(scope: GovernanceScope, limit?: number): Promise<GovernanceEvent[]> {
|
||||
const response = await apiClient.get(`/audit/scope/${scope}`, {
|
||||
params: limit ? { limit } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAuditStats(fromDate?: Date, toDate?: Date): Promise<AuditStats> {
|
||||
const params: Record<string, string> = {};
|
||||
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<GovernanceEvent[]> {
|
||||
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,
|
||||
};
|
||||
|
||||
187
src/api/governance.ts
Normal file
187
src/api/governance.ts
Normal file
@@ -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<AgentRevocation[]> {
|
||||
const response = await apiClient.get(`/governance/agent/${agentId}/revocations`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGENT ROLES & PERMISSIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAgentRoles(agentId: string): Promise<AgentRolesResponse> {
|
||||
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<string, string> = {};
|
||||
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<Array<{ id: string; name: string; role: string }>> {
|
||||
const response = await apiClient.get('/governance/agents/city');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getDistrictLeadAgents(districtId?: string): Promise<Array<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
districtId: string;
|
||||
districtName: string;
|
||||
}>> {
|
||||
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<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
level: AgentGovLevel;
|
||||
status: string;
|
||||
homeMicrodaoId?: string;
|
||||
}>> {
|
||||
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,
|
||||
};
|
||||
|
||||
143
src/api/incidents.ts
Normal file
143
src/api/incidents.ts
Normal file
@@ -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<string, unknown>;
|
||||
}): Promise<Incident> {
|
||||
const response = await apiClient.post('/incidents', params);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getIncident(incidentId: string): Promise<Incident | null> {
|
||||
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<IncidentsCount> {
|
||||
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<IncidentHistory[]> {
|
||||
const response = await apiClient.get(`/incidents/${incidentId}/history`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const incidentsApi = {
|
||||
createIncident,
|
||||
getIncident,
|
||||
listIncidents,
|
||||
getIncidentsCount,
|
||||
assignIncident,
|
||||
escalateIncident,
|
||||
resolveIncident,
|
||||
closeIncident,
|
||||
addIncidentComment,
|
||||
getIncidentHistory,
|
||||
};
|
||||
|
||||
307
src/features/governance/components/AuditDashboard.tsx
Normal file
307
src/features/governance/components/AuditDashboard.tsx
Normal file
@@ -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<Record<GovernanceEventType, string>> = {
|
||||
'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<AuditEventFilter>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const [selectedEvent, setSelectedEvent] = useState<GovernanceEvent | null>(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 (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-900/50 to-cyan-900/50 px-6 py-4 border-b border-slate-700">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
📊 Audit Dashboard
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Перегляд governance-подій та аудит дій
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-6 border-b border-slate-700">
|
||||
<StatCard
|
||||
label="Всього подій"
|
||||
value={stats.totalEvents}
|
||||
icon="📈"
|
||||
/>
|
||||
<StatCard
|
||||
label="Типів подій"
|
||||
value={Object.keys(stats.eventsByType).length}
|
||||
icon="🏷️"
|
||||
/>
|
||||
<StatCard
|
||||
label="Активних учасників"
|
||||
value={stats.topActors.length}
|
||||
icon="👥"
|
||||
/>
|
||||
<StatCard
|
||||
label="Подій за 30 днів"
|
||||
value={stats.eventsByDay.reduce((sum, d) => sum + d.count, 0)}
|
||||
icon="📅"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-6 border-b border-slate-700 bg-slate-800/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Тип події</label>
|
||||
<select
|
||||
value={filter.eventType || ''}
|
||||
onChange={(e) => handleFilterChange('eventType', e.target.value as GovernanceEventType)}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">Всі типи</option>
|
||||
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
|
||||
<option key={type} value={type}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Actor ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.actorId || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Target ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.targetId || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Scope</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.scope || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="p-6">
|
||||
{loadingEvents ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : eventsData?.events.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">Немає подій за заданими фільтрами</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{eventsData?.events.map((event) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{eventsData && eventsData.total > filter.limit! && (
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: Math.max(0, (prev.offset || 0) - prev.limit!) }))}
|
||||
disabled={!filter.offset}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
← Попередні
|
||||
</button>
|
||||
<span className="px-4 py-2 text-slate-400">
|
||||
{(filter.offset || 0) + 1} - {Math.min((filter.offset || 0) + filter.limit!, eventsData.total)} з {eventsData.total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: (prev.offset || 0) + prev.limit! }))}
|
||||
disabled={(filter.offset || 0) + filter.limit! >= eventsData.total}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
Наступні →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Detail Modal */}
|
||||
{selectedEvent && (
|
||||
<EventDetailModal
|
||||
event={selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Stat Card Component
|
||||
const StatCard: React.FC<{ label: string; value: number; icon: string }> = ({ label, value, icon }) => (
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm mb-1">
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 hover:border-slate-500 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{label.split(' ')[0]}</span>
|
||||
<div>
|
||||
<div className="font-medium text-white">{label.split(' ').slice(1).join(' ')}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{event.actorId} → {event.targetId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs ${statusColors[event.status]}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(event.createdAt).toLocaleString('uk-UA')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Event Detail Modal
|
||||
const EventDetailModal: React.FC<{ event: GovernanceEvent; onClose: () => void }> = ({ event, onClose }) => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-600 max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h3 className="font-bold text-white">Деталі події</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">ID</div>
|
||||
<div className="text-white font-mono text-sm">{event.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Тип</div>
|
||||
<div className="text-white">{event.eventType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Actor</div>
|
||||
<div className="text-white">{event.actorId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Target</div>
|
||||
<div className="text-white">{event.targetId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Scope</div>
|
||||
<div className="text-white">{event.scope}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Статус</div>
|
||||
<div className="text-white">{event.status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Створено</div>
|
||||
<div className="text-white">{new Date(event.createdAt).toLocaleString('uk-UA')}</div>
|
||||
</div>
|
||||
{event.publishedAt && (
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Опубліковано</div>
|
||||
<div className="text-white">{new Date(event.publishedAt).toLocaleString('uk-UA')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-400 mb-2">Payload</div>
|
||||
<pre className="bg-slate-900 rounded-lg p-4 text-sm text-green-400 overflow-auto">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuditDashboard;
|
||||
|
||||
246
src/features/governance/components/CityGovernancePanel.tsx
Normal file
246
src/features/governance/components/CityGovernancePanel.tsx
Normal file
@@ -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<CityGovernancePanelProps> = ({ 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 (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-red-900/50 to-purple-900/50 px-6 py-4 border-b border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
🏛️ City Governance
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
DAARION.city — управління на рівні міста
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Incidents Counter */}
|
||||
{incidentsCount && incidentsCount.city > 0 && (
|
||||
<div className="bg-red-500/20 border border-red-500/50 rounded-lg px-4 py-2">
|
||||
<div className="text-2xl font-bold text-red-400">{incidentsCount.city}</div>
|
||||
<div className="text-xs text-red-300">відкритих інцидентів</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('agents')}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'agents'
|
||||
? 'text-white bg-slate-800 border-b-2 border-purple-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
👥 City Agents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('districts')}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'districts'
|
||||
? 'text-white bg-slate-800 border-b-2 border-purple-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
🏘️ Districts ({districtLeads?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('incidents')}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
activeTab === 'incidents'
|
||||
? 'text-white bg-slate-800 border-b-2 border-purple-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
⚠️ Incidents
|
||||
{incidentsCount && incidentsCount.city > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{incidentsCount.city}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* City Agents Tab */}
|
||||
{activeTab === 'agents' && (
|
||||
<div className="space-y-4">
|
||||
{loadingAgents ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{cityAgents?.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-2xl">
|
||||
{agent.id === 'daarwizz' && '🧙'}
|
||||
{agent.id === 'dario' && '👋'}
|
||||
{agent.id === 'daria' && '⚙️'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{agent.name}</div>
|
||||
<div className="text-sm text-slate-400">{agent.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<GovernanceLevelBadge level="city_governance" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Districts Tab */}
|
||||
{activeTab === 'districts' && (
|
||||
<div className="space-y-4">
|
||||
{loadingDistricts ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : districtLeads?.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">Немає активних дистриктів</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{districtLeads?.map((district) => (
|
||||
<div
|
||||
key={district.districtId}
|
||||
className="bg-slate-800/50 rounded-lg p-4 border border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-white">{district.districtName}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
Lead: {district.agentName}
|
||||
</div>
|
||||
</div>
|
||||
<GovernanceLevelBadge level="district_lead" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incidents Tab */}
|
||||
{activeTab === 'incidents' && (
|
||||
<div className="space-y-4">
|
||||
{loadingIncidents ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : incidentsData?.incidents.length === 0 ? (
|
||||
<div className="text-green-400 text-center py-8">
|
||||
✅ Немає відкритих інцидентів на рівні City
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{incidentsData?.incidents.map((incident: Incident) => (
|
||||
<IncidentCard key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className={`rounded-lg p-4 border ${priorityColors[incident.priority]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white">{incident.title}</div>
|
||||
{incident.description && (
|
||||
<div className="text-sm text-slate-400 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-slate-500">
|
||||
<span>📍 {incident.targetScopeType}: {incident.targetScopeId}</span>
|
||||
<span>⏰ {new Date(incident.createdAt).toLocaleDateString('uk-UA')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={`
|
||||
px-2 py-1 rounded text-xs font-medium
|
||||
${incident.priority === 'critical' ? 'bg-red-500 text-white' : ''}
|
||||
${incident.priority === 'high' ? 'bg-orange-500 text-white' : ''}
|
||||
${incident.priority === 'medium' ? 'bg-yellow-500 text-black' : ''}
|
||||
${incident.priority === 'low' ? 'bg-gray-500 text-white' : ''}
|
||||
`}>
|
||||
{priorityLabels[incident.priority]}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{incident.status === 'open' ? '🔴 Відкрито' : '🟡 В роботі'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CityGovernancePanel;
|
||||
|
||||
72
src/features/governance/components/GovernanceLevelBadge.tsx
Normal file
72
src/features/governance/components/GovernanceLevelBadge.tsx
Normal file
@@ -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<string, string> = {
|
||||
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<GovernanceLevelBadgeProps> = ({
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center rounded-full border font-medium
|
||||
${sizeClasses[size]}
|
||||
${isRevoked ? 'bg-red-100 text-red-700 border-red-300 line-through' : colorClasses[color]}
|
||||
${isSuspended ? 'opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
{showLabel && label}
|
||||
</span>
|
||||
|
||||
{status && status !== 'active' && (
|
||||
<span className={`
|
||||
inline-flex items-center rounded-full text-xs px-2 py-0.5 font-medium
|
||||
${isRevoked ? 'bg-red-500 text-white' : 'bg-yellow-500 text-white'}
|
||||
`}>
|
||||
{AGENT_STATUS_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GovernanceLevelBadge;
|
||||
|
||||
519
src/features/governance/components/IncidentsList.tsx
Normal file
519
src/features/governance/components/IncidentsList.tsx
Normal file
@@ -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<IncidentsListProps> = ({
|
||||
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<Incident | null>(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 (
|
||||
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-orange-900/50 to-red-900/50 px-6 py-4 border-b border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
⚠️ Incidents
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Управління інцидентами та ескалація
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCreateButton && actorDaisId && (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
+ Створити інцидент
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex flex-wrap gap-4">
|
||||
<select
|
||||
value={filter.status || ''}
|
||||
onChange={(e) => setFilter(prev => ({ ...prev, status: e.target.value as IncidentStatus || undefined, offset: 0 }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">Всі статуси</option>
|
||||
{Object.entries(INCIDENT_STATUS_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filter.escalationLevel || ''}
|
||||
onChange={(e) => setFilter(prev => ({ ...prev, escalationLevel: e.target.value as EscalationLevel || undefined, offset: 0 }))}
|
||||
className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm"
|
||||
>
|
||||
<option value="">Всі рівні</option>
|
||||
{Object.entries(ESCALATION_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-slate-400 text-center py-8">Завантаження...</div>
|
||||
) : data?.incidents.length === 0 ? (
|
||||
<div className="text-green-400 text-center py-8">
|
||||
✅ Немає інцидентів за заданими фільтрами
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data?.incidents.map((incident) => (
|
||||
<IncidentCard
|
||||
key={incident.id}
|
||||
incident={incident}
|
||||
onSelect={() => setSelectedIncident(incident)}
|
||||
onEscalate={actorDaisId ? (level) => escalateMutation.mutate({ incidentId: incident.id, newLevel: level }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total > filter.limit && (
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: Math.max(0, prev.offset - prev.limit) }))}
|
||||
disabled={!filter.offset}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
← Попередні
|
||||
</button>
|
||||
<span className="px-4 py-2 text-slate-400">
|
||||
{filter.offset + 1} - {Math.min(filter.offset + filter.limit, data.total)} з {data.total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilter(prev => ({ ...prev, offset: prev.offset + prev.limit }))}
|
||||
disabled={filter.offset + filter.limit >= data.total}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-white disabled:opacity-50"
|
||||
>
|
||||
Наступні →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedIncident && (
|
||||
<IncidentDetailModal
|
||||
incident={selectedIncident}
|
||||
actorDaisId={actorDaisId}
|
||||
onClose={() => setSelectedIncident(null)}
|
||||
onResolve={(resolution) => resolveMutation.mutate({ incidentId: selectedIncident.id, resolution })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && actorDaisId && (
|
||||
<CreateIncidentModal
|
||||
actorDaisId={actorDaisId}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['incidents'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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<EscalationLevel, EscalationLevel | null> = {
|
||||
microdao: 'district',
|
||||
district: 'city',
|
||||
city: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-4 border ${priorityBg[incident.priority]} cursor-pointer hover:brightness-110 transition-all`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1" onClick={onSelect}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded text-xs font-medium
|
||||
${incident.priority === 'critical' ? 'bg-red-500 text-white' : ''}
|
||||
${incident.priority === 'high' ? 'bg-orange-500 text-white' : ''}
|
||||
${incident.priority === 'medium' ? 'bg-yellow-500 text-black' : ''}
|
||||
${incident.priority === 'low' ? 'bg-gray-500 text-white' : ''}
|
||||
`}>
|
||||
{INCIDENT_PRIORITY_LABELS[incident.priority]}
|
||||
</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded text-xs font-medium
|
||||
${incident.status === 'open' ? 'bg-red-900/50 text-red-300' : ''}
|
||||
${incident.status === 'in_progress' ? 'bg-yellow-900/50 text-yellow-300' : ''}
|
||||
${incident.status === 'resolved' ? 'bg-green-900/50 text-green-300' : ''}
|
||||
${incident.status === 'closed' ? 'bg-gray-900/50 text-gray-300' : ''}
|
||||
`}>
|
||||
{INCIDENT_STATUS_LABELS[incident.status]}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-purple-900/50 text-purple-300">
|
||||
{ESCALATION_LABELS[incident.escalationLevel]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="font-medium text-white mt-2">{incident.title}</div>
|
||||
|
||||
{incident.description && (
|
||||
<div className="text-sm text-slate-400 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span>📍 {incident.targetScopeType}: {incident.targetScopeId}</span>
|
||||
<span>⏰ {new Date(incident.createdAt).toLocaleDateString('uk-UA')}</span>
|
||||
{incident.assignedToDaisId && (
|
||||
<span>👤 {incident.assignedToDaisId}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{onEscalate && nextLevel[incident.escalationLevel] && incident.status !== 'resolved' && incident.status !== 'closed' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEscalate(nextLevel[incident.escalationLevel]!);
|
||||
}}
|
||||
className="px-3 py-1 bg-orange-600 hover:bg-orange-500 text-white rounded text-xs font-medium"
|
||||
>
|
||||
Ескалювати → {ESCALATION_LABELS[nextLevel[incident.escalationLevel]!]}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-600 max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h3 className="font-bold text-white">Деталі інциденту</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-white text-lg">{incident.title}</h4>
|
||||
{incident.description && (
|
||||
<p className="text-slate-400 mt-2">{incident.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
incident.priority === 'critical' ? 'bg-red-500 text-white' :
|
||||
incident.priority === 'high' ? 'bg-orange-500 text-white' :
|
||||
incident.priority === 'medium' ? 'bg-yellow-500 text-black' :
|
||||
'bg-gray-500 text-white'
|
||||
}`}>
|
||||
{INCIDENT_PRIORITY_LABELS[incident.priority]}
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full text-sm bg-purple-600 text-white">
|
||||
{ESCALATION_LABELS[incident.escalationLevel]}
|
||||
</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
incident.status === 'open' ? 'bg-red-600' :
|
||||
incident.status === 'in_progress' ? 'bg-yellow-600' :
|
||||
incident.status === 'resolved' ? 'bg-green-600' :
|
||||
'bg-gray-600'
|
||||
} text-white`}>
|
||||
{INCIDENT_STATUS_LABELS[incident.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-400">Створено</div>
|
||||
<div className="text-white">{new Date(incident.createdAt).toLocaleString('uk-UA')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-400">Ціль</div>
|
||||
<div className="text-white">{incident.targetScopeType}: {incident.targetScopeId}</div>
|
||||
</div>
|
||||
{incident.assignedToDaisId && (
|
||||
<div>
|
||||
<div className="text-slate-400">Призначено</div>
|
||||
<div className="text-white">{incident.assignedToDaisId}</div>
|
||||
</div>
|
||||
)}
|
||||
{incident.resolution && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-slate-400">Рішення</div>
|
||||
<div className="text-green-400">{incident.resolution}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{history && history.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-3">Історія</h4>
|
||||
<div className="space-y-2">
|
||||
{history.map((h) => (
|
||||
<div key={h.id} className="bg-slate-700/50 rounded p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white font-medium">{h.action}</span>
|
||||
<span className="text-slate-500 text-xs">
|
||||
{new Date(h.createdAt).toLocaleString('uk-UA')}
|
||||
</span>
|
||||
</div>
|
||||
{h.comment && <div className="text-slate-400 mt-1">{h.comment}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve form */}
|
||||
{actorDaisId && incident.status !== 'resolved' && incident.status !== 'closed' && (
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<h4 className="text-sm font-medium text-slate-400 mb-2">Вирішити інцидент</h4>
|
||||
<textarea
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
placeholder="Опишіть рішення..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm min-h-[80px]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={!resolution.trim()}
|
||||
className="mt-2 px-4 py-2 bg-green-600 hover:bg-green-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
✅ Вирішити
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Incident Modal
|
||||
const CreateIncidentModal: React.FC<{
|
||||
actorDaisId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}> = ({ actorDaisId, onClose, onSuccess }) => {
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium' as IncidentPriority,
|
||||
targetScopeType: 'microdao' as const,
|
||||
targetScopeId: '',
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => incidentsApi.createIncident({
|
||||
...form,
|
||||
createdByDaisId: actorDaisId,
|
||||
}),
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-600 max-w-lg w-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||
<h3 className="font-bold text-white">Створити інцидент</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Заголовок *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Опис</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Пріоритет</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, priority: e.target.value as IncidentPriority }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
{Object.entries(INCIDENT_PRIORITY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Тип цілі</label>
|
||||
<select
|
||||
value={form.targetScopeType}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, targetScopeType: e.target.value as any }))}
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value="microdao">MicroDAO</option>
|
||||
<option value="district">District</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="room">Room</option>
|
||||
<option value="node">Node</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">ID цілі *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.targetScopeId}
|
||||
onChange={(e) => setForm(prev => ({ ...prev, targetScopeId: e.target.value }))}
|
||||
placeholder="Введіть ID..."
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.title || !form.targetScopeId || createMutation.isPending}
|
||||
className="w-full px-4 py-2 bg-red-600 hover:bg-red-500 disabled:opacity-50 text-white rounded-lg font-medium"
|
||||
>
|
||||
{createMutation.isPending ? 'Створення...' : 'Створити інцидент'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IncidentsList;
|
||||
|
||||
295
src/types/governance.ts
Normal file
295
src/types/governance.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Governance Types
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE LEVELS
|
||||
// ============================================================================
|
||||
|
||||
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_LABELS: Record<AgentGovLevel, string> = {
|
||||
guest: 'Guest',
|
||||
personal: 'Personal',
|
||||
member: 'Member',
|
||||
worker: 'Worker',
|
||||
core_team: 'Core Team',
|
||||
orchestrator: 'Orchestrator',
|
||||
district_lead: 'District Lead',
|
||||
city_governance: 'City Governance',
|
||||
};
|
||||
|
||||
export const GOV_LEVEL_COLORS: Record<AgentGovLevel, string> = {
|
||||
guest: 'gray',
|
||||
personal: 'blue',
|
||||
member: 'green',
|
||||
worker: 'yellow',
|
||||
core_team: 'orange',
|
||||
orchestrator: 'purple',
|
||||
district_lead: 'pink',
|
||||
city_governance: 'red',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AGENT STATUS
|
||||
// ============================================================================
|
||||
|
||||
export type AgentStatus = 'active' | 'suspended' | 'revoked';
|
||||
|
||||
export const AGENT_STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
active: 'Активний',
|
||||
suspended: 'Призупинено',
|
||||
revoked: 'Заблоковано',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE POWERS
|
||||
// ============================================================================
|
||||
|
||||
export type GovernancePower =
|
||||
| 'administrative'
|
||||
| 'moderation'
|
||||
| 'execution'
|
||||
| 'infrastructure'
|
||||
| 'identity'
|
||||
| 'protocol'
|
||||
| 'district';
|
||||
|
||||
export const POWER_LABELS: Record<GovernancePower, string> = {
|
||||
administrative: 'Адміністрування',
|
||||
moderation: 'Модерація',
|
||||
execution: 'Виконання',
|
||||
infrastructure: 'Інфраструктура',
|
||||
identity: 'Ідентичність',
|
||||
protocol: 'Протокол',
|
||||
district: 'Район',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE SCOPE
|
||||
// ============================================================================
|
||||
|
||||
export type GovernanceScope = 'city' | `district:${string}` | `microdao:${string}` | `node:${string}`;
|
||||
|
||||
// ============================================================================
|
||||
// REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
export type RevocationType = 'soft' | 'hard' | 'shadow';
|
||||
|
||||
export const REVOCATION_LABELS: Record<RevocationType, string> = {
|
||||
soft: 'Тимчасове',
|
||||
hard: 'Постійне',
|
||||
shadow: 'Тіньове',
|
||||
};
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
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 const INCIDENT_STATUS_LABELS: Record<IncidentStatus, string> = {
|
||||
open: 'Відкрито',
|
||||
in_progress: 'В роботі',
|
||||
resolved: 'Вирішено',
|
||||
closed: 'Закрито',
|
||||
};
|
||||
|
||||
export const INCIDENT_PRIORITY_LABELS: Record<IncidentPriority, string> = {
|
||||
low: 'Низький',
|
||||
medium: 'Середній',
|
||||
high: 'Високий',
|
||||
critical: 'Критичний',
|
||||
};
|
||||
|
||||
export const INCIDENT_PRIORITY_COLORS: Record<IncidentPriority, string> = {
|
||||
low: 'gray',
|
||||
medium: 'yellow',
|
||||
high: 'orange',
|
||||
critical: 'red',
|
||||
};
|
||||
|
||||
export const ESCALATION_LABELS: Record<EscalationLevel, string> = {
|
||||
microdao: 'MicroDAO',
|
||||
district: 'District',
|
||||
city: 'City',
|
||||
};
|
||||
|
||||
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<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
resolvedAt?: Date;
|
||||
closedAt?: Date;
|
||||
}
|
||||
|
||||
export interface IncidentHistory {
|
||||
id: string;
|
||||
incidentId: string;
|
||||
action: 'created' | 'assigned' | 'escalated' | 'resolved' | 'closed' | 'comment';
|
||||
actorDaisId: string;
|
||||
oldValue?: Record<string, unknown>;
|
||||
newValue?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
version: string;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: Date;
|
||||
publishedAt?: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API REQUESTS/RESPONSES
|
||||
// ============================================================================
|
||||
|
||||
export interface AgentRolesResponse {
|
||||
level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
powers: GovernancePower[];
|
||||
assignments: Array<{
|
||||
microdaoId: string;
|
||||
role: string;
|
||||
scope: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PromoteAgentRequest {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
newLevel: AgentGovLevel;
|
||||
scope: GovernanceScope;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RevokeAgentRequest {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
revocationType?: RevocationType;
|
||||
}
|
||||
|
||||
export interface CreateIncidentRequest {
|
||||
createdByDaisId: string;
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
priority?: IncidentPriority;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AuditEventFilter {
|
||||
eventType?: GovernanceEventType;
|
||||
actorId?: string;
|
||||
targetId?: string;
|
||||
scope?: GovernanceScope;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
totalEvents: number;
|
||||
eventsByType: Record<string, number>;
|
||||
eventsByDay: Array<{ date: string; count: number }>;
|
||||
topActors: Array<{ actorId: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface IncidentsCount {
|
||||
microdao: number;
|
||||
district: number;
|
||||
city: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user