correspond.model#

The data model every channel is described in.

Accounts, conversation references, channel identities, authenticity, messages, attachments, events, capabilities, audiences, drafts and send results: frozen dataclasses that round-trip through JSON-ready dicts (to_dict / from_dict), because every surface (the CLI, MCP, a ledger on disk) moves them as JSON.

There is no person here. A ChannelIdentity is who the platform says sent a message and Authenticity is how sure the channel is. Linking an identity to a person is a job for a people registry, with its own evidence. An Audience lists readers the same way: as channel identities, never as people.

>>> ref = ConversationRef.parse("github:octocat/hello-world#1")
>>> ref.channel, ref.id, str(ref)
('github', 'octocat/hello-world#1', 'github:octocat/hello-world#1')
>>> ConversationRef.from_dict(ref.to_dict()) == ref
True
>>> Grade("bound") in {Grade.PLATFORM, Grade.BOUND, Grade.CRYPTO}
True

Module Attributes

OPERATIONS

The operations, in the order capabilities and surfaces list them.

ACTS_AS

Whose name a write goes out under.

EVENT_KINDS

What an Event reports.

PRIORITIES

The priorities a draft may ask for; channels without priorities refuse anything but None.

DURABILITY

searchable, archived by third parties, copies delivered to readers (email notifications), an edit history anyone who reads can see.

WIDENING

How a readership can grow after a send.

CLASS_WORDS

Short phrases for reader classes, used by Audience.in_words(); a class not listed here is shown as written.

AUDIENCE_UNHASHED

they differ between two computations of the same audience.

Functions

format_time(value)

ISO 8601 in UTC with a Z suffix; a naive datetime is taken to be UTC.

parse_time(value)

The inverse of format_time(); also accepts offsets and datetimes.

Classes

Account(*, channel, id[, acts_as])

The credentialed endpoint correspond acts through: whose name a write goes out under.

Attachment(*, ref[, media_type, name, size, ...])

A file that came with a message: referenced by ref, fetched only on content(), never inlined.

Audience(*, ref, scope[, readers, complete, ...])

Who can read a conversation, now and plausibly later, as far as its channel can tell.

Authenticity(*, grade[, evidence])

A grade and its evidence, computed on correspond's side of the hop and never read from a payload.

Capabilities(*, channel[, read, listen, ...])

What a channel can do, graded, with its limits.

ChannelIdentity(*, channel, native_id[, ...])

Who a channel says sent something: its native id and what the platform attests about it.

ConversationRef(*, channel[, id, kind, parent])

Where a conversation lives: a channel and that channel's own id, encoded <channel>:<id>.

Draft(*, text[, title, reply_to, priority, ...])

What to write: the text, and the few things channels share (a title, the message answered, a priority, copies).

Event(*, kind, channel, delivery_id[, ...])

Something that happened on a channel, as a listener reports it: dedupe on delivery_id, resume from cursor.

Grade(*values)

How sure the channel is about who sent a message: a vocabulary, not a ranking.

HistoryDepth(*values)

How far back read sees: all history, a 24-hour buffer, only what correspond has seen since it was linked or started listening, or nothing.

Message(*, id, conversation, author, ...[, ...])

One message in a conversation, normalised, with the channel's own fields kept in native.

Scope(*values)

The widest class of reader a conversation can reach.

SendResult(*, ok, channel, conversation[, ...])

What a write did, or would do (dry_run), or why it failed and whether a retry could help.

Support(*values)

How well a channel does something: full, partial (with limits in notes), or none.

correspond.model.ACTS_AS = ('bot', 'user', 'app', 'service')#

Whose name a write goes out under.

correspond.model.AUDIENCE_UNHASHED = ('as_of', 'evidence')#

they differ between two computations of the same audience.

Type:

The fields Audience.hash leaves out

class correspond.model.Account(*, channel, id, acts_as='user')[source]#

Bases: object

The credentialed endpoint correspond acts through: whose name a write goes out under.

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Account

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.model.Attachment(*, ref, media_type='application/octet-stream', name=None, size=None, sha256=None, loader=None)[source]#

Bases: object

A file that came with a message: referenced by ref, fetched only on content(), never inlined.

content()[source]#

The bytes, fetched now through the channel that produced the attachment.

Return type:

bytes

classmethod from_dict(data)[source]#

The inverse of to_dict() (the result has no loader).

Return type:

Attachment

to_dict()[source]#

JSON-ready (without the loader).

Return type:

dict

class correspond.model.Audience(*, ref, scope, readers=(), complete=False, classes=(), external=None, retractable=False, durability=(), widening=(), as_of=_now(), evidence=(), defaulted=False)[source]#

Bases: object

Who can read a conversation, now and plausibly later, as far as its channel can tell.

scope is the widest class of reader it can reach. readers are the channel identities known to read it, a lower bound unless complete; classes name, in words, the readers that cannot be listed. external says whether readers outside the operator’s own accounts exist (None: unknown). durability is what a send leaves behind (DURABILITY), widening how the readership can grow (WIDENING). evidence records the calls made and why anything was assumed; defaulted is true when an unknown resolved to public, which it always does.

Set-valued fields (readers, classes, durability, widening) are stored deduplicated and sorted, so the same audience compares and hashes alike whatever order a platform listed it in. A durability or widening flag this version does not know is kept, never dropped. A public audience is never complete, retractable or free of external readers, and a defaulted one is always public.

>>> a = Audience(ref="github:example/app#12", scope="public", classes=["watchers and participants receive the body by email"],
...              durability=["indexed", "archived_by_others", "copies_pushed", "edit_history_visible"], as_of="2026-09-15T12:00:00Z")
>>> a.in_words()
'world-readable; emailed to watchers and participants; archived by others; edits keep a visible history; not retractable'
>>> Audience.from_dict(a.to_dict()) == a
True
classmethod from_dict(data)[source]#

The inverse of to_dict(); unknown keys are ignored.

Return type:

Audience

property hash: str#

The SHA-256, in hex, of the canonical JSON of to_dict() without AUDIENCE_UNHASHED.

data is exactly what to_dict() returns, derived readers[].address included. Canonical JSON is json.dumps(data, sort_keys=True, separators=(",", ":")) (Python’s default ASCII escaping), encoded as UTF-8. Approvals bind to this value, so the construction is a contract. A to_dict output can be hashed as it stands; any other dict should go through Audience.from_dict(d).hash, which normalises.

in_words()[source]#

One line: the scope, the readers and reader classes, what a send leaves behind, whether it can be withdrawn.

Return type:

str

to_dict()[source]#

JSON-ready.

Return type:

dict

classmethod unknown(ref, *reasons)[source]#

The audience nobody could compute: public, incomplete, every durability and widening flag, not retractable, defaulted, with reasons as evidence.

Return type:

Audience

class correspond.model.Authenticity(*, grade, evidence=dict())[source]#

Bases: object

A grade and its evidence, computed on correspond’s side of the hop and never read from a payload.

>>> Authenticity(grade="bound", evidence={"method": "hmac-sha256"}).to_dict()
{'grade': 'bound', 'evidence': {'method': 'hmac-sha256'}}
classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Authenticity

to_dict()[source]#

JSON-ready.

Return type:

dict

correspond.model.CLASS_WORDS = {'watchers and participants receive the body by email': 'emailed to watchers and participants'}#

Short phrases for reader classes, used by Audience.in_words(); a class not listed here is shown as written.

class correspond.model.Capabilities(*, channel, read=Support.NONE, listen=Support.NONE, send=Support.NONE, edit=Support.NONE, react=Support.NONE, upload=Support.NONE, verify=Support.NONE, audience=Support.NONE, label=Support.NONE, unlabel=Support.NONE, close=Support.NONE, reopen=Support.NONE, initiate=Support.NONE, reply=Support.NONE, priority=Support.NONE, cc=Support.NONE, notify=Support.NONE, history_depth=HistoryDepth.NONE, listen_modes=(), grades=(), max_text_length=None, max_title_length=None, edit_max_age_s=None, reactions=(), reactions_per_message=None, max_upload_bytes=None, max_attachments=0, formats=('plain',), draft_fields=(), upload_fields=(), native_fields=None, rate_limits=(), notes=())[source]#

Bases: object

What a channel can do, graded, with its limits.

One Support per operation in OPERATIONS (audience: can the channel say who reads a conversation; label / unlabel: can labels be added to or removed from a conversation, apart from its opening send; close / reopen: can a conversation be settled, and unsettled), plus five features of writing: initiate (can a write start a conversation; Telegram bots cannot), reply (can a draft answer a specific message), priority, cc (can a draft copy further recipients, Draft.cc and Draft.bcc), and notify (can the writer guarantee that the people in Draft.notify are notified: a mention it adds, an address it copies). history_depth says how far back read sees; grades are the authenticity grades the channel can attest; native_fields are the keys its messages may carry in native (what a routing condition can test), or None when the channel does not declare them; draft_fields are the keys a Draft may carry in its native for this channel (GitHub: labels, assignees, milestone when opening an issue), and any other key is refused rather than dropped; upload_fields are the options upload takes in its native. max_attachments is how many files a send’s Draft.attachments may carry (0: a send takes none, though upload may still send a file on its own).

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Capabilities

property operations: tuple[str, ...]#

The operations the channel has at all (full or partial).

supports(operation)[source]#

The support level for one of OPERATIONS.

Return type:

Support

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.model.ChannelIdentity(*, channel, native_id, handle=None, display_name=None, is_bot=False, is_self=False, authority=None)[source]#

Bases: object

Who a channel says sent something: its native id and what the platform attests about it. Not a person.

property address: str#

the form a people registry resolves.

>>> ChannelIdentity(channel="github", native_id="583231", handle="octocat").address
'github:octocat'
Type:

<channel>:<handle>, or the native id when there is no handle

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

ChannelIdentity

label()[source]#

A short name for transcripts.

Return type:

str

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.model.ConversationRef(*, channel, id='', kind='', parent=None)[source]#

Bases: object

Where a conversation lives: a channel and that channel’s own id, encoded <channel>:<id>.

Two references are equal when channel and id are. kind (issue, discussion, chat, address, …) and parent describe the conversation and are filled in by the channel’s adapter, so a freshly parsed reference equals its normalised form.

>>> ConversationRef.parse("telegram:-4001/7") == ConversationRef(channel="telegram", id="-4001/7", kind="topic")
True
>>> ConversationRef.parse("nonsense")
Traceback (most recent call last):
...
correspond.errors.InvalidRef: 'nonsense' is not a conversation reference: the form is <channel>:<id>, e.g. github:octocat/hello-world#1
property encoded: str#

The stable string form, <channel>:<id>.

classmethod from_dict(data)[source]#

The inverse of to_dict(); a bare encoded string also works.

Return type:

ConversationRef

classmethod parse(text)[source]#

Split <channel>:<id> without consulting the channel (its adapter normalises the id).

Return type:

ConversationRef

to_dict()[source]#

JSON-ready, with the encoded form under ref.

Return type:

dict

correspond.model.DURABILITY = ('indexed', 'archived_by_others', 'copies_pushed', 'edit_history_visible')#

searchable, archived by third parties, copies delivered to readers (email notifications), an edit history anyone who reads can see.

Type:

What a send leaves behind

class correspond.model.Draft(*, text, title=None, reply_to=None, priority=None, cc=(), bcc=(), native=dict(), notify=(), attachments=())[source]#

Bases: object

What to write: the text, and the few things channels share (a title, the message answered, a priority, copies).

cc and bcc are further recipients, on channels whose capabilities grade cc (email). They count in the conversation’s audience.

>>> Draft(text="hi", cc=["bob@example.org", " "]).cc
('bob@example.org',)

native carries the fields only one channel has, in that channel’s vocabulary – the write-side mirror of Message.native. Which keys a channel takes is Capabilities.draft_fields; send refuses any other key rather than dropping it.

>>> Draft(text="hi", native={"labels": ["question"]}).native
{'labels': ['question']}

notify names who must be notified: handles on a mention platform (octocat or @octocat), addresses on email. The writer guarantees it – a mention added when the text lacks one, an address put in Cc when it is not in To or Cc – and a channel that cannot raises NotSupported("notify") (see correspond.notify).

>>> Draft(text="hi", notify=["octocat", " ", "Octocat", "hubot"]).notify
('octocat', 'hubot')

attachments are files that go with the text, each an Attachment whose content() gives its bytes, on channels whose capabilities grade upload. The before_send check sees them (name, media type, size, SHA-256, ref), never their bytes.

to_dict()[source]#

