Status: v1 specification — 2026-07-16

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:

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.

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:

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:

1.3 Creation from a Root Message

The canonical command is CreateThreadFromMessage. The command MUST validate, in this order:

  1. The actor is globally active and is a member of the server.
  2. The parent exists, belongs to the server, and is a server text channel.
  3. The root exists, belongs to the parent, and is not a transient/system-only event.
  4. The root is visible under current moderation and history policy.
  5. No thread already claims the root.
  6. The actor has channel.view, message.send, and thread.create in the parent.
  7. Typed command policy permits creation under server feature state, rate limits, and moderation.
  8. 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:

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:

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:

Edits, reactions, read acknowledgements, subscription changes, typing, and presence do not reset the archive deadline.

On accepted activity the write transaction sets:

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:

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:

  1. Calculate current parent-channel capabilities using the canonical layered algebra.
  2. Fail closed if channel.view is denied in the parent.
  3. Reuse relevant parent grants for the thread.
  4. Apply thread-local channel_overrides only if v1 product UI exposes them.
  5. Apply timeout/moderation masks and owner bypass from the canonical calculator.
  6. Apply typed thread constraints such as archive behavior, read-only lock, slow mode, and attachment limits.
  7. 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:

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:

  1. a service_principals row with a stable ID, display name, description, and owner user;
  2. a bot_users association to a user_profiles row;
  3. a rotating signing key pair stored in the deployment secret manager;
  4. 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:

Part 3 — Elysia OpenAPI

3.1 API Rules

Boja's public API uses Bun-native Elysia with @elysia/openapi. Every route MUST:

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 & PathPurposeRequired capabilities
POST /channels/{channelId}/threadsCreate thread from root messagechannel.view, message.send, thread.create in parent
GET /channels/{channelId}/threadsList active threads (cursor paginated)channel.view in parent
GET /channels/{channelId}/threads/archivedList archived threads (cursor paginated)channel.view in parent
GET /channels/{threadId}Get thread metadatachannel.view in parent at call time
PATCH /channels/{threadId}Rename, archive, reopen, lock, unlock, slow-modeThread owner or channel.manage in parent
DELETE /channels/{threadId}Moderation delete (tombstone)message.moderate in parent
PUT /channels/{threadId}/subscriptionSet subscription levelchannel.view in parent
DELETE /channels/{threadId}/subscriptionUnfollowAuthenticated

3.3 Message Routes

Method & PathPurposeRequired capabilities
POST /channels/{channelId}/messagesSend message (idempotent via client_nonce)channel.view, message.send
GET /channels/{channelId}/messagesHistory window (keyset cursor)channel.view, message.read
GET /channels/{channelId}/messages/{messageId}/aroundContextual window (hot jump)channel.view, message.read
GET /history/channels/{channelId}/messages/{messageId}/aroundArchive window (HistoryWindow)channel.view, message.read
PATCH /channels/{channelId}/messages/{messageId}Edit own messagemessage.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 reactionmessage.react
DELETE /channels/{channelId}/messages/{messageId}/reactions/{emoji}Remove own reactionAuthor of reaction
POST /channels/{channelId}/messages/{messageId}/pinPin messagechannel.manage
DELETE /channels/{channelId}/messages/{messageId}/pinUnpin messagechannel.manage

3.4 Bot & Service Principal Routes

Method & PathPurposeRequired scope
POST /botsRegister a bot (platform admin)Platform admin session
GET /bots/{botId}Get bot metadataBot owner or platform admin
POST /bots/{botId}/tokensIssue short-lived bot tokenBot owner session + secret key proof
POST /servers/{serverId}/botsInstall bot in serverserver.manage
DELETE /servers/{serverId}/bots/{botId}Uninstall bot from serverserver.manage
POST /bots/{botId}/webhooksRegister outbox webhookBot owner + webhook.receive scope
GET /bots/{botId}/webhooksList webhooksBot owner
DELETE /bots/{botId}/webhooks/{webhookId}Remove webhookBot 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:

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:

  1. Elysia routes emit the OpenAPI schema at startup;
  2. a codegen script reads the schema and emits typed client code and MCP tool definitions;
  3. the CLI uses the generated typed client for admin API calls;
  4. MCP manifest is published to the versioned endpoint;
  5. 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.