From 473f016566acbedb72330bc0b6eeb743bfc55c30 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Fri, 7 Aug 2026 17:21:15 +0200 Subject: [PATCH] fix(consent): a malformed consent request is reported, not ignored parseTaskConsentRequest returned "not-a-task-consent-request" for two completely different situations: a message not addressed to this handler, and a genuine consent request whose payload is unusable. dispatchInbound keys on that reason to decide whether to stay quiet: if (consent.reason !== "not-a-task-consent-request") { warn; return; } // Anything else is ignored. <- silent So a malformed consent request was discarded in total silence -- no prompt, no log -- and handleInbound's finally then cleared its pending record. The result is indistinguishable from a message that never arrived, which is how it presented: arrival logged by #105, then nothing at all, and a pending-approval badge (#110) that counted zero because the record was already gone. Malformed payloads now return "malformed-payload", so the existing warn path reports them with their detail. The silent reason keeps its single honest meaning: not addressed to this handler. The test that pinned the old shared reason now pins the distinction and the detail, with the reasoning recorded -- it was asserting the exact behaviour that hid this. Signed-off-by: Glenn Gore --- packages/core/src/inbound/task-consent.ts | 11 ++++++++++- packages/core/tests/inbound.task-consent.mjs | 11 +++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/core/src/inbound/task-consent.ts b/packages/core/src/inbound/task-consent.ts index ad2ffa0..47cc8f0 100644 --- a/packages/core/src/inbound/task-consent.ts +++ b/packages/core/src/inbound/task-consent.ts @@ -152,7 +152,16 @@ export interface ParsedTaskConsentRequest { } export type TaskConsentRequestRejection = + /** Not addressed to this handler at all — the ONLY reason a caller may + * ignore silently. Everything else claimed to be a consent request and + * failed, which a human is waiting on and must therefore be reported. */ | "not-a-task-consent-request" + /** It IS a consent request, but its payload is unusable. Distinct from the + * above because it used to share it, and callers key on that reason to + * decide whether to stay quiet: a malformed request was dropped in total + * silence — no prompt, no log, and the pending record cleared — which is + * indistinguishable from a message that never arrived. */ + | "malformed-payload" | "untrusted_issuer" | "expired" | "not_eligible"; @@ -246,7 +255,7 @@ export async function parseTaskConsentRequest( !payload.exposure || typeof payload.exposure !== "object" ) { - return reject("not-a-task-consent-request", "payload is missing required members"); + return reject("malformed-payload", "payload is missing required members"); } const now = opts.now ?? new Date(); diff --git a/packages/core/tests/inbound.task-consent.mjs b/packages/core/tests/inbound.task-consent.mjs index 890860e..2ec1501 100644 --- a/packages/core/tests/inbound.task-consent.mjs +++ b/packages/core/tests/inbound.task-consent.mjs @@ -146,12 +146,19 @@ test("…but may approve its own task when policy permits it", async () => { assert.equal(res.ok, true); }); -test("a payload missing required members is refused", async () => { +test("a payload missing required members is refused, and says so distinctly", async () => { // A genuinely-signed request that is nonetheless unusable: without the digest // there is nothing to bind an approval to, so there is nothing to approve. const res = await parseTaskConsentRequest(await inbound({ drop: ["payloadDigest"] }), opts); assert.equal(res.ok, false); - assert.equal(res.reason, "not-a-task-consent-request"); + // NOT "not-a-task-consent-request". That reason means "not addressed to this + // handler", and callers are entitled to ignore it silently — which is exactly + // what happened to a malformed request in the field: dropped with no prompt, + // no log, and its pending record cleared, indistinguishable from a message + // that never arrived. A request that claimed to be a consent ask and failed + // is something a human is waiting on, so it must stay reportable. + assert.equal(res.reason, "malformed-payload"); + assert.match(res.detail ?? "", /missing required members/); }); // ── What the human is shown ──────────────────────────────────────────────────