Skip to content
Merged
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.

## [Unreleased]

### Fixed — service-grant gate covers legacy rows, plugin-facing callers, and per-plugin factories (#470 C2b, PR #783)

- Filled the dated `ctx.services.get` legacy allowlist with the currently-real built-in and hub-plugin rows the first audit missed: some service names are hidden behind exported constants (`PROCESS_MEMORY_SERVICE_NAME`, `PLUGIN_CAPABILITIES_SERVICE`, `CHANNEL_RESOLVER_SERVICE`, …) and some channel repos resolve them through shared `@omadia/channel-sdk` helpers rather than a literal string in the plugin's own file. The boot-breaking orchestrator/orchestrator-extras gaps are now grandfathered explicitly until their manifests catch up.
- Added `test/pluginServiceGrantCoverage.test.ts`, which derives service reads from every built-in `middleware/packages/*/manifest.yaml` plus its `src/**/*.ts` call sites and fails loud on undeclared or stale legacy rows instead of trusting a hand-maintained snapshot.
- Threaded the plugin's `ServiceCaller` through plugin-facing accessors that resolved services outside `ctx.services.get` (`ctx.memory`, the knowledge-graph accessor, `ctx.mcp`, `ctx.subAgents`, `ctx.llm`, `ctx.events`) so `perCallerService(...)` providers see the consuming plugin instead of the kernel.
- Made `perCallerService(...)` truthful to its docs: one implementation is now memoized per consuming plugin and per factory object, so repeat reads by the same plugin reuse the same instance while a replaced provider starts cold automatically.

### Fixed — verifier: hallucinated record references no longer pass with a disclaimer (#129, PR #781)

- **Behaviour change (blocking).** A qualitative answer that names a concrete
Expand Down
4 changes: 2 additions & 2 deletions middleware/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion middleware/packages/harness-channel-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"peerDependencies": {
"@omadia/api-key-auth": "^0.1.0",
"@omadia/channel-sdk": "^0.1.0",
"@omadia/plugin-api": "^0.1.0",
"@omadia/plugin-api": "*",
"express": "^5.1.0",
"zod": "^4.0.0"
},
Expand Down
91 changes: 91 additions & 0 deletions middleware/packages/plugin-api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Changelog — `@omadia/plugin-api`

