# 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 ```