# ADR-008: Temporal Layer — Time as a First-Class Citizen **Status**: Accepted **Date**: 2026-05-16 **Context**: [CONCEPT.md](../CONCEPT.md), [ADR-005 Desktop Viewport](ADR-005-desktop-viewport.md), [ADR-006 Mutation Strategy](ADR-006-mutation-strategy.md), [ADR-007 Regression Pattern](ADR-007-regression-pattern.md) --- ## Problem The graph stores timestamps (`created_at`, `updated_at`) on every node and relation, but they are passive metadata — written once, never queried structurally. Three critical agent capabilities depend on time: 1. **Anchor** — where did the agent stop last time? Without a temporal layer, `desktop_focus` cannot determine the "last position" deterministically. 2. **Sequence recall** — what happened in what order? How did decisions build on each other over time? 3. **Temporal pruning** — what is outdated not by status but by time? `archive_stale_nodes` already uses dates, but it's the only time-based mechanism. Without a proper temporal layer, the graph remains "flat" — relationships exist, but there is no time axis to navigate. ## Options ### Option A: Keep Status Quo (rejected) Only `created_at` / `updated_at` as strings, no indexes, no time-range API. - Cannot answer "what happened in the last hour" - Cannot filter vector search by time - Session timeline must be assembled manually from `get_nav_history` without aggregation - Anchor point — only via `get_last_focus_nodes` (5 nodes, no weight) ### Option B: Minimal — Indexes + `updated_at` on Relations (baseline) Add: - SQLite indexes on `(workspace_id, created_at)` and `(workspace_id, updated_at)` - `updated_at` column on `Relation` table - Time-range parameters on `graph_get_node` and `vector_search` - Covers 80% of needs - Migration: `ALTER TABLE relations ADD COLUMN`, `CREATE INDEX` - Does not touch the data model — only indexes and API ### Option C: Full Temporal — Versioning + Point-in-Time (rejected) Store full history of every node change (Event Sourcing style). - Can rewind to any point in time - Massive write and storage overhead - Overkill for MVP — 17 node types × versions = combinatorial explosion ### Option D: Temporal Walks — Chronological Graph Traversal (selected as complement to B) Add to Option B: - `temporal_walk` — traverse the graph sorted by `created_at` (chronological order) - `session_timeline` — flat timeline of all events in a session - Deterministic anchor — `last_focus` with timestamp, single deterministic entry point - Qdrant `DatetimeRange` filter on `created_at` ## Decision ### Strategy: Option B + D — indexes + relation `updated_at` + temporal API ### 1. Add `updated_at` to Relations **Current:** ```python class Relation(BaseModel): ... created_at: str # exists # updated_at: str — missing ``` **Target:** ```python class Relation(BaseModel): ... created_at: str updated_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) ``` SQLite migration: ```sql ALTER TABLE relations ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''; UPDATE relations SET updated_at = created_at; ``` API: `graph_update_relation()` sets `updated_at` on every call. ### 2. Temporal Indexes ```sql CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(workspace_id, created_at); CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(workspace_id, updated_at); CREATE INDEX IF NOT EXISTS idx_nav_created ON navigation_history(workspace_id, created_at); ``` ### 3. Time-Range Query API Temporal filter parameters added to existing methods: ``` vector_search(query, time_from="2026-05-14T00:00:00Z", time_to="2026-05-16T00:00:00Z") → Qdrant Filter with DatetimeRange on created_at desktop_history(workspace_id, time_from, time_to, limit=100) → time-sliced navigation history ``` Qdrant filter: ```python Filter( must=[ FieldCondition( key="created_at", range=DatetimeRange( gte="2026-05-14T00:00:00Z", lte="2026-05-16T00:00:00Z" ) ) ] ) ``` ### 4. Temporal Walk New method `GraphManager.temporal_walk()` — traverses nodes in chronological order. ```python def temporal_walk( self, workspace_id: str, from_time: Optional[str] = None, to_time: Optional[str] = None, relation_type: Optional[str] = None, limit: int = 50 ) -> list[dict]: """ Walk the graph along the time axis. Returns nodes ordered by created_at ASC within optional time range. Optionally filtered by node type/relation type. """ ``` Usage: "Show me the chain of decisions that led to the current architecture, in chronological order." ```mermaid graph LR T0["2026-05-12 10:00
ADR-001: Custom MCP"] --> T1["2026-05-12 14:00
ADR-002: SQLite Graph"] T1 --> T2["2026-05-12 16:00
ADR-006: Mutation Strategy"] T2 --> T3["2026-05-14 12:00
Demo Strategy v2"] T3 --> T4["2026-05-14 21:00
Doc-Code Gap Analysis"] T4 --> NOW["🟢 NOW
2026-05-16"] style NOW fill:#4CAF50,color:#fff ``` ### 5. Session Timeline New method `DesktopManager.timeline()` — flat temporal log of a session. ```python def timeline( self, workspace_id: str, from_time: Optional[str] = None, to_time: Optional[str] = None, limit: int = 50 ) -> list[dict]: """ Linear timeline of everything that happened in a session: - nodes created - navigation events (focus, branch, open) All merged and sorted by created_at ASC. """ ``` Agent can now ask "what happened in this session?" and get a single flat response instead of multiple calls. ### 6. Deterministic Anchor **Current mechanism**: `get_last_focus_nodes(limit=5)` — 5 last focuses without weight. **New mechanism**: - `last_focus` — single most recent `focus` action in `navigation_history` - `anchor` = `(last_focus_node_id, created_at)` — deterministic - On `desktop_open()`: if `last_focus` exists → it becomes the first hot node - If `last_focus` is stale → warning + fallback to previous focus ```python def get_anchor(self, workspace_id: str) -> Optional[tuple[str, str]]: """Get the deterministic anchor point: node_id + timestamp.""" row = self.conn.execute( """SELECT node_id, created_at FROM navigation_history WHERE workspace_id = ? AND action = 'focus' ORDER BY created_at DESC LIMIT 1""", (workspace_id,), ).fetchone() return (row["node_id"], row["created_at"]) if row else None ``` ### 7. Qdrant Temporal Filter Add `time_from` / `time_to` to `vector_search()`: ```python def search( self, query: str, workspace_id: Optional[str] = None, time_from: Optional[str] = None, time_to: Optional[str] = None, top_k: int = 10, ) -> list[dict]: """Vector search with optional temporal filter.""" must_conditions = [] if workspace_id: must_conditions.append( FieldCondition(key="workspace_id", match=MatchValue(value=workspace_id)) ) if time_from or time_to: range_kwargs = {} if time_from: range_kwargs["gte"] = time_from if time_to: range_kwargs["lte"] = time_to must_conditions.append( FieldCondition(key="created_at", range=DatetimeRange(**range_kwargs)) ) query_filter = Filter(must=must_conditions) if must_conditions else None # ... search with filter ``` ## Consequences ### Positive 1. **Anchor**: deterministic session entry point — `last_focus` + timestamp 2. **Sequence**: `temporal_walk()` reconstructs decision chronology 3. **Session timeline**: flat event log — instant answer to "what happened" 4. **Temporal vector search**: results relevant to a time window 5. **Relation tracking**: `updated_at` on relations tracks when relationships changed 6. **Migration**: backwards-compatible — old data works without changes ### Negative 1. **Database size**: indexes add ~20-30% to SQLite size 2. **New API surface**: each method gains 2 new parameters (`time_from` / `time_to`) 3. **Qdrant version**: `DatetimeRange` requires Qdrant >= 1.9 — compatibility must be verified ### Migration Path ```sql -- Step 1: updated_at for relations ALTER TABLE relations ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''; UPDATE relations SET updated_at = created_at; -- Step 2: Temporal indexes CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(workspace_id, created_at); CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(workspace_id, updated_at); CREATE INDEX IF NOT EXISTS idx_nav_created ON navigation_history(workspace_id, created_at); -- Step 3: Update _create_schema() in db.py for new databases ``` ### Code Changes Summary | File | Change | |------|--------| | `src/cortex/models.py` | Relation: add `updated_at` field | | `src/cortex/db.py` | `_create_schema()`: new indexes; `create_relation()`: write `updated_at`; new methods `time_range_query`, `get_anchor`, `update_relation` | | `src/cortex/graph.py` | New method `temporal_walk()` | | `src/cortex/desktop.py` | New method `timeline()`; `open()`: deterministic anchor | | `src/cortex/vector.py` | `search()`: `time_from` / `time_to` params with Qdrant `DatetimeRange` | | `src/cortex/server.py` | New MCP tools: `temporal_walk`, `session_timeline`; time-range params on existing tools | ## Related ADRs - [ADR-001](ADR-001-fractal-memory.md): Fractal Memory — base architecture - [ADR-002](ADR-002-sqlite-graph.md): SQLite + JSON for graph — where data lives - [ADR-005](ADR-005-desktop-viewport.md): Desktop Viewport — Hot/Cold/Archive tiers - [ADR-006](ADR-006-mutation-strategy.md): Mutation Strategy — stale-cascade, status changes - [ADR-007](ADR-007-regression-pattern.md): Regression Pattern — search: meaning → context → specifics