Delegated "chat mods"
Delegated Moderation Proposal
stream.place
Updated proposal in progress. See comments below for context.
Previous version (deprecated)
Overview
This issue describes a proposition for delegated moderation in stream.place: enabling streamers to grant moderation powers to trusted users.
Key principle: Streamers delegate authority. Moderators create moderation actions. stream.place enforces moderation.
Terminology note: "Block" and "ban" are used interchangeably for user-level moderation. "Gate" refers to the record type; "hide" refers to the action.
TL;DR: Ship delegated moderation by extending existing gates with mod authorization checks. Add place.stream.chat.block for per-stream user bans and place.stream.chat.moderator for delegation.
Existing Moderation System (Before Delegation)
Blocks (app.bsky.graph.block)
Standard AT Proto records for blocking users across AT Proto apps (Bluesky social graph blocking).
Enforcement in stream.place: Query-time filtering (messages from blocked users excluded from chat queries).
Scope: Social graph level (if streamer blocks user on Bluesky, they're also blocked from chat).
Limitations:
- No per-stream granularity (block applies everywhere or nowhere)
- Only stream owner can create
- No expiration support
- No delegation support
- Not designed for per-stream moderation with multiple mods
Why we need place.stream.chat.block:
- Mods operating across multiple streams need to specify: "Ban user X on streamer Y's stream"
-
app.bsky.graph.blockhas nostreamerfield needed for mods operating across streams - Both will coexist:
app.bsky.graph.blockfor social blocking,place.stream.chat.blockfor per-stream mod actions
Gates (place.stream.chat.gate)
stream.place-specific records for hiding individual messages.
Enforcement: Query-time filtering (specific messages excluded from results).
Limitations: Only stream owner can create. No delegation support.
Labels (com.atproto.label)
AT Proto standard for content moderation metadata. stream.place consumes labels from external labelers (e.g., Bluesky moderation).
Enforcement:
-
Auth-time: Prevent banned users from streaming (checked in
checkBanned()during stream key validation) - Query-time: Filter labeled content from chat
Scope: Global (external labelers apply labels across entire network).
What's missing for delegated moderation:
- Moderator delegation system (declaring who has mod powers on which stream)
- Per-stream user bans (ban user from THIS stream, not all streams globally)
- Ingest-time enforcement of mod bans
- Audit trail (tracking which mod banned which user, on which stream)
What We're Adding
Complete Lexicon Family
place.stream.chat.message (exists) - Chat messages
place.stream.chat.gate (exists) - Hide messages
place.stream.chat.block (NEW) - Block users from chat (permanent or temporary via optional expiresAt)
place.stream.chat.moderator (NEW) - Delegate mod permissions
Key Distinctions:
-
app.bsky.graph.block(exists): Social graph blocking, affects chat as side-effect -
place.stream.chat.block(NEW): Per-stream chat bans with mod delegation and streamer scoping -
com.atproto.label(exists): Global streaming bans via labelers
Delegated Moderation
- Delegated moderators with granular permissions:
place.stream.chat.moderator - User-level moderation:
ban(viaplace.stream.chat.block) - Message-level moderation:
hide(viaplace.stream.chat.gate) - Ingest-time enforcement (bans)
- Query-time enforcement (hide messages)
- Basic audit trail
How it works:
- Extend existing gates enforcement with mod authorization (SQL JOIN)
- Add new
place.stream.chat.blocklexicon for mods to create per-stream bans for users - Add new
place.stream.chat.moderatorlexicon for delegation - Authorization checked at enforcement time
-
Two new lexicons:
place.stream.chat.blockandplace.stream.chat.moderator
Non-Goals (V1)
This proposal explicitly excludes the following features to maintain focus and ship quickly:
- ❌ Cross-app federation - Future: AT Proto labels + labeler service when needed
- ❌ Appeal system - Future: Users can appeal bans to streamers
- ❌ Automated moderation - Future: Spam filters, keyword blocking, ML-based detection
- ❌ Role hierarchy - Future: Head mod vs junior mod permission tiers
- ❌ Mod-to-mod coordination - Future: Mod chat, action notifications, coordination tools
- ❌ Rate limiting design - To be designed separately when implementing
- ❌ Message curation - Future:
highlightpermission for positive moderation
Migration path: All non-goals can be added incrementally without breaking changes.
Architecture
Simple Flow
1. Mod clicks action in UI (ban or hide)
↓
2. Mod's client creates ONE record in mod's repo:
- place.stream.chat.block (ban)
- place.stream.chat.gate (hide)
↓
3. Mod's PDS signs and emits record to firehose
↓
4. stream.place receives record:
- Store in appropriate table (chat_blocks or gates)
- No authorization check yet (lazy evaluation)
- Publish to WebSocket Bus
↓
5. Enforcement (when someone tries to post/query):
- Query relevant records for user/message
- JOIN with chat_moderators table
- Check: Is creator the streamer OR authorized mod?
- Apply enforcement (drop message at ingest OR hide at query time)
↓
6. Real-time:
- WebSocket subscribers receive record
- UI updates immediately (ban notification, message hidden, etc.)
Authorization Check (Enforcement Time)
Key insight: Authorization is checked at enforcement time, not firehose time. This is simpler and uses existing patterns.
Ban enforcement (ingest time):
func (s *Server) isUserBanned(userDID, streamerDID string) (bool, error) {
// Query: Get all blocks targeting this user on this stream
var count int
err := s.DB.Raw(`
SELECT COUNT(*)
FROM chat_blocks
WHERE subject_did = ?
AND streamer_did = ?
AND (expires_at IS NULL OR expires_at > NOW()) -- Check expiration for temporary bans
AND (
-- Streamer's own blocks
repo_did = ?
OR
-- Mod blocks (if mod has 'ban' permission)
EXISTS (
SELECT 1 FROM chat_moderators
WHERE streamer_did = ?
AND moderator_did = chat_blocks.repo_did
AND JSON_CONTAINS(permissions, '"ban"')
)
)
`, userDID, streamerDID, streamerDID, streamerDID).Scan(&count).Error
return count > 0, err
}
Hide enforcement (query time):
-- In MostRecentChatMessages query
SELECT chat_messages.*
FROM chat_messages
LEFT JOIN gates ON gates.hidden_message = chat_messages.uri
AND (
gates.repo_did = chat_messages.streamer_did
OR EXISTS (
SELECT 1 FROM chat_moderators
WHERE streamer_did = chat_messages.streamer_did
AND moderator_did = gates.repo_did
AND JSON_CONTAINS(permissions, '"hide"')
)
)
WHERE gates.hidden_message IS NULL -- Only messages without gates
Enforcement Model
Precedence: Ban > (message exists) > Hide
| Action | Enforcement | When | Bypassable |
|---|---|---|---|
| Ban (block) | Ingest | Message arrives | No (auto-expires if temporary) |
| Hide (gate) | Query | Message fetched | Yes (via settings) |
Streamer Override & Precedence Rules
Core Principle: Streamers always have final authority over their own streams.
V1 Implementation: Delegation Removal (Nuclear Option)
Streamer removes entire mod delegation:
- Delete the
place.stream.chat.moderatorrecord - All mod's blocks/gates instantly stop being enforced (SQL JOIN fails)
- Mod's records still exist in their repo, but have no effect
Limitation: Cannot override individual mod actions - it's all or nothing.
V2 Enhancement: Per-Action Override (Future)
Streamer overrides specific mod action:
- Mod created
place.stream.chat.block→ user banned - Streamer disagrees with THIS specific ban
- Streamer creates their own expired
place.stream.chat.blockfor the same user (withexpiresAtin the past) - Requires implementing precedence logic: streamer records override mod records
Benefit: Granular control - remove one bad ban without removing entire mod.
V1 Rollout: Ship with delegation removal only (simpler, validates model).
V2 Rollout: Add per-action override after gathering feedback on V1 usage patterns.
Multi-Moderator Conflict Resolution
Simple union approach - only ban and hide actions:
How it works:
- Ban enforcement: If ANY authorized mod (or streamer) has active block for user → user is banned
- Hide enforcement: If ANY authorized mod (or streamer) has gate for message → message is hidden
- Union semantics: More restrictive action wins (any ban = banned, any hide = hidden)
Self-revocation:
- Mod can delete their own block/gate records to undo their actions
- Mod CANNOT delete other mods' records (AT Proto - each user owns their own repo)
- Streamer can remove mod delegation to revoke ALL of that mod's actions
Key insight: With only restrictive actions (ban/hide), conflicts are impossible. Union of restrictions always has clear semantics.
Data Model
Database Tables
Two new tables needed:
chat_moderators (NEW)
CREATE TABLE chat_moderators (
streamer_did TEXT NOT NULL,
moderator_did TEXT NOT NULL,
permissions TEXT NOT NULL, -- JSON: ["ban","hide"]
record BLOB, -- Full CBOR record from PDS
rkey TEXT,
cid TEXT,
created_at DATETIME,
PRIMARY KEY (streamer_did, moderator_did),
INDEX idx_streamer (streamer_did),
INDEX idx_moderator (moderator_did)
);
chat_blocks (NEW)
CREATE TABLE chat_blocks (
rkey TEXT PRIMARY KEY,
cid TEXT,
repo_did TEXT NOT NULL, -- DID of who created block
streamer_did TEXT NOT NULL, -- DID of streamer (which stream)
subject_did TEXT NOT NULL, -- DID of user being blocked
expires_at DATETIME, -- Optional: when block expires (NULL = permanent ban)
reason TEXT, -- Optional reason
record BLOB, -- Full CBOR record
created_at DATETIME NOT NULL,
INDEX idx_streamer_subject (streamer_did, subject_did),
INDEX idx_streamer_subject_expires (streamer_did, subject_did, expires_at),
INDEX idx_repo (repo_did)
);
Existing tables used (no schema changes):
-
gates- Already exists forplace.stream.chat.gaterecords
Performance Considerations:
- SQL indexes on key lookup paths (see table definitions above)
- Future optimization: Cache mod permissions per stream (invalidate on delegation changes)
Lexicons
Two new lexicons - see /lexicons/place/stream/chat/ for complete definitions.
place.stream.chat.moderator
Streamer declares who has mod powers on their stream.
Example:
{
"$type": "place.stream.chat.moderator",
"moderator": "did:plc:mod123",
"permissions": ["ban", "hide"],
"createdAt": "2025-01-15T10:00:00Z"
}
Storage: Streamer's repository (signed by streamer).
place.stream.chat.block
Ban user from specific stream (permanent or temporary).
Example (temporary ban):
{
"$type": "place.stream.chat.block",
"streamer": "did:plc:alice",
"subject": "did:plc:bob",
"expiresAt": "2025-01-15T10:10:00Z",
"reason": "spam",
"createdAt": "2025-01-15T10:00:00Z"
}
Example (permanent ban):
{
"$type": "place.stream.chat.block",
"streamer": "did:plc:alice",
"subject": "did:plc:bob",
"reason": "harassment",
"createdAt": "2025-01-15T10:00:00Z"
}
Key features:
-
streamerfield provides per-stream scoping (critical for mods working across multiple streams) -
expiresAtis optional: omit for permanent, include for temporary - Both use same lexicon (no separate "timeout" record type)
Storage: Mod's or streamer's repository (signed by creator).
API
Frontend (Mod Client)
Simple pattern: Each action creates ONE record in mod's repo.
Ban User
// Pattern: New hook in js/components/src/streamplace-store/moderation.tsx
const agent = usePDSAgent(); // Mod's authenticated agent
await agent.com.atproto.repo.createRecord({
repo: agent.did,
collection: "place.stream.chat.block",
record: {
streamer: streamerDID, // Which stream this applies to
subject: targetUserDID, // Who is being blocked
expiresAt: duration ? new Date(Date.now() + duration).toISOString() : undefined, // Optional
reason: "spam",
createdAt: new Date().toISOString()
}
});
Duration examples: Permanent: undefined | 10min: 600000 | 1hr: 3600000 | 24hr: 86400000
Hide Message
// EXISTING: js/components/src/streamplace-store/block.tsx:39-71
// Already implemented, works for mods (no changes needed)
await agent.com.atproto.repo.createRecord({
repo: agent.did,
collection: "place.stream.chat.gate",
record: {
hiddenMessage: targetMessageURI
}
});
Backend (Firehose Processing)
Simple pattern: Just store the record. No authorization checks at firehose time.
Location: pkg/atproto/sync.go - Add new case handlers after line 183
Pattern: Copy existing case *streamplace.ChatGate: handler at lines 152-183
// Handle place.stream.chat.block from firehose
func (s *Server) handleChatBlock(evt *RepoCommit) error {
var block ChatBlock
err := cbor.Unmarshal(evt.Record, &block)
if err != nil {
return err
}
// Store block (no authorization check yet - lazy evaluation)
block.RKey = evt.RKey
block.RepoDID = evt.Repo
err = s.Model.CreateChatBlock(&block)
// Publish to bus for real-time updates
go s.Bus.Publish(block.StreamerDID, &block)
return nil
}
Authorization happens at enforcement time, not firehose time. See Architecture section for enforcement code examples.
Implementation
Backend
Modified files:
-
pkg/atproto/sync.go:84-183- Add handlers forplace.stream.chat.blockandplace.stream.chat.moderator -
pkg/atproto/sync.go:98-105- AddisUserBanned()check at message ingest -
pkg/model/chat_message.go:132-133- UpdateMostRecentChatMessages()SQL joins for mod authorization
New files:
-
pkg/model/chat_block.go- CRUD for block records (pattern: copy frompkg/model/block.go) -
pkg/model/chat_moderator.go- Moderator delegation CRUD (pattern: copy frompkg/model/gate.go)
Implementation delta:
- chat_blocks table + CRUD (new table for per-stream bans)
- chat_moderators table + CRUD (new table for delegation)
-
Firehose handlers (process
place.stream.chat.blockandplace.stream.chat.moderator) - Enforcement SQL updates (add JOIN with chat_moderators table)
Frontend
New components:
-
js/components/src/streamplace-store/moderation.tsx- CreateuseCreateChatBlockRecord()hook -
js/components/src/components/dashboard/mod-management.tsx- Moderator management UI
Modified components:
-
js/components/src/components/dashboard/mod-actions.tsx:21-46- Wire up actions to record creation hooks - WebSocket consumer - Handle incoming block records
- Chat reducer - Remove messages from banned users in real-time
Known Limitations
1. Lazy Authorization Race Condition
Scenario: Mod's pending blocks arrive after delegation is removed.
Impact: Blocks briefly enforced for seconds after mod removal.
Mitigation: Accept as acceptable race condition. Eventually consistent (always reaches correct state).
2. V1 Override Granularity
Limitation: Streamer can only remove entire mod delegation, not override individual actions.
Workaround: Remove mod or ask them to remove disputed block.
Fix: V2 per-action override implementation.
Rollout
Backend:
- New tables:
chat_blocksandchat_moderators - New lexicons:
place.stream.chat.blockandplace.stream.chat.moderator - Firehose handlers for both record types
- Enforcement SQL updates (2 queries: ingest ban check + query hide check)
Frontend:
- Mod dashboard: Add/remove mods, configure permissions
- Chat UI: Mod actions context menu (Ban User / Hide Message)
- Record creation: Create block/gate records with streamer field
Launch: Feature flag ENABLE_DELEGATED_MODERATION
User Experience
Streamers
Dashboard → Moderators:
- Add/remove mods by handle or DID
- Configure permissions:
["ban", "hide"] - View audit log (filterable by mod, action, date)
Moderators
In chat:
- Mod badge displayed
- Right-click message → Mod Actions menu:
- Hide Message - Hides this specific message (creates gate)
- Ban User - Permanent ban (creates block)
- Timeout User - Temporary ban with duration presets
- Confirm → creates record in mod's own repo
Viewers
Default: Banned users' messages don't appear.
Transparency (if needed): "User banned by @mod (reason: spam)" or "User timed out by @mod for 5 more minutes"
Design Decisions
1. Auto-Revoke Actions When Mod Removed
Decision: All mod actions automatically stop being enforced when delegation is removed.
Rationale:
- Removing mod removes their authorization → SQL JOIN fails → no enforcement
- Mod's records still exist in their repo (AT Proto - they own them)
- Clean, automatic, respects AT Proto ownership model
Implementation:
- Mod self-revocation: Delete their own block/gate record
- Streamer override (V1): Remove mod delegation (auto-revokes all mod's actions)
- Streamer override (V2): Create expired block as "unban" (requires precedence logic)
2. Temporary Bans via Optional Field
Decision: Use optional expiresAt field in place.stream.chat.block (not separate lexicon).
Rationale:
- Simpler: One lexicon for both permanent and temporary bans
- Natural: Omit field = permanent, include field = temporary
- AT Proto native: Optional fields are standard pattern
3. Custom Lexicons vs AT Proto Labels
Decision: Start with custom lexicons (place.stream.chat.*). Migrate to AT Proto labels when federation is needed.
Why custom lexicons first:
- ✅ Ships quickly (2 new lexicons vs 4+ with labels)
- ✅ Simple: One record per action (vs two with labels)
- ✅ Per-stream scoping via explicit
streamerfield - ✅ No record desync
- ✅ Easy to understand and debug
When to migrate to labels:
- When cross-app consumption is requested
- When federated moderation becomes valuable
- After delegation model is proven useful in production
Migration path: Custom lexicons → AT Proto labels + action records for federation (non-breaking).
Example Scenarios
Scenario 1: Message Hiding
Setup: Streamer Alice, Mod Bob with ["ban", "hide"], User Dan
Flow:
- Dan posts offensive message
- Bob right-clicks → "Hide Message" → Creates
place.stream.chat.gatein Bob's repo - stream.place stores gate, publishes to WebSocket
- Query enforcement: SQL checks gate creator (Bob) has
hidepermission → ✅ message filtered - Alice can override: Remove Bob's delegation → all Bob's gates stop being enforced
Result: Message-level moderation via gate delegation
Scenario 2: User Ban
Setup: Streamer Alice, Mod Carol with ["ban", "hide"], User Eve (spammer)
Flow:
- Eve repeatedly posts spam
- Carol → "Ban User" → Creates
place.stream.chat.blockwithstreamer: "did:plc:alice" - stream.place stores block, publishes to WebSocket
- Ingest enforcement: Eve tries to post →
isUserBanned()checks Carol hasbanpermission → ✅ message dropped - Eve sees: "You are banned from this chat (reason: spam)"
- Eve CAN still post on other streams (block is per-stream)
Result: User-level moderation via per-stream blocks with mod delegation
Scenario 3: Streamer Override
Setup: Streamer Alice, Mod Bob, User Dave (friend of Alice)
Flow:
- Bob bans Dave → Dave is banned
- Alice reviews: "Dave banned by @bob" → Alice disagrees
- V1: Alice removes Bob's delegation → Dave immediately unbanned (all Bob's actions stop)
- V2 (future): Alice creates expired block for Dave → Only Dave unbanned, Bob's other actions remain
Result: V1 = coarse-grained override, V2 = fine-grained override
Scenario 4: Moderator Removed
Setup: Streamer Alice, Mod Greg, User Helen
Flow:
- Greg bans Helen → Helen is banned
- Alice removes Greg as moderator → Deletes
place.stream.chat.moderator - Authorization check fails: Greg's blocks exist but have no effect (SQL JOIN fails)
- Helen tries to post →
isUserBanned()returns false → ✅ message goes through
Result: Auto-revoke - removing mod removes their authorization
Future Considerations
Federation and Labeler Services
When: After V1 proves value and federation is requested.
Approach:
- Migrate to
com.atproto.label+ action records for cross-app consumption - stream.place runs as labeler service, re-signs verified labels
- Apps subscribe to stream.place for curated, authorized-only labels
Questions to explore:
- Priority for cross-app moderation visibility?
- Balance custom innovation vs. standard adoption?
- Labeler infrastructure requirements?
Protocol-Level Features
When federation is valuable:
- AT Proto labels for cross-app consumption
- Labeler service infrastructure
- Action records for richer audit trail with explicit stream context
- Message curation:
highlightpermission for positive moderation
Thanks for the thorough proposal, let me give my quick reaction: there are a few reasons that I want moderation actions to be taken on the streamers' account. This can be facilitated through a record that delegates certain atproto actions, similar to place.stream.chat.moderator but potentially with a more generic name. These actions can then be executed by the Streamplace node by accepting authenticated requests from moderators and using the streamers' OAuth session. Reasons:
- It avoids attributing moderation actions to individual moderators, which minimizes harassment of moderators (though when we implement this we should have a private audit log for the streamer to see who did what)
- It avoids a lot of complexity as you've explored in this schema. "Hey I'm ready to retire as a mod, could you demote me" should not imply "and undo every moderation action that I've already taken."
- We need delegated-action anyway because we want the ability for moderators to create
place.stream.livestreamrecords for a streamer; mods updating the stream title and whatnot is a very common pattern. So it's efficient to do all moderation through this mechanism. - It gives us an opportunity to demonstrate a general delegated-action framework to atproto-at-large, gather feedback, and maybe build some useful tooling for people
Thankfully, I think this can make your design much simpler, you don't need to think about any of the "how to handle conflicting actions in different moderators' repos" stuff.
I'll leave it there for now — let me think for a while on place.stream.chat.block and whether that's where we want to go right now 🤔
Thanks @iameli! That makes total sense. Delegating moderation actions through the streamer’s account covers all the pain points I was trying to solve the hard way.
I got three questions that will help me revise the proposal:
-
Delegation lexicon naming: You mentioned using a more generic name than
place.stream.chat.moderator. Should we call itplace.stream.delegatewith namespaced permissions like["chat.ban", "chat.hide", "livestream.update"]? -
Action lexicons for V1: Do we need
place.stream.chat.blockas atproto records, or should V1 just store bans/hides privately in the database via XRPC endpoints? We could add lexicons later if portability becomes valuable. -
Scope for V1: Should we include delegated title updates in V1, or keep it focused on just chat moderation (ban/hide) initially?
Once I hear your thoughts I'll update the proposal with the delegated-action approach.
Hey @iameli, Did you have time to think more about the approach?
Just want to make sure I have clarity on those three questions before I dive into finalizing the proposal.
Also, when discussing the revised proposal with Cole, we identified a trade-off: the lack of external auditability. Streamers would be the only ones to view which mod took which action via private audit logs in Streamplace. This is not externally provable (as records are signed by streamer), prioritizing mod protection over external accountability.
I got three questions that will help me revise the proposal:
- Delegation lexicon naming: You mentioned using a more generic name than
place.stream.chat.moderator. Should we call itplace.stream.delegatewith namespaced permissions like["chat.ban", "chat.hide", "livestream.update"]?
Yeah place.stream.delegate namespace sounds right for this. place.stream.delegate.delegation could be the record type. The actual action might be place.stream.delegate.impersonate? @ThisIsMissEm suggested that verb and I think it accurately describes what's happening on an atproto level
The schema for place.stream.delegate can draw from OAuth permission scopes, they should be identical probably unless there's stuff I'm not thinking about
- Action lexicons for V1: Do we need
place.stream.chat.blockas atproto records, or should V1 just store bans/hides privately in the database via XRPC endpoints? We could add lexicons later if portability becomes valuable.
We already have these. When you block someone, a app.bluesky.graph.block record gets created, and "hiding" a chat message just creates a place.stream.chat.gate record that causes the AppView to hide it. All we need to do is allow chat mods to hit a user on their behalf.
- Scope for V1: Should we include delegated title updates in V1, or keep it focused on just chat moderation (ban/hide) initially?
I think it's easy to add all three of these if we do a dedicated CRUD framework.
What about called it: place.stream.delegation.impersonate ? Though I'm hesitant really to ever suggest impersonation.
I do still think this would be better as:
-
place.stream.moderation.permission - this would fit with the procedures being
place.stream.moderation.* - You could optionally later add a
place.stream.moderation.groupwhich contains multiple permissions grouped together - You could introduce a
approvedByorneeds_approvaltype notion such that new moderators for your stream can propose actions which either the streamer or a more experienced moderator can approve. - You will probably want a query permission like
place.stream.moderation.listPermissionsto enable displaying a UI of all the permissions. - You may want to add a "schedule" to a permission" — maybe you've a good mod on your team on like Sunday to Thursday, but on the weekends they like to party, as such you don't want them acting as a moderator but just a regular user if for some reason they join your stream. The schedule can also be used to relieve mods of push notifications for reports or other activities.
For scopes, I'd recommend it being the moderation actions (procedures) instead of the record type the procedure ultimately creates. That'd encourage going through the procedure and discouraging anyone writing anything that goes directly to the repo. Perhaps in the scope is where you could define "approval" as a concept, ["place.stream.moderation.createTimeout", "place.stream.moderation.banActor?approval=did:or:group-id"]
You could also have permission sets that allow grouping the procedures together.
Oh, and be sure to create a rewriter for all v1 records to v2
i prefer delegate over impersonate
Updated with the latest place.stream.moderation.* lexicon format
Ready for review
I've split the work into 3 PRs:
- #719 - Lexicons
- #787 - Backend (Go implementation, depends on #719)
- #788 - Tests (depends on #787)
Best reviewed in that order. Merging #788 would close this issue.