dealspace/SPEC.md

887 lines
32 KiB
Markdown

# Dealspace — Architecture Specification
**Version:** 0.2 — 2026-02-28
**Status:** Pre-implementation. This document is the ground truth. Code follows the spec, never the reverse.
---
## Changelog
### v0.2 (2026-02-28)
**Security hardening based on architectural review. All P0/P1 issues addressed.**
#### P0 — Critical Security
- **6.1:** Replaced deterministic encryption with blind indexes (HMAC-SHA256 truncated to 128 bits)
- **6.1:** Added BoringCrypto requirement (`GOEXPERIMENT=boringcrypto` for FIPS 140-3)
- **5.5/18:** Added MFA requirement (TOTP RFC 6238, mandatory for ib_admin/ib_member)
- **7:** Fixed object store dedup contradiction — ObjectID is SHA-256 of ciphertext; dedup is per-project only
#### P1 — Required Before Launch
- **4.1:** Added `version` column for optimistic locking
- **4.1:** Added `deleted_at` + `deleted_by` for soft delete
- **6.1:** Added `key_version` column for key rotation
- **4.3:** Added `broadcasts` table for idempotent answer broadcasting
- **5.2:** Added `can_grant` column and role hierarchy for access control
- **16.3:** Clarified routing chain traversal algorithm (stack-based)
- **16.4:** Added `channel_participants` table; unknown senders quarantined
- **18:** Added session management section (opaque tokens, single active session)
- **18:** Added delegations table for vacation/unavailability coverage
- **4.4:** Clarified `broadcast_to` semantics
### v0.1 (2026-02-28)
- Initial specification
---
## 1. What This Is
A workflow platform for M&A deal management. Investment Banks, Sellers, and Buyers collaborate on a structured request-and-answer system. The core primitive is a **Request** — not a document, not a folder. Documents are how requests get resolved.
**Not** a VDR that grew features. Designed clean, from first principles.
---
## 2. What This Is Not
- Not a document repository with a request list bolted on
- Not a project management tool with deal branding
- Not a clone of any existing product
- Not feature-complete on day one — the spec defines the architecture; MVP scope is separate
---
## 3. The Flow
```
IB creates Project
→ configures Workstreams (Finance, Legal, IT, HR, Operations...)
→ invites Participants (assigns role per workstream)
→ issues Request List to Seller
Seller receives Requests
→ assigns internally
→ uploads Answers (documents, data)
→ marks complete
IB vets Answers
→ approves → Answer published to Data Room
→ rejects → back to Seller with comment
Buyers enter
→ submit Requests (via Data Room interface)
→ AI matches against existing Answers (human confirms)
→ unmatched → routed to IB/Seller for resolution
→ Answer published → broadcast to all Buyers who asked equivalent question
```
---
## 4. Core Data Model — Entry-Based
Inspired directly by inou's entry architecture. **One table to rule them all.**
### 4.1 The Entry
```go
type Entry struct {
EntryID string // UUID, plain (never encrypted)
ProjectID string // UUID, plain
ParentID string // UUID, plain (empty = project root)
Type string // structural kind (see 4.2)
Depth int // 0=project, 1=workstream, 2=list, 3=request/answer
SearchKey string // blind index: HMAC-SHA256(projectKey, primary lookup), 128-bit hex
SearchKey2 string // blind index: HMAC-SHA256(projectKey, secondary lookup), 128-bit hex
Summary string // packed: structural/navigational ONLY — no content
Data []byte // packed: all content, metadata, routing, status
Stage string // plain: "pre_dataroom" | "dataroom" | "closed"
Version int // optimistic locking — increment on every write
KeyVersion int // encryption key version — for key rotation
DeletedAt *int64 // soft delete timestamp (null = active)
DeletedBy *string // who deleted
CreatedAt int64 // unix ms, plain
UpdatedAt int64 // unix ms, plain
CreatedBy string // user ID, plain
}
```
**Rule:** Summary is navigational only. Never put content in Summary. LLMs and MCP tools read Data.
**Optimistic Locking:** All updates must include `WHERE version = ?`. Increment version on success. Return conflict error if version mismatch.
**Soft Delete:** Entries are never hard-deleted. Set `deleted_at` and `deleted_by`. Queries exclude deleted entries by default. Admins can view/restore deleted entries.
### 4.2 Entry Types (Type field)
| Type | Depth | Description |
|----------------|-------|------------------------------------------|
| `project` | 0 | Top-level container |
| `workstream` | 1 | RBAC anchor (Finance, Legal, IT...) |
| `request_list` | 2 | Named collection of requests |
| `request` | 3 | A single request item |
| `answer` | 3 | A response to one or more requests |
| `comment` | N+1 | Threaded comment on any entry (parent depth + 1) |
### 4.3 Answer → Request Links (many-to-many)
One answer can satisfy N requests. When an answer is published, all linked requests are notified — broadcast to all requesting parties who have access.
```sql
CREATE TABLE answer_links (
answer_id TEXT NOT NULL REFERENCES entries(entry_id),
request_id TEXT NOT NULL REFERENCES entries(entry_id),
linked_by TEXT NOT NULL,
linked_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'confirmed' | 'rejected'
ai_score REAL,
reviewed_by TEXT,
reviewed_at INTEGER,
reject_reason TEXT,
PRIMARY KEY (answer_id, request_id)
);
```
**Broadcasts Table** — ensures idempotent notification delivery:
```sql
CREATE TABLE broadcasts (
id TEXT PRIMARY KEY,
answer_id TEXT NOT NULL REFERENCES entries(entry_id),
request_id TEXT NOT NULL REFERENCES entries(entry_id),
recipient_id TEXT NOT NULL REFERENCES users(id),
sent_at INTEGER NOT NULL,
UNIQUE(answer_id, request_id, recipient_id)
);
```
Before sending a broadcast notification, check the UNIQUE constraint. If exists, skip. This prevents duplicate notifications during race conditions or retries.
### 4.4 Entry Data (JSON inside packed blob)
For `request`:
```json
{
"title": "Provide audited financials FY2024",
"body": "...",
"priority": "high|normal|low",
"due_date": "2026-03-15",
"assigned_to": ["user_id"],
"status": "open|assigned|answered|vetted|published|closed",
"ref": "FIN-042",
"routing_chain": []
}
```
For `answer`:
```json
{
"title": "...",
"body": "...",
"files": ["object_id"],
"status": "draft|submitted|approved|rejected|published",
"rejection_reason": "...",
"broadcast_to": "linked_requesters|all_workstream|all_dataroom"
}
```
**broadcast_to Semantics:**
| Value | Who receives notification | When to use |
|-------|---------------------------|-------------|
| `linked_requesters` | Only buyers whose requests are linked via `answer_links` with `status='confirmed'` | Default. Targeted response to specific questions. |
| `all_workstream` | All buyers with access to this workstream | Answer has broad relevance within domain (e.g., key contract affecting all Legal requests). |
| `all_dataroom` | All buyers with any data room access | Major disclosure affecting entire deal (e.g., material adverse change). |
IB admin sets `broadcast_to` during publish. Default is `linked_requesters`. Changing to broader scope requires explicit confirmation ("This will notify N additional parties").
---
## 5. RBAC
### 5.1 Roles
| Role | Scope | Permissions | MFA Required |
|-----------------|----------------|----------------------------------------------------------|--------------|
| `ib_admin` | Project | Full control, all workstreams | **Yes** |
| `ib_member` | Workstream(s) | Manage requests + vet answers in assigned workstreams | **Yes** |
| `seller_admin` | Project | See all requests directed at seller, manage seller team | Recommended |
| `seller_member` | Workstream(s) | Answer requests in assigned workstreams | Recommended |
| `buyer_admin` | Project | Manage buyer team, see data room | Recommended |
| `buyer_member` | Workstream(s) | Submit requests, view published data room answers | No |
| `observer` | Workstream(s) | Read-only, no submission | No |
**Observer Visibility:** Observers see published answers only. They cannot see `entry_events` (workflow thread), `routing_chain` (internal forwarding), or draft/rejected answers. For auditor access requiring full visibility, use `ib_member` with read-only `ops`.
### 5.2 Access Table
```sql
CREATE TABLE access (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
workstream_id TEXT, -- null = all workstreams in this project
user_id TEXT NOT NULL,
role TEXT NOT NULL,
ops TEXT NOT NULL, -- "r", "rw", "rwdm"
can_grant INTEGER NOT NULL DEFAULT 0, -- 1 = can grant roles at or below own level
granted_by TEXT NOT NULL,
granted_at INTEGER NOT NULL,
revoked_at INTEGER, -- soft revocation timestamp
revoked_by TEXT
);
```
**RBAC anchor:** The `workstream` entry (depth 1) is the access root. Every operation walks up to the workstream to resolve permissions.
### 5.3 Role Hierarchy & Grant Rules
```go
var RoleHierarchy = map[string]int{
"ib_admin": 100,
"ib_member": 80,
"seller_admin": 70,
"seller_member": 50,
"buyer_admin": 40,
"buyer_member": 30,
"observer": 10,
}
```
**Grant Rules:**
- User can only grant access if `can_grant = 1` in their access record
- User can only grant roles with hierarchy value ≤ their own role
- User can only grant access to workstreams they have access to
- `ib_admin` can always grant any role (implicit `can_grant = 1`)
**Revoke Rules:**
- User can revoke access they granted (`granted_by = self`)
- `ib_admin` can revoke any access in their project
- `*_admin` roles can revoke access for their organization type
- Revocation sets `revoked_at` and `revoked_by` (soft revoke)
- Active sessions are immediately invalidated on revocation (see Section 18)
### 5.4 The Single Throat
Three choke points. No exceptions. Not even "just this once."
```
All Reads → EntryRead(actorID, projectID, filter) → CheckAccess → query
All Writes → EntryWrite(actorID, entries...) → CheckAccess → save
All Deletes → EntryDelete(actorID, projectID, filter) → CheckAccess → soft delete
Object I/O → ObjectRead/ObjectWrite/ObjectDelete → CheckAccess → storage
```
No handler ever touches the DB directly. No raw SQL outside lib/dbcore.go.
### 5.5 Data Room Visibility
`Stage = "pre_dataroom"` entries are invisible to buyer roles — not filtered in the UI, invisible at the DB layer via CheckAccess. Buyers cannot see a question exists, let alone its answer, until the IB publishes it to the data room.
---
## 6. Security
### 6.1 Encryption & Key Management
**FIPS 140-3 Compliance:** All builds must use BoringCrypto:
```bash
GOEXPERIMENT=boringcrypto go build ./...
```
Go stdlib `crypto/aes` alone is NOT FIPS 140-3 validated. BoringCrypto provides the validated module.
**Encryption Scheme:**
All string content fields use Pack / Unpack:
```
Pack: raw string → zstd compress → AES-256-GCM encrypt → []byte
Unpack: []byte → decrypt → decompress → string
```
**Key Derivation (HKDF-SHA256):**
```go
func DeriveProjectKey(master []byte, projectID string) []byte {
return hkdf.Expand(sha256.New, master, []byte("dealspace-project-"+projectID), 32)
}
```
**Field Encryption Strategy:**
| Field | Method | Purpose |
|-------|--------|---------|
| SearchKey, SearchKey2 | **Blind Index** | Indexed lookups without exposing plaintext |
| Summary, Data | AES-256-GCM (random nonce) | Content protection |
| IDs, integers, Stage | Plain text | Structural, never sensitive |
**Blind Index Implementation:**
SearchKey and SearchKey2 use HMAC-SHA256 truncated to 128 bits, stored as hex:
```go
func BlindIndex(projectKey []byte, plaintext string) string {
h := hmac.New(sha256.New, projectKey)
h.Write([]byte(plaintext))
sum := h.Sum(nil)[:16] // truncate to 128 bits
return hex.EncodeToString(sum)
}
```
This allows indexed lookups (`WHERE search_key = ?`) without deterministic encryption, which leaks information via frequency analysis.
**Key Rotation:**
Each entry has `key_version` indicating which key version was used to encrypt it.
```go
// On read: check key_version, use appropriate key
// On write: always use current key version, set key_version field
// Rotation: background job re-encrypts entries with old key_version
```
Rotation procedure:
1. Generate new master key, increment global key version
2. Background job: `SELECT entry_id FROM entries WHERE key_version < current`
3. For each: decrypt with old key, re-encrypt with new key, update key_version
4. After all migrated: old key can be archived (keep for N days, then destroy)
### 6.2 File Protection Pipeline
Files are never served raw. Every file goes through the protection pipeline at serve time. The stored file is always the clean original.
| Type | Protection |
|---------------|------------------------------------------------------------------|
| PDF | Dynamic watermark (user + timestamp + org) rendered per-request |
| Word (.docx) | Watermark injected into document XML before serve |
| Excel (.xlsx) | Sheet protection + watermark header row injected before serve |
| Images | Watermark text burned into pixel data per-request |
| Video | Watermark overlay via ffmpeg, served as stream |
| Other | Encrypted download only, no preview |
Watermark content (configurable per project):
```
{user_name} · {org_name} · {datetime} · CONFIDENTIAL
```
Watermarks are generated at serve time. Parameters are project-level config, not hardcoded.
### 6.3 Storage Pricing (Competitive Advantage)
Files stored compressed + encrypted. No per-MB extortion. Competitors charge up to $20/MB for "secure storage." We store at actual cost. This is a direct and easy competitive win for Misha.
### 6.4 Audit Log
Every access grant change, file download, status transition — logged.
```sql
CREATE TABLE audit (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
action TEXT NOT NULL, -- packed
target_id TEXT,
details TEXT, -- packed
ip TEXT,
ts INTEGER NOT NULL
);
```
---
## 7. Object Store
```go
type ObjectStore interface {
Write(projectID string, data []byte) (objectID string, error)
Read(projectID string, objectID string) ([]byte, error)
Delete(projectID string, objectID string) error
Exists(projectID string, objectID string) bool
}
```
**Object ID Calculation:**
```go
// 1. Encrypt data with project key
ciphertext := Encrypt(projectKey, plaintext)
// 2. ObjectID = SHA-256 of ciphertext
objectID := sha256Hex(ciphertext)
// 3. Store ciphertext at objectID path
```
**Deduplication Scope:** Per-project keys mean identical files uploaded to different projects produce different ciphertext, thus different ObjectIDs. **Deduplication is per-project only.** There is no cross-project dedup — this is a security feature, not a limitation.
Implementations: local filesystem (default), S3-compatible (plug-in). App code never knows the difference.
---
## 8. AI Matching Pipeline
When a buyer submits a request:
1. Embed request text (Fireworks nomic-embed-text-v1.5 — zero retention)
2. Cosine similarity vs all published answers in same workstream
3. Score ≥ 0.72 → suggest match, require human confirmation
4. Score < 0.72 route to IB/Seller for manual response
5. Human confirms `answer_links.status = 'confirmed'`, broadcast fires (idempotent via `broadcasts` table)
**Retroactive Matching:** When a new request is submitted, search includes all existing published answers. When a new answer is published, optionally re-run matching against pending requests in the workstream (configurable per project).
Private data never leaves Fireworks (zero retention policy). Same infra as inou.
---
## 9. Themes
Theme = CSS custom properties bundle. Zero hardcoded colors in templates. Every color references a CSS var.
```go
type Theme struct {
ID string
Name string
ProjectID string // null = system theme
Properties string // packed — CSS vars as JSON
}
```
Built-in: Light, Dark, High-contrast. Projects can define a custom theme (brand colors, logo). Users can override with personal preference. Theme switching = swap one class on `<html>`. No JavaScript framework required.
---
## 10. MCP Support
MCP server exposes deal context to AI tools. Follows inou's MCP pattern:
- All tools operate within `(actor, project)` context full RBAC enforced
- Read tools: list requests, query answers, check status, get workstream summary
- Write tools: AI-suggested routing (human confirmation required before any state change)
- Gating: AI cannot read pre-dataroom content without explicit unlock (mirrors inou's tier-1/tier-2 pattern)
Detailed MCP spec written separately after core schema is stable.
---
## 11. Go Implementation Rules
Non-negotiable. Violations require explicit discussion.
### 11.1 Package Structure
```
dealspace/
cmd/server/ main entry point, config loading
lib/
types.go All shared types — Entry, User, Project, Theme, etc.
dbcore.go EntryRead, EntryWrite, EntryDelete — the three choke points
rbac.go CheckAccess, permission resolution, role definitions
crypto.go Pack, Unpack, BlindIndex, ObjectEncrypt, ObjectDecrypt
store.go ObjectStore interface + implementations
watermark.go Per-type watermark injection (PDF, DOCX, XLSX, image, video)
embed.go AI embedding client + cosine similarity
notify.go Broadcast logic — answer published → notify requesters (idempotent)
session.go Session management — create, validate, revoke
mfa.go TOTP implementation (RFC 6238)
api/
middleware.go Auth, MFA check, logging, rate limiting, CORS
handlers.go Thin handlers only — extract input, call lib, return response
routes.go Route registration
portal/
templates/ HTML templates (no hardcoded colors)
static/ CSS (theme vars), JS (minimal)
mcp/
server.go MCP tool registration and dispatch
```
### 11.2 Handler Rules
- Handlers: extract input call lib return response. Nothing else.
- No SQL in handlers. Ever.
- No business logic in handlers. Ever.
- If two handlers share logic extract to lib.
- Error responses: one helper function, used everywhere. `{"error": "...", "code": "..."}`
### 11.3 DB Access Rules
- No `db.Query` / `db.Exec` outside `lib/dbcore.go`
- No raw SQL in any file outside `lib/dbcore.go`
- Entry access: `EntryRead`, `EntryWrite`, `EntryDelete` only
- Object access: `ObjectRead`, `ObjectWrite`, `ObjectDelete` only
- User/project/access operations: dedicated functions in dbcore, never inline SQL
- **All updates require version check:** `WHERE version = ? ... SET version = version + 1`
### 11.4 Naming Conventions
- RBAC-enforced functions: exported, full name (`EntryRead`, `EntryWrite`)
- System-only bypass: unexported, explicit suffix (`entryReadSystem`)
- The distinction must be obvious from the name alone
---
## 12. UI Philosophy
- **Project = select box at the top.** One line. You pick your project and you're in it.
- No project browser consuming 20% of screen real estate.
- Workstream tabs within a project: Finance | Legal | IT | HR | Operations
- Information hierarchy: Workstream Request List Request Answer
- Status visible without clicking in
- Competitor trap to avoid: adding features without removing complexity. Every new feature must justify its screen cost.
---
## 13. Out of Scope for MVP
- Email notifications (inbound channel ingestion is in scope; outbound notifications are not)
- Mobile app
- Third-party integrations (DocuSign, Salesforce)
- Public API
- Per-firm white-labeling
---
## 14. Retired Code
Previous attempt archived at `/home/johan/dev/dealroom-retired-20260228/`
**Carried forward:**
- AI matching concept (embeddings + cosine similarity at 0.72 threshold)
- Broadcast answer semantics
- Color palette
**Everything else starts fresh.**
---
## 15. Schema Change Checklist
When modifying the data model:
1. Update this SPEC.md first
2. Update `lib/types.go`
3. Update `lib/dbcore.go`
4. Update `lib/rbac.go` if access model changes
5. Update migration files
6. Update MCP tools if query patterns changed
7. No exceptions to this order
---
## 16. Workflow & Task Model
### 16.1 The Core Insight
Most users are **workers, not deal managers**. When the accountant logs in they see their task inbox not a deal room, not workstream dashboards, not buyer activity. Just: what do I need to do today.
The big picture (deal progress, buyer activity, request completion %) is the IB admin's view. Role determines UI surface entirely. Same platform, completely different experience.
### 16.2 The Routing Chain
Tasks don't just get assigned they have a return path. Every forward creates an obligation to return.
```
Buyer → IB analyst → CFO → accountant
↓ (done)
Buyer ← IB analyst ← CFO ←──┘
```
Each hop knows where it came from and where it goes back when done. The IB analyst sees "waiting on CFO" the buyer sees nothing until the answer is published. Internal routing is invisible to external parties.
### 16.3 Routing Chain Data Structure & Algorithm
**Entry Fields (plain, indexed):**
| Field | Purpose |
|----------------|---------------------------------------------------------------|
| `assignee_id` | Who has it RIGHT NOW powers the personal task inbox |
| `return_to_id` | Who it goes back to when done |
| `origin_id` | The ultimate requestor (buyer) who triggered the chain |
**routing_chain (in Data)** a **stack** of hops:
```go
type RoutingHop struct {
ActorID string // who received the task at this hop
ReturnToID string // who to return to when this actor completes
Action string // "forward" | "delegate" | "escalate"
Timestamp int64
Reason string // optional note
}
// routing_chain is []RoutingHop stored in Data JSON
```
**Chain Traversal Algorithm:**
When current assignee marks task as "done":
```go
func CompleteHop(entry *Entry) {
chain := entry.Data.RoutingChain
if len(chain) == 0 {
// End of chain — task is complete
entry.AssigneeID = ""
entry.Status = "completed"
return
}
// Pop the top of stack
currentHop := chain[len(chain)-1]
entry.Data.RoutingChain = chain[:len(chain)-1]
// Set new assignee to the return_to from this hop
entry.AssigneeID = currentHop.ReturnToID
// Update return_to for the new assignee
if len(entry.Data.RoutingChain) > 0 {
entry.ReturnToID = entry.Data.RoutingChain[len(entry.Data.RoutingChain)-1].ReturnToID
} else {
entry.ReturnToID = "" // last hop, no return
}
}
```
**Forward Operation:**
```go
func ForwardTask(entry *Entry, fromUserID, toUserID string, reason string) {
// Push new hop onto stack
hop := RoutingHop{
ActorID: toUserID,
ReturnToID: fromUserID,
Action: "forward",
Timestamp: time.Now().UnixMilli(),
Reason: reason,
}
entry.Data.RoutingChain = append(entry.Data.RoutingChain, hop)
// Update plain fields
entry.AssigneeID = toUserID
entry.ReturnToID = fromUserID
}
```
### 16.4 Channel Ingestion & Authentication
**entry_events Table** the thread behind every entry:
```sql
CREATE TABLE entry_events (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL REFERENCES entries(entry_id),
actor_id TEXT NOT NULL,
channel TEXT NOT NULL, -- "web" | "email" | "slack" | "teams"
action TEXT NOT NULL, -- packed: "message"|"upload"|"forward"|"approve"|"reject"|"publish"
data TEXT NOT NULL, -- packed: message body, file refs, status transition details
ts INTEGER NOT NULL
);
CREATE INDEX idx_events_entry ON entry_events(entry_id);
CREATE INDEX idx_events_actor ON entry_events(actor_id);
```
**channel_threads Table** maps external thread IDs to entries:
```sql
CREATE TABLE channel_threads (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL REFERENCES entries(entry_id),
channel TEXT NOT NULL, -- "email" | "slack" | "teams"
thread_id TEXT NOT NULL, -- Message-ID, thread_ts, conversationId
project_id TEXT NOT NULL,
UNIQUE(channel, thread_id)
);
```
**channel_participants Table** authorized participants per entry:
```sql
CREATE TABLE channel_participants (
entry_id TEXT NOT NULL REFERENCES entries(entry_id),
channel TEXT NOT NULL, -- "email" | "slack" | "teams"
external_id TEXT NOT NULL, -- email address, slack user ID, teams user ID
user_id TEXT, -- linked internal user (null if unverified)
verified INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL,
PRIMARY KEY (entry_id, channel, external_id)
);
```
**Inbound Message Flow:**
1. Parse sender identity from message (email address, Slack user ID, etc.)
2. Look up `channel_threads` by `(channel, thread_id)` to find entry
3. Look up `channel_participants` for `(entry_id, channel, external_id)`
4. **If not found OR not verified:**
- Create `entry_event` with `action = "quarantined"`
- Notify IB admin: "Message from unknown sender on [entry] review required"
- Do NOT advance routing chain
5. **If verified:**
- Create `entry_event` with appropriate action
- Trigger routing chain progression if applicable
**Email Authentication:**
- Require DKIM pass OR quarantine
- Store DKIM verification result in `entry_events.data`
- SPF/DMARC failures quarantine with warning
### 16.5 Final Table List
```
entries — the tree + workflow state (assignee_id, return_to_id, origin_id, version, key_version, deleted_at/by)
entry_events — the thread / workflow history per entry
channel_threads — external channel routing (email/Slack/Teams → entry)
channel_participants — authorized senders per entry+channel
answer_links — answer ↔ request, ai_score, status, reviewed_by/at, reject_reason
broadcasts — idempotent broadcast tracking (answer_id, request_id, recipient_id, sent_at)
users — accounts + auth + MFA secrets
access — RBAC: (user, project, workstream, role, ops, can_grant, revoked_at/by)
sessions — active sessions (opaque tokens, expiry, user binding)
delegations — vacation/unavailability coverage
embeddings — (entry_id, vector BLOB) for AI matching
audit — security events: grants, downloads, logins, key transitions
```
---
## 17. Serving Architecture
```
Internet → Caddy (TLS termination, port 443) → Dealspace binary (port 8080)
```
Caddy is the only thing that faces the internet. It handles TLS (Let's Encrypt, auto-renew) and proxies everything to the Dealspace Go binary on localhost:8080.
The Go binary serves:
- `/` marketing website (static HTML/CSS/JS from embedded fs or disk)
- `/app` deal room UI (authenticated, role-based)
- `/api` REST API
- `/mcp` MCP server endpoint
No nginx. No Apache. One Caddy config, one binary, one systemd unit.
### Embedding static files
Website files (`website/`) are embedded into the binary at build time using Go's `embed.FS`. Zero separate file deployment `make deploy` ships one binary and it's done.
### Caddy config (/etc/caddy/Caddyfile on 82.24.174.112)
```
muskepo.com, www.muskepo.com {
reverse_proxy localhost:8080
encode gzip
}
```
### Deployment target
- Server: 82.24.174.112 (Shannon/Dealspace VPS)
- Binary: /opt/dealspace/bin/dealspace
- Data: /opt/dealspace/data/
- Store: /opt/dealspace/store/
- Config: /opt/dealspace/.env
---
## 18. Authentication & Session Management
### 18.1 Multi-Factor Authentication (MFA)
**TOTP Implementation (RFC 6238):**
```go
// User setup
type UserMFA struct {
UserID string
TOTPSecret []byte // 160-bit secret, encrypted at rest
TOTPEnabled bool
BackupCodes []string // 10 single-use codes, hashed
EnabledAt int64
}
// TOTP parameters (RFC 6238 defaults)
const (
TOTPDigits = 6
TOTPPeriod = 30 // seconds
TOTPAlgorithm = "SHA1"
)
```
**MFA Requirements by Role:**
| Role | MFA Requirement |
|------|-----------------|
| `ib_admin` | **Mandatory** cannot access without MFA enabled |
| `ib_member` | **Mandatory** cannot access without MFA enabled |
| `seller_admin` | Strongly recommended, can be enforced per-project |
| `seller_member` | Optional, project policy |
| `buyer_admin` | Optional, project policy |
| `buyer_member` | Optional |
| `observer` | Optional |
**Enforcement:** `ib_admin` and `ib_member` users cannot complete login without MFA configured. If MFA is not set up, redirect to mandatory setup flow after password verification.
### 18.2 Session Management
**No JWT for sessions.** Use opaque tokens with server-side state.
```sql
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- opaque token (32 bytes, hex encoded)
user_id TEXT NOT NULL REFERENCES users(id),
access_token TEXT NOT NULL, -- short-lived (1 hour)
refresh_token TEXT NOT NULL, -- longer-lived (7 days)
access_expires_at INTEGER NOT NULL,
refresh_expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
ip TEXT,
user_agent TEXT,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_access ON sessions(access_token);
```
**Session Rules:**
1. **Single active session per user:** Creating a new session revokes all existing sessions for that user
2. **Token lifetimes:**
- Access token: 1 hour (used for API requests)
- Refresh token: 7 days (used to obtain new access token)
3. **Immediate revocation:** On access change (role change, revocation, MFA disable), all sessions for that user are immediately revoked
4. **Refresh flow:** Client presents refresh token server validates issues new access token (same refresh token until it expires)
5. **Activity tracking:** `last_used_at` updated on each access token use
**Token Format:**
```go
func GenerateToken() string {
b := make([]byte, 32)
crypto.Rand.Read(b)
return hex.EncodeToString(b)
}
```
Tokens are opaque no embedded claims, no client-side validation. All validation hits the sessions table.
### 18.3 Delegations
For vacation/unavailability coverage:
```sql
CREATE TABLE delegations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
delegate_id TEXT NOT NULL REFERENCES users(id),
project_id TEXT, -- null = all projects user has access to
starts_at INTEGER NOT NULL,
ends_at INTEGER, -- null = indefinite
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(user_id, delegate_id, project_id)
);
CREATE INDEX idx_delegations_user ON delegations(user_id);
CREATE INDEX idx_delegations_delegate ON delegations(delegate_id);
```
**Delegation Rules:**
- Tasks assigned to `user_id` during active delegation period also appear in `delegate_id`'s inbox
- Delegate can act on behalf of user (forward, respond, approve within their permission level)
- Actions by delegate are logged with both `actor_id = delegate_id` and `on_behalf_of = user_id`
- User can create their own delegations; admins can create delegations for their team
---
*This document is the ground truth. If code disagrees with the spec, the code is wrong.*