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.
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:
- 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.
- 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.
- 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:
-
Row-level security is the gate, not a second opinion. The similarity
function carries no owner predicate in its body — adding one would imply the policy is
not trusted. Running it as
SECURITY DEFINERwould bypass RLS entirely and expose every user’s records; the definition says so in a comment, so the next person does not “optimize” it. - Service-role access is scoped to two jobs: writing analytics events and backfilling embeddings. It never serves a read on the user’s behalf.
- Model output is never trusted into the response. Rejected generations are written to the log rather than dropped silently, so drift is observable instead of anecdotal.
4. Decisions worth knowing before changing anything
| Decision | Reason |
|---|---|
| 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.
| Module | Invariant |
|---|---|
| alpha cohort | who is admitted to the alpha, and on what basis |
| entry policy | what may be written, edited, or soft-deleted |
| principle versioning | principles are versioned, never overwritten |
| language guard | a quote’s script must match the user’s culture circle |
| calibration | prompt 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.