Deployed application · Architecture note

Inner Order OS architecture

A guide to the application's structure and the design decisions behind it. This is the public version of the architecture document maintained in the repository.

Counts stated as of 2026-08-20: 129 TypeScript modules, 28 route handlers, 11 versioned migrations, 20 unit tests. I designed, built, and maintain the application.

Status

Deployed and preparing for alpha testing, with external access not yet open. These diagrams describe the implemented architecture; user traffic and retention have not yet been measured.

1. System map

flowchart TB
    subgraph client["Browser — Next.js 14 App Router, TS strict"]
        UI["Feature components
entries · free-record · chapters · wisdom
review · tags · settings · onboarding"] XR["Xirang terrain renderer
read-only view of the record stream"] end MW["middleware.ts
Supabase SSR cookie session
every /api/* except health + register
plus all protected app paths"] subgraph api["28 route handlers — src/app/api"] R1["Records & entries
free-records · entries · tags · principles"] R2["AI
calibrate · wisdom-match · weekly-calibration"] R3["Account & ops
auth · settings · onboarding · export
feedback · analytics · health · admin backfill"] end subgraph domain["Domain layer — src/lib"] D1["entries/policy · validation · content"] D2["principles/versioning"] D3["alpha/cohort · access"] D4["wisdom/language-guard"] D5["free-records/similar"] D6["terrain-engine"] end subgraph ext["Provider-agnostic adapters"] L["LLM adapter → Groq
8B calibration · 70B wisdom matching"] E["Embedding adapter
Jina v3 · 1024-dim · bge-m3 drop-in"] end subgraph db["Supabase — PostgreSQL"] T["Tables + RLS
caller owns the row, soft-deleted rows excluded"] V["pgvector vector(1024)
HNSW cosine index"] F["RPC — SECURITY INVOKER
nearest-neighbour search"] A["analytics_events
insert-only, sanitized props"] end UI --> MW --> api XR --> R1 R1 --> D1 & D2 & D3 R2 --> D4 & D5 XR -.derives from stored records, no LLM.-> D6 R2 --> L R1 --> E D5 --> F api --> T F --> V R3 --> A classDef boundary stroke-dasharray: 4 3 class MW boundary

Three things this diagram is meant to make obvious:

  1. The AI layer is a leaf, not a spine. Records save, list, export and render without any provider being reachable. Every AI path degrades to “no question this time” rather than failing the write.
  2. Retrieval terminates in the database, not in the application. The nearest-neighbour search is a SQL function, so the row-level policy is the only access gate on both the source row and its candidates.
  3. The terrain layer reads and never writes. It adds no input path and makes no model calls; it is a projection of the existing record stream.

2. Retrieval-augmented calibration

The one request path where retrieval, a model call and the guardrails all meet. It is also the path carrying the deliberate injection defence.

sequenceDiagram
    autonumber
    participant C as Browser
    participant MW as middleware
    participant API as calibrate route
    participant DB as Postgres · RLS
    participant LLM as Groq 8B

    C->>MW: POST { record_id, tool_type, content }
    Note over C,MW: record id only — never prior-record text
    MW->>API: session attached (401 if absent)
    API->>DB: nearest-neighbour RPC (limit 2, min similarity 0.5)
    Note over DB: SECURITY INVOKER — RLS scopes source AND candidates.
Another user's id simply returns no rows. DB-->>API: 0-2 neighbour bodies API->>API: cap context — 2 records x 120 chars API->>LLM: system prompt + capped excerpts LLM-->>API: one question (or failure) API->>API: deterministic filters — shape, length, language, no-restatement alt passes API-->>C: question else rejected API-->>API: log rejection for drift tracking API-->>C: no question this time end API->>DB: analytics event (never blocks the response)

Why the client sends an id and not text. If the browser supplied the prior records, anyone could hand the model arbitrary context and call it the user’s own history. Fetching server-side means the retrieved context is, by construction, rows the caller already owns.

Why two thresholds. The browsable “you have written this before” list uses a similarity floor of 0.35; a loose match there costs the reader nothing. The same neighbours become prompt context here, where a weak match actively degrades the question — so this path requires 0.5.

Why the context cap is small. The 8B model degenerates on long open-ended input (see §4). Two short excerpts are enough to aim a question at a repeat; more is risk without benefit.

3. Trust boundaries

flowchart LR
    subgraph untrusted["Untrusted"]
        CL["Browser
ids, not content, for AI context"] MO["Model output
treated as unvalidated"] end subgraph server["Server — session-scoped"] RT["Route handlers
anon key + caller session"] GD["Deterministic filters
shape · length · script · no-restatement"] end subgraph privileged["Privileged — narrow by design"] AD["Admin client
analytics insert + embedding backfill only"] end DBX["PostgreSQL
RLS is the access gate,
not an application filter"] CL --> RT --> DBX MO --> GD --> RT RT --> AD --> DBX classDef warn stroke-dasharray: 4 3 class untrusted,MO warn

Three rules hold this together:

4. Decisions worth knowing before changing anything

DecisionReason
Two models by task — 8B for calibration, 70B for wisdom matching The 8B model degenerated on open-ended input: looping output, truncated JSON. Prompt and temperature changes did not fix it, which identified it as a capability limit rather than a prompt defect. Load was split by task instead of upgrading everything.
Provider-agnostic embeddings Switching to bge-m3 is two environment variables and a key. The single non-portable fact is the 1024 column width, which is stated in the migration itself.
Asymmetric encoding Stored records are encoded as passages, search text as queries. The embedding model applies a different adapter per task, which measurably improves retrieval over encoding both sides the same way.
No mock embeddings in development A plausible mock question is still useful; a random vector produces nonsense neighbours, which is worse than having no semantic search at all. No key means no embeddings, everywhere.
Embedding failures never throw Every caller reads a null as “not embedded yet” and leaves the row for the backfill script. Embedding is an additive property of a record, never a precondition for saving one.
HNSW index shipped before it pays No measurable benefit at current row counts; present so it grows with the data instead of requiring a later migration under load.
Records are soft-deleted, principles are versioned Nothing the user wrote is destroyed by ordinary use. Both are enforced in the schema and covered by unit tests.
Analytics can never block a flow The write is wrapped and swallowed. A telemetry outage must not cost a user their record.

5. Schema evolution

0001  init                      0007  free_records
0002  boundary script tool      0008  tags architecture
0003  chapter system            0009  feedback
0004  language preference       0010  xirang terrain
0005  alpha observation events  0011  semantic search — pgvector
0006  saved wisdom

Migrations are additive and versioned; each states in a header comment whether it removes anything. After one TRUNCATE CASCADE incident on this project, a destructive statement now requires listing every foreign-key referencing table and confirming each one first — the check lives in the repository’s agent specification, not in somebody’s memory.

6. What the tests cover

20 unit tests across five business invariants, chosen because these are the rules that would be expensive to get wrong and cheap to break by accident.

ModuleInvariant
alpha cohortwho is admitted to the alpha, and on what basis
entry policywhat may be written, edited, or soft-deleted
principle versioningprinciples are versioned, never overwritten
language guarda quote’s script must match the user’s culture circle
calibrationprompt assembly and the output contract

They are invariant tests, not a coverage target. The gate before a commit is TypeScript strict mode, ESLint, tsc --noEmit and a production build.