Skip to content

Latest commit

 

History

History
517 lines (384 loc) · 28.4 KB

File metadata and controls

517 lines (384 loc) · 28.4 KB

Web 7.0 Pando — Claude Code Project Context


Repository Structure

SVRN7/
├── src/
│   ├── Svrn7.Core/          Models, interfaces, enums (DidDocument, Svrn7Role, DidStatus, etc.)
│   ├── Svrn7.Crypto/        secp256k1 key generation, Base58, signing (CryptoService)
│   ├── Svrn7.DIDComm/       DIDComm V2 pack/unpack (all modes: plaintext, JWS, JWE, SignThenEncrypt)
│   ├── Svrn7.Store/         LiteDB registries: LiteDidDocumentRegistry, LiteInboxStore, etc.
│   ├── Svrn7.Federation/    ISvrn7Driver, Svrn7Driver — federation + DID management
│   ├── Svrn7.Society/       ISvrn7SocietyDriver — adds citizen/VC/transfer layer
│   ├── Svrn7.Identity/      DIDDocumentService, DID resolve pipeline
│   ├── Svrn7.Ledger/        Epoch, supply, Merkle ledger
│   ├── Svrn7.TDA/           Trusted Digital Assistant — Kestrel host + LOBE runtime
│   └── Web7.SVRN7.Apps.PandoMail/   WinForms email client (.NET 8, Windows)
├── tests/
│   ├── Svrn7.Tests/
│   ├── Svrn7.TDA.Tests/
│   └── Svrn7.Society.Tests/
├── tools/                   Initialize-Testnet.ps1, Build-LOBEPackages.ps1, Pando.Packaging.psm1
└── docs/                    DEBUG.md, DIDDEBUG.md, WANDERERDEBUG.md, LOBEDEBUG.md, etc.

TDA Architecture

POST /didcomm (HTTP/2, Kestrel)
  └── DrawbridgeService
        └── IDIDCommService.UnpackAsync       ← JWE decrypt + JWS verify at inbound boundary
              └── LiteInboxStore.EnqueueAsync
                    └── DIDCommMessageSwitchboard (drain loop)
                          └── LobeManager.TryResolveProtocol(@type)
                                └── LobeManager.EnsureLoadedAsync(modulePath)
                                      └── Import-Module [-Force] in IsolatedRunspaceFactory
                                            └── Cmdlet invocation (PowerShell.Invoke())
                                                  └── OutboundMessage (plaintext envelope)
                                                        └── PackOutboundAsync            ← SignThenEncrypt at outbound boundary (HTTP only)
                                                              └── DeliverAsync (HTTP/2)

Key components:

Class Role
DrawbridgeService Single inbound gate: POST /didcomm only
DIDCommMessageSwitchboard Inbox drain loop; routes by @type; outbound delivery
LobeManager Protocol registry; eager/JIT import; EnsureLoadedAsync
IsolatedRunspaceFactory Runspace pool (min 2, max ProcessorCount×2)
TdaHost / $SVRN7 Context object injected into every runspace; exposes Driver, GetMessageAsync, CurrentEpoch, etc.

Eager LOBEs are loaded once at startup into the InitialSessionState. JIT LOBEs run Import-Module -Force on every dispatch — hot-update without TDA restart (overhead ~30 ms, tracked as TDA-001a).


Design Rationale: HTTP/2 for POST /didcomm

The TDA's public inbound surface is HTTP/2-only (no HTTP/1.1 fallback). Reasons:

  1. mTLS mutual peer authentication — TDA-to-TDA communication requires both sides to authenticate with certificates. HTTP/2 + TLS 1.3 via ALPN is the modern standard for this on Kestrel; it eliminates downgrade attacks that are possible when HTTP/1.1 is allowed as a fallback.

  2. Multiplexed concurrent streams without HOL blocking — A TDA may receive many inbound DIDComm messages simultaneously from multiple peers. HTTP/2 multiplexes them over a single connection; HTTP/1.1 head-of-line blocking would serialize them.

  3. Minimal, single-endpoint attack surface — One verb (POST), one path (/didcomm), one protocol, one port. Disabling HTTP/1.1 removes an entire class of HTTP/1.1-specific parsing vulnerabilities (request smuggling, header injection).

  4. Mature on Kestrel / same transport as gRPC — .NET's HTTP/2 stack is battle-tested via gRPC. Same deployment, same operational patterns, no additional runtime dependency.

  5. HPACK header compression — DIDComm headers (Content-Type: application/didcomm-encrypted+json, etc.) are identical on every message. HTTP/2 compresses them after the first exchange.

