AkurAI Build
Menu

Public repository / c2f43d4988686a5b5142bb6dd4255f9b473792dc

AkurAI-Notes / .plan/ARCHITECTURE.md

Latest change f1d9b8cc6ff48113d891a2d95fd79b2c49f1f4ff - Initial plan and .plan directory by Claude Code

# Architecture

## Why AkurAI-Framework, not a from-scratch stack

The user said **"pure rust no reps project … UI based on AkurAI-Framework … use
it"**. The Framework already ships every primitive this app needs, and the
constraint "the shipped binary links NO external crates" is the Framework's
own constitutional principle #1 — by path-depending on its crates we inherit
that guarantee for free.

| Need | Framework crate | Notes role |
|------|-----------------|------------|
| HTTP server + routing | `akurai-http`, `akurai-router` | REST API + static `site/frontend` + SSR pages |
| JSON | `akurai-json` | request/response bodies, session records, MCP JSON-RPC |
| Storage | `akurai-storage` (`BTree`) | all persistent state — notes, notebooks, tags, sessions, embeddings |
| Schema declarations | `akurai-schema` | type-checked fields for note/notebook/tag in `notes-core` |
| Collections / auto-API | `akurai-collections` | auto-mounted `/api/notes`, `/api/notebooks` … |
| Semantic search primitives | `akurai-vector` | `encode`/`decode` f32 bytes, `cosine`, `rank`, `embed`/`embed_many` (HTTP) |
| Templating (SSR) | `akurai-template` | rendering the AkurAI-style landing pages + the app shell |
| Markdown rendering | `akurai-markdown` | rendering note bodies to HTML for the editor preview |
| i18n | `akurai-i18n` | (optional) EN later, IS later |

This means the Notes workspace's `Cargo.toml` deps list is **only** entries like

```toml
[dependencies]
akurai-http       = { path = "../../AkurAI-Framework/crates/http" }
akurai-json        = { path = "../../AkurAI-Framework/crates/json" }
akurai-storage     = { path = "../../AkurAI-Framework/crates/storage" }
akurai-router      = { path = "../../AkurAI-Framework/crates/router" }
akurai-collections = { path = "../../AkurAI-Framework/crates/collections" }
akurai-vector      = { path = "../../AkurAI-Framework/crates/vector" }
akurai-markdown    = { path = "../../AkurAI-Framework/crates/markdown" }
akurai-template    = { path = "../../AkurAI-Framework/crates/template" }
notes-core         = { path = "../notes-core" }
notes-api          = { path = "../notes-api" }
```

— exactly the layout `akurai-crm/crates/crm-api/Cargo.toml` already uses.

## Workspace shape

Mirrors `akurai-crm` 1:1, with `notes-mcp` added as a peer of `crm-mcp`.

