graph-only-architecture
Design proposal for making the graph the single source of truth, eliminating markdown files.
Motivation
Current workflow:
Markdown files → Parse frontmatter → Extract entities → Sync to graphProblems:
- Two sources of truth (files + graph)
- Sync step required after every change
- Entity extraction is a separate manual step
- Claude Code must write files, then run sync
Proposed workflow:
Claude Code → CLI → Graph (direct)Benefits:
- Single source of truth
- No sync step needed
- Immediate consistency
- Simpler mental model
Schema Changes
Current Schema (v1.0.3)
Lattice uses a unified nodes table for all entity types (including Documents):
-- Single table for all node types (graph.service.ts:136-148)CREATE TABLE nodes ( label VARCHAR NOT NULL, -- 'Document', 'Technology', 'Concept', etc. name VARCHAR NOT NULL, -- File path (for docs) or entity name properties JSON, -- { title, summary, topic, description, ... } embedding FLOAT[512], -- Voyage voyage-3-lite embeddings created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY(label, name) -- Composite key);
-- Relationships between nodes (graph.service.ts:150-162)CREATE TABLE relationships ( source_label VARCHAR NOT NULL, source_name VARCHAR NOT NULL, relation_type VARCHAR NOT NULL, -- 'REFERENCES', 'APPEARS_IN' target_label VARCHAR NOT NULL, target_name VARCHAR NOT NULL, properties JSON, -- { documentPath, ... } created_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY(source_label, source_name, relation_type, target_label, target_name));Current document storage:
label = 'Document'name = file path(e.g.,/home/uptown/.lattice/docs/lattice/README.md)properties = { title, summary, topic, status, ... }- Content lives in markdown files, NOT in the graph
Proposed Schema (Graph-Only)
Option A: Add content to properties JSON (minimal change)
-- No schema change needed!-- Just store content in the existing properties JSON:properties = { "title": "Research Title", "summary": "Brief summary", "topic": "databases", "content": "Full research content here..." -- NEW}Option B: Add dedicated content column (cleaner)
ALTER TABLE nodes ADD COLUMN content TEXT;
-- Then for Document nodes:-- label = 'Document'-- name = UUID or slug (not file path)-- content = full research text-- properties = { title, summary, topic }Key changes either way:
namechanges from file path → UUID/slug for documents- Content stored in graph instead of files
- Remove
content_hash,frontmatter_hashfrom manifest (no files to track)
Recommendation
Option A (properties JSON) for Phase 1:
- No schema migration needed
- DuckDB handles large JSON values well
- Can extract to dedicated column later if needed
// GraphService additionasync createDocument(doc: { title: string; content: string; ... }): Promise<string> { const id = crypto.randomUUID(); await this.upsertNode('Document', { name: id, title: doc.title, summary: doc.summary, content: doc.content, // Store in properties topic: doc.topic, }); return id;}New CLI Commands
lattice create
Create a new document directly in the graph.
# Basic usagelattice create --title "Topic Name" --content "Research content..."
# With entities (JSON format)lattice create \ --title "DuckDB Performance Analysis" \ --content "Full research content here..." \ --summary "Analysis of DuckDB query performance" \ --topic "databases" \ --entities '[{"name": "DuckDB", "type": "Technology", "description": "Embedded OLAP database"}]' \ --relationships '[{"source": "DuckDB", "relation": "IMPLEMENTS", "target": "OLAP"}]'
# From stdin (for piping)echo "Research content" | lattice create --title "Topic" --stdin
# Returns: document ID# Output: Created document abc123-def456-...Implementation (following existing NestJS patterns in src/commands/):
@Injectable()@Command({ name: 'create', description: 'Create a new document in the graph' })export class CreateCommand extends CommandRunner { constructor( private readonly graphService: GraphService, private readonly embeddingService: EmbeddingService, ) { super(); }
async run(params: string[], options: CreateOptions): Promise<void> { const id = crypto.randomUUID();
// Upsert document node (uses existing GraphService.upsertNode) await this.graphService.upsertNode('Document', { name: id, title: options.title, summary: options.summary, content: options.content || await this.readStdin(), topic: options.topic, });
// Generate embedding (uses existing EmbeddingService) const embeddingText = `${options.title}\n\n${options.summary || ''}`; const embedding = await this.embeddingService.embed(embeddingText); await this.graphService.updateNodeEmbedding('Document', id, embedding);
// Create entity relationships if provided if (options.entities) { const entities = JSON.parse(options.entities); for (const entity of entities) { await this.graphService.upsertNode(entity.type, { name: entity.name, description: entity.description, }); await this.graphService.upsertRelationship( entity.type, entity.name, 'APPEARS_IN', 'Document', id, ); } }
console.log(`Created document ${id}`); process.exit(0); }}lattice update
Update an existing document.
# Update contentlattice update <id> --content "Updated content..."
# Update specific fieldslattice update <id> --title "New Title" --summary "New summary"
# Add entities to existing documentlattice update <id> --add-entities '[{"name": "Redis", "type": "Technology"}]'
# From stdinecho "New content" | lattice update <id> --stdinImplementation:
@Command({ name: 'update', description: 'Update an existing document' })export class UpdateCommand extends CommandRunner { async run([id]: string[], options: UpdateOptions): Promise<void> { const updates: Partial<Document> = {};
if (options.content) updates.content = options.content; if (options.title) updates.title = options.title; if (options.summary) updates.summary = options.summary; if (options.stdin) updates.content = await this.readStdin();
updates.updated = new Date();
await this.graphService.updateDocument(id, updates);
// Re-generate embedding if content changed if (updates.content) { const doc = await this.graphService.getDocument(id); const embedding = await this.embeddingService.embed( composeDocumentEmbeddingText(doc) ); await this.graphService.updateEmbedding(id, embedding); }
console.log(`Updated document ${id}`); }}lattice get
Retrieve a document’s full content.
# Get full document as JSONlattice get <id>
# Get just content (for piping)lattice get <id> --content-only
# Get with related entitieslattice get <id> --with-entities
# Output formatlattice get <id> --format json|markdown|textOutput example:
{ "id": "abc123-def456", "title": "DuckDB Performance Analysis", "content": "Full research content here...", "summary": "Analysis of DuckDB query performance", "topic": "databases", "created": "2025-12-07T15:00:00Z", "updated": "2025-12-07T15:30:00Z", "entities": [ {"name": "DuckDB", "type": "Technology", "description": "..."} ], "relationships": [ {"source": "DuckDB", "relation": "IMPLEMENTS", "target": "OLAP"} ]}lattice delete
Remove a document from the graph.
lattice delete <id>lattice delete <id> --force # Skip confirmationlattice list
List documents with optional filtering.
lattice list # All documentslattice list --topic databases # Filter by topiclattice list --recent 10 # Last 10 modifiedlattice list --format table|jsonUpdated /research Workflow
The /research slash command changes to:
## Process
### Step 1: Search Existing Researchlattice search "<query>" --limit 10
### Step 2: Review Results- If found: Display document content- Ask user if existing research answers their question
### Step 3: Create New (if needed)If user wants new research:
1. Perform web research2. Create document directly:
lattice create \ --title "Research Title" \ --content "$(cat <<'EOF'# Research Title
## Summary...
## Key Findings...
## Sources...EOF)" \ --summary "Brief summary for search" \ --entities '[...]' \ --relationships '[...]'
### Step 4: Entity ExtractionEntities are provided at creation time via --entities flag.No separate extraction step needed.Migration Path
Phase 1: Add New Commands (Non-Breaking)
- Add
contentcolumn to existing schema - Implement
create,update,get,deletecommands - Keep existing
synccommand working - Test with Claude Code
Phase 2: Migrate Existing Data
-- Migrate content from files to graph-- Run once for existing documentsUPDATE documentsSET content = (SELECT read_text(path))WHERE content IS NULL;Or via CLI:
lattice migrate-content # Reads files, populates content columnPhase 3: Deprecate File-Based Commands
- Mark
sync,statusas deprecated - Update
/researchcommand to use new workflow - Remove file watching
Phase 4: Remove File Support
- Remove
sync.command.ts - Remove
document-parser.service.ts - Remove
manifest.service.ts - Remove path-based operations
- Update schema to remove
pathcolumn
Trade-offs
What We Lose
| Feature | Current | Proposed | Mitigation |
|---|---|---|---|
| Git history | Full file history | No automatic versioning | Add document_versions table? |
| Human-readable | Edit in any editor | CLI-only editing | lattice get --format markdown |
| Collaboration | Git merge/conflict resolution | Single-writer | Out of scope for personal KB |
| Backup | Copy files | lattice export | Add export command |
| Grep/find | Unix tools work | Must use CLI | lattice search is better anyway |
What We Gain
| Feature | Current | Proposed |
|---|---|---|
| Single source of truth | Files + Graph | Graph only |
| Immediate consistency | Requires sync | Always consistent |
| Simpler mental model | Write → Sync → Search | Write → Search |
| Claude Code integration | Write file + run sync | Single CLI call |
| Entity handling | Separate extraction step | Inline at creation |
Open Questions
-
Versioning: Do we need document version history? Could add
document_versionstable. -
Backup/Export: Should
lattice exportdump to markdown for backup? -
Bulk import: Keep a
lattice import <file.md>for one-time migrations? -
Entity auto-extraction: Should
createauto-extract entities from content, or require explicit--entities?
Next Steps
- Implement
contentcolumn in schema - Implement
lattice createcommand - Implement
lattice getcommand - Implement
lattice updatecommand - Update
/researchslash command - Test end-to-end with Claude Code
- Deprecate
synccommand - Remove file-based code
Sources
Lattice codebase (~/Projects/zabaca/lattice/):
- Graph schema:
src/graph/graph.service.ts:136-162 - Node operations:
src/graph/graph.service.ts:203-232(upsertNode) - Vector search:
src/graph/graph.service.ts:464-506 - Sync workflow:
src/sync/sync.service.ts - Document parser:
src/sync/document-parser.service.ts - Path utilities:
src/utils/paths.ts - Example command:
src/commands/init.command.ts
External docs:
- DuckDB JSON functions
- DuckDB VARCHAR/TEXT
- nest-commander - CLI framework used by Lattice