Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/triggers/run-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { IdentityService } from "../identity/identity-service.ts";
import type { DeliveryStore } from "../delivery/delivery-store.ts";
import type { IdempotencyStore } from "../idempotency/idempotency-store.ts";
import { turnModelOptions } from "../core/turn-options.ts";
import { reachEnqueue } from "../reach/reach.ts";
import { principalDestination, reachEnqueue } from "../reach/reach.ts";
import { consentRequiredRecipient, recipientConsentSatisfied } from "./trigger-store.ts";
import { isVisible, type VisibilityDirectory } from "../directory/visibility.ts";
import { samePerson } from "../directory/person.ts";
Expand Down Expand Up @@ -188,15 +188,27 @@ export async function runTrigger(deps: TriggerDeps, spec: TriggerSpec): Promise<
let note: string | undefined;
let reply: string | undefined;
let sessionId: string | undefined;
const ownerSkipNotice = async () => {
await deps.deliveries.enqueue({
destination: principalDestination(spec.owner, spec.owner),
text: `Scheduled delivery skipped: ${consentNote}`,
idempotencyKey: `${spec.fireKey}:err`,
provenance: deliveryProvenance(spec, threadRef),
...(spec.shadow ? { shadow: true } : {}),
});
};
const ran = await deps.idempotency.once(spec.fireKey, async () => {
if (spec.message !== undefined) {
status = "ok";
if (!spec.destination) return;
if (!consented) {
status = "refused";
note = consentNote;
await ownerSkipNotice();
return;
}
if (!deliverable) {
status = "refused";
note = notVisibleNote;
return;
}
Expand Down Expand Up @@ -248,10 +260,13 @@ export async function runTrigger(deps: TriggerDeps, spec: TriggerSpec): Promise<
if (!spec.destination) return;
if (liveDelivery) return;
if (!consented) {
status = "refused";
note = consentNote;
await ownerSkipNotice();
return;
}
if (!deliverable) {
status = "refused";
note = notVisibleNote;
return;
}
Expand Down
8 changes: 7 additions & 1 deletion test/cron-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,13 @@ test("a recurring teammate-DM cron without current recipient consent is withheld
},
});
await scheduler.runNow(cron.id);
assert.equal((await deliveries.pending("principal")).length, 0);
const pending = await deliveries.pending("principal");
assert.equal(pending.length, 1);
assert.equal(pending[0]?.destination.target, "U1");
assert.match(pending[0]?.text ?? "", /consent.*skipped/i);
const stored = await crons.get(cron.id);
assert.equal(stored?.fireLog?.[0]?.status, "refused");
assert.match(stored?.fireLog?.[0]?.note ?? "", /consent/);
});

test("a teammate-DM cron created in a channel delivers its real output (§10 parity gate)", async () => {
Expand Down
36 changes: 32 additions & 4 deletions test/trigger-consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ describe("runTrigger: recipient-consent gate", () => {
recipientConsent: { recipientId: "U-alice", status: "pending" },
});
assert.equal(
(await deps.deliveries.pending("principal")).length,
(await deps.deliveries.pending("principal")).filter((delivery) => delivery.destination.target === "U-alice")
.length,
0,
"nothing reaches a recipient who hasn't accepted",
);
Expand Down Expand Up @@ -145,7 +146,11 @@ describe("runTrigger: recipient-consent gate", () => {
destination: toAlice,
recipientConsentRequired: true,
});
assert.equal((await deps.deliveries.pending("principal")).length, 0);
assert.equal(
(await deps.deliveries.pending("principal")).filter((delivery) => delivery.destination.target === "U-alice")
.length,
0,
);
});

it("accepted consent for a prior recipient does not authorize a retargeted standing DM", async () => {
Expand All @@ -160,7 +165,26 @@ describe("runTrigger: recipient-consent gate", () => {
recipientConsent: { recipientId: "U-alice", status: "accepted" },
recipientConsentRequired: true,
});
assert.equal((await deps.deliveries.pending("principal")).length, 0);
assert.equal(
(await deps.deliveries.pending("principal")).filter((delivery) => delivery.destination.target === "U-bob").length,
0,
);
});

it("an unrelated turn refusal sends no consent notice (nothing was withheld for consent)", async () => {
const deps = triggerDeps(async () => ({ status: "refused", reason: "runtime not approved" }));
const out = await runTrigger(deps, {
owner: "U-carol",
ownerScopeId: scopeId("personal", "U-carol"),
input: "compose",
fireKey: "c-unrelated",
surface: "cron",
destination: toAlice,
recipientConsentRequired: true,
});
assert.equal(out.status, "refused");
assert.doesNotMatch(out.note ?? "", /consent/);
assert.equal((await deps.deliveries.pending("principal")).length, 0, "no misleading skip notice to anyone");
});

it("withholds a verbatim relay too (a declined recipient gets nothing)", async () => {
Expand All @@ -177,7 +201,11 @@ describe("runTrigger: recipient-consent gate", () => {
destination: toAlice,
recipientConsent: { recipientId: "U-alice", status: "declined" },
});
assert.equal((await deps.deliveries.pending("principal")).length, 0);
assert.equal(
(await deps.deliveries.pending("principal")).filter((delivery) => delivery.destination.target === "U-alice")
.length,
0,
);
assert.match(out.note ?? "", /turned this delivery off/);
});
});
1 change: 1 addition & 0 deletions test/visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe("runTrigger: fire-time visibility gate", () => {
assert.equal(out.authzFailed, false, "not disabled — staleness must not kill a cron");
assert.equal((await deps.deliveries.pending("slack")).length, 0, "no post to a channel the owner can't see");
assert.match(out.note ?? "", /no longer visible/);
assert.equal(out.status, "refused", "a withheld delivery is not recorded as a success");
});

it("skips the run entirely (§10 leg a) when the actor lost the HOME scope — the exfil shape", async () => {
Expand Down