# 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

---

---

## 19. Organizations

Organizations represent companies/firms participating in deals. They exist at the platform level and can be linked to multiple deals.

### 19.1 Data Model

Organizations are entries with `type: "organization"`, `depth: 0`, no parent. They live at platform level.

**OrgData (packed into entry.Data):**
```go
type OrgData struct {
    Name         string   `json:"name"`
    Domains      []string `json:"domains"`      // required, e.g. ["kaseya.com","datto.com"]
    Role         string   `json:"role"`         // seller | buyer | ib | advisor
    Website      string   `json:"website,omitempty"`
    Description  string   `json:"description,omitempty"`
    ContactName  string   `json:"contact_name,omitempty"`
    ContactEmail string   `json:"contact_email,omitempty"`
}
```

A **deal_org** entry (`type: "deal_org"`, `depth: 1`, `parent = project entry_id`) links an organization into a specific deal:

```go
type DealOrgData struct {
    OrgID      string `json:"org_id"`      // entry_id of the organization
    Role       string `json:"role"`        // seller | buyer | ib | advisor
    DomainLock bool   `json:"domain_lock"` // if true, enforce domain check on invites
}
```

### 19.2 Domain Validation (ELIGIBILITY ONLY)

**CRITICAL SECURITY BOUNDARY:**

Organization domains determine **invite eligibility only**. They answer: "Is this email address allowed to be invited to this role in this deal?"

**They do NOT grant any access whatsoever.**

Example: PwC (@pwc.com) is a buyer in Project James AND a seller in Project Tanya. A PwC partner invited to Project James has **ZERO visibility** into Project Tanya, even though PwC is listed there.

**Rules:**
1. `ListProjects` / `GET /api/projects` returns ONLY projects where the actor has an explicit row in the `access` table. NEVER derive project visibility from deal_org or org membership.
2. `CheckAccess` is the single gate — it checks the `access` table, not org membership.
3. The `deal_org` entry is queried ONLY during invite creation to validate the email domain. After that, it has no effect on access.
4. `super_admin` bypasses this (as already implemented) — but no other role does.

**ValidateOrgDomain function:**
- Called during invite creation only
- Finds all `deal_org` entries for the project where role matches
- For each with `DomainLock=true`: checks if email ends with @domain for ANY domain in org.Domains
- If no match found → returns `ErrDomainMismatch`
- If `DomainLock=false` or no matching deal_orgs → passes through

### 19.3 Multi-Domain Support

Organizations can have multiple domains (e.g., post-acquisition companies). All domains are stored in the `Domains` array. Domain validation passes if the email matches **any** of the org's domains.

Empty domains are not allowed. Domains are normalized to lowercase on create/update.

### 19.4 API Endpoints

**Organizations (platform level):**
- `GET /api/orgs` — list all orgs (super_admin sees all; others see orgs linked to their deals)
- `POST /api/orgs` — create org (ib_admin or super_admin only). Domains required.
- `GET /api/orgs/{orgID}` — get org
- `PATCH /api/orgs/{orgID}` — update org

**Deal orgs (per project):**
- `GET /api/projects/{projectID}/orgs` — list orgs in this deal
- `POST /api/projects/{projectID}/orgs` — add org to deal `{"org_id":"...","role":"seller","domain_lock":true}`
- `DELETE /api/projects/{projectID}/orgs/{dealOrgID}` — remove org from deal


---

*This document is the ground truth. If code disagrees with the spec, the code is wrong.*
