Skip to content

Extension IoC Safeguards — keeping the extension system truly extensible

This page is the durable control that keeps the extension system extensible: an executable definition of “extensible”, the CI gates that make a regression un-mergeable, and the review contract every change is held to. A genuinely extensible system is the prerequisite for live runtime install and uninstall — you cannot hot-uninstall a capability that core has statically wired.

Core defines the kinds of extension (agent / connector / artifact / skill / workflow) and discovers instances dynamically from the installed_extension manifest. Core must never name, import, or hardcode a specific extension instance. Capabilities come from the manifest plus the per-kind native stores via the runtime-discovery dispatcher — never from if (packageName === "@cinatra-ai/foo"), a hardcoded @scope/pkg string, or an extensions/<scope>/<name>/… path.

The single active-manifest dispatcher (split-brain guard)

Section titled “The single active-manifest dispatcher (split-brain guard)”

There is exactly one discovery dispatcher, discoverActiveExtensionCapabilities, and it is the only authority on “what is live right now”. It resolves discovery as:

discovery = (installed_extension rows with status active|locked)
∩ (per-kind visible native rows for the actor/scope)

This intersection is the split-brain guard. The canonical installed_extension manifest is the lifecycle authority; the per-kind native store holds the kind’s content (an agent template, a connector config, an artifact renderer, a skill, a workflow template). Neither side alone may decide visibility:

  • An archived or uninstalled extension is suppressed from discovery even if a stale native row survives — the manifest side of the intersection is empty.
  • A locked extension stays discoverable (it is still live) — locked is in the active|locked set.
  • A native row with no manifest row is not discovered — discovery never surfaces ungoverned content.

Because every surface goes through this one dispatcher, archive / uninstall / restore take effect immediately and uniformly, with no surface holding its own divergent view of “what is installed”.

The executable definition of “extensible” (CI-enforced)

Section titled “The executable definition of “extensible” (CI-enforced)”

“Extensible” is the conjunction of these four checks, all required in CI. If they pass, the system is extensible by definition; if any fails, it is not.

ControlWhat it provesWhere
Runtime contract — golden conformance testdiscovery = lifecycle-live manifests (active|locked) ∩ per-kind visible native rows; archive / uninstall suppress stale native rows; locked stays discoverablepackages/extensions/src/__tests__/extension-discovery-conformance.test.ts (the Canonical extension invariants required job)
Structural contract 1 — import-banno NEW core import of a specific extension packagescripts/audit/core-extension-import-ban.mjs (shrink-only baseline)
Structural contract 2 — instance-couplingno NEW core string / path reference to a specific extension instancescripts/audit/core-extension-instance-coupling-ban.mjs (shrink-only baseline)
Structural contract 3 — discovery-bypassno NEW surface reads a native store directly instead of the dispatcherscripts/audit/discovery-dispatcher-bypass-ban.mjs

The runtime conformance test is the canonical behavioral truth: it asserts the discovery = active-manifests ∩ visible-native-rows intersection directly, including that an archived or uninstalled extension’s stale native rows are suppressed and that locked rows remain discoverable. The three structural gates are the canonical structural truth.

The two coupling vectors that let core drift back toward naming a specific extension are banned outright:

  • Import-ban — no NEW core import resolves to a specific extension package. Core depends on the SDK contract and the dispatcher, not on any one extension.
  • Instance-coupling ban — no NEW core string literal, JSX text, schema description, prompt, path, or package-metadata branch names a specific extension instance. This closes the gap that a pure import-ban misses (a hardcoded package name in a string, or a hand-built extensions/<scope>/<name> path).

Both are no-new-rot ratchets: the baseline can only shrink. A monotonic guard (*_BASE env compared against the base ref, with a fail-closed rev-parse) blocks regenerating the baseline to pass, and decoupling work drives each baseline toward 0.

The @/-import ban (core stays decoupled from extensions)

Section titled “The @/-import ban (core stays decoupled from extensions)”

Extensions are forbidden from reaching into core via the @/ app alias. An extension consumes the host only through the SDK port surface it is granted (ctx.settings, ctx.secrets, ctx.objects, ctx.mcp, and the rest of the 14 host ports on SDK ABI 2), never through a direct @/lib/... or @/components/... import. This keeps the dependency arrow pointing one way: extensions depend on the SDK; core depends on neither a specific extension nor an extension’s internals. It is what makes an extension cleanly extractable into its own package and cleanly removable at runtime.

Every change touching extensions or discovery is held to this checklist. A “no” on any line blocks the change.

  1. No instance naming. No core import, string literal, JSX text, schema description, prompt, path, or package-metadata branch names a specific extension instance — except existing ratcheted baseline debt or src/lib/generated/** (the legit data-driven install list).
  2. Discovery via the dispatcher. Discovery surfaces call discoverActiveExtensionCapabilities; direct native readers are allowed only inside the sanctioned per-kind handler listActive facet.
  3. Scope preserved. Discovery passes actor / scope and preserves visibility filtering; no unscoped native reads.
  4. Lifecycle authority. Archive / uninstall / restore use the canonical installed_extension manifest as the lifecycle authority; stale native rows must not reappear in discovery (no split-brain).
  5. Kind-generic, not instance-specific. Per-kind code is generic over the kind; instance behavior comes from the extension’s manifest / capabilities / metadata, never if packageName === ….
  6. Generic routing. UI / render / tool routing prefers generic capability and renderer registries; package-prefixed renderer keys are baseline debt unless specifically migrated.
  7. New kinds. A genuinely new extension kind ships a conformant listActive facet plus a per-kind intersection test. (New instances need no code — that is the point.)
  8. Ratchets only shrink. No no-new-rot baseline grows. If a change removes debt, it regenerates the relevant baseline in the same change.
  9. CI wiring is part of the contract. The gates and their tests stay required checks.

When a surface statically couples to an extension instance: replace the hardcoded reference with a manifest / registry lookup (the dispatcher for discovery; the catalog or native store via the kind handler for content), then regenerate the relevant baseline so the gate locks the reduction. Each removed reference is permanent — the ratchet cannot let it back.


See also: developer guide index · Extensions engineering reference · Extension lifecycle and distribution · Extension access contract