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
@@ -115,4 +115,22 @@ describe('Adapter outbound (P5)', () => {
expect(cmd.status).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);
});
});