Status: Accepted (revised 2026-05-18) Date: 2026-01-04 Deciders: QNTX Core Team
Plugins need to share data and coordinate work. How should plugins communicate?
Requirements:
Plugins communicate through three mechanisms, all mediated by core:
ServiceRegistryNo direct plugin-to-plugin calls exist. Core mediates everything.
┌──────────┐ ┌──────────┐
│ Plugin A │ ──┐ ┌── │ Plugin B │
└──────────┘ │ │ └──────────┘
▼ ▼
┌──────────┐ ┌───────────┐
│Attestation│ │ Service │
│ Store │ │ Registry │
└──────────┘ └───────────┘
▲ ▲
│ │
┌──────────┐ ┌──────────┐
│ Pulse │ │ Plugin C │
│ (async) │ └──────────┘
└──────────┘
Plugins write attestations to record events:
// Code plugin creates attestation when git repo is ingested
attestation := &types.As{
Actor: "ixgest-git@user",
Context: "repository_ingested",
Entity: "github.com/teranos/QNTX",
Payload: json.RawMessage(`{"commit_count": 150, "language": "Go"}`),
}
store.Create(ctx, attestation)
Other plugins query attestations to discover events:
// Finance plugin watches for new repositories
filter := &types.AxFilter{
Context: ptr("repository_ingested"),
}
repos, err := store.Query(ctx, filter)
Plugins maintain state in attestations:
// Code plugin updates file analysis state
attestation := &types.As{
Actor: "code-analyzer@system",
Context: "file_analyzed",
Entity: "domains/code/plugin.go",
Payload: json.RawMessage(`{
"complexity": 12,
"coverage": 85.3,
"last_modified": "2026-01-04"
}`),
}
Long-running cross-plugin workflows use Pulse jobs:
// Code plugin triggers dependency analysis job
job := &async.Job{
HandlerName: "analyze_dependencies",
Payload: json.RawMessage(`{"repo": "github.com/teranos/QNTX"}`),
Source: "code-plugin",
}
queue.Enqueue(ctx, job)
Plugins that provide capabilities (LLM, search, embedding, Python, fetch) register them with core via optional interfaces (ADR-001). Other plugins consume these services through ServiceRegistry without knowing which plugin provides them:
// Plugin calls LLM — core routes to whichever LLM provider is active
resp, err := services.LLM().Chat(ctx, req)
This is indirect plugin-to-plugin communication: Plugin A calls core, core routes to Plugin B. Neither plugin knows the other exists.
Prohibited:
// ❌ WRONG: Direct plugin-to-plugin calls
financePlugin := registry.Get("finance")
financePlugin.(*finance.Plugin).AnalyzeRepository(repo)
Rationale:
Correct approach:
// ✅ RIGHT: Communication via attestations
store.Create(ctx, &types.As{
Actor: "code@plugin",
Context: "repository_ready",
Entity: repoURL,
Payload: json.RawMessage(`{"trigger": "finance_analysis"}`),
})
Plugins interact with QNTX exclusively via ServiceRegistry (ATSStore gRPC API):
type ServiceRegistry interface {
Database() *sql.DB
Logger(domain string) *zap.SugaredLogger
Config(domain string) Config
ATSStore() ats.AttestationStore
Queue() QueueService
Schedule() ScheduleService
FileService() FileService
LLM() LLMService // plugin-provided (ADR-014)
VectorSearch() VectorSearchService // plugin-provided (ADR-016)
Search() SearchService // plugin-provided (ADR-015)
}
Services like LLM(), Search(), and VectorSearch() return nil when no provider plugin is registered. Plugins that provide these services register via optional interfaces on DomainPlugin (ADR-001).
✅ Decoupling: Plugins can be added/removed without affecting others ✅ Async by default: Database acts as durable message queue ✅ Consistency: Database transactions provide ACID guarantees ✅ Queryable: Ax query language works across all plugin data ✅ Audit trail: All plugin interactions are attestations (queryable history)
⚠️ Latency: Database round-trip slower than direct method calls ⚠️ Complexity: Developers must think in events/attestations, not procedure calls ⚠️ Polling: Plugins may need to poll for new attestations (mitigated by Pulse jobs)
// 1. Code plugin ingests git repo
store.Create(ctx, &types.As{
Actor: "ixgest-git@user",
Context: "repository_cloned",
Entity: "github.com/teranos/QNTX",
Payload: json.RawMessage(`{"path": "/tmp/qntx", "branch": "main"}`),
})
// 2. Pulse job monitors for new repos and triggers analysis
// (This could be a scheduled job or a separate plugin watching attestations)
// 3. Analysis results also stored as attestations
store.Create(ctx, &types.As{
Actor: "code-analyzer@system",
Context: "repository_analyzed",
Entity: "github.com/teranos/QNTX",
Payload: json.RawMessage(`{"files": 250, "loc": 45000, "complexity": 3.2}`),
})
Finance plugin wants to analyze code complexity:
// Finance plugin queries code plugin's attestations
filter := &types.AxFilter{
Actor: ptr("code-analyzer@system"),
Context: ptr("repository_analyzed"),
Entity: ptr("github.com/teranos/QNTX"),
}
results, err := store.Query(ctx, filter)
// Parse results[0].Payload to get complexity metrics
No code→finance dependency, finance reads code's public data (attestations).