Version: 4.0 — AI SaaS (Graph RAG) & COSS Architecture
Status: Active
Last Updated: 2026-09-01
Source of Truth: This file + 02-Projects/RepoGraph Side Project.md (Validated PRD v2.0) + ARCHITECTURE.md
Supersedes: v3.0 JIT + ELK + Projection (retained below, extended with AI/COSS)
RepoGraph is NOT a crawler or code visualizer. It is a progressive context layer over GitHub that answers: “Who is involved, what is connected, what should I look at next?” within 1–2 hops of focus.
MVP Promise:
Any public repo can be focused in <15s to a readable subgraph (<15 nodes seeded, <50 total) that answers who owns what and which PRs relate. Full repo graph is never attempted.
Supported Nodes (MVP): repository | issue | pr | user
Supported Edges (Tier 1 eager, Tier 2 lazy): authored | assigned | closes | reviewed | review_requested | mentioned | referenced (see §8.1)
UX Guardrails:
- Focus DEFAULT ON — canvas never renders unfiltered hairball
focusrequired onGET /graph→ 400 if missingdepth ≤2,limit ≤400truncated server-sidetimeWindow7d/30d/90d/all (default 30d)- Lanes
Users | Issues | PRsleft-to-right, thickness = signal
Nodes: repository | issue | pr | user (+ review/comment/commit/file deferred to V1, see packages/graph-model/src/extended.ts)
Edges — Tiered:
- Tier 1 eager (materialized in
repository_edges):authored✅assigned✅reviewed✅review_requested✅closes(viaclosingIssuesReferences, confidencederived) ✅ - Tier 2 lazy (on-demand, 1-hop expand only):
mentioned⚠️ referenced⚠️ (via timeline, not eager) - Deferred:
commented,has_label,belongs_to_project,modified,owned_by
Experience Spec (replaces PRD §10–16):
Landing: [github.com/owner/repo] [Explore] + 1-click demos → POST /repositories/resolve → JIT seed
Overview (no graph yet): Health chips + 3 Lenses [Most Discussed 30d] [PRs Awaiting Review] [Recent Activity] + Search
Graph (on lens/search/focus): Hybrid List (left) + Graph (center, Focus ON, <15 seeded → <50 total) + Details (right)
Filters: Time 7/30/90/All (default 30d) • Status • Node type • Depth [1 hop][2 hops] (default 1) • + Expand per node
Edge hover: "Authored: Sarah created Issue #123 Aug 20 [View]" — one sentence + sourceUrl
Shareable: ?focus=issue:123&depth=1&timeWindow=30d → copy
Errors: degraded banner "Review info rate-limited. Issues/PRs available." never "Something went wrong"
Anti-Pattern Replaced: Upfront batch crawling of entire repo history (80k calls for kubernetes/kubernetes, 16h @ 1 token) → Just-In-Time (JIT) GraphQL Ingestion (1-hop on demand).
On initial POST /repositories/resolve for owner/repo:
- Single GraphQL query fetches repository summary metadata + top 10–15 active issues/PRs as initial canvas seeds (by
updatedAtdesc,state: OPEN, 2 pages × 15). Cost ≈ 3–5 GraphQL points. - Store seeds via
ON CONFLICT DO UPDATEintorepository_nodes+repository_edges(explicit edges only). - Cache adjacency list in Redis
adj:{repo_name}:{node_id}TTL per §30. - Return
202 { status: partially_ready, seedCount: 12, next: "expand via + or lens" }— no deep crawl. User sees overview + lenses in <5s.
Why 10–15 seeds: Enough to populate 3 lenses without hairball, small enough to stay <5s even at 60 req/hr unauthenticated, large enough to validate “who is involved”.
When user clicks + / [Expand] on any node:
POST /repositories/:repo/nodes/:id/expand or POST /repositories/:repo/graph/expand { nodeId, depth:1 }
→ GitHub GraphQL 1-hop query:
Issue/PR: author, assignees(first:10), requestedReviewers, reviews(first:20, states: APPROVED|CHANGES_REQUESTED|COMMENTED), crossReferences(first:20), closingIssuesReferences(first:10)
User: authored issues/PRs (first:10, recent)
→ Upsert transaction into Postgres (nodes + edges) + Redis adjacency cache
→ Return new nodes/edges + ELK layout delta
Cost per expand ≈ 2–4 points. Lazy mentioned/referenced only fetched here, not on cold start.
POST /api/nodes/:id/sync invalidates single entity subtree (see §30). Webhooks issues/pull_request → enqueue repository:refresh LOW priority with since: lastSuccessfulSyncAt.
Anti-Pattern Replaced: Single shared system token pool exhausting at 100 users → Multi-Tier Token Strategy (System Pool + User PATs).
| Tier | Source | Capacity | When Used |
|---|---|---|---|
| User PAT | x-github-token header (client-provided Personal Access Token) |
5,000 req/hr per user token | If present, bypass system pool — request routed through client token. No server cost. |
| System Pool | GITHUB_TOKEN + GINTR GITHUB_TOKENS (comma-separated, 5–10 tokens, Redis sliding-window) |
5,000 × N req/hr | If no x-github-token, consume system pool via leaky bucket (1 req/s/token, Retry-After honor). |
apps/api/src/common/guards/rate-limit.guard.ts (NestJS guard + middleware):
- Check
x-github-tokenheader — if present, validate viaGET /userwith that token, thenoctokit = new Octokit({ auth: userToken })for this request only. - Else,
token = tokenPool.acquire()(round-robin, skip blocked,remaining/resetAtfromX-RateLimitheaders). - Track usage via Redis
INCRsliding-windowrl:{tokenHash}:{minute}. - If system usage >85% capacity (
remaining < 750per token avg), return structured429 { code: "RATE_LIMIT_DEGRADED", message: "System pool at 85% — supply x-github-token or view cached nodes", retryAfter, degraded: true, cachedNodes: 12 }instead of generic 429. Frontend shows degraded banner + “Supply PAT” CTAs.
Headers Always: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After (when degraded).
Anti-Pattern Replaced: Batch jobs repository:issues, repository:pull-requests, repository:relationships pre-crawling thousands → JIT single-hop jobs only.
NestJS workers via BullMQ still process repository jobs, but only JIT:
repository:seed (HIGH, 1) — initial 10–15 active issues/PRs
repository:expand (NORMAL, 5) — 1-hop for nodeId
repository:sync (NORMAL, 5) — single node revalidation (POST /api/nodes/:id/sync)
repository:refresh (LOW, 10) — webhook incremental
Only seed and expand are MVP. seed is triggered on POST /repositories/resolve (cold start). expand is triggered on POST /repositories/:repo/graph/expand or POST /api/nodes/:id/sync.
Priority: seed (HIGH) > expand/sync (NORMAL) > refresh (LOW). Abandoned background scans never run.
HIGH repository seed (metadata + 15 active)
NORMAL expand / sync (1-hop, user-initiated)
LOW refresh (webhook incremental)
Viewer’s repo gets higher priority than abandoned scan via jobId = repo:fullName:seed:{etag} deduplication.
Anti-Pattern Replaced: Uniform TTL → Split TTL Caching (5m Active / 7d Closed) + Node Sync.
| Entity State | Redis Key | TTL | Rationale |
|---|---|---|---|
| Open Issue/PR, active User | node:{repo}:{id}, adj:{repo}:{node_id}, graph:{repo}:{focus}:{depth} |
5 minutes | High churn, need freshness. On hit, serve stale + background revalidate if >3m old. |
| Merged/Closed Issue/PR | same | 7 days | Immutable, cache long. On POST /api/nodes/:id/sync, force miss + re-fetch. |
| Graph response | graph:{repo}:{focus}:{depth}:{filtersHash} |
5m (active focus) / 7d (closed focus) derived from focus node state | See below. |
Implementation: CacheService checks node.state before SETEX. If state IN ('closed','merged') TTL 604800 else 300. GET honors stale-while-revalidate via X-Cache: HIT-STALE.
Granular Revalidation: POST /api/nodes/:id/sync (see §35.1) invalidates node:{id} + adj:{id} + any graph:* containing id (via SCAN graph:{repo}:*), then re-fetches 1-hop via JIT and re-caches with correct TTL.
Cache keys (unchanged):
repo:{owner}:{repo}
issue:{repo}:{number}
pr:{repo}:{number}
user:{login}
graph:{repo}:{focus}:{depth}:{filters}
Never rebuild whole repo. On POST /api/nodes/:id/sync or webhook:
Invalidate node:{id} + adj:{id}
↓
JIT 1-hop fetch for that node only
↓
Upsert affected nodes/edges
↓
Re-cache adjacency
Later, webhooks enable near-real-time for installed repos.
Frontend requests subgraph, not entire repo.
Example:
POST /repositories/:repo/graph/expand { "nodeId": "github:issue:123456", "depth": 1 }
GET /repositories/:id/graph?focus=issue:123456&depth=1&timeWindow=30dOptional filters:
{
"nodeTypes": ["issue", "pull_request", "user"],
"relationshipTypes": ["assigned", "authored", "reviewed", "closes"]
}Response: { nodes[], edges[], meta: { focus, depth, complete, truncated, cacheHit } }
meta.complete false if truncated at 400 nodes, meta.truncated true if sampled.
POST /api/nodes/:id/sync
Headers: x-github-token: <optional PAT>
→ { status: "synced", nodeId, refreshedAt, ttl: 300|604800, newNodes: 3, newEdges: 5 }Invalidates Redis, re-fetches 1-hop via JIT with correct token tier, upserts Postgres, re-caches. Used by user clicking ↻ Sync on entity details panel and by webhooks.
Avoid GET /repository/all-graph. Use ?focus=&depth= to keep payloads small, queries predictable, rendering fast.
PostgreSQL is primary datastore. No graph DB.
Core tables: repositories, users, issues, pull_requests, installation, plus projection tables repository_nodes / repository_edges (see §44).
Generic node:
interface GraphNode {
id: string; // "github:issue:123456" | "github:pr:789" | "github:user:101"
repo_name: string; // "goharbor/harbor"
entity_type: "repository" | "issue" | "pr" | "user";
entity_number?: number;
title?: string;
state?: string; // "open" | "closed" | "merged"
data: Record<string, unknown>; // raw GitHub payload + denormalized title/state
last_synced_at: Date;
}Edge:
interface GraphEdge {
id: string; // UUID
repo_name: string;
source_id: string; // FK → repository_nodes.id
target_id: string; // FK → repository_nodes.id
relationship_type: "authored" | "assigned" | "closes" | "reviewed" | "review_requested" | "referenced";
confidence: "explicit" | "derived" | "inferred";
created_at: Date;
}Why repo_name on edge: Enables WHERE repo_name = ? without JOIN for 1-hop adjacency, critical for idx_edges_forward/backward.
Domain tables (issues, pull_requests, users) + projection (repository_nodes/repository_edges) gives both reliable domain queries + flexible graph.
CREATE TABLE repository_nodes (
id VARCHAR(255) PRIMARY KEY, -- format: "github:issue:123456"
repo_name VARCHAR(255) NOT NULL,
entity_type VARCHAR(50) NOT NULL, -- 'issue' | 'pr' | 'user' | 'repository'
entity_number INT,
title TEXT,
state VARCHAR(50),
data JSONB NOT NULL,
last_synced_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE repository_edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
repo_name VARCHAR(255) NOT NULL,
source_id VARCHAR(255) REFERENCES repository_nodes(id) ON DELETE CASCADE,
target_id VARCHAR(255) REFERENCES repository_nodes(id) ON DELETE CASCADE,
relationship_type VARCHAR(50) NOT NULL, -- 'authored' | 'assigned' | 'closes' | 'reviewed' | 'review_requested' | 'referenced'
confidence VARCHAR(20) DEFAULT 'explicit', -- 'explicit' | 'derived' | 'inferred'
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_edge UNIQUE (repo_name, source_id, target_id, relationship_type)
);
CREATE INDEX idx_edges_forward ON repository_edges (source_id, repo_name);
CREATE INDEX idx_edges_backward ON repository_edges (target_id, repo_name);
CREATE INDEX idx_nodes_repo_type ON repository_nodes (repo_name, entity_type);Adjacency Query (1-hop, bidirectional, <10ms):
SELECT * FROM repository_edges
WHERE repo_name = 'goharbor/harbor'
AND (source_id = 'github:issue:123456' OR target_id = 'github:issue:123456');
-- Uses idx_edges_forward + idx_edges_backward via UNION or OR → BitmapOrLegacy tables: Existing Node/Edge (with repositoryId FK) remain for backward compat until migration is cut over; new code writes to both, reads from repository_nodes/repository_edges when USE_PROJECTION=true.
Details panel now shows last_synced_at + ↻ Sync button → POST /api/nodes/:id/sync (respects x-github-token tier).
Potential relationships now include reviewed with review_state in data (APPROVED etc.), closes via closingIssuesReferences (derived, not closes keyword parsing).
Anti-Pattern Replaced: React Flow jitter / random placement on expansion → Web Worker ELK.js Layout Engine + CSS Spring Shifts.
React Flow must not perform manual or random placement.
Pipeline:
User clicks + Expand
↓
Fetch 1-hop via JIT (2–4 points)
↓
Pass current graph + new entities to Web Worker (layout.worker.ts)
↓
Worker runs elkjs (layered algorithm, direction: RIGHT, spacing: 80x40)
↓
Compute new (x,y) without blocking main thread
↓
Apply to React Flow state with CSS transitions (transition: all 0.4s ease-out)
Implementation:
apps/web/workers/layout.worker.ts— dedicated Worker,import Elk from 'elkjs',elk.layout({ id: "root", layoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.spacing.nodeNode': '80', 'elk.layered.spacing.nodeNodeBetweenLayers': '40' }, children: nodes, edges })apps/web/hooks/useElkLayout.ts— manages Worker lifecycle,postMessage({ nodes, edges })→onmessage→setNodeswithposition+style: { transition: 'all 0.4s ease-out' }apps/web/components/graph/GraphCanvas.tsx— no manuallaneXplacement on expansion; initial seed lanes bootstrapped via ELK as well, but withanimate: falsefor first paint to avoid flash.- Performance: 400 nodes layout <300ms on worker, main thread free for pan/zoom. On low-end devices, fallback to
elk.algorithm: 'mrtree'ifnavigator.hardwareConcurrency <4.
Zustand Store:
apps/web/stores/graphStore.ts — focusedNodeId, loadedHops: Map<id, depth>, expandedNodes: Set<id>, isLayouting, actions focus(nodeId), expand(nodeId), sync(nodeId) → triggers JIT fetch + worker layout.
Validate GET /repositories/:id/graph?focus=...&depth=1 p95 <800ms (worker <300ms + GraphQL 1-hop <400ms + PG <10ms) on goharbor/harbor with 400 nodes.
Positioning: Repository Context AI Agent — interactive repository intelligence gated behind paid subscription. Unlike naive vector RAG, RepoGraph uses Graph-Augmented Retrieval (Graph RAG) to ensure zero hallucination on work provenance.
Core Principle: Never hallucinate connections, review statuses, or author relationships. Only assert relationships that exist in retrieved graph edges or explicit tool outputs. Cite Issue/PR numbers and usernames.
System Prompt — RepoGraph Context Intelligence Agent:
You are the RepoGraph Context Intelligence Agent, an expert repository navigator.
Your primary role is to answer questions about GitHub repository work items (Issues, Pull Requests, Reviews, and Ownership) using deterministic graph relationships.
CRITICAL OPERATIONAL RULES:
1. NEVER hallucinate connections, review statuses, or author relationships.
2. Only assert relationships that exist in the retrieved graph edges or explicit tool outputs.
3. Distinguish between 'review_requested' (pending action) and 'reviewed' (action completed with a state like APPROVED, CHANGES_REQUESTED, or COMMENTED).
4. Clearly distinguish 'explicit' relationships (provided by GitHub) from 'derived' references (extracted from cross-reference timeline events).
5. Always cite specific Issue/PR numbers and usernames when explaining a workflow.
6. When explaining complex workflows, provide a step-by-step chronological summary of the issue-to-merge lifecycle.
Instead of passing raw dumps or naive vector chunks to an LLM, the agent queries NestJS Graph tools to extract deterministic relationship subgraphs:
// Tool call → Prisma repository_edges (bidirectional) → deterministic subgraph → LLM context
const edges = await prisma.$queryRaw`
SELECT e.source_id, e.target_id, e.relationship_type, e.confidence,
src.entity_type as src_type, src.title as src_title,
tgt.entity_type as tgt_type, tgt.title as tgt_title
FROM repository_edges e
JOIN repository_nodes src ON e.source_id = src.id
JOIN repository_nodes tgt ON e.target_id = tgt.id
WHERE e.repo_name = 'goharbor/harbor' AND (e.source_id = 'github:issue:123' OR e.target_id = 'github:issue:123')
`;Ensures authored/closes/reviewed are grounded in repository_edges.confidence (explicit vs derived).
Definitions — packages/ai-tools/src/definitions.ts (also mirrored in ee/ai-agent):
import { z } from 'zod';
export const RepoGraphTools = {
getNeighborhood: {
description: 'Fetches 1-hop or 2-hop connected graph context around a GitHub entity.',
parameters: z.object({
repoOwner: z.string(), repoName: z.string(),
entityType: z.enum(['issue','pr','user']),
identifier: z.string(), // "123" or "octocat"
depth: z.number().min(1).max(2).default(1)
})
},
getWorkLifecycle: {
description: 'Traces issue/PR from creation → assignment → linked PRs → reviews → closing.',
parameters: z.object({ repoOwner: z.string(), repoName: z.string(), issueOrPrNumber: z.number() })
},
findAreaExperts: {
description: 'Identifies active authors/dominant reviewers for labels/milestones.',
parameters: z.object({ repoOwner: z.string(), repoName: z.string(), label: z.string().optional(), limit: z.number().min(1).max(10).default(5) })
},
// Aliases required by brief: findKeyContributors/topic, explainWorkLifecycle, identifyBlockers
findKeyContributors: { description: 'Analyzes review/author edges for topic/label', parameters: z.object({ topic: z.string(), label: z.string().optional() }) },
explainWorkLifecycle: { description: 'Traces Issue -> PR -> Reviewer -> Merge', parameters: z.object({ issueNumber: z.number() }) },
identifyBlockers: { description: 'Finds open dependencies & unapproved reviews', parameters: z.object({ issueNumber: z.number() }) },
};NestJS Handler — ee/ai-agent/src/graph-rag.service.ts:
@Injectable()
export class GraphRagService {
async getNeighborhood(params: { repoOwner:string, repoName:string, entityType:'issue'|'pr'|'user', identifier:string, depth?:number }) { /* … see §101.1 … */ }
async getWorkLifecycle(params: { repoOwner:string, repoName:string, issueOrPrNumber:number }) {
const rows = await this.prisma.$queryRaw`SELECT ... WHERE (src.entity_number = ${n} OR tgt.entity_number = ${n}) ORDER BY e.created_at`;
return { workNumber: params.issueOrPrNumber, lifecycleEvents: rows.map(r => ({ event: r.relationship_type, confidence: r.confidence, from: { id: r.src_id, type: r.src_type }, to: { id: r.tgt_id, type: r.tgt_type }, timestamp: r.created_at })) };
}
}Next.js Streaming Route — ee/ai-agent/src/route.ts:
import { streamText, tool } from 'ai'; import { openai } from '@ai-sdk/openai';
export async function POST(req: Request, graphRagService: GraphRagService) {
const { messages, repoContext } = await req.json();
const result = streamText({
model: openai('gpt-5-mini'),
system: `You are the RepoGraph AI Agent for ${repoContext.owner}/${repoContext.name}. Use tools to query the context graph.`,
messages,
tools: {
getNeighborhood: tool({ description: RepoGraphTools.getNeighborhood.description, parameters: RepoGraphTools.getNeighborhood.parameters, execute: async (a) => graphRagService.getNeighborhood(a) }),
getWorkLifecycle: tool({ description: RepoGraphTools.getWorkLifecycle.description, parameters: RepoGraphTools.getWorkLifecycle.parameters, execute: async (a) => graphRagService.getWorkLifecycle(a) })
},
maxSteps: 3
});
return result.toDataStreamResponse();
}| Tenant | Model | Quota |
|---|---|---|
| Self-hosted Pro | BYOK (OpenAI/Anthropic key in workspace.settings.llmApiKey, encrypted) |
Unlimited, user pays provider |
| Hosted SaaS | Managed pool (gpt-5-mini/claude-3.5) |
Pro: 100k tokens/mo, Team: 500k tokens/mo per workspace, tracked in ee/billing-stripe/token-usage (Redis tokens:{workspaceId}:{month} + Postgres billing_ledger) |
Metering middleware ee/ai-agent/src/token.guard.ts checks x-workspace-id + remaining before streamText, returns 429 { code: QUOTA_EXCEEDED } when exhausted, offers BYOK fallback.
Model: Open Core monorepo with hard directory isolation.
repograph/
├── apps/
│ ├── web/ # Next.js App Router (OSS UI) — AGPLv3
│ └── api/ # NestJS API Engine (OSS Backend) — AGPLv3
├── packages/
│ ├── graph-model/ # Canonical TS types — AGPLv3
│ └── layout-worker/ # ELK.js calculator — AGPLv3
└── ee/ # Proprietary SaaS & Enterprise — Commercial License
├── ai-agent/ # Graph RAG engine & tool callers
├── billing-stripe/ # Checkout, portal, webhook handlers
├── github-app-sync/ # Ephemeral token rotation & private webhooks
└── multi-tenancy/ # Workspace isolation & RBAC
| Scope | License | Rationale |
|---|---|---|
Core (/apps, /packages) |
AGPLv3 or FSL (Functional Source License, Busl-1.1 style) | Prevents cloud providers/competitors from selling proprietary hosted forks without contributing back. Network copyleft triggers on hosted use. |
Enterprise (/ee) |
Commercial License (proprietary) | SaaS-specific: Stripe, GitHub App private sync, multi-tenancy, AI. Not available under AGPL/FSL. |
Dual-license note: Contributors sign CLA (see §102.3) granting RepoGraph Inc. relicensing rights for commercial distribution.
- Source code is open (AGPLv3/FSL), brand marks are proprietary: name RepoGraph, logos, wordmarks,
repograph.appdomain assets. - Trademark Terms: Hosted forks must rebrand (“Powered by RepoGraph” allowed, “RepoGraph Cloud” not). Automated check in
CONTRIBUTING.md+TRADEMARK.md. - Enforcement via
LICENSEheader +ee/boundary + GitHubCODEOWNERSfor/ee.
- All community PRs require CLA via
cla-assistantGitHub Action (.github/workflows/cla.yml). - CLA text:
CLA.md— grants patent + copyright license to RepoGraph Inc., ensures FSL/Commercial relicensing, does not transfer ownership. - Bot comments
CLA not signedand blocks merge until/cla sign.
Zero-friction GitHub App flow (no manual PATs in SaaS):
- User clicks
Connect GitHub→GET /ee/github-app-sync/install→https://github.com/apps/repograph/installations/new(select org/repos, read-only:contents:read,metadata:read,issues:read,pull_requests:read). - Callback
GET /ee/github-app-sync/callback?installation_id→ backend storesinstallation { id, accountLogin }, mints ephemeral token viaPOST /app/installations/{id}/access_tokens(60m TTL), cachesgithub:installation:{id}:tokenin Redis (50m), never stores long-lived secret. - Frontend stores only
installationId+workspaceId, sendsX-Installation-Id+X-Workspace-Idon private graph requests.
Ephemeral Credentials:
- Zero long-lived repo secrets.
ee/github-app-sync/src/token.service.tsgetInstallationToken(installationId)→ JWT (10m) → GitHub →ghs_...(60m) → Redis 50m. Concurrent callers useSET NXlock. GITHUB_APP_PRIVATE_KEYfromeeKMS/Secrets Manager, never repo.
Tenant Isolation:
- PostgreSQL RLS or compound indices: All private
repository_nodes/repository_edgescarryworkspace_id+installation_id(not justrepo_name). Queries must includeWHERE workspace_id = $1 AND installation_id = $2(or RLS policyUSING (workspace_id = current_setting('app.workspace_id'))). Compound index(workspace_id, installation_id, repo_name)prevents cross-tenant scan. Addworkspace_idtorepository_nodes/repository_edges(nullable for public,NOT NULLfor private). - Enforcement:
ee/multi-tenancy/src/tenant.guard.tsextractsx-workspace-id+x-installation-idfrom headers, setsSET LOCAL app.workspace_id, and rejects ifworkspaceIdmismatchesinstallation.accountLogin. - Test:
SELECT * FROM repository_nodes WHERE workspace_id != $1must return 0 for any tenant.
Cache Partitioning (Encrypted, Namespaced):
| Scope | Redis Key | TTL | Notes |
|---|---|---|---|
| Public | graph:public:{owner}:{repo}:{nodeId} (old graph:{owner}:{repo}:... aliased) |
5m active / 7d closed | Shared, no workspace |
| Private | graph:private:{workspaceId}:{installationId}:{nodeId} |
Same split, plus 60m token TTL | Encrypted via ee/multi-tenancy/src/crypto.service.ts (AES-256-GCM with REDIS_ENCRYPTION_KEY) when ENCRYPT_PRIVATE_CACHE=true |
Implementation: CacheService.graphKey now branches on isPrivate: isPrivate ? graph:private:{workspaceId}:{installationId}:{focus}:{depth} : graph:public:{owner}:{repo}:{focus}:{depth}. Legacy graph: keys remain as alias with private:false for backward compat until cutover (USE_PARTITIONED_CACHE=true).
| Feature | Free (Community / OSS) | Pro (Developer / Individual) | Team / Enterprise |
|---|---|---|---|
| License | AGPLv3 self-hosted | AGPLv3 + Commercial /ee (hosted) |
Commercial + SLA |
| Public URL exploration | ✅ Unlimited (3/day/IP soft-gate on hosted) | ✅ Unlimited | ✅ Unlimited |
| JIT 1-hop / 2-hop engine | ✅ | ✅ | ✅ |
| React Flow canvas + ELK worker | ✅ | ✅ | ✅ |
| AI Chat Assistant (Graph RAG) | ❌ (prompt to upgrade) | ✅ 100k tokens/mo (managed) or BYOK | ✅ 500k tokens/mo + BYOK + workspace pool |
| GitHub App private repo sync | Self-host only (manual PAT) | ✅ 1 workspace, 5 private repos (ephemeral 60m tokens) | ✅ Unlimited workspaces, RBAC, RLS |
| Multi-tenant RBAC | ❌ | ❌ | ✅ ee/multi-tenancy (Owner/Admin/Member, workspace_id RLS) |
| Stripe billing & quotas | ❌ | ✅ Checkout/portal/webhooks ee/billing-stripe |
✅ + invoicing, SSO |
| Encrypted Redis tenant caches | ❌ | ✅ (private namespaces) | ✅ |
| Support | Community (GitHub Issues) | Email (48h) | Slack + SLA (24h), DPA |
Billing Implementation: ee/billing-stripe (Checkout POST /ee/billing/checkout, Portal POST /ee/billing/portal, Webhooks POST /ee/billing/webhook → workspace.billingStatus, Redis tokens:{workspaceId}:{month} metering, 429 QUOTA_EXCEEDED).
Validate GET /repositories/:id/graph?focus=...&depth=1 p95 <800ms and AI getNeighborhood p95 <400ms (Prisma repository_edges 1-hop <10ms + LLM streaming <300ms) on goharbor/harbor.