/** * E3.3 graph layer over consolidated state - the graph-on-consolidated guard. * (docs/plans/2026-06-01-e3-graph-guard.md). * * A graph of canonical `entities` (person/project/customer/system/policy/decision) and * `relations` (owns/supersedes/depends-on/blocked-by/references) sits ON TOP OF * consolidated memories. The hard rule: the graph NEVER indexes the raw layer - every * entity and relation references a memory whose `kind IN ('distilled','superseded')`, * never `kind='raw'`. Enforced at the DB level (CHECK on source_kind/kind + BEFORE * INSERT and BEFORE UPDATE triggers that tie source_kind/kind to the FK'd memory's * actual kind and enforce tenant-match - relations also reject cross-tenant edges), so * the forbidden state is unrepresentable regardless of code path. These helpers * surface the same guard as clear throws BEFORE hitting the trigger backstop. * * Scope (E3.3 first slice): the substrate + the guard + a thin insert/load/enqueue * API. The `graph_extraction_queue` is the interface the deferred `hippo sleep` * enqueue-hook + E3.1 entity extraction will call. No operator surface (CLI/HTTP/SDK) * until E3.2 multi-hop recall. */ import { openHippoDb } from './db.js'; /** The DB connection handle `openHippoDb` returns. Threaded (optionally) through * the graph writers so `extractGraph` can run clear + all inserts in ONE * transaction — see `runGraphRebuildTransaction`. */ export type GraphTxDb = ReturnType; export type EntityType = 'person' | 'project' | 'customer' | 'system' | 'policy' | 'decision'; export type RelationType = 'owns' | 'supersedes' | 'depends-on' | 'blocked-by' | 'references'; export type GraphQueueStatus = 'pending' | 'processed' | 'skipped'; /** The consolidated source kinds the graph is permitted to index (never 'raw'). */ export type SourceKind = 'distilled' | 'superseded'; /** The authoritative E2 object types a graph row may be anchored to (the object * provenance path, alongside the memory path). Maps to source_object_type. */ export type SourceObjectType = 'decision' | 'policy' | 'customer' | 'project'; /** A soft (type,id) pointer to the authoritative E2 row a graph row descends from. * Survives a mirror memory forget/prune (memory_id may go NULL); the rebuild * re-validates it (it is not a hard FK). */ export interface SourceObjectRef { type: SourceObjectType; id: number; } export declare const GRAPH_ENTITY_TYPES: ReadonlySet; export declare const GRAPH_RELATION_TYPES: ReadonlySet; export declare const VALID_QUEUE_STATES: ReadonlySet; export declare const MAX_ENTITY_NAME_LEN = 512; export interface Entity { id: number; tenantId: string; entityType: EntityType; name: string; /** The consolidated source memory this entity was extracted from. NULL once the * mirror is forgotten/pruned (ON DELETE SET NULL) - the entity then survives via * its source_object provenance. */ memoryId: string | null; sourceKind: SourceKind; /** The authoritative E2 object this entity is anchored to (E2-provenance path). * Set for E2-sourced entities; absent for memory-only (prose/NLP) entities. */ sourceObjectType?: SourceObjectType; sourceObjectId?: number; createdAt: string; } export interface Relation { id: number; tenantId: string; fromEntityId: number; toEntityId: number; relType: RelationType; /** NULL once the mirror is forgotten/pruned; the relation survives via source_object. */ memoryId: string | null; sourceKind: SourceKind; sourceObjectType?: SourceObjectType; sourceObjectId?: number; createdAt: string; } export interface GraphQueueItem { id: number; tenantId: string; memoryId: string; kind: SourceKind; status: GraphQueueStatus; enqueuedAt: string; processedAt: string | null; } export interface InsertEntityOpts { entityType: EntityType; name: string; /** A consolidated (distilled/superseded) memory; raw is rejected. NULL/omitted when * the entity is anchored only to its E2 source object (mirror forgotten/pruned). */ memoryId?: string | null; /** The authoritative E2 object this entity descends from. Required when memoryId is * null; optional alongside a live memory (both paths may be set). */ sourceObject?: SourceObjectRef; } export interface InsertRelationOpts { fromEntityId: number; toEntityId: number; relType: RelationType; /** A consolidated (distilled/superseded) memory; raw is rejected. NULL/omitted when * the relation is anchored only to its E2 source object. */ memoryId?: string | null; /** The authoritative E2 object this relation descends from. */ sourceObject?: SourceObjectRef; } /** * Insert a graph entity extracted from a consolidated memory. Throws if the source * memory is missing / cross-tenant / raw (the DB trigger is the backstop). */ export declare function insertEntity(hippoRoot: string, tenantId: string, opts: InsertEntityOpts, txDb?: GraphTxDb): Entity; /** * Insert a graph relation between two entities, sourced from a consolidated memory. * Both entities must exist in the same tenant; the source memory must be consolidated. */ export declare function insertRelation(hippoRoot: string, tenantId: string, opts: InsertRelationOpts, txDb?: GraphTxDb): Relation; export declare function loadEntityById(hippoRoot: string, tenantId: string, id: number): Entity | null; /** Entities with an exact `name` (read), bounded by `limit` in SQL with a * deterministic order. Lets the graph-view focus query find the `--entity NAME` * entity DIRECTLY (not from a globally-capped list) WITHOUT materializing every * same-name row when a name maps to many entities. */ export declare function loadEntitiesByName(hippoRoot: string, tenantId: string, name: string, opts?: { limit?: number; }, txDb?: GraphTxDb): Entity[]; export declare function loadEntities(hippoRoot: string, tenantId: string, opts?: { entityType?: EntityType; limit?: number; }, txDb?: GraphTxDb): Entity[]; export declare function loadRelations(hippoRoot: string, tenantId: string, opts?: { fromEntityId?: number; limit?: number; }, txDb?: GraphTxDb): Relation[]; /** * Map consolidated source memory ids -> their graph entities. The SEED step of E3.2 * multi-hop recall (recall result memory ids -> entities to traverse from). Tenant- * scoped, read-only; chunks the IN-list under the SQLite variable cap. */ export declare function loadEntitiesByMemoryId(hippoRoot: string, tenantId: string, memoryIds: string[]): Entity[]; /** * Load entities by their primary ids. Resolves the entity rows reached during the BFS * (whose `memory_id` maps back to a recall result). Tenant-scoped, read-only. */ export declare function loadEntitiesByIds(hippoRoot: string, tenantId: string, ids: number[], txDb?: GraphTxDb): Entity[]; /** * All relations touching ANY of `entityIds` in EITHER direction (from OR to) — the * per-hop neighbour query for E3.2 multi-hop traversal. ONE query for the whole frontier * (not one per node): this is the bidirectional read `loadRelations` (from-only) lacks, * and avoids an N+1 across BFS frontier nodes. `limit` caps rows for the frontier and * must be a non-negative integer (the raw `LIMIT ?` rejects a fractional value). */ export declare function loadNeighborRelations(hippoRoot: string, tenantId: string, entityIds: number[], opts?: { limit?: number; }, txDb?: GraphTxDb): Relation[]; /** * Relations with BOTH endpoints in `entityIds` (edges AMONG the set, not merely * touching it). Read. Used by the graph-view focus subgraph so the displayed * edges are exactly the intra-union edges: the `LIMIT` only caps genuinely-many * intra-union edges — no out-of-union row can evict a valid in-set edge. The * caller bounds `entityIds` (<= the view limit), so a single query is safe. */ export declare function loadRelationsAmong(hippoRoot: string, tenantId: string, entityIds: number[], opts?: { limit?: number; }, txDb?: GraphTxDb): Relation[]; /** * Run `fn` inside ONE read transaction (a single WAL snapshot) so every graph read * it performs — pass the supplied `txDb` to the `load*` functions — sees a consistent * view, even if a `graph extract` / sleep-drain rebuild commits concurrently between * reads (the rebuild clears + reinserts entities, so separate reads could otherwise * mix old entity ids with new relation ids). Reads only; the connection is opened * once and closed after. */ export declare function withGraphReadSnapshot(hippoRoot: string, fn: (txDb: GraphTxDb) => T): T; /** * Enqueue a consolidated memory for later graph extraction. Rejects a raw / missing / * cross-tenant memory (the DB trigger is the backstop). The producer hook in * `hippo sleep` is deferred (E3.1); this is the API it will call. */ export declare function enqueueExtraction(hippoRoot: string, tenantId: string, memoryId: string): GraphQueueItem; export declare function loadExtractionQueue(hippoRoot: string, tenantId: string, opts?: { status?: GraphQueueStatus; limit?: number; }): GraphQueueItem[]; /** * Mark a queue item terminal (processed | skipped). Only `status`/`processed_at` * change, so the consolidated-source guard trigger (which fires on * memory_id/kind/tenant_id changes) is not involved. CAS on the current status to a * non-terminal 'pending'. */ export declare function markExtractionProcessed(hippoRoot: string, tenantId: string, id: number, status?: 'processed' | 'skipped'): GraphQueueItem; /** * Delete ALL entities for a tenant (relations cascade via the from/to FKs). Returns * the number of entities deleted. The rebuild primitive for graph extraction: the * deterministic graph is a pure derived function of the consolidated objects, so an * extract clears then re-derives. Lives in graph.ts (the sole sanctioned graph * writer), so the E3.3 CI lint permits this `DELETE FROM entities`. Does NOT touch * graph_extraction_queue (the enqueue-hook's domain). */ export declare function clearGraph(hippoRoot: string, tenantId: string, txDb?: GraphTxDb): number; /** * Run a full graph rebuild for one tenant inside a single transaction. `clearGraph` * + every `insertEntity`/`insertRelation` call made inside `fn` (passing the supplied * `txDb`) share the one connection and its `BEGIN IMMEDIATE` write lock, so the * rebuild is ATOMIC: two concurrent rebuilds serialize on the write lock (the second * waits, then re-derives cleanly) instead of interleaving into duplicate rows, and a * throw mid-rebuild ROLLS BACK the clear (no bricked/empty graph). The sole sanctioned * place to wrap graph writes in a transaction. */ export declare function runGraphRebuildTransaction(hippoRoot: string, tenantId: string, fn: (txDb: GraphTxDb) => T): T; /** * Fail-soft producer hook: mark a tenant dirty for graph re-extraction by * enqueuing its consolidated mirror memory. NEVER throws into the caller — a * graph-dirty signal failing must not abort a core E2 write. Graph staleness is * recoverable (next sleep / manual `graph extract`); a broken `hippo decide` is * not. Called POST-COMMIT from the E2 graph-source save/close mutations of * decision, policy, customer_note and project_brief. A null memoryId (a * forgotten mirror) is a no-op. */ export declare function markGraphDirty(hippoRoot: string, tenantId: string, memoryId: string | null): void; /** * Remove the graph rows sourced from one E2 object, by its (type, id). Used when a * MIRRORLESS object is closed: it has no mirror memory, so `markGraphDirty` cannot * enqueue a rebuild (the queue is memory-keyed). Closing must still drop the object's * now-stale entity + edges from the graph, so we remove them directly here. Fail-soft * like `markGraphDirty` (never throws into the E2 close caller; graph staleness is * recoverable). Deleting the entity cascade-deletes any relation where it is an endpoint * (relations FK entities ON DELETE CASCADE); the explicit relations DELETE also covers a * relation whose OWN provenance is this object (defensive — every such edge has the object * as an endpoint today, so the cascade already covers it). DELETE fires no BEFORE * INSERT/UPDATE guard trigger. */ export declare function removeGraphEntitiesForObject(hippoRoot: string, tenantId: string, sourceObjectType: SourceObjectType, sourceObjectId: number): void; /** * The dirty tenants awaiting graph re-extraction, each with the MAX pending * queue id at read time (a watermark). The sleep drain rebuilds each tenant's * graph, then marks only items at or below the watermark processed, so items * enqueued DURING the rebuild stay pending for the next sleep (no lost-update * race). Host-wide read (the queue is per-tenant but sleep is cross-tenant). */ export declare function loadPendingExtractionTenants(hippoRoot: string): { tenantId: string; maxPendingId: number; }[]; /** * Mark every pending queue item for a tenant with `id <= maxId` processed, in * one UPDATE. Status/processed_at only, so the consolidated-source guard trigger * is not involved (same as markExtractionProcessed). Returns the count marked. * The `<= maxId` watermark excludes items enqueued after the drain snapshot. */ export declare function markPendingProcessedUpTo(hippoRoot: string, tenantId: string, maxId: number): number; //# sourceMappingURL=graph.d.ts.map