```
AkurAI-Notes/
├── .plan/                      ← this directory — docs only, not shipped
├── .references/                ← clone of Oblivion + 4 other note apps; reference-only
├── site/
│   ├── frontend/               ← Framework no-build ESM SPA (from `akurai new`)
│   │   ├── index.html          ← landing (public)
│   │   ├── app.html            ← logged-in shell: notes list + editor + search (protected)
│   │   ├── auth.html           ← post-login interstitial
│   │   ├── header.html, footer.html
│   │   ├── styles.css          ← AkurAI dark-first theme tokens
│   │   ├── signals.js, islands.js, spa.js   ← copied verbatim or enhanced
│   │   ├── app.js              ← per-page glue (binds editor, search box, sidebar)
│   │   ├── notes.js            ← the editor/search client module
│   │   ├── icon-notes.png      ← GPT-5.5 generated (E02)
│   │   ├── favicon.svg         ← GPT-5.5 generated (E02)
│   │   └── ...
│   ├── backend/
│   │   ├── page.json           ← site metadata (title, nav, version badge)
│   │   ├── collections.toml    ← declarative schema for notes/notebooks/tags
│   │   ├── routes.json         ← declarative route table (loaded by serve)
│   │   ├── data/               ← runtime-seedable fixtures
│   │   └── locales/            ← i18n strings (en.toml, is.toml)
├── crates/
│   ├── notes-core/             ← data model + metadata (entity defs)
│   │   ├── Cargo.toml
│   │   ├── src/
│   │   │   ├── lib.rs
│   │   │   ├── entities/
│   │   │   │   ├── mod.rs
│   │   │   │   ├── note.rs
│   │   │   │   ├── notebook.rs
│   │   │   │   ├── tag.rs
│   │   │   │   └── embedding.rs
│   │   │   ├── metadata/      ← ObjectMetadata for each entity
│   │   │   ├── error.rs
│   │   │   └── types.rs
│   ├── notes-api/             ← HTTP routes: REST + SSR + auth
│   │   ├── Cargo.toml
│   │   ├── src/
│   │   │   ├── lib.rs
│   │   │   ├── auth/
│   │   │   │   ├── mod.rs     ← login_route / callback_route / logout_route / me_route
│   │   │   │   ├── idp_client.rs   ← PORT of crm-api/src/auth/idp_client.rs (env-name swap)
│   │   │   │   └── session.rs      ← PORT of crm-api/src/auth/session.rs (cookie name swap)
│   │   │   ├── handlers/
│   │   │   │   ├── mod.rs
│   │   │   │   ├── crud.rs     ← list/get/create/update/delete for notes/notebooks/tags
│   │   │   │   ├── search.rs   ← GET /api/search?q=… → cosine-rank over embeddings
│   │   │   │   ├── meta.rs     ← GET /api/_meta
│   │   │   │   ├── ssr.rs      ← render site/frontend/*.html via akurai-template
│   │   │   │   ├── static_files.rs
│   │   │   │   └── timeline.rs ← GET /api/timeline (recent edits)
│   │   │   ├── embed.rs        ← wraps akurai-vector::embed_many, persists f32 bytes
│   │   │   ├── error.rs
│   │   │   └── router_setup.rs ← build_router() — port of crm-api/src/router_setup.rs
│   │   └── tests/
│   │       └── api_test.rs
│   ├── notes-cli/             ← the shipped binary `akurai-notes`
│   │   ├── Cargo.toml
│   │   ├── src/
│   │   │   ├── main.rs        ← serve | mcp | seed | version | help (port of crm-cli/main.rs)
│   │   │   ├── cmd_serve.rs   ← starts the HTTP server (port of crm-cli/cmd_serve.rs)
│   │   │   ├── cmd_mcp.rs     ← starts the MCP fork (notes-mcp::server)
│   │   │   └── cmd_seed.rs    ← seeds demo notes (port of crm-cli/cmd_seed.rs)
│   │   └── rust-toolchain.toml
│   └── notes-mcp/             ← HTTPS MCP server (POST /mcp JSON-RPC)
│       ├── Cargo.toml
│       └── src/
│           ├── lib.rs
│           ├── server.rs      ← port of crm-mcp/src/server.rs (path: /mcp, POST only)
│           └── tools.rs       ← tool list + handle_request (search_notes, get_note, create_note,
│           │                    list_notebooks, create_notebook, add_tag, semantic_search, …)
├── deploy.sh                  ← release engine — full Framework variant (E07)
├── VERSION                    ← 0.0.1
├── CHANGELOG.md               ← Keep-a-Changelog, Unreleased on top
├── Cargo.toml                 ← workspace root
├── Cargo.lock
├── rust-toolchain.toml        ← pins the same toolchain as Framework
├── AGENTS.md                  ← repo-local index, like akurai-crm/AGENTS.md
├── ISA.md                     ← ideal state artifact, one-page summary
├── LICENSE                    ← MIT (matches Framework)
└── README.md
```

## Data flow

### Write (create/update a note)

```
browser                     nginx       akurai-notes (notes-api)        akurai-notes-embed (:8081)
   │  POST /api/notes            │            │                                   │
   │  {title, body, notebookId, }│            │                                   │
   │  ──────────────────────────►│ ──────────►│                                   │
   │                              │            │  session_db.get(cookie) ──► owner │
   │                              │            │  btree.insert("note:<id>", json)  │
   │                              │            │  btree.insert("note_emb:<id>",   │
   │                              │            │       akurai_vector::encode(v))  │
   │                              │            │  ─── embed_many("title + body")─►│  POST /v1/embeddings
   │                              │            │  ◄─── Vec<f32>(dim=384|768) ──────│
   │  ◄─ 201 {id} ────────────────│◄───────────│  btree.commit()                   │
```

Key insight: the **embedding step is the only cross-process call**. Everything
else stays inside the single Notes binary. The `/var/lib/akurai-notes/notes.db`
B+tree holds both `note:<id>` (JSON record) and `note_emb:<id>` (LE f32 bytes)
in the same keyspace, exactly the layout the Framework's `collections` crate
uses (`coll:<name>:` / `\0` reserved prefixes — see
`~/Projects/AkurAI-Framework/AGENTS_DATA.md`).

