feat(p9): iios_audit_link table + audit binding at decision points

Task O.3: IiosAuditLink (traceId/correlationId/scopeId/actorRefId/action/
resourceType/resourceId) + recordAudit() helper (reads the current trace from ALS,
best-effort). Written at the decision/mutation points — route approve/deny, AI
accept/reject, meeting summarize, outbound send — so the trace appears in DB and a
decision can be replayed / traced across services (mandated iios_audit_link).
Migration audit-link; resetDb truncates it. 2 tests bind approve/accept to a trace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:08:55 +05:30
parent 1d3e18f417
commit 6548b40f17
9 changed files with 161 additions and 1 deletions
@@ -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");
@@ -1158,3 +1158,23 @@ model IiosCalendarSyncCursor {
@@unique([providerAccountId]) @@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])
}
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CapabilityBroker } from '../capability/capability.broker'; import { CapabilityBroker } from '../capability/capability.broker';
import { recordAudit } from '../observability/audit';
/** /**
* Outbound command layer (P5). Owns idempotency + per-(channelType,target) rate * 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 }, 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; return updated;
} }
@@ -9,6 +9,7 @@ import {
InferenceResult, InferenceResult,
} from '@insignia/iios-contracts'; } from '@insignia/iios-contracts';
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context'; import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
import { recordAudit } from '../observability/audit';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PLATFORM_PORTS } from '../platform/platform-ports'; import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed'; import { decideOrThrow } from '../platform/fail-closed';
@@ -206,6 +207,7 @@ export class AiJobService {
data: { validationStatus: 'ACCEPTED', acceptedByRef: actor.id }, data: { validationStatus: 'ACCEPTED', acceptedByRef: actor.id },
}); });
await this.emit(IIOS_EVENTS.aiArtifactAccepted, artifact.job.scopeId, artifactId, { artifactId, acceptedByActorId: 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; return updated;
} }
@@ -219,6 +221,7 @@ export class AiJobService {
data: { status: 'REJECTED', acceptedByActorId: actor.id }, data: { status: 'REJECTED', acceptedByActorId: actor.id },
}); });
await this.prisma.iiosAiClaim.updateMany({ where: { artifactId }, data: { validationStatus: 'REJECTED', acceptedByRef: 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; return updated;
} }
@@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes
import { Prisma, IiosMeetingType, IiosConsentStatus } from '@prisma/client'; import { Prisma, IiosMeetingType, IiosConsentStatus } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts'; import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context'; import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
import { recordAudit } from '../observability/audit';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PLATFORM_PORTS } from '../platform/platform-ports'; import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed'; 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 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); return this.getMeeting(meetingId);
} }
@@ -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');
});
});
@@ -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<void> {
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 */
}
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma, IiosRouteBinding, IiosRouteDecision, IiosRouteMode, IiosOutputFormat } from '@prisma/client'; import { Prisma, IiosRouteBinding, IiosRouteDecision, IiosRouteMode, IiosOutputFormat } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts'; import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context'; import { currentTraceId, newTraceId, toTraceparent } from '../observability/trace-context';
import { recordAudit } from '../observability/audit';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RouteSimulator, type OriginRef } from './route-simulator'; import { RouteSimulator, type OriginRef } from './route-simulator';
import { OutboundService } from '../adapters/outbound.service'; import { OutboundService } from '../adapters/outbound.service';
@@ -71,6 +72,7 @@ export class RouteService {
data: { decisionState: 'ALLOW', decidedByActorId: actor.id }, data: { decisionState: 'ALLOW', decidedByActorId: actor.id },
}); });
await this.emitApproved(decision.routeBinding.scopeId, decisionId); 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); await this.execute(updated, decision.routeBinding);
return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } }); return this.prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } });
} }
@@ -80,10 +82,12 @@ export class RouteService {
if (!decision) throw new NotFoundException('route decision not found'); if (!decision) throw new NotFoundException('route decision not found');
await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02) await this.actors.assertOwns(principal, decision.routeBinding.scopeId); // tenant fence (KG-02)
const actor = await this.actors.resolveActor(decision.routeBinding.scopeId, principal); 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 }, where: { id: decisionId },
data: { decisionState: 'DENY', decidedByActorId: actor.id }, 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. */ /** Dispatch an ALLOW decision to the P5 sandbox (no real network). Idempotent. */
@@ -8,6 +8,7 @@ import type { PrismaClient } from '@prisma/client';
export async function resetDb(prisma: PrismaClient): Promise<void> { export async function resetDb(prisma: PrismaClient): Promise<void> {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`TRUNCATE TABLE `TRUNCATE TABLE
"IiosAuditLink",
"IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest", "IiosMeetingActionItem","IiosActionItem","IiosTranscriptSegment","IiosMeetingTranscript","IiosMeetingSummary","IiosMeetingParticipant","IiosMeeting","IiosMeetingRequest",
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",