Skip to Content
Numinance
Protocol

PluginService

Every RPC a plugin serves to the host, with exact messages, bounds, stream rules and rejection semantics.

View as .mdUpdated
Available in public spec
PluginService is part of the Apache-2.0 numinance-spec protocol. The host that calls it is the Numinance plugin host in the proprietary runtime; the conformance kits and Go SDK packages in the spec repository exercise the plugin side.

PluginService is the lifecycle surface a plugin serves and the host calls. It is never a model tool, and no PluginService RPC is ever an action target.

When to use this page

Read it when you implement a plugin from scratch against the wire contract, when you write a test host, or when you need the exact rejection rules the host applies to what a plugin returns. If you only want to build a plugin with the Go packages, start with Build a custom plugin in Go and return here for the details.

The other half of the plugin boundary is HostService: the plugin calls it, from inside an Invoke, to log, emit evidence and obtain credential handles.

The service

PluginService has six RPCs. These lines are copied from plugin.proto; the annotation bodies are shown in the annotation table.

proto/numinance/plugin/v1/plugin.proto
service PluginService {
  rpc Handshake(HandshakeRequest) returns (HandshakeResponse) { ... }
  rpc GetManifest(GetManifestRequest) returns (GetManifestResponse) { ... }
  rpc GetDescriptors(GetDescriptorsRequest) returns (GetDescriptorsResponse) { ... }
  rpc Invoke(InvokeRequest) returns (stream InvokeResponse) { ... }
  rpc Health(HealthRequest) returns (HealthResponse) { ... }
  rpc Shutdown(ShutdownRequest) returns (ShutdownResponse) { ... }
}

Only Invoke is server-streaming. Every element of the service is since: "1.0.0".

Plugin invocation sequence

In what order do host and provider talk, and who decides what?

Illustrative · simulated
Scenario

Policy allows the action; the provider runs it and every privileged step goes through the host.

HostProviderEgressEvidence

Host: Plugin host + policy decision point · Provider: Plugin subprocess · Egress: Broker / egress proxy · Evidence: Evidence writer (runner identity)

Step 1 of 14

Host launches the subprocess

exec + NUMINANCE_PLUGIN_COOKIE

Message and fields
  • NUMINANCE_PLUGIN_COOKIE = MagicCookie (plugin.CookieEnv)
  • NUMINANCE_PLUGIN_PROTOCOLS (offer)
  • host address env (plugin.HostAddrEnv)
Who holds authority
Host: it starts the process and offers the protocol versions. The host identifies the plugin by the connection it established at launch, never by request content.
✕ The provider can not
The cookie is not a secret and not authority: it only stops a user from running the provider binary by hand. A provider with a wrong cookie refuses to serve.

Allowed. Step 1 of 14: Host launches the subprocess. exec + NUMINANCE_PLUGIN_COOKIE. Host: it starts the process and offers the protocol versions. The host identifies the plugin by the connection it established at launch, never by request content.

▶ selected message◆ host policy decision✕ denied or rejectedName typed RPC from the spec
Text description

Illustrative and simulated: nothing here talks to a running host. RPC and field names are the ones in proto/numinance/plugin/v1 and sdk/go/plugin.

  1. The host starts the provider subprocess with NUMINANCE_PLUGIN_COOKIE set to the magic cookie, then calls Handshake, GetManifest and GetDescriptors on PluginService.
  2. The policy decision and the grant happen on the host, outside the model and before the provider sees the action.
  3. If allowed, the host calls Invoke (server-streaming) with an Envelope whose ActionContext the host sets. The provider may call back on HostService: RequestCredential returns an opaque handle that only works through the egress proxy, which injects the real token; EmitEvidence submits evidence that the host appends under the runner identity; ReportProgress reports progress.
  4. The stream ends with an ActionResult; the host records the outcome, may call Health, and finally Shutdown, which first revokes handles and egress grants.
  5. If policy denies, Invoke is never sent and the denial is recorded as evidence. If the provider requests a credential intent that is not in its declared credential_intents, the host rejects it and records the call.

The provider never holds authority: it cannot approve, widen a grant, see a credential, or append evidence.

