Files
curriculum-project-hub/docs/adr/0027-external-capability-registry.md
T
hongjr03 8df8156969 feat(hub): external capability registry for PDF/ASR transforms (ADR-0027)
Introduces the External Capability abstraction: platform-registered,
org-enabled document/media transform services invoked as side effects of
an AgentRun. The first concrete capability is pdf_to_md_bundle backed by
MinerU. This lands the contract, schema, adapter interface, and a fully
mock-tested adapter; the real MinerU HTTP client is deferred until
credentials and pricing are confirmed.

Contract:
- spec/Spec/System/Agent/Capability.lean pins ExternalCapability,
  OrganizationCapabilityConnection (reuses ADR-0024 envelope, distinct
  from model-provider connection), and CapabilityInvocation.Authorized
  (workspace containment predicate, ADR-0018). lake build green (41 jobs).
- ADR-0027 records why capability credentials are a separate connection
  type (MinerU/Whisper have their own auth shape, not OpenRouter /v1/models),
  the adapter seam, and what is deferred (capability invocation record,
  pricebook, agent-facing tool discovery).

Schema:
- OrganizationCapabilityConnection + CapabilityCredentialVersion, mirroring
  the Feishu Application Connection shape. Unique by (organizationId,
  capabilityId). Migration 20260718130000 applied.

Adapter (hub/src/capability):
- types.ts: CapabilityId, CapabilityAdapter interface, secret payload,
  CapabilityConnectionUnavailable.
- mineruClient.ts: MineruClient interface + MineruParseResult + errors.
  Real HTTP client deferred; the interface is the seam.
- capabilityConnections.ts: resolveCapabilityCredential — org-scoped,
  fail-closed, reuses LocalSecretEnvelope with purpose="capability".
- pdfToMdBundle.ts: adapter that resolves credential, confines input/output
  to the run workspace (ADR-0018), calls MineruClient, writes md + images
  into workspace, and writes a UsageFact (kind=external_capability,
  quantity=pages, costSource=provider_reported|unknown per ADR-0022).

Tests (7, all green):
- md + images written to workspace, UsageFact attributes pages + cost
- costUsd null -> costSource=unknown (missing cost != zero, ADR-0022)
- no ACTIVE connection -> fail-closed, no fact written
- input/output path escape -> CapabilityPathEscape
- MinerU client error propagates, no fact written
- empty markdown -> parse failure

Deferred (needs operator): MinerU account/API key/pricing, real MineruClient
HTTP implementation, org admin UI for capability connection management,
agent-facing tool exposure (MCP vs built-in).
2026-07-18 15:54:00 +08:00

7.4 KiB

ADR 0027: External Capability Registry

Status

Accepted.

Context

ADR-0026 introduced the UsageFact ledger with a kind = external_capability fact and a capabilityId field, but deferred the capability registry itself. Two concrete needs now force the issue:

  • PDF→Markdown bundle conversion (and, imminently, audio/video→text) must run as a side effect of an AgentRun, bill in non-token units (pages, seconds), use a different provider than the model loop, and report cost through a different channel. It is not a sub-run (ADR-0026 rejected that) and not a model-provider call (it does not speak the Anthropic/ OpenRouter protocol).
  • The Agent already has a Bash tool. Without a first-class capability seam, the path of least resistance is for the agent to shell out to ad-hoc scripts that embed API keys, write to arbitrary paths, and report nothing to the ledger. That is exactly the unattributed, uncontained external consumption ADR-0022/0026 exist to prevent.

The model-provider connection (OrganizationProviderConnection, ADR-0024) is the wrong seam for these services:

  • Its payload schema (baseUrl + authToken + anthropicApiKey) and readiness probe (/v1/models?supported_parameters=tools) are specific to OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have different auth shapes (an API token, optionally a project id) and no /v1/models endpoint.
  • Its uniqueness key is (organizationId, providerId) where providerId is an OpenRouter-style model-routing id. A capability provider id (mineru) names a service, not a model.
  • Coupling capability credentials into the model-provider table would force every capability's auth shape through ProviderSecretPayloadV1 and every readiness probe through probeOpenRouterCredential.