The type contract every omadia plugin compiles against. The package is
`private: true` and is not published to npm; plugin repositories consume it by
`file:` link or a vendored `.d.ts` (epic #470, decision D1).

Versioning is SemVer over the **exported type surface**. Removing or narrowing
an exported type, or adding a required member to an interface a plugin
implements, is a major.

## 1.0.0 — 2026-08-20

First stable cut of the contract. Two breaking changes are taken together,
deliberately, in one major — **now**, while the installed base is still zero
and every consumer is a repository we control. There is no published `0.x`
range on npm and no third-party plugin pinned to one, so the cost of the break
is a coordinated bump across the sibling repos rather than an ecosystem event.
Deferring it would only have made it expensive (epic #470, `implementation.md`
§1 row 4).

### Breaking

- **Removed the dev-platform job types and their context accessor.** No longer
exported (spelled out on one line, once, so a consumer grepping its own source finds this entry): `DevJobKind`, `DevJobStatus`, `DevJobDescriptor`, `DevJobCreateRequest`, `DevJobEventRecord`, `DevJobsAccessor`, `PluginContext.devJobs`.

They were never usable. Nothing ever registered the backing host service, so
every call threw, and no manifest in this repository, in the private byte5
plugin set, or in any sibling plugin repository ever declared the matching
permission (`specs/470-dev-platform-plugin/dormant-capabilities.md` §2). The
view types survive core-locally under `middleware/src/` and travel with the
extraction into its own repository, where the plugin will own them as
`@omadia/dev-platform-plugin-api`. They are deliberately not re-published
from here for zero consumers.

*Migration:* none required — no working code can exist against a surface that
threw on every call. A stale manifest still declaring the legacy permission
key keeps installing and activating unchanged; unknown permission keys are
ignored, not rejected (regression-pinned in
`test/manifestDevJobsLegacyKey.test.ts`).

- **`ctx.services.get(name)` is now gated on the manifest.** A plugin may only
resolve capability names it declares in `requires:` (or `provides:`, to read
back its own registration). An undeclared name throws the new
`ServiceNotDeclaredError` instead of returning the implementation.

Previously the accessor was a bare pass-through: any installed plugin could
ask for any registered service — including `graphPool`, the same Postgres
pool the kernel uses — with no declaration and nothing in the install dialog
(epic #470, bug B1).

*Migration:* add the capability to the manifest's `requires:` list, e.g.
`requires: ["graphPool@^1"]`. The service-registry key **is** the capability
name. A dated allowlist
(`LEGACY_UNDECLARED_SERVICE_GRANTS_2026_08_20` in
`middleware/src/platform/pluginServiceGrants.ts`) grandfathers the exact
(plugin, capability) pairs an audit found in shipped plugins: those warn once
and resolve. The allowlist is closed — a different plugin, or a different
name, still throws.

*Note:* `requires:` is also the activation dependency, so declaring an
optionally-consumed capability makes it mandatory. Expressing an optional
requirement is an open design question and the reason the allowlist exists at
all rather than every row being fixed in place.

- **`ServicesAccessor.provide` / `.replace` widened to
`T | PerCallerFactory<T>`.** Source-compatible for every existing call; only
code that *implements* `ServicesAccessor` (the kernel, and test doubles that
type themselves against it) sees the change.

### Added

- `perCallerService(factory)` — register a service that mints one
implementation per consuming plugin. The factory receives a `ServiceCaller`
(`{ agentId, pluginId }`) built from the id the **kernel** activated the
consumer under, never from an argument the consumer supplies. This is what
lets a provider attribute, scope or filter per consumer without asking the
consumer to name itself — the self-attribution hole that removing the
accessor above would otherwise have opened (epic #470 §2.2).
- `ServiceCaller`, `PerCallerFactory<T>`, `isPerCallerService`,
`resolvePerCallerService` — the supporting surface. The factory is a
symbol-branded object rather than a bare function, so a service that *is* a
function can never be mistaken for a factory.
- `ServiceNotDeclaredError` — typed, carrying `pluginId`, `capability` and
`manifestField`, so a plugin can tell "the operator has not installed a
provider" (`get` returns `undefined`) from "I forgot to declare this" (this
throw). The two used to look identical.

## 0.1.0

Initial extraction of the plugin-facing types out of the middleware kernel, so
plugin packages could import them without reaching back into `middleware/src`.
22 changes: 20 additions & 2 deletions middleware/packages/plugin-api/api-snapshot/plugin-api.d.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1454,11 +1454,28 @@ constructor(raw: string, detail: string);
}
export declare function parseCapabilityRef(raw: string): CapabilityRef;
export declare function capabilitiesMatch(provider: CapabilityRef, consumer: CapabilityRef): boolean;
export interface ServiceCaller {
readonly agentId: string;
readonly pluginId: string;
}
declare const PER_CALLER_FACTORY: unique symbol;
export interface PerCallerFactory<T> {
readonly [PER_CALLER_FACTORY]: (caller: ServiceCaller) => T;
}
export declare function perCallerService<T>(factory: (caller: ServiceCaller) => T): PerCallerFactory<T>;
export declare function isPerCallerService<T = unknown>(value: unknown): value is PerCallerFactory<T>;
export declare function resolvePerCallerService<T>(factory: PerCallerFactory<T>, caller: ServiceCaller): T;
export declare class ServiceNotDeclaredError extends Error {
readonly pluginId: string;
readonly capability: string;
readonly manifestField = "requires";
constructor(pluginId: string, capability: string);
}
export interface ServicesAccessor {
get<T>(name: string): T | undefined;
has(name: string): boolean;
provide<T>(name: string, impl: T): () => void;
replace<T>(name: string, impl: T): () => void;
provide<T>(name: string, impl: T | PerCallerFactory<T>): () => void;
replace<T>(name: string, impl: T | PerCallerFactory<T>): () => void;
}
export interface NativeToolSpec {
readonly name: string;
Expand Down Expand Up @@ -1759,6 +1776,7 @@ export declare class MigrationHookError extends Error {
readonly migrationCause: unknown;
constructor(agentId: string, fromVersion: string, toVersion: string, cause: unknown);
}
export {};

// ===== privacyMode.d.ts =====
export declare const PRIVACY_MODE_CONFIG_KEY = "_privacy_mode";
Expand Down
2 changes: 1 addition & 1 deletion middleware/packages/plugin-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@omadia/plugin-api",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
Expand Down
173 changes: 168 additions & 5 deletions middleware/packages/plugin-api/src/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,143 @@ export function capabilitiesMatch(
return provider.name === consumer.name && provider.major === consumer.major;
}

// ---------------------------------------------------------------------------
// Service resolution — the grant gate and per-caller attribution (epic #470 B1)
// ---------------------------------------------------------------------------

/**
* Who is asking for a service. Every field is the **kernel-known** installed
* plugin id — `createPluginContext` fills it from the id the kernel activated
* the plugin under, never from an argument the caller supplies. A provider can
* therefore trust it for attribution, scoping and per-tenant filtering.
*
* `agentId` and `pluginId` are the same value under two names: the kernel's
* internal term is `agentId`, the manifest/registry term is `pluginId`. Both
* are present so a provider can read whichever name its own domain uses
* without a lookup table.
*/
export interface ServiceCaller {
/** Kernel-known installed plugin id (kernel-internal name for it). */
readonly agentId: string;
/** The same kernel-known id under the manifest's name for it. */
readonly pluginId: string;
}

/** Brand for {@link PerCallerFactory}. A unique symbol, so a plain value a
* plugin happens to register can never be mistaken for a factory — including
* a value that *is* a function, which is why the factory is wrapped in a
* branded object rather than detected by `typeof impl === 'function'`. */
const PER_CALLER_FACTORY = Symbol.for('@omadia/plugin-api.perCallerService');

/**
* A service registration that mints one implementation **per consuming
* plugin** instead of sharing a single instance.
*
* Build one with {@link perCallerService}; it is otherwise opaque. Resolution
* is memoized by the FACTORY OBJECT and then by `caller.pluginId`, so one
* provider instance is reused for repeat reads by the same consuming plugin,
* while a re-registered provider starts cold automatically because it is a
* different factory object.
*/
export interface PerCallerFactory<T> {
readonly [PER_CALLER_FACTORY]: (caller: ServiceCaller) => T;
}

/**
* Per-caller factory cache.
*
* Keying first on the wrapper object means a provider swap self-invalidates:
* `ctx.services.replace(name, perCallerService(...))` registers a fresh object,
* so the old cache becomes unreachable without any explicit lifecycle hook.
* Keying second on `caller.pluginId` makes the contract literal: one
* implementation per consuming plugin.
*/
const perCallerFactoryCache = new WeakMap<
PerCallerFactory<unknown>,
Map<string, unknown>
>();

/**
* Wrap a factory so the kernel invokes it once per consuming plugin, handing
* it the {@link ServiceCaller}. The factory must therefore be idempotent for a
* given caller: repeat reads by the same plugin receive the cached result, not
* a freshly constructed instance.
*
* ctx.services.provide(
* 'repoGrants',
* perCallerService((caller) => grantsScopedTo(caller.pluginId)),
* );
*
* Why this exists (epic #470 §2.2): before it, a provider that needed to know
* which plugin was calling had exactly one option — take the id as an argument
* from the consumer (`listGrantedRepoIds(myOwnPluginId)`). That is
* self-attribution: the caller names itself, and nothing stops it naming
* someone else. Routing attribution through the kernel closes that by
* construction.
*
* Value providers are unaffected: `provide(name, impl)` with a plain value
* keeps returning that exact value to every consumer.
*/
export function perCallerService<T>(
factory: (caller: ServiceCaller) => T,
): PerCallerFactory<T> {
return { [PER_CALLER_FACTORY]: factory };
}

/** Narrow an arbitrary registration to a per-caller factory. */
export function isPerCallerService<T = unknown>(
value: unknown,
): value is PerCallerFactory<T> {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<symbol, unknown>)[PER_CALLER_FACTORY] === 'function'
);
}

