Přeskočit obsah

Contacts and Messaging HOWTO

This document follows the complete path from contact-channel attestation, through route publication and relationship acceptance, to private message delivery. The conceptual answers and responsibility boundaries are in the Contacts and Messaging FAQ.

Separate the sources of truth

Layer Responsibility Not authoritative for
Contact Attestation control of an email address or phone at a particular time civil identity and relationships
Contact Catalog attested claims and lookup-safe invitation routes the local address book
Local contacts labels and an owner-scoped UX projection capability authority
Local Relationship Layer owner classes, memberships, predicates, and groups transport and passports
Pseudonym Vault routing subject, pairwise nym, and recovery material a public directory of people
Messaging outbox, inbox, content, flags, and temporal delivery trail provider discovery
AD + INAC private artifact transport and admission relationship consent

Check configuration and readiness

Run daemon check-config first, then inspect each layer separately:

GET /v1/contact-catalog/status
GET /v1/messaging/status
GET /v1/local-relationships/status
GET /v1/messaging/contactability/options

One component reporting ready does not imply readiness of the whole path. Contactability options should expose a local participant and a discovered, trusted, fresh attestation provider. Remote contact additionally needs a working AD/INAC path.

Prepare a participant and routing subject

Contactability is published for a particular local participant, but the catalog must not expose that participant's root identity. Create or import the participant, unlock their signing key, and create a separate routing subject. Preserve this separation:

participant/id       -> local authority and signature
routing-subject/id   -> public invitation route
pairwise nym         -> relationship after contact-request acceptance

Save a contactability draft

The draft declares handles and the route purpose. It does not publish a claim yet:

POST /v1/messaging/contactability/draft
Content-Type: application/json
{
  "handles": [
    {"handle/kind": "email", "handle/value": "[email protected]"}
  ],
  "routes": [
    {"participant/id": "participant:LOCAL", "purpose": "messaging"}
  ]
}

The handle value is transient operator input. The durable catalog index should be lookup-safe; do not copy a raw email address into public diagnostics or audit.

Obtain contact attestation

Start a challenge through the daemon bridge, then redeem it using the provider's delivery adapter:

POST /v1/messaging/contactability/attestation/challenges
POST /v1/messaging/contactability/attestation/challenges/{challenge_id}/redeem

The contact-attestation-request.v1 request specifies contact/kind, contact/value, subject, requested profile, and validity period. The result is a channel-control passport. Story-010 profiles may use an explicit development always_accept shortcut; never carry that setting into a production profile.

Bind the attestation and publish a claim

Bind the passport to the draft handle first, then publish the signed claim:

POST /v1/messaging/contactability/attest
POST /v1/messaging/contactability/publish
{
  "handle/kind": "email",
  "handle/value": "[email protected]",
  "passport": {"schema": "capability-passport.v1"}
}

The passport value above abbreviates the complete passport returned by redeem; the shown object is not a valid standalone passport.

Publication passes through the supervised Contact Catalog. A claim with invalid attestation, an expired passport, or a mismatched subject should be refused.

Verify lookup without disclosing identity

The Contact Catalog provider accepts POST /v1/contact-catalog/lookups. A production client should use a provider discovered through Seed Directory rather than a hard-coded endpoint. The minimum request semantics are:

{
  "schema": "contact-lookup-request.local.v1",
  "contact_index_value": "sha256:LOOKUP_SAFE_VALUE",
  "purpose": "messaging",
  "lookup_mode": "invitation-only"
}

The expected contact-lookup-result.v1 has match/class = invitation-available and result/routes. It must not contain the root participant/id or raw handle.

Send the first message

The sender may provide an external handle. Messaging records the message in its outbox, but does not send content to an unknown recipient before completing the contact request:

POST /v1/messaging/outbound
Content-Type: application/json
{
  "recipient/handle": {"kind": "email", "value": "[email protected]"},
  "subject": "Contact attempt",
  "body": "Hello, can we talk?",
  "content-type": "text/plain"
}

Run the queue processor:

POST /v1/messaging/outbox/process

{"batch/limit": 10}

If the nodes do not yet have a transport relationship, the recipient sees an inac/invitation-request notification. Accept it and process the sender outbox again. A separate contact-request/received notification then appears:

GET /v1/operator/notifications?limit=50
POST /v1/operator/notifications/{notification_id}/actions/accept

{"version": 1}

Use the version returned by that notification; 1 is not a constant. Contact-request acceptance creates or updates the local contact, contacts membership, pairwise mapping, and narrow receive passport. Process the sender outbox again after the decision.

Inspect the outbox, inbox, and body

The read surfaces are deliberately separated:

GET /v1/messaging/outbox
GET /v1/messaging/outbox/{envelope_id}/body
GET /v1/messaging/mailboxes
GET /v1/messaging/mailboxes/{mailbox_id}/messages
GET /v1/messaging/mailboxes/{mailbox_id}/messages/{envelope_id}/body
GET /v1/messaging/messages/{message_id}

Body endpoints are separate bounded surfaces. A message list must not accidentally carry full content or diagnostic secrets.

Change flags as facts

Write read/unread and related flags through the message flag endpoint. The runtime records messaging.flag.v1, and the read model derives current state:

POST /v1/messaging/messages/{message_id}/flags

Do not edit SQLite or Maildir files manually. When a projection needs rebuilding, use POST /v1/messaging/reindex; for pending facts use POST /v1/messaging/pending-facts/replay.

Classify contacts without granting authority

LRL can append a membership and resolve a group:

POST /v1/local-relationships/memberships
POST /v1/local-relationships/group.resolve
{
  "owner/ref": "participant:LOCAL",
  "contact/ref": "contact:EXAMPLE",
  "class/id": "friends",
  "status": "active",
  "actor/ref": "participant:LOCAL",
  "reason/code": "operator-classification"
}

Group resolution is owner-scoped and respects blocks. Its result is a routing candidate set, not a passport and not consent to broadcast.

Withdraw a contact or capability

When a relationship ends, perform the relevant operations explicitly: change or delete the local contact, set LRL membership to blocked or inactive, and revoke the receive passport. Then inspect the revocation view and rerun reindex if the operator repaired a projection. Do not delete historical facts merely to "clean" the view.

Run Story-010

From the node/ workspace, prepare profiles and run the complete self-contained smoke:

python3 tools/acceptance/story-010-operator/story-010-local-profiles.py init
python3 tools/acceptance/story-010-operator/story-010-local-profiles.py \
  ad-smoke --strict

Run the LRL gate with:

python3 tools/acceptance/story-010-operator/story-010-relationship-acceptance.py

The acceptance uses two node profiles on one host. It verifies contracts, restarts, provider discovery, private delivery, and owner scope, but does not replace a public network test or a production OTP delivery adapter.

Diagnose the boundary that refused the operation

Symptom Check first
no attestation provider Seed Directory trust, freshness, and capability profile
claim publication refused passport, subject, expiry, and claim signature
lookup returns no-match lookup-safe canonicalization, purpose, and active claim state
outbox waits for permission INAC notification, contact request, and retry schedule
contact exists but message is refused contacts membership, receive passport, and revocation view
inbox has a record without body bounded body surface, Maildir, and projection diagnostics
group differs between operators owner/ref, membership status, and local predicates

Repair the lowest unsatisfied boundary. Manually inserting records into a later layer hides the cause and creates state that cannot be reproduced.

Source documents