Rules that hold for every RPC

  • Every RPC except Invoke is host-only lifecycle. Invoke carries an action whose target is a method in a numinance.ports.* package; the target's annotations in the host's descriptor copy govern it, never Invoke's own.
  • No PluginService RPC declares evidence. The host records each lifecycle call in its own lifecycle evidence and each Invoke under the target's declared evidence.
  • Every string is at most 1024 bytes and every repeated field at most 256 entries unless a field states otherwise. Over-limit input is rejected, never truncated. Duplicate entries in any repeated field are rejected.
  • The host reads every response through a size-limited reader with a per-call timeout.
  • A rejection names an index-based field path and the violated bound, never the offending value.

Annotations of each RPC

RPCRiskExecIdempotentDry run
HandshakeRISK_CLASS_READEXEC_MODE_SYNCtruefalse
GetManifestRISK_CLASS_READEXEC_MODE_SYNCtruefalse
GetDescriptorsRISK_CLASS_READEXEC_MODE_SYNCtruefalse
InvokeRISK_CLASS_PRIVILEGEDEXEC_MODE_OPERATIONfalsefalse
HealthRISK_CLASS_READEXEC_MODE_SYNCtruefalse
ShutdownRISK_CLASS_LOWEXEC_MODE_SYNCtruefalse

Invoke is RISK_CLASS_PRIVILEGED because it carries one policy-approved action to code that may touch customer systems. Its exec is OPERATION because the stream carries OPERATION targets, not because Invoke has the start, get and cancel shape. The meaning of each annotation is in Governance annotations.

Handshake

Handshake negotiates the protocol version, the spec version and the capabilities between host and plugin.

proto/numinance/plugin/v1/plugin.proto
message HandshakeRequest {
  string spec_version = 1;
  repeated uint32 protocol_versions = 2;
  repeated string ports = 3;
  repeated string capabilities = 4;
}

message HandshakeResponse {
  uint32 protocol_version = 1;
  string spec_version = 2;
  repeated string capabilities = 3;
}

(Per-field [(numinance.annotations.v1.field).since = "1.0.0"] options omitted.)

HandshakeRequest fields

FieldNumberTypeMeaning
spec_version1stringStrict x.y.z spec version of the host.
protocol_versions2repeated uint32Protocol versions the host offers. Must equal the NUMINANCE_PLUGIN_PROTOCOLS offer. At install both carry the host's full set; at run time both carry only the version locked at install.
ports3repeated stringPorts of the installed manifest, each at the major that manifest declares, in the Manifest.ports grammar. A port the host cannot serve at that major fails install and aborts the handshake.
capabilities4repeated stringNon-Core capability names the host knows for the ports of the installed manifest only. Core verbs are never negotiated.

HandshakeResponse fields

FieldNumberTypeMeaning
protocol_version1uint32One of the offered versions; equals the version on the plugin's stdout protocol line. Zero or a value outside the offer aborts the handshake.
spec_version2stringStrict x.y.z spec version the plugin was built against. It never selects the host's validation path.
capabilities3repeated stringThe negotiated capabilities: a subset of both the host offer and the manifest. Any entry not in both aborts the handshake.

The response is at most 64 KiB serialized.

Semantics worth knowing

  • The host records the negotiated versions in its own lifecycle evidence; the plugin declares none.
  • The host writes the negotiated protocol version into the lockfile at install and offers only that version at run time. A plugin cannot select a lower protocol later.
  • Negotiation is an intersection, never a union: a plugin cannot claim a capability the manifest does not declare, and the host will not enable one it did not offer.

GetManifest

proto/numinance/plugin/v1/plugin.proto
message GetManifestRequest {}

message GetManifestResponse {
  Manifest manifest = 1;
}

GetManifestRequest has no fields; the plugin has one manifest. The returned manifest is at most 1 MiB serialized and must equal (proto.Equal) the manifest decoded from the installed package's manifest.yaml, whose digest the lockfile pins. If it does not, the host refuses to start the plugin. The manifest returned here is never a source of intents or capabilities: those come from the installed, digest-pinned package. See Manifest and capabilities.

GetDescriptors

proto/numinance/plugin/v1/plugin.proto
message GetDescriptorsRequest {}

message GetDescriptorsResponse {
  google.protobuf.FileDescriptorSet descriptor_set = 1;
}

The plugin returns every port it implements as a serialized FileDescriptorSet, at most 2 MiB and at most 64 files. The host validates it against the spec version it supports, and its own compiled copy of each port file is authoritative:

  • A shared port file that differs from the host copy in any option fails install.
  • An RPC that is not in a port file the host serves is not dispatchable.
  • A file whose name or package collides with a host-compiled file (annotations, types, the google.protobuf well-known types, any numinance.ports.*) must equal the host copy (proto.Equal on FileDescriptorProto with source_code_info cleared) or install fails; the host copy is used either way.
  • Plugin-private files must not use the numinance. or google. package prefixes and must not extend any google.protobuf options message.
  • Options are always read through the host's own registry. source_code_info of every plugin-supplied file is discarded after validation and never rendered.