/** Invoke a per-caller factory. Exported for the kernel's registry; plugins
* never need it — `ctx.services.get` already resolves the factory. */
export function resolvePerCallerService<T>(
factory: PerCallerFactory<T>,
caller: ServiceCaller,
): T {
let byPlugin = perCallerFactoryCache.get(factory);
if (!byPlugin) {
byPlugin = new Map<string, unknown>();
perCallerFactoryCache.set(factory, byPlugin);
}
if (byPlugin.has(caller.pluginId)) {
return byPlugin.get(caller.pluginId) as T;
}
const resolved = factory[PER_CALLER_FACTORY](caller);
byPlugin.set(caller.pluginId, resolved);
return resolved;
}

/**
* Thrown by `ctx.services.get(name)` when the plugin's manifest does not
* declare `name` as a capability it `requires` (or `provides`).
*
* Typed so a plugin can distinguish "the operator has not installed a
* provider" (`get` returns `undefined`) from "I forgot to declare this"
* (this throw) — two very different bugs that used to look identical.
*/
export class ServiceNotDeclaredError extends Error {
public readonly pluginId: string;
public readonly capability: string;
/** The manifest field that would grant it. */
public readonly manifestField = 'requires';
constructor(pluginId: string, capability: string) {
super(
`plugin '${pluginId}' called ctx.services.get('${capability}') but its manifest does not declare that capability — ` +
`add '${capability}@<major>' to the manifest's \`requires:\` list (or \`provides:\` if this plugin is the provider)`,
);
this.name = 'ServiceNotDeclaredError';
this.pluginId = pluginId;
this.capability = capability;
}
}

