Extensibility
Extension model
The eight capability systems — Intake, Atlas, Warrant, Lineage, Conduit, Lens, Crucible, Ports — are extensible by third parties. A plugin is an in-process TypeScript module that declares what it needs, contributes to named extension points, and reads and writes the other systems only through a permission-gated facade and a workspace-scoped event bus. It never receives a pool handle, a transaction, or a workspaceId it chose itself — which is what lets the product stay extensible without weakening the property it is actually sold on: Postgres RLS multi-tenancy.
Zero plugins is the normal case
hasContributions(kind) — a synchronous frozen-map lookup — so a deployment with no plugins pays one map lookup per call site and never touches the database. Plugins are additive and optional, always.How the pieces fit
The contract
The manifest declares everything statically — id, semver, the host API range it supports, permissions, event subscriptions, a config schema, and the name of every contribution — while setup() merely binds implementations to those declared names. The host then asserts that declaration and registration are exactly equal. A plugin can therefore be inspected, permission-reviewed and diffed across versions without executing any plugin code, and a plugin that quietly grows a contribution or a permission fails at load rather than at 3am.
Manifest fields
Extension points, by system
Eleven named points. Each one is consumed by the system that owns it, through a single host helper (invokeContributions) that resolves the workspace's active installs, checks the capability the point requires, builds a per-(plugin, workspace) context, runs the contribution under a deadline, and returns typed outcomes in deterministic plugin-id order. Written once in the host rather than seven times in the consuming systems, which is what makes "the capability check happened" a property rather than a convention.
| Extension point | Consumed by | What it does · budget |
|---|---|---|
| intakeConnectors | src/server/connectors/registry.ts | A new source type. The closed Record<ConnectorType, factory> is now open: built-ins resolve first, a miss consults the registry. 60s per page, 300s max. |
| intakeDocumentTransforms | src/server/connectors/sync.ts | Pre-persist document rewrite, after normalisation and before hashing. Must be pure and deterministic. 2s (5s max). |
| dataAdapters | src/server/data/adapters/index.ts | Conduit live-data engine. Resolved synchronously and shape-validated; the read-only SQL gate stays host-owned. |
| rerankers | src/server/retrieval/rerank/index.ts | A Lens reranker kind. The host owns the reported kind so a plugin cannot impersonate "voyage" in telemetry. |
| candidateChannels | src/server/retrieval/pipeline.ts | An extra retrieval channel merged into the candidate pool. ACL-filtered downstream: the worst a channel can do is promote documents the caller could already read. 800ms (2s max). |
| evalMetrics | src/server/evals/runner.ts | A named metric on every eval run, namespaced into the additive run_metrics.plugin_metrics bag so it cannot collide with a first-party number. 2s (5s max). |
| lineageSections | src/server/lineage/index.ts | A titled table appended to DocumentLineage.contributed. 1s (2s max) — an overrun sets partial: true and omits the section. |
| policyObservers | src/server/policy/decisions.ts | Read-only, post-hoc observer of an answer-policy decision. Never able to change a verdict. 2s (5s max). |
| tools | src/server/mcp/server.ts · agent harness | One definition becomes both an agent tool and an MCP tool, behind the same scope guard as a native tool. 30s. |
| jobs | src/worker/index.ts | A pg-boss queue, auto-namespaced to plugin.<id>.<name> so it can never collide with a core queue. 60s (300s max). |
| httpOperations | src/app/api/v1/plugins/[...slug]/route.ts | A REST operation under /api/v1/plugins/<id>/<name>, behind the ordinary API-key guard. The declared method and required capability are both enforced before the handler runs. 15s (30s max). |
A plugin gets a mounted namespace, not arbitrary routes
/api/v1/plugins/<id>/<operation>and nowhere else. Next's file router is static, so a plugin that could claim a path of its own choosing could also shadow a first-party endpoint. Everything above that prefix stays ours; everything below it is plugin territory. Four consequences worth knowing before you write one:- Auth runs first.A workspace or user API key is required before any plugin code is reached, and the key's own capabilities join the
declared ∩ granted ∩ principalintersection — a plugin is never a ladder to something the caller could not do directly. - The declared
methodis a gate. The catch-all accepts every verb, so the manifest is the only thing standing between a GET handler and a POST:405. - Everything unknown is
404, with one body. An unknown plugin, one that is not installed here, and an unknown operation are indistinguishable — status codes must not let a prober enumerate what a deployment ships. - A failing handler is
502, not 500. Throw, hang past the 15s budget, or return something that is not aResponse, and the host answers "the upstream failed" — which is what tells an operator whose logs to read.
Register the same function as a tool too
tool, by contrast, is discoverable through both the agent harness and MCP. The example plugin registers the same function under both, which costs one line and makes the capability reachable by a model and by curl.The event taxonomy
A closed, versioned set of core events, derived from real emit sites — the places that already write an audit row or finalise a durable record, so an event is never a second source of truth. Plugins subscribe to these; a plugin may only emit under its own namespace (plugin.<id>.<name>), so the core vocabulary cannot be counterfeited.
| System | Live — has an emitter today | Declared, not yet emitted |
|---|---|---|
| Intake | document.ingested · document.updated · document.deleted · connector.source.created / updated / deleted · connector.sync.completed / failed | document.revision.recorded |
| Atlas | graph.extraction.completed · entity.created | entity.merged · entity.deleted |
| Warrant | entity.certified · policy.decision.recorded | entity.deprecated · entity.authority_changed · document.verified · change_request.submitted / approved / rejected |
| Conduit | data.call.completed | data.query.saved |
| Lens | answer.produced | cache.invalidated |
| Crucible | eval.run.completed · alert.raised | drift.detected |
| Ports | agent.run.completed / failed / suspended | apikey.created · apikey.revoked |
Nineteen of the thirty-three names have an emitter
ALL_PLUGIN_EVENT_NAMES against the emit sites in src/server/ before you build a feature on an event, and if you need one that is not live, add the emitter to the module that already writes that record — never to a second place, or the event becomes a rival source of truth.An event carries identifiers and metadata, never content — never document text, never an answer body — and is capped at 8KB. Reading the thing an event points at is a separate, permission-checked, ACL-checked act, which is precisely what stops the bus from becoming a way to see answers you are not entitled to.
Two delivery channels
| Channel | Guarantee | When to use it |
|---|---|---|
| queued | durable, at-least-once — written to a transactional outbox inside the producing transaction, delivered by the worker drain, retried with exponential backoff (2s, 4s, 8s, 16s), dead-lettered after 5 attempts, replayable | The default, and the one to reach for. Anything that writes, calls another system, or must not be lost. Handlers must be idempotent: key your state on the event's ULID. |
| inline | best-effort, at-most-once — runs in the producing process, on the producing request's clock | Only when the payload already carries everything and the handler makes zero facade calls. Losing one delivery must be acceptable. |
A subscription may additionally set ordered: true (queued only) to serialise deliveries per subject, trading throughput for the guarantee that a retried older event cannot land after a newer one for the same document. The outbox is the durability guarantee; the worker's one-minute sweep is only the trigger, so a missed tick delays delivery and never loses it.
Permissions
A plugin declares the capabilities it needs; the runtime enforces that declaration at call time. The effective set is a three-way intersection, and every facade method asserts against it before touching any server module — so a denied write is not a rolled-back write, it never starts.
Permissions come from the existing capability union — no parallel vocabulary, no new API-key scope — so role editors, custom roles and the scope→capability map keep working unchanged. A request-derivedcontext (an MCP tool call, an HTTP operation) also intersects with the caller's live capabilities, so a viewer cannot use a plugin as a privilege ladder. An ambient context (a queued handler, a worker job, a scheduled scan) has no principal and runs at declared ∩ granted, attributed to plugin:<id>. Granting is itself bounded by the granting admin's own capabilities, exactly as workspace-key creation bounds scopes today.
A denial is explicit, never a silent empty result: it throws PluginPermissionError and writes a plugin.permission_denied row naming the capability, the surface and one of three reasons — not_declared (fix the manifest), not_granted (an admin must grant it), or principal_lacks (the caller is not allowed to do this). That distinction is what makes a denial actionable instead of a mystery.
Enterprise guarantees
| Guarantee | Enforced by | What it means concretely |
|---|---|---|
| Tenancy | ctx.data → withWorkspace() | The plugin API has no database primitive at all — no pool, no Tx, no SQL. Every method delegates into the same exported server function the UI and REST call, which already runs inside withWorkspace, so RLS, content ACLs, ontology constraints, the read-only SQL gate and spend caps apply identically. workspaceId is injected from the triggering event or request and is never plugin-supplied. |
| Least privilege | permissions.ts · assertCapability | A plugin that never declared graph:write cannot write entities, and the failure is an explicit typed error plus an audit row — not a silent no-op. The three-way intersection is re-evaluated per call, so revoking a grant takes effect immediately. |
| Failure isolation | contain.ts · runContained | Every invocation runs under a per-surface timeout, a per-(plugin, workspace) concurrency limit of 16, and a circuit breaker that opens after 5 consecutive failures in a 5-minute window and stays open for 15 minutes. A throw, a hang or a tripped breaker degrades the FEATURE — an omitted lineage section, a skipped metric, an untransformed document — and never the request, the worker, or another plugin. |
| Auditability | audit.ts → the existing audit_log | Install, grant, permission denial, circuit-open and every plugin-initiated write land in the same audit_log the rest of the product uses, actor-stamped plugin:<id>. No new audit surface to learn or to ship to a SIEM separately. |
| Determinism & versioning | registry.ts · semver.ts | Load is resolve → validate → register → freeze, ordered lexicographically by id, with named failures (duplicate_id, bad_semver, api_version, contribution_collision). A patch or minor bump with unchanged permissions auto-adopts; a major bump, a widened permission set, or a downgrade parks the install in needs_review until an admin re-grants — loudly at load, not mysteriously at runtime. |
| Zero-plugin equivalence | hasContributions · empty frozen registry | With nothing installed, no queue is created, no row is written, and the drain returns before its first query. The app must keep working with zero plugins — and it does, as a tested property. |
This is an extension model, not a security sandbox
Plugins are in-process TypeScript modules. There is no VM, no worker isolate, and no new heavyweight dependency — the guarantees above come from capability scoping, timeouts, containment and audit, not from isolation. Be precise about what that does not protect against:
- A plugin can
importanything the process can, including@/server/db. Nothing at runtime stops it from opening its own query path and bypassing the facade entirely. - It can call global
fetchinstead of the SSRF-guardedctx.fetch, readprocess.env, and touch the filesystem. - Timeouts are cooperative: they bound wall clock and payload size, not CPU or memory. A synchronous infinite loop defeats every budget on this page, because nothing can interrupt a loop that ignores its
AbortSignal.
The real control is therefore installation, not enforcement. A plugin is installed by a static import in src/server/plugins/installed.ts and nothing else — no filesystem scan, no glob, no import() of a path read from the database, which would be a remote-code-execution vector. Adding a plugin is a deploy-time diff reviewed exactly as seriously as merging code into src/server. Enablement, configuration and permission granting are runtime, per-workspace and audited; installation is code review. Treat a third-party plugin as a dependency you are vendoring, not as untrusted input you are sandboxing.
Where to go next
Follow Build a plugin to scaffold, declare, subscribe, read and write across systems, test and load one — worked end to end against the real example plugin. Then read Extend or modify a plugin for changing one you did not write: which edits are safe, which trip needs_review, and how to add an extension point to the host itself. The internal decision record lives in docs/architecture/plugins.md.