Why not gRPC directly? gRPC framing (Protobuf + length-prefixed binary) adds schema complexity for third-party LOBE authors and clients that only need to POST a JSON envelope. Raw HTTP/2 POST keeps the wire format simple.

Local UI clients (PandoMail): PandoMail is on .NET 8. TdaMailClient.Send() uses HttpClient with HTTP/2 + mTLS directly for POST /didcomm. The /localcomm-ws push channel uses RFC 8441 (WebSocket over HTTP/2 extended CONNECT) — a deliberate design choice for server-push, not a .NET limitation.


Design Rationale: SignThenEncrypt at Outbound Boundary (DIDCommMessageSwitchboard)

DIDCommMessageSwitchboard.PackOutboundAsync applies SignThenEncrypt (secp256k1 ES256K JWS inside X25519 JWE) to every outbound message before HTTP delivery. LOBEs always return plaintext OutboundMessage objects — they have no access to key material and no knowledge of which pack mode to use. The Switchboard is the sole place where packing decisions are made.

Transport rule (P-008):

Transport Direction Protocol Pack mode Enforcement
HTTP (POST /didcomm) — TDA-to-TDA outbound General SignThenEncrypt (secp256k1 ES256K JWS inside X25519 JWE) Switchboard PackOutboundAsync
HTTP (POST /didcomm) — TDA-to-TDA outbound DID discovery Plaintext (application/didcomm-plain+json) Switchboard IsPlaintextDiscoveryMessage → skip PackOutboundAsync
HTTP (POST /didcomm) — TDA-to-TDA inbound General SignThenEncrypt required DrawbridgeService rejects non-encrypted with 415
HTTP (POST /didcomm) — TDA-to-TDA inbound DID discovery Plaintext accepted DrawbridgeService admits application/didcomm-plain+json only for PlaintextDiscoveryProtocols; rejects other plaintext with 403
WebSocket (/localcomm-ws) — TDA-to-local-UI outbound All Plaintext (application/didcomm-plain+json) Switchboard (PeerEndpoint starts with ws://)
WebSocket (/localcomm-ws) — local-UI/tool-to-TDA inbound All Plaintext accepted localhost-only; no content-type gate

DID discovery plaintext whitelist (Svrn7Constants.PlaintextDiscoveryProtocols):

  • did:drn:svrn7.net/protocols/Svrn7.Identity.0.8.0/did-resolve-request
  • did:drn:svrn7.net/protocols/Svrn7.Identity.0.8.0/did-resolve-response

Why plaintext for DID resolution? DID resolution is an open discovery service — any TDA may query any other TDA's DID Documents (own, Citizens, Societies) freely, without a prior relationship. Encryption is structurally impossible on this path: you cannot encrypt to a peer whose DID Document you have not yet resolved, which is exactly what the query is for. The plaintext permission is scoped strictly to PlaintextDiscoveryProtocols; any other plaintext on POST /didcomm is rejected 403.

Why plaintext for WebSocket? The /localcomm-ws channel is localhost-only. PandoMail holds no key material and shares the Citizen TDA's DID. Encryption would require giving PandoMail long-lived private keys — that contradicts the shared-identity design and would expand the attack surface unnecessarily.

Symmetric design: This is the outbound counterpart of the decrypt-at-boundary pattern on the inbound side. DrawbridgeService unpacks every inbound message at the HTTP boundary before anything enters the inbox; DIDCommMessageSwitchboard.PackOutboundAsync packs every outbound HTTP message at the delivery boundary before anything leaves the process (DID discovery protocols excepted).

Fallback behaviour: If the recipient's DID Document does not contain an X25519KeyAgreementKey2020 entry — for example, a TDA bootstrapped before X25519 keys were added — PackOutboundAsync logs a warning and sends plaintext. This is a degraded mode. All TDAs bootstrapped with the current codebase include an X25519 key by default.

Key material in TdaOptions:

Field Key type Use
AgentKeyAgreementPrivateKey X25519 (32 bytes) Inbound JWE decryption (DrawbridgeService.UnpackAsync)
AgentSigningPrivateKey secp256k1 (32 bytes) Outbound JWS signing (DIDCommMessageSwitchboard.PackOutboundAsync)

Both are decrypted from agent-identity.wallet at startup (pre-host) and held for the process lifetime. Neither is passed into LOBE runspaces.


Design Rationale: Decrypt-at-Boundary (DrawbridgeService)

DrawbridgeService fully unpacks every inbound DIDComm message — JWE decryption and JWS signature verification — before anything is written to the inbox. The decrypted plaintext body is what gets stored in InboxMessage.PackedPayload. The original wire envelope is preserved separately in InboxMessage.JweEnvelope for audit. This is a deliberate architectural constraint, not an implementation convenience.

Storing the raw JWE and decrypting later (in the Switchboard or in a LOBE) would break the system in five distinct ways:

  1. Routing breaks entirely — DIDCommMessageSwitchboard routes by InboxMessage.MessageType, which is extracted from the unpacked inner payload at enqueue time. A stored JWE has no accessible @type; the Switchboard would have to assign a synthetic type (application/didcomm-encrypted+json) that no LOBE handles, dead-lettering every encrypted message.

  2. Every LOBE breaks — LOBE handlers access message content as:

    $body = $msg.PackedPayload | ConvertFrom-Json

    If PackedPayload is a JWE envelope, ConvertFrom-Json returns the JWE structure, not the application payload. All existing LOBEs would require a decrypt-first step, making LOBE authoring significantly more complex and error-prone.

  3. Key material must enter the LOBE runspace — Decrypting in a LOBE requires the X25519 private key (AgentKeyAgreementPrivateKey) to be available there. Currently it is held only by DrawbridgeService and never enters a runspace. Injecting it into $SVRN7 expands the attack surface: a buggy or malicious LOBE would have access to the TDA's decryption key.

  4. FromDid is unavailable for reply routing — The sender DID is extracted from the JWS inner layer during UnpackJwsAsync. Without unpacking at the boundary, InboxMessage.FromDid is null. LOBE handlers that need to send a reply have no sender identity without repeating the full unpack.

  5. Stuck-message recovery corrupts the retry cycle — LiteInboxStore.ResetStuckMessagesAsync requeues Processing messages to Pending on startup after a crash. If stored payloads are JWE envelopes the Switchboard cannot route, each recovery attempt fails, re-enqueues, and fails again until maxAttempts is exhausted and the message is permanently dead-lettered. Every unclean shutdown would silently destroy messages.

The JweEnvelope field satisfies the audit/forensics use case without any of the above costs: the original wire format is always available for inspection, replay, or legal hold, while the pipeline operates entirely on verified plaintext.


LOBE Authoring

Folder and file convention

lobes/
└── {Name}.{version}/
      ├── {Name}.{version}.psm1       ← entry-point module (exported cmdlets only)
      ├── {Name}.{version}.lobe.json  ← descriptor
      └── {Name}.Impl.{version}.psm1  ← optional implementation module

lobe.json required fields

{
  "lobe": { "name": "Pando.Diagnostics", "version": "0.1.0", "module": "Pando.Diagnostics.0.1.0.psm1" },
  "protocols": [
    {
      "uri":        "did:drn:svrn7.net/protocols/Pando.Diagnostics.0.1.0/Query-TOD",
      "direction":  "inbound",
      "match":      "exact",
      "entrypoint": "Invoke-PandoDiagnosticsDateQuery"
    }
  ]
}

match is "exact" or "prefix". direction is always "inbound" for handler protocols.

psm1 rules

  • #Requires -Version 7.2 and Set-StrictMode -Version Latest at the top of every LOBE psm1.
  • $ErrorActionPreference = 'Stop'
  • Use Assert-BodyFields / Get-BodyField (from Svrn7.Common) for all body field access — Set-StrictMode throws on absent PSCustomObject properties before guards can fire.
  • Every handler returns either [Svrn7.TDA.OutboundMessage] (to trigger outbound delivery) or $null (no reply).
  • LOBEs always construct plaintext envelopes (typ = 'application/didcomm-plain+json'). The Switchboard applies SignThenEncrypt for HTTP delivery and plaintext for WebSocket delivery — LOBEs do not call DIDCommPackingService directly.
  • Export-ModuleMember -Function @(...) must be explicit.

Handler signature

function Invoke-MyLobeVerb {
    [CmdletBinding()]
    [OutputType([Svrn7.TDA.OutboundMessage])]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string] $MessageDid
    )
    process {
        $msg  = $SVRN7.GetMessageAsync($MessageDid).GetAwaiter().GetResult()
        $body = $msg.PackedPayload | ConvertFrom-Json -ErrorAction Stop
        # ... handle ...
        $endpoint = Resolve-SocietySenderEndpoint -Did $msg.FromDid
        if (-not $endpoint) { Write-Warning "...: cannot resolve endpoint — result not delivered."; return }
        [Svrn7.TDA.OutboundMessage]::new($endpoint, $envelope)
    }
}