/**
* Accessor for plugin-bereitgestellte (plugin-provided) services.
*
Expand All @@ -407,23 +544,46 @@ export function capabilitiesMatch(
* const graph = ctx.services.get<GraphAccessor>('graph');
* if (!graph) { // provider not installed — handle gracefully }
*
* **`get` is manifest-gated (epic #470 B1).** The service-registry key IS the
* capability name, so a plugin may only resolve names it declared in its
* manifest's `requires:` (or `provides:`, for reading back its own
* registration). An undeclared name throws {@link ServiceNotDeclaredError}
* instead of handing over the implementation. Before this gate any installed
* plugin could ask for any service — including `graphPool`, the same Postgres
* pool core uses — with no manifest declaration and nothing in the install
* dialog.
*
* `has` stays ungated: it answers a yes/no existence question and hands over
* no capability, so gating it would only turn feature-probing into
* exception-handling.
*
* Well-known service names and their accessor interfaces are documented
* alongside the providing plugin. Plugins that depend on a specific service
* should declare the provider in their manifest's `depends_on` so the
* installer can enforce ordering.
*/
export interface ServicesAccessor {
/** Returns the registered provider for the given service, or undefined
* if no provider is installed. */
* if no provider is installed.
*
* Throws {@link ServiceNotDeclaredError} when this plugin's manifest does
* not declare `name` — that is a manifest bug, not a missing provider, and
* the two must not be reported the same way. */
get<T>(name: string): T | undefined;
/** Whether a provider is currently registered. */
/** Whether a provider is currently registered. Ungated — see the interface
* doc. */
has(name: string): boolean;
/** Register THIS plugin as the provider for the given service name.
* Returns a dispose handle — the plugin's `close()` MUST invoke it to
* symmetrically unregister the service on deactivate. Throws on
* duplicate-provider (two plugins cannot both claim the same name; the
* operator must uninstall one). */
provide<T>(name: string, impl: T): () => void;
* operator must uninstall one).
*
* `impl` is normally the shared implementation every consumer receives.
* Wrap it in {@link perCallerService} instead to mint one implementation
* per consuming plugin, with the kernel-known caller id supplied by the
* kernel. */
provide<T>(name: string, impl: T | PerCallerFactory<T>): () => void;
/**
* OB-71 (palaia capture-pipeline): wrap an already-registered provider
* with a decorator. The previous provider stays live behind the wrapper;
Expand All @@ -434,8 +594,11 @@ export interface ServicesAccessor {
* decorator for the named capability (e.g. `harness-orchestrator-extras`
* wrapping `knowledgeGraph` with the capture-filter). Treat the swap as
* a coordinated handoff, not a competing provider.
*
* Accepts a {@link perCallerService} wrapper on the same terms as
* `provide`.
*/
replace<T>(name: string, impl: T): () => void;
replace<T>(name: string, impl: T | PerCallerFactory<T>): () => void;
}

/**
Expand Down
Loading
Loading