From 55acc3c18722a9e302ed38db2a440add8ea9bf42 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 17:57:36 +0530 Subject: [PATCH] =?UTF-8?q?feat(p8):=20consent=20gate=20=E2=86=92=20transc?= =?UTF-8?q?ript=20=E2=86=92=20summary=20=E2=86=92=20action=20items=20(KG-1?= =?UTF-8?q?4/Scenario=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/calendar/calendar-consent.spec.ts | 97 +++++++++++++ .../src/calendar/calendar-consent.ts | 30 ++++ .../src/calendar/calendar.service.ts | 130 +++++++++++++++++- .../src/calendar/calendar.spec.ts | 2 +- 4 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 packages/iios-service/src/calendar/calendar-consent.spec.ts create mode 100644 packages/iios-service/src/calendar/calendar-consent.ts diff --git a/packages/iios-service/src/calendar/calendar-consent.spec.ts b/packages/iios-service/src/calendar/calendar-consent.spec.ts new file mode 100644 index 0000000..d2dcb4e --- /dev/null +++ b/packages/iios-service/src/calendar/calendar-consent.spec.ts @@ -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); + }); +}); diff --git a/packages/iios-service/src/calendar/calendar-consent.ts b/packages/iios-service/src/calendar/calendar-consent.ts new file mode 100644 index 0000000..ae17588 --- /dev/null +++ b/packages/iios-service/src/calendar/calendar-consent.ts @@ -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[], +): Promise { + 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 }; +} diff --git a/packages/iios-service/src/calendar/calendar.service.ts b/packages/iios-service/src/calendar/calendar.service.ts index a1d5d1b..dcca24c 100644 --- a/packages/iios-service/src/calendar/calendar.service.ts +++ b/packages/iios-service/src/calendar/calendar.service.ts @@ -1,10 +1,12 @@ 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 { PrismaService } from '../prisma/prisma.service'; import { PLATFORM_PORTS } from '../platform/platform-ports'; import { decideOrThrow } from '../platform/fail-closed'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { AiJobService } from '../ai/ai.service'; +import { checkMeetingConsent } from './calendar-consent'; export interface AttendeeInput { userId: string; @@ -44,6 +46,7 @@ export class CalendarService { private readonly prisma: PrismaService, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, private readonly actors: ActorResolver, + private readonly ai: AiJobService, ) {} // ── Genesis: meeting requests ──────────────────────────────────── @@ -192,6 +195,131 @@ export class CalendarService { 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): Promise { const event: CloudEvent = { specversion: '1.0', diff --git a/packages/iios-service/src/calendar/calendar.spec.ts b/packages/iios-service/src/calendar/calendar.spec.ts index a4f4e7b..5682aa6 100644 --- a/packages/iios-service/src/calendar/calendar.spec.ts +++ b/packages/iios-service/src/calendar/calendar.spec.ts @@ -20,8 +20,8 @@ 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 cal = (ports?: FakePorts) => new CalendarService(asService, ports ?? makeFakePorts(), 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 { const m = new MessageService(asService, makeFakePorts(), actors);