Files
BountyBoard/specification.md
T
etalon 976f5238f8 feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)
- internal .env loader (real env wins) + strictly validated config struct
- ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable
- HTTP server with recovery/request-id/trusted-proxy/access-log middleware
- /healthz, /readyz (pluggable checkers), /metricsz (counter registry)
- multi-stage Dockerfile (alpine, non-root) + compose with healthchecks,
  app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:10:30 +02:00

814 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
`<html>`, 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": "<p>sanitized rich text</p>",
"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 <token>`,
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="<email>" AND project=<key> AND updated >= "<ts>"` (Basic auth: email+API token) |
| Azure DevOps | `POST …/_apis/wit/wiql?api-version=7.1` with WIQL `[System.AssignedTo] = '<email>' AND [System.TeamProject] = '<project>' AND [System.ChangedDate] >= '<ts>'`, then batch `GET workitems` (PAT auth) |
| YouTrack | `GET /api/issues?query=assignee: <login> project: <key> updated: <ts> .. *` (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=<ulid>`. 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 (01, 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
```