Continuum docs

Continuum Engine

Complete

Milestone 1 · Mar - Apr 2026

Cortex: Context as a Service

A three-tier context store - PostgreSQL for truth, Qdrant for vectors, FalkorDB for relationships - with a durable chat plane on top of it and a Blazor dashboard to watch both from.

Overview

The store had to exist before anything could be recalled from it.

Milestone 0 wrapped an inference call in durable infrastructure that costs 24 to 28 ms at p95: 26 ms on LM Studio, 1.3% of a 658-token answer's p95. It had nowhere to put what it learned. Milestone 1 built that somewhere: three stores with an HTTP API in front of them, and a chat plane that gives the store something to be used by.

The split is deliberate. PostgreSQL holds the claim - what was asserted, at what scope, with what confidence. Qdrant holds the embedding, so a natural-language question can find an entry point. FalkorDB holds the edges, so from that entry point you can walk the neighbourhood. Postgres is truth; the other two are projections of it, reconciled by a sweep that can rebuild either from scratch.

On top of it: threads, messages, a Temporal chat workflow that survives a worker dying mid-token, Redis-first streaming with three streams per message, and a Blazor dashboard where a person can watch the model think and then go look at what it wrote down.

M1 stopped deliberately short of recall. Entries could be stored, searched, and listed. Ranking them by anything cleverer than recency and a full-text score turned out to need a function that did not exist yet - so the assembly cascade was specified here and built in Milestone 2. Writing the spec and then not implementing it was the right call; implementing it against a scoring step nobody had fitted would have produced a number that meant nothing.

Architecture

Three stores, one truth.

Two of the three are derived from Postgres, so a corrupted index is an inconvenience rather than an incident.

Layer 1

The Context Store

PostgreSQL is the truth store. M1 shipped it as three tables - a skinny ranking table, a 1:many content payload, and a projection manifest tracking what had been pushed to Qdrant and FalkorDB. The ranking table is column-aligned (16 → 8 → 4 → variable) because it is the one read on every candidate scan and padding is not free at that volume.

Entries are typed - Decision, Pattern, Constraint, Task, Observation, Synthesis, Correction - and carry a confidence that defaults by who asserted them: human 0.80, agent 0.50, system 0.30, consolidation 0.40. An agent's guess does not arrive weighted like a person's decision.

ContextRepo is Dapper with every query as a self-contained static string. No expression-tree translation, no surprise N+1, and the SQL that runs is the SQL in the file. Repo types never leak past the service boundary - the host maps to DTOs, so a schema change cannot reach the wire contract by accident.

Layer 2

Vectors & Graph

Qdrant holds embeddings generated through IEmbeddingGenerator, chunked by a splitter that respects markdown headings rather than cutting at a fixed token count. A question goes in, an entry point comes out.

FalkorDB holds the relationships, and the model is deliberately neuroplastic. One graph with scope on the nodes, not a graph per tenant - because a graph-per-tenant design makes cross-scope traversal impossible the moment you want it. SUPERSEDES edges are treated as highways: traversal follows superseded paths rather than stopping at them, so a correction can be reached from the thing it corrected. Every edge carries a traversal_count - path density as a signal, on the theory that the routes actually walked are the routes worth walking.

Edge types are validated against an allow-list rather than parameterized. Cypher does not parameterize relationship types, so the choice was a validated switch or string interpolation into a query language. The allow-list won.

Layer 3

The Chat Plane

AgentChatWorkflow is stateless by design - no conversation history held in workflow memory. It keeps a queue of prompt signals and reloads context from Postgres each turn, which means ContinueAsNew after 100 turns carries zero state across the boundary. A Temporal workflow that accumulates history in a field is a workflow whose replay cost grows without limit.

Streaming is Redis-first: three streams per message (input, output, collating), an XADD per token during generation, and a sentinel plus a 24-hour TTL at the end. Durable stream and real-time pub/sub publish together, so SignalR gets immediacy and the stream gets replay. A client that reconnects mid-generation reads what it missed instead of losing it.

