Skip to content

fix(consent): read the executor's answer to a decision, don't drop it - #112

Merged
stormer78 merged 1 commit into
mainfrom
fix/read-the-executors-answer-to-a-decision
Aug 9, 2026
Merged

fix(consent): read the executor's answer to a decision, don't drop it#112
stormer78 merged 1 commit into
mainfrom
fix/read-the-executors-answer-to-a-decision

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

The other half of OpenVTC/verifiable-trust-infrastructure#907 (merged). That fixed the VTA refusing an approver who held no ACL entry. This fixes the wallet throwing away the answer, which is why that took so long to find.

The bug

Sending a decision is not the end of the ceremony. The executor replies on the same DIDComm thread — a task-consent/decision/0.1#response on success, a trust-task-error/{0.1,0.2} on refusal — and nothing here recognised either shape. Both fell through onInboundMessage's final "anything else is ignored" branch. No log, no surface, nothing.

From the field, the entire wallet-side record of a refused approval was:

[pnm inbound] task-consent decision sent: approve
[pnm inbound] received (approver inbox) type= …/binding/didcomm/0.1/envelope id= f73b6071… from= did:webvh:…glenn-vta

That second line is the refusal. It was logged as an arrival and then discarded unread.

So a refused approval was indistinguishable, from this side, from one that worked: the human was shown a change, agreed to it, the VTA said no, and the wallet dropped the reason — while the requester re-submitted forever and the operator had no way to tell which end was at fault. That is strictly worse than a lost prompt, because the person believes they have acted.

The fix

  • parseTaskConsentOutcome (core) reads the answer — both error versions and the success response, with status / approvals / needed / details carried through. code is left opaque: 0.1 says permission_denied, 0.2 says permissionDenied, and normalising would invite a caller to branch on one casing.
  • buildTaskConsentDecision now returns { packed, id }. The id is the thid the executor answers on, so the wallet can say which approval was refused rather than that one was. Both call sites (approver inbox, local relay) record it.
  • A refusal raises a notification, not just a console line. It contradicts something the user was shown seconds earlier and agreed to, so it has to reach them where they are. The notifications permission was already declared and previously unused.

What is trusted

Only the authcrypt sender, and only to decide whether to believe the reply. An unenrolled sender is dropped: an unauthenticated party must not be able to tell this device that an approval failed — an invitation to approve a second time — nor that one succeeded. Nothing in the outcome grants anything; the executor's grant remains the authority, and this path is diagnostic.

Ordering

The outcome check runs before parseTaskConsentRequest, because a reply rides the same envelope type and that parser can only classify it as not-a-task-consent-request — the one reason a caller is allowed to ignore something, which is exactly how the reply vanished. A genuine consent request still falls through to the parser that prompts a human; there is a test for that.

The awaiting-decision map

In-memory, bounded at 64, oldest-evicted. Deliberately not persisted: it explains an outcome, it never decides one, so an MV3 teardown losing it costs a good log line and not correctness. Persisting would add a write to the consent hot path and buy nothing. An answer that arrives with no local record is still reported, just without the "which decision" detail — there is a test for that too.

This is unrelated to the R1.6 persist-before-ack defect in CLAUDE.md, and does not touch it.

Tests

192 tests pass (11 new); tsc -b clean across all four workspaces; build clean; the MV3 single-bundle / no-dynamic-import() invariant holds.

New coverage: the permissionDenied refusal that actually occurred, the 0.1 error type, details pass-through, missing retryable reading as not-retryable, granted and partial-approval responses, sender not enrolled, sender unattributable, non-answers returning null so other handlers still see them, thid falling back to the document threadId, and an uncorrelated answer still being reported.

Sending a decision is not the end of the ceremony. The executor replies on
the same DIDComm thread — a `decision/0.1#response` on success, a
`trust-task-error/{0.1,0.2}` on refusal — and nothing here recognised either
shape. Both fell through `onInboundMessage`'s final "anything else is
ignored" branch: no log, no surface, nothing.

So a refused approval was indistinguishable, from this side, from one that
worked. The human was shown a change, agreed to it, the VTA said no, and the
wallet discarded the reason — while the requester re-submitted forever and
the operator had no way to tell which end was at fault. That is strictly
worse than a lost prompt, because the person believes they have acted.

`parseTaskConsentOutcome` reads the answer. `buildTaskConsentDecision` now
returns the decision's document id alongside the packed message, so the
caller can match the reply to the decision it sent and say *which* approval
was refused rather than that one was.

A refusal raises a notification, not just a console line: it contradicts
something the user was shown seconds earlier and agreed to, so it has to
reach them where they are.

Only the authcrypt sender is trusted, and only to decide whether to believe
the reply. An unenrolled sender is dropped — an unauthenticated party must
not be able to tell this device an approval failed (an invitation to approve
a second time) or that one succeeded. Nothing in the outcome grants
anything; the executor's grant remains the authority.

The awaiting-decision map is in-memory, bounded and best-effort by design. It
explains an outcome, never decides one, so an MV3 teardown losing it costs a
good log line and not correctness — persisting it would add a write to the
consent hot path and buy nothing.

`not-a-task-consent-request` remains the only reason a caller may ignore an
inbound message, and the outcome check runs before the request parser so a
reply cannot be mistaken for one. A consent request still falls through to
the parser that prompts a human.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 3267363 into main Aug 9, 2026
3 checks passed
@stormer78
stormer78 deleted the fix/read-the-executors-answer-to-a-decision branch August 9, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant