Every chat and every sender is a JID: a typed URI of the form <platform>:<rest>. The first segment of <rest> is a kind discriminator. Built on net/url for free RFC 3986 compliance + percent-encoding; matched with path.Match glob semantics across all routing keys.
From core/jid.go:28 (ParseJID):
jid := platform ":" rest
platform := scheme (lowercase adapter name; JID.Platform())
rest := non-empty opaque path (JID.Path())
kind := first segment of rest before "/" (JID.Kind())
Validation:
ParseJID("") → error; ":foo" → error (jid.go:38)."telegram:" → error (jid.go:41).//): "http://x" → error.ParseChatJID / ParseUserJID additionally require a non-empty kind (first segment): "telegram:/missing" → error (validateKind).From core/jid.go:18:
type JID struct{ u *url.URL } // wire-typed base
type ChatJID struct{ JID } // a destination/chat resource
type UserJID struct{ JID } // a user identity resource
Message.ChatJID is a typed ChatJID string; Message.Sender is a typed UserJID string. The Go compiler refuses to swap them. Both implement database/sql.Scanner and driver.Valuer, and both round-trip JSON (MarshalJSON ↔ UnmarshalJSON). The empty wire string scans to a zero JID and emits "".
Each adapter owns its <rest> shape. From specs/5/S-jid-format.md § "Per-platform schemas" plus the rewriter rules in 0042-typed-jids.sql:
| platform | kind | shape | example |
|---|---|---|---|
telegram | user | telegram:user/<chat_id> | telegram:user/12345 |
group | telegram:group/<chat_id> | telegram:group/67890 | |
discord | <guild_id> | discord:<guild_id>/<channel_id> | discord:5678/9012 |
dm | discord:dm/<channel_id> | discord:dm/54321 | |
user | discord:user/<user_id> | discord:user/u123 | |
whatsapp | — (server suffix) | whatsapp:<id>@<server> | whatsapp:1234@g.us, …@s.whatsapp.net, …@lid |
mastodon | account | mastodon:account/<account_id> | mastodon:account/42 |
status | mastodon:status/<status_id> | mastodon:status/abc | |
reddit | comment | reddit:comment/<id> | reddit:comment/abc |
submission | reddit:submission/<id> | reddit:submission/xyz | |
dm | reddit:dm/<id> | reddit:dm/m123 | |
user | reddit:user/<username> | reddit:user/alice | |
bluesky | user | bluesky:user/<did> (DID's : percent-encoded) | bluesky:user/did%3Aplc%3A123 |
post | bluesky:post/<at_uri> | bluesky:post/at%3A… | |
twitter | user | twitter:user/<user_id> | twitter:user/123 |
tweet | twitter:tweet/<tweet_id> | twitter:tweet/456 | |
dm | twitter:dm/<id> | twitter:dm/789 | |
linkedin | user | linkedin:user/<urn> | linkedin:user/urn123 |
post | linkedin:post/<urn> | linkedin:post/urn456 | |
email | address | email:address/<addr> | email:address/foo@bar.com |
thread | email:thread/<msgid> | email:thread/<Message-ID> | |
web | folder | web:<folder>[/<suffix>] | web:acme/support |
user | web:user/<sub> | web:user/sub-1 | |
hook | hook:<folder>/<source>[/<suffix>] | hook:acme/eng/github | |
slack | — | per adapter; slack: JIDs handled by slakd |
The route table's match column is a space-separated list of key=glob predicates. Evaluation lives in router.RouteMatches; keys are extracted from the message via msgField:
| key | extracts | source |
|---|---|---|
platform | JidPlatform(msg.ChatJID) — everything before the first : | core/types.go:183 |
room | JidRoom(msg.ChatJID) — everything after the first : | core/types.go:77 |
chat_jid | msg.ChatJID — full canonical string | |
sender | msg.Sender — full sender JID | |
verb | msg.Verb — "message" default; "join", "edit", … |
Glob match uses Go's path.Match semantics:
* matches any non-/ sequence (segments are first-class).? matches exactly one non-/ char.[abc] / [a-z] matches a character class.\ escapes the next character.From the RouteMatches contract (router.go:290-324):
| predicate | matches when |
|---|---|
key=<exact> | value equals <exact> |
key=<glob> | value matches glob (*, ?, […]; * doesn't cross /) |
key=* | value is present (non-empty) — bare * silently rejects empty |
key= | value is absent (empty) |
| omit key | unconstrained |
The same path.Match-based matcher is available as core.MatchJID(pattern, value) for direct JID-pattern checks outside the route engine.
# all telegram groups
match='platform=telegram chat_jid=telegram:group/*'
# discord guild 67890, any channel/thread
match='chat_jid=discord:67890/*'
# all Discord DMs
match='chat_jid=discord:dm/*'
# all WhatsApp groups (server suffix discriminates)
match='chat_jid=whatsapp:*@g.us'
# all activity on a Mastodon instance (single account-id digit-or-letter)
match='chat_jid=mastodon:account/*'
# specific Telegram room by signed numeric ID (room=<rest after colon>)
match='platform=telegram room=group/-100123456'
# any non-message verb on any platform (join, edit, delete, …)
match='verb=*'
match='verb=' is the negation: only verb-less (default 'message') rows
The same jid=<glob> syntax appears in grant rules (grants reference). The matcher there is grants.matchGlob with isValueDelim stopping * at , or ) — not at /. That's a deliberate difference: grant param globs span the whole JID, route globs are segment-aware.
send(jid=telegram:group/*) # rule: only telegram groups (grants matcher: * spans /)
match='chat_jid=telegram:group/*' # route: only telegram groups (path.Match: * doesn't cross /)
For top-level platform globs both forms agree (telegram:*/* in routes vs telegram:* in grants); both mean "anything telegram." For grant params there's only one trailing segment to match against, so the wider * is harmless.
Migrations 0042-typed-jids.sql and 0043-typed-jids-tail.sql rewrote every JID-shaped value in the store to the typed form. Affected columns:
messages.chat_jid, messages.sender, messages.reply_to_senderchats.jid (PK)onboarding.jiduser_jids.jid and vestigial grants.jid were rewritten too, before being dropped by the ACL unification)routes.match — chat_jid= / sender= / room= predicatesscheduled_tasks.chat_jidchat_reply_state.jidEvery UPDATE is guarded by a NOT LIKE on the new shape, so re-running is idempotent. Rules:
telegram:user/<id>, negative → telegram:group/<|id|> (sign dropped).chats.is_group: DMs → discord:dm/<channel>, guild channels → discord:_/<channel> (placeholder).account/<id> for senders/recipients.t1_→comment, t2_→user, t3_→submission.: in did:plc:<rest> → bluesky:user/did%3Aplc%3A<rest>.email:address/<addr>; chat_jid rewrite deferred until emaid emits typed form.<urn> → user/<urn>.web:<folder>[/<suffix>] remains folder-keyed; the URL token is decoupled into the route_tokens table (one row per URL, JID per row).hook:<folder>/<source>[/<suffix>] for webhook ingest via route_tokens; <folder> is the destination folder, sender on inbound is the <source> segment.platform:* globs rewritten to platform:*/* so segment-aware path.Match still matches anything on that platform.ChatJID vs UserJID distinguishes resource role at compile time (code).<platform>:<rest> + non-empty kind. Each adapter parses j.Path() per its own declared shape.net/url opaque-path form; percent-encoding for reserved chars (Bluesky DIDs) is free.t1_/t2_/t3_ prefix soup, no in-band server suffixes for code (WhatsApp keeps @server because its existing shape already encodes kind).core/jid.go — JID, ChatJID, UserJID, ParseJID, MatchJIDcore/jid_test.go — full happy-path and rejection test casesrouter/router.go — RouteMatches, msgField, ResolveRoutespecs/5/S-jid-format.md — full spec, per-platform schemas, design disciplinejid=… appears in grant rule params