The full system: the machine in the cabinet, the code that runs on it, the DIFC and DFSA rules it meets, the twelve jobs it does with an adviser signing each one, and the training that lets your own team run it. No client name, document or number ever reaches the internet.
Same system, same rules. The only difference is who builds it.
A five to ten person DIFC office needs one locked cabinet, one safe and one desk that stays on the normal internet. Nothing else changes for visitors or clients.
| Size | Machine | Fits | Model that runs well | Planning range |
|---|---|---|---|---|
| Small | Mac Studio, 192 GB+ unified memory, or a workstation with one 48 GB professional GPU | 5 to 10 staff, documents and drafts | gpt-oss:120b or qwen3:32b | AED 20k to 50k |
| Medium | Rack server with one or two 80 GB GPUs, 256 GB RAM | 10 to 40 staff, many documents a day | gpt-oss:120b with vLLM | AED 150k and up |
| Both | UPS, managed switch, two 4 TB encrypted USB drives, small safe | Every build | n/a | AED 4k to 8k |
Staff open desk.office.lan in a browser. Only the HTTPS gateway faces the office network. The model and the database sit on an internal Docker network with no route out, not even to the LAN.
docker-compose.yml, in the kit. The names in the boxes match the service names in that file.The one rule everything hangs on: client data never touches the internet. Anything with a client in it lives in World 1. Public work lives in World 2. Nothing crosses except on an approved encrypted drive.
| Rule | What it asks | What this design does | Where in the build | Read more |
|---|---|---|---|---|
| DIFC Data Protection Law No. 5 of 2020 | The firm is the controller: lawful basis, security measures, data subject rights, breach reporting | Data never leaves the firm. Access logged. Retention set per client | 01-setup-server.sh, retain_until column | DIFC Commissioner |
| DIFC DP Regulation 10 (autonomous and semi-autonomous systems) | Tell clients an AI system is used and name it. Keep a human in control. Keep records. High-risk uses may need an Autonomous Systems Officer and Commissioner approval | AI drafts, adviser signs. A named line in the client agreement. The audit log is the record. Onboarding assessed before go-live | jobs table status, ai_audit_log | Regulation text · Clyde & Co |
| DFSA GEN 5.3 systems and controls, outsourcing 5.3.21 and 5.3.22 | Written agreement with any supplier, risk assessment, contingency and exit plan, firm stays responsible | Written scope and NDA, all logins handed back, runbook and Track B training so the firm runs it alone | README.md runbook | DFSA GEN module |
| DFSA record keeping | Client files, suitability reports and logs kept and retrievable. Six years is the period cited for governance records | Server plus two offline backups kept 72 months, restore tested quarterly | ops/backup.sh, ops/restore-test.sh | DFSA GEN module |
| DFSA AI Survey 2025 expectations | Written AI policy, clear accountability, an AI register. 26% of firms using AI in critical areas had no governance | Policy, register and named owner written in phase 1, before any AI runs | claude/CLAUDE.md, register template | DFSA survey |
app/jobs.py.Every file below is in the server kit. Tap a file name to open the code. It is sample code: your IT support and compliance officer review it before live use. Track progress on the development checklist (PDF).
Ubuntu Server 24.04 LTS, full-disk encryption ticked in the installer. The script creates three roles, the folder tree, an idle lock and an audit watch on every client file.
#!/usr/bin/env bash
# World 1 server setup. Ubuntu Server 24.04 LTS, run once as root.
# Disk encryption (LUKS) is chosen in the Ubuntu installer, before this script.
set -euo pipefail
# 1. Roles: advisers see their own clients, compliance sees all, ops runs the box
groupadd -f advisers; groupadd -f compliance; groupadd -f ops
# 2. Folder tree. One folder per client is created later by ops/new-client.sh
install -d -m 0750 -g compliance /srv/wealth/{clients,models,inbox,audit,exports}
install -d -m 0770 -g advisers /srv/wealth/inbox
chmod 1770 /srv/wealth/inbox # sticky: staff cannot delete each other's drops
# 3. Packages that work offline once installed
apt-get update
apt-get install -y docker.io docker-compose-v2 acl restic cryptsetup jq ufw postgresql-client
systemctl enable --now docker
# 4. Auto-lock idle sessions and log every login
echo 'TMOUT=900; readonly TMOUT; export TMOUT' > /etc/profile.d/idle-lock.sh
systemctl enable --now auditd 2>/dev/null || apt-get install -y auditd
auditctl -w /srv/wealth/clients -p rwa -k client_files # every open and edit, logged
# 5. Firewall: run 02-firewall.sh next, then unplug the WAN cable for good
echo "Done. Next: ./02-firewall.sh"Deny all outgoing traffic, then block containers in the DOCKER-USER chain, since Docker skips ufw. The last line must print OK: no internet. Screenshot it for the compliance file, then pull the WAN cable.
#!/usr/bin/env bash
# Second layer behind the physical air gap. The office LAN is 192.168.50.0/24.
set -euo pipefail
LAN=192.168.50.0/24
ufw --force reset
ufw default deny incoming
ufw default deny outgoing
ufw allow from $LAN to any port 443 proto tcp # the dashboard, over HTTPS
ufw allow from $LAN to any port 22 proto tcp # ops only, key login
ufw allow out to $LAN
ufw --force enable
# Docker writes its own rules and skips ufw. Block container traffic leaving the LAN.
iptables -I DOCKER-USER -d $LAN -j RETURN
iptables -I DOCKER-USER 2 -d 172.16.0.0/12 -j RETURN
iptables -A DOCKER-USER -j DROP
apt-get install -y iptables-persistent && netfilter-persistent save
# Proof for the compliance file: this must FAIL
curl -s --max-time 5 https://example.com && echo "WARNING: internet reachable" || echo "OK: no internet"On the internet desk: ollama pull gpt-oss:120b, docker compose build, then docker save every image to the encrypted transfer drive. On the server: docker load and copy the model folder to /srv/wealth/models. Ops logs the drive in and out.
# Copy to .env and change every value. Never commit .env.
POSTGRES_PASSWORD=change-me-long-random
MODEL=gpt-oss:120b # small office: gpt-oss:20b or qwen3:32b
OLLAMA_URL=http://ollama:11434
# Mac Studio: run Ollama natively, then use
# OLLAMA_URL=http://host.docker.internal:11434docker compose up -d. The model and database live on the core network marked internal: true. Only Caddy publishes a port, and only to the office LAN.
# World 1 stack. Start with: docker compose up -d
name: wealth-desk
networks:
core: # model and database. No route out, not even to the LAN
internal: true
lan: # only Caddy publishes a port to the office network
driver: bridge
services:
ollama: # the AI model, on the firm's own GPU
image: ollama/ollama:latest
networks: [core]
volumes: ["/srv/wealth/models:/root/.ollama"]
environment: ["OLLAMA_KEEP_ALIVE=24h"]
deploy:
resources:
reservations:
devices: [{driver: nvidia, count: all, capabilities: ["gpu"]}]
restart: unless-stopped
db: # client register, review queue, audit log
image: postgres:16
networks: [core]
environment:
POSTGRES_DB: wealth
POSTGRES_USER: wealth
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/wealth/db:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
restart: unless-stopped
app: # the workflow dashboard (drop, draft, review, approve, file)
build: ./app
networks: [core, lan]
environment:
DATABASE_URL: postgresql://wealth:${POSTGRES_PASSWORD}@db:5432/wealth
OLLAMA_URL: ${OLLAMA_URL}
MODEL: ${MODEL}
CLIENT_ROOT: /srv/wealth/clients
volumes:
- /srv/wealth/clients:/srv/wealth/clients
- /srv/wealth/inbox:/srv/wealth/inbox
depends_on: [db, ollama]
restart: unless-stopped
chat: # Open WebUI: a private ChatGPT-style screen for staff
image: ghcr.io/open-webui/open-webui:main
networks: [core, lan]
environment:
OLLAMA_BASE_URL: ${OLLAMA_URL}
ENABLE_SIGNUP: "false"
ENABLE_WEB_SEARCH: "false"
OFFLINE_MODE: "true"
volumes: ["/srv/wealth/webui:/app/backend/data"]
restart: unless-stopped
caddy: # HTTPS on the office LAN, staff login, passes the user name on
image: caddy:2
networks: [lan]
ports: ["443:443"]
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "/srv/wealth/caddy:/data"]
restart: unless-stopped# Internal certificate. Install Caddy's root cert on office PCs once.
{
local_certs
}
desk.office.lan {
basic_auth {
# caddy hash-password --plaintext 'their-password'
sara $2a$14$REPLACE_WITH_HASH
omar $2a$14$REPLACE_WITH_HASH
comply $2a$14$REPLACE_WITH_HASH
}
reverse_proxy app:8000 {
header_up X-User {http.auth.user.id}
}
}
chat.office.lan {
reverse_proxy chat:8080
}Loaded on first start. The audit log refuses updates and deletes, so nobody can tidy it later. Each client gets a retain_until date six years from opening.
-- Who may see what
CREATE TABLE staff (
username text PRIMARY KEY,
full_name text NOT NULL,
role text NOT NULL CHECK (role IN ('adviser','compliance','ops'))
);
CREATE TABLE clients (
client_id text PRIMARY KEY, -- e.g. C-0042, never the name
adviser text NOT NULL REFERENCES staff,
stage text NOT NULL DEFAULT 'enquiry', -- enquiry, kyc, suitability, active, review
risk_level text,
opened_on date NOT NULL DEFAULT current_date,
retain_until date GENERATED ALWAYS AS (opened_on + interval '6 years') STORED
);
-- Every AI job waits here until a person approves it
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
job_type text NOT NULL,
client_id text REFERENCES clients,
source_file text NOT NULL,
draft text,
status text NOT NULL DEFAULT 'drafted' CHECK (status IN ('drafted','approved','rejected')),
created_by text NOT NULL REFERENCES staff,
approved_by text REFERENCES staff,
created_at timestamptz NOT NULL DEFAULT now(),
decided_at timestamptz
);
CREATE TABLE tasks ( -- staff follow-up tracker
id bigserial PRIMARY KEY,
client_id text REFERENCES clients,
owner text NOT NULL REFERENCES staff,
action text NOT NULL,
due_on date NOT NULL,
done boolean NOT NULL DEFAULT false
);
-- The record the regulator reads. Append only.
CREATE TABLE ai_audit_log (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
username text NOT NULL,
action text NOT NULL, -- draft, approve, reject, view
job_id bigint,
model text,
prompt_sha text,
output_sha text,
note text
);
REVOKE UPDATE, DELETE ON ai_audit_log FROM PUBLIC;
CREATE RULE no_update AS ON UPDATE TO ai_audit_log DO INSTEAD NOTHING;
CREATE RULE no_delete AS ON DELETE TO ai_audit_log DO INSTEAD NOTHING;About a hundred lines of Python. An adviser drops a file, the model drafts with page references, the draft waits in the queue, and only an approve call writes it into the client folder. Every step is hashed into the audit log. Advisers see their own clients. Compliance sees all.
"""Wealth desk: drop a file -> AI drafts -> review queue -> adviser approves -> filed + logged."""
import hashlib, os, pathlib, httpx, psycopg
from fastapi import FastAPI, Header, HTTPException, UploadFile, Form
from pypdf import PdfReader
from jobs import HOUSE_RULES, JOBS
DB = os.environ["DATABASE_URL"]
OLLAMA = os.environ["OLLAMA_URL"]
MODEL = os.environ["MODEL"]
ROOT = pathlib.Path(os.environ["CLIENT_ROOT"])
app = FastAPI(title="Wealth desk (World 1)")
def sha(t: str) -> str: return hashlib.sha256(t.encode()).hexdigest()
def log(cur, user, action, job_id=None, prompt="", output="", note=""):
cur.execute("INSERT INTO ai_audit_log(username,action,job_id,model,prompt_sha,output_sha,note)"
" VALUES (%s,%s,%s,%s,%s,%s,%s)",
(user, action, job_id, MODEL, sha(prompt), sha(output), note))
def role_of(cur, user):
row = cur.execute("SELECT role FROM staff WHERE username=%s", (user,)).fetchone()
if not row: raise HTTPException(403, "unknown user")
return row[0]
def may_see(cur, user, client_id):
if role_of(cur, user) == "compliance": return True
row = cur.execute("SELECT adviser FROM clients WHERE client_id=%s", (client_id,)).fetchone()
return bool(row) and row[0] == user
def read_text(f: UploadFile) -> str:
if f.filename.lower().endswith(".pdf"):
pages = PdfReader(f.file).pages
return "\n".join(f"[p.{i+1}]\n{p.extract_text() or ''}" for i, p in enumerate(pages))
return "[p.1]\n" + f.file.read().decode("utf-8", "replace")
@app.post("/jobs")
def new_job(job_type: str = Form(...), client_id: str = Form(...), file: UploadFile = None,
x_user: str = Header(...)):
if job_type not in JOBS: raise HTTPException(400, "unknown job")
with psycopg.connect(DB) as con, con.cursor() as cur:
if not may_see(cur, x_user, client_id): raise HTTPException(403, "not your client")
prompt = f"{HOUSE_RULES}\n\nTASK: {JOBS[job_type]}\n\nDOCUMENT:\n{read_text(file)}"
r = httpx.post(f"{OLLAMA}/api/generate", timeout=600,
json={"model": MODEL, "prompt": prompt, "stream": False,
"options": {"temperature": 0.1}})
draft = r.json()["response"]
job_id = cur.execute(
"INSERT INTO jobs(job_type,client_id,source_file,draft,created_by)"
" VALUES (%s,%s,%s,%s,%s) RETURNING id",
(job_type, client_id, file.filename, draft, x_user)).fetchone()[0]
log(cur, x_user, "draft", job_id, prompt, draft)
return {"job": job_id, "status": "drafted", "draft": draft}
@app.get("/queue")
def queue(x_user: str = Header(...)):
with psycopg.connect(DB) as con, con.cursor() as cur:
sql = ("SELECT j.id,j.job_type,j.client_id,j.created_by,j.created_at FROM jobs j "
"JOIN clients c USING(client_id) WHERE j.status='drafted'")
if role_of(cur, x_user) != "compliance":
sql += " AND c.adviser=%s"
rows = cur.execute(sql, (x_user,)).fetchall()
else:
rows = cur.execute(sql).fetchall()
log(cur, x_user, "view", note="queue")
return [dict(zip(["id", "type", "client", "by", "at"], r)) for r in rows]
@app.post("/jobs/{job_id}/{decision}")
def decide(job_id: int, decision: str, final_text: str = Form(""), x_user: str = Header(...)):
if decision not in ("approve", "reject"): raise HTTPException(400)
with psycopg.connect(DB) as con, con.cursor() as cur:
job = cur.execute("SELECT client_id,job_type,draft FROM jobs WHERE id=%s AND status='drafted'",
(job_id,)).fetchone()
if not job or not may_see(cur, x_user, job[0]): raise HTTPException(404)
text = final_text or job[2]
cur.execute("UPDATE jobs SET status=%s, approved_by=%s, decided_at=now() WHERE id=%s",
(decision + "d", x_user, job_id))
if decision == "approve": # filed into the client's own folder
out = ROOT / job[0] / job[1] / f"{job_id}-approved-by-{x_user}.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text)
log(cur, x_user, decision, job_id, output=text)
return {"job": job_id, "status": decision + "d"}# One instruction per workflow. The firm's compliance officer owns this file.
HOUSE_RULES = """You work for a DIFC-regulated wealth firm. Rules:
- Draft only. A named adviser approves everything you write.
- Never recommend a product, fund or security.
- Every figure carries its source as [p.N] from the page markers.
- If a field is not in the document, write MISSING. Never guess.
- Plain English. No promises about returns."""
JOBS = {
"kyc": "Extract: full name, date of birth, nationality, passport number and expiry, "
"residential address, source of wealth, source of funds, employer, annual income. "
"Return one line per field as FIELD: value [p.N]. List every MISSING field at the end.",
"meeting_prep": "Write a one-page meeting brief: who, goals on file, holdings vs model, "
"open actions, three questions to ask. Max 300 words.",
"file_note": "Turn these meeting notes into a file note: attendees, what was discussed, "
"what the client asked for, decisions, actions with owner and due date.",
"suitability": "Draft a suitability report from the fact-find and risk profile: client "
"objectives, risk level and why, how the model portfolio fits, risks, costs. "
"Leave the recommendation section as [ADVISER TO WRITE].",
"drift": "Compare holdings to the model weights. List every line outside +/-5 points "
"with current %, target %, and the gap. No trade suggestions.",
"research": "Summarise this report in one page. Every figure with [p.N]. End with "
"'What this does not say' in three bullets.",
"client_update": "Draft a short personal update email for this client from their numbers "
"and open actions. Warm, factual, under 180 words. No forecasts.",
"screening": "Apply the house screening rules below to each security. For each: PASS or "
"FAIL and the rule that decided it.",
}FROM python:3.12-slim
WORKDIR /app
# Build this image on the World 2 desk, then: docker save | load on the server
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]fastapi==0.115.*
uvicorn==0.32.*
psycopg[binary]==3.2.*
httpx==0.27.*
pypdf==5.*
python-multipart==0.0.*Six sub-folders, access for the named adviser and compliance only, and a matching row in the database. Client IDs only, never names, in folder names.
#!/usr/bin/env bash
# Usage: sudo ./new-client.sh C-0042 sara
# Creates the client folder. Only the named adviser and compliance get in.
set -euo pipefail
ID=$1; ADVISER=$2; D=/srv/wealth/clients/$ID
install -d -m 0700 "$D"/{kyc,fact-find,suitability,portfolio,correspondence,reviews}
setfacl -R -m u:$ADVISER:rwX -m g:compliance:rX "$D"
setfacl -R -d -m u:$ADVISER:rwX -d -m g:compliance:rX "$D"
psql "$DATABASE_URL" -c "INSERT INTO clients(client_id,adviser) VALUES ('$ID','$ADVISER')"
echo "Client $ID opened for $ADVISER"Two drives swap every Monday: one in the safe, one off site. 72 monthly snapshots cover six years. Once a quarter compliance picks a random client and ops restores it while both watch.
#!/usr/bin/env bash
# Nightly at 22:00 via cron. Two encrypted drives (A and B) swap every Monday:
# one in the office safe, one off-site.
set -euo pipefail
DRIVE=$(ls -d /media/backup-[AB] | head -1) # whichever drive is plugged in
export RESTIC_REPOSITORY=$DRIVE/restic
export RESTIC_PASSWORD_FILE=/root/.restic-pass # also sealed in the safe on paper
docker compose -f /opt/wealth-desk/docker-compose.yml exec -T db \
pg_dump -U wealth wealth > /srv/wealth/exports/db-$(date +%F).sql
restic backup /srv/wealth --exclude /srv/wealth/models --tag nightly
restic forget --keep-daily 14 --keep-weekly 52 --keep-monthly 72 --prune # 72 months = 6 years
restic check --read-data-subset=5%
echo "$(date -Is) backup ok to $DRIVE" >> /srv/wealth/audit/backup.log#!/usr/bin/env bash
# Quarterly. Compliance picks a random client, ops restores it, both sign the log.
set -euo pipefail
CLIENT=${1:?client id}
T=/tmp/restore-test-$(date +%F); rm -rf "$T"
restic restore latest --target "$T" --include "/srv/wealth/clients/$CLIENT"
find "$T" -type f | wc -l
echo "$(date -Is) restore test $CLIENT ok, files: $(find "$T" -type f | wc -l)" >> /srv/wealth/audit/restore.log
rm -rf "$T"Claude Code is used to build and test the workflows on sample data. Managed settings deny web access and client folders, and the hook logs every tool call. Users cannot switch these off. Details in the next section.
{
"permissions": {
"defaultMode": "default",
"disableBypassPermissionsMode": "disable",
"deny": [
"WebFetch",
"WebSearch",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(scp:*)",
"Bash(ssh:*)",
"Read(//srv/wealth/clients/**)",
"Edit(//srv/wealth/clients/**)"
]
},
"hooks": {
"PreToolUse": [
{ "matcher": "*",
"hooks": [ { "type": "command", "command": "/opt/wealth-desk/claude/audit-hook.sh" } ] }
]
}
}#!/usr/bin/env bash
# Runs before every Claude Code tool call. Logs it, and blocks client paths.
# Exit code 2 = blocked, and the reason goes back to Claude.
IN=$(cat)
TOOL=$(jq -r '.tool_name' <<<"$IN")
ARGS=$(jq -c '.tool_input' <<<"$IN")
echo "$(date -Is) $USER $TOOL $ARGS" >> /srv/wealth/audit/claude-code.log
if grep -q '/srv/wealth/clients' <<<"$ARGS"; then
echo "Blocked: client folders are off limits to the build tool. Use sample data." >&2
exit 2
fi
exit 0# Firm rules for Claude Code (build machine)
- You build and test workflows. You never see live client data.
- Test files live in ./samples and use made-up clients only (C-9001 to C-9099).
- Never write code that sends data outside 192.168.50.0/24.
- Every new workflow needs: an entry in app/jobs.py, a line in the AI register,
and a test on three sample files before the compliance officer sees it.
- Drafts only. Nothing in this system sends an email or places a trade.Claude Code builds and tests the workflows. Its controls are plain settings files the compliance officer can read, not promises in a sales deck.
WebFetch, WebSearch, curl, wget, scp, ssh, and any read or edit under /srv/wealth/clients.claude-code.log, and stops any call that names a client path./etc/claude-code/ on Linux or /Library/Application Support/ClaudeCode/ on macOS, owned by root. User settings cannot override it.CLAUDE.md carries the firm's rules: sample clients only, no outbound code, every workflow tested on three files and added to the register.Every job runs the same five steps. Only the input and the person who signs change.
app/jobs.py. "Today" times are industry ranges reported by Automaton (May 2026), a vendor, and are replaced by the firm's own pilot figures. Interactive version: azizsaif.com/wealth-demoFour of the twelve jobs need no AI model at the moment of use. Their rules were written in advance with Claude and frozen as code that runs inside the staff member's own browser. Nothing is uploaded, nothing is stored, and the page carries a browser-enforced block on every outgoing connection.
connect-src 'none': the browser itself refuses any upload, even from a bad scriptPrint this section and pin it next to every World 1 desk. It is the short version of the AI-use policy.
backup.log shows 7 nightsretain_until| What happened | First 10 minutes | Who |
|---|---|---|
| Client data pasted into a website | Screenshot, close the tab, tell compliance. Do not delete anything. Compliance assesses a breach notice to the DIFC Commissioner | Staff member, then compliance |
| A draft went to a client unapproved | Call the client, correct in writing, log it in the audit table as a note | Adviser, then compliance |
| Backup drive lost | Drives are encrypted, so record it. Rotate the restic password, take a fresh backup on a new drive | Ops |
| Server will not start | Work from paper files. Ops follows the runbook. Restore to spare hardware from the safe drive if needed | Ops |
Taught at your DIFC office on the firm's own server, using made-up sample clients. Each person leaves able to do their part without calling anyone.
-- 1. Who did what with AI in the last 30 days
SELECT username, action, count(*) FROM ai_audit_log
WHERE at > now() - interval '30 days' GROUP BY 1, 2 ORDER BY 1;
-- 2. Ten random approved jobs to read in full
SELECT id, job_type, client_id, created_by, approved_by, decided_at
FROM jobs WHERE status = 'approved'
ORDER BY random() LIMIT 10;
-- 3. Anything approved by the same person who created it
SELECT id, job_type, client_id, created_by FROM jobs
WHERE status = 'approved' AND created_by = approved_by;| Time | Block |
|---|---|
| 09:00 | The two worlds, with the cabinet open in front of the group |
| 09:45 | Hands-on: each person runs one job on a sample client |
| 11:00 | Find the planted mistake: drafts with one wrong figure each |
| 12:00 | The never-list, and what to do in the first 10 minutes |
| 13:00 | Role track: advisers, compliance or IT |
| 15:30 | Each person signs the AI-use policy |