JSON-ready. native and notify appear only when set, so a draft without them keeps the dict – and the idempotency fingerprint of a key claimed before native existed – it always had.

Return type:

dict

correspond.model.EVENT_KINDS = ('message.created', 'message.updated', 'message.deleted', 'reaction.added', 'identity.changed')#

What an Event reports.

class correspond.model.Event(*, kind, channel, delivery_id, cursor=None, message=None, payload=dict())[source]#

Bases: object

Something that happened on a channel, as a listener reports it: dedupe on delivery_id, resume from cursor.

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Event

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.model.Grade(*values)[source]#

Bases: StrEnum

How sure the channel is about who sent a message: a vocabulary, not a ranking.

  • forged: verification was attempted and failed.

  • claimed: nothing verifiable (a typed name, an unauthenticated From header, anonymous input).

  • platform: the platform authenticated the account (GitHub, Telegram).

  • domain: email authenticated by your own receiving server’s Authentication-Results.

  • bound: a host application signed an assertion about its logged-in user.

  • crypto: a signed payload (a GitHub webhook’s X-Hub-Signature-256).

A policy names the grades it accepts for a permission. Comparing grades with < raises, because domain is neither stronger nor weaker than platform.

>>> Grade.CLAIMED < Grade.PLATFORM
Traceback (most recent call last):
...
TypeError: authenticity grades are not ranked; test membership in the grades a policy accepts
class correspond.model.HistoryDepth(*values)[source]#

Bases: StrEnum

How far back read sees: all history, a 24-hour buffer, only what correspond has seen since it was linked or started listening, or nothing.

class correspond.model.Message(*, id, conversation, author, authenticity, sent_at, text, body=None, body_format='plain', attachments=(), reply_to=None, thread_root=None, edited_at=None, url=None, native=dict(), raw=None)[source]#

Bases: object

One message in a conversation, normalised, with the channel’s own fields kept in native.

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

Message

to_dict(*, include_raw=False)[source]#

JSON-ready; raw (the untouched payload) only when asked for.

Return type:

dict

correspond.model.OPERATIONS = ('read', 'listen', 'send', 'edit', 'react', 'upload', 'verify', 'audience', 'label', 'unlabel', 'close', 'reopen')#

The operations, in the order capabilities and surfaces list them.

correspond.model.PRIORITIES = ('low', 'normal', 'high', 'urgent')#

The priorities a draft may ask for; channels without priorities refuse anything but None.

class correspond.model.Scope(*values)[source]#

Bases: StrEnum

The widest class of reader a conversation can reach.

  • operator: only the operator’s own devices (a notification banner).

  • named: exactly the explicit recipients (an email, a user’s private repository).

  • group: a bounded membership (a Telegram group).

  • org: an organisation or workspace (a private organisation repository).

  • public: anyone. What an unknown audience resolves to.

class correspond.model.SendResult(*, ok, channel, conversation, operation='send', dry_run=False, message_id=None, url=None, account=None, plan=dict(), error=None, error_kind=None, retryable=False, retry_after=None)[source]#

Bases: object

What a write did, or would do (dry_run), or why it failed and whether a retry could help.

classmethod failure(error, *, channel, conversation, operation='send', dry_run=False, plan=None)[source]#

A result carrying a ChannelError’s classification.

Return type:

SendResult

classmethod from_dict(data)[source]#

The inverse of to_dict().

Return type:

SendResult

to_dict()[source]#

JSON-ready.

Return type:

dict

class correspond.model.Support(*values)[source]#

Bases: StrEnum

How well a channel does something: full, partial (with limits in notes), or none.

correspond.model.WIDENING = ('visibility_flip', 'joiners_read_history', 'forwarding', 'forks', 'list_expansion')#

How a readership can grow after a send.

correspond.model.format_time(value)[source]#

ISO 8601 in UTC with a Z suffix; a naive datetime is taken to be UTC.

>>> format_time(datetime(2026, 9, 11, 12, 30, tzinfo=timezone.utc))
'2026-09-11T12:30:00Z'
Return type:

str | None

correspond.model.parse_time(value)[source]#

The inverse of format_time(); also accepts offsets and datetimes. Naive means UTC.

>>> parse_time("2026-09-11T12:30:00Z") == datetime(2026, 9, 11, 12, 30, tzinfo=timezone.utc)
True
Return type:

datetime | None