Skip to content

Extending the framework

Most applications need no Java at all: routes, SQL, views, workflows, and shared definitions cover the ordinary cases. When they do not, there is a ladder — four rungs, each with a different reach and a different cost. Take the lowest one that solves the problem.

Rung Add Reach Cost
0 A declaration Everything documented None
1 An expression function One predicate, anywhere expressions run Not admissible for distribution
2 A service provider binding Runtime state a route can read Route becomes extended; not admissible
3 A plugin jar Routes and beans at boot Signed jar; not admissible

“Not admissible” means tesseraql admission fails, so the application cannot be distributed for other people to install (admission.md). It says nothing about running it yourself, which is the normal case — see governance.md for what each rung costs on an everyday build.

Before writing Java, check that the declaration does not already exist. The YAML surface reference is generated from the schema, so it is complete by construction. Several things people reach for Java to do are declarative: conditional statuses, nested composition, computed fields (response-shaping.md), business rules that change without a release (decision tables), and multi-statement operations in one transaction (transactional-writes.md).

When a validation rule needs one predicate the built-ins cannot express — a checksum, a code-format rule, a calendar check — implement ExpressionFunction and ship it as a module, not a plugin.

The full recipe, with the purity contract and the failure modes, is in declarative-validation.md. In short: one class per function, registered in META-INF/services, declared under tesseraql.modules, and pinned by modules.lock.

Modules are the reviewed, lock-pinned channel, which is exactly why expression functions load from there and never from plugins/.

Module visibility equals runtime scope: in a stack, each application’s runtime loads its own work/modules on its own classloader, so your function is visible exactly to the application that declared it — a neighbour declaring a same-named function keeps its own semantics, and neither can shadow the other. Changing the declared set is a restart (dev re-resolves on start; a hosted member is redeployed), never a live swap.

A service provider exposes runtime state that SQL cannot reach — execution lanes, traces, file trees, drafts. A route binds one instead of a SQL file:

sources:
main:
sql:
service: ops.lanes

The interface is one method:

@FunctionalInterface
public interface ServiceProvider {
Object invoke(Map<String, Object> params);
}

Three rules the runtime holds you to:

  • Return template-ready data. Maps, lists, and scalars only, with display formatting — status classes, indents, ISO timestamps — already computed. There is no serializer to configure.
  • Query routes must be side-effect free. A provider bound on a command route may perform runtime administration; one bound on a query route may not.
  • Steps cannot bind a provider. service: is legal on any sources: entry, and is refused at build time inside a command’s steps: — a transactional step must be a SQL file or a sequence.

A route that binds a provider is assessed extended and scores +1 (governance.md).

Providers are registered into the runtime registry, which means something has to do the registering — rung 3.

A runtime extension installs routes and beans into the runtime as it is assembled. This is how the optional feature modules work: SAML, SCIM, and OIDC are all runtime extensions, so the runtime carries no compile-time dependency on any of them.

public interface RuntimeExtension {
String name(); // for diagnostics, logs, and the allowlist
boolean enabled(AppConfig config); // usually one config key
void install(ExtensionContext context) throws Exception;
}

install runs after the core beans are bound — datasources, security, session store, identity service and realm — and before the runtime starts. ExtensionContext gives you:

runtime() the runtime context being assembled: mount routes, use the registry
manifest() the app manifest, including its configuration and app home
dataSource() the main datasource — business and identity data
frameworkDataSource() ambient framework state (framework datasource)
bean(name, type) look up a framework bean already bound
bind(name, bean) bind your own

Register the implementation in META-INF/services/io.tesseraql.compiler.ext.RuntimeExtension. OidcRuntimeExtension in this repository is a complete worked example, at about the size a real extension runs to.

An extension is discovered from the runtime classpath — a jar added with --modules, or a dependency of your own runtime build — or from the plugins/ directory of the application the runtime serves. The classpath route is simpler and needs no signing; plugins/ is the one that travels with the application.

The framework’s own extensions — OIDC, SAML and SCIM — are already on the runtime classpath and need neither route: configuration alone turns them on (module-channel.md decision 2). Both routes above are for extensions you or a third party write.

plugins/ is read from that one application. Extensions are a host decision: they register routes and beans on the runtime, which is runtime-wide, so a runtime hosting an application loads that application’s plugins and no others. Under tesseraql host each application has its own runtime, and therefore its own plugins/.

Every jar in plugins/ must carry a detached Ed25519 signature beside it, <jar>.sig, holding the base64 signature over the jar bytes. Generate a key pair once:

Terminal window
openssl genpkey -algorithm ed25519 -out plugin-signing.pem
openssl pkey -in plugin-signing.pem -pubout -outform DER | base64 -w0 # the trustedKeys value

Sign each jar at build time:

Terminal window
openssl pkeyutl -sign -inkey plugin-signing.pem -rawin -in my-plugin.jar | base64 -w0 \
> my-plugin.jar.sig

Then configure the app:

tesseraql:
plugins:
dir: plugins # relative to the app home; *.jar beside *.jar.sig
trustedKeys:
- MCowBQYDK2Vw... # the base64 public key printed above (PEM also accepted)
requireSignature: true # false skips verification — development only
allowlist: [saml, scim] # when present, only these extension names may install

Keep the private key out of the repository. It signs code that runs inside your application.

Each verified jar gets its own isolated class loader, so plugins cannot see each other’s classes. The allowlist applies to every discovered extension, classpath and plugin alike — a jar that arrives on the classpath unnoticed cannot install itself past the configuration.

Failures are explicit rather than silent:

Code Meaning
TQL-PLUGIN-1301 Signature verification is on and no trusted keys are configured.
TQL-PLUGIN-1302 A jar has no <jar>.sig beside it.
TQL-PLUGIN-1303 A signature does not verify against any trusted key.

An extension not on the allowlist is skipped with a warning naming it, rather than installed quietly.

  • Can a declaration do it? Use the declaration. The reference is generated, so if it is not there, it is not there.
  • Is it one predicate inside an expression? Rung 1. It stays lock-pinned and reviewable.
  • Does a route need runtime state SQL cannot see? Rung 2, and accept the extended mode.
  • Do you need routes or beans the framework does not have? Rung 3 — and prefer the classpath over plugins/ unless the extension must travel with the application.

If you are building something other people will install, stop at rung 0. That is what the admission profile means by declarative-only, and it is checked rather than trusted.

Admission is opt-in: it is the bar for an application someone else runs without reviewing it. An application you deploy yourself, or one a vendor ships to a customer who has read the contract, may use every rung — the profile is a gate for distribution, not a house style.