Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/spec/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Audience: This documentation is written for LLM consumption and should remain ex
## Specs

- `docs/spec/system_elf_memory_service_v2.md` - ELF Memory Service v2.0 specification.
- `docs/spec/system_graph_memory_postgres_v1.md` - Graph memory schema and invariants for Postgres.
- `docs/spec/system_version_registry.md` - Registry of versioned identifiers and schema versions.

## Authoring guidance (LLM-first)
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/system_elf_memory_service_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Multi-tenant namespace:
- tenant_id, project_id, agent_id, scope, read_profile.

Optional future work:
- Graph memory backend (Neo4j) is reserved and out of scope for v2.0.
- Graph memory backend is defined in Postgres in `system_graph_memory_postgres_v1.md` and kept aligned with this specification.

============================================================
0. INVARIANTS (MUST HOLD)
Expand Down
139 changes: 139 additions & 0 deletions docs/spec/system_graph_memory_postgres_v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Graph Memory Postgres v1.0 Specification

Description: Canonical entity/fact temporal memory schema and invariants for PostgreSQL-backed graph memory.
Language: English only.

Purpose:
- Persist entities, aliases, temporal facts, and evidence links for ELF graph memory.
- Keep one active fact per `(tenant, project, scope, subject, predicate, value-or-entity)` combination.

Core tables:
- `graph_entities`
- `graph_entity_aliases`
- `graph_facts`
- `graph_fact_evidence`

============================================================
1. ENTITIES
============================================================

`graph_entities` columns:
- `entity_id uuid PRIMARY KEY`
- `tenant_id text NOT NULL`
- `project_id text NOT NULL`
- `canonical text NOT NULL`
- `canonical_norm text NOT NULL`
- `kind text NULL`
- `created_at timestamptz NOT NULL DEFAULT now()`
- `updated_at timestamptz NOT NULL DEFAULT now()`

Indexes:
- `UNIQUE (tenant_id, project_id, canonical_norm)`

Constraint and behavior:
- Canonical values are normalized by application helper before insert/upsert.
- Normalized canonical names allow idempotent upsert behavior across whitespace/case differences.

`graph_entity_aliases` columns:
- `alias_id uuid PRIMARY KEY`
- `entity_id uuid NOT NULL REFERENCES graph_entities(entity_id) ON DELETE CASCADE`
- `alias text NOT NULL`
- `alias_norm text NOT NULL`
- `created_at timestamptz NOT NULL DEFAULT now()`

Indexes:
- `UNIQUE (entity_id, alias_norm)`
- `INDEX (alias_norm)`

============================================================
2. FACTS
============================================================

`graph_facts` columns:
- `fact_id uuid PRIMARY KEY`
- `tenant_id text NOT NULL`
- `project_id text NOT NULL`
- `agent_id text NOT NULL`
- `scope text NOT NULL`
- `subject_entity_id uuid NOT NULL REFERENCES graph_entities(entity_id)`
- `predicate text NOT NULL`
- `object_entity_id uuid NULL REFERENCES graph_entities(entity_id)`
- `object_value text NULL`
- `valid_from timestamptz NOT NULL`
- `valid_to timestamptz NULL`
- `created_at timestamptz NOT NULL DEFAULT now()`
- `updated_at timestamptz NOT NULL DEFAULT now()`

Checks:
- Exactly one object reference per fact:
- `(object_entity_id IS NULL AND object_value IS NOT NULL)` OR
`(object_entity_id IS NOT NULL AND object_value IS NULL)`
- `valid_to IS NULL OR valid_to > valid_from`

Indexes:
- `(tenant_id, project_id, subject_entity_id, predicate)`
- `(tenant_id, project_id, valid_to)`
- `(tenant_id, project_id, object_entity_id) WHERE object_entity_id IS NOT NULL`
- `UNIQUE (tenant_id, project_id, scope, subject_entity_id, predicate, object_entity_id)
WHERE valid_to IS NULL AND object_entity_id IS NOT NULL`
- `UNIQUE (tenant_id, project_id, scope, subject_entity_id, predicate, object_value)
WHERE valid_to IS NULL AND object_value IS NOT NULL`

============================================================
3. EVIDENCE
============================================================

