diff --git a/packages/iios-service/prisma/migrations/20260702193535_audit_link/migration.sql b/packages/iios-service/prisma/migrations/20260702193535_audit_link/migration.sql new file mode 100644 index 0000000..b29a3ce --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260702193535_audit_link/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "IiosAuditLink" ( + "id" TEXT NOT NULL, + "traceId" TEXT, + "correlationId" TEXT, + "scopeId" TEXT, + "actorRefId" TEXT, + "action" TEXT NOT NULL, + "resourceType" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IiosAuditLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IiosAuditLink_traceId_idx" ON "IiosAuditLink"("traceId"); + +-- CreateIndex +CREATE INDEX "IiosAuditLink_resourceType_resourceId_idx" ON "IiosAuditLink"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "IiosAuditLink_scopeId_idx" ON "IiosAuditLink"("scopeId"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index d6feb45..9977329 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -1158,3 +1158,23 @@ model IiosCalendarSyncCursor { @@unique([providerAccountId]) } + +// ─── Observability (P9) ─────────────────────────────────────────── + +/// Binds an IIOS action to its trace / actor / resource for cross-service causality +/// and "replay a decision" (mandated iios_audit_link). Written at decision points. +model IiosAuditLink { + id String @id @default(cuid()) + traceId String? + correlationId String? + scopeId String? + actorRefId String? + action String + resourceType String + resourceId String + createdAt DateTime @default(now()) + + @@index([traceId]) + @@index([resourceType, resourceId]) + @@index([scopeId]) +} diff --git a/packages/iios-service/src/adapters/outbound.service.ts b/packages/iios-service/src/adapters/outbound.service.ts index 2bb8b2a..21f7e78 100644 --- a/packages/iios-service/src/adapters/outbound.service.ts +++ b/packages/iios-service/src/adapters/outbound.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CapabilityBroker } from '../capability/capability.broker'; +import { recordAudit } from '../observability/audit'; /** * Outbound command layer (P5). Owns idempotency + per-(channelType,target) rate @@ -69,6 +70,7 @@ export class OutboundService { data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode }, }), ]); + await recordAudit(this.prisma, { action: 'channel.send', resourceType: 'outbound_command', resourceId: cmd.id, scopeId }); return updated; } diff --git a/packages/iios-service/src/ai/ai.service.ts b/packages/iios-service/src/ai/ai.service.ts index 1aad2cd..77744bd 100644 --- a/packages/iios-service/src/ai/ai.service.ts +++ b/packages/iios-service/src/ai/ai.service.ts @@ -9,6 +9,7 @@ import { InferenceResult, } from '@insignia/iios-contracts'; import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context'; +import { recordAudit } from '../observability/audit'; import { PrismaService } from '../prisma/prisma.service'; import { PLATFORM_PORTS } from '../platform/platform-ports'; import { decideOrThrow } from '../platform/fail-closed'; @@ -206,6 +207,7 @@ export class AiJobService { data: { validationStatus: 'ACCEPTED', acceptedByRef: actor.id }, }); await this.emit(IIOS_EVENTS.aiArtifactAccepted, artifact.job.scopeId, artifactId, { artifactId, acceptedByActorId: actor.id }); + await recordAudit(this.prisma, { action: 'ai.artifact.accepted', resourceType: 'ai_artifact', resourceId: artifactId, scopeId: artifact.job.scopeId, actorRefId: actor.id }); return updated; } @@ -219,6 +221,7 @@ export class AiJobService { data: { status: 'REJECTED', acceptedByActorId: actor.id }, }); await this.prisma.iiosAiClaim.updateMany({ where: { artifactId }, data: { validationStatus: 'REJECTED', acceptedByRef: actor.id } }); + await recordAudit(this.prisma, { action: 'ai.artifact.rejected', resourceType: 'ai_artifact', resourceId: artifactId, scopeId: artifact.job.scopeId, actorRefId: actor.id }); return updated; } diff --git a/packages/iios-service/src/calendar/calendar.service.ts b/packages/iios-service/src/calendar/calendar.service.ts index 6bc67c2..cf3b029 100644 --- a/packages/iios-service/src/calendar/calendar.service.ts +++ b/packages/iios-service/src/calendar/calendar.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes import { Prisma, IiosMeetingType, IiosConsentStatus } from '@prisma/client'; import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts'; import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context'; +import { recordAudit } from '../observability/audit'; import { PrismaService } from '../prisma/prisma.service'; import { PLATFORM_PORTS } from '../platform/platform-ports'; import { decideOrThrow } from '../platform/fail-closed'; @@ -351,6 +352,7 @@ export class CalendarService { } await this.emit(IIOS_EVENTS.meetingSummarized, scopeId, meetingId, { meetingId, summaryId: summary.id }); + await recordAudit(this.prisma, { action: 'meeting.summarized', resourceType: 'meeting', resourceId: meetingId, scopeId }); return this.getMeeting(meetingId); } diff --git a/packages/iios-service/src/observability/audit.spec.ts b/packages/iios-service/src/observability/audit.spec.ts new file mode 100644 index 0000000..636cbea --- /dev/null +++ b/packages/iios-service/src/observability/audit.spec.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit'; +import { MessageService, type MessagePrincipal } from '../messaging/message.service'; +import { ActorResolver } from '../identity/actor.resolver'; +import { RuleEngine } from '../routing/rule-engine'; +import { RouteSimulator } from '../routing/route-simulator'; +import { RouteService } from '../routing/route.service'; +import { AiJobService } from '../ai/ai.service'; +import { AiBudgetGuard } from '../ai/ai-budget.guard'; +import { OutboundService } from '../adapters/outbound.service'; +import { CapabilityBroker } from '../capability/capability.broker'; +import { CapabilityProviderRegistry } from '../capability/capability.registry'; +import { runWithTrace } from './trace-context'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; +const actors = new ActorResolver(asService); +const user: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo' }; + +const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors); +const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry())); +const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors); + +async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> { + const m = new MessageService(asService, makeFakePorts(), actors); + const { threadId } = await m.openThread(null, user); + const msg = await m.send(threadId, user, { content: text }, `k-${randomUUID()}`); + const i = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } }); + return { interactionId: msg.id, scopeId: i.scopeId }; +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('audit link (P9 — trace appears in DB at decision points)', () => { + it('approving a route decision writes an audit link carrying the request trace id', async () => { + const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); + await prisma.iiosRouteBinding.create({ + data: { scopeId, originChannelType: 'WHATSAPP', originRef: 'g', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true }, + }); + const { decisions } = await routeSvc().simulate(interactionId, { channelType: 'WHATSAPP', ref: 'g' }); + const decisionId = decisions[0]!.id; + + await runWithTrace('trace-approve-1', async () => { + await routeSvc().approve(decisionId, user); + }); + + const link = await prisma.iiosAuditLink.findFirstOrThrow({ where: { resourceId: decisionId } }); + expect(link.action).toBe('route.decision.approved'); + expect(link.resourceType).toBe('route_decision'); + expect(link.traceId).toBe('trace-approve-1'); + expect(link.actorRefId).toBeTruthy(); + }); + + it('accepting an AI artifact writes an audit link for it', async () => { + const { interactionId } = await makeInteraction('party at 9 PM'); + const { artifact } = await ai().runJob({ interactionId, jobType: 'CLASSIFY', principal: user }); + await runWithTrace('trace-accept-1', async () => { + await ai().accept(artifact!.id, user); + }); + const link = await prisma.iiosAuditLink.findFirstOrThrow({ where: { resourceId: artifact!.id, action: 'ai.artifact.accepted' } }); + expect(link.traceId).toBe('trace-accept-1'); + }); +}); diff --git a/packages/iios-service/src/observability/audit.ts b/packages/iios-service/src/observability/audit.ts new file mode 100644 index 0000000..6b8e63c --- /dev/null +++ b/packages/iios-service/src/observability/audit.ts @@ -0,0 +1,35 @@ +import type { PrismaService } from '../prisma/prisma.service'; +import { currentTraceId } from './trace-context'; + +export interface AuditInput { + action: string; + resourceType: string; + resourceId: string; + scopeId?: string; + actorRefId?: string; + correlationId?: string; +} + +/** + * Write an audit link (P9): binds an action to its trace + actor + resource so a + * decision can be replayed / a request traced across services. Plain function — reads + * the current request's trace id from AsyncLocalStorage (no DI). Best-effort: never + * throws into the caller's happy path. + */ +export async function recordAudit(prisma: PrismaService, input: AuditInput): Promise { + try { + await prisma.iiosAuditLink.create({ + data: { + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId, + scopeId: input.scopeId, + actorRefId: input.actorRefId, + traceId: currentTraceId(), + correlationId: input.correlationId ?? currentTraceId(), + }, + }); + } catch { + /* audit is best-effort; do not break the request */ + } +} diff --git a/packages/iios-service/src/routing/route.service.ts b/packages/iios-service/src/routing/route.service.ts index c422323..574bab6 100644 --- a/packages/iios-service/src/routing/route.service.ts +++ b/packages/iios-service/src/routing/route.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma, IiosRouteBinding, IiosRouteDecision, IiosRouteMode, IiosOutputFormat } from '@prisma/client'; import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts'; import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context'; +import { recordAudit } from '../observability/audit'; import { PrismaService } from '../prisma/prisma.service'; import { RouteSimulator, type OriginRef } from './route-simulator'; import { OutboundService } from '../adapters/outbound.service'; @@ -71,6 +72,7 @@ export class RouteService { data: { decisionState: 'ALLOW', decidedByActorId: actor.id }, }); await this.emitApproved(decision.routeBinding.scopeId, decisionId); + await recordAudit(this.prisma, { action: 'route.decision.approved', resourceType: 'route_decision', resourceId: decisionId, scopeId: decision.routeBinding.scopeId, actorRefId: actor.id }); await this.execute(updated, decision.routeBinding); return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } }); } @@ -80,10 +82,12 @@ export class RouteService { if (!decision) throw new NotFoundException('route decision not found'); await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02) const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal); - return this.prisma.iiosRouteDecision.update({ + const updated = await this.prisma.iiosRouteDecision.update({ where: { id: decisionId }, data: { decisionState: 'DENY', decidedByActorId: actor.id }, }); + await recordAudit(this.prisma, { action: 'route.decision.denied', resourceType: 'route_decision', resourceId: decisionId, scopeId: decision.routeBinding.scopeId, actorRefId: actor.id }); + return updated; } /** Dispatch an ALLOW decision to the P5 sandbox (no real network). Idempotent. */ diff --git a/packages/iios-service/src/test-utils/reset-db.ts b/packages/iios-service/src/test-utils/reset-db.ts index bb02b9f..6913cb3 100644 --- a/packages/iios-service/src/test-utils/reset-db.ts +++ b/packages/iios-service/src/test-utils/reset-db.ts @@ -8,6 +8,7 @@ import type { PrismaClient } from '@prisma/client'; export async function resetDb(prisma: PrismaClient): Promise { await prisma.$executeRawUnsafe( `TRUNCATE TABLE + "IiosAuditLink", "IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest", "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",