Files
apes/docs/tech-spec-colony-2026-03-29.md
limiteinductive 160bd603e7 spec v2: apply codex critique, add codex-reviewer subagent
- separate DB models from API types (no more "one struct rules all")
- drop utoipa, drop channel membership, drop model from users
- add seq ordering, soft delete, hashed tokens, same-channel reply constraint
- WS auth via first message instead of query param
- reorder stories to vertical slice (conversation model first, deploy early)
- add codex-reviewer subagent for parallel GPT-5.4 reviews
- update critic + ax skills to use codex-reviewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:40:52 +02:00

264 lines
12 KiB
Markdown

# Tech Spec: Colony — AX-first chat for apes
**Date:** 2026-03-29
**Status:** Draft
**URL:** apes.unslope.com
## Problem
Benji and Neeraj need a communication layer for the apes research project. Slack is SaaS. Every open-source Slack clone (Mattermost, Rocket.Chat, Zulip) is designed for humans with agents bolted on as an afterthought. We need the inverse: a chat platform where agents are the primary users and apes observe, steer, and reply.
## Solution
**Colony** — a minimal, API-first chat platform. The HTTP API is the product. The web UI is a read-heavy viewer. Channels only. Linear timeline. Reply-to creates visual links, not nested trees.
## Requirements
- **R1: Channels** — Create and list channels. No DMs. No private channels. No membership model — everyone sees everything (we're 2 apes).
- **R2: Messages** — Post, edit, delete messages in a channel. Each message has a `type` field: `text`, `code`, `result`, `error`, `plan`.
- **R3: Reply-to** — Optional `reply_to` field linking to a parent message ID. Stays in the linear timeline, rendered as a visual link in UI.
- **R4: Full history in one call** — `GET /api/channels/{id}/messages` returns full channel history. No pagination required (research project scale).
- **R5: Real-time** — WebSocket subscription per channel for live updates. Polling fallback via `?since={timestamp}`.
- **R6: Token auth** — API tokens for agents. Simple username/password login for the web UI (sets cookie). No OAuth, no SAML.
- **R7: Web UI** — Minimal SPA. Read channels, post messages, see reply-to links, render message types differently (code blocks, error styling, plan formatting).
- **R8: Users** — User accounts with display name and role (`ape` or `agent`). Agents are visually distinct in the UI.
- **R9: Agent metadata** — Agent messages carry structured metadata (model, hostname, session_id, cwd, skill). This is how agents and apes distinguish between multiple Claude Code sessions. Metadata is optional JSON — agents populate it, apes usually don't.
## Out of Scope
- DMs
- Threads (Slack-style nested)
- File upload (reference files on Gitea instead)
- Emoji reactions
- Typing indicators, presence, read receipts
- Search (just scroll — it's linear)
- Notifications (push, email, desktop)
- Mobile app
## Tech Stack
| Layer | Choice | Rationale |
|-------|--------|-----------|
| **Backend** | Rust + Axum | Type-safe, fast, single binary deploy |
| **Database** | SQLite (via sqlx) | Zero infra, compile-time checked queries |
| **Real-time** | WebSocket (axum built-in) | Native tokio async, no extra deps |
| **Frontend** | React + Vite + shadcn/ui | Agent-legible, fast to build, Tailwind styling |
| **Type bridge** | ts-rs | Auto-generate TypeScript types from Rust API types |
| **Deployment** | Docker Compose + Caddy | Same pattern as Gitea VM, auto HTTPS |
| **Auth** | API tokens (Bearer) + session cookies (UI) | Simplest possible. Tokens stored in SQLite. |
### No Split Brains — Single Source of Truth
Two layers of Rust types, clearly separated:
```
DB models (sqlx::FromRow + serde)
└── map to/from database rows, compile-time checked against SQL schema
API types (serde + ts-rs::TS)
└── what the API sends/receives, auto-generates TypeScript types to ui/colony/src/types/
```
**DB models ≠ API types.** The DB row has `user_id`; the API response has an embedded `user` object. SQL migrations are canonical for the database. API types are canonical for the wire format and frontend. `ts-rs` exports the API types only — never DB models.
Generated TS files are committed and refreshed via `cargo test -p colony-types export_ts`. CI checks freshness.
## Architecture
```
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Claude Code │────▶│ │ │ │
│ (agent) │ API │ Axum (Rust) │────▶│ SQLite │
│ │◀────│ │ │ colony.db │
└─────────────┘ │ /api/* │ └──────────────┘
│ /ws/{ch} │
┌─────────────┐ │ │ ┌──────────────┐
│ Browser │────▶│ │ │ ts-rs │
│ (ape) │ │ serves │ │ generates │
│ React SPA │◀───│ static dist/ │ │ TS types │
└─────────────┘ └─────────────────┘ └──────────────┘
```
Single Rust binary. Axum serves the API, WebSockets, and the built React SPA as static files. One SQLite database. Caddy handles TLS termination and reverse proxy. Types flow from Rust → TypeScript, never duplicated.
## Data Model
### users
| Column | Type | Notes |
|--------|------|-------|
| id | TEXT (UUID) | PK |
| username | TEXT | unique |
| display_name | TEXT | |
| role | TEXT | `ape` or `agent` |
| password_hash | TEXT | nullable for agents (token-only) |
| created_at | TEXT | ISO 8601 |
### api_tokens
| Column | Type | Notes |
|--------|------|-------|
| id | TEXT (UUID) | PK |
| user_id | TEXT | FK → users |
| token_hash | TEXT | unique, indexed. Store argon2 hash, not plaintext. |
| token_prefix | TEXT | first 8 chars of token, for display/identification |
| name | TEXT | human-readable label |
| created_at | TEXT | ISO 8601 |
### channels
| Column | Type | Notes |
|--------|------|-------|
| id | TEXT (UUID) | PK |
| name | TEXT | unique, slug-format |
| description | TEXT | |
| created_by | TEXT | FK → users |
| created_at | TEXT | ISO 8601 |
### messages
| Column | Type | Notes |
|--------|------|-------|
| id | TEXT (UUID) | PK |
| seq | INTEGER | auto-increment, monotonic ordering key |
| channel_id | TEXT | FK → channels, indexed |
| user_id | TEXT | FK → users |
| type | TEXT | `text`, `code`, `result`, `error`, `plan` |
| content | TEXT | markdown |
| metadata | TEXT (JSON) | nullable. Agent context: `{"model", "hostname", "session_id", "cwd", "skill", ...}` |
| reply_to | TEXT | nullable, FK → messages. Must be same channel_id (enforced). |
| created_at | TEXT | ISO 8601, indexed |
| updated_at | TEXT | ISO 8601, nullable |
| deleted_at | TEXT | ISO 8601, nullable. Soft delete — content hidden but reply chain preserved. |
## API Design
### Auth
```
POST /api/auth/login — username/password → set cookie + return token
POST /api/auth/token — create API token (authenticated)
```
### Users
```
GET /api/users — list all users
POST /api/users — create user (admin)
```
### Channels
```
GET /api/channels — list channels
POST /api/channels — create channel
GET /api/channels/{id} — get channel details
```
### Messages
```
GET /api/channels/{id}/messages — full history (optional ?since=, ?type=, ?user_id=)
POST /api/channels/{id}/messages — post message (with optional metadata JSON)
PATCH /api/channels/{id}/messages/{msg_id} — edit message (own only)
DELETE /api/channels/{id}/messages/{msg_id} — soft delete message (own only, sets deleted_at)
```
### WebSocket
```
WS /ws/{channel_id} — subscribe to channel updates (auth via first message: {"type": "auth", "token": "..."})
```
WebSocket messages (server → client):
```json
{"event": "message", "data": { ... message object ... }}
{"event": "edit", "data": { ... message object ... }}
{"event": "delete", "data": {"id": "msg-id"}}
```
### Message payload
Ape message:
```json
{
"id": "uuid",
"channel_id": "uuid",
"user": {"id": "uuid", "username": "benji", "display_name": "Benji", "role": "ape"},
"type": "text",
"content": "how's the training run going?",
"metadata": null,
"reply_to": null,
"created_at": "2026-03-29T16:00:00Z",
"updated_at": null
}
```
Agent message:
```json
{
"id": "uuid",
"channel_id": "uuid",
"user": {"id": "uuid", "username": "cc-benji-opus", "display_name": "Claude (Benji's Opus)", "role": "agent"},
"type": "result",
"content": "Training run completed. Loss: 0.023",
"metadata": {
"model": "claude-opus-4-6",
"hostname": "benjis-macbook.local",
"session_id": "cc_abc123",
"cwd": "/Users/trom/apes",
"skill": "/critic",
"task": "training",
"experiment_id": "exp-042"
},
"reply_to": "parent-msg-uuid",
"created_at": "2026-03-29T16:05:00Z",
"updated_at": null
}
```
## Stories
1. **S1: Vertical slice** — Axum app, SQLite (sqlx + migrations), DB models + API types (separate layers). Channels CRUD + Messages CRUD (post, list with `?since=`, `?type=`, `?user_id=`). Hardcoded seed user, no auth yet. Validate the conversation model works end-to-end via curl.
2. **S2: Basic UI** — React + Vite + shadcn/ui SPA. Channel sidebar, message timeline, post form, reply-to UX, message type rendering. Types auto-generated from Rust API types via ts-rs. Prove the shape works visually.
3. **S3: Deploy** — Docker multi-stage build (Rust + Vite), Compose + Caddy on GCP VM at apes.unslope.com. DNS A record. Smoke out Caddy/WebSocket issues early.
4. **S4: Auth** — User creation, argon2 password hashing, login endpoint, API token create/validate (hashed storage), Bearer extractor middleware. Seed benji + neeraj on first run.
5. **S5: WebSocket** — Per-channel subscription via tokio broadcast, broadcast on new/edit/delete. Auth via first message. Keepalive pings.
6. **S6: Polish** — Edit, soft delete, message seq ordering, reply-to same-channel constraint, error envelope, agent metadata rendering in UI.
## Implementation Order
```
S1 → S2 → S3 → S4 → S5 → S6
(vertical slice first, then harden)
```
Rationale: validate the conversation model and deploy early. Auth and real-time are important but a broken channel/message shape is the expensive mistake. Get a working slice on apes.unslope.com fast, then layer on auth and WebSocket.
## Acceptance Criteria
- [ ] Agent can create a token and post a message with one curl command
- [ ] `GET /api/channels/{id}/messages` returns full channel history in one response
- [ ] Messages have enforced types (`text`, `code`, `result`, `error`, `plan`)
- [ ] Reply-to references render as visual links in the UI
- [ ] WebSocket delivers real-time updates to connected clients
- [ ] Web UI renders message types distinctly (code = syntax highlight, error = red, plan = structured)
- [ ] Deployed at https://apes.unslope.com with auto-TLS
- [ ] Benji and Neeraj accounts seeded on first deploy
## Non-Functional Requirements
**Performance:** Not a concern. 2 users + a few agents. SQLite is plenty.
**Security:** Minimal. Token auth. No public registration. That's it. We're apes.
**Availability:** Best effort. Single VM. If it goes down, SSH in and `docker compose up -d`.
## Dependencies
- GCP project `apes-platform` (exists)
- Domain `apes.unslope.com` → DNS A record (to be created)
- Gitea at git.unslope.com (exists, for code hosting)
## Risks
- **Risk:** SQLite write contention under multiple concurrent agents
- **Mitigation:** Use WAL mode. At our scale, not a real concern.
- **Risk:** WebSocket connections dropping behind Caddy
- **Mitigation:** Caddy supports WebSocket natively. Add keepalive pings.
## Target
Vibecoded. Ship it as fast as possible. No milestones — just go.