The polis content system organizes all site data around bundles -- namespaced packages that declare content types, events, storage layout, and handler dispatch. The initial bundle pub.polis.core ships with six content types: post, comment, follow, feed, dm, and tag. Third-party bundles extend the system with custom types without modifying the core protocol.
.well-known/
polis # identity + bundle registry + site metadata
site/
snippets/ # rendering primitives (about.md, etc.)
themes/ # theme templates (sols, turbo, zane)
content/ # SOURCE OF TRUTH (all content lives here)
pub.polis.core/
bundle.json # bundle definition (the contract)
index.jsonl # bundle content registry
post/ # pub.polis.post source
YYYYMMDD/slug.md
YYYYMMDD/.versions/slug.md
comment/ # pub.polis.comment source
YYYYMMDD/slug.md
blessed.json # blessed comments index (public artifact)
follow/ # pub.polis.follow data
following.json
policies/ # public policies (one rule per line, JSONL)
rules.jsonl
# RENDERED OUTPUT (generated from content/ via mount points)
posts/ # mount: /posts (rendered HTML + copied .md)
YYYYMMDD/slug.html
YYYYMMDD/slug.md
comments/ # mount: /comments
YYYYMMDD/slug.html
YYYYMMDD/slug.md
blessed.json # copied from content/
.polis/ # ALL PRIVATE STATE
keys/
id_ed25519
id_ed25519.pub
storage-salt # 32 random bytes (hex), site-wide encryption salt
api-keys.json # API key hashes (SHA-256)
logs/ # structured event logs
policies/ # private policies (evaluated before public)
rules.jsonl
ds/<domain>/
pub.polis.core/ # bundle-scoped DS (handler-managed)
state/ # computed/derived data (safely deletable)
cursors.json # per-projection cursor positions
pub.polis.follow.json # materialized follower set
pub.polis.comment.blessing.json # blessing state
pub.polis.notification.jsonl # notification entries
pub.polis.feed.jsonl # feed items
config/ # user preferences (survives resets)
notifications.json # rules, muted_domains
feed.json # staleness_minutes, max_items, max_age_days
content/
pub.polis.core/ # private bundle state
posts/
drafts/
comments/
drafts/
pending/
denied/
dm/ # pub.polis.dm (private, encrypted at rest)
conversations.json
conv/
webapp/
config.json
hooks/
| Category | Root | Purpose |
|---|---|---|
| Public source | content/ |
Authoritative content files. All posts, comments, and follow data live here. |
| Rendered output | posts/, comments/ |
Generated from content/ via mount points. Regeneratable, but must be committed for self-hosting. |
| Site resources | site/ |
Snippets, themes, and other rendering resources. |
| Identity | .well-known/polis |
Author identity, public key, bundle registry, site metadata. |
| Private state | .polis/ |
Keys, logs, DS state/config, drafts, pending comments, API keys, webapp config. |
| Policies | policies/, .polis/policies/ |
Public and private policy rules (JSONL). Private evaluated first. |
Bundle naming. Bundles use reverse-domain notation: pub.polis.core, com.example.recipes. The bundle name is the directory name under content/.
Content type directories. Each content type declares a dir field. The source directory is content/<bundle>/<dir>/. Example: content/pub.polis.core/post/ for pub.polis.post.
Date format. Posts and comments use YYYYMMDD date directories (e.g., content/pub.polis.core/post/20260301/hello.md). The format is declared per content type in storage.date_format.
Version storage. When storage.versions is true, previous versions are stored in .versions/ subdirectories alongside content. Example: content/pub.polis.core/post/20260301/.versions/hello.md.
Private mirroring. Private state mirrors the public content path under .polis/content/. If bundle content is at content/pub.polis.core/, the private root is .polis/content/pub.polis.core/. The private directory uses plural names for content types that have public mounts: post becomes posts, comment becomes comments. Private-only types like dm keep their original directory name. Example: .polis/content/pub.polis.core/posts/drafts/.
DS state scoping. Discovery service state is scoped by DS domain and bundle name: .polis/ds/<domain>/<bundle>/. Each bundle maintains its own state and config directories. State files are safely deletable (recomputed from the stream). Config files hold user preferences and survive resets. All state filenames match their cursor key in cursors.json (e.g., cursor key pub.polis.feed corresponds to file pub.polis.feed.jsonl).
Bundle declarations live at content/<bundle-name>/bundle.json. The .well-known/polis file maintains a registry of active bundles:
{
"version": "polis-cli-go/0.57.0",
"public_key": "ssh-ed25519 ...",
"author": "alice",
"email": "alice@example.com",
"site_title": "Alice's Space",
"created": "2026-01-01T00:00:00Z",
"active_theme": "sols",
"bundles": {
"pub.polis.core": {
"active": true,
"path": "content/pub.polis.core/bundle.json"
}
}
}{
"name": "<string, required>",
"version": "<semver string, required>",
"description": "<string, optional>",
"handler": { "<handler declaration, required>" },
"ds": { "<DS integration, optional>" },
"types": { "<map of content type name → declaration, required>" },
"artifacts": ["<bundle-level output filenames, optional>"]
}| Field | Type | Description |
|---|---|---|
name |
string | Reverse-domain bundle identifier (e.g., pub.polis.core, com.example.recipes). |
version |
string | Semantic version of the bundle. |
handler |
object | Declares how the system invokes this bundle. See Handler Declaration. |
types |
map | Content type declarations, keyed by fully-qualified type name. |
| Field | Type | Description |
|---|---|---|
description |
string | Human-readable description of the bundle. |
ds |
object | Discovery service integration settings. See DS Integration. |
artifacts |
string[] | Bundle-level output filenames (e.g., ["index.jsonl"]). |
The handler declares how the system dispatches operations for this bundle's content types.
{
"handler": {
"type": "builtin | executable | http",
"path": "<path to executable, required for executable type>",
"url": "<endpoint URL, required for http type>"
}
}| Type | Description | Use Case |
|---|---|---|
builtin |
Handled by CLI/webapp natively (Go code) | pub.polis.core types |
executable |
External binary invoked with JSON stdin/stdout | Third-party local plugins |
http |
Remote HTTP endpoint called with JSON POST | Remote/hosted plugins |
Validation rules:
typeis required and must be one ofbuiltin,executable, orhttp.executabletype requirespath.httptype requiresurl.builtintype requires no additional fields.
The ds field controls how the bundle interacts with discovery service events.
{
"ds": {
"subscribes_to": ["pub.polis.*"]
}
}| Field | Type | Description |
|---|---|---|
subscribes_to |
string[] | Event glob patterns for fan-out routing. Multiple bundles can match the same events (no ordering guarantee). |
Glob matching rules:
*matches everything.pub.polis.*matches any event starting withpub.polis..- Exact strings match exactly.
Each entry in the types map declares a single content type. The key is the fully-qualified type name (e.g., pub.polis.post).
{
"pub.polis.post": {
"dir": "post",
"mount": "/posts",
"renderer": "html",
"storage": {
"pattern": "dated",
"date_format": "YYYYMMDD",
"versions": true
},
"emits": [
"pub.polis.post.published",
"pub.polis.post.republished",
"pub.polis.post.removed"
],
"notifications": [
{
"id": "new-post",
"on": "pub.polis.post.published",
"relevance": "followed_author",
"template": "{{actor}} published a new post",
"icon": "pencil"
}
]
}
}These fields are read by the engine and resolver to route, render, and organize content.
| Field | Type | Required | Description |
|---|---|---|---|
dir |
string | yes | Content type directory, relative to content/<bundle>/. Must be unique within the bundle. |
mount |
string | no | Public URL path for rendered output (e.g., /posts). Must be unique within the bundle. |
renderer |
string | no | Rendering engine. Currently only html is supported. |
emits |
string[] | no | Fully namespaced event names this type produces. Used for policy matching and DS validation. |
These fields are read by the handler, not the system. The handler uses them as guidance for storage decisions.
| Field | Type | Description |
|---|---|---|
storage |
object | Storage layout configuration. |
storage.pattern |
string | "dated" (date-based directories) or "flat" (single directory). |
storage.date_format |
string | Date format for directory names (e.g., "YYYYMMDD"). |
storage.versions |
boolean | Whether to store previous versions in .versions/ subdirectories. |
The notifications array declares when and how to notify users about events from this content type.
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique identifier for this rule (across the entire bundle). |
on |
string | yes | Event type that triggers this notification. |
relevance |
string | yes | Filter that determines who sees the notification. |
template |
string | yes | Mustache-like template for the notification message. |
icon |
string | no | Icon name (e.g., pencil, comment, prayer, check, x, follow, unfollow). |
enabled |
boolean | no | Whether the rule is active. Defaults to true if omitted. |
batch |
string | no | Batching window (e.g., "24h"). When set, notifications of this type are batched over the window. |
Relevance values:
| Value | Meaning |
|---|---|
target_domain |
Notify when payload.target_domain matches the local site's domain. Used for incoming actions (comments on your posts, follow events targeting you, blessing decisions on your posts). |
source_domain |
Notify when payload.source_domain matches the local site's domain. Used for outgoing action results (your comment was blessed/denied). |
followed_author |
Notify when the event actor is in the local site's following list. Used for content from authors you follow (new posts, updates). |
The following are the handler's business and are NOT declared in bundle.json:
- Internal workflow states (drafts, pending, denied).
- DS state/config file names, formats, and structure.
- Per-type artifact lists.
- Action/command lists.
- The handler creates and manages its own DS files under
.polis/ds/<domain>/<bundle>/.
{
"name": "pub.polis.core",
"version": "1.0.0",
"description": "Core polis content types",
"handler": {
"type": "builtin"
},
"ds": {
"subscribes_to": ["pub.polis.*"]
},
"types": {
"pub.polis.post": {
"dir": "post",
"mount": "/posts",
"renderer": "html",
"storage": {"pattern": "dated", "date_format": "YYYYMMDD", "versions": true},
"emits": ["pub.polis.post.published", "pub.polis.post.republished", "pub.polis.post.removed"],
"notifications": [
{"id": "new-post", "on": "pub.polis.post.published", "relevance": "followed_author", "template": "{{actor}} published a new post", "icon": "pencil"},
{"id": "updated-post", "on": "pub.polis.post.republished", "relevance": "followed_author", "template": "{{actor}} updated a post", "icon": "pencil", "enabled": false}
]
},
"pub.polis.comment": {
"dir": "comment",
"mount": "/comments",
"renderer": "html",
"storage": {"pattern": "dated", "date_format": "YYYYMMDD", "versions": false},
"emits": [
"pub.polis.comment.published", "pub.polis.comment.republished",
"pub.polis.comment.blessing.requested", "pub.polis.comment.blessing.granted", "pub.polis.comment.blessing.denied"
],
"notifications": [
{"id": "new-comment", "on": "pub.polis.comment.published", "relevance": "target_domain", "template": "{{actor}} commented on {{post_name}}", "icon": "comment"},
{"id": "blessing-requested", "on": "pub.polis.comment.blessing.requested", "relevance": "target_domain", "template": "{{actor}} requested a blessing on {{post_name}}", "icon": "prayer"},
{"id": "blessing-granted", "on": "pub.polis.comment.blessing.granted", "relevance": "source_domain", "template": "{{actor}} blessed your comment", "icon": "check"},
{"id": "blessing-denied", "on": "pub.polis.comment.blessing.denied", "relevance": "source_domain", "template": "{{actor}} denied your comment", "icon": "x"}
]
},
"pub.polis.follow": {
"dir": "follow",
"mount": "/follow",
"emits": ["pub.polis.follow.announced", "pub.polis.follow.removed"],
"notifications": [
{"id": "new-follower", "on": "pub.polis.follow.announced", "relevance": "target_domain", "template": "{{actor}} started following you", "icon": "follow", "batch": "24h"},
{"id": "lost-follower", "on": "pub.polis.follow.removed", "relevance": "target_domain", "template": "{{actor}} unfollowed you", "icon": "unfollow"}
]
},
"pub.polis.feed": {
"dir": "feed",
"mount": "/feed",
"renderer": "html"
},
"pub.polis.dm": {
"dir": "dm",
"storage": {"pattern": "flat"}
},
"pub.polis.tag": {
"dir": "tag",
"mount": "/tags",
"storage": {"pattern": "flat"},
"emits": ["pub.polis.tag.applied", "pub.polis.tag.removed"]
}
},
"artifacts": ["index.jsonl"]
}When an action is triggered, the system resolves the content type to its bundle and handler, then dispatches using a standard JSON contract. The contract is the same regardless of handler type (builtin, executable, or http).
Request:
{
"action": "create",
"content_type": "com.example.recipes.recipe",
"payload": {
"file": "content/com.example.recipes/recipes/pasta.md",
"site_dir": "/path/to/site",
"author": "alice",
"domain": "alice.example.com"
}
}Response:
{
"status": "success",
"data": {
"path": "content/com.example.recipes/recipes/pasta.md",
"title": "Pasta Carbonara",
"version": "sha256:abc123..."
}
}For builtin handlers, the dispatch is a Go function call within BuiltinCoreHandler. For executable handlers, the request is written to the binary's stdin and the response is read from stdout. For http handlers, the request is POSTed to the handler URL and the response is the HTTP body.
Executable handler environment variables:
| Variable | Value |
|---|---|
POLIS_SITE_DIR |
Absolute path to the site root |
POLIS_BASE_URL |
Site's base URL |
POLIS_DISCOVERY_URL |
Discovery service URL |
Bundle names use reverse-domain notation. The pub.polis prefix is reserved for official polis types. Third-party bundles use their own domain prefix (e.g., com.example.recipes, org.writers.prompt).
Content type names are prefixed with the publisher's namespace, not the bundle name. Example: pub.polis.post belongs to the pub.polis.core bundle, but its namespace prefix is pub.polis.
The following constraints are enforced when loading a bundle:
nameis required and must be non-empty.versionis required and must be non-empty.handler.typeis required and must bebuiltin,executable, orhttp.executablehandlers must providepath;httphandlers must provideurl.- Every content type must have a non-empty
dir. - No two content types within a bundle may share the same
dir. - No two content types within a bundle may share the same
mount. - Notification rule
idvalues must be unique across the entire bundle. - Each notification rule must have
id,on,relevance, andtemplate.
The pub.polis.core bundle declares six content types. Each has distinct storage patterns, actions, and lifecycle behavior.
Posts are the primary content unit in polis -- markdown files with signed frontmatter published to the author's domain.
Source path: content/pub.polis.core/post/YYYYMMDD/slug.md
Mount path: posts/YYYYMMDD/slug.html (rendered), posts/YYYYMMDD/slug.md (copied)
Private path: .polis/content/pub.polis.core/posts/drafts/
Renderer: html
Storage: Dated directories (YYYYMMDD), with version history in .versions/
Actions:
| Action | Description |
|---|---|
list |
Read from content/pub.polis.core/index.jsonl, return post entries (newest first). |
get |
Retrieve a single post by path or ID. |
create |
Publish a new post: sign content, write to content dir, update index, register with DS. |
update |
Republish an existing post with updated content (new version). |
delete |
Remove a post (unpublish). |
render |
Render markdown to HTML using the active theme. |
draft.list |
List saved drafts. |
draft.get |
Retrieve a single draft. |
draft.save |
Save a draft to .polis/content/pub.polis.core/posts/drafts/. |
draft.delete |
Delete a draft. |
Events emitted: pub.polis.post.published, pub.polis.post.republished, pub.polis.post.removed
Lifecycle: Create writes signed markdown to the dated content directory, stores a version hash in frontmatter, appends to index.jsonl, and registers with the discovery service (emitting pub.polis.post.published). Republish updates the content, creates a new version entry in .versions/, and emits pub.polis.post.republished. Delete removes the post and emits pub.polis.post.removed.
Comments are replies to posts or other comments, published on the commenter's own domain. They participate in the blessing workflow -- the original author must approve (bless) a comment before it is promoted.
Source path: content/pub.polis.core/comment/YYYYMMDD/slug.md
Mount path: comments/YYYYMMDD/slug.html (rendered), comments/YYYYMMDD/slug.md (copied)
Private paths:
.polis/content/pub.polis.core/comments/drafts/-- comment drafts.polis/content/pub.polis.core/comments/pending/-- comments awaiting blessing.polis/content/pub.polis.core/comments/denied/-- denied comments Renderer:htmlStorage: Dated directories (YYYYMMDD), no version history Public artifacts:content/pub.polis.core/comment/blessed.json-- index of blessed comments
Actions:
| Action | Description |
|---|---|
list |
List comments from the index. |
get |
Retrieve a single comment. |
create |
Publish a comment and automatically beseech the original author for blessing. |
bless |
Grant blessing to a pending comment (post author action). |
deny |
Deny blessing to a pending comment (post author action). |
revoke |
Revoke a previously granted blessing. |
sync |
Synchronize blessing state with the discovery service. |
Events emitted: pub.polis.comment.published, pub.polis.comment.republished, pub.polis.comment.blessing.requested, pub.polis.comment.blessing.granted, pub.polis.comment.blessing.denied
Lifecycle: Create writes a signed comment with in_reply_to metadata, then beseeches the original author via the DS (emitting pub.polis.comment.blessing.requested). The post author can bless (emitting pub.polis.comment.blessing.granted) or deny (emitting pub.polis.comment.blessing.denied). Blessed comments are added to blessed.json and rendered alongside the original post. Blessing decisions are policy-driven: by default, comments from self, followed authors, and thread-trusted authors are auto-blessed via emit rules in policies/rules.jsonl. See docs/cli/user/policies.md.
Follow is the social graph primitive. It manages the list of authors a site trusts and the follower set maintained from stream events.
Source path: content/pub.polis.core/follow/
Mount path: follow/
Public artifacts:
content/pub.polis.core/follow/following.json-- authors this site follows DS state:.polis/ds/<domain>/pub.polis.core/state/pub.polis.follow.json-- materialized follower set (from stream projection)
Actions:
| Action | Description |
|---|---|
list |
Return the following list with entry metadata (URL, added_at, site_title, author_name). |
create |
Follow a new author: add to following.json, auto-bless their comments, publish pub.polis.follow.announced event. |
delete |
Unfollow an author: remove from following.json, remove their blessed comments, publish pub.polis.follow.removed event. |
Events emitted: pub.polis.follow.announced, pub.polis.follow.removed
Lifecycle: Follow events are published directly to the stream (explicit emission) -- the client signs a canonical payload and calls POST /stream-publish. The local follower set is maintained as a client-side projection: the FollowHandler processes pub.polis.follow.announced and pub.polis.follow.removed events, materializing a follower list in .polis/ds/<domain>/pub.polis.core/state/pub.polis.follow.json.
Feed is an aggregated view of content from followed authors. It combines stream events with direct site polling to provide a unified timeline.
Source path: content/pub.polis.core/feed/
Mount path: feed/
Renderer: html (can render as RSS or JSON feed)
DS state: .polis/ds/<domain>/pub.polis.core/state/pub.polis.feed.jsonl (feed item cache)
DS config: .polis/ds/<domain>/pub.polis.core/config/feed.json (staleness_minutes, max_items, max_age_days)
Actions:
| Action | Description |
|---|---|
list |
Return cached feed items. |
refresh |
Pull new content from followed authors and update the cache. |
Lifecycle: Feed items are collected from followed authors via stream events and direct site polling. The cache is stored as JSONL in the DS state directory. Staleness is tracked per-entry with configurable thresholds. The feed type has no events of its own -- it consumes events from other types.
Direct messages are private, end-to-end encrypted messages between polis instances. This is the first content type with no mount point, no renderer, and no DS events -- it validates that the bundle system handles private content types gracefully.
Source path: .polis/content/pub.polis.core/dm/
Mount path: None (private content, never rendered to public HTML)
Renderer: None
Storage: Flat (conversations stored as JSON files, not dated directories)
Encryption: Transport: NaCl box (X25519 + XSalsa20-Poly1305). Storage: NaCl secretbox with HKDF-derived key.
Actions:
| Action | Auth | Description |
|---|---|---|
list |
Bearer token | List conversation summaries with unread counts. |
get |
Bearer token | Get a conversation with decrypted messages. |
send |
Bearer token | Encrypt and deliver a DM to a remote instance. |
deliver |
Signed request | Receive an encrypted DM from a remote instance. |
mark_read |
Bearer token | Mark messages in a conversation as read. |
delete |
Bearer token | Delete a conversation locally. |
retry |
Bearer token | Retry delivering unsent messages. |
The deliver action is the key innovation: it accepts signed-request auth (not Bearer token) so remote instances can push messages without a pre-shared API key. See the security model for details on instance-to-instance signed request authentication.
Events emitted: None. DMs are private and do not register with the discovery service.
Lifecycle: Sending encrypts the message with the recipient's public key (fetched from .well-known/polis), POSTs the encrypted envelope to the recipient's /v1/content/dm/actions/deliver endpoint with signed request headers, and stores a local copy encrypted with the storage key. On delivery failure, the message is saved locally as "unsent" and retryable via retry. Receiving verifies the signed request, checks DM acceptance policy, decrypts the transport encryption, re-encrypts with the local storage key, and stores the message.
Tags are lightweight labels applied to content (posts, feed items). A tag is a metadata association between a tag name and a target URL, stored as a flat JSON file on the author's own site.
Source path: content/pub.polis.core/tag/
Mount path: /tags
Renderer: None
Storage: Flat (single directory, no date-based subdirectories)
Actions:
| Action | Description |
|---|---|
list |
List all tags, optionally filtered by tag name or target URL. |
apply |
Apply a tag to a target URL. Creates the tag if it does not exist. |
remove |
Remove a tag from a target URL. |
delete |
Delete a tag and all its associations. |
Events emitted: pub.polis.tag.applied, pub.polis.tag.removed
Lifecycle: Apply writes a tag association linking a tag name to a target URL, registers with the discovery service (emitting pub.polis.tag.applied). Remove deletes the association and emits pub.polis.tag.removed. Tags are local to the author's site -- each author maintains their own tag vocabulary.
Events follow the pattern <content_type>.<action>. The content type IS the namespace prefix.
pub.polis.post.published
^^^^^^^^^^^^^^^^ ^^^^^^^^
content type action
Core events use the pub.polis.* namespace. Third-party events use reverse-domain namespacing (e.g., com.bookclub.recommendation). No registration or permission is required for custom event types -- if the site is registered and the signature is valid, it can publish events with any non-pub.polis.* type.
| Event | Emitted By | Description |
|---|---|---|
pub.polis.post.published |
DS (side-effect of post registration) | A new post was published. |
pub.polis.post.republished |
DS (side-effect of post update) | An existing post was updated with new content. |
pub.polis.post.removed |
DS (side-effect of post deletion) | A post was removed/unpublished. |
| Event | Emitted By | Description |
|---|---|---|
pub.polis.comment.published |
DS (side-effect of comment registration) | A new comment was published. |
pub.polis.comment.republished |
DS (side-effect of comment update) | An existing comment was updated. |
pub.polis.comment.blessing.requested |
DS (side-effect of beseech) | A comment was submitted for blessing. |
pub.polis.comment.blessing.granted |
DS (side-effect of grant/auto-bless) | A comment was blessed by the post author. |
pub.polis.comment.blessing.denied |
DS (side-effect of deny) | A comment was denied blessing. |
| Event | Emitted By | Description |
|---|---|---|
pub.polis.follow.announced |
Client (explicit via stream-publish) |
A site started following another site. |
pub.polis.follow.removed |
Client (explicit via stream-publish) |
A site unfollowed another site. |
| Event | Emitted By | Description |
|---|---|---|
pub.polis.tag.applied |
DS (side-effect of tag registration) | A tag was applied to content. |
pub.polis.tag.removed |
DS (side-effect of tag removal) | A tag was removed from content. |
Site events use pub.polis.site.*. These are not content type events -- they describe site-level state changes. They are part of the core event set and cannot be blocked by operators.
| Event | Emitted By | Description |
|---|---|---|
pub.polis.site.registered |
DS (side-effect of site registration) | A new site was registered with the DS. |
pub.polis.site.reregistered |
DS (side-effect of re-registration) | A site re-registered (e.g., after metadata update). |
pub.polis.site.key_rotated |
DS (side-effect of key rotation) | A site rotated its Ed25519 signing key. |
Every event in the stream has the same outer structure:
{
"id": 4521,
"type": "pub.polis.post.published",
"created_at": "2026-02-08T14:30:00Z",
"actor": "alice.com",
"signature": "-----BEGIN SSH SIGNATURE-----\n...",
"payload": { }
}| Field | Type | Description |
|---|---|---|
id |
integer | Monotonically increasing event ID. Serves as the cursor -- clients request "give me everything after ID N." |
type |
string | Fully-qualified event type name. |
created_at |
string | ISO 8601 timestamp of when the event was recorded. |
actor |
string | Domain of the site that caused the event. |
signature |
string | Ed25519 SSH signature over the canonical payload. |
payload |
object | Type-specific data (see below). |
These fields appear across multiple event types:
| Field | Used In | Description |
|---|---|---|
target_domain |
follow, comment, blessing | The domain the action is directed at. |
source_domain |
comment, blessing | The domain that originated the action (commenter's domain). |
url |
post, comment | The canonical URL of the published content. |
title |
post | The post title. |
version |
post, comment | SHA-256 content hash. |
in_reply_to |
comment, blessing | URL of the content being replied to. |
root_post |
comment, blessing | URL of the root post in the thread. |
comment_url |
blessing | URL of the comment being blessed/denied/requested. |
source_url |
blessing (manual grant/deny) | URL of the comment (manual blessing path). |
target_url |
blessing (manual grant/deny) | URL of the post being commented on (manual blessing path). |
post_name |
comment notification template | Name of the post being commented on (used in notification templates). |
Post published:
{
"url": "https://alice.com/posts/20260208/on-gardens.md",
"version": "sha256:abc123...",
"title": "On Gardens"
}Follow announced:
{
"target_domain": "bob.com"
}Blessing requested:
{
"comment_url": "https://carol.com/comments/20260210/reply.md",
"in_reply_to": "https://alice.com/posts/20260208/on-gardens.md",
"root_post": "https://alice.com/posts/20260208/on-gardens.md",
"target_domain": "alice.com",
"source_domain": "carol.com"
}Third-party event:
{
"type": "com.bookclub.recommendation",
"actor": "carol.com",
"payload": {
"book": "Braiding Sweetgrass",
"review_url": "https://carol.com/posts/20260205/sweetgrass.md"
}
}Bundles declare event interest using glob patterns in ds.subscribes_to. The stream store routes events to handlers based on these patterns.
| Pattern | Matches |
|---|---|
pub.polis.* |
All core polis events (post, comment, follow, site). |
pub.polis.post.* |
Only post events (published, republished, removed). |
pub.polis.comment.blessing.* |
Only blessing events (requested, granted, denied). |
com.bookclub.* |
All events from a hypothetical book club bundle. |
* |
All events (wildcard). |
Matching is prefix-based: pub.polis.* matches any event whose type starts with pub.polis.. Multiple bundles can subscribe to the same patterns, resulting in fan-out (each bundle processes the events independently with its own cursors).
Client Discovery Service Events Table
| | |
| POST /posts-register | |
| {post_url, version, sig} | |
|----------------------------->| |
| | UPSERT posts_metadata |
| |-------------------------->|
| | |
| | INSERT event |
| | type: pub.polis.post.published
| | (fire-and-forget) |
| |-------------------------->|
| | |
| 201 {success: true} | |
|<-----------------------------| |
Client stream-publish Events Table
| | |
| POST /stream-publish | |
| {type, actor, payload, sig} | |
|----------------------------->| |
| | Verify registration |
| | Verify signature |
| | Check blocks |
| | Check rate limit |
| | |
| | INSERT event |
| | type: pub.polis.follow.announced
| |-------------------------->|
| | |
| 201 {event_id: 4521} | |
|<-----------------------------| |
Client Stream (GET /stream) Local Disk
| | |
| Load cursor | |
|<---------------------------------------------------------|
| cursor = "4500" | |
| | |
| GET /stream?since=4500 | |
| &type=pub.polis.follow.* | |
|----------------------------->| |
| | |
| {events: [...], cursor: "4521"} |
|<-----------------------------| |
| | |
| Process events: | |
| +alice.com, -carol.com | |
| | |
| Save state + cursor | |
|---------------------------------------------------------→|
| {followers: [alice, bob], count: 2} |
| cursor = "4521" | |
The following 13 events are core protocol events. Operators cannot block them:
pub.polis.follow.announced
pub.polis.follow.removed
pub.polis.post.published
pub.polis.post.republished
pub.polis.post.removed
pub.polis.comment.published
pub.polis.comment.republished
pub.polis.comment.blessing.requested
pub.polis.comment.blessing.granted
pub.polis.comment.blessing.denied
pub.polis.tag.applied
pub.polis.tag.removed
pub.polis.site.registered
pub.polis.site.reregistered
pub.polis.site.key_rotated
Only two event patterns support direct client publication via POST /stream-publish:
pub.polis.follow.announced-- the client signs{type, payload}and publishes.pub.polis.follow.removed-- same pattern.
All other pub.polis.* events are emitted server-side as side effects of DS mutations. Third-party events (non-pub.polis.* types) can be published directly by any registered actor.
For explicit events (published via stream-publish), the signed payload is:
JSON.stringify({ type: eventType, payload: payloadObject })
For side-effect events, the signature comes from the original DS mutation (e.g., the post registration signature, the blessing grant signature).
The following constraints are enforced by the DS when accepting events:
- The actor must be registered with the discovery service.
- The Ed25519 signature must be valid over the canonical payload.
pub.polis.*event types cannot be published by clients (except follow events).- Rate limit: 100 events/hour/actor.
- Payload must be a JSON object under 8KB serialized.
- Events are immutable once inserted -- operators cannot modify content after insertion.
The event naming scheme was updated from polis.* to pub.polis.* as part of the bundle-based content type architecture. This table maps old event names to their current equivalents.
| Old Name | New Name |
|---|---|
polis.post.published |
pub.polis.post.published |
polis.post.republished |
pub.polis.post.republished |
polis.post.removed |
pub.polis.post.removed |
polis.comment.published |
pub.polis.comment.published |
polis.comment.republished |
pub.polis.comment.republished |
polis.blessing.requested |
pub.polis.comment.blessing.requested |
polis.blessing.granted |
pub.polis.comment.blessing.granted |
polis.blessing.denied |
pub.polis.comment.blessing.denied |
polis.follow.announced |
pub.polis.follow.announced |
polis.follow.removed |
pub.polis.follow.removed |
polis.site.registered |
pub.polis.site.registered |
polis.site.reregistered |
pub.polis.site.reregistered |
polis.site.key_rotated |
pub.polis.site.key_rotated |
Note that the blessing events moved from polis.blessing.* to pub.polis.comment.blessing.*, nesting them under the comment content type rather than being a top-level namespace. The stream store includes automatic cursor key migration from old polis.* keys to their pub.polis.* equivalents.