← All specs
Phase 2-5+ draft

Messaging — Anonymous Share Fabric

Pseudonym-based DM where artifacts circulate. Trust-inverted discovery. Telegram as doorbell. Voice (Phase 5+) deferred but scoped.

Messaging — Anonymous Share Fabric

Mission

The messaging layer isn't generic chat — it's the circulation infrastructure for artifacts. The primary action is "share this recipe with this handle," and everything else (thread model, telegram notifications, voice calls) follows from that.

Read README.md for platform mission and phase map. Read artifact-platform.md for what gets shared through this layer.

Design center

1. Messaging is the share fabric, not a chat app

Every artifact has a permalink and a share button. Clicking "share" opens a recipient picker, drops a DM with the artifact embedded as a rich card, cascades permission to the recipient's pseudonym if the artifact was private/link-only. The thread is the context around a shared artifact — not the main event.

2. Trust-inverted discovery

Anonymity primitives are strong enough that users feel safe being more discoverable, not less. This inverts the usual privacy/discovery tradeoff. A user will opt-in to bio, topics, open inbox — because the anonymity holds and they know it holds.

3. The Unencumbered Bar applies here more than anywhere

From README.md: optimistic UI, no permission modals, keyboard-first, instant pseudonym switching. Messaging accumulates friction faster than any other surface if you let it. Fight it on every change.

Pseudonym model

Principles

  • One user → many pseudonyms simultaneously. No cap, no friction to spawn.
  • Each pseudonym is its own discovery surface with its own settings.
  • Pseudonyms are cheap to mint and cheap to burn.
  • User→pseudonym link is stored (for cross-pseudonym abuse handling by autonomous mod, see below) — never user-visible.
  • No visible cross-linkage between pseudonyms of the same user, anywhere.

Per-pseudonym configuration

Setting Values Default Purpose
handle string, unique auto-generated (e.g. ghost-7c0a) Public identifier
bio markdown, free-form empty About-this-pseudonym text
topics array of tags [] Discovery taxonomy
inbox_mode closed / allowlist / open / public closed Who can DM
discoverable bool false Shows in public directory
searchable bool false Findable by handle text search
accepts_voice bool false Can be voice-called (Phase 5+)

All discovery toggles default off. Discovery is opt-in per pseudonym, not per user.

Lifecycle

  • Spawn: one click in the pseudonym switcher. New handle auto-generated, user can rename. No verification, no cooldown (rate-limited as abuse trigger only).
  • Switch: one keypress in the switcher. Changes the "acting as" pseudonym for all new actions.
  • Burn: one click. Handle retired, all threads closed, pseudonym profile page shows "retired." No forwarding pointer.

Inbox modes explained

Mode Who can initiate DM
closed Nobody. Pseudonym can still send outbound.
allowlist Only pseudonyms on the owner's allowlist.
open Anyone with the handle. Handle sharing is the friction.
public Anyone, and pseudonym appears in the public directory.

Schema

CREATE TABLE identity.pseudonyms (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES identity.users(id),
  handle          TEXT NOT NULL UNIQUE,
  bio             TEXT,
  topics          JSONB DEFAULT '[]',
  inbox_mode      TEXT NOT NULL DEFAULT 'closed',
  discoverable    BOOLEAN DEFAULT false,
  searchable      BOOLEAN DEFAULT false,
  accepts_voice   BOOLEAN DEFAULT false,
  display_order   INT DEFAULT 0,
  created_at      TIMESTAMPTZ DEFAULT now(),
  retired_at      TIMESTAMPTZ
);

CREATE INDEX ON identity.pseudonyms (user_id);
CREATE INDEX ON identity.pseudonyms (handle) WHERE retired_at IS NULL;
CREATE INDEX ON identity.pseudonyms (discoverable, searchable) WHERE retired_at IS NULL;