### Read (semantic search)

```
browser                     nginx       akurai-notes            akurai-notes-embed
   │  GET /api/search?q=ai    │            │                         │
   │  ───────────────────────►│ ──────────►│                         │
   │                           │            │  embed(q) ─────────────►│
   │                           │            │  ◄── Vec<f32> ──────────│
   │                           │            │  scan "note_emb:*" (BTree.iter)
   │                           │            │  for each: decode ←→ cosine(q, doc)
   │                           │            │  akurai_vector::rank(scores, k=20)
   │                           │            │  resolve note:<id> for top-k
   │  ◄─ 200 {results:[…]} ───│◄───────────│  btree snapshot
```

The `?q=` substring fallback and the `?search` embedding path already exist in
the Framework's `crates/cli/src/collections_api.rs` — consult that file before
writing the semantic-search handler; the Notes version only swaps "rank by
substring score" for "rank by cosine over stored embeddings" when
`AKURAI_NOTES_EMBED_URL` is set.

## Auth flow (AkurAI IDP)

Same shape as `crm-api/src/auth/` — only env-var names change.

```
browser ─GET /dashboard─► notes-api ─302─► auth.olibuijr.com/authorize
                                                      (user logs in with
                                                       olibuijr@olibuijr.com / M3ga.p4bb1!!!)
browser ◄───redirect ?code=XYZ──────────  IDP
browser ─GET /auth/callback?code=XYZ─► notes-api
                                      └─► POST auth.olibuijr.com/token
                                      └─► Bearer ${access_token}
                                      └─► GET auth.olibuijr.com/userinfo → {sub,email}
                                      └─► session_db.insert("_session:<rand>", {sub,email})
                                      └─► Set-Cookie: notes_session=<rand>; HttpOnly; Secure; Path=/
browser ◄─302 /dashboard────────────
```

Notes env names (every app-prefixed, never hardcoded client_id):

```
AKURAI_NOTES_PORT=8099
AKURAI_NOTES_DB=/var/lib/akurai-notes/notes.db
AKURAI_NOTES_PUBLIC_URL=https://akurai-notes.olibuijr.com
AKURAI_NOTES_FRONTEND_DIR=/opt/akurai-notes/site/frontend

AKURAI_NOTES_IDP_ISSUER=https://auth.olibuijr.com
AKURAI_NOTES_IDP_CLIENT_ID=<from IDP admin API>
AKURAI_NOTES_IDP_CLIENT_SECRET=<from IDP admin API>
AKURAI_NOTES_IDP_REDIRECT=https://akurai-notes.olibuijr.com/auth/callback
AKURAI_NOTES_ALLOW=olibuijr@olibuijr.com

AKURAI_NOTES_EMBED_URL=http://127.0.0.1:8081/v1/embeddings
AKURAI_NOTES_EMBED_MODEL=qwen3-embedding-0.6b
```

## Embedding strategy ("on device")

The constraint is "embed on device" within "no external deps". The cleanest
answer that keeps the Notes binary deps-free while still running a real CPU
embedding model:

1. **The Notes binary itself never parses model weights.** It only talks the
   OpenAI-compatible `/v1/embeddings` protocol that `akurai-vector::embed`
   already speaks.
2. **A separate systemd unit `akurai-notes-embed.service` runs `llama-server`
   (or any small OpenAI-compatible runner) with a CPU-friendly GGUF model**:
   - First pick: `Qwen3-Embedding-0.6B-GGUF` (384-dim) — tiny, multilingual,
     strong on short note-length inputs.
   - Alt: `bge-small-en-v1.5-Q6_K.gguf` (384-dim) if the EN-only path is fine.
   - Alt: `nomic-embed-text-v1.5` if the local runner exposes it.
   The unit ships a `/opt/akurai-notes-embed/<model>.gguf` file and exposes
   `/v1/embeddings` on `127.0.0.1:8081` (loopback only; never public).
3. **`deploy.sh`ENSURES the model file is present on the EC2 host** (idempotent
   `scp` or `wget` with a fixed sha256 check). The unit is installed by the same
   `deploy.sh` install step that installs `akurai-notes.service`.