The consequence is the invariant that matters: a plugin cannot change the risk, execution mode or evidence declarations of a port verb by shipping its own copy of the descriptor. Governance annotations come from the host's descriptors.

Invoke

Invoke dispatches one action after the policy decision and the grant check.

proto/numinance/plugin/v1/plugin.proto
message InvokeRequest {
  Envelope envelope = 1;
}

message InvokeResponse {
  ActionResult result = 1;
}

InvokeRequest.envelope is the action, built by the host. Its structure is on Envelope, ActionContext and ActionResult. Each InvokeResponse.result is one reported result of the stream; status is INVOKE_STATUS_RUNNING on every message but the last.

Stream bounds

Target kindMessages the plugin sends
A non-OPERATION targetExactly one.
An OPERATION targetAt most 1024. A RUNNING message is at most 64 KiB and the terminal message at most 4 MiB, so a stream is at most 64 MiB of RUNNING messages plus the terminal one. Only the last message is terminal.

Server-streaming targets

A target whose method is itself server-streaming (a port verb such as authorexecutor Execute) is always an OPERATION target and rides the same stream.

  • Each message of the target's stream is one RUNNING message whose ActionResult.result holds it. The type URL is the method's output type, and the 64 KiB RUNNING bound covers it.
  • The terminal message carries no result. Its status follows the last stream message as the port defines it. For authorexecutor Execute, SUCCEEDED follows EXECUTION_COMPLETED and FAILED follows EXECUTION_FAILED.
  • A terminal status that disagrees with the last stream message is rejected.
  • A stream whose last stream message is not one the port names terminal is rejected.
  • A stream that ends without any stream message may only end FAILED (a provider rejection).
  • A RUNNING message with a result on a target that is not server-streaming is rejected.

Error semantics

The host reads each message through a size-limited reader, never buffers the stream, and decodes strictly, rejecting unknown fields. A stream that breaks any rule above is rejected and recorded as failed. The host decides the recorded outcome itself: the plugin's status is a claim, not the record (see InvokeStatus).

There is no dry run of Invoke itself. The host sets ActionContext.dry_run only for a target whose supports_dry_run is true.

Health

proto/numinance/plugin/v1/plugin.proto
message HealthRequest {}

message HealthResponse {
  HealthStatus status = 1;
}

enum HealthStatus {
  HEALTH_STATUS_UNSPECIFIED = 0;
  HEALTH_STATUS_SERVING = 1;
  HEALTH_STATUS_NOT_SERVING = 2;
}

Health is reported for the whole plugin. The host rejects HEALTH_STATUS_UNSPECIFIED. HEALTH_STATUS_SERVING accepts calls; HEALTH_STATUS_NOT_SERVING accepts none.

Shutdown

proto/numinance/plugin/v1/plugin.proto
message ShutdownRequest {}

message ShutdownResponse {}

Shutdown asks the plugin to stop. Before calling it the host cancels in-flight operations and revokes the plugin's credential handles and egress grants. The plugin starts no new work after it, and the host kills the process after a bounded grace period. It has no dry run: stopping its own process is the whole effect, and the host may always kill the process.

Failure modes and safeguards

SituationResult
Handshake protocol_version is zero or not in the offerHandshake aborts.
Handshake capability not in both the host offer and the manifestHandshake aborts.
GetManifest result differs from the digest-pinned manifest.yamlHost refuses to start the plugin.
Plugin descriptor for a shared port file differs from the host copyInstall fails; the host copy is used.
Over-limit string, repeated field or messageRejected, never truncated; the error names a field path and the bound.
Invoke stream breaks a bound or terminal-status ruleStream rejected and recorded as failed.
Plugin reaches for a lower protocol after installNot possible: only the locked version is offered at run time.

Troubleshooting

  • Install fails after a descriptor change: you shipped a port file that differs from the host's copy. Regenerate from the pinned spec revision instead of editing options.
  • A streaming verb is recorded as failed although the work finished: check that the last stream message is the one the port names terminal and that the terminal status agrees with it.
  • The host rejects a Diagnostic: its field_path or subject did not resolve in the host's registry. Details are on the envelope page.

Next steps

On this page