Protocol URI Convention

did:drn:svrn7.net/protocols/{LOBE}.{version}/{Verb-Noun}

Examples:

  • did:drn:svrn7.net/protocols/Pando.Diagnostics.0.1.0/Query-TOD
  • did:drn:svrn7.net/protocols/Svrn7.Federation.0.8.0/initialize-federation
  • did:drn:svrn7.net/protocols/PandoMail.0.8.0/message

svrn7.net always (never svrn7.io). Verb-Noun uses PascalCase or kebab-case consistently within a LOBE.

Outbound PeerEndpoint is the full URL including path — used verbatim, no suffix appended:

http://localhost:8443/didcomm

TDA Launch and Data Layout

See docs/AGENTWALLET.md for the full design (per-identity storage, encrypted wallet, encrypted databases, per-instance LOBEs, publish workflow) and SECURITY.md for the at-rest/local security model (password, pin, wallet, LiteDB encryption, instance naming/port design, threat model).

$env:PANDO_WALLET_PASSWORD = '...'      # or you are prompted (double-entry on first run)
dotnet .\Svrn7.TDA.dll --name MyTDA [--port 8443] [--url http://localhost] [--reset]
Parameter Default Notes
--name (required) Selects the runtime directory; stored as Svrn7Name in the Wanderer DID Document on first run
--port auto First run: used verbatim if given, else auto-selected from --port-base (8440). Later runs: rebinds the published port; a conflicting --port is rejected unless --republish-endpoint
--port-base / --port-span 8440 / 64 First-run auto-select range
--url http://localhost Base URL for the DID Document service endpoint; full endpoint = {url}:{port}/didcomm
--data-root / $PANDO_HOME ~/.web7-pando Root for all per-identity data
--recovery-phrase "<12 words>" — First run only: restore from an existing BIP39 phrase
--republish-endpoint off Move the published endpoint to this run's --port/--url (rewrites the DID Document in the encrypted svrn7-dids.db, Version + 1)
--reset off Deletes the whole <name>-<genesisHash8>/ directory and re-bootstraps (confirm prompt on a terminal)
--lobe-feed / Tda:LobeRemoteFeed — Fallback source (dir/UNC or HTTP base URL) for a LOBE package not in lobe-library/
db-shell (subcommand) — Svrn7.TDA.dll db-shell --name X [--db dids|msg|vcs|schemas|main] [--collection C] [--sql "…"] — read the encrypted databases after unlocking the wallet

Data layout (~/.web7-pando/ by default):

~/.web7-pando/
├── bin/Debug/net8.0/              published TDA binaries (web7-pando publish profile)
├── lobe-library/                  {id}.{version}.nupkg — LOBE package source (filled by Publish or --lobe-feed)
└── <name>-<genesisHash8>/         one directory per identity  (slug = Blake3(secp256k1 pub)[..8])
      ├── agent-identity.wallet    encrypted: secp256k1 + X25519 keys, did, role, recovery phrase, DB master key
      ├── agent-identity.wallet.bak / .lockout
      ├── identity.meta.json       cleartext locator — did, name, role, createdUtc, parentTdaDid (pointer only).
      │                            NO endpoint URLs: every endpoint comes from the encrypted svrn7-dids.db
      │                            (own port via DidRegistryPeek pre-host; parent via its DID Document).
      │                            Rewritten unconditionally each startup (scrubs stale keys from old builds).
      ├── lobes/                   per-instance; lobes.config.json materialized from the embedded default,
      │                            LOBE modules installed on first reference from lobe-library/
      └── mem/                     svrn7.db, svrn7-dids.db, svrn7-msg.db, svrn7-vcs.db, svrn7-schemas.db
                                   (+ LiteDB *-log.db) — ALL AES-encrypted; key derived from the wallet

Wallet: Svrn7.Trust.AgentWallet — Argon2id (64 MiB/3/4) + AES-256-GCM. Password from $PANDO_WALLET_PASSWORD else an interactive prompt (fails fast if neither). The DB master key is a stable random 32 bytes inside the wallet payload, so a password change never re-keys the databases.

Testnet script: tools/Initialize-Testnet.ps1 builds, copies dist/*.nupkg into ~/.web7-pando/lobe-library/, sets $env:PANDO_WALLET_PASSWORD, and launches Wanderer1–4 on 8441–8444 in separate titled console windows.


DID and Role Model

Roles (additive, not exclusive)

Role Svrn7Role enum DID format
Wanderer Wanderer did:drn:wanderer.svrn7.net/agent/1.0/<genesis-hash>
Citizen Citizen did:drn:<societyname>.svrn7.net/citizen/1.0/<genesis-hash>
Society Society did:drn:federation.svrn7.net/<societyname>/1.0/<genesis-hash>
Federation Federation did:drn:federation.svrn7.net/federation/1.0/<genesis-hash>

All DIDs use the did:drn: method. <genesis-hash> = Blake3(genesis_secp256k1_compressed_pubkey_bytes) hex-encoded (64 chars). Derived once from the initial key pair and never changes — key rotation updates the DID Document's verificationMethod entries without altering the DID itself. New-Svrn7Did derives the DID deterministically from the key pair and role.

DidStatus enum

Active | Suspended | Deactivated

Svrn7Role enum (partial)

Wanderer | Citizen | Society | Federation | LOBEPackageManager | LOBEMarketplace

First-run Wanderer bootstrap

On a first run (no <name>-<hash8>/ directory for this --name) the TDA, before the host is built:

  1. generates a 12-word BIP39 phrase (or takes --recovery-phrase), derives the secp256k1 identity key (BIP32 m/7'/0'/0'/0/0) and the X25519 key-agreement key (HKDF from the same seed);
  2. genesisHash = Blake3(secp256k1 compressed pubkey) → instance dir ~/.web7-pando/<name>-<genesisHash8>/;
  3. generates a random 32-byte database master key;
  4. writes agent-identity.wallet — one AES-256-GCM blob (Argon2id password key) holding both private keys, did, role, the recovery phrase, and the DB master key. No plaintext key file.

After the host builds it creates the DID Document (secp256k1 + X25519 public keys, X25519KeyAgreementKey2020, service endpoint on the bound port) inside the encrypted svrn7-dids.db — the single source for every endpoint URL — and writes the cleartext identity.meta.json locator (did / name / role / createdUtc / parentTdaDid only; no endpoint). Later runs read their own bound port from that DB via DidRegistryPeek in the pre-host block. Bootstrap telemetry: Svrn7.TDA.Bootstrap ActivitySource + Meter (AddSource/AddMeter in Program.cs), counters tda.bootstrap.{endpoint_peek,port_resolved,meta_write, parent_endpoint_resolve}.

Key roles (unchanged): the secp256k1 key does genesis-hash derivation, DIDComm JWS signing (AgentSigningPrivateKey), and transaction signing; the X25519 key does JWE encrypt/decrypt (AgentKeyAgreementPrivateKey). Neither ever enters a LOBE runspace. agent-identity.json (plaintext) is superseded — migrate with --reset.


Commit Convention

  • Never add Co-Authored-By, Generated by, or any AI attribution to commits or source files.
  • Commit messages: imperative mood, concise subject line, blank line before body if needed.

PowerShell Requirements

  • PowerShell 7.2+ required everywhere — LOBE psm1 files, debug guides, tooling scripts.
  • Set-StrictMode -Version Latest is active in all LOBEs — never access PSCustomObject properties without guards (Assert-BodyFields / Get-BodyField).
  • Dot-sourcing .psm1 files in PS 7 applies module-context scoping. Use [scriptblock]::Create([System.IO.File]::ReadAllText($path)).Invoke() for dynamic loading outside the LOBE runtime.
  • Initialize-Svrn7Assemblies -ModuleRoot $PSScriptRoot must be called before accessing any Svrn7.* .NET types in a standalone PS session (outside a TDA runspace). It is called automatically by New-Svrn7KeyPair, New-Svrn7Did, etc. if the driver is not already initialised.
  • Send-LocalDIDCommMessage (in Svrn7.Common) connects to a local TDA's /localcomm-ws WebSocket and sends a plaintext DIDComm message. POST /didcomm enforces application/didcomm-encrypted+json (SignThenEncrypt) and rejects plaintext. A running TDA is required to receive DIDComm replies.

Known Limitations

Limitation Detail Backlog ref
JIT LOBE reimport on every dispatch Import-Module -Force runs each time for hot-update support; ~30 ms overhead per message. Deferred to Epoch 1. TDA-001a
PS cannot receive DIDComm replies A standalone PowerShell session has no HTTP/2 listener — replies require a running TDA as the from DID. —

Naming Conventions (apply everywhere)

  • Protocol URIs: svrn7.net (never svrn7.io)
  • Cmdlets: Verb-Noun PascalCase — e.g. Send-EmailNotify, Invoke-PandoDiagnosticsDateQuery
  • "DID Document Resolver" / "DID Document Resolution" (not "DID Resolver")
  • Interfaces: IDidDocumentResolver, ISvrn7Driver, ISvrn7SocietyDriver
  • Implementations: LocalDidDocumentResolver, FederationDidDocumentResolver
  • "Shared Reserve Currency (SRC)" (not "Reserve Currency")
  • DidDocument (one word, not DIDDocument) in C# types; DIDDocument in prose
  • LiteDB registry classes: Lite{Entity}Registry / Lite{Entity}Store

PandoMail ↔ Citizen TDA Integration

Overview

Web7.SVRN7.Apps.PandoMail is a .NET 8 WinForms Outlook 2003-style email client. It integrates with the Web 7.0 Pando Citizen TDA in a 1:1 relationship (one PandoMail instance per Citizen TDA instance, same machine).


Transport: DIDComm V2 over Local WebSocket

The TDA pushes async notifications to PandoMail. There is no polling.

The notification channel is a localhost-only WebSocket endpoint on the TDA, on the same port as POST /didcomm:

ws://localhost:{port}/localcomm-ws

Single port serves both surfaces:

Path Protocol Direction
/didcomm HTTP/2 (POST) Inbound DIDComm from remote TDAs
/localcomm-ws WebSocket (RFC 8441 — HTTP/2 extended CONNECT) Outbound push to local UI clients

Kestrel uses HttpProtocols.Http2 only. RFC 8441 enables WebSocket over HTTP/2 on /localcomm-ws without enabling HTTP/1.1 on the listener. PandoMail is on .NET 8 and HttpClient supports RFC 8441, so no HttpProtocols.Http1AndHttp2 workaround is needed and no HTTP/1.1 attack surface is introduced.

This endpoint is not published in the Citizen TDA's DID Document — it is a private local UI attachment point, not a peer-to-peer TDA interface.

Messages on this channel are DIDComm V2 plaintext envelopes (application/didcomm-plain+json). The channel is localhost-only; PandoMail holds no key material and shares the Citizen TDA's DID. The @type field determines dispatch on the PandoMail side. See Principles.md P-008.


Inbound Flow (TDA → PandoMail)

Sender TDA (remote)
  └── DIDComm V2 SignThenEncrypt (secp256k1 JWS + X25519 JWE)
        └── POST /didcomm → Local Citizen TDA (Kestrel, HTTP/2, mTLS)
              └── DrawbridgeService.UnpackAsync  ← decrypt + verify at inbound boundary
                    └── Switchboard (routes by @type)
                          └── Svrn7.Email LOBE (.psm1)
                                ├── Persist to LiteDB (Long-Term Message Memory)
                                ├── Decode SMTP-over-DIDComm payload
                                └── OutboundMessage (plaintext) → ws://localhost:{port}/localcomm-ws
                                      └── TdaMailClient (PandoMail background thread)
                                            ├── DIDComm unpack (plaintext — no decrypt/verify)
                                            ├── Dispatch on @type
                                            └── BeginInvoke → MessageStore.Append()
                                                  └── SortableBindingList<MailMessage>
                                                        └── DataGridView refresh

Outbound Flow (PandoMail → Recipient TDA)

PandoMail Compose UI
  └── TdaMailClient.Send(MailMessage)
        └── POST /didcomm (localhost, mTLS)
              └── Svrn7.Email LOBE
                    └── Wrap as SMTP-over-DIDComm
                          └── DIDComm V2 SignThenEncrypt → Recipient TDA

DIDComm Protocol Type URI

Inbound email notification @type:

did:drn:svrn7.net/protocols/Email-Notify.0.1.0/new-message

Follows the Locator DID URL convention (draft-herman-drn-resource-addressing-00). Protocol URIs use svrn7.net (not svrn7.io).


Key Classes to Add/Modify

Component Project Purpose
TdaMailClient Web7.SVRN7.Apps.PandoMail Send() → POST /didcomm; WebSocket receive loop
MessageStore Web7.SVRN7.Apps.PandoMail Replace Inbox.xml source with TdaMailClient initial load + live append
MainForm Web7.SVRN7.Apps.PandoMail BeginInvoke marshal on WebSocket notification receipt
Svrn7.Email LOBE Svrn7.Email.psm1 After LiteDB persist, send DIDComm Email-Notify envelope over WebSocket

Svrn7.Email LOBE

  • Handles SMTP messages over DIDComm — this LOBE already exists
  • On inbound message: persist to LiteDB first, then push notification to PandoMail via WebSocket
  • The LOBE does not write directly to any PandoMail data structure — pass-by-reference semantics apply (LiteDB ObjectId passed, not payload copy)

Open Decisions

  1. PandoMail DID identity: shared DID (decided). PandoMail shares the Citizen TDA's DID — same identity, no key fragment or sub-DID. TdaMailClient posts to the local TDA using the shared DID; the TDA signs and forwards using its own key material.

  2. Initial inbox load: How PandoMail populates MessageStore on startup (before any WebSocket push arrives) — options are a DIDComm query message to the Svrn7.Email LOBE, or a localhost-only REST endpoint on the TDA.


Architecture Constraints

  • PandoMail targets .NET 8 (Windows only). TdaMailClient.Send() uses HttpClient with HTTP/2 + mTLS directly for POST /didcomm. The /localcomm-ws push channel uses RFC 8441 (WebSocket over HTTP/2 extended CONNECT) — both Kestrel and HttpClient support it at .NET 8.

  • Both /didcomm and /localcomm-ws are on the same port for a given TDA. Kestrel uses HttpProtocols.Http2 only — RFC 8441 carries the WebSocket upgrade over HTTP/2 streams, so no HTTP/1.1 is needed and no second port is opened.

  • The TDA's public inbound surface remains POST /didcomm only (Kestrel, HTTP/2, mTLS). The WebSocket endpoint is a separate, localhost-scoped path on the same port.

  • The WebSocket Local UI Attachment Point pattern is reusable — any local UI app (calendar, contacts, tasks) on any OS where the TDA runs can connect to the same WebSocket endpoint and dispatch on @type. Future notification types:

    did:drn:svrn7.net/protocols/Calendar-Notify.0.1.0/new-event
    did:drn:svrn7.net/protocols/Presence-Notify.0.1.0/status-change