4. **The Notes binary only needs `AKURAI_NOTES_EMBED_URL` set** to do semantic
   search; with it empty, `notes-api/src/handlers/search.rs` falls back to the
   Framework's substring search (matches `crates/cli/src/collections_api.rs`'s
   fallback). This keeps the binary honest: it never assumes a model is present.

Why this is "no reps": the shipped Notes binary depends only on
AkurAI-Framework's std-only crates. `llama-server` is a public, already-built
binary on the EC2 box (it's the same approach already used by `titan` for
`llama.cpp`; see `AGENTS_projects.md` titan note).

## MCP server

`akurai-notes.olibuijr.com/mcp` is a JSON-RPC 2.0 endpoint in the same binary.
Two implementation options — pick based on the simplest port:

- **Option A (preferred, matches CRM):** `notes-cli` forks a second HTTP
  listener on `127.0.0.1:8100` whose sole route is `POST /mcp`. nginx maps
  `https://akurai-notes.olibuijr.com/mcp` → `127.0.0.1:8100/mcp`. This is a
  1:1 port of `crm-mcp/src/server.rs`; just swap `tools.rs` for the Notes tool
  set.
- **Option B (one port):** register `POST /mcp` alongside the API routes in
  `router_setup.rs`, behind the Notes session check (`session::extract_session_id`).
  Tighter auth surface, but means the MCP and the web share a listener.

Either way the MCP transport is **Streamable-HTTP** (per JSON-RPC 2.0 over
HTTP): single `POST /mcp` with `Content-Type: application/json`, body is the
JSON-RPC request, response is the JSON-RPC response. Initialize →
`notifications/initialized` → `tools/list` → `tools/call`. Reference impl in
`~/Projects/akurai-crm/crates/crm-mcp/src/server.rs` already does this.

MCP tools shipped with AkurAI-Notes v0.0.1 (see `tools.rs` seed in E06):

- `search_notes` — semantic + substring search across the user's notes
- `get_note` — full note by id
- `list_notebooks` — owner's notebooks
- `create_note` — title + body + notebookId (embeds on insert)
- `update_note` — patch title/body (re-embeds on body change)
- `delete_note`
- `add_tag` / `remove_tag`
- `semantic_search` — same as `search_notes` but always embedding-based

## Releases

`./deploy.sh patch` (default) runs (full Framework variant — see E07):

1. **Quality gate** — `cargo fmt --all --check` → `cargo clippy --all-targets
   --all-features -D warnings` → `cargo test --all`. (e2e + bench gates skip if
   missing — the Framework itself is the reference.)
2. **Lockstep bump** — `VERSION` ↔ `Cargo.toml [workspace.package] version` ↔
   `site/backend/page.json version badge` all set in one commit. Start at
   `0.0.1`; `./deploy.sh patch` → `0.0.2`, …  Pre-1.0 minor bumps may break.
3. **Dual changelog cut** — `CHANGELOG.md` repo file (Keep-a-Changelog) and
   optional `site/content/changelog.md` are stamped from `## [Unreleased]` →
   `## [X.Y.Z] - <date>` in lockstep.
4. **Commit, tag, push** — `release: vX.Y.Z` + annotated `vX.Y.Z` tag to
   `origin`. On first run it `gh repo create`s `olibuijr/AkurAI-Notes` private.
5. **`ec2_publish`** — static musl build, `akurai-ec2 deploy-binary
   akurai-notes <bin> 8099 site`, `akurai-ec2 nginx-proxy
   akurai-notes.olibuijr.com 8099`, `akurai-ec2 tls
   akurai-notes.olibuijr.com` (graceful skip if DNS not propagated yet).

MCP at `/mcp` rides the same binary + nginx vhost; no second TLS cert.

## What this plan deliberately does NOT specify

- Mobile clients (web is the client; installable PWA later via manifest)
- Realtime collaborative editing (single-user for v0.0.x)
- Public sharing links (private-only for v0.0.x)
- Generation features (no LLM summaries / drafting in v0.0.x — search only)
- E2E-encrypted-at-rest storage (server-side at-rest on the B+tree only; if
  Vaultwarden-style client encryption is desired, that's a v0.1.x epic)

## Reading order before code

1. [README.md](README.md) — this file (top)
2. [CODE-PATTERNS.md](CODE-PATTERNS.md) — exact files to copy from in CRM Framework
3. [HANDOFF.md](HANDOFF.md) — execution rules for DeepSeek v4 Flash
4. The epic for the task at hand, in order E01 → E08