Skip to main content

ADR-0005 · Horizontal extensions

Status: accepted v1.0 (2026-04-16) · Full normative text

Why a separate ADR for cross-cutting concerns

dagstack is an umbrella brand for AI/ML infrastructure. Plugin-system is the bottom layer; higher-level products are planned on top of it:

  • governance / iam — permissions per (tenant, plugin, action) triple, audit logs.
  • quota / metering — accounting for consumption (LLM tokens, vector-DB writes, storage volume) per tenant, plus enforcement of limits.
  • observability — uniform traces / metrics / structured logs across all plugins and orchestrators.
  • tenancy — multi-tenant scoping of resources.

These products live in separate spec repositories and bindings, but plug into plugin-system as middleware plugins or resource decorators. Problem: if the plugin-system core knows nothing about tenant / actor / quota, adding them later becomes a breaking change for every hookspec (the tenant context would need to be threaded through as an argument to every hook).

ADR-0005 pins down five extension points through which horizontal products plug into plugin-system without modifying the core. This lets the higher-level products evolve in parallel with plugin-system, without blocking each other.

This ADR does NOT define: the tenancy model, the IAM permission model, the quota model, observability backends, the audit-log schema — each lives in its own product spec and is resolved there.

The five extension points

1. PluginContext.metadata — open key-value slot

PluginContext (from ADR-0001) MUST contain the field:

FieldTypeSemantics
metadataimmutable Mapping<string, any>Open key-value slot for cross-cutting concerns. The plugin-system core does not interpret the keys — middleware reads and writes them.

Canonical keys (reserved for horizontal products):

KeyWriterReaderPurpose
tenant_idgovernance middlewareI/O plugins, resourcesTenant scope of the current invocation.
actoriam middlewaregovernance middleware, auditSubject identity (user / service).
quota_budgetquota middlewareI/O plugins (optional)Remaining budget per (tenant, resource_type).
trace_contextobservability middlewareI/O plugins, resourcesW3C Trace Context (traceparent + tracestate).
request_idhost runtimeeveryoneCorrelation in logs.

The list is open: new keys are added through an ADR in the spec repository of the relevant horizontal product, not in plugin-system.

Constraints:

  • metadata MUST be serialisable (so it can be propagated across runtimes through mcp_stdio / mcp_http). Do not stash complex objects with methods or references to host state in it.
  • The plugin author MUST NOT rely on the presence of any specific key. If a key is missing, the plugin runs without that feature — it does not crash.

2. Chain dispatch as the canonical middleware mechanism

ADR-0002 §4 already pins down the chain dispatch class: output[N] becomes input[N+1], strict order by priority desc. ADR-0005 canonises chain as the primary mechanism for horizontal middleware.

Example "governance + quota as chain middleware around an ordinary tool plugin":

hookspec: tool.execute (singleton)

governance plugin (chain, priority=1000):
• reads ctx.metadata["tenant_id"], ctx.metadata["actor"]
• checks permission(actor, tool=name, action="execute")
• deny → throw PermissionDenied (chain aborts)
• allow → forwards input unchanged

quota plugin (chain, priority=500):
• checks ctx.metadata["quota_budget"][tool.kind]
• budget == 0 → throw QuotaExceeded
• forwards input downstream

target tool plugin (singleton, priority=0):
• performs execute

quota plugin post-hook:
• increments usage based on the result

governance plugin post-hook:
• writes the audit log

Additions of this ADR on top of ADR-0002:

  • The plugin-system core MUST support chain wrapping for any dispatch class, not only for hooks that explicitly declare chain in the hookspec. That is, a governance plugin MAY plug into every hook through the priority-based chain layer that the core applies automatically.
  • The priority range [1000, ∞) is reserved for horizontal middleware. A plugin author MUST NOT use this range for business plugins. A contract test verifies this.

3. Resource decoration

ADR-0003 §3 pins down Resources DI: HTTP clients, DB clients and blob stores are injected by the host through PluginContext.resources. ADR-0005 adds an invariant:

Resources MUST support decoration through wrapping: the host runtime or a middleware plugin MAY return a proxy object that implements the same interface, delegates calls to the original, and adds cross-cutting logic (metering, rate limiting, audit).

Example "quota as a resource decorator":

manifest: tool requires resources = ["llm_client", "vector_store"]

the host runtime assembles resources:
llm_client = OriginalLLMClient(...)
vector_store = OriginalVectorStore(...)

the quota middleware asks the registry for decorators at resolve time:
llm_client = QuotaTracker(llm_client, budget=ctx.metadata["quota_budget"]["tokens"])
vector_store = QuotaTracker(vector_store, budget=ctx.metadata["quota_budget"]["vector_writes"])

the plugin calls llm_client.chat(...):
QuotaTracker.chat() increments the counter before forwarding the call
to OriginalLLMClient.chat()

Constraint: the resource interface MUST be formally specified (Protocol / interface), otherwise decoration breaks type safety. Every plugin kind that declares a resource MUST publish its interface in the spec.

4. Governance-driven filtering through capability dispatch

ADR-0002 §5 pins down capability dispatch: a single request is routed to a single plugin with a matching capability declaration. ADR-0005 adds the use case:

The host runtime MAY restrict the set of allowed plugins for a given invocation based on ctx.metadata["tenant_id"] (or another governance key). The resulting set = capability declarations of all plugins of the kind ∩ governance policy. From it capability dispatch picks the final executor.

Example "PII-safe routing":

manifest: tool-plugin-A has capability = ["pii_handling"]
manifest: tool-plugin-B has capability = [] (cannot handle PII)

governance policy:
tenant="acme-healthcare" allowed_plugins where capability ⊇ {"pii_handling"}

host filters the registry → candidates = [tool-plugin-A]

capability dispatch picks tool-plugin-A by input matcher

The plugin-system core does not implement filtering itself — it only publishes capability declarations and accepts a filter callback from the host. The governance product registers the filter through chain middleware (see §2).

5. Trace-context propagation

ADR-0003 already requires propagation of W3C Trace Context. ADR-0005 makes the mechanism concrete:

  • Trace context lives in ctx.metadata["trace_context"] — W3C strings (traceparent + tracestate), MUST be serialisable.
  • The mcp_stdio and mcp_http adapters MUST propagate trace context across the protocol boundary (HTTP headers / JSON-RPC params).
  • Resources MUST accept trace_context through decoration (see §3) — observability middleware wraps them and emits spans.
  • The plugin author MUST NOT call the tracer directly — observability middleware emits spans automatically around every hook invocation and every resource call.

Example: a minimal governance plugin as chain middleware

plugins/governance-middleware/dagstack.toml
[plugin]
name = "governance"
kind = "horizontal_middleware"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"

# Priority in the horizontal range — runs ahead of business plugins.
priority = 1000

# Declares that the plugin wants to participate in the chain around every hook of every kind.
[plugin.chain_wrap]
kinds = ["*"] # all plugin kinds
hooks = ["*"] # all hooks of each kind
plugins/governance-middleware/plugin.py
from dagstack.plugin_system import PluginContext


class PermissionDenied(Exception):
"""Raised by a governance binding when a policy check rejects the call."""


class GovernanceMiddleware:
async def setup(self, context: PluginContext) -> None:
self._policy = load_policy() # from a governance-spec binding

def before(self, ctx: PluginContext, kind: str, name: str, hook: str, args: dict) -> dict:
tenant_id = ctx.metadata.get("tenant_id")
actor = ctx.metadata.get("actor")
if tenant_id is None or actor is None:
# If the governance middleware runs without metadata — silently pass through.
return args
if not self._policy.allow(actor, tenant_id, kind, name, hook):
raise PermissionDenied(
f"actor={actor.id} tenant={tenant_id} kind={kind} hook={hook}",
)
return args

def after(self, ctx: PluginContext, kind: str, name: str, hook: str, result: object) -> None:
self._audit_log(ctx=ctx, kind=kind, name=name, hook=hook, result=result)

The plugin is wired into the chain of every hook automatically through chain_wrap — business plugins know nothing about its existence, but every one of their invocations passes through the permission check and audit log.

Backwards compatibility

All five extension points are additive to the existing spec:

  • §1 metadata — a new optional field on PluginContext. Existing plugins ignore it.
  • §2 chain wrapping — an extension of the already-existing dispatch class. Without middleware nothing changes.
  • §3 resource decoration — an invariant over the already-existing DI mechanism.
  • §4 capability filtering — an additional callback in the registry.
  • §5 trace-context propagation — a concretisation of the existing invariant from ADR-0003.

Bumping kind_api_version is not required. Bumping the manifest schema_version — minor (1.0 → 1.1) due to the addition of metadata to the PluginContext schema.

Consequences

Positive:

  • The governance / quota / observability products evolve in parallel with plugin-system, without blocking each other.
  • Plugin authors write code unaware of tenancy / quota — their plugins automatically work in a multi-tenant environment as soon as governance middleware is wired in.
  • Cross-product integration through a shared contract of canonical metadata keys — governance writes tenant_id, quota reads it; observability writes trace_context, everyone reads it.

Trade-offs:

  • PluginContext.metadata is an open dictionary with any-typed values. Type safety is provided by the spec of each horizontal product (which declares its canonical keys and their types), not by the plugin-system core.
  • The priority range [1000, ∞) for middleware is a convention, not enforcement. If a plugin author accidentally uses priority=2000, they get preference over governance. Mitigation: a contract test verifies that business plugins keep priority < 1000.

What this ADR forbids:

  • A horizontal product MUST NOT require changes to the hookspec of a business plugin for its own integration — all five extension points are self-sufficient.
  • The plugin-system core MUST NOT add keys to metadata whose semantics it interprets itself — the core only stores and propagates them; interpretation is the middleware's responsibility.
  • ADR-0001PluginContext, to which ADR-0005 adds metadata.
  • ADR-0002 — the chain and capability dispatch classes that ADR-0005 canonises as a middleware mechanism.
  • ADR-0003 — Resources DI + the trace-context invariant that ADR-0005 makes concrete.

Normative source

The full text of ADR-0005, including the detail on priority reservation, backwards-compat guarantees and a discussion of open questions: plugin-system-spec/adr/0005-extensibility-hooks-for-horizontal-concerns.md.

W3C Trace Context format: https://www.w3.org/TR/trace-context/