The Feishu Application Connection (OrganizationFeishuApplicationConnection) is the right structural precedent: it reuses the ADR-0024 envelope (KEK → DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its own connection table, its own payload schema, its own readiness probe, and its own per-org uniqueness. Capability connections follow the same pattern.

Decision

External Capability

An External Capability is a platform-registered, org-enabled document or media transform service invoked as a side effect of an AgentRun. It is identified by a stable capabilityId (e.g. pdf_to_md_bundle, audio_video_to_text). A capability:

  • Has an input kind (PDF, image, audio, video, …) and an output contract (markdown bundle with extracted images, transcript text, …).
  • Bills in non-token units (pages, audio-seconds) recorded on a UsageFact with kind = external_capability, or in tokens when the backing service reports them.
  • Writes its output into the invoking run's workspace (ADR-0018 AgentSurface — no escapes).
  • Is invoked through a capability adapter in Hub, never by the Agent shelling out with embedded credentials.

Capability Connection

Credentials for a capability live in an OrganizationCapabilityConnection, structurally identical to the Feishu Application Connection:

  • Belongs to exactly one Organization.
  • Unique by (organizationId, capabilityId).
  • DRAFT / ACTIVE / DISABLED; resolution accepts only ACTIVE with a valid active secret version.
  • Secret material is an immutable, AAD-bound, KEK-wrapped envelope version (CapabilityCredentialVersion), reusing the ADR-0024 encryption machinery with purpose = "capability".
  • Its payload schema is capability-specific (CapabilitySecretPayloadV1: baseUrl, apiToken, optional projectId). New capability types extend the payload, not the connection table.
  • A capability-specific readiness probe validates the credential before activation (e.g. MinerU: a trivial authenticated GET). The probe is injectable, matching the Feishu/provider pattern, so tests never hit the network.

Capability Adapter

The adapter is the seam between the Agent and the external service. It:

  • Resolves the org's active capability connection (fail-closed, no process-global fallback — ADR-0024).
  • Accepts a workspace-relative input path and an output directory.
  • Calls the backing service (MinerU, Whisper, …) via an injectable Client interface so the real HTTP client is swappable and mockable.
  • Writes the produced markdown + image assets into the run's workspace.
  • Writes one UsageFact (or more, if the service reports per-stage consumption) with kind = external_capability, capabilityId, provider (the service id), quantity + unit (pages / seconds), and costUsd + costSource = provider_reported when the service reports cost.

Registry

The platform maintains a registry of known capabilities: their id, input kind, output contract, metering unit, and adapter. This is code-level registration (like ToolRegistry), not a database table — a capability is available to an Organization only when (a) the platform knows the adapter and (b) the Organization has an ACTIVE connection for it. Both gates are required.

What is NOT in this ADR

  • The capability invocation is not a first-class persisted record (CapabilityInvocation table) in this ADR. The UsageFact row with capabilityId + correlationId is the durable trace. If we later need a richer invocation log (retries, partial output, multi-stage status), that is a follow-up; for now the fact is enough.
  • Pricebook remains deferred (ADR-0026). Capability facts use provider_reported when the service returns cost; otherwise unknown.
  • Org-scoped enable/disable policy beyond connection status is deferred. An org with an ACTIVE connection has the capability; one without does not. A finer "enabled but no credential" toggle is not needed yet.
  • Agent-facing tool exposure (how the Agent discovers and calls the capability — MCP tool, Bash wrapper, or built-in) is an implementation detail of the adapter wiring, not a contract concern. The contract pins that the Agent never receives the capability credential.

Consequences

  • Adding a new external capability (e.g. image_ocr) is: register an adapter, add a capabilityId constant, optionally extend the secret payload — no schema change to UsageFact or AgentRun.
  • The model-provider connection table stays focused on model routing; capability credentials do not pollute its payload or readiness probe.
  • Three connection types now share the ADR-0024 envelope: model-provider, Feishu application, and capability. Each has its own table, payload schema, and probe, but the same encryption, rotation, and resolver boundary.
  • The Agent's Bash tool remains available, but the intended path for document/media transforms is the capability adapter. Whether to narrow Bash for capability-shaped tasks is an operational policy decision, not a contract one.
  • Tests prove: workspace containment of capability output, fail-closed credential resolution, UsageFact attribution with non-token metering, and that the Agent process never receives the capability credential.

Deferred

  • CapabilityInvocation as a first-class durable record (status, retries, partial output) — currently the UsageFact row is the only trace.
  • Pricebook derivation for capability costs (ADR-0026 deferred).
  • Org-scoped capability enable/disable policy finer than connection status.
  • Agent-facing tool discovery (MCP vs built-in) for capabilities.