Skip to content

feat(tsp): carry Trust Tasks in the TSP binding envelope - #246

Merged
stormer78 merged 1 commit into
mainfrom
feat/tsp-binding-envelope
Sep 15, 2026
Merged

stormer78 merged 1 commit into
mainfrom
feat/tsp-binding-envelope

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

The wallet half of a coordinated wire change. VTA half: OpenVTC/verifiable-trust-infrastructure (stacked after #1477). They land together.

What we were speaking

Over TSP this wallet sealed the bare document, and said so in its own comments — tsp-channel.ts: "TSP plaintext = the Trust-Task envelope JSON (no binding wrapper)"; tsp-inbound.ts: "over TSP the plaintext is the document, with no wrapper". The VTA parsed exactly that.

The two agreed with each other and with nothing else. The published binding (https://trusttasks.org/binding/tsp/0.1) specifies {"type": …/envelope, "document": <TrustTask>}, and trust-tasks-tsp's unpack_trust_task refuses anything else with WrongEnvelopeType.

So a conformant peer would have rejected every frame we sent, and we all of theirs. More to the point: neither side could ever have used the binding library — which is what makes "adding a transport is adding a binding" untrue in practice rather than in principle. That is the reason to fix it now, ahead of anything that would build on TSP.

Why TSP needs a wrapper when the others don't

Each binding has to say "this payload is a Trust Task" somewhere a reader sees before parsing. HTTPS says it with the request path; DIDComm with the message type. A TSP frame has neither — sender VID, recipient VID, opaque bytes — so the binding puts it in the JSON. The wrapper isn't ceremony; it's the only place TSP has.

One module, both directions

tsp-binding.ts wraps outbound and opens inbound, so the binding is one fact about this package rather than a convention each path remembers. The next transport should be a module beside it, not an edit spread across every sender and receiver.

The wrapper goes on after signing, and must: the proof is taken over the document, so reshaping it during wrapping would invalidate every signature while looking identical.

A payload that isn't an envelope, or carries another binding's type, is refused rather than read as a document — and the refusal names what arrived, so a peer still sending the old shape can see which half is wrong. Accepting a bare one "just in case" would keep the dialect alive as long as anything spoke it.

The test change that matters most

The simulated VTA in tsp.channel.mjs now speaks the binding in both directions. That's more important than the assertions around it: a stub that kept accepting a bare document would let this wallet regress to the old dialect with every test still green.

npm test1352 pass, 0 fail. Build clean.

Over TSP this wallet sealed the **bare document**, and said so: `tsp-channel.ts`
carried the comment "TSP plaintext = the Trust-Task envelope JSON (no binding
wrapper)", and `tsp-inbound.ts` explained that "over TSP the plaintext *is* the
document, with no wrapper". The VTA parsed exactly that. The two agreed with
each other and with nothing else.

The published binding (`https://trusttasks.org/binding/tsp/0.1`) specifies the
payload as `{"type": …/envelope, "document": <TrustTask>}`, and
`trust-tasks-tsp`'s `unpack_trust_task` refuses anything else with
`WrongEnvelopeType`. So a conformant peer would have rejected every frame we
sent and we would have rejected all of theirs — and, more to the point, neither
side could ever have used the binding library, which is what makes "adding a
transport is adding a binding" untrue in practice rather than in principle.

## Why TSP needs a wrapper when the other two bindings do not

Each binding has to say "this payload is a Trust Task" somewhere a reader can
see before parsing. HTTPS says it with the request path; DIDComm says it with
the message `type`. A TSP frame has neither — a sender VID, a recipient VID and
opaque bytes — so the binding puts the marker in the JSON. The wrapper is not
ceremony; it is the only place TSP has to put it.

## One module, both directions

`tsp-binding.ts` wraps outbound and opens inbound, so the binding is a single
fact about this package rather than a convention each path remembers. The next
transport should be a module beside it, not an edit spread across every sender
and receiver.

The wrapper goes on **after** signing, and must: the proof is taken over the
document, so anything that reshaped it during wrapping would invalidate every
signature while looking identical.

A payload that is not an envelope, or carries another binding's type, is refused
rather than read as a document — and the refusal names what actually arrived, so
a peer still sending the old shape can see which half is wrong. Accepting a bare
one "just in case" would keep the dialect alive for as long as anything spoke
it, and nothing is deployed that needs the kindness.

## Cut over with the VTA

A wire change with no deprecation window, which is the rule here: nothing is
deployed, so the two sides move together rather than one of them learning to
accept both shapes.

## Tests

The simulated VTA in `tsp.channel.mjs` now speaks the binding in both
directions. That matters more than the assertions around it: a stub that kept
accepting a bare document would let this wallet regress to the old dialect with
every test still green.

`npm test` — 1352 across the workspace, 0 failures. Build clean.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 21b0465 into main Sep 15, 2026
4 checks passed
@stormer78
stormer78 deleted the feat/tsp-binding-envelope branch September 15, 2026 04:51
stormer78 added a commit to OpenVTC/verifiable-trust-infrastructure that referenced this pull request Sep 15, 2026
Over TSP this service sealed and parsed the **bare document**, and its own
comments said so: `tsp_inbound`'s header called the payload "identical to the
REST body", and the browser wallet's `tsp-channel.ts` carried "TSP plaintext =
the Trust-Task envelope JSON (no binding wrapper)". The two agreed with each
other and with nothing else.

The published binding (`https://trusttasks.org/binding/tsp/0.1`) specifies the
payload as `{"type": …/envelope, "document": <TrustTask>}`, and
`trust-tasks-tsp`'s `unpack_trust_task` refuses anything else with
`WrongEnvelopeType`. A conformant peer would have rejected every frame we sent
and we would have rejected all of theirs.

**The reason to fix it is not conformance for its own sake.** While the wire is
a private shape, `trust-tasks-tsp` can never be used — which is what makes
"adding a transport is adding a binding" untrue in practice rather than in
principle. Every transport after this one would face the same fork.

## Why TSP needs a wrapper when the other two bindings do not

Each binding has to say "this payload is a Trust Task" somewhere a reader sees
before parsing. HTTPS says it with the request path; DIDComm with the message
`type`. A TSP frame has neither — a sender VID, a recipient VID and opaque bytes
— so the binding puts the marker in the JSON. It is not ceremony; it is the only
place TSP has to put it.

## One module, both directions

`messaging::tsp_binding` wraps outbound and opens inbound, so the binding is a
single fact about this service rather than a convention each path remembers. The
push helper `try_push_over_tsp` — shared by step-up and task-consent — goes
through it too: a push is still a Trust Task on a TSP wire, and sending one bare
would leave exactly one frame in the system speaking the old dialect, which is
the hardest kind to find later.

`vault::unseal_tsp_secret` deliberately does **not** change. It unseals a vault
secret, not a Trust Task; the binding has nothing to say about it.

## The refusal names the carriage, not the document

The obvious implementation routes a rejected frame through `reject_trust_task`,
which re-parses the body and, when that fails, replaces the caller's reason with
"body did not parse as a Trust Task document". Here the document is often
perfectly well formed and merely unwrapped, so that message sends the sender to
inspect the one thing that is fine — during a binding cutover, the most
misleading sentence this service could say.

`malformed_request_response` is `body_parse_error_response` with the wording
left to the caller, and a test asserts the refusal names the envelope rather
than the document.

## Cut over with the wallet

OpenVTC/vta-browser-plugin#246 is the other half. No deprecation window and no
dual-accept arm: nothing is deployed, so the two sides move together rather than
one of them learning to speak both shapes.

## A feature gate that moved silently

Adding the module put `tsp_binding` under the `#[cfg(feature = "tsp")]` that
belonged to `tsp_inbound`, so in a build *without* that feature `tsp_inbound`
compiled against two modules that were not there. Every `cargo check --features
tsp` passed; only the full-workspace run, which builds some crates without it,
found it.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit to OpenVTC/verifiable-trust-infrastructure that referenced this pull request Sep 15, 2026
Over TSP this service sealed and parsed the **bare document**, and its own
comments said so: `tsp_inbound`'s header called the payload "identical to the
REST body", and the browser wallet's `tsp-channel.ts` carried "TSP plaintext =
the Trust-Task envelope JSON (no binding wrapper)". The two agreed with each
other and with nothing else.

The published binding (`https://trusttasks.org/binding/tsp/0.1`) specifies the
payload as `{"type": …/envelope, "document": <TrustTask>}`, and
`trust-tasks-tsp`'s `unpack_trust_task` refuses anything else with
`WrongEnvelopeType`. A conformant peer would have rejected every frame we sent
and we would have rejected all of theirs.

**The reason to fix it is not conformance for its own sake.** While the wire is
a private shape, `trust-tasks-tsp` can never be used — which is what makes
"adding a transport is adding a binding" untrue in practice rather than in
principle. Every transport after this one would face the same fork.

## Why TSP needs a wrapper when the other two bindings do not

Each binding has to say "this payload is a Trust Task" somewhere a reader sees
before parsing. HTTPS says it with the request path; DIDComm with the message
`type`. A TSP frame has neither — a sender VID, a recipient VID and opaque bytes
— so the binding puts the marker in the JSON. It is not ceremony; it is the only
place TSP has to put it.

## One module, both directions

`messaging::tsp_binding` wraps outbound and opens inbound, so the binding is a
single fact about this service rather than a convention each path remembers. The
push helper `try_push_over_tsp` — shared by step-up and task-consent — goes
through it too: a push is still a Trust Task on a TSP wire, and sending one bare
would leave exactly one frame in the system speaking the old dialect, which is
the hardest kind to find later.

`vault::unseal_tsp_secret` deliberately does **not** change. It unseals a vault
secret, not a Trust Task; the binding has nothing to say about it.

## The refusal names the carriage, not the document

The obvious implementation routes a rejected frame through `reject_trust_task`,
which re-parses the body and, when that fails, replaces the caller's reason with
"body did not parse as a Trust Task document". Here the document is often
perfectly well formed and merely unwrapped, so that message sends the sender to
inspect the one thing that is fine — during a binding cutover, the most
misleading sentence this service could say.

`malformed_request_response` is `body_parse_error_response` with the wording
left to the caller, and a test asserts the refusal names the envelope rather
than the document.

## Cut over with the wallet

OpenVTC/vta-browser-plugin#246 is the other half. No deprecation window and no
dual-accept arm: nothing is deployed, so the two sides move together rather than
one of them learning to speak both shapes.

## A feature gate that moved silently

Adding the module put `tsp_binding` under the `#[cfg(feature = "tsp")]` that
belonged to `tsp_inbound`, so in a build *without* that feature `tsp_inbound`
compiled against two modules that were not there. Every `cargo check --features
tsp` passed; only the full-workspace run, which builds some crates without it,
found it.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit to OpenVTC/verifiable-trust-infrastructure that referenced this pull request Sep 15, 2026
* refactor(webvh): the did-hosting client builds documents; the seam carries them

`webvh_didcomm` was the second of the workspace's hand-rolled carriages: it
packed a DIDComm envelope, awaited the correlated reply, and mapped a
problem-report, for one peer. All three are now `operations::outbound`'s, and
this client keeps only what is actually did-management's — the document it
builds and the reply it reads.

The measure of the change is that **the module no longer names a transport or a
message type anywhere**. Removing the last one produced an `unused import` on
`ENVELOPE_TYPE`, which is a better proof than any assertion: there is nothing
left here that could send.

## `build_outbound` is gone, and that is the point

It returned the DIDComm message type alongside the document, and its own note
explained why: the type "is the value this entire change exists to get right,
and a wrong one fails *silently*", so it had to come from somewhere a test could
look at. That care was real and it was **local** — the same care had to be taken
again by the next client.

`operations::outbound::DIDCOMM_MESSAGE_TYPE` now names it once for every caller,
with the assertion that went with it. A caller has no argument that could carry
a message type, so the mistake stops being prevented by discipline and starts
being unrepresentable.

## `TransportAuthenticated`, and why this is a disclosure rather than a change

This client has never verified a reply's proof. Routing it through the seam made
that an argument that has to be written down, and the honest value today is
`TransportAuthenticated`: over the DIDComm binding the peer is authenticated end
to end by the authcrypt envelope, and these replies confer nothing — a reserved
path, an availability answer, an acknowledgement.

Whether it *should* be `SignedByRecipient` is a real question, and it is one to
ask of did-hosting (whether it signs these replies at all) rather than to assume
here. What has changed is that the question is now visible at the call site
instead of being the silent consequence of nobody having added a check.

## What this does not finish, and why the blocker is not here

The eight REST arms in `did_webvh`'s transport enum stay. They cannot move,
because did-hosting's Trust-Task HTTPS endpoint does not serve the verbs the VTA
sends it: `build_dispatcher` registers `acl/{grant,revoke,change-role,show,list}`
and `trust-task-discovery`, and nothing else. The did-management verbs travel
over the DIDComm envelope path or the legacy JWT `/api/...` routes.

So completing the single path for did-hosting needs a change in
`affinidi-webvh-service` — serving those verbs over `/api/trust-tasks` — after
which the legacy REST client and this enum both delete. Recorded here so the
next person reads it as a far-side gap rather than an unfinished refactor.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

* feat(tsp): carry Trust Tasks in the TSP binding envelope

Over TSP this service sealed and parsed the **bare document**, and its own
comments said so: `tsp_inbound`'s header called the payload "identical to the
REST body", and the browser wallet's `tsp-channel.ts` carried "TSP plaintext =
the Trust-Task envelope JSON (no binding wrapper)". The two agreed with each
other and with nothing else.

The published binding (`https://trusttasks.org/binding/tsp/0.1`) specifies the
payload as `{"type": …/envelope, "document": <TrustTask>}`, and
`trust-tasks-tsp`'s `unpack_trust_task` refuses anything else with
`WrongEnvelopeType`. A conformant peer would have rejected every frame we sent
and we would have rejected all of theirs.

**The reason to fix it is not conformance for its own sake.** While the wire is
a private shape, `trust-tasks-tsp` can never be used — which is what makes
"adding a transport is adding a binding" untrue in practice rather than in
principle. Every transport after this one would face the same fork.

## Why TSP needs a wrapper when the other two bindings do not

Each binding has to say "this payload is a Trust Task" somewhere a reader sees
before parsing. HTTPS says it with the request path; DIDComm with the message
`type`. A TSP frame has neither — a sender VID, a recipient VID and opaque bytes
— so the binding puts the marker in the JSON. It is not ceremony; it is the only
place TSP has to put it.

## One module, both directions

`messaging::tsp_binding` wraps outbound and opens inbound, so the binding is a
single fact about this service rather than a convention each path remembers. The
push helper `try_push_over_tsp` — shared by step-up and task-consent — goes
through it too: a push is still a Trust Task on a TSP wire, and sending one bare
would leave exactly one frame in the system speaking the old dialect, which is
the hardest kind to find later.

`vault::unseal_tsp_secret` deliberately does **not** change. It unseals a vault
secret, not a Trust Task; the binding has nothing to say about it.

## The refusal names the carriage, not the document

The obvious implementation routes a rejected frame through `reject_trust_task`,
which re-parses the body and, when that fails, replaces the caller's reason with
"body did not parse as a Trust Task document". Here the document is often
perfectly well formed and merely unwrapped, so that message sends the sender to
inspect the one thing that is fine — during a binding cutover, the most
misleading sentence this service could say.

`malformed_request_response` is `body_parse_error_response` with the wording
left to the caller, and a test asserts the refusal names the envelope rather
than the document.

## Cut over with the wallet

OpenVTC/vta-browser-plugin#246 is the other half. No deprecation window and no
dual-accept arm: nothing is deployed, so the two sides move together rather than
one of them learning to speak both shapes.

## A feature gate that moved silently

Adding the module put `tsp_binding` under the `#[cfg(feature = "tsp")]` that
belonged to `tsp_inbound`, so in a build *without* that feature `tsp_inbound`
compiled against two modules that were not there. Every `cargo check --features
tsp` passed; only the full-workspace run, which builds some crates without it,
found it.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

---------

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
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