Messages are a flat model with an inline-allocated sequence (INSERT...SELECT MAX+1 RETURNING) and compare-and-swap completion gated on status = 'Streaming'. Edit and regenerate write new rows with a replaced_by pointer rather than mutating history - the same append-only instinct the context store runs on.

Layer 4

Tools, Ingestion & Dashboard

IToolHandler returns an AIFunction, handlers self-register through DI, and results carry a dual payload - a JSON string for crossing Temporal boundaries and a typed object for in-process callers, so nothing serializes where it does not need to.

Ingestion splits markdown on headings and plain text on paragraphs, classifies each section into an entry kind, and writes them through the Cortex client. PDFs go through PdfPig with an ArrayPoolBufferWriter<char> to stay off the large object heap. Upload is multipart with a 10MB cap and format detected from the filename.

The dashboard is Blazor Server - chat, a collapsible thinking panel fed by the collating stream, cursor-paginated history, stop-generation wired to Temporal cancellation, and a drag-and-drop ingest page. The 740-line chat page was eventually broken into seven circuit-scoped services with 66 unit tests behind them, which is the point at which it stopped being frightening to change.

What Shipped

Everything below shipped in Milestone 1.

Context Store

  • ✓ Postgres schema - ranking table, content payload, projection manifest
  • ✓ ContextRepo - Dapper, 18 methods, all SQL self-contained
  • ✓ ContextService with source-based confidence defaults
  • ✓ Cortex host - 7 endpoints under /v1/context/entries
  • ✓ Idempotent document ingest, projection reconcile sweep as a Temporal workflow

Vectors & Graph

  • ✓ Qdrant hosting, IEmbeddingGenerator, heading-aware chunker
  • ✓ Custom Aspire.Hosting.FalkorDB container resource
  • ✓ Neuroplastic graph model - scope on nodes, SUPERSEDES as traversable highways
  • ✓ Edge traversal_count, seed generation versioning
  • ✓ Cypher injection prevention via edge-type allow-list

Chat & Streaming

  • ✓ Stateless AgentChatWorkflow, ContinueAsNew at 100 turns
  • ✓ PurgeConversationWorkflow - durable, idempotent hard-delete cascade
  • ✓ Three Redis streams per message, per-token XADD, 24h TTL
  • ✓ 11 REST endpoints plus a SignalR hub at /v1/chat/stream
  • ✓ Regenerate, edit-resend, soft delete, cursor-paginated history

Tools & Ingestion

  • ✓ IToolHandler framework with DI auto-registration
  • ✓ Cortex tools - search, create entry, list entries
  • ✓ Markdown and plain-text splitters with kind classification
  • ✓ PDF extraction via PdfPig, zero-LOH buffer writer
  • ✓ Multipart upload, 10MB limit, format auto-detection

UI & Models

  • ✓ Blazor Server dashboard - chat, thinking panel, ingest page, profile
  • ✓ Continuum.UI Razor class library extracted from the monolith
  • ✓ Chat page split into 7 circuit-scoped services, 66 unit tests
  • ✓ ModelRegistry - capability-keyed models with startup validation
  • ✓ Multi-model LM Studio hosting, keyed IChatClient

Hosts & Testing

  • ✓ Three new hosts - Cortex, Chat, Identity
  • ✓ 11 test projects consolidated onto a single Aspire boot
  • ✓ Shared PlatformFixture - 136/136 passing in ~30 seconds
  • ✓ 8 BenchmarkDotNet suites
  • ✓ Rate limiting, body size limits, deep health checks, CORS

Design Decisions

Why it's built this way.

▶ Why three stores instead of one?

Because they answer different questions and no single engine answers all three well. Postgres answers "what is claimed, by whom, at what scope" - relational, transactional, with a query planner that has been tuned for decades. Qdrant answers "what is this like" over a few hundred dimensions. FalkorDB answers "what connects to what" in a traversal that would be a recursive CTE nightmare in SQL.

The alternative was pgvector plus a recursive CTE, keeping everything in one engine. That is genuinely simpler, and for a smaller store it would be the right answer. It was rejected because the traversal is not incidental here - spreading activation is the whole retrieval model, and a graph engine that does it natively beats a CTE that emulates it.

The cost is consistency across three engines. That cost is contained by making two of them derived: Postgres is truth, the other two are projections, and a reconcile sweep can rebuild either.

▶ Why specify the assembly cascade and then not build it?

The cascade has three steps: semantic search in Qdrant, graph expansion in FalkorDB, then scoring in Postgres. The first two were buildable. The third needed a ranking function, and the one on paper at the time was six hand-weighted signals that nobody had fitted against anything.

Shipping it would have produced a working pipeline returning confidently-ranked results with no evidence the ranking was better than recency. That is worse than not shipping it, because it looks finished. The honest move was to write the cascade down, mark the scoring step as unbuilt, and go find out what the function should actually be.

It became ACT-R activation, and fitting it properly took most of Milestone 2. The first fit found a constant that had silently disabled the entire decay term. That error would have shipped invisibly in M1.

▶ Why is the chat workflow stateless?

A Temporal workflow replays its event history to rebuild state. Keep the conversation in a workflow field and every turn makes every future replay more expensive - and the history has a hard size limit you eventually hit. The obvious fix, ContinueAsNew, only helps if there is little state to carry across the boundary.

So the workflow holds no history at all. It holds a queue of prompt signals and reloads context from Postgres each turn. ContinueAsNew after 100 turns carries nothing. The database was already the durable record; duplicating it into workflow state bought nothing and cost replay.

This design had one real bug: prompts still sitting in the queue at ContinueAsNew were silently dropped. Found and fixed in M2 - the queue is drained before the boundary.

▶ Why Redis-first streaming with three streams per message?

Input, output, and collating - the last being the model's reasoning tokens, which are a separate concern from its answer and belong on their own channel. A consumer that wants only the answer never reads the thinking.

"Redis-first" means the durable stream is written before the real-time notification goes out, not after. Push to SignalR first and a client that reconnects two seconds later has no way to recover the tokens it missed - the notification was the only copy. Writing the stream first makes reconnection a read.

The streams get a 24-hour TTL and an explicit end-of-stream sentinel. Without the sentinel a consumer cannot distinguish "generation finished" from "generation stalled", and that ambiguity ends up rendered as a spinner that never stops.

▶ Why collapse 11 test projects into one?

The tests run against live services through Aspire - real Postgres, real Redis, real Temporal, real Qdrant. Eleven test projects meant eleven Aspire boots, and the boot dominated the run. The suite was slow enough that it stopped being run.

One project with a shared PlatformFixture boots the platform once: 136 tests in about 30 seconds. The tradeoff is that test classes now share a platform and must not assume an empty database - every test scopes its own data. That discipline is worth a suite people actually run.

Integration tests run against live services, not mocks. If the test passes, the code works in production.

▶ Why are SUPERSEDES edges traversable instead of terminal?

The intuitive model is that a superseded entry is dead - you replaced it, stop walking to it. That is wrong for a memory store. The superseded version is often the thing someone remembers, and it is the only route from an old mental model to the current one.

Traversal follows supersession in both directions: a query that lands on the old decision reaches the correction, and a query about the correction can see what it replaced. Nothing is removed from the graph - it is re-pointed.

This became load-bearing later. The context store's First Law says no claim may be destroyed, only superseded, and the superseded claim stays queryable forever. The graph was already built that way before the law was written down.

Carried Forward

What M1 deliberately left undone.

Four items, all downstream of the same missing piece - a ranking function worth trusting.

The assembly cascade

Specified in full - semantic search, graph expansion, scoring. The scoring step needed a function that did not exist. Replaced in M2 by ACT-R activation.

Inference enrichment

Pulling assembled context into the inference path. Pointless before there is an assembly worth pulling.

ProjectEntryWorkflow

The durable path from a new entry to its embedding, its Qdrant point, and its FalkorDB node. The pieces all exist; the workflow that chains them does not.

Seeing the graph

Graph and assembly visualization in the dashboard. Hard to justify rendering a ranking nobody can yet defend.