Skip to content

refactor(core): inbound dispatch and join protocol in core, one message-type registry, typed remote errors - #605

Merged
stormer78 merged 9 commits into
mainfrom
refactor/core-dispatch-typed-errors
Nov 15, 2026
Merged

stormer78 merged 9 commits into
mainfrom
refactor/core-dispatch-typed-errors

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Moves the protocol logic that had no TUI dependency out of openvtc/src/state_handler/ into openvtc-core, derives the inbound accept gate and the dispatcher from one registry, and gives remote failures a typed class. Seven commits, each building and passing clippy -D warnings and cargo test --workspace on its own: three moves, then the registry, then typed errors, then tests, then the changelog.

Closes #530
Closes #531
Closes #536
Part of #537

What moved where (#530)

From (CLI) To (openvtc-core) Notes
state_handler/message_dispatch.rs (whole file: process_inbound_message, InboundEffects, VerifyJob, PreVerified, Arrival, Deferred, issue_member_vmc_for, triage helpers, 36 tests) inbound/mod.rs, inbound/tests.rs No logic change in this commit. The three per-panel InboundReply structs are now aliases of one inbound::ThreadedReply<R>. The one test that applies a reply to the TUI's Repos view stays in the CLI (state_handler/inbound_glue_tests.rs).
join_cmd::apply_inbound inbound::apply_inline repos_cmd and join_cmd call core now, not state_handler.
relationship_actions::{RDidPlan, CreatedRDid, plan_relationship_did, create_io, create_relationship_keys, relationship_did_signer} relationships::keys Return OpenVTCError instead of anyhow::Error.
setup_sequence::vta::{vta_retry, vta_retryable} (and their tests) vta_task::{retry, retryable, MAX_ATTEMPTS} The CLI keeps vta_retry as a re-export.
join_flow::{learn_over_http, NotLearned, learn_signing, note_signing, suggested_context, context_taken, build_pending_record, forget_minted_persona, RESOLVE_TIMEOUT} join::sequence
The submit transport choice and the signed submit (duplicated in run_join_sequence and join_cmd) join::sequence::{choose_submit_route, SubmitRoute, TransportNote, submit, SubmitError} Both callers now use the same code. The core function returns the transport note; each caller shows it (TUI: join page; headless: a progress line).
state_handler/join_status_poll.rs (whole file) join::status_poll

Where a function used to write to TUI State, core now returns a value (SubmitRoute.note, SubmitError, InboundEffects) and the CLI applies it. Core has no State or ratatui types.

Not moved yet (the join-flow rework, #529, picks it up): the rest of run_join_sequence. That covers minting a fresh persona into state.setup, resolving and verifying the invitation (vault lookup, linkage proof), answering the community's questions from a face, the hidden-vetting proof precheck, the listener start/await, and the page/progress plumbing. These steps are interleaved with State writes and state_tx sends at almost every step. Moving them cleanly needs the progress-callback seam #529 introduces. setup_sequence/ (persona minting, VTA provisioning; mint_persona_into twice) is untouched.

The registry (#531)

openvtc_core::inbound::registry::ROUTES is a &[Route] of (TypeMatcher, Family). TypeMatcher is Exact, Prefix or Predicate(fn(&str) -> bool). A predicate is used where the owning module already defines its types: witness::is_reply_type, JoinProtocol::is_submit_response, and vetting's may_claim minus the three types it shares. Family is declared through a small macro so Family::ALL is complete by construction.

  • Gate. didcomm::routes_inbound_type and classify_inbound admit a type exactly when registry::classify returns a family. The hand-written OPENVTC_CATCH_ALL_PATTERN regex and the regex dependency are gone.

  • Dispatcher. process_inbound does the same pre-routing steps as before (age, sender binding, replay, size, envelope open, deferral). It then makes one exhaustive match family { … } with one handler function per family. The 25-branch if-chain is gone, and a new family without a handler does not compile. verification_job triages on the same families.

  • Shared traffic. Routes are disjoint, so their order means nothing; a test checks this. Three families share traffic inside their handler, in the order the if-chain used:

    • a trust-task-error is offered to capability, git-ns, rooms, elections, witness, vetting, a renewal of ours, then the join;
    • a credential delivery is read by vetting first;
    • a problem report is read by vetting first.

    Each handler body is the old branch text. Only its variable bindings changed.

  • Census. openvtc/tests/inbound_gate_census.rs moved to openvtc-core/tests/inbound_gate_census.rs and now checks the registry. It checks that every routed type reaches its expected family, every family has a route, no type is claimed twice, the gate equals the registry, every trust-task-error version is admitted, and unrouted types are refused.

Visible change: the gate no longer admits whole namespaces that no handler claims: any spec/vtc/*, the legacy trusttasks.org/openvtc/vtc/* authority, and any linuxfoundation.org/openvtc/*. Before this PR, such messages reached the dispatcher and were dropped as "unknown message type". Now the gate drops them, and they are still logged at warn: the gate's warn now also covers the OpenVTC protocol namespaces. Every type the new gate admits was admitted by the old regex, so the gate only got narrower. didcomm_transport_e2e used a made-up linuxfoundation.org/openvtc/test/1.0/ping type that only the old broad gate admitted; it now uses a real routed type.

Typed remote errors (#536)

OpenVTCError::Remote { service: RemoteService, kind: RemoteKind, detail } (additive; OpenVTCError is #[non_exhaustive]).

  • RemoteService: Vta, Mediator, Peer, Http.
  • RemoteKind: Unreachable, Timeout, AuthRejected, Contract, NotFound, Refused { code }, Failed.
  • Classifiers live in one place: RemoteKind::from_vta(&VtaError) (uses vta_receive_leg::is_reply_timeout for reply timeouts), from_atm(&ATMError) (uses its typed HTTP status), from_refusal_code (Trust Task / problem-report codes, scoped or not), and from_http_status.
  • UI accessors: OpenVTCError::class() -> ErrorClass (with is_remote() and remedy()), remote_kind(), and errors::class_of(&dyn Error), which walks an anyhow chain. ErrorClass maps the older variants by their documented meaning: Vta and Resolver → Unreachable, Auth → AuthRejected, ATM → classified.
  • DiscoverError::kind() (peer HTTP manifest fetch) and MessagingError::class() (listener/send) give the same classes for those layers.

Where it is used:

  • 71 typed construction sites in core (OpenVTCError::vta(context, &e) / OpenVTCError::remote(..)). These cover persona pool, profile, binding, world, contacts, lifecycle, disclosure, join answers, claim types and correlation; devices; rebuild; community contexts (vta_failure); relationship keys; git signing; and the VTA connect path. Responses missing a field they must carry are classed Contract.
  • Counts:
    • OpenVTCError::Vta(String) constructions went from 75 to 18 occurrences. Of those 18, 6 are real constructors left on local or shape conditions; the rest are the variant itself, matches and tests.
    • Of the 155 Config(String) sites in core, 5 were remote failures (git signing's VTA calls) and are converted. The 154 left are local config, storage, encoding or crypto errors, plus 3 local preconditions that moved in with dispatch. Splitting those further (Clock, Encoding, Io, …) is left for later.
  • Substring matching replaced: the persona pool's "is it still there" check used format!("{e}").contains("notFound"). It now checks RemoteKind::NotFound, which also counts a plain REST 404/410 as gone.
  • Startup and diagnosis read the class: main.rs's VTA-failure branch picks its hint from the class, and diagnostics::diagnose has a Remote arm with the class's remedy.

Wording that changed: VTA failures render as VTA <kind>: <context>: <sdk error> (e.g. VTA timed out: …, VTA rejected our credentials: …) instead of VTA Error: …. vta_failure's auth and forbidden cases are now Remote rather than Auth; same text, typed.

Not converted (string matching that is not about failure class):

  • git_workspace::clone_failure_words parses git's stderr, which is the only signal there is.
  • holder_grant::needs_holder_grant and vetting_actions::faces::preview_refusal match specific refusal phrases the VTA sends with no code. That needs codes upstream (R3.7).
  • ui/pages/main/components/identity_panel.rs (failed.contains("connection refused")) is in the directory another PR is editing, so it is deliberately untouched. It and the connection header move to class() in the follow-up the issue names.
  • CLI setup_sequence/vta.rs still wraps VtaError in anyhow! strings with explain().

Tests (#537, part)

openvtc-core/tests/inbound_dispatch_e2e.rs drives process_inbound_message end to end. Alice sends over the in-process test mediator; Bob's transport unpacks the message; it goes through routes_inbound_type and the dispatcher; and any deferred proof check is run off the loop and handed back, as the runtime loop does. The tests:

  • a trust-pong closes our ping, and a trust-ping is recorded;
  • a relationship request: its DID-binding proof is checked against Alice's resolved did:peer, and it is queued;
  • a VRC in an established relationship: its proof is checked, and it is queued;
  • a community's signed trust-task-error in the binding envelope rejects our pending join and tears the session down;
  • a type no handler claims is refused by the gate, and dispatch changes nothing.

They are #[ignore]d like the rest of the mediator suite (CI's coverage job runs them with --include-ignored).

The harness gained start_profile_inbound, which delivers every message with its sender. Reviewer note: the delivery layer reports the forwarding mediator as sender and does not expose the authcrypt unpack metadata. The harness therefore uses the inner from of an encrypted message as the authenticated sender. That is sound for these tests, because the harness authcrypt-packs everything it sends, but it is not the production encrypted_from_kid check.

Also new: unit tests for the error classifiers and class_of through anyhow; census tests on the registry; and the CLI glue test for the Repos view.

What is left

Pre-merge checklist (dev guide §9)

  • No new reqwest::Client::new() / bare fetch; all clients have finite timeouts (R1.2). No new clients; the moved code keeps its existing bounded timeouts (RESOLVE_TIMEOUT, JOB_TIMEOUT, VTA_TIMEOUT).
  • No lock held across a network await (R1.3). Unchanged; the moved code takes no locks.
  • No local state committed before its remote effect, or the flow is resumable (R2.1). The order is unchanged: the pending record is still written only after submit returns Ok, and a minted persona is still rolled back on a failed send.
  • Every retry is bounded and backed off; non-idempotent ops are not blind-retried (R1.4). vta_task::retry is the same 3-attempt exponential back-off, moved.
  • Accept/poll/listen loops survive transient errors (R1.5). Unchanged.
  • Acks/deletes happen only after durable handoff (R1.6). Unchanged; dispatch does not ack.
  • New/changed wire types (R3.*). No wire types changed. The gate now admits only what a handler claims (R7.1 deny-by-default).
  • Config absence = most restrictive; fail closed (R5.*). No config added.
  • Logs/status claim only what was verified; failures surfaced (R6.*). R6.4 is the point of the typed errors. Dropped-at-gate messages stay at warn for Trust Task and OpenVTC namespaces.
  • "Process dies on the next line" answered for every mutation touched (R2.1). No mutation was reordered.
  • Deviations flagged. One: the e2e harness's authenticated sender is the inner from of an authcrypt message (see Tests). Removing the public OPENVTC_CATCH_ALL_PATTERN is a breaking openvtc-core API change, so this PR should not be backported to a release branch.

Verification

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --locked -- -D warnings, and with --no-default-features
  • cargo test --workspace --locked, and with --no-default-features
  • cargo test --workspace --locked -- --include-ignored
  • cargo clippy -p openvtc --all-targets --features dev-overrides -- -D warnings
  • cargo test -p openvtc --features dev-overrides
  • RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps
  • cargo machete

…:inbound

process_inbound_message, InboundEffects, VerifyJob/PreVerified/Arrival/
Deferred and the helpers they need had no TUI dependency, so they move to
openvtc-core unchanged. The three per-panel InboundReply structs become
aliases of one core ThreadedReply<R>. The headless loop
(join_cmd::apply_inbound) becomes openvtc_core::inbound::apply_inline,
which repos_cmd and join_cmd call instead of reaching into state_handler.

The dispatcher's tests move with it; the one that applies a reply to the
TUI's Repos view stays in the CLI (inbound_glue_tests). Test-only
VerifyJob variants are behind a new test-support feature the CLI enables
from dev-dependencies only.

Refs #530

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

RDidPlan, plan_relationship_did, RDidPlan::create_io and the VTA key
minting behind it move to openvtc_core::relationships::keys, with
relationship_did_signer. They now return OpenVTCError instead of
anyhow::Error. The bounded VTA retry the setup flow and the R-DID minting
share moves to openvtc_core::vta_task::retry (with its tests); the CLI
keeps vta_retry as a re-export for its setup code.

Refs #530

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

The parts of the join sequence that touch only Config and the network move
to openvtc_core::join::sequence: reading a community's manifest over HTTP
(learn_over_http / NotLearned / learn_signing), choosing the membership's
context, choosing the submit transport (choose_submit_route, which
returns the note the TUI used to show inline), signing and sending the
submit (submit, with a SubmitError the caller words), the pending record,
and rolling back a minted persona. The join status poll moves whole to
openvtc_core::join::status_poll.

The TUI's run_join_sequence and the headless community join now call the
same submit and transport choice instead of two copies. What the person is
shown stays with each caller. Minting a persona, invitation resolution and
the answers/hidden-proof steps stay in join_flow for the join-flow rework
(#529).

Refs #530

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

openvtc_core::inbound::registry::ROUTES lists every inbound type this
client handles and the Family it belongs to. The transport's accept gate
(didcomm::routes_inbound_type / classify_inbound) admits a type exactly
when classify() gives it a family, and process_inbound matches on that
family exhaustively, one handler function per family. The hand-written
OPENVTC_CATCH_ALL_PATTERN regex and the order-dependent if-chain are gone,
and verification_job triages on the same families.

Routes are disjoint; the families that share traffic (a trust-task-error
offered to every view, a credential delivery and a problem report vetting
reads first) do so inside their handler, in the order the if-chain used.

The gate no longer admits whole namespaces no handler claims (spec/vtc/*,
the legacy openvtc/vtc/* authority, any linuxfoundation.org/openvtc/*).
Those messages were dropped by the dispatcher as unknown before; they are
now dropped at the gate, still at warn for Trust Task and OpenVTC protocol
namespaces. The census test moves to core and checks the registry.

Closes #531

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…, ErrorClass)

OpenVTCError::Remote { service, kind, detail } carries what kind of remote
failure happened: Unreachable, Timeout, AuthRejected, Contract, NotFound,
Refused { code } or Failed, each a different remedy (R6.4). RemoteKind
classifies VtaError, ATMError, Trust Task refusal codes and HTTP statuses
in one place; OpenVTCError::class() and errors::class_of() (through an
anyhow chain) give the UI an ErrorClass with is_remote() and remedy().
DiscoverError::kind() and MessagingError::class() do the same for peer
HTTP fetches and the messaging layer.

VTA client calls in core now build Remote via OpenVTCError::vta(context,
&e) (persona pool/profile/binding/world/contacts/lifecycle/disclosure/
join answers, devices, rebuild, community contexts, relationship keys,
git signing, the connect path); responses missing a field they must carry
are Contract. The persona pool's "is it still there" check matches the
NotFound kind instead of the substring "notFound". Startup's VTA-failure
branch and the diagnosis read the class.

Refs #536

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
inbound_dispatch_e2e sends from one party over the in-process mediator
and puts what the other's transport delivers through the inbound gate and
process_inbound_message, running any deferred proof check off the loop
as the runtime loop does: a trust-pong closing our ping and a trust-ping
recorded, a relationship request proven against the sender's did:peer
and queued, a VRC proven and queued, a community's signed refusal
rejecting a pending join, and an unclaimed type refused by the gate.

The harness gains start_profile_inbound, which delivers every message
with its sender.

Part of #537

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
… typed remote errors

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner November 15, 2026 05:41
…y 404

A bare REST 404/410 (an older VTA, a misrouted request) is reported as the
failure it is rather than as "no single stored value".

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 86a74eb into main Nov 15, 2026
19 checks passed
@stormer78
stormer78 deleted the refactor/core-dispatch-typed-errors branch November 15, 2026 06:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant