feat(p8): consent gate → transcript → summary → action items (KG-14/Scenario 6)
Task 8.3: checkMeetingConsent requires unanimous attendee GRANTED consent + CMP non-DENY before any recording artifact. generateTranscript → BLOCKED (no segments) unless the gate passes, else READY + speaker segments. summarize re-checks consent live (revoke blocks re-summary), reuses P7 AiJobService over a synthetic transcript interaction → IiosMeetingSummary(aiArtifactId) + action items + per-attendee MEETING_FOLLOWUP inbox items, excluding visibility=NONE. 6 tests cover the spine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
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 { type MessagePrincipal } from '../messaging/message.service';
|
||||||
|
import { ActorResolver } from '../identity/actor.resolver';
|
||||||
|
import { AiJobService } from '../ai/ai.service';
|
||||||
|
import { AiBudgetGuard } from '../ai/ai-budget.guard';
|
||||||
|
import { CalendarService } from './calendar.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type { FakePorts } from '@insignia/iios-testkit';
|
||||||
|
|
||||||
|
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 alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai());
|
||||||
|
|
||||||
|
async function scheduledMeeting(attendees: { userId: string; visibility?: 'FULL' | 'NONE' }[]) {
|
||||||
|
const m = await cal().scheduleDirect(alice, { meetingType: 'INTERNAL', title: 'Board sync', startAt: START, attendees });
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
const grantAll = async (meetingId: string) => {
|
||||||
|
const ps = await prisma.iiosMeetingParticipant.findMany({ where: { meetingId } });
|
||||||
|
for (const p of ps) if (p.actorRefId) await cal().setConsent(meetingId, p.actorRefId, 'GRANTED');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
describe('Calendar consent gate (P8 — KG-14 / Scenario 6)', () => {
|
||||||
|
it('transcript is BLOCKED with no segments when an attendee has not consented (KG-14)', async () => {
|
||||||
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
||||||
|
// organizer + bob; nobody granted consent
|
||||||
|
const t = await cal().generateTranscript(m.id, alice);
|
||||||
|
expect(t.status).toBe('BLOCKED');
|
||||||
|
expect(await prisma.iiosTranscriptSegment.count()).toBe(0);
|
||||||
|
expect(await prisma.iiosMeetingSummary.count()).toBe(0);
|
||||||
|
expect(await prisma.iiosMeetingActionItem.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transcript is READY with segments once every attendee consents', async () => {
|
||||||
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
||||||
|
await grantAll(m.id);
|
||||||
|
const t = await cal().generateTranscript(m.id, alice);
|
||||||
|
expect(t.status).toBe('READY');
|
||||||
|
expect(t.segments.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CMP DENY blocks the transcript even if all attendees granted', async () => {
|
||||||
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
||||||
|
await grantAll(m.id);
|
||||||
|
const ports = makeFakePorts();
|
||||||
|
ports.cmp.setStatus('DENY');
|
||||||
|
const t = await cal(ports).generateTranscript(m.id, alice);
|
||||||
|
expect(t.status).toBe('BLOCKED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarize → summary linked to a P7 ai_artifact + action items + MEETING_FOLLOWUP inbox', async () => {
|
||||||
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
||||||
|
await grantAll(m.id);
|
||||||
|
await cal().generateTranscript(m.id, alice);
|
||||||
|
await cal().summarize(m.id, alice);
|
||||||
|
|
||||||
|
const summary = await prisma.iiosMeetingSummary.findFirstOrThrow({ where: { meetingId: m.id } });
|
||||||
|
expect(summary.aiArtifactId).toBeTruthy();
|
||||||
|
const artifact = await prisma.iiosAiArtifact.findUniqueOrThrow({ where: { id: summary.aiArtifactId! } });
|
||||||
|
expect(artifact.artifactType).toBe('SUMMARY');
|
||||||
|
expect(await prisma.iiosMeetingActionItem.count({ where: { meetingId: m.id } })).toBeGreaterThan(0);
|
||||||
|
expect(await prisma.iiosInboxItem.count({ where: { kind: 'MEETING_FOLLOWUP' } })).toBe(2); // organizer + bob
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a visibility=NONE attendee is excluded from inbox follow-ups (Scenario 6)', async () => {
|
||||||
|
const m = await scheduledMeeting([{ userId: 'bob', visibility: 'NONE' }]);
|
||||||
|
await grantAll(m.id);
|
||||||
|
await cal().generateTranscript(m.id, alice);
|
||||||
|
await cal().summarize(m.id, alice);
|
||||||
|
// only the organizer (FULL) gets a follow-up; bob (NONE) is excluded
|
||||||
|
expect(await prisma.iiosInboxItem.count({ where: { kind: 'MEETING_FOLLOWUP' } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoking consent after a READY transcript blocks re-summary', async () => {
|
||||||
|
const m = await scheduledMeeting([{ userId: 'bob' }]);
|
||||||
|
await grantAll(m.id);
|
||||||
|
await cal().generateTranscript(m.id, alice);
|
||||||
|
const bob = await prisma.iiosMeetingParticipant.findFirstOrThrow({ where: { meetingId: m.id, role: 'REQUIRED' } });
|
||||||
|
await cal().setConsent(m.id, bob.actorRefId!, 'REVOKED');
|
||||||
|
await expect(cal().summarize(m.id, alice)).rejects.toThrow();
|
||||||
|
expect(await prisma.iiosMeetingSummary.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import type { IiosMeetingParticipant } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface ConsentVerdict {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
receiptRef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The P8 safety spine (Critics KG-14 / Scenario 6). A recording artifact
|
||||||
|
* (transcript / summary / action items) may exist ONLY when **every** participant
|
||||||
|
* has GRANTED recording consent AND the CMP purpose check does not DENY. Anything
|
||||||
|
* else fails closed. This is deliberately unanimous: one non-consenting attendee
|
||||||
|
* blocks the whole recording.
|
||||||
|
*/
|
||||||
|
export async function checkMeetingConsent(
|
||||||
|
ports: IiosPlatformPorts,
|
||||||
|
meetingId: string,
|
||||||
|
participants: Pick<IiosMeetingParticipant, 'recordingConsent'>[],
|
||||||
|
): Promise<ConsentVerdict> {
|
||||||
|
const cmp = await ports.cmp.checkPurpose({ purpose: 'meeting_recording', meetingId });
|
||||||
|
if (cmp.status === 'DENY') return { allowed: false, reason: 'CMP_DENIED' };
|
||||||
|
|
||||||
|
const nonConsenting = participants.filter((p) => p.recordingConsent !== 'GRANTED');
|
||||||
|
if (nonConsenting.length > 0) {
|
||||||
|
return { allowed: false, reason: 'ATTENDEE_CONSENT_MISSING' };
|
||||||
|
}
|
||||||
|
return { allowed: true, receiptRef: cmp.receiptRef };
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma, IiosMeetingType } 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 { 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';
|
||||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { AiJobService } from '../ai/ai.service';
|
||||||
|
import { checkMeetingConsent } from './calendar-consent';
|
||||||
|
|
||||||
export interface AttendeeInput {
|
export interface AttendeeInput {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -44,6 +46,7 @@ export class CalendarService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
private readonly actors: ActorResolver,
|
private readonly actors: ActorResolver,
|
||||||
|
private readonly ai: AiJobService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Genesis: meeting requests ────────────────────────────────────
|
// ── Genesis: meeting requests ────────────────────────────────────
|
||||||
@@ -192,6 +195,131 @@ export class CalendarService {
|
|||||||
return meeting;
|
return meeting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Consent + transcript + summary (the KG-14 safety spine) ──────
|
||||||
|
async setConsent(meetingId: string, actorRefId: string, status: IiosConsentStatus) {
|
||||||
|
return this.prisma.iiosMeetingParticipant.update({
|
||||||
|
where: { meetingId_actorRefId: { meetingId, actorRefId } },
|
||||||
|
data: { recordingConsent: status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the transcript ONLY if every attendee consented + CMP allows; otherwise
|
||||||
|
* a BLOCKED transcript with no segments (fail-closed, KG-14). Idempotent per meeting.
|
||||||
|
*/
|
||||||
|
async generateTranscript(meetingId: string, _principal: MessagePrincipal) {
|
||||||
|
const meeting = await this.prisma.iiosMeeting.findUnique({ where: { id: meetingId }, include: { participants: true } });
|
||||||
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
|
||||||
|
const existing = await this.prisma.iiosMeetingTranscript.findFirst({ where: { meetingId }, include: { segments: true } });
|
||||||
|
if (existing && existing.status === 'READY') return existing;
|
||||||
|
|
||||||
|
const verdict = await checkMeetingConsent(this.ports, meetingId, meeting.participants);
|
||||||
|
if (!verdict.allowed) {
|
||||||
|
return this.prisma.iiosMeetingTranscript.create({
|
||||||
|
data: { meetingId, transcriptRef: `transcript:${meetingId}`, status: 'BLOCKED' },
|
||||||
|
include: { segments: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = await this.prisma.iiosMeetingTranscript.create({
|
||||||
|
data: { meetingId, transcriptRef: `transcript:${meetingId}`, status: 'READY', consentRefId: verdict.receiptRef },
|
||||||
|
});
|
||||||
|
let seg = 0;
|
||||||
|
for (const p of meeting.participants) {
|
||||||
|
await this.prisma.iiosTranscriptSegment.create({
|
||||||
|
data: {
|
||||||
|
transcriptId: transcript.id,
|
||||||
|
segmentNo: seg,
|
||||||
|
speakerActorId: p.actorRefId ?? undefined,
|
||||||
|
startMs: seg * 1000,
|
||||||
|
endMs: (seg + 1) * 1000,
|
||||||
|
contentRef: `Speaker ${seg + 1}: discussed "${meeting.title}" and agreed to follow up.`,
|
||||||
|
confidence: 0.9,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
seg++;
|
||||||
|
}
|
||||||
|
return this.prisma.iiosMeetingTranscript.findUniqueOrThrow({ where: { id: transcript.id }, include: { segments: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a READY transcript via the P7 AI layer + derive action items and
|
||||||
|
* per-attendee inbox follow-ups. Re-checks consent live (defense in depth) so a
|
||||||
|
* revoked consent blocks summarization even if a transcript already exists.
|
||||||
|
* Attendees with visibility=NONE are excluded from inbox exposure (Scenario 6).
|
||||||
|
*/
|
||||||
|
async summarize(meetingId: string, _principal: MessagePrincipal) {
|
||||||
|
const meeting = await this.prisma.iiosMeeting.findUnique({
|
||||||
|
where: { id: meetingId },
|
||||||
|
include: { participants: true, transcripts: { include: { segments: true } } },
|
||||||
|
});
|
||||||
|
if (!meeting) throw new NotFoundException('meeting not found');
|
||||||
|
|
||||||
|
const verdict = await checkMeetingConsent(this.ports, meetingId, meeting.participants);
|
||||||
|
if (!verdict.allowed) throw new BadRequestException(`recording consent not satisfied: ${verdict.reason}`);
|
||||||
|
|
||||||
|
const transcript = meeting.transcripts.find((t) => t.status === 'READY');
|
||||||
|
if (!transcript) throw new BadRequestException('no READY transcript to summarize');
|
||||||
|
|
||||||
|
const already = await this.prisma.iiosMeetingSummary.findFirst({ where: { meetingId } });
|
||||||
|
if (already) return this.getMeeting(meetingId);
|
||||||
|
|
||||||
|
const scopeId = meeting.scopeId;
|
||||||
|
const text = transcript.segments.map((s) => s.contentRef).join(' ');
|
||||||
|
const idk = `meeting-transcript:${meetingId}`;
|
||||||
|
const interaction =
|
||||||
|
(await this.prisma.iiosInteraction.findFirst({ where: { scopeId, idempotencyKey: idk } })) ??
|
||||||
|
(await this.prisma.iiosInteraction.create({
|
||||||
|
data: { scopeId, kind: 'SUMMARY', idempotencyKey: idk, parts: { create: [{ partIndex: 0, kind: 'TEXT', bodyText: text }] } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sum = await this.ai.runJob({ interactionId: interaction.id, jobType: 'SUMMARIZE' });
|
||||||
|
const ext = await this.ai.runJob({ interactionId: interaction.id, jobType: 'EXTRACT' });
|
||||||
|
|
||||||
|
const summary = await this.prisma.iiosMeetingSummary.create({
|
||||||
|
data: { meetingId, aiArtifactId: sum.artifact?.id, summaryType: 'AI', status: 'PROPOSED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = ext.artifact
|
||||||
|
? await this.prisma.iiosAiClaim.findMany({ where: { artifactId: ext.artifact.id, claimType: 'EVENT' } })
|
||||||
|
: [];
|
||||||
|
const titles = claims.length > 0 ? claims.map((c) => String((c.claimJson as { title?: string }).title ?? 'follow-up')) : ['Meeting follow-up'];
|
||||||
|
for (const title of titles) {
|
||||||
|
const item = await this.prisma.iiosActionItem.create({
|
||||||
|
data: { scopeId, ownerActorId: meeting.organizerActorId, title, status: 'OPEN', sourceInteractionId: interaction.id },
|
||||||
|
});
|
||||||
|
await this.prisma.iiosMeetingActionItem.create({
|
||||||
|
data: { meetingId, actionItemId: item.id, ownerActorId: meeting.organizerActorId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-attendee inbox follow-up — attendee-level visibility gates exposure.
|
||||||
|
for (const p of meeting.participants) {
|
||||||
|
if (p.visibility === 'NONE' || !p.actorRefId) continue;
|
||||||
|
await this.prisma.iiosInboxItem.create({
|
||||||
|
data: { scopeId, ownerActorId: p.actorRefId, kind: 'MEETING_FOLLOWUP', state: 'OPEN', title: `Follow-up: ${meeting.title}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.emit(IIOS_EVENTS.meetingSummarized, scopeId, meetingId, { meetingId, summaryId: summary.id });
|
||||||
|
return this.getMeeting(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveSummary(summaryId: string, principal: MessagePrincipal) {
|
||||||
|
const summary = await this.prisma.iiosMeetingSummary.findUnique({ where: { id: summaryId }, include: { meeting: true } });
|
||||||
|
if (!summary) throw new NotFoundException('summary not found');
|
||||||
|
const actor = await this.actors.resolveActor(summary.meeting.scopeId, principal);
|
||||||
|
return this.prisma.iiosMeetingSummary.update({
|
||||||
|
where: { id: summaryId },
|
||||||
|
data: { status: 'APPROVED', approvedByActorId: actor.id, publishedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActionItems(meetingId: string) {
|
||||||
|
return this.prisma.iiosMeetingActionItem.findMany({ where: { meetingId }, include: { actionItem: true } });
|
||||||
|
}
|
||||||
|
|
||||||
protected async emit(type: string, scopeId: string, aggregateId: string, data: Record<string, unknown>): Promise<void> {
|
protected async emit(type: string, scopeId: string, aggregateId: string, data: Record<string, unknown>): Promise<void> {
|
||||||
const event: CloudEvent = {
|
const event: CloudEvent = {
|
||||||
specversion: '1.0',
|
specversion: '1.0',
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const actors = new ActorResolver(asService);
|
|||||||
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
const START = '2026-07-02T10:00:00.000Z';
|
const START = '2026-07-02T10:00:00.000Z';
|
||||||
|
|
||||||
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors);
|
|
||||||
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
|
||||||
|
const cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), actors, ai());
|
||||||
|
|
||||||
async function makeInteraction(text: string): Promise<string> {
|
async function makeInteraction(text: string): Promise<string> {
|
||||||
const m = new MessageService(asService, makeFakePorts(), actors);
|
const m = new MessageService(asService, makeFakePorts(), actors);
|
||||||
|
|||||||
Reference in New Issue
Block a user