From 7ed3c11891358e6ed023770906b9ca76471131b5 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 14:29:01 +0530 Subject: [PATCH] feat(service): P6.2 RuleEngine + RouteSimulator (deterministic, preview-first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyword moderation flags; deny-by-default for protected (CHILD) destinations, REVIEW for other flags/requiresReview, ALLOW otherwise; versioned rulepack for replay. Simulate renders per-format previews + persists decisions, NEVER sends. BDD (party@9pm → children DENY / adult+seniors REVIEW / 0 sends) + replay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/routing/route-simulator.ts | 127 ++++++++++++++++++ .../iios-service/src/routing/route.spec.ts | 95 +++++++++++++ .../src/routing/routing.module.ts | 9 ++ .../iios-service/src/routing/rule-engine.ts | 49 +++++++ 4 files changed, 280 insertions(+) create mode 100644 packages/iios-service/src/routing/route-simulator.ts create mode 100644 packages/iios-service/src/routing/route.spec.ts create mode 100644 packages/iios-service/src/routing/routing.module.ts create mode 100644 packages/iios-service/src/routing/rule-engine.ts diff --git a/packages/iios-service/src/routing/route-simulator.ts b/packages/iios-service/src/routing/route-simulator.ts new file mode 100644 index 0000000..298ed61 --- /dev/null +++ b/packages/iios-service/src/routing/route-simulator.ts @@ -0,0 +1,127 @@ +import { randomUUID } from 'node:crypto'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, IiosRouteBinding } from '@prisma/client'; +import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts'; +import { PrismaService } from '../prisma/prisma.service'; +import { RuleEngine } from './rule-engine'; + +export interface OriginRef { + channelType: string; + ref?: string; +} + +/** + * Preview-first route simulator (P6). Produces a decision + rendered preview per + * enabled binding for an origin — and NEVER sends. Deterministic + replayable + * (re-simulating yields the same decisions). Persists decisions so an admin can + * approve by id. + */ +@Injectable() +export class RouteSimulator { + constructor( + private readonly prisma: PrismaService, + private readonly rules: RuleEngine, + ) {} + + async simulate(interactionId: string, origin: OriginRef, opts?: { simulationId?: string }) { + const interaction = await this.prisma.iiosInteraction.findUnique({ + where: { id: interactionId }, + include: { parts: { orderBy: { partIndex: 'asc' } } }, + }); + if (!interaction) throw new NotFoundException('interaction not found'); + + const text = interaction.parts.find((p) => p.kind === 'TEXT')?.bodyText ?? ''; + const hasAttachment = interaction.parts.some((p) => !!p.contentRef); + const flags = this.rules.flag(text); + const flagTypes = flags.map((f) => f.flagType); + await this.persistFlags(interactionId, flags); + + const simulationId = opts?.simulationId ?? randomUUID(); + const bindings = await this.prisma.iiosRouteBinding.findMany({ + where: { + scopeId: interaction.scopeId, + enabled: true, + originChannelType: origin.channelType, + ...(origin.ref ? { originRef: origin.ref } : {}), + }, + }); + + const decisions = []; + for (const b of bindings) { + const { state, reasonCodes } = this.rules.decide(b, flagTypes); + const previewPayload = this.render(b, text, hasAttachment); + const decision = await this.prisma.iiosRouteDecision.upsert({ + where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, + create: { + interactionId, + routeBindingId: b.id, + decisionState: state, + reasonCodes, + previewPayload: previewPayload as Prisma.InputJsonValue, + outputFormat: b.outputFormat, + rulepackVersion: this.rules.rulepackVersion, + simulationId, + }, + update: { decisionState: state, reasonCodes, previewPayload: previewPayload as Prisma.InputJsonValue, simulationId }, + }); + decisions.push(decision); + } + + // Trace event — a preview record, never a send. + if (decisions.length > 0) { + const event: CloudEvent = { + specversion: '1.0', + id: `evt_routesim_${simulationId}`, + type: IIOS_EVENTS.routeDecisionPreviewed, + source: `iios/routing/${interaction.scopeId}`, + subject: `interaction/${interactionId}`, + time: new Date().toISOString(), + datacontenttype: 'application/json', + insignia: { scopeSnapshotId: interaction.scopeId, correlationId: interaction.traceId ?? undefined, idempotencyKey: `routesim:${simulationId}`, dataClass: 'internal' }, + data: { simulationId, interactionId, decisions: decisions.map((d) => ({ id: d.id, state: d.decisionState })) }, + }; + await this.prisma.iiosOutboxEvent.create({ + data: { + aggregateType: 'route_simulation', + aggregateId: interactionId, + eventType: IIOS_EVENTS.routeDecisionPreviewed, + cloudEvent: event as unknown as Prisma.InputJsonValue, + partitionKey: `${interaction.scopeId}:${interactionId}`, + }, + }); + } + + return { simulationId, decisions }; + } + + private render(binding: IiosRouteBinding, text: string, hasAttachment: boolean) { + const attach = binding.includeAttachments && hasAttachment ? ' [+attachment]' : ''; + const rendered = + binding.outputFormat === 'DIGEST' + ? `[digest] ${text}` + : binding.outputFormat === 'SUMMARY' + ? `[summary] ${text.slice(0, 60)}` // deterministic stub; real summary is P7 (AI) + : binding.outputFormat === 'TRANSCRIPT' + ? `[transcript] ${text}` + : text; // FORWARD / THREADED + return { + format: binding.outputFormat, + destinationChannelType: binding.destinationChannelType, + destinationRef: binding.destinationRef, + text: rendered + attach, + }; + } + + private async persistFlags(interactionId: string, flags: RuleFlagLike[]): Promise { + if (flags.length === 0) return; + const existing = await this.prisma.iiosModerationFlag.count({ where: { interactionId, proposedBy: 'RULE' } }); + if (existing > 0) return; // keep replay deterministic — flag once + await this.prisma.iiosModerationFlag.createMany({ + data: flags.map((f) => ({ interactionId, flagType: f.flagType, proposedBy: 'RULE' })), + }); + } +} + +interface RuleFlagLike { + flagType: string; +} diff --git a/packages/iios-service/src/routing/route.spec.ts b/packages/iios-service/src/routing/route.spec.ts new file mode 100644 index 0000000..d86933c --- /dev/null +++ b/packages/iios-service/src/routing/route.spec.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient, IiosRouteMode } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { MessageService, type MessagePrincipal } from '../messaging/message.service'; +import { ActorResolver } from '../identity/actor.resolver'; +import { RuleEngine } from './rule-engine'; +import { RouteSimulator } from './route-simulator'; +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 sim = () => new RouteSimulator(asService, new RuleEngine()); + +const sender: MessagePrincipal = { userId: 'wa-user', orgId: 'org_demo', appId: 'portal-demo', displayName: 'WA User' }; +const ORIGIN = { channelType: 'WHATSAPP', ref: 'adult-group' }; + +async function makeInteraction(text: string): Promise<{ interactionId: string; scopeId: string }> { + const m = new MessageService(asService, makeFakePorts(), actors); + const { threadId } = await m.openThread(null, sender); + const msg = await m.send(threadId, sender, { content: text }, `k-${randomUUID()}`); + const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: msg.id } }); + return { interactionId: msg.id, scopeId: interaction.scopeId }; +} + +async function makeBinding( + scopeId: string, + destinationRef: string, + restrictionProfile?: string, + opts?: { requiresReview?: boolean; mode?: IiosRouteMode }, +) { + return prisma.iiosRouteBinding.create({ + data: { + scopeId, + originChannelType: ORIGIN.channelType, + originRef: ORIGIN.ref, + destinationChannelType: 'PORTAL', + destinationRef, + restrictionProfile, + requiresReview: opts?.requiresReview ?? true, + mode: opts?.mode ?? 'MANUAL', + enabled: true, + }, + }); +} + +async function stateFor(interactionId: string, destinationRef: string): Promise { + const b = await prisma.iiosRouteBinding.findFirstOrThrow({ where: { destinationRef } }); + const d = await prisma.iiosRouteDecision.findUniqueOrThrow({ + where: { interactionId_routeBindingId: { interactionId, routeBindingId: b.id } }, + }); + return d.decisionState; +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('Route simulator (P6, preview-first)', () => { + it('"party at 9 PM" → children DENY, adult+seniors REVIEW, and NO send', async () => { + const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); + await makeBinding(scopeId, 'adult', 'ADULT'); + await makeBinding(scopeId, 'seniors', 'SENIORS'); + await makeBinding(scopeId, 'children', 'CHILD'); + + await sim().simulate(interactionId, ORIGIN); + + expect(await stateFor(interactionId, 'children')).toBe('DENY'); + expect(await stateFor(interactionId, 'adult')).toBe('REVIEW'); + expect(await stateFor(interactionId, 'seniors')).toBe('REVIEW'); + expect(await prisma.iiosOutboundCommand.count()).toBe(0); // simulation never sends + }); + + it('safe message + requiresReview:false → ALLOW', async () => { + const { interactionId, scopeId } = await makeInteraction('hello team, meeting notes attached'); + await makeBinding(scopeId, 'general', undefined, { requiresReview: false }); + await sim().simulate(interactionId, ORIGIN); + expect(await stateFor(interactionId, 'general')).toBe('ALLOW'); + }); + + it('replay: simulating twice yields identical decisions (deterministic)', async () => { + const { interactionId, scopeId } = await makeInteraction('party at 9 PM'); + await makeBinding(scopeId, 'children', 'CHILD'); + + const first = await sim().simulate(interactionId, ORIGIN); + const second = await sim().simulate(interactionId, ORIGIN); + + expect(await prisma.iiosRouteDecision.count()).toBe(1); // upsert, not duplicated + expect(first.decisions[0]?.decisionState).toBe(second.decisions[0]?.decisionState); + expect(first.decisions[0]?.reasonCodes).toEqual(second.decisions[0]?.reasonCodes); + }); +}); diff --git a/packages/iios-service/src/routing/routing.module.ts b/packages/iios-service/src/routing/routing.module.ts new file mode 100644 index 0000000..7160602 --- /dev/null +++ b/packages/iios-service/src/routing/routing.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { RuleEngine } from './rule-engine'; +import { RouteSimulator } from './route-simulator'; + +@Module({ + providers: [RuleEngine, RouteSimulator], + exports: [RuleEngine, RouteSimulator], +}) +export class RoutingModule {} diff --git a/packages/iios-service/src/routing/rule-engine.ts b/packages/iios-service/src/routing/rule-engine.ts new file mode 100644 index 0000000..5847c17 --- /dev/null +++ b/packages/iios-service/src/routing/rule-engine.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { IiosRouteDecisionState } from '@prisma/client'; + +export interface RuleFlag { + flagType: string; +} + +interface DecidableBinding { + restrictionProfile: string | null; + requiresReview: boolean; +} + +/** + * Deterministic routing rules (P6). Keyword-based moderation flags + a decision + * function that is **deny-by-default for restricted destinations**. Versioned so + * a past decision is reproducible on replay. AI may only *propose* flags later + * (P7) — it can never turn a DENY/REVIEW into an ALLOW here. + */ +@Injectable() +export class RuleEngine { + readonly rulepackVersion = 'v1'; + + flag(text: string): RuleFlag[] { + const t = (text ?? '').toLowerCase(); + const flags: RuleFlag[] = []; + if (t.includes('party') || /\b(9|10|11)\s*pm\b/.test(t)) flags.push({ flagType: 'AFTER_HOURS_EVENT' }); + if (t.includes('sale') || t.includes('promo') || t.includes('discount')) flags.push({ flagType: 'PROMOTION' }); + return flags; + } + + /** Destination profiles that are deny-by-default for any flagged content. */ + private readonly protectedProfiles = new Set(['CHILD']); + + decide(binding: DecidableBinding, flagTypes: string[]): { state: IiosRouteDecisionState; reasonCodes: string[] } { + const protectedDest = !!binding.restrictionProfile && this.protectedProfiles.has(binding.restrictionProfile); + + // Deny-by-default: a protected destination (e.g. CHILD) never receives flagged content. + if (protectedDest && flagTypes.length > 0) { + return { state: 'DENY', reasonCodes: [`${binding.restrictionProfile}_GROUP_RESTRICTION`, ...flagTypes] }; + } + if (flagTypes.length > 0) { + return { state: 'REVIEW', reasonCodes: [...flagTypes, 'ADMIN_APPROVAL_REQUIRED'] }; + } + if (binding.requiresReview) { + return { state: 'REVIEW', reasonCodes: ['ADMIN_APPROVAL_REQUIRED'] }; + } + return { state: 'ALLOW', reasonCodes: [] }; + } +}