Messaging Middleware¶
Messaging Middleware is the node-attached application messaging component
that turns Proposal 060 into runtime responsibilities for personal message
admission, outbound contact permission, mailbox indexing, and recovery metadata.
Status: hard-mvp-done
Date: 2026-05-17
Executive Summary¶
Cross-reference to Solution 032 (Local Relationship Layer): With Solution 032, the canonical owner of the
contactsconcept (relationship classes, membership facts, pairwise nym continuity) moves out of this solution. Messaging becomes a consumer of the Local Relationship Layer: it reads activecontactsclass membership for receive consent, triggers membership append through the local host capabilitylocal-relationship.membership.appendoncontact-request.accept, and emitsmessaging-receive@v1based on relationship state.Because the system is still before first release, the Solution 032 compatibility bridge was removed instead of retained. Messaging no longer writes a
contacts_membershipcache and no longer emitscontacts.membership-changed.v1; the canonical fact isrelationship-membership-fact.v1(Solution 032).Messaging may declare relationship-derived
trust_requirementsin its package manifest for autonomous decisions outside the operator loop (e.g. accepting a contact-request from a peer whose operator is already infriendsof the local operator, under bounded scope). Middleware never reads sealed relationship state; it receivesrelationship-policy-decision.v1shapes from the host policy evaluator.
The solution is deliberately stratified:
message-envelope.v1 / contact-request.v1
-> messaging-core domain invariants
-> messaging-service Maildir + SQLite domain runtime
-> daemon host capabilities and authority checks
-> Node UI mailbox and compose surface
The daemon remains the host and authority layer. It owns signing, Capability
Binding, revocation freshness, local participant-handle evidence, notification
actions, Artifact Delivery, Memarium, Pseudonym Vault access, and (via
Solution 032) the canonical Local Relationship Layer. The messaging-service
owns messaging-domain state: Maildir bodies, the hot SQLite index, outbound
queue state, and the bounded Layer 3 messaging-fact stream. contacts
membership read/write goes through the Local Relationship host capability
layer; messaging no longer owns the relationship concept end-to-end.
Scope¶
This solution implements the MVP+ personal messaging path:
- inbound
message-envelope.v1admission through Artifact Delivery; - outbound compose, route lookup, contact permission waiting, and private-direct delivery through host capabilities;
- the canonical
messaging-receive@v1passport profile; - the local contactability draft surface used by Story-010 before Contact Catalog publication is fully automated;
- user-mode readiness wiring: after participant identity and operator binding,
the user wizard prepares messaging by enabling
messaging-service, checking Local Relationship ownership/storage readiness, and saving either a pseudonymous-only contactability route or an optional public-handle draft; - local participant mailbox resolution through a daemon-owned authority store;
- Layer 1 Maildir bodies, Layer 2 SQLite indexes, and Layer 3 Memarium facts;
- degraded operation when Memarium or host capabilities are temporarily unavailable;
- recovery mirroring for contact membership and receive-passport references;
- a thin Node UI for compose, inbox, outbox, status, and diagnostics.
Out of scope for this hard-MVP are body encryption, HTML rendering, group
messaging, CC/BCC, live multi-device mailbox-state push, and full
multi-device vault merge. Read/unread sync is in scope as replayable
messaging.flag.v1 Layer 3 facts.
Passport Profile¶
messaging-receive@v1 is the canonical receive-consent passport profile. The
solution does not introduce a second shape; it documents the
MessagingReceiveProfileV1 profile already used by node/capability.
capability_id = "messaging-receive"
grant = "messaging/receive"
revocation freshness default = 300 seconds
scope profile:
request/id
sender_subjects
recipient_routes
contact_nym_id? # optional; when present, MUST be contact-nym:*
purposes
max_revocation_staleness_seconds
limits?
The messaging acceptor applies three messaging-specific checks after Capability Binding has verified the passport signature and freshness:
- the sender subject in the envelope must match
sender_subjects; - the receiver route must match
recipient_routes; contact_nym_id, when present in the profile, is an additional pairwise contact constraint and must match acontact-nym:*receiver context; route-only profiles for arouting:did:key:...receiver intentionally omit it;- the purpose must include
messaging.
For this solution, a routing subject is a routable pseudonymous subject rather than a full participant nym. It can act as a routing nym for delivery and reply routes, but it does not automatically carry pairwise contact-nym, relationship, reputation, or recovery semantics. Those semantics are added only by an explicit binding/profile or by Local Relationship state.
The preferred private post-accept receiver subject is the pairwise
contact-nym created for the accepted messaging relationship. Its transport
reachability is resolved through a private binding or projection to node or
routing-subject delivery targets, without exposing a root participant id. A
dedicated per-contact routing subject is only the fallback receive-route
profile when contact-nym delivery is not available.
Messaging keeps two precedence orders explicit. Relationship/conversation
subject selection prefers contact-nym/id, then participant/id, before
falling back to transport fields. Delivery target selection keeps the transport
order: routing-subject/id, node/id, then participant/id. This prevents a
routable subject from silently hiding a participant identity in relationship
matching while preserving the existing delivery path for AD/INAC targets.
Sender-side passport lookup keeps those two axes separate. When the queued
recipient route is a transport subject such as routing:did:key:...,
capability.passport.lookup requires that transport receiver and must not
send the sender-local contact-nym/id as an additional scope requirement. If
the returned messaging-receive@v1 passport is pairwise-nym scoped, the
sender records that receiver-issued contact-nym/id in the effective route
context before delivery. Passport route context may refresh the pairwise
contact-nym/id, but it must not overwrite the current queued transport route;
transport fields from the passport only fill missing address fields.
The passport may be presented inline or by reference, but the service never owns private keys and never mints the passport directly.
Verifier boundary rule: when capability_id = "messaging-receive", the
capability layer rejects passports that do not contain at least one canonical
messaging-receive@v1 scope profile, and it rejects alternate profile
discriminators such as bare messaging-receive. If
max_revocation_staleness_seconds is omitted by an older producer, the typed
profile defaults it to 300 seconds before evaluation.
Host Capabilities¶
The messaging service consumes host capabilities through the standard supervised module environment:
ORBIPLEX_HOST_CAPABILITY_BASE_URL;ORBIPLEX_HOST_CAPABILITY_AUTH_HEADER;ORBIPLEX_HOST_CAPABILITY_AUTHTOK_FILE.
The following capabilities are part of the solution boundary:
| Capability | Owner | Messaging use |
|---|---|---|
capability.passport.lookup |
Daemon / Capability Binding | Select or verify a usable messaging-receive passport for outbound queue promotion and inbound passport-ref admission. |
local-recipient-mailbox.resolve |
Daemon / local participant authority | Resolve an inbound receiver route and optional public handle to an operator or participant mailbox. |
artifact.delivery.send |
Daemon / Artifact Delivery | Send signed contact-request.v1 via contact lookup and signed message-envelope.v1 via private-direct; outbound envelopes carry classification.v1 so INAC/private routes pass the shared classification egress guard. |
signer.sign |
Daemon / signer | Sign outbound contact requests and message envelopes under the contact-request.v1 and message-envelope.v1 domains granted to messaging-service. |
memarium.write |
Daemon / Memarium | Append bounded Layer 3 messaging facts. |
notification.create |
Daemon / notification center | Notify the operator about newly stored inbound messages. |
identity.routing-subject.create |
Daemon / Pseudonym Vault | Create a private reply route for outbound contact requests. |
identity.messaging-recovery.mirror |
Daemon / Pseudonym Vault | Persist private recovery mirror records for membership and receive-passport references. |
agora.vault.put/list/get/delete |
Agora service / daemon host bridge | Store and recover encrypted generic vault artifacts for recorded messages without exposing message metadata as Agora topic records. |
User-Mode Readiness¶
The user desktop wizard treats messaging readiness as an application-level requirement after participant identity and operator binding. This gate is for the user-mode app and messaging surface; operator/headless nodes may keep messaging disabled and report it as a degraded or unavailable capability without blocking the whole node.
The MVP wizard has one messaging setup screen. It checks or prepares:
- Local Relationship Store readiness and owner/ref configuration;
messaging-serviceenabled/running status;- the local
messaging-sendreadiness/passport gate, issuing the missing local passport with a bounded bootstrap lifetime when the daemon exposes it as a pending local-readiness requirement; - Maildir/SQLite/Temporal storage paths reported by the service;
- a contactability draft with a messaging routing subject.
Public e-mail or SMS is optional at this stage. The default mode is
pseudonymous-only: the daemon creates or reuses a Pseudonym Vault
routing-subject for messaging contactability and stores an explicit draft
marker with no public handles. The alternate public-handle-draft mode stores
an e-mail/SMS draft routed through the same routing subject; attestation and
Contact Catalog publication remain in the full messaging surface.
This setup does not create a global "messaging nym". Contactability names a local routing subject, and pairwise contact nyms are created only when a contact request or relationship is accepted.
Storage Model¶
Layer 1 is Maildir. Outbound local bodies are stored as native Orbiplex EML
profile v1 files under maildir/outbox/body/, so the outbox can be
reconstructed without SQLite. Inbound accepted message-envelope.v1 artifacts
remain byte-identical signed JSON under <mailbox-id>/new/*.json; an EML
Maildir sidecar under projections/maildir-eml/ is only a regenerable MUA
projection marked by .orbiplex-projection. Bodies are retained until
explicit user/operator delete or archive.
Layer 2 is SQLite. It stores mailbox indexes, outbound queue state, pending Layer 3 facts, and recovery replay cursors. It is rebuildable from Layer 1 plus Layer 3 and vault recovery records.
Layer 3 is Memarium. It receives separate fact schemas, not a generic
messaging.fact.v1 artifact:
messaging.passport-issued.v1;messaging.passport-revoked.v1;messaging.retention-decided.v1;messaging.crisis-marked.v1.
If Memarium is unavailable, the service stores facts in pending_facts, reports
degraded, and replays them idempotently when requested.
Recorded messages add a side path, not a fourth messaging storage layer.
message-envelope.v1.recording states that a signed envelope is intended for
encrypted preservation. The messaging service keeps delivery authoritative in
the ordinary outbox/mailbox state and then attempts a best-effort
agora.vault.put of an agora-vault-entry.v1 artifact. Vault failures update
the message's vault.* diagnostics and remain retryable; they do not roll back
message delivery. Replies or forwards to a locally known recorded parent must
carry recording.required = true; otherwise inbound admission refuses before
Maildir write with recording-lineage-required.
Mailbox Resolution¶
Inbound messaging always calls local-recipient-mailbox.resolve; it never
infers a mailbox from contact-nym/id alone.
The daemon owns local public-handle evidence in a store separate from remote
address-book contacts. verified means a fresh evidence reference to a
contact-control passport exists. A local UX projection without that evidence is
only mapped. Node-id or routing-subject delivery without a known public
handle or local participant-handle evidence falls back to the operator mailbox.
When the receiver is a nym or participant id, that identity is the primary routing authority. A public handle supplied with the message may narrow the route only when local evidence proves that the handle belongs to the same participant; otherwise the daemon refuses without giving the sender a user-unknown oracle.
Outbound Flow¶
The outbound queue is deterministic and retryable:
waiting-for-routesends a signedcontact-request.v1throughartifact.delivery.sendwithselector/kind = contact-lookup,lookup/mode = invitation-only, andselector/purpose = contact-request/messaging. The request uses the signer-derived participant id and a fresh routing-subject reply route from the Pseudonym Vault. If Contact Catalog lookup is the only known recipient address,recipient/routeis omitted; the receiving daemon binds the request to its local node id instead of trusting a sender-invented route. Theartifact-delivery-envelope.v1wrapper is labelledclassification.v1witheffective_tier = Community, matching the hard-MVP INAC/private route budget. When acontact-lookup-result.v1arrives for an outbox item, the service promotes only concrete route candidates whoseselected/route.purposescontainsmessaging;no-match,policy-denied, andambiguousare terminal failures, while stale or rate-limited results stay retryable.waiting-for-contact-permissioncallscapability.passport.lookupfor a usablemessaging-receivepassport. Repeated equivalent contact requests use a logical local-state id derived from sender, recipient route, requested capability and purposes, so retries collapse the visible pending prompt while preserving the original wirerequest/idin the stored request JSON.ready-for-deliverybuilds and signsmessage-envelope.v1, attaches the passport reference or inline passport, and sends it throughartifact.delivery.sendasprivate-direct. The AD envelope carries the sameCommunityclassification label before leaving the service boundary; message-body privacy and future per-conversation policy remain separate messaging-domain layers.in-flightbecomesdeliveredafter a successful host capability call.- Retryable transport/host failures set
next_attempt_at; terminal schema, conflict, or scope failures becomefailed-terminal.
The service never sends unsigned artifacts. If signer.sign is unavailable,
the queued row remains retryable and the service reports degraded state.
Recovery¶
The recovery boundary is private and host-owned. The messaging service mirrors:
contactsmembership records;messaging-receivepassport references needed to recover receive consent.
The daemon persists these records through
identity.messaging-recovery.mirror in a durable local recovery mirror table.
Local contact and messaging recovery bundles are sealed into
pseudonym-vault.v1; import and operational-vault-key startup replay preserve
terminal pairwise mapping states. Legacy root-only snapshots are handled as
recovery/migration inputs, while explicit passphrase replay covers
root+local-passphrase local-contact recovery snapshots. A separate
POST /v1/messaging/reindex rebuilds SQLite mailbox indexes from Maildir and
Layer 3 facts and exposes reindexing through service status.
UI Boundary¶
Node UI owns /admin/messaging. It renders status, contactability draft
settings, provider challenge/redeem controls, compose, inbox, mailbox lists,
read/unread controls, outbox, message detail, pending-facts diagnostics, and
recovery/reindex actions by calling /v1/messaging/* daemon proxies. It does
not duplicate messaging policy logic.
The contactability panel is draft-first. Editing public handles and route
bindings never mutates Contact Catalog state until the user invokes Publish.
Publish now requires contact-control evidence from the attestation flow and
submits a signed contact-claim.v1 admission to the supervised Contact Catalog.
Local contacts are a daemon-owned UX and continuity projection. They may carry
labels, local metadata, and the active pairwise contact-nym mapping used for
operator-facing continuity, but they are not network evidence. The canonical
receive-consent state is Local Relationship contacts membership plus the
corresponding messaging-receive@v1 passport.
mailbox.open is a host-owned notification action target. It opens
/admin/messaging/messages/{message_id} and may mark the notification handled
or the local read UX state; it does not mutate messaging-domain consent or
delivery state.
The user contact chat modal is a narrow read-model over the same mailbox and
outbox stores. It derives route keys from the selected local contact, normalizes
inbound and outbound rows before matching, and only matches exact canonical
keys. Human labels and display names are presentation data, not identity
evidence. The modal fetches at most a bounded recent window, hydrates text
bodies through the bounded body endpoints, and renders non-text, oversized,
missing, or digest-mismatched bodies as placeholders or typed error rows.
Opening or refreshing the modal has no write effects. Read state changes only
through the explicit mark-read command, which recomputes conversation
membership and writes messaging.flag.v1 facts for visible unread inbound
messages. messaging-mailbox-changed SSE carries only id-like invalidation
data; the UI then re-reads through authenticated HTTP and preserves the composer
while swapping the history fragment.
The local contact route-key index is active-contact only. handle.kind =
"other" remains a display/local-continuity handle, not a Contact Catalog lookup
handle; when its value is itself a typed route subject (routing:*,
participant:*, node:*, contact-nym:*), the index records the corresponding
typed route key so contact-request repair and conversation matching can reuse
the existing contact. The index is rebuildable: opening an upgraded local
contact store backfills missing route-key index rows from stored contact records
before matching uses it. state = blocked contacts are excluded from active
matching, and existing schema-safe pairwise/contact-nym-id values are
preserved during contact-request repair instead of being replaced by fallback
nyms. If a repair replaces a legacy non-schema-safe nym, the daemon emits a
typed audit trace with an explicit legacy-contact-nym-not-schema-safe reason.
The user contact rail excludes local receive routing-subject keys from its
dedupe set but keeps participant and contact-nym keys, so route rotation does
not hide a distinct contact while stable pairwise identity still collapses
duplicates.
For operator/MUA tooling, inbound signed JSON envelopes remain the authority.
The projections/maildir-eml/ tree is a disposable Maildir projection marked
with .orbiplex-projection; a MUA may rename files or set Maildir flags there
without mutating canonical messaging state. Outbox bodies are native Orbiplex
EML profile v1 files and can be used for outbox recovery during reindex.
Implementation Tracker¶
| ID | Feature | Status | Evidence |
|---|---|---|---|
| S027-001 | Canonical messaging-receive@v1 profile enforcement |
done | Node capability rejects non-canonical messaging-receive profile discriminators, defaults missing max_revocation_staleness_seconds to 300 seconds, accepts route-only profiles without contact_nym_id, and rejects malformed contact_nym_id values that do not use the contact-nym:* namespace. |
| S027-002 | Messaging service runtime | done | messaging-service covers inbound accept, outbox, contact-lookup-result promotion, sender-side lookup against a shared remote Contact Catalog provider, receive-passport handoff, private-direct delivery, native EML outbox body storage, canonical inbound JSON Maildir storage with disposable EML sidecar projection, SQLite storage, bounded body-read endpoints for inbound mailbox rows and outbound outbox rows, temporal outbox transaction/event/attempt tables with outbox as the public projection, redacted outbox event snapshots that omit raw recipient handles and subjects, replay-equivalence tests, operator temporal diagnostics endpoints for status/redacted events/correlation/replay-check via daemon proxy, recorded-message lineage enforcement, best-effort Agora Vault storage diagnostics and retryable vault-job replay, kind-specific Layer 3 fact artifacts, pending Memarium replay, recovery mirroring, receiver-side revocation snapshot checks for inline messaging-receive@v1 passports, fail-closed no-host behavior for passport-based first contact, revocation-triggered messaging.passport-revoked.v1, read/unread sync through messaging.flag.v1, reindex with remote Memarium replay + local Layer 3 replay + native EML outbox recovery + canonical inbound Maildir JSON + disposable EML sidecar rebuild + FTS5 rebuild, and strict Story-010 cross-node delivery smoke coverage. Mock-host coverage covers inline revocation, outbound passport lookup, signer, artifact.delivery.send, agora.vault.put, redacted failure classes, and remote replay. |
| S027-003 | Contactability and local contacts | done | Daemon exposes contactability draft/options/attest/publish endpoints, requires contact-control passport evidence at publish time, binds the published owner participant to the draft route or attestation passport subject, signs canonical route-set contact-claim.v1, admits it to the supervised Contact Catalog, validates local-contact.v1 import/export, stores local contact labels/metadata, tracks pairwise mapping lifecycle, and exposes /v1/local-contacts/resolve. Local contact and messaging recovery bundles seal into pseudonym-vault.v1, replay on import and operational-vault-key startup, preserve terminal pairwise mapping states, treat root-only as recovery/migration-only, and explicit operator passphrase replay covers root+local-passphrase local-contact recovery snapshots. |
| S027-004 | Contact attestation service dependency | done | Node adds attestation-core, supervised opt-in attestation-service, contact attestation schemas/examples, schema-gate validators, email-attestation / phone-attestation capability ids, local/dev delivery, SMTP email delivery, SMS webhook delivery, attempt limits, challenge TTL, quotas, delivery audit, and a default-disabled always_accept provider policy for local acceptance profiles. Daemon contactability options discover trusted/fresh role/email-attestation / role/phone-attestation providers through Seed Directory, expose provider status in Node UI, start challenges through daemon contactability endpoints, import immediate always_accept passport results when returned, and retain challenge redeem for non-local profiles. Story-010 now uses that runtime path for e-mail-control acquisition without manual OTP handling in local profiles. |
| S027-005 | Node UI messaging surface | done | Node UI renders /admin/messaging with contactability draft controls, provider challenge/redeem controls, compose, local-contact based unknown-recipient warning, inbox, read/unread actions, outbox, diagnostics, and message detail. User UI also renders a contact chat modal as a bounded read-model over the messaging-service mailbox/outbox stores: it hydrates text bodies through bounded body endpoints, merges inbound and outbound rows through the shared local-relationship-core::route_key canonicalizer, refreshes history through messaging-mailbox-changed SSE plus polling fallback, and marks rendered unread inbound rows through an explicit mailbox-scoped flag mutation. Relationship/conversation subject precedence is separated from delivery-route precedence so participant/id is not hidden by a transport route, and active local-contact route-key matching preserves schema-safe pairwise contact nyms while excluding blocked contacts. |
| S027-006 | Story-010 acceptance pack | done | node/tools/acceptance/story-010-operator/ provides two-node profile generation, launchers, UI helpers, story-smoke, and self-contained ad-smoke. Strict ad-smoke now defaults to the no-scaffold path: contact-request.v1 is admitted through the schema-scoped INAC preflight/policy gate, then the receiver sees the contact-request notification and explicitly accepts the relationship before any message-envelope.v1 delivery authority exists. It also covers local always_accept Attestation Service acquisition through daemon contactability endpoints, daemon contactability publish, supervised Contact Catalog admission, shared remote lookup, contact request delivery, operator accept, messaging-receive@v1 passport handoff, private-direct message-envelope.v1 delivery, delivered inbox/outbox state, messaging.flag.v1 read/unread replay, and a second recorded message stored as an encrypted generic artifact in Node B's Agora Vault. The old peer allowlist/preissued transport passport path and explicit INAC operator-approval path are retained only as acceptance debug/policy variants. |
| S027-007 | User-mode messaging readiness wizard | done | Node UI adds a third welcome step after participant identity and operator binding. The step can enable messaging-service, checks Local Relationship owner/storage readiness and messaging-send readiness, creates/reuses a Pseudonym Vault routing subject, and saves either explicit pseudonymous-only contactability or a public-handle-draft. It does not mint a global messaging nym; pairwise contact nyms remain acceptance-time relationship facts. |
References¶
doc/project/40-proposals/060-messaging-middleware.mddoc/project/40-proposals/061-contact-attestation-service.mddoc/project/30-stories/story-010-message-to-a-friend.mddoc/project/60-solutions/023-artifact-delivery/023-artifact-delivery.mddoc/project/60-solutions/025-contact-catalog/025-contact-catalog.mddoc/project/60-solutions/026-pseudonym-vault-and-key-roles/026-pseudonym-vault-and-key-roles.md