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
|
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?
|
||||||
createdAt DateTime @default(now())
|
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
|
||||||
|
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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user