CREATE TABLE identity.threads (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  pseudonym_a_id  UUID NOT NULL REFERENCES identity.pseudonyms(id),
  pseudonym_b_id  UUID NOT NULL REFERENCES identity.pseudonyms(id),
  -- canonical: a_id < b_id for dedupe
  status          TEXT NOT NULL DEFAULT 'open',  -- open | blocked | archived
  created_at      TIMESTAMPTZ DEFAULT now(),
  last_message_at TIMESTAMPTZ,
  CHECK (pseudonym_a_id < pseudonym_b_id),
  UNIQUE(pseudonym_a_id, pseudonym_b_id)
);

CREATE INDEX ON identity.threads (pseudonym_a_id, last_message_at DESC);
CREATE INDEX ON identity.threads (pseudonym_b_id, last_message_at DESC);

CREATE TABLE identity.messages (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  thread_id       UUID NOT NULL REFERENCES identity.threads(id) ON DELETE CASCADE,
  sender_pseudonym_id UUID NOT NULL REFERENCES identity.pseudonyms(id),
  kind            TEXT NOT NULL DEFAULT 'text',  -- text | artifact_embed | thread_link | voice_invite
  body            TEXT,                          -- text body or null for embed-only
  embed           JSONB,                         -- artifact_id, thread_id, voice_session_id etc.
  created_at      TIMESTAMPTZ DEFAULT now(),
  read_at         TIMESTAMPTZ,
  deleted_at      TIMESTAMPTZ
);

CREATE INDEX ON identity.messages (thread_id, created_at DESC);

CREATE TABLE identity.artifact_grants (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  artifact_id     UUID NOT NULL REFERENCES identity.artifacts(id) ON DELETE CASCADE,
  pseudonym_id    UUID NOT NULL REFERENCES identity.pseudonyms(id),
  granted_via     TEXT NOT NULL,                 -- share_dm | share_link | owner
  granted_by_pseudonym_id UUID REFERENCES identity.pseudonyms(id),
  created_at      TIMESTAMPTZ DEFAULT now(),
  UNIQUE(artifact_id, pseudonym_id)
);

CREATE TABLE identity.telegram_links (
  user_id         UUID PRIMARY KEY REFERENCES identity.users(id),
  chat_id         BIGINT NOT NULL UNIQUE,
  paired_at       TIMESTAMPTZ DEFAULT now(),
  notify_dms      BOOLEAN DEFAULT true,
  muted_threads   JSONB DEFAULT '[]'
);

CREATE TABLE identity.telegram_pair_tokens (
  token           TEXT PRIMARY KEY,
  user_id         UUID NOT NULL REFERENCES identity.users(id),
  expires_at      TIMESTAMPTZ NOT NULL,
  used_at         TIMESTAMPTZ
);

CREATE INDEX ON identity.telegram_pair_tokens (user_id, expires_at);

API shape

All endpoints auth'd via existing admin/user auth. Pseudonym context passed via X-Acting-As-Pseudonym header (the currently-switched pseudonym).

Pseudonym management

GET    /api/pseudonyms                 list mine
POST   /api/pseudonyms                 mint a new one
PATCH  /api/pseudonyms/:id             update settings (bio, topics, inbox_mode, etc.)
POST   /api/pseudonyms/:id/retire      burn

Threads + messages

POST   /api/dm/threads                 start/resume by recipient handle
GET    /api/dm/threads                 list mine (for acting pseudonym)
GET    /api/dm/threads/:id             thread detail
POST   /api/dm/threads/:id/messages    send (supports text + embeds)
GET    /api/dm/threads/:id/since?after=N&wait=25   long-poll (mirrors /api/konan/since)
POST   /api/dm/threads/:id/read        mark read
POST   /api/dm/threads/:id/block       block + archive

Sharing primitive

POST   /api/artifacts/:id/share        body: { recipient_handle, note? }
       — creates thread if none exists
       — grants access if artifact is private/link-only
       — drops artifact_embed message
       — returns thread_id

Telegram pairing (Phase 2)

POST   /api/telegram/pair/start        returns { token, telegram_deeplink }
POST   /api/telegram/pair/complete     bot webhook: { token, chat_id }
DELETE /api/telegram/pair              unlink
PATCH  /api/telegram/preferences       mute thread, toggle DM notifications

The sharing primitive in detail

The one-click share flow

[artifact page]
 └─ [share button]
    └─ recipient picker opens (modal, but NO "are you sure" friction)
       ├─ recent DM recipients (one-tap)
       ├─ handle search (typeahead)
       └─ paste handle (direct)
    ↓ (user picks recipient, optional note)
    POST /api/artifacts/:id/share
    ↓
    — if thread exists: append embed message
    — if not: create thread, append embed message
    — if artifact was private/link-only: grant recipient's pseudonym access
    ↓
    user sees: confirmation toast "shared with @ghost-7c0a"
               + "open thread" button

Rich artifact-card embed

When a kind=artifact_embed message is rendered in a thread, it shows a card:

┌─ shared by @you ───────────────────────────────
│ [artifact_kind badge]  Artifact Title
│ ─────────────────────────────────────────────
│ Summary line from artifact.summary
│ [recipe snippet if kind=conversation]
│
│ [View full →]
└────────────────────────────────────────────────

Click → navigate to artifact page. Access grant was created when the share happened, so the recipient can view immediately.

Conversation (thread) permalinks

Threads themselves are shareable (e.g., "look what @ghost-7c0a told me"):

POST /api/dm/threads/:id/quote    → creates a read-only snapshot, returns permalink

The snapshot is a new artifact (kind=thread_quote or similar) that can itself be shared into other threads. Recursive sharing fabric.

Inbox — the unified mailbox

DM threads alone aren't enough. The platform also needs a lightweight, native, email-like inbox where messages sit and wait — a surface the user pulls toward themselves on their schedule. Different mental model from active conversation: async, persistent, browseable.

What the inbox is

The user's home page after login. A unified view of every incoming item across all of their pseudonyms, with read/unread/archive state.

Inbox row kinds Source
New DM message messages table — unread for any of my pseudonyms
Shared artifact artifact_grants rows where I'm the recipient
Mod notice mod_actions targeting one of my pseudonyms (Stoka's voice)
System notice platform announcements (rare; opt-out by default)
Mention in artifact (Phase 3+) someone @-mentioned a handle of mine in a public artifact

Cross-pseudonym aggregation

The inbox is user-scoped, not pseudonym-scoped. Every row is tagged with which pseudonym received the item. The user sees "everything for me, in one place," but anonymity is preserved on the recipient side — other users only see the specific pseudonym they messaged. The user→pseudonym aggregation is internal-only.

This solves the practical problem: if you have 5 pseudonyms, you don't want to switch between them just to check if anything happened. One inbox, contextual rows.

UX

  • Default home view at /inbox (or /messages)
  • Filter pills: All · Unread · Shared · Mod · Pseudonym dropdown · Date range
  • Stoka search at the top — natural-language ("anything from @ghost-7c0a about RAG")
  • Click row → opens contextual surface (thread for DM, artifact page for share, mod chat thread for mod)
  • Bulk select + actions: mark read, archive, star
  • Compose button → recipient picker → new thread (same primitive as share-to-handle)
  • Keyboard-first (per Unencumbered Bar): j/k navigate, e archive, s star, r reply

Schema

CREATE TABLE identity.inbox_items (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES identity.users(id),
  pseudonym_id    UUID REFERENCES identity.pseudonyms(id),  -- which handle received
  kind            TEXT NOT NULL,            -- dm_message | shared_artifact | mod_notice | system | mention
  subject         TEXT,                     -- e.g. thread title, artifact title, mod summary
  preview         TEXT,                     -- short text snippet for the row
  ref_kind        TEXT NOT NULL,            -- thread | artifact | mod_action | system
  ref_id          UUID NOT NULL,
  state           TEXT DEFAULT 'unread',    -- unread | read | archived | starred
  arrived_at      TIMESTAMPTZ DEFAULT now(),
  state_changed_at TIMESTAMPTZ
);

CREATE INDEX ON identity.inbox_items (user_id, arrived_at DESC);
CREATE INDEX ON identity.inbox_items (user_id, state, arrived_at DESC);
CREATE INDEX ON identity.inbox_items (ref_kind, ref_id);

Inbox rows are written on event by the workers that produce DMs / shares / mod actions. Redundant with the source data on purpose — the read/unread state is per-row, queries are fast, and the UI is simple. Refactor to a materialized view if it grows past comfort.

API

GET    /api/inbox                        list (filter by state, kind, pseudonym, date)
POST   /api/inbox/:id/state              { state: read | archived | starred }
POST   /api/inbox/bulk-state             { ids: [...], state: ... }
GET    /api/inbox/unread-counts          per-pseudonym + total badge counts

Long-poll variant (/api/inbox/since?after=N&wait=25) for live updates without page refresh.

Inbox vs DM threads — when to use which

Surface When
Inbox "What's new for me?" — the home dashboard, async catch-up, browse + triage
DM thread "I'm in a conversation right now" — focused on one back-and-forth

A DM message produces an inbox_items row when it arrives unread. Once the user opens the thread, the row's state flips to read. The thread is the conversation; the inbox is the doorway.

Telegram as doorbell

The one rule

Telegram never contains message bodies. It's a doorbell: "you have a message, click here." The body stays behind Stoka auth.

This protects anonymity against:

  • Shoulder-surfing on the Telegram side
  • Telegram itself being compromised or subpoenaed
  • Cross-correlation of pseudonyms via Telegram account

Pairing flow

Telegram pairing — deeplink + token
  1. userClicks "Connect Telegram" in settings
  2. clientPOST /api/telegram/pair/start

    Receives { token, deeplink — https://t.me/StokaDmBot?start=<token> }

  3. userOpens deeplink, taps Start in Telegram
  4. telegramBot receives "/start <token>"

    Posts to /api/telegram/pair/complete with { token, chat_id }

  5. serverValidates token (unused, unexpired)

    Stores telegram_links row. Rejects if token expired or already consumed.

    branches
    • if token validstep 6
    • if token expired or reusedstep abort
  6. telegramBot replies — "linked. you'll get a ping here when someone messages your handles."

Pair tokens are single-use with 10-minute TTL.

Notification rule

When a message lands in any thread where the recipient has a telegram_links row:

async def maybe_notify_telegram(recipient_user_id, thread_id, sender_handle):
    # Skip if recipient is online (recently active in-app)
    if await is_recipient_online(recipient_user_id, window_seconds=60):
        return

    # Skip if thread is muted
    link = await get_telegram_link(recipient_user_id)
    if not link or not link.notify_dms:
        return
    if thread_id in link.muted_threads:
        return

    # Doorbell — no body, ever
    await telegram_bot.send_message(
        chat_id=link.chat_id,
        text=f"📨 new message for you in Stoka\n"
             f"→ https://stokasoftware.com/dm/{thread_id}",
    )

Bot implementation

New container: stoka-tg-bot. ~50 lines of python-telegram-bot. Long-poll getUpdates (webhook has issues with Cloudflare Tunnel for some bot endpoints). Handles /start <token> → calls backend pair/complete.

Lives next to the existing admin services in docker-compose.yml. Env: TG_BOT_TOKEN, STOKA_API_URL, STOKA_API_KEY.

Notifications — opt-in pull, never push

Telegram is one channel. The platform supports several, all opt-in, all configurable per event type and per pseudonym. The platform never sends notifications by default. A new user gets zero pings until they explicitly turn one on.

This is not "good UX hygiene" — it's the trust contract. The platform doesn't demand attention, it waits for the user to ask to be pinged.

Channels

Channel What Status
In-app badge Unread count on inbox icon, browser tab title Default on (no opt-in needed; passive)
Telegram doorbell "you have a message → link" — body never included Opt-in pairing flow
Web push Browser-native push notifications via Push API + service worker Opt-in subscription
Email out Real email to user's verified address (digest or instant) Opt-in, requires email verification (Phase 2.5)
Mobile push Native app push (when there's a mobile app) Future (Phase 5+)

All channels follow the doorbell rule for content: notifications never include message bodies, artifact bodies, or pseudonym discovery info. Just "you have a thing, click here." Body stays behind Stoka auth.

Per-event configuration

Notifications are configured at the event type level. Each user picks which events ping them on which channels.

Event Default Notes
New DM message Off (in-app badge only) Telegram/web push opt-in
Shared artifact arrived Off Often higher signal — many users will turn this on
Mod notice (your pseudonym) On (in-app + email if verified) Important — affects account state
Appeal status change On (in-app + chosen channels) Important — directly user-relevant
Mention in public artifact Off Phase 3+
Voice call invite Configurable Phase 5+
System announcement Off (rare; user controls)

Quiet hours + digest mode

Standard email-style controls.

  • Quiet hours: per-user time window where no channels fire (in-app badge still updates silently). Stored in user TZ.
  • Digest mode per channel: instant | hourly | daily | off. Digest mode batches notifications into a single message (e.g., "you have 3 new messages and 1 new mod notice").

Schema

CREATE TABLE identity.notification_channels (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES identity.users(id),
  kind            TEXT NOT NULL,                       -- telegram | web_push | email_out
  config          JSONB NOT NULL,                      -- channel-specific (chat_id for tg, push subscription for web_push, etc)
  enabled         BOOLEAN DEFAULT true,
  verified_at     TIMESTAMPTZ,                         -- for channels that require verification
  created_at      TIMESTAMPTZ DEFAULT now(),
  UNIQUE(user_id, kind)
);

CREATE TABLE identity.notification_preferences (
  user_id         UUID PRIMARY KEY REFERENCES identity.users(id),
  prefs           JSONB NOT NULL DEFAULT '{}',
  -- prefs schema:
  -- {
  --   "events": {
  --     "dm_new":          { "telegram": true, "web_push": false, "email_out": "daily" },
  --     "share_arrived":   { ... },
  --     "mod_notice":      { ... },
  --     "appeal_change":   { ... }
  --   },
  --   "quiet_hours": { "enabled": true, "start": "22:00", "end": "08:00", "tz": "America/New_York" },
  --   "per_pseudonym": { "<pseudonym_id>": { "muted": true } },
  --   "per_thread": { "<thread_id>": { "muted": true } }
  -- }
  updated_at      TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE identity.pending_notifications (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES identity.users(id),
  channel_kind    TEXT NOT NULL,
  event_type      TEXT NOT NULL,
  payload         JSONB NOT NULL,                      -- enough to render the notification
  created_at      TIMESTAMPTZ DEFAULT now(),
  scheduled_for   TIMESTAMPTZ,                         -- for quiet-hours deferral or digest batching
  sent_at         TIMESTAMPTZ,
  failed_at       TIMESTAMPTZ,
  failure_reason  TEXT
);

CREATE INDEX ON identity.pending_notifications (user_id, scheduled_for) WHERE sent_at IS NULL;

The dispatch worker

A single worker processes pending_notifications:

async def dispatch_pending():
    rows = await db.fetch_due_notifications(now())
    by_user_channel = group(rows, key=lambda r: (r.user_id, r.channel_kind))
    for (user_id, channel_kind), batch in by_user_channel.items():
        prefs = await get_preferences(user_id)
        if in_quiet_hours(prefs, now()):
            await defer(batch, until=quiet_hours_end(prefs))
            continue
        if prefs.digest_mode(channel_kind) == "instant":
            for n in batch:
                await send(n)
        else:
            await send_digest(channel_kind, user_id, batch)

Recipient online check (skip if recently active in-app within window) applies before scheduling, not at dispatch — so an actively-typing user doesn't get pinged for messages they're literally about to see.

Settings UX

/settings/notifications page — single dashboard:

  • Channels list — connect/disconnect Telegram, web push, email
  • Per-event matrix — checkboxes for each event × channel combination
  • Quiet hours — start/end picker + timezone (auto-detected)
  • Digest mode picker per channel
  • Per-pseudonym mute — quick toggle to silence a specific handle
  • Per-thread mute — accessible from the thread itself, shown here for review

Web push specifics

  • VAPID key pair in env (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
  • Service worker at /sw.js (registered on first visit, only used after subscription)
  • Subscription endpoint stored in notification_channels.config as { endpoint, keys: { p256dh, auth } }
  • Use pywebpush or equivalent for delivery

Email-out specifics (Phase 2.5)

Requires an outbound mail provider (Postmark / SES / Mailgun). User must verify their email before this channel can be enabled. Anonymity contract: the email address is never associated with pseudonyms in any user-visible surface — it's purely a delivery channel for notifications about the user's account.

Email contents follow the doorbell rule: subject = "you have N new messages in Stoka," body = a list of "from @handle: [open]" links. Never the message body.

The Unencumbered Bar — messaging specifics

Specific mechanics for the UX quality bar:

  • Optimistic send: message appears in the thread before the server acks. Failed sends show a retry affordance, don't remove the message.
  • No confirm dialogs: sending, switching pseudonym, closing a thread — none of these ask "are you sure."
  • Keyboard-first:
    • Ctrl+K — command palette (search handles, jump to thread, spawn pseudonym)
    • `Ctrl+`` — toggle Stoka bubble (existing, extends to DM surface context)
    • Ctrl+Shift+P — pseudonym switcher dropdown
    • Enter to send, Shift+Enter for newline (consistent with Konan bubble)
  • Instant thread load: stream first 50 messages; background-load the rest.
  • No full-page reloads: client-side routing for thread navigation, artifact embeds, pseudonym profiles.
  • Rate limits are invisible in the happy path: only abuse-triggered, never "you've sent too many messages — wait N seconds" for normal use.

Moderation — Stoka as autonomous mod

Autonomous mod strengthens the trust story (vs human admin looking through reports): the de-anon query becomes "an agent ran rule X and logged it," more auditable than "an admin looked."

The Stoka-as-mod pattern

Same Stoka identity (see stoka-bot.md → Stoka Across Surfaces), different context injection:

[Layer 3 — Content]   triggered rule + pseudonym behavior log + content (if applicable)
[Layer 4 — Context]   flagged user's strike history across all their pseudonyms
                      (visible to mod context only — requires admin privilege)

Mod agent picks a rung on the action ladder, writes its reasoning in Stoka's voice, action takes effect immediately.

Trigger ruleset (public)

Trigger Detection
Report threshold N user reports on a pseudonym within window
Rate-limit hit Outbound DM rate exceeds threshold
Recipient diversity High ratio of unique recipients for new pseudonym
Block rate >X% of recipients block after first message
Content classifier Slurs, threats, CSAM perceptual hash, link spam patterns
Behavioral pattern Same body to >N recipients; mass-DM after mint

Rule categories are public. Exact thresholds are private and rotating (adversarial-probing mitigation).

Action ladder

Stoka-as-mod — escalation rungstargetpseudonym → user
  1. Silent warn

    low

    Pseudonym sees notice. No external signal.

    reversibilityauto-reversesvisibilitylogged & audited
  2. Throttle outbound

    medium

    Cooldown window applied to sends. Receiving still works.

    reversibilityauto-reversesvisibilitylogged & audited
  3. Freeze pseudonym

    high

    Read-only. Can't send or receive. Existing threads stay visible.

    reversibilitymanually reversiblevisibilitylogged & audited
  4. Escalate to user_id

    high

    Freeze all of that user's pseudonyms simultaneously. Crosses the pseudonym barrier.

    reversibilitymanually reversiblevisibilitylogged & audited
  5. Permaban user_id

    critical

    No new pseudonyms can spawn from this user. Final rung.

    reversibilityirreversiblevisibilitypublicly visible

Schema additions (Phase 4)

moderation schema — Phase 4
identity.mod_triggers6

What caused a mod event — rule, evidence, affected entities.

  • idUUIDPK
  • ruleTEXTNN
    public rule name from the trigger ruleset
  • pseudonym_idUUIDFK
    → identity.pseudonyms.id
  • user_idUUIDFK
    → identity.users.id
  • evidenceJSONB
    raw evidence blob — messages
  • created_atTIMESTAMPTZ
    defaultnow()
identity.mod_actions9

What was done in response. Links trigger → action ladder rung.

  • idUUIDPK
  • trigger_idUUIDFK
    → identity.mod_triggers.id
  • rungINTNN
    1..5 on the action ladder
  • target_kindTEXTNN
    enumpseudonym · user
  • target_idUUIDNN
  • reasoningTEXTNN
    Stoka's voice
  • decided_atTIMESTAMPTZ
    defaultnow()
  • reversed_atTIMESTAMPTZ
  • reversed_reasonTEXT
identity.user_strikes5

Per-user strike counter and permaban flag. One row per user.

  • user_idUUIDPKFK
    → identity.users.id
  • active_strikesINT
    default0
  • escalation_tierINT
    default0
  • last_strike_atTIMESTAMPTZ
  • permabannedBOOLEAN
    defaultfalse
identity.mod_appeals8

Appeal thread — Stoka-chat interface, weighted toward overturn.

  • idUUIDPK
  • action_idUUIDFK
    → identity.mod_actions.id
  • appeal_textTEXTNN
  • pseudonym_idUUIDFK
    → identity.pseudonyms.id
  • created_atTIMESTAMPTZ
    defaultnow()
  • resolved_atTIMESTAMPTZ
  • resolutionTEXT
    enumupheld · overturned · modified
  • reasoningTEXT
    Stoka's voice on appeal
relationships
  • mod_actions.trigger_idmod_triggers.idcaused by
  • mod_appeals.action_idmod_actions.idcontests
  • user_strikes.user_idusers.idtracks

Appeal UX — embedded, not hidden

Per the one-brain-many-masks pattern, the appeal surface is a Stoka chat thread. User opens appeal → Stoka bubble appears with the mod context + user's original strike + ability to chat through the appeal. The appeal "feels" like talking to the site itself, not filing a form.

Appeals weight toward overturning: false-ban cost > missed-ban cost for trust in the platform.

/transparency — public audit surface

Public page (kind=spec artifact, technically). Updates in real-time:

  • Triggers/week by category
  • Actions/week by rung
  • Appeal grant rate
  • Active permabans (count, no identities)
  • False-positive rate (manual sample audit, quarterly)

No identities, no message content, no thread links. Just counts and narratives — Stoka writes a weekly summary in its voice.

Voice calls (Phase 5+)

Architecture deferred. Captured here so the design doesn't drift before we get there.

Principle

Voice = pseudonym for audio. Same opt-in / swap-anytime pattern as text pseudonyms. A user might have a "warm" preset for one circle and a "neutral" preset for another.

Non-negotiables

  • Quality bar is high. Flat TTS kills the feel; uncanny prosody kills it harder. Better 6 great voices than 60 mediocre ones.
  • Expression-aware — prosody, emotion, pacing. Not just text-to-speech.
  • Preset-curated — no "train on my voice" (identity risk). Authors pick from a curated preset library.
  • Anonymity holds through audio — cannot be linked to the user's real voice.

Tech candidates

  • Open-source: Coqui XTTS, GPT-SoVITS, Zonos
  • Commercial: ElevenLabs voice-changer API

Decision deferred to Phase 5 scoping. The bot/call infrastructure (WebRTC, STUN/TURN, session keys) is also Phase 5.

Schema stub

-- Phase 5+ — placeholder, refine when scoping
CREATE TABLE identity.voice_sessions (
  id              UUID PRIMARY KEY,
  thread_id       UUID REFERENCES identity.threads(id),
  initiator_pseudonym_id UUID,
  recipient_pseudonym_id UUID,
  voice_preset_initiator TEXT,
  voice_preset_recipient TEXT,
  started_at      TIMESTAMPTZ,
  ended_at        TIMESTAMPTZ
);

Phase plan

Phase 2 — Sharing core (invite round)

  • identity.pseudonyms + threads + messages + artifact_grants tables
  • Pseudonym management endpoints
  • Thread + message endpoints with long-poll
  • Sharing primitive + rich artifact-card embed
  • Recipient picker UI
  • Pseudonym switcher (Ctrl+Shift+P)
  • Command palette (Ctrl+K)
  • Inboxinbox_items table, /inbox page, cross-pseudonym aggregation, bulk actions, keyboard nav
  • Notifications corenotification_channels + notification_preferences + pending_notifications tables, dispatch worker, /settings/notifications page
  • In-app badge channel (default on)
  • Telegram pair tables + bot container (one channel)
  • Web push channel (VAPID + service worker + subscription endpoint)
  • Quiet hours + per-channel digest mode

Phase 2.5 — Email-out channel

  • Email verification flow
  • Outbound mail provider integration (Postmark or equivalent)
  • Email digest renderer (doorbell rule — no bodies)

Phase 3 — Public discovery overlay

  • discoverable/searchable flags wired into directory UI
  • Pseudonym profile pages
  • Handle search + directory browse

Phase 4 — Stoka-as-mod

  • mod_triggers + mod_actions + user_strikes + mod_appeals tables
  • Trigger detection workers (rate-limit, content classifier, report threshold)
  • Stoka mod-context injector
  • Action ladder executor (freeze / throttle / etc.)
  • Appeal UI (Stoka chat thread surface)
  • /transparency page with live audit data

Phase 5+ — Voice

  • Deferred. Scope when Phase 4 is stable and demand is confirmed.

Open questions (TBDs)

  1. Rate-limit tuning. New-pseudonym outbound thresholds — conservative (risk: friction for real users) vs permissive (risk: abuse window). Decide with Phase 4 content classifier output informing defaults.
  2. Handle auto-generation style. ghost-7c0a (word-hex) vs tungsten-marsh (word-word) vs @a_nonymous_4928 (adjectives). Decide on aesthetics + collision rate tradeoff before shipping.
  3. Thread-quote permalinks. Should thread quotes be a separate kind or just an artifact attribute? Leaning separate kind (thread_quote) for clean rendering.
  4. Telegram bot library choice. python-telegram-bot (async, mature) vs raw aiohttp against bot API (smaller dep). Default to python-telegram-bot.
  5. Voice preset library curation. Who picks presets, how many, how they're updated. Phase 5 scope.
  6. Inbox materialization. Write-on-event vs query-derived. Default to write-on-event for simplicity; reconsider if storage/perf becomes an issue.
  7. Web push fallback. Browsers without service worker support — degrade to in-app badge only, or refuse to enable web push? Default: refuse to enable, show clear message.
  8. Email verification UX. Magic link vs code paste. Magic link is friendlier; code paste survives email-client preview-rendering issues. Default: magic link.

Related specs