# 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 | `POST /rest/api/3/search/jql` with body `{jql:'assignee="<email>" AND project=<key> AND updated >= "<ts>"', fields, maxResults}`, cursor-paginated via `nextPageToken`/`isLast` (Basic auth: email+API token). The legacy `GET /rest/api/3/search` was removed by Atlassian — CHANGE-2046. |
| 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 (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
```
