streamplace icon indicating copy to clipboard operation
streamplace copied to clipboard

Delegated "chat mods"

Open tripledoublev opened this issue 2 months ago • 3 comments

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.block has no streamer field needed for mods operating across streams
  • Both will coexist: app.bsky.graph.block for social blocking, place.stream.chat.block for 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 (via place.stream.chat.block)
  • Message-level moderation: hide (via place.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.block lexicon for mods to create per-stream bans for users
  • Add new place.stream.chat.moderator lexicon for delegation
  • Authorization checked at enforcement time
  • Two new lexicons: place.stream.chat.block and place.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: highlight permission 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:

  1. Delete the place.stream.chat.moderator record
  2. All mod's blocks/gates instantly stop being enforced (SQL JOIN fails)
  3. 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:

  1. Mod created place.stream.chat.block → user banned
  2. Streamer disagrees with THIS specific ban
  3. Streamer creates their own expired place.stream.chat.block for the same user (with expiresAt in the past)
  4. 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 for place.stream.chat.gate records

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:

  • streamer field provides per-stream scoping (critical for mods working across multiple streams)
  • expiresAt is 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 for place.stream.chat.block and place.stream.chat.moderator
  • pkg/atproto/sync.go:98-105 - Add isUserBanned() check at message ingest
  • pkg/model/chat_message.go:132-133 - Update MostRecentChatMessages() SQL joins for mod authorization

New files:

  • pkg/model/chat_block.go - CRUD for block records (pattern: copy from pkg/model/block.go)
  • pkg/model/chat_moderator.go - Moderator delegation CRUD (pattern: copy from pkg/model/gate.go)

Implementation delta:

  1. chat_blocks table + CRUD (new table for per-stream bans)
  2. chat_moderators table + CRUD (new table for delegation)
  3. Firehose handlers (process place.stream.chat.block and place.stream.chat.moderator)
  4. Enforcement SQL updates (add JOIN with chat_moderators table)

Frontend

New components:

  • js/components/src/streamplace-store/moderation.tsx - Create useCreateChatBlockRecord() 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:

  1. New tables: chat_blocks and chat_moderators
  2. New lexicons: place.stream.chat.block and place.stream.chat.moderator
  3. Firehose handlers for both record types
  4. Enforcement SQL updates (2 queries: ingest ban check + query hide check)

Frontend:

  1. Mod dashboard: Add/remove mods, configure permissions
  2. Chat UI: Mod actions context menu (Ban User / Hide Message)
  3. 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 streamer field
  • ✅ 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:

  1. Dan posts offensive message
  2. Bob right-clicks → "Hide Message" → Creates place.stream.chat.gate in Bob's repo
  3. stream.place stores gate, publishes to WebSocket
  4. Query enforcement: SQL checks gate creator (Bob) has hide permission → ✅ message filtered
  5. 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:

  1. Eve repeatedly posts spam
  2. Carol → "Ban User" → Creates place.stream.chat.block with streamer: "did:plc:alice"
  3. stream.place stores block, publishes to WebSocket
  4. Ingest enforcement: Eve tries to post → isUserBanned() checks Carol has ban permission → ✅ message dropped
  5. Eve sees: "You are banned from this chat (reason: spam)"
  6. 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:

  1. Bob bans Dave → Dave is banned
  2. Alice reviews: "Dave banned by @bob" → Alice disagrees
  3. V1: Alice removes Bob's delegation → Dave immediately unbanned (all Bob's actions stop)
  4. 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:

  1. Greg bans Helen → Helen is banned
  2. Alice removes Greg as moderator → Deletes place.stream.chat.moderator
  3. Authorization check fails: Greg's blocks exist but have no effect (SQL JOIN fails)
  4. 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: highlight permission for positive moderation

tripledoublev avatar Nov 06 '25 22:11 tripledoublev

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:

  1. 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)
  2. 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."
  3. We need delegated-action anyway because we want the ability for moderators to create place.stream.livestream records 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.
  4. 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 🤔

iameli avatar Nov 06 '25 22:11 iameli

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.

tripledoublev avatar Nov 10 '25 08:11 tripledoublev

I got three questions that will help me revise the proposal:

  1. Delegation lexicon naming: You mentioned using a more generic name than place.stream.chat.moderator. Should we call it place.stream.delegate with namespaced permissions like ["chat.ban", "chat.hide", "livestream.update"]?

  2. Action lexicons for V1: Do we need place.stream.chat.block as 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.

  3. 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.

tripledoublev avatar Nov 10 '25 08:11 tripledoublev

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.

tripledoublev avatar Nov 24 '25 13:11 tripledoublev

I got three questions that will help me revise the proposal:

  1. Delegation lexicon naming: You mentioned using a more generic name than place.stream.chat.moderator. Should we call it place.stream.delegate with 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

  1. Action lexicons for V1: Do we need place.stream.chat.block as 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.

  1. 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.

iameli avatar Dec 04 '25 03:12 iameli

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.group which contains multiple permissions grouped together
  • You could introduce a approvedBy or needs_approval type 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.listPermissions to 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.

ThisIsMissEm avatar Dec 04 '25 06:12 ThisIsMissEm

Oh, and be sure to create a rewriter for all v1 records to v2

ThisIsMissEm avatar Dec 04 '25 06:12 ThisIsMissEm

i prefer delegate over impersonate

PSingletary avatar Dec 05 '25 15:12 PSingletary

Updated with the latest place.stream.moderation.* lexicon format

tripledoublev avatar Dec 16 '25 16:12 tripledoublev

Ready for review

I've split the work into 3 PRs:

  1. #719 - Lexicons
  2. #787 - Backend (Go implementation, depends on #719)
  3. #788 - Tests (depends on #787)

Best reviewed in that order. Merging #788 would close this issue.

tripledoublev avatar Dec 16 '25 17:12 tripledoublev