887 lines
32 KiB
Markdown
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.*
|