Social Kernel — the configurable socialization substrate
Why this doc exists. Madara (2026-06-10): “build stokasoftware’s socialization infrastructure now and choose an architecture that will allow us to be endlessly configurable given the scope of the canon.” The canon’s scope is large — the Discovery engine is “the anonymous-communication / social layer (Stoka’s answer to traditional social media)” whose algorithm “optimizes for developing the user’s interests further, not retention” (CANON.md §III), with users “always anonymous yet reputation-bearing” (§VII). This spec chooses the architecture. It restates ratified canon (
wiki/canon/infra-requirements.md,specs/messaging.md) and adds the kernel design; the kernel design itself is proposed-pending until ratified.
0. What already exists (audit 2026-06-10)
Live in prod:
| Piece | Where | State |
|---|---|---|
Actor identity (act_<hex32>, handle, x25519 keys) | identity.account_actors, identity.actor_keys | live, but one-active-actor-per-account (uq_account_actors_one_active) |
| DM substrate (E2E-capable envelopes, cipher epochs, per-envelope TTL) | msg.threads/envelopes/participants/group_keys | live; SSE realtime + presence + typing in admin/main.py |
| Social graph | social.follows, social.blocks + actor-addressed APIs | live (“phase 2” block, admin/main.py:4773) |
| Profiles, recommendations, messages UI | src/pages/profile/, recommendations/, messages.astro | live |
| Audience-tier precedent | zetsu share substrate: public /wz/, auth /zetsu/, token /trace/ | live 2026-06-10 |
| Rank lore-RBAC (admin plane) | layer2/deidara/roles.yaml — viewer rank R sees X iff R ≤ X.rank | live |
Specced, not built: multi-pseudonym (I-R1), per-pseudonym config (bio/topics/ inbox_mode/discoverable/searchable), discovery directory, public content objects (posts), the interest-development feed, reputation.
Canon constraints inherited (ratified, do not re-litigate):
- I-R1 one human → many simultaneous pseudonyms, cheap mint/burn, no cap.
- I-R2 cross-pseudonym unlinkability is the END-STATE hard requirement; phase 2
ships custodial (B) behind the C/E interface — the
stoka_idFK may exist now but must be quarantined: no public API, payload, or page ever speaksstoka_id; everything public is actor-addressed. The FK lives in ONE module so the shielded migration is one seam. - I-R6 / ratified decision 4: abuse handling is per-context only, no global ban, no cross-context correlation capability is ever built.
- Reputation: per-pseudonym, non-transferable, new pseudonym starts at zero (working default, pending confirm).
- Discovery feed content = opt-in published provenance (infra-requirements §5) — the feed and provenance are the same data at two visibility levels.
1. The architecture — four planes, all config-driven
The kernel principle is the canon principle: LORE IS CONFIG. A new social surface (a content type, a visibility rule, a feed lens) must be a config entry + renderer, never a schema migration or a hand-rolled policy check.
┌─────────────────────────────────────────────────────────────┐
│ 4. DISCOVERY PLANE directory · topics · feed lenses │
│ (ranks BY reputation later; interest-development algo) │
├─────────────────────────────────────────────────────────────┤
│ 3. OBJECT PLANE social.objects + social.activities │
│ (typed payloads; type registry is config) │
├─────────────────────────────────────────────────────────────┤
│ 2. POLICY PLANE one audience resolver │
│ (public/followers/allowlist/auth/rank≤N/token — config) │
├─────────────────────────────────────────────────────────────┤
│ 1. PSEUDONYM PLANE identity.account_actors, multi-actor │
│ (acting-as switching; stoka_id FK quarantined) │
└─────────────────────────────────────────────────────────────┘
Plane 1 — Pseudonym (evolve account_actors, don’t replace)
identity.account_actors is already the right primitive (better than messaging.md’s
draft identity.pseudonyms — it has the actor_id shape and the key registry). Evolve:
- Drop
uq_account_actors_one_active→ many simultaneous active actors per account (I-R1). Keepuq_account_actors_one_primary(the default acting-as). - Add per-actor config (from messaging.md, ratified shape):
bio text,topics jsonb default '[]',inbox_mode text default 'closed'(closed|allowlist|open|public),discoverable bool default false,searchable bool default false,retired_at timestamptz(burn = retire, handle released to a tombstone, no forwarding pointer). All discovery toggles default OFF — trust-inverted discovery is opt-in per pseudonym. - Acting-as: every social/msg API accepts
X-Acting-As: act_…; the backend verifies the actor belongs to the caller’s stoka_id and is active. Omitted → primary actor. (JWT stays account-scoped; acting-as is a per-request claim. Cheap switch = one header, satisfies the messaging.md “one keypress” bar.) - Quarantine module:
admin/routers/social_kernel.pyowns every query that joinsstoka_id ↔ actor_id. Nothing else may write that join. This is the C/E seam. - Mint/burn endpoints:
POST /api/actors(auto-handleghost-XXXX, rate-limited),POST /api/actors/{id}/retire. No cap, no cooldown.
Plane 2 — Policy (one audience resolver, generalizing the zetsu tiers)
Every object carries an audience jsonb descriptor. ONE resolver evaluates it:
{"tier": "public"} // anyone, no auth (like /wz/)
{"tier": "auth"} // any signed-in stoka user (like /zetsu/)
{"tier": "followers"} // followers of author actor
{"tier": "allowlist", "actors": ["act_…"]} // explicit share targets
{"tier": "rank", "max": 2} // lore-RBAC rule, admin-plane objects
{"tier": "token", "hash": "…"} // capability link (like /trace/)
resolve_audience(viewer_ctx, audience) -> bool is the only gate, used by objects,
feeds, the directory, and (eventually) artifact grants. Blocks are evaluated BEFORE
audience (either-direction block = invisible, matching the live follows/blocks
semantics). New visibility rules = new tier evaluator registered in config — the
rank tier makes the admin plane and the public plane share one mechanism.
Plane 3 — Object (the endlessly-configurable core)
Two tables, ActivityStreams-shaped but local-first:
CREATE TABLE social.objects (
id TEXT PRIMARY KEY, -- obj_<hex32>
type TEXT NOT NULL, -- registry-checked, NOT enum-constrained
author_actor TEXT REFERENCES identity.account_actors(actor_id) ON DELETE SET NULL,
audience JSONB NOT NULL DEFAULT '{"tier":"public"}',
payload JSONB NOT NULL DEFAULT '{}', -- type-specific, schema in the registry
in_reply_to TEXT REFERENCES social.objects(id) ON DELETE CASCADE,
refs JSONB NOT NULL DEFAULT '{}', -- artifact ids, zetsu slugs, urls…
topics JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE TABLE social.activities (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id TEXT NOT NULL REFERENCES identity.account_actors(actor_id) ON DELETE CASCADE,
verb TEXT NOT NULL, -- like | boost | bookmark | flag | …
object_id TEXT NOT NULL REFERENCES social.objects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (actor_id, verb, object_id)
);
The type registry is a config file (layer2/social/object-types.yaml): each type
declares its payload schema, renderer, who may author it, default audience, whether
it appears in feeds. Launch types: post (markdown ≤ N chars), share (refs a zetsu
slug / artifact / url with a comment), reply. Future types (poll, badge,
provenance-card, bounty, listing…) are registry entries + an Astro renderer component
— no migration. This is the configurability contract.
Replies are objects with in_reply_to; likes/boosts/bookmarks are activities — so
“comments” and “reactions” on ANY future surface are already built.
Plane 4 — Discovery (directory + topics + feed lenses)
-
Directory:
GET /api/discover/actors?topic=…overdiscoverable=trueactors. Opt-in only. Handle search oversearchable=true. -
Topics: free tags now (jsonb on actors + objects), curated taxonomy later via the registry. Topic pages = filtered object queries.
-
Feed = a stack of configured lenses, not a ranking model.
GET /api/feedcomposes, in declared proportions (config, not code):following— objects from followed actors (chronological);topics— objects matching the acting pseudonym’s declared topics;adjacent— the canon-critical lens: objects from topics near yours that you do NOT follow — the “develop the user’s interests further” injection;fresh— new discoverable actors’ first posts (cold-start fairness).
No engagement-optimization, no retention loop — the lens stack and proportions are config (
layer2/social/feed-lenses.yaml), so tuning the algorithm is editing a yaml, and the algorithm is inspectable — which is itself an ethos differentiator Stoka can publish. Reputation-weighted ranking slots in later as a lens parameter when the provenance/reputation core lands (it reads, it doesn’t restructure).
2. What this is NOT (yet)
- Not federation. ActivityStreams shape keeps the door open; ActivityPub federation is a non-goal until the shielded core exists (federating custodial pseudonyms would export the linkage risk).
- Not the reputation system. That is gated on the shielded provenance core (infra-requirements §5–6). The kernel leaves it a clean read-side join.
- Not E2E for public objects. Public/auth objects are plaintext by nature; the
E2E machinery stays in
msg.*where it already lives.
3. Phasing
| Phase | Ships | Surface |
|---|---|---|
| A (this sprint) | multi-pseudonym + per-actor config + acting-as + mint/burn/switcher · audience resolver · objects/activities + registry (post/share/reply) · directory + topic + profile-feed pages · feed v1 (following+topics lenses) | /discover, actor profile pages, pseudonym switcher in nav |
| B | share deep-integration: zetsu/artifact rich cards in DMs and posts (messaging.md’s real mission); telegram doorbell | messages + post composer |
| C | adjacent+fresh lenses tuned; reputation lens once provenance core lands | feed |
Backend (migrations + social_kernel.py router) → codex-xhigh. FE (switcher,
discover, feed, composer) → Bernays. Playwright smoke before handoff. BE+FE rebuilt
together.
4. Decisions for Madara
- Lift one-active-actor now? Recommended YES — I-R1 is the identity primitive and retrofitting acting-as under live content is far costlier than under today’s 11 actors.
- Phase A scope: full kernel (objects + feed + directory) vs identity-first (multi-pseudonym + directory only, objects next).
- Public surface naming/placement:
/discoveras the canonical front-door path (canon calls it the Discovery engine) — confirm the name.