feat(p9): thread purpose + persist consent receipt through OutboundService

Task S2.2: OutboundService.send gains an optional purpose, passes it to the broker,
and persists the returned consentReceiptRef on IiosOutboundCommand (new nullable
column, migration outbound-consent). RouteService.execute passes 'route_forward';
calendar reminders pass 'meeting_reminder'. CMP-DENY → command BLOCKED, no send.
2 tests: receipt recorded on allow, purpose-DENY blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:26:01 +05:30
parent 2223d7a891
commit cdc59be481
6 changed files with 35 additions and 6 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "IiosOutboundCommand" ADD COLUMN "consentReceiptRef" TEXT;
+2 -1
View File
@@ -706,9 +706,10 @@ model IiosOutboundCommand {
channelType String channelType String
target String target String
payload Json payload Json
status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED | BLOCKED
idempotencyKey String @unique idempotencyKey String @unique
providerRef String? providerRef String?
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
attempts IiosDeliveryAttempt[] attempts IiosDeliveryAttempt[]
@@ -115,4 +115,22 @@ describe('Adapter outbound (P5)', () => {
expect(cmd.status).toBe('BLOCKED'); expect(cmd.status).toBe('BLOCKED');
expect(cmd.providerRef).toBe('blocked'); expect(cmd.providerRef).toBe('blocked');
}); });
it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
const ports = makeFakePorts();
ports.cmp.setStatus('ALLOW');
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
expect(cmd.status).toBe('SENT');
expect(cmd.consentReceiptRef).toBe('fake-receipt');
});
it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
const ports = makeFakePorts();
ports.cmp.denyPurpose('marketing');
const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()));
const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
expect(cmd.status).toBe('BLOCKED');
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
});
}); });
@@ -27,6 +27,7 @@ export class OutboundService {
payload: Record<string, unknown>, payload: Record<string, unknown>,
idempotencyKey: string = randomUUID(), idempotencyKey: string = randomUUID(),
scopeId?: string, scopeId?: string,
purpose?: string,
) { ) {
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } }); const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
if (existing) return existing; if (existing) return existing;
@@ -45,7 +46,7 @@ export class OutboundService {
let result; let result;
try { try {
result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId }); result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose });
} catch (err) { } catch (err) {
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate. // Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
await this.prisma.$transaction([ await this.prisma.$transaction([
@@ -56,7 +57,10 @@ export class OutboundService {
} }
const [updated] = await this.prisma.$transaction([ const [updated] = await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: result.outcome, providerRef: result.providerRef } }), this.prisma.iiosOutboundCommand.update({
where: { id: cmd.id },
data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef },
}),
this.prisma.iiosDeliveryAttempt.create({ this.prisma.iiosDeliveryAttempt.create({
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 },
}), }),
@@ -178,6 +178,8 @@ export class CalendarService {
`meeting:${meeting.id}`, `meeting:${meeting.id}`,
{ text: `Reminder: "${meeting.title}"` }, { text: `Reminder: "${meeting.title}"` },
`meeting-reminder:${meeting.id}`, `meeting-reminder:${meeting.id}`,
meeting.scopeId,
'meeting_reminder',
); );
for (const p of meeting.participants) { for (const p of meeting.participants) {
if (!p.actorRefId) continue; if (!p.actorRefId) continue;
@@ -87,6 +87,8 @@ export class RouteService {
binding.destinationRef ?? 'default', binding.destinationRef ?? 'default',
{ text: preview.text ?? '' }, { text: preview.text ?? '' },
`route:${decision.id}`, `route:${decision.id}`,
binding.scopeId,
'route_forward',
); );
await this.prisma.iiosRouteDecision.update({ where: { id: decision.id }, data: { executed: true, executedCommandId: cmd.id } }); await this.prisma.iiosRouteDecision.update({ where: { id: decision.id }, data: { executed: true, executedCommandId: cmd.id } });
} }