Platform Surfaces Spec
Threads, bots/service principals, Elysia OpenAPI, MCP/CLI generation, bojastack.
This document extends the architecture in 00-final-recommendation.md, the constraints and SLAs in 07-deep-dive-open-questions.md, and the canonical relational model in 08-core-datamodel-spec.md.
0. Authority, Boundaries, and Vocabulary
Fixed points:
- TypeScript across client, server, domain, contracts, jobs, CLI, and MCP code.
- A Bun-compatible application server.
- Postgres as the durable system of record.
- Rocicorp Zero as the only client reactive/synced store.
- App-owned HTTP query and mutate endpoints for Zero.
- Better Auth for identity and session/credential mechanics, not product authorization.
- TRW-derived layered authorization stored as
capability_key/effectrows. - Typed command policy after coarse capability calculation.
- Transactional outbox rows created in the same transaction as domain writes.
- Self-hosting self-hosted, with approved Cloudflare enterprise products and the billing tax adapter as the defined production exceptions.
- NMI plus value.io in an in-repository billing service.
TRW is prior-art evidence. Boja's schema, runtime and deployment are greenfield and independent.
Normative language uses MUST, SHOULD, and MAY in their ordinary RFC sense.
- An "actor" is the authenticated human user or service principal responsible for a request.
- A "bot user" is the visible user-shaped identity shown in messages and member surfaces.
- A "service principal" is the non-human security identity that authenticates a bot, worker, CLI, or operator tool.
- A "thread subscription" is attention state; it is neither visibility authorization nor read state.
Part 1 — Threads
1.1 Decision and v1 Invariants
Threads are rows in channels with type = 'thread'. There is no separate authoritative threads entity.
Every thread MUST have exactly one parent text channel and exactly one root message:
parent_channel_idpoints to the parent channel.root_message_idpoints to a message in that same parent.- Both fields are non-null together for
type = 'thread'and null for other channel types. - One root message can own at most one thread.
- The root message is not copied into the thread message stream.
- The thread view renders the authorized root or its durable tombstone as context above replies.
Threads in v1 are public within the visibility boundary of their parent channel. There are no private threads, invite-only threads, or membership-gated thread reads in v1. Thread subscription rows control attention only. Thread read_states rows control read progress only. Parent access is recalculated dynamically; stale synced rows or a subscription can never preserve thread access. The root tombstone and thread remain addressable if the root is deleted or moderated.
1.2 TRW evidence and deliberate corrections
Boja MUST correct these prior-art inconsistencies:
- TRW's current worker archives after four days, not 48 hours. Boja's authoritative v1 policy is 48 hours.
- TRW threads with no replies never auto-archive; Boja archives them from their creation activity.
- TRW combines membership, mute, notifications, and read cursor; Boja separates them.
- TRW's
member_countis drift-prone; Boja does not restore it. - TRW can hard-delete roots and thread history; Boja preserves tombstones and retention policy.
- TRW's archive comments and send behavior disagree; Boja defines archive and lock independently.
- TRW's thread list cursor based only on
last_message_idis unstable for empty threads; Boja uses a compound activity cursor.
1.3 Creation from a Root Message
The canonical command is CreateThreadFromMessage. The command MUST validate, in this order:
- The actor is globally active and is a member of the server.
- The parent exists, belongs to the server, and is a server text channel.
- The root exists, belongs to the parent, and is not a transient/system-only event.
- The root is visible under current moderation and history policy.
- No thread already claims the root.
- The actor has
channel.view,message.send, andthread.createin the parent. - Typed command policy permits creation under server feature state, rate limits, and moderation.
- The normalized name is between 1 and 100 Unicode code points.
If no name is supplied, the server derives it from the first 50 display-safe code points of root content and falls back to New thread.
The transaction MUST:
- allocate the thread channel ID server-side or accept a validated client ULID;
- insert the
channels(type='thread')row; - set
last_activity_atandauto_archive_atfrom the transaction timestamp; - insert the creator's
thread_subscriptionsrow asnotification_level = 'all'; - insert a normal thread
read_statesrow lazily only when a cursor is first acknowledged; - insert an idempotent
thread.createdoutbox event; - rely on the unique root constraint to resolve concurrent creators.
If concurrent requests target the same root, the loser returns 409 thread_already_exists with the existing authorized thread ID. Creation is all-or-nothing in Postgres; there is no nontransactional fallback.
1.4 Listing and Discovery
Every parent text channel exposes two logical lists:
- Active: non-archived threads ordered by
(last_activity_at DESC, id DESC). - Archived: archived threads ordered by
(archived_at DESC, id DESC). Query-on-demand and cursor paginated.
The default page size is 50 and the hard maximum is 200. The opaque cursor encodes the ordering timestamp and thread ID, not a message ID alone.
Each row supplies: thread ID, name, parent ID, root message ID, owner ID; archive and read-only state; last activity and last message cursor; reply count from a bounded/reconciled projection or authorized count query; the caller's subscription state, if any; unread dot and exact unread mention count for the caller; a safe last-message preview only when the caller still has access.
Archived threads remain readable and permalink-addressable while the parent remains readable. Archived thread metadata does not enter the default bootstrap merely because the user can view the parent.
1.5 Archive, Reopen, Lock, and Deletion Lifecycle
The v1 auto-archive interval is exactly 48 hours after the latest durable thread activity.
Durable thread activity is:
- thread creation for a thread with no replies;
- an accepted, non-ephemeral message in the thread;
- message restoration when restoration makes the item visible again.
Edits, reactions, read acknowledgements, subscription changes, typing, and presence do not reset the archive deadline.
On accepted activity the write transaction sets:
last_activity_at = transaction_timestamp()auto_archive_at = last_activity_at + interval '48 hours'is_archived = falseandarchived_at = nullif the thread was archived
In v1, a valid reply to an archived but unlocked thread atomically reopens it.
The archive worker runs at least every 15 minutes, in bounded batches, using FOR UPDATE SKIP LOCKED. Its update predicate MUST repeat is_archived = false AND auto_archive_at <= now() so concurrent new activity wins safely.
is_archived controls discovery grouping; it is not an authorization boundary and not a write lock. is_read_only is the thread lock. The typed send policy rejects ordinary replies to a locked thread after capability calculation. Only an actor with message.moderate or the narrowly declared channel.manage moderator bypass may send into or unlock a read-only thread.
Deleting a thread is a moderation operation, not ordinary owner cleanup. In v1 it produces a channel tombstone, retains message tombstones/audit evidence according to the retention class, and never clears history in a way that breaks reply anchors. Deleting or moderating the root preserves the root ID, parent/thread linkage, author/time fields allowed by policy, and a safe tombstone label.
1.6 Subscription and Participation Semantics
The canonical table is thread_subscriptions, separate from read_states. A subscription has a notification level of all, mentions, or none, plus is_muted. No row means "not followed"; it does not mean "cannot see."
Automatic follow rules:
- creator:
all - actor who sends a reply:
all, unless an existing row explicitly saysnoneor is muted - directly mentioned user:
mentions, if no row exists and the user can view the parent - author of the message being replied to:
mentions, if no row exists and access remains
Role mentions and @everyone never mass-create subscription rows. Merely viewing, reading, or opening a deep link does not follow the thread. The explicit UI actions are Follow, Following all, Mentions only, No notifications, Mute, and Unfollow. Mute is an attention override, not a cursor mutation. Unfollow deletes or deactivates the subscription row and leaves visibility/read history unchanged. There is no v1 participant roster derived from subscription rows.
1.7 Permission Inheritance and Thread Constraints
Thread authorization is evaluated on every server command and every Zero row/query transform. The effective decision is:
- Calculate current parent-channel capabilities using the canonical layered algebra.
- Fail closed if
channel.viewis denied in the parent. - Reuse relevant parent grants for the thread.
- Apply thread-local
channel_overridesonly if v1 product UI exposes them. - Apply timeout/moderation masks and owner bypass from the canonical calculator.
- Apply typed thread constraints such as archive behavior, read-only lock, slow mode, and attachment limits.
- Apply entitlements after permissions.
Parent revocation MUST remove thread channels, messages, roots, subscriptions, read state, previews, and mention projections from the user's reachable Zero graph within the Spike 3 SLA (P95 ≤ 2s, P99 ≤ 5s). Subscription status never enters the allow/deny calculation. No child override may grant a capability that the actor lacks at the parent visibility boundary.
1.8 Read, Unread, and Mention Behavior
Each viewed thread uses the ordinary (user_id, channel_id) read_states row. The thread's unread dot derives from last_message_sort_id vs last_read_sort_id. Exact unread mention count derives from unacknowledged message_mentions for the thread channel. A server badge counts threads with any unread according to the server notification policy. The parent-channel badge counts threads with an unread mention if the user has that channel muted at the channel level. No badge logic reads message bodies; all logic runs over cursor projections and mention acknowledgement rows.
Part 2 — Bots and Service Principals
2.1 Bot Identity Model
A bot is a non-human participant that posts messages or responds to events. Every bot has:
- a bot user (
user_profilesrow withis_bot = true) for display and message attribution; - a service principal (separate security identity) for API authentication;
- a set of server memberships (ordinary
server_membersrows) granting access to specific servers.
Service principals authenticate with short-lived signed tokens, not user sessions. They have no human login, no Better Auth social provider, and no Zero client. Their capabilities are restricted to the explicit roles assigned to the bot user in each server, plus their declared scopes. No service principal may inherit platform-owner break-glass access.
2.2 Bot Registration
Bots are registered through the bojastack CLI or admin API. Registration creates:
- a
service_principalsrow with a stable ID, display name, description, and owner user; - a
bot_usersassociation to auser_profilesrow; - a rotating signing key pair stored in the deployment secret manager;
- a declared scope list (e.g.,
message.send,message.read,webhook.receive).
Server admins install a bot by adding its bot user as a member with a specific role. That role determines the bot's capabilities in the server. Bot installations are audited and revocable by server admins or the platform operator.
2.3 Bot Authentication
Service principals authenticate with a short-lived JWT signed by the platform's bot signing key. The JWT claims include: sub (service principal ID), iat, exp (max 15 minutes), scopes (declared scope list), and optionally server_id (server-scoped token). The application validates the signature, expiry, scope intersection, and server membership before processing any request.
Bot tokens are never issued to clients. Bots call the API server directly from their hosted environment. Bot credentials must never appear in Zero data, message content, notification payloads, or analytics events.
2.4 Bot Capabilities and Restrictions
Bots are subject to the same layered capability algebra as humans. In addition:
- Bots MUST NOT send messages that exceed their declared scope.
- Bots MUST NOT read channels they are not members of.
- Bot message sends are rate-limited per server (10/second by default; configurable by server admin).
- Bots MUST include a
X-Bot-Idempotency-Keyheader on all message sends. - Bots MUST NOT query Zero directly; they use the HTTP API.
- Bots MAY subscribe to webhook events (outbox-derived, verified) for their installed servers.
- System bots (platform-owned service principals) have a separate
system_botflag and may use a wider scope under explicit policy review.
Part 3 — Elysia OpenAPI
3.1 API Rules
Boja's public API uses Bun-native Elysia with @elysia/openapi. Every route MUST:
- declare a complete request schema (
body,params,query) with typed validation; - declare all success and error response schemas;
- use the same validation library (Zod or TypeBox) as the Zero schema and worker task contracts;
- include the OpenAPI
operationId(used for MCP tool generation); - authenticate via the
authPlugin(Better Auth session or bot JWT); - authorize via the capability calculator before any domain access;
- return structured error objects (not bare strings) with a stable
codefield; - rate-limit by actor/resource with explicit bucket configuration.
Never write raw SQL outside the repository layer. Never pass raw user input to the search engine or storage key paths. Never log request bodies that may contain secrets, PAN, CVV, or message content at DEBUG level in production.
Base URL: /api/v1/. Versioning is path-based. Breaking changes require a new version prefix.
3.2 Thread Routes
| Method & Path | Purpose | Required capabilities |
|---|---|---|
POST /channels/{channelId}/threads | Create thread from root message | channel.view, message.send, thread.create in parent |
GET /channels/{channelId}/threads | List active threads (cursor paginated) | channel.view in parent |
GET /channels/{channelId}/threads/archived | List archived threads (cursor paginated) | channel.view in parent |
GET /channels/{threadId} | Get thread metadata | channel.view in parent at call time |
PATCH /channels/{threadId} | Rename, archive, reopen, lock, unlock, slow-mode | Thread owner or channel.manage in parent |
DELETE /channels/{threadId} | Moderation delete (tombstone) | message.moderate in parent |
PUT /channels/{threadId}/subscription | Set subscription level | channel.view in parent |
DELETE /channels/{threadId}/subscription | Unfollow | Authenticated |
3.3 Message Routes
| Method & Path | Purpose | Required capabilities |
|---|---|---|
POST /channels/{channelId}/messages | Send message (idempotent via client_nonce) | channel.view, message.send |
GET /channels/{channelId}/messages | History window (keyset cursor) | channel.view, message.read |
GET /channels/{channelId}/messages/{messageId}/around | Contextual window (hot jump) | channel.view, message.read |
GET /history/channels/{channelId}/messages/{messageId}/around | Archive window (HistoryWindow) | channel.view, message.read |
PATCH /channels/{channelId}/messages/{messageId} | Edit own message | message.edit_own; author only |
DELETE /channels/{channelId}/messages/{messageId} | Delete (tombstone) | message.delete_own or message.moderate |
POST /channels/{channelId}/messages/{messageId}/reactions/{emoji} | Add reaction | message.react |
DELETE /channels/{channelId}/messages/{messageId}/reactions/{emoji} | Remove own reaction | Author of reaction |
POST /channels/{channelId}/messages/{messageId}/pin | Pin message | channel.manage |
DELETE /channels/{channelId}/messages/{messageId}/pin | Unpin message | channel.manage |
3.4 Bot & Service Principal Routes
| Method & Path | Purpose | Required scope |
|---|---|---|
POST /bots | Register a bot (platform admin) | Platform admin session |
GET /bots/{botId} | Get bot metadata | Bot owner or platform admin |
POST /bots/{botId}/tokens | Issue short-lived bot token | Bot owner session + secret key proof |
POST /servers/{serverId}/bots | Install bot in server | server.manage |
DELETE /servers/{serverId}/bots/{botId} | Uninstall bot from server | server.manage |
POST /bots/{botId}/webhooks | Register outbox webhook | Bot owner + webhook.receive scope |
GET /bots/{botId}/webhooks | List webhooks | Bot owner |
DELETE /bots/{botId}/webhooks/{webhookId} | Remove webhook | Bot owner |
Part 4 — MCP and CLI
4.1 MCP Tools
Boja generates an MCP (Model Context Protocol) tool manifest from the OpenAPI spec using the declared operationId and route schemas. Generated tools are read-only by default. Write tools require an explicit x-mcp-writable: true extension in the route definition and a dedicated MCP service principal scope.
The MCP server runs as a separate process (or Workers AI worker) that authenticates to the Boja API using a scoped service principal token. It never holds a user session or full admin credentials. The MCP manifest is versioned and published at /api/v1/mcp/manifest.json.
v1 MCP tool categories:
- Message tools:
search_messages,get_message_context,get_channel_history - Learning tools:
search_vault,get_lesson,get_course_structure,get_transcript_chunk - Channel tools:
list_channels,get_thread_list - Member tools:
get_member_profile,list_server_members - Write tools (explicit opt-in):
send_message,create_thread
Every MCP tool call performs a full authorization check using the same capability calculator as the HTTP API. Results are filtered through the same post-filter as search results. The MCP server MUST NOT cache raw engine results between tool calls; each tool call is independent.
4.2 bojastack CLI
bojastack is the operator/developer CLI for managing Boja deployments. It is a TypeScript CLI (Bun runtime, distributed as a single binary) that calls the admin API using a scoped service principal. Commands:
# Database and migrations
bojastack db migrate
bojastack db rollback --to <version>
bojastack db status
# Zero deployment
bojastack zero status
bojastack zero rebuild
bojastack zero swap-publication <new-pub-name>
# Search
bojastack search backfill --index messages --from-date 2026-01-01
bojastack search status
bojastack search rebuild --index learning_chunks
bojastack synonyms publish --file ./synonyms.json --scope platform
bojastack synonyms status
# Bots
bojastack bots register --name my-bot --scopes message.send,message.read
bojastack bots list
bojastack bots rotate-key <botId>
# Admin
bojastack servers suspend <serverId> --reason <reason>
bojastack users anonymize <userId> # GDPR erasure
bojastack audit export --from 2026-07-01 --to 2026-07-16
# Billing
bojastack billing reconcile --provider nmi --date 2026-07-15
bojastack billing ledger-verify --from 2026-07-01
# Infra
bojastack workers status
bojastack queue drain --queue search.erase.v1
bojastack stream reconcile
All CLI commands that mutate state require an explicit --confirm flag and produce a structured JSON audit event in audit_log. The CLI never accepts raw database passwords or NMI/value.io keys; it reads scoped credentials from the deployment secret manager. Output is structured JSON by default with --format text for human-readable output.
4.3 MCP and CLI Generation Pipeline
The OpenAPI spec is the authoritative source for both the MCP tool manifest and the CLI command completions. The pipeline runs during CI:
- Elysia routes emit the OpenAPI schema at startup;
- a codegen script reads the schema and emits typed client code and MCP tool definitions;
- the CLI uses the generated typed client for admin API calls;
- MCP manifest is published to the versioned endpoint;
- schema drift between routes and generated clients is a CI failure.
The Zod validators in routes, Zero schema, and task contracts must be kept in sync. Use a shared @boja/contracts package that all three import from.