# Bounty Board — System Specification
Version: 1.0 (draft)
Status: Ready for implementation
---
## 1. Overview
Bounty Board is a consulting work-management platform. It imports tickets from a customer's
existing ticketing system (Jira, Azure DevOps, or YouTrack), lets a **consultant** atomize
them into small, well-scoped developer tasks (with AI assistance via an external Atomization
Service), publishes those tasks on a **bounty board** where **developers** claim work, and
tracks review, approval, and bounty-based performance metrics. Tasks may alternatively be
assigned to an **AI work performer** (pluggable interface; Claude Code-based placeholder).
### 1.1 Core flow
```
Customer ticketing system (Jira / Azure DevOps / YouTrack)
│ (ticket assigned to consultant's linked account)
▼
Sync worker (poll) ──► Imported task on Atomization Board (consultant view)
│
▼
Consultant: Subdivide (optional note) ──► Atomization Service ──► N subtasks
Extend (required note) ──► Extension API ──► 1 sibling task
│ (consultant edits titles/descriptions/AC/effort coefficients, sets budget)
▼
Publish ──► Bounty Board (developer view, bounty = coefficient × budget)
│
├── Developer requests assignment ──► Consultant approves ──► In progress
│ ──► Developer marks "In review" ──► Consultant approves/rejects
│ ──► Approved: bounty awarded to developer, synced stats
│
└── Consultant assigns to AI ──► Work Performer Service ──► result ──► review
```
### 1.2 Goals and non-goals
Goals: reliability, speed (proper Mongo indexes), minimal dependencies, modern square-edged
UI with light (beige) and dark themes, OIDC SSO **and** local accounts, instant messaging
with files/images/rich text, health checks, full test coverage, Docker Compose deployment.
Non-goals (v1): real AI work performer implementation (interface + Claude Code placeholder
container only), billing/payments (bounty is a performance metric, not money), mobile apps,
pushing changes back into the customer's ticketing system (read-only sync in v1; status
write-back is listed as a quality-of-life extension).
---
## 2. Technology choices (minimal dependencies)
### 2.1 Backend — Go (≥ 1.22)
| Concern | Choice | Rationale |
|---|---|---|
| HTTP server & routing | `net/http` stdlib (Go 1.22 pattern routing: `GET /api/v1/tasks/{id}`) | zero deps, reliable |
| MongoDB | `go.mongodb.org/mongo-driver/v2` | official driver, required |
| OIDC | `github.com/coreos/go-oidc/v3` + `golang.org/x/oauth2` | de-facto standard, small |
| Passwords | `golang.org/x/crypto/argon2` | stdlib-adjacent |
| WebSockets (chat, live updates) | `github.com/coder/websocket` (single, maintained, no transitive deps) | gorilla is in maintenance mode; coder/websocket is minimal |
| JWT sessions | **none** — opaque session tokens stored in Mongo, httpOnly+Secure+SameSite=Lax cookie | fewer deps, instant revocation |
| Config | stdlib `os.Getenv` + tiny internal `.env` loader (~40 lines, no lib) | zero deps |
| Logging | `log/slog` stdlib, JSON handler | zero deps |
| Testing | stdlib `testing` + `httptest`; `testcontainers` is NOT used — integration tests run against the compose Mongo with a dedicated test database | zero extra deps |
Everything else (Jira/Azure/YouTrack clients, rate limiting, CSRF, validation, ID
generation, AES-GCM encryption of customer credentials) is implemented with the standard
library.
### 2.2 Frontend — no framework, no build step
- Server-rendered Go `html/template` pages + small vanilla ES-module JavaScript per page.
- No npm, no bundler. Static assets served by the Go binary from `embed.FS`.
- CSS: hand-written, CSS custom properties for theming (`data-theme="light|dark"` on
``, persisted in `localStorage` + user profile). Square edges: `--radius: 2px`
globally. Light theme = beige palette (see §10).
- Live updates: one WebSocket per logged-in session multiplexing channels
(`chat`, `board`, `notifications`). Graceful fallback to 15 s polling if WS fails.
- Rich text in chat: `contenteditable` with a whitelist sanitizer **on the server**
(allowed tags: `b i u s a code pre ul ol li br p blockquote`). No editor library.
- Drag & drop file/image upload in chat; images previewed inline.
### 2.3 Services (Docker Compose)
| Service | Image / build | Purpose |
|---|---|---|
| `app` | `./` (multi-stage Go build, distroless or alpine) | main Bounty Board app |
| `mongo` | `mongo:7` | database, named volume |
| `atomizer-mock` | `./services/atomizer` (Go, port 8090, compose profile `mocks`) | standalone placeholder Atomization Service (atomize + extend API) backed by Anthropic's OpenAI-compatible chat-completions endpoint |
| `work-performer` | `./services/work-performer` (Node base + `@anthropic-ai/claude-code`, port 8091, compose profile `mocks`) | standalone placeholder Work Performer Service; mounts local Claude Code config/secrets |
---
## 3. Roles & permissions
One account can hold multiple roles (boolean flags on the user document).
| Capability | Admin | Consultant | Developer |
|---|---|---|---|
| Application settings (SMTP, OIDC, atomizer URL override, branding) | ✔ | | |
| Customer CRUD + ticketing-system connection setup & test | ✔ | | |
| User management (roles, disable, reset password, delete) | ✔ | | |
| Assign consultants to customers/projects | ✔ | | |
| Select developers from global pool into own pool | | ✔ | |
| Atomization board (subdivide / extend / edit / set budget / publish) | | ✔ (own customers) | |
| Approve developer assignment requests; assign to AI | | ✔ | |
| Review / approve / reject submitted work; award bounty | | ✔ | |
| Bounty board (view + request assignment for own consultants' tasks) | | | ✔ |
| Work tracking on own tasks (status, comments, time log, mark for review) | | | ✔ |
| Own metrics dashboard | ✔ (global) | ✔ (per project/dev) | ✔ (own) |
| Messaging | ✔ | ✔ | ✔ |
| Edit own profile (avatar, name, contacts, bio, arbitrary key/value fields) | ✔ | ✔ | ✔ |
Registration: open self-registration creates a **developer** account in the global pool.
Admin and consultant flags are granted only by an admin. First-run bootstrap: if the users
collection is empty, credentials from `ADMIN_EMAIL` / `ADMIN_INITIAL_PASSWORD` env vars
create the initial admin (password change forced at first login).
---
## 4. Data model (MongoDB, database `bountyboard`)
Conventions: `_id` is a ULID string (sortable, generated in Go, no dep — ~80-line internal
package). All documents carry `createdAt`, `updatedAt` (UTC), and `version` (int,
incremented on update; used for optimistic concurrency — updates filter on `version` and
return 409 on mismatch).
### 4.1 `users`
```jsonc
{
"_id": "01J...",
"email": "a@b.c", // unique, lowercase
"name": "Jane Doe",
"avatarFileId": "01J...|null", // ref files
"bio": "…",
"contact": { "phone": "…", "location": "…", "links": ["…"] },
"extra": { "anyKey": "anyValue" }, // arbitrary admin/self-defined info
"roles": { "admin": false, "consultant": false, "developer": true },
"auth": {
"local": { "passwordHash": "argon2id$…", "mustChange": false } | null,
"oidc": { "issuer": "…", "subject": "…" } | null // at least one of local/oidc
},
"settings": { "theme": "light", "notifications": { "email": true, "inApp": true } },
"disabled": false,
"lastSeenAt": ISODate
}
```
### 4.2 `customers`
```jsonc
{
"_id": "01J...",
"name": "ACME Corp",
"ticketing": {
"type": "jira" | "azure_devops" | "youtrack",
"baseUrl": "https://acme.atlassian.net",
// encrypted blob, AES-256-GCM with key from CREDENTIALS_ENC_KEY (32B base64):
"credentialsEnc": "base64(nonce|ciphertext)",
// plaintext shape before encryption, by type:
// jira: { "email": "...", "apiToken": "..." }
// azure_devops:{ "organization": "...", "project": "...", "pat": "..." }
// youtrack: { "permanentToken": "..." }
"projectKey": "ACME", // Jira project key / ADO project / YouTrack project
"pollIntervalSec": 60,
"lastSyncAt": ISODate, "lastSyncStatus": "ok|error", "lastSyncError": "…"
},
"consultantIds": ["01J..."], // consultants assigned to this customer (≥1 to sync)
"archived": false
}
```
A "project" in the UI == a customer. Multiple consultants per customer and multiple
customers per consultant are both supported by this single array.
### 4.3 `pools` — developer pool membership
```jsonc
{ "_id": "01J...", "consultantId": "01J...", "developerId": "01J...", "addedAt": ISODate }
```
### 4.4 `tasks`
```jsonc
{
"_id": "01J...",
"customerId": "01J...",
"consultantId": "01J...", // owning consultant (the sync assignee); ALL
// consultants assigned to the customer can view and
// manage the task — consultantId records provenance
// and is the default reviewer/notification target
"origin": "imported" | "subdivided" | "extended",
"parentId": "01J...|null", // imported root for subdivided; sibling source for extended
"rootId": "01J...", // top imported ancestor (== _id for imported)
"external": { // only for origin=imported
"system": "jira|azure_devops|youtrack",
"key": "ACME-123", "url": "…",
"type": "epic" | "story" | "task", // shown as icon only; treated uniformly
"raw": { … } // original payload snapshot
} | null,
"title": "…",
"description": "…", // markdown-ish plain text
"acceptanceCriteria": ["…", "…"],
"attachments": [{ "name": "…", "url": "…", "mimeType": "…", "fileId": "01J...|null" }],
"links": ["https://…"],
"effortCoefficient": 0.25, // 0..1; siblings under one parent sum to 1.0
"budget": 1000, // set by consultant on root; inherited, overridable
"bounty": 250, // computed = effortCoefficient × budget; denormalized
"status": "imported" | "atomizing" | "atomized" | "published"
| "claim_requested" | "assigned" | "in_progress"
| "in_review" | "changes_requested" | "approved" | "archived",
"assignee": { "kind": "human"|"ai", "userId": "01J...|null", "jobId": "…|null" } | null,
"claimRequests": [{ "developerId": "01J...", "note": "…", "at": ISODate }],
"atomizationNote": "…|null", // last subdivide note
"timeline": [{ "at": ISODate, "actorId": "01J...|system", "event": "…", "data": {…} }],
"comments": [{ "_id": "01J...", "authorId": "…", "body": "…(sanitized html)", "at": ISODate }],
"timeLog": [{ "developerId": "…", "minutes": 90, "note": "…", "at": ISODate }]
}
```
Status rules (enforced server-side, single source of truth in `internal/domain/status.go`):
```
imported ──subdivide──► atomizing ──service ok──► (children created: atomized)
imported/atomized ──extend──► sibling created (atomized)
atomized ──consultant publishes──► published
published ──dev requests──► claim_requested ──consultant approves──► assigned
published ──consultant assigns AI──► assigned (assignee.kind=ai)
assigned ──dev starts──► in_progress ──dev submits──► in_review
in_review ──consultant──► approved (bounty awarded) | changes_requested ──► in_progress
claim_requested ──developer withdraws / consultant declines──► published (declined devs notified)
assigned|in_progress ──consultant unassigns OR developer abandons──► published (timeline entry)
any ──consultant/admin──► archived
```
### 4.5 `bountyAwards`
```jsonc
{ "_id": "01J...", "taskId": "…", "developerId": "…", "consultantId": "…",
"customerId": "…", "amount": 250, "coefficient": 0.25, "awardedAt": ISODate }
```
Immutable ledger; metrics aggregate from here (never recompute from mutable tasks).
### 4.6 `conversations` and `messages`
```jsonc
// conversations
{ "_id": "01J...", "kind": "dm" | "group" | "project",
"customerId": "01J...|null", // for kind=project
"title": "…|null",
"participantIds": ["…"], // for dm exactly 2; unique key for dm
"lastMessageAt": ISODate }
// messages
{ "_id": "01J...", "conversationId": "…", "senderId": "…",
"body": "
sanitized rich text
",
"attachments": [{ "fileId": "…", "name": "…", "mimeType": "…", "size": 12345,
"isImage": true }],
"readBy": [{ "userId": "…", "at": ISODate }],
"editedAt": ISODate|null, "deletedAt": ISODate|null }
```
### 4.7 `files` (GridFS)
Uploads (avatars, chat attachments, imported ticket attachments cached locally) stored in
GridFS buckets `fs`. Metadata: `ownerId`, `scope` (`avatar|chat|task`), `mimeType`,
`size`, `sha256`. Max upload size: `MAX_UPLOAD_MB` (default 25). Served via
`GET /files/{id}` with access control by scope.
### 4.8 `sessions`, `auditLog`, `notifications`, `settings`
- `sessions`: `{ _id: token(32B random, base64url), userId, createdAt, expiresAt, ip, ua }`
with TTL index on `expiresAt`.
- `auditLog`: every privileged mutation `{ at, actorId, action, entity, entityId, diff }`.
- `notifications`: `{ userId, kind, title, body, link, readAt|null, createdAt }`.
- `settings`: single document `{_id:"app"}` for admin-editable runtime settings
(env vars are the defaults; DB overrides where marked overridable).
### 4.9 Indexes (created idempotently at startup, `internal/store/indexes.go`)
```
users: { email: 1 } unique
{ "auth.oidc.issuer": 1, "auth.oidc.subject": 1 } unique sparse
customers: { name: 1 } unique; { consultantIds: 1 }
pools: { consultantId: 1, developerId: 1 } unique; { developerId: 1 }
tasks: { customerId: 1, status: 1, updatedAt: -1 }
{ consultantId: 1, status: 1, updatedAt: -1 }
{ "assignee.userId": 1, status: 1 }
{ rootId: 1 } ; { parentId: 1 }
{ "external.system": 1, "external.key": 1, customerId: 1 } unique sparse
{ title: "text", description: "text" } // board search
bountyAwards: { developerId: 1, awardedAt: -1 }
{ consultantId: 1, awardedAt: -1 }
{ customerId: 1, awardedAt: -1 }
conversations: { participantIds: 1, lastMessageAt: -1 }
{ kind: 1, customerId: 1 }
messages: { conversationId: 1, _id: 1 } // ULID ⇒ time-ordered
notifications: { userId: 1, readAt: 1, createdAt: -1 }
sessions: { expiresAt: 1 } expireAfterSeconds: 0; { userId: 1 }
auditLog: { at: -1 } ; { entityId: 1, at: -1 }
```
---
## 5. External service interfaces (REST)
The Atomization Service and the Work Performer Service are **two fully independent
services**: separate codebases (`services/atomizer`, `services/work-performer`),
separate containers, separate base URLs, separate ports, **separate bearer tokens**
(`ATOMIZER_TOKEN`, `WORK_PERFORMER_TOKEN`), independent health checks, and independent
versioning. The app must never assume they are co-located, share state, or share
credentials; each is replaceable on its own by changing only its base URL + token in
`.env`. Both are **owned interfaces**: the Bounty Board app is the client; any
implementation honoring these contracts can be swapped in. Mock implementations ship in
this repo (§9). Common conventions only: JSON, UTF-8, `Authorization: Bearer `,
errors as `{ "error": { "code": "string", "message": "string" } }`.
### 5.1 Atomization Service — base URL `ATOMIZER_BASE_URL`
#### POST `/v1/atomize`
Subdivides one task into smaller tasks.
Request:
```jsonc
{
"taskId": "01J...", // for tracing/idempotency
"title": "Implement user import",
"description": "…full original description…",
"acceptanceCriteria": ["…"], // original AC (may be empty)
// attachment URLs carry a short-lived signed token (?st=hmac, 1 h TTL) so external
// services can fetch them WITHOUT a session cookie — /files/{id} accepts session OR valid st
"attachments": [{ "name": "spec.pdf", "url": "https://app/files/01J...?st=…", "mimeType": "application/pdf" }],
"links": ["https://acme.atlassian.net/browse/ACME-123"],
"subdivisionNote": "split backend/frontend, keep DB migration separate", // optional
"constraints": { "minTasks": 2, "maxTasks": 8 } // optional
}
```
Response `200`:
```jsonc
{
"tasks": [
{
"title": "DB migration for user import",
"description": "…",
"acceptanceCriteria": ["migration is reversible", "…"],
"effortCoefficient": 0.2 // 0 < c ≤ 1
},
{ "title": "…", "description": "…", "acceptanceCriteria": ["…"], "effortCoefficient": 0.5 },
{ "title": "…", "description": "…", "acceptanceCriteria": ["…"], "effortCoefficient": 0.3 }
],
"model": "claude-sonnet-4-6", // optional metadata
"notes": "…" // optional, shown to consultant
}
```
Contract: `sum(effortCoefficient) == 1.0 ± 0.001`. The **client** (app) re-normalizes
coefficients defensively if the sum deviates ≤ 0.05, otherwise treats it as a 502-class
error. Timeout: `ATOMIZER_TIMEOUT_SEC` (default 120). The app sets the task to
`atomizing` and processes the call in a background job; UI updates over WebSocket.
#### POST `/v1/extend`
Creates exactly one parallel (sibling) task extending the source task's functionality.
Request: same shape as `/v1/atomize` but with required `"extensionNote"` instead of
`subdivisionNote`, and no `constraints`.
Response `200`:
```jsonc
{
"task": {
"title": "User import — CSV mapping presets",
"description": "…",
"acceptanceCriteria": ["…"],
"effortCoefficient": 0.4 // suggested effort RELATIVE TO THE SOURCE task (0..1+ allowed up to 2.0)
},
"model": "…", "notes": "…"
}
```
The new sibling's bounty = its coefficient × the same `budget` as the source. Because an
extension adds scope, sibling coefficients under a parent are allowed to sum to > 1 once
extensions exist; the UI shows the per-parent sum and lets the consultant rebalance or
raise the budget.
#### GET `/healthz` → `200 {"status":"ok"}`
### 5.2 Work Performer Service — base URL `WORK_PERFORMER_BASE_URL`
Asynchronous job API. v1 ships a placeholder implementation; the contract is final.
- `POST /v1/jobs` — request body:
```jsonc
{
"taskId": "01J...",
"title": "…", "description": "…", "acceptanceCriteria": ["…"],
"attachments": [{ "name": "…", "url": "…", "mimeType": "…" }],
"links": ["…"],
"context": { "repositoryUrl": "…|null", "branch": "…|null", "instructions": "…|null" },
"callbackUrl": "http://app:8787/api/v1/internal/work-results" // signed callback
}
```
→ `202 { "jobId": "wp_…", "status": "queued" }`
- `GET /v1/jobs/{jobId}` → `{ "jobId": "…", "status": "queued|running|succeeded|failed",
"startedAt": "…", "finishedAt": "…|null" }`
- `DELETE /v1/jobs/{jobId}` → cancel (best effort).
- Callback (service → app): `POST {callbackUrl}` with header
`X-Signature: hex(hmac-sha256(body, WORK_PERFORMER_TOKEN))`:
```jsonc
{ "jobId": "wp_…", "taskId": "01J...", "status": "succeeded|failed",
"summary": "what was done", "artifacts": [{ "name": "patch.diff", "url": "…" }],
"log": "…tail of execution log…" }
```
On `succeeded`, the app verifies the signature, treats the callback as **idempotent by
`jobId`** (duplicates ignored), downloads all artifact URLs into GridFS (so review
survives the performer container), and moves the task to `in_review` with the AI as
submitter; the consultant reviews exactly as for a human. On `failed`, status returns to
`assigned` with a timeline entry and a consultant notification.
- `GET /healthz` → `200`.
### 5.3 Inbound ticketing sync (Jira / Azure DevOps / YouTrack)
v1 uses **polling** (reliable, no inbound firewall requirements). A per-customer worker
runs every `pollIntervalSec`, querying for tickets **assigned to the consultant's linked
account** in that system, updated since `lastSyncAt − 5 min` (overlap window):
| System | Query mechanism |
|---|---|
| Jira Cloud | `GET /rest/api/3/search?jql=assignee="" AND project= AND updated >= ""` (Basic auth: email+API token) |
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '' AND [System.TeamProject] = '' AND [System.ChangedDate] >= ''`, then batch `GET workitems` (PAT auth) |
| YouTrack | `GET /api/issues?query=assignee: project: updated: .. *` (Bearer permanent token) |
Mapping → task `external.type`: Jira Epic→epic, Story→story, else task; ADO Epic→epic,
User Story/PBI→story, else task; YouTrack by issue type name, default task. Each
consultant stores per-system identity in `users.extra.ticketingIdentities`
(`{ jira: "email", azure_devops: "email", youtrack: "login" }`).
Upsert key: `(external.system, external.key, customerId)`. New tickets → status
`imported`, notification to consultant. Updated tickets → refresh `external.raw`, title,
description, attachments **only while status is `imported`** (after atomization begins,
changes only add a timeline entry "upstream ticket changed" + notification). Attachments
are downloaded into GridFS so the atomizer can fetch them from the app. If a previously
imported ticket is no longer returned for the consultant (reassigned or deleted
upstream), the task is **not** deleted: it gets `external.orphaned: true`, a timeline
entry, a consultant notification, and a visible badge; the consultant decides whether to
archive it.
A "Test connection" button on the customer form calls a cheap authenticated endpoint
(`/myself`, `/_apis/projects`, `/api/users/me`) and reports success/failure inline.
---
## 6. Application HTTP API (selected, `/api/v1`)
All responses JSON. Auth: session cookie; CSRF: double-submit token header `X-CSRF-Token`
required on mutations. Pagination: `?limit=50&cursor=`. Full OpenAPI 3.1 document
is generated by hand into `api/openapi.yaml` and served at `/api/docs` (rendered with
embedded Swagger-UI-free minimal HTML viewer or plain YAML download).
```
Auth
POST /auth/register {email,name,password} → developer account
POST /auth/login {email,password}
POST /auth/logout
GET /auth/oidc/login → redirect to provider (PKCE)
GET /auth/oidc/callback
GET /auth/me
Admin
GET/POST/PATCH /admin/users… roles, disable, force-reset
GET/POST/PATCH/DELETE /admin/customers…
POST /admin/customers/{id}/test-connection
POST /admin/customers/{id}/sync-now
GET/PATCH /admin/settings
GET /admin/audit-log
Consultant
GET /consultant/board?customerId=&status= atomization board
POST /tasks/{id}/subdivide { note?: string, constraints? } → 202 (async)
POST /tasks/{id}/extend { note: string } → 202 (async)
PATCH /tasks/{id} edit title/description/AC/coefficient/budget (version-checked)
POST /tasks/{id}/publish single or POST /tasks/publish {ids:[…]}
POST /tasks/{id}/approve-claim { developerId }
POST /tasks/{id}/assign-ai { context?: {...} }
POST /tasks/{id}/review { decision: "approve"|"request_changes", note }
GET/POST/DELETE /consultant/pool… manage developer pool
GET /consultant/metrics?customerId=&developerId=&from=&to=
Developer
GET /board bounty board (published tasks of dev's consultants)
filters: customer, search(q), minBounty, sort
POST /tasks/{id}/claim { note? }
POST /tasks/{id}/start | /submit-review
POST /tasks/{id}/comments { body }
POST /tasks/{id}/time { minutes, note }
GET /developer/metrics?from=&to=
Shared
GET /tasks/{id} detail incl. timeline (role-scoped)
GET/POST /conversations… GET/POST /conversations/{id}/messages…
POST /files (multipart) GET /files/{id}
GET/PATCH /profile GET /users/{id}/card public profile card
GET /notifications POST /notifications/read
WS /ws multiplexed live channel
Health & ops
GET /healthz liveness: always 200 if process up
GET /readyz readiness: Mongo ping + (non-fatal, reported) atomizer
and work-performer /healthz status
GET /metricsz JSON counters (requests, sync runs, queue depths)
```
### 6.1 Bounty calculation
`bounty = round(effortCoefficient × budget, 2)`; recomputed and persisted whenever either
input changes and task is not yet `approved`. Once `approved`, the awarded amount in
`bountyAwards` is frozen forever.
### 6.2 Metrics definitions
- Developer: total bounty earned, tasks completed, approval rate
(approved / (approved + changes_requested submissions)), average time
assigned→approved, time logged, earnings-over-time series (weekly buckets), per-customer
breakdown.
- Consultant: same aggregations grouped per developer and per customer + atomization
throughput (imported→published lead time), open board depth.
- All computed via Mongo aggregation pipelines over `bountyAwards` + `tasks.timeline`;
rendered as SVG charts generated by ~150 lines of vanilla JS (no chart library).
---
## 7. Authentication
1. **Local**: email + password (argon2id, parameters: t=3, m=64MiB, p=2). Rate limit:
10 attempts / 15 min / IP+email (in-memory token bucket). Optional email verification
when SMTP is configured (QoL, off by default).
2. **OIDC SSO**: standard code flow with PKCE against `OIDC_ISSUER_URL` /
`OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`. On first OIDC login: if a local user with the
same verified email exists, link; otherwise create a developer account. Buttons appear
only when OIDC env vars are set.
3. Sessions: 32-byte random opaque token, httpOnly Secure SameSite=Lax cookie, 30-day
sliding expiry, server-side revocation (logout-all in profile).
---
## 8. Configuration (`.env`, loaded by app and referenced in `docker-compose.yml`)
`.env.example` (committed; real `.env` gitignored):
```dotenv
# --- core ---
APP_BASE_URL=http://localhost:8787 # set to the public https URL when behind the proxy
TRUSTED_PROXY_CIDRS= # e.g. 172.16.0.0/12,10.0.0.0/8 — empty = trust no proxy headers
APP_PORT=8787
MONGO_URI=mongodb://mongo:27017
MONGO_DB=bountyboard
SESSION_SECRET=change-me-32-bytes-random
CREDENTIALS_ENC_KEY= # base64 32 bytes; `openssl rand -base64 32`
MAX_UPLOAD_MB=25
# --- bootstrap admin ---
ADMIN_EMAIL=admin@example.com
ADMIN_INITIAL_PASSWORD=change-me-now
# --- external services (REQUIRED to be configurable here) ---
# two independent services — separate URLs, ports, and tokens
ATOMIZER_BASE_URL=http://atomizer-mock:8090
ATOMIZER_TOKEN=change-me-atomizer
ATOMIZER_TIMEOUT_SEC=120
WORK_PERFORMER_BASE_URL=http://work-performer:8091
WORK_PERFORMER_TOKEN=change-me-performer
WORK_PERFORMER_HTTP_TIMEOUT_SEC=30 # job submission/polling only; jobs are async
# --- SMTP (optional; enables forgot-password + email notifications when set) ---
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=bountyboard@example.com
# --- hardening / tuning ---
COOKIE_SECURE=auto # auto: Secure flag only when APP_BASE_URL is https
ATOMIZE_MAX_CONCURRENCY=3 # cap parallel LLM calls (cost + rate-limit control)
MONGO_USERNAME= # optional; enable Mongo auth for non-local deploys
MONGO_PASSWORD=
# --- OIDC (optional; SSO hidden if empty) ---
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# --- mock atomizer / work performer ---
ANTHROPIC_API_KEY= # paste your key here; NEVER commit
ANTHROPIC_OPENAI_BASE_URL=https://api.anthropic.com/v1
ANTHROPIC_MODEL=claude-sonnet-4-6
LLM_API_STYLE=openai # openai | anthropic (native /v1/messages fallback)
```
---
## 9. Placeholder service containers
### 9.1 `atomizer-mock` (Go, same repo, `services/atomizer`)
Implements §5.1 exactly. Internally calls Anthropic through its **OpenAI-compatible**
chat-completions endpoint (`POST {ANTHROPIC_OPENAI_BASE_URL}/chat/completions`, header
`Authorization: Bearer $ANTHROPIC_API_KEY`, model `$ANTHROPIC_MODEL`). Because the
compatibility layer covers core chat completions only, the service also implements the
native Anthropic Messages API (`/v1/messages`, headers `x-api-key`,
`anthropic-version: 2023-06-01`) selected by `LLM_API_STYLE=anthropic` — flip this if the
compatibility endpoint misbehaves. Plain `net/http`; no SDK.
Prompting: system prompt instructs strict-JSON output matching the response schema;
response is parsed defensively (strip code fences), validated (coefficient sum
normalization), retried once on parse failure, and falls back to a deterministic
"split into N equal parts" stub when no API key is configured (so the full system remains
testable offline).
### 9.2 `work-performer` (Node 20 + Claude Code, `services/work-performer`)
Implements §5.2. `Dockerfile`: `FROM node:20-bookworm`, `npm install -g
@anthropic-ai/claude-code`, plus a ~200-line Express-free Node `http` server. The compose
file mounts the host's local Claude Code configuration/secrets read-only:
```yaml
volumes:
- ${HOME}/.claude:/root/.claude
- ${HOME}/.claude.json:/root/.claude.json
```
For each job it prepares `/work/{jobId}/TASK.md` (title, description, AC, links,
downloaded attachments) and runs `claude -p "$(cat TASK.md)" --output-format json
--dangerously-skip-permissions` inside that directory, then posts the HMAC-signed
callback with the summary and any produced files as artifacts. This is explicitly a
placeholder: single-job concurrency, no repo cloning unless `context.repositoryUrl` is
given (then `git clone` first). The interface, not the implementation, is the deliverable.
---
## 10. UI / UX
Pages: Login/Register · Admin → Settings, Customers (list + wizard with system-specific
credential fields + Test connection), Users · Consultant → Atomization Board, Pool,
Reviews queue, Metrics · Developer → Bounty Board, My tasks (kanban: Assigned / In
progress / In review / Done), Metrics · Shared → Task detail, Messages, Profile,
Notifications.
Atomization board: column or tree layout grouped by root ticket; each card shows source-type
icon (epic ◆ / story ▣ / task ▢), title, coefficient slider (0–1, step 0.01, live
per-parent sum indicator that turns red when ≠ 1.00 for non-extended sets), bounty
preview, and the two primary buttons **Subdivide** (modal with optional note +
min/max task count) and **Extend** (modal with required note). While atomizing, the card
shows a progress shimmer; results stream in via WebSocket.
Bounty board: responsive card grid; bounty badge prominent; filters (customer, text
search via Mongo text index, min bounty, newest/highest sort); "Request assignment"
button with optional pitch note; "requested" state visible to other developers
(configurable: admins may hide competing requests).
Design tokens (light, beige default):
```css
:root[data-theme=light] {
--bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0;
--text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff;
--ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px;
}
:root[data-theme=dark] {
--bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c;
--text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f;
--ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px;
}
```
System font stack; 8-px spacing grid; visible focus rings; keyboard shortcuts
(`g b` board, `g m` messages, `/` search); WCAG AA contrast in both themes.
---
## 11. Quality-of-life features (in scope v1 unless marked later)
1. In-app notification center + WS toasts (claim requests, approvals, review results,
sync errors, mentions in chat).
2. Audit log with admin UI (filter by user/entity).
3. Task comments + @mentions (mention → notification).
4. Time logging per task; surfaced in metrics.
5. Optimistic-concurrency conflict toasts ("reload — consultant edited this task").
6. Coefficient rebalancer: "distribute remaining evenly" / lock-and-normalize controls.
7. Re-run subdivision (replaces previous unpublished children after confirm dialog).
8. Saved board filters per user; leaderboard (top developers by bounty, opt-out flag).
9. Bulk publish; bulk archive.
10. Search everywhere (tasks text index; users by name/email).
11. Seed script `make seed` — demo customer (mock ticketing type `demo` that fabricates
tickets locally, so the whole flow is demonstrable without real Jira), 1 admin,
2 consultants, 6 developers, sample conversations.
12. Export metrics as CSV.
13. Profile cards on hover (avatar, roles, bio, contact).
14. Unread badges in messaging; typing indicator; image lightbox.
15. Per-user theme persistence (profile + localStorage).
16. Graceful degradation: if the Atomization Service is down, Subdivide/Extend buttons
are disabled with the last health-check status; a simple circuit breaker (open after
3 consecutive failures, half-open probe every 60 s) stops hammering either external
service. Independent breaker per service.
17. Admin **service status panel**: live health, latency, breaker state, and queue depth
for Atomization Service, Work Performer Service, and each customer sync worker.
18. **Review checklist**: in the review dialog each acceptance criterion renders as a
checkbox; the consultant's per-AC verdict is stored on the timeline — this is the
quality-control audit trail.
19. **Stale-task aging**: bounty-board cards show subtle age badges (>7 d, >14 d
published without assignment) so the consultant spots unattractive tasks and can
raise the budget or re-atomize.
20. **Per-customer default budget**: set on the customer; pre-fills the root-task budget
so consultants don't retype it.
21. Compose **profile `mocks`** for the two placeholder services: `docker compose
--profile mocks up` runs everything; omitting the profile runs only app+mongo against
real external services configured in `.env`.
22. Self-service "Forgot password" (token email; active only when SMTP configured).
23. `make backup` / `make restore` — `mongodump`/`mongorestore` into ./backups via the
mongo container; README documents a cron example.
24. Later (documented stubs only): webhook ingestion instead of polling; status
write-back to customer systems; email digests; AI work performer production
implementation; localization.
---
## 12. Reliability, security, operations
- Health checks: `/healthz`, `/readyz` (§6) + Docker Compose `healthcheck` blocks for all
four services; `app` `depends_on` mongo `condition: service_healthy`.
- Graceful shutdown (SIGTERM): stop accepting, drain WS, flush sync workers, 15 s budget.
- Background jobs (sync, atomize calls, callbacks) run through a small internal worker
pool with per-job panic recovery and exponential-backoff retry (max 3) persisted in a
`jobs` collection so restarts don't lose work.
- All external HTTP calls: contexts with timeouts, retry on 5xx/network ×2 with jitter.
- Security: argon2id; AES-256-GCM for stored credentials; CSRF double-submit; strict
cookie flags; HTML sanitizer for all rich text; upload MIME sniffing + size limits;
per-IP rate limits on auth and file upload; security headers (CSP without
unsafe-inline for scripts, X-Frame-Options DENY); RBAC middleware asserting both role
and resource ownership (consultant ↔ customer ↔ task chains).
- Deployment posture: the app is designed to run **behind a TLS-terminating reverse
proxy** (Caddy/nginx). Reverse-proxy readiness checklist, all implemented in v1:
- app listens on `APP_PORT` (default **8787**), bound to localhost in compose; Mongo
port is never published to the host;
- `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` are honored **only**
when the direct peer is within `TRUSTED_PROXY_CIDRS` (otherwise ignored) — the
resolved client IP feeds rate limiting, sessions, and the audit log;
- `APP_BASE_URL` set to the public https URL drives all absolute URLs (OIDC redirect
URI, signed file URLs, email links) and, with `COOKIE_SECURE=auto`, enables Secure
cookies behind https even though app↔proxy traffic is plain http;
- WebSocket endpoint works through proxies (Upgrade/Connection pass-through; 30 s
server ping/pong heartbeats so idle proxy timeouts don't kill connections); Origin
is checked against `APP_BASE_URL`;
- request body limits enforced in-app (don't rely on proxy limits); no HTTP redirects
to absolute http:// URLs anywhere;
- README must include working sample configs for **Caddy** and **nginx** (incl. WS
location block and `client_max_body_size` matching `MAX_UPLOAD_MB`).
- Backups: named volume + `make backup`/`make restore` (mongodump); restore drill is part
of the acceptance checklist for production use (not required for this test deploy).
- Logging: slog JSON to stdout; request ID middleware; sync/atomizer failures logged at
ERROR and surfaced as admin notifications.
## 13. Testing & acceptance
- Unit tests: status machine, bounty math, coefficient normalization, sanitizer,
crypto round-trip, ULID, RBAC matrix.
- Integration tests (`go test -tags=integration`, runs against compose Mongo with a
per-run database name): auth flows, full task lifecycle imported→approved with the
fallback (stubbed) atomizer, messaging, sync upsert idempotency, optimistic locking.
- Contract tests for §5.1/§5.2 run against the mock services.
- A Playwright-free smoke script (`scripts/smoke.sh`, curl-based) exercises
register→login→healthz→board after `docker compose up`.
- Target: `go vet`, `gofmt -l` clean; CI-able via `make test`.
Acceptance checklist: admin can add a Jira customer and test the connection · ticket
assigned to consultant appears on atomization board within one poll interval · subdivide
returns N tasks whose coefficients sum to 1 and are editable · publish puts tasks on the
bounty board with bounty = coefficient × budget · developer claim → consultant approve →
submit → review approve → bountyAwards row + metrics update · extend creates one sibling ·
AI assignment creates a work-performer job and the callback moves the task to in_review ·
consultant can decline a claim and unassign an assigned task back to the board ·
signed attachment URL fetch works without a session (and expires) · OIDC and local login both work · light/dark theme toggle persists · `/readyz` reflects a
stopped Mongo · all compose health checks green · app reachable through a sample reverse-proxy config
on 8787 with correct client IPs in the audit log · stopping the atomizer container opens
its breaker and disables Subdivide/Extend without affecting Work Performer assignments
(and vice versa — proving full service independence).
## 14. Repository layout
```
bountyboard/
├─ cmd/app/main.go
├─ internal/{config,store,domain,http,auth,sync,atomize,workperform,chat,ws,metrics,files,jobs,crypto,ulid}
├─ web/{templates,static/{css,js,icons}}
├─ services/atomizer/ services/work-performer/
├─ api/openapi.yaml
├─ scripts/{seed.go,smoke.sh}
├─ docker-compose.yml Dockerfile Makefile .env.example specification.md README.md
```