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/knavigate,earchive,sstar,rreply
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
- userClicks "Connect Telegram" in settings
- clientPOST /api/telegram/pair/start
Receives { token, deeplink — https://t.me/StokaDmBot?start=<token> }
- userOpens deeplink, taps Start in Telegram
- telegramBot receives "/start <token>"
Posts to /api/telegram/pair/complete with { token, chat_id }
- serverValidates token (unused, unexpired)
Stores telegram_links row. Rejects if token expired or already consumed.
branches- if token valid→step 6
- if token expired or reused→step abort
- 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.configas{ endpoint, keys: { p256dh, auth } } - Use
pywebpushor 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
Silent warn
lowPseudonym sees notice. No external signal.
Throttle outbound
mediumCooldown window applied to sends. Receiving still works.
Freeze pseudonym
highRead-only. Can't send or receive. Existing threads stay visible.
Escalate to user_id
highFreeze all of that user's pseudonyms simultaneously. Crosses the pseudonym barrier.
Permaban user_id
criticalNo new pseudonyms can spawn from this user. Final rung.
Schema additions (Phase 4)
What caused a mod event — rule, evidence, affected entities.
- idUUIDPK
- ruleTEXTNNpublic rule name from the trigger ruleset
- pseudonym_idUUIDFK→ identity.pseudonyms.id
- user_idUUIDFK→ identity.users.id
- evidenceJSONBraw evidence blob — messages
- created_atTIMESTAMPTZdefaultnow()
What was done in response. Links trigger → action ladder rung.
- idUUIDPK
- trigger_idUUIDFK→ identity.mod_triggers.id
- rungINTNN1..5 on the action ladder
- target_kindTEXTNNenumpseudonym · user
- target_idUUIDNN
- reasoningTEXTNNStoka's voice
- decided_atTIMESTAMPTZdefaultnow()
- reversed_atTIMESTAMPTZ
- reversed_reasonTEXT
Per-user strike counter and permaban flag. One row per user.
- user_idUUIDPKFK→ identity.users.id
- active_strikesINTdefault0
- escalation_tierINTdefault0
- last_strike_atTIMESTAMPTZ
- permabannedBOOLEANdefaultfalse
Appeal thread — Stoka-chat interface, weighted toward overturn.
- idUUIDPK
- action_idUUIDFK→ identity.mod_actions.id
- appeal_textTEXTNN
- pseudonym_idUUIDFK→ identity.pseudonyms.id
- created_atTIMESTAMPTZdefaultnow()
- resolved_atTIMESTAMPTZ
- resolutionTEXTenumupheld · overturned · modified
- reasoningTEXTStoka's voice on appeal
- mod_actions.trigger_id→mod_triggers.idcaused by
- mod_appeals.action_id→mod_actions.idcontests
- user_strikes.user_id→users.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_grantstables - 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)
- Inbox —
inbox_itemstable,/inboxpage, cross-pseudonym aggregation, bulk actions, keyboard nav - Notifications core —
notification_channels+notification_preferences+pending_notificationstables, dispatch worker,/settings/notificationspage - 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/searchableflags wired into directory UI - Pseudonym profile pages
- Handle search + directory browse
Phase 4 — Stoka-as-mod
-
mod_triggers+mod_actions+user_strikes+mod_appealstables - 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)
-
/transparencypage with live audit data
Phase 5+ — Voice
- Deferred. Scope when Phase 4 is stable and demand is confirmed.
Open questions (TBDs)
- 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.
- Handle auto-generation style.
ghost-7c0a(word-hex) vstungsten-marsh(word-word) vs@a_nonymous_4928(adjectives). Decide on aesthetics + collision rate tradeoff before shipping. - Thread-quote permalinks. Should thread quotes be a separate kind or just an artifact attribute? Leaning separate kind (
thread_quote) for clean rendering. - Telegram bot library choice.
python-telegram-bot(async, mature) vs raw aiohttp against bot API (smaller dep). Default topython-telegram-bot. - Voice preset library curation. Who picks presets, how many, how they're updated. Phase 5 scope.
- Inbox materialization. Write-on-event vs query-derived. Default to write-on-event for simplicity; reconsider if storage/perf becomes an issue.
- 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.
- 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
- README.md — platform mission + phases
- artifact-platform.md — what gets shared through this layer
- stoka-bot.md — the Stoka-across-surfaces pattern + mod context