feat(tsp): carry Trust Tasks in the TSP binding envelope - #246
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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>}, andtrust-tasks-tsp'sunpack_trust_taskrefuses anything else withWrongEnvelopeType.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.tswraps 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.mjsnow 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 test— 1352 pass, 0 fail. Build clean.