Plugin query & domain APIs
This guide covers two read-side extension points:
- a state machine can answer a bounded query against one committed state root;
- the same manifested bundle can publish framework-neutral domain routes that call that query through a constrained host facade.
Start with the working project in scaffolds/plugin-template. It contains the
provider classes, both ServiceLoader descriptors, one manifest, unit tests, and
a production-catalog launch probe described below.
1. Keep the two execution planes separate
Section titled “1. Keep the two execution planes separate”AppStateMachine.apply(...) remains deterministic consensus execution.
AppStateMachine.query(..., AppQueryContext) is off-consensus and read-only.
The query callback may overlap a later apply() on another thread, so do not
mutate state-machine fields or use the query callback as a command path.
Its result must depend only on the path, parameters, and supplied snapshot;
external I/O, wall-clock time, or randomness would produce a payload that the
reported state root does not attest.
The context’s committed height, state root, and all key reads come from one root-fixed snapshot. The context expires when the callback returns. Do not retain it, create child work, emit effects, write state, perform unbounded CPU work, or depend on a callback continuing after its deadline.
@Overridepublic byte[] query(String path, byte[] params, AppQueryContext state) { if (!"passport/read".equals(path)) { throw new AppQueryException(AppQueryException.Code.UNSUPPORTED, "unknown passport query"); } if (!validAssetId(params)) { throw new AppQueryException(AppQueryException.Code.INVALID_REQUEST, "invalid asset id"); } return state.get(passportKey(params)).orElse(new byte[0]);}A state-machine query may deliberately report only UNSUPPORTED or
INVALID_REQUEST. The host owns BUSY, TIMEOUT, RESULT_TOO_LARGE,
UNAVAILABLE, and FAILED; it bounds admission, one decoded request to 64
KiB, one result to 1 MiB, and execution time. Unexpected plugin exceptions are
logged by type, redacted, and exposed as FAILED.
The generic HTTP adapter is:
POST /api/v1/app-chain/chains/{chainId}/query/{queryPath}Content-Type: application/json
{"paramsHex":"<canonical lowercase even-length hex>"}The response binds the opaque payload to chainId, stateMachineId,
committedHeight, and the 32-byte stateRoot. Query paths are normalized
relative paths of unreserved ASCII segments. Percent escapes, empty/dot
segments, leading/trailing slashes, and aliases are rejected.
2. Publish a constrained domain API product
Section titled “2. Publish a constrained domain API product”Implement DomainApiProvider. Its stable id is the containing bundle id, not
a short state-machine selector:
public final class PassportDomainApiProvider implements DomainApiProvider { public static final String BUNDLE_ID = "com.example.product-passport";
@Override public String id() { return BUNDLE_ID; }
@Override public DomainApi create(DomainApiContext context) { return new PassportDomainApi(context.queryService()); }}The context exposes only a bounded DomainQueryService. It does not expose a
JAX-RS router, request identity, message submission, effects, mutable runtime
services, or administration. In the current API,
DomainApiContext.bundleConfig() is deliberately an empty map until Yano has a
typed, secret-safe configuration/reference contract. Do not put a domain API’s
operation or security behind assumed configuration values.
Routes and deterministic matching
Section titled “Routes and deterministic matching”Return at most 64 immutable DomainApiRoute entries and validate the exact set
in plugin tests with DomainApiRouteSet.validateAndOrder(...). The host applies
the same validator before publication. Route ids are bundle-local stable
identifiers. Templates use literal segments and whole-segment lowercase
parameters such as passports/{asset_id}.
When routes overlap, the first segment at which one route is literal and the
other is a variable decides precedence; the literal wins. Parameter names do
not affect matching. Routes with the same method and structural shape are a
startup error, for example claims/{id} and claims/{claim_id}.
private static final List<DomainApiRoute> ROUTES = DomainApiRouteSet.validateAndOrder(List.of( new DomainApiRoute("passport.read", DomainHttpMethod.GET, "passports/{asset_id}", DomainApiAccess.READ), new DomainApiRoute("passport.report", DomainHttpMethod.POST, "operator/report/{asset_id}", DomainApiAccess.PRIVILEGED), new DomainApiRoute("debug", DomainHttpMethod.GET, "internal/debug", DomainApiAccess.INTERNAL)));The v1 access classes are:
READ: available under the host’s read policy. A topic-scoped API key may call it.PRIVILEGED: requires an unscoped full key, independently of broad READ/SUBMIT authentication. If that safe key configuration is absent, the HTTP route is hidden as 404 and the handler is not invoked.INTERNAL: reserved, secret-free inventory only. It is not dispatchable by HTTP or the public host/library gateway in v1.
The host owns /api/v1/plugins/{bundleId}/{relativePath}, GET/POST method
selection, authentication, raw request bounds, queueing, deadlines, response
validation, and error redaction. A plugin must not start another HTTP server or
assume its route can override a host endpoint.
Handle requests and encode output safely
Section titled “Handle requests and encode output safely”Dispatch on request.routeId(), not on untrusted raw path text. The request
contains immutable validated path/query maps and a defensive body copy. GET
has no body; POST is limited to 64 KiB. A response is limited to 1 MiB and can
be JSON or octet-stream. Plugins may return 200, 400, 404, 409, 410,
or 422; every other status is host-owned so redirects, authentication,
admission, no-content/partial-content, and server semantics cannot be forged.
If returning JSON, use a JSON library already packaged in the plugin or a
tested encoder. Never concatenate chainId, a query result, a parameter, or an
exception message directly into a JSON string. The scaffold’s JsonSupport
is a small dependency-free example and its domain response hex-encodes opaque
bytes before JSON encoding. The host rejects malformed or trailing JSON.
Translate a committed-query failure by stable code and use a generic message:
static DomainApiException translate(AppQueryException failure) { DomainApiException.Code code = switch (failure.code()) { case INVALID_REQUEST, REQUEST_TOO_LARGE -> DomainApiException.Code.INVALID_REQUEST; case UNSUPPORTED -> DomainApiException.Code.NOT_FOUND; case BUSY -> DomainApiException.Code.BUSY; case TIMEOUT -> DomainApiException.Code.TIMEOUT; case RESULT_TOO_LARGE -> DomainApiException.Code.RESULT_TOO_LARGE; case UNAVAILABLE -> DomainApiException.Code.UNAVAILABLE; case FAILED -> DomainApiException.Code.FAILED; }; return new DomainApiException(code, "passport query failed", failure);}Do not copy failure.getMessage() to the new exception or response. The
runtime preserves a plugin-thrown DomainApiException reason code but replaces
its message with canonical host-owned safe text. Any other exception becomes
FAILED.
DomainApi is lifecycle-owned. routes() is snapshotted during construction,
callbacks are bounded and serialized per bundle in v1, and close() runs after
admission is sealed and callbacks drain. Make close idempotent, reject work
after close, release only resources owned by the product, and never close a
host facade.
3. Declare both ServiceLoader and manifest metadata
Section titled “3. Declare both ServiceLoader and manifest metadata”Create this exact resource:
META-INF/services/org.yanoproject.api.plugin.domain.DomainApiProviderIts content is the provider’s binary class name:
com.example.passport.PassportDomainApiProviderThen declare the same class in the bundle-qualified manifest. For schema v1,
the domain contribution name, DomainApiProvider.id(), manifest id, and
manifest filename id must all be identical:
{ "schemaVersion": 1, "id": "com.example.product-passport", "version": "1.0.0", "yanoApi": { "min": 1, "max": 1, "minLevel": 1 }, "dependencies": [], "contributions": [ { "kind": "app-state-machine", "name": "passport", "provider": "com.example.passport.PassportStateMachineProvider" }, { "kind": "domain-api", "name": "com.example.product-passport", "provider": "com.example.passport.PassportDomainApiProvider" } ]}The schema-v1 yanoApi.minLevel field is required. Compatibility requires both
a host API major within min/max and a host global API level at least
minLevel; an incompatible bundle is rejected before any provider is
constructed. The level advances for additive public plugin APIs (including new
contribution kinds) and never resets when the major changes. It is independent
of the bundle’s version SemVer.
ServiceLoader remains the behavior-instantiation contract. The manifest is identity, compatibility, policy, ownership, and inventory metadata; it is not an arbitrary constructor list. A missing/mismatched descriptor or provider fails catalog validation before product activation.
4. Build, deploy, and secure
Section titled “4. Build, deploy, and secure”Package one self-contained reproducible plugin JAR. Compile against
yano-core-api as compileOnly and never bundle
org/yanoproject/api/**. Shade third-party runtime dependencies into
the same JAR; adjacent thin dependency JARs are not one catalog bundle.
Copy the JAR into yano.plugins.directory. If an allow-list is configured,
allow the bundle id. Select the state-machine contribution by its short
selector on the app chain. The domain contribution is activated as part of the
selected bundle and is addressed by bundle id.
For privileged routes configure an unscoped full key:
yano.app-chain.api.keys=<unscoped-full-key>,<submit-key>=topic-a|topic-bSend the unscoped key in X-API-Key. When broad authentication is enabled,
topic-scoped keys can read and submit only to their topics; they cannot call
privileged routes. With broad authentication disabled, READ and SUBMIT are
public and only PRIVILEGED routes inspect the configured full key. Treat all
plugin JARs as trusted in-process code: manifest validation and centralized
HTTP auth do not sandbox Java code.
Set yano.app-chain.api.auth.enabled=true as well when READ and SUBMIT routes
must also require keys.
5. Test before deployment
Section titled “5. Test before deployment”At minimum:
- Test
apply()determinism and replay withStateMachineConformance. - Test contextual queries with an in-memory
AppQueryContext: unsupported and invalid input codes, no mutation, root/height envelope, request/result size. - Test the complete route set with
DomainApiRouteSet.validateAndOrder, including structural collisions and literal-before-variable precedence. - Test every route id/access class, query-code translation, JSON injection, response bounds, close idempotence, and post-close rejection.
- Inspect the built JAR for both ServiceLoader descriptors and the bundle-qualified manifest; reject bundled Yano API classes.
- Launch the JAR through Yano’s production directory catalog and resolve both providers. Then run packaged JVM/native smoke and an HTTP test with auth enabled and disabled.
The scaffold wires steps 1–5 into its Gradle check lifecycle and is the
reference authoring baseline. For event-listener and NodePlugin lifecycle
contributions, see runtime/docs/events-and-plugins-guide.md.