- 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>
39 KiB
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/templatepages + 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 inlocalStorage+ user profile). Square edges:--radius: 2pxglobally. 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:
contenteditablewith 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
{
"_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
{
"_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
{ "_id": "01J...", "consultantId": "01J...", "developerId": "01J...", "addedAt": ISODate }
4.4 tasks
{
"_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
{ "_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
// 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 onexpiresAt.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:
{
"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:
{
"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:
{
"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:
{
"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 headerX-Signature: hex(hmac-sha256(body, WORK_PERFORMER_TOKEN)):
{ "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
- 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).
- 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. - 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):
# --- 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:
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):
: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)
- In-app notification center + WS toasts (claim requests, approvals, review results, sync errors, mentions in chat).
- Audit log with admin UI (filter by user/entity).
- Task comments + @mentions (mention → notification).
- Time logging per task; surfaced in metrics.
- Optimistic-concurrency conflict toasts ("reload — consultant edited this task").
- Coefficient rebalancer: "distribute remaining evenly" / lock-and-normalize controls.
- Re-run subdivision (replaces previous unpublished children after confirm dialog).
- Saved board filters per user; leaderboard (top developers by bounty, opt-out flag).
- Bulk publish; bulk archive.
- Search everywhere (tasks text index; users by name/email).
- Seed script
make seed— demo customer (mock ticketing typedemothat fabricates tickets locally, so the whole flow is demonstrable without real Jira), 1 admin, 2 consultants, 6 developers, sample conversations. - Export metrics as CSV.
- Profile cards on hover (avatar, roles, bio, contact).
- Unread badges in messaging; typing indicator; image lightbox.
- Per-user theme persistence (profile + localStorage).
- 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.
- Admin service status panel: live health, latency, breaker state, and queue depth for Atomization Service, Work Performer Service, and each customer sync worker.
- 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.
- 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.
- Per-customer default budget: set on the customer; pre-fills the root-task budget so consultants don't retype it.
- Compose profile
mocksfor the two placeholder services:docker compose --profile mocks upruns everything; omitting the profile runs only app+mongo against real external services configured in.env. - Self-service "Forgot password" (token email; active only when SMTP configured).
make backup/make restore—mongodump/mongorestoreinto ./backups via the mongo container; README documents a cron example.- 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 Composehealthcheckblocks for all four services;appdepends_onmongocondition: 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
jobscollection 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-Hostare honored only when the direct peer is withinTRUSTED_PROXY_CIDRS(otherwise ignored) — the resolved client IP feeds rate limiting, sessions, and the audit log;APP_BASE_URLset to the public https URL drives all absolute URLs (OIDC redirect URI, signed file URLs, email links) and, withCOOKIE_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_sizematchingMAX_UPLOAD_MB).
- app listens on
- 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 afterdocker compose up. - Target:
go vet,gofmt -lclean; CI-able viamake 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