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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "IiosOutboundCommand" ADD COLUMN "consentReceiptRef" TEXT;
|
||||
@@ -706,10 +706,11 @@ model IiosOutboundCommand {
|
||||
channelType String
|
||||
target String
|
||||
payload Json
|
||||
status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED
|
||||
idempotencyKey String @unique
|
||||
providerRef String?
|
||||
createdAt DateTime @default(now())
|
||||
status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED | BLOCKED
|
||||
idempotencyKey String @unique
|
||||
providerRef String?
|
||||
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
attempts IiosDeliveryAttempt[]
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export class OutboundService {
|
||||
payload: Record<string, unknown>,
|
||||
idempotencyKey: string = randomUUID(),
|
||||
scopeId?: string,
|
||||
purpose?: string,
|
||||
) {
|
||||
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
|
||||
if (existing) return existing;
|
||||
@@ -45,7 +46,7 @@ export class OutboundService {
|
||||
|
||||
let result;
|
||||
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) {
|
||||
// Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate.
|
||||
await this.prisma.$transaction([
|
||||
@@ -56,7 +57,10 @@ export class OutboundService {
|
||||
}
|
||||
|
||||
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({
|
||||
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}`,
|
||||
{ text: `Reminder: "${meeting.title}"` },
|
||||
`meeting-reminder:${meeting.id}`,
|
||||
meeting.scopeId,
|
||||
'meeting_reminder',
|
||||
);
|
||||
for (const p of meeting.participants) {
|
||||
if (!p.actorRefId) continue;
|
||||
|
||||
@@ -87,6 +87,8 @@ export class RouteService {
|
||||
binding.destinationRef ?? 'default',
|
||||
{ text: preview.text ?? '' },
|
||||
`route:${decision.id}`,
|
||||
binding.scopeId,
|
||||
'route_forward',
|
||||
);
|
||||
await this.prisma.iiosRouteDecision.update({ where: { id: decision.id }, data: { executed: true, executedCommandId: cmd.id } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user