`graph_fact_evidence` columns:
- `evidence_id uuid PRIMARY KEY`
- `fact_id uuid NOT NULL REFERENCES graph_facts(fact_id) ON DELETE CASCADE`
- `note_id uuid NOT NULL REFERENCES memory_notes(note_id) ON DELETE CASCADE`
- `created_at timestamptz NOT NULL DEFAULT now()`

Indexes:
- `UNIQUE (fact_id, note_id)`
- `(note_id)`
- `(fact_id)`

============================================================
4. INVARIANTS
============================================================
- `graph_entities.canonical_norm` must be deterministic using:
- trim
- whitespace collapse to one space
- lowercase
- An active fact is defined by: `valid_from <= now AND (valid_to IS NULL OR valid_to > now)`.
- Active duplicate prevention is enforced by partial unique indexes.

============================================================
5. CALL EXAMPLES
============================================================

```
canonical = normalize_entity_name(" Alice Example ")
=> "alice example"

upsert_entity("tenant-a", "project-b", canonical, Some("person")) -> entity_id
upsert_entity_alias(entity_id, "A. Example")

insert_fact_with_evidence(
"tenant-a",
"project-b",
"agent-c",
"project_shared",
subject_entity_id,
"connected_to",
Some(object_entity_id),
None,
now,
None,
&[note_id_1, note_id_2],
)

fetch_active_facts_for_subject(
"tenant-a",
"project-b",
"project_shared",
subject_entity_id,
now,
)
```
47 changes: 47 additions & 0 deletions packages/elf-service/src/graph.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use time::OffsetDateTime;
use uuid::Uuid;

use crate::Result;
use elf_storage::graph;

#[allow(dead_code)]
pub(crate) struct GraphUpsertFactArgs<'a> {
pub tenant_id: &'a str,
pub project_id: &'a str,
pub agent_id: &'a str,
pub scope: &'a str,
pub subject_entity_id: Uuid,
pub predicate: &'a str,
pub object_entity_id: Option<Uuid>,
pub object_value: Option<&'a str>,
pub valid_from: OffsetDateTime,
pub valid_to: Option<OffsetDateTime>,
pub evidence_note_ids: &'a [Uuid],
}

impl crate::ElfService {
#[allow(dead_code)]
pub(crate) async fn graph_upsert_fact(&self, args: GraphUpsertFactArgs<'_>) -> Result<Uuid> {
let mut tx = self.db.pool.begin().await?;
let fact_id = graph::insert_fact_with_evidence(
&mut tx,
args.tenant_id,
args.project_id,
args.agent_id,
args.scope,
args.subject_entity_id,
args.predicate,
args.object_entity_id,
args.object_value,
args.valid_from,
args.valid_to,
args.evidence_note_ids,
)
.await
.map_err(|err| crate::Error::Storage { message: err.to_string() })?;

tx.commit().await?;

Ok(fact_id)
}
}
1 change: 1 addition & 0 deletions packages/elf-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod add_event;
pub mod add_note;
pub mod admin;
pub mod delete;
pub mod graph;
pub mod list;
pub mod notes;
pub mod progressive_search;
Expand Down
1 change: 1 addition & 0 deletions packages/elf-service/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,7 @@ impl ElfService {
Ok(result)
}

#[allow(clippy::too_many_arguments)]
async fn collect_recursive_candidates(
&self,
args: &RecursiveRetrievalArgs<'_>,
Expand Down
6 changes: 6 additions & 0 deletions packages/elf-service/tests/acceptance/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,10 @@ where
sqlx::query(
"\
TRUNCATE
graph_entities,
graph_entity_aliases,
graph_facts,
graph_fact_evidence,
memory_hits,
memory_note_versions,
note_field_embeddings,
Expand All @@ -410,6 +414,8 @@ TRUNCATE
memory_note_chunks,
note_embeddings,
search_trace_items,
search_trace_stage_items,
search_trace_stages,
search_traces,
search_trace_outbox,
search_sessions,
Expand Down
2 changes: 2 additions & 0 deletions packages/elf-storage/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
pub enum Error {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error("Invalid argument: {0}")]
InvalidArgument(String),
#[error(transparent)]
Qdrant(#[from] Box<qdrant_client::QdrantError>),
}
Expand Down
Loading
Loading