Spec: One authoritative role registration for native extensions #919
DamianReeves
started this conversation in
Proposal
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
One authoritative role registration
Status: design settled, not yet implemented
Repository:
finos/morphir-rust(theecosystem/morphir-rustsubmodule)Relationship to other work: lands before step 3 of
2026-09-21-single-file-projects-are-synthesized.md, which needs a native workspace role.Problem
An extension's roles — frontend, backend, and soon workspace — are described four times over, and
the four descriptions are kept in agreement by hand.
In
crates/morphir-extension-sdk/src/native.rs:declared_types: Vec<ExtensionType>, passed intowith_extensioncapabilities: ExtensionCapabilities, returned by the author'sE::capabilities()frontend/backend,Option<Arc<dyn …>>dispatchers: Vec<DispatchFn<E>>, passed intowith_extensionvalidate_capabilities(native.rs:157-196) exists to reconcile (1) against (2). It neverinspects the handles, though its error messages say otherwise — "extension advertises frontend
without a native frontend handle" is testing
declared_types.contains(&ExtensionType::Frontend).The reachability matters and was initially overstated.
with_extensionis private, and allthree public constructors supply tags, dispatchers and handles that agree. So (1), (3) and (4)
are a maintenance hazard inside the SDK rather than a hole in its public API. The one externally
reachable inconsistency is (2), the independently authored capabilities — and native construction
already rejects the mismatches it can see.
The reason to act now is that adding the workspace role means adding a third entry to each of the
four descriptions, plus constructors for the new combinations. The hazard compounds.
The same split exists on the guest side.
export_extension!derives types and dispatchers from onetoken list, but capabilities arrive independently from
E::capabilities()(
crates/morphir-extension-sdk/src/lib.rs:120), so a guest can advertise a role it does notdispatch.
The deeper defect
Extension::capabilities()makes a wire aggregate an authoring interface. That is the disease;the four-way correspondence is a symptom. An author is asked to hand-write the negotiated payload,
and separately to hand-register the implementations that payload claims.
Decision
One authoritative role registration, from which everything else projects.
A role is registered once, binding together the three things that must agree: its authoring
descriptor, its typed handle, and its invocation adapter.
ExtensionTypelists, capabilitypayloads, and protocol dispatch all become projections of that registration rather than
independently authored values.
What each layer owns
Registration semantics sit below both adapters and depend on neither
Archandles, Extism, nordaemon types.
These are module boundaries first, not a crate split. Splitting crates without fixing ownership
would distribute the same correspondence across more packages. A protocol crate is a sensible later
extraction — the daemon needs the contract without the authoring and export machinery — and the
Extism dependency is already target-gated, so nothing forces the split now.
The native shape
Three points, each load-bearing:
protocolis derived, not supplied. TodayProtocolHandle<E>(native.rs:242) stores its owndispatcher list and a second metadata snapshot. That is the most consequential of the four
correspondences, because it is the one that decides what actually runs. The protocol adapter reads
the same registration and invokes the same typed handles as a direct call; the SDK supplies JSON
decode, invoke and encode. There is no caller-supplied dispatcher left to mismatch. Retaining a
NativeProtocoltrait object is fine provided the SDK is its only constructor.Role records are concrete, not generic. A generic
Role<Capability, Handle>permits backendmetadata beside a frontend handle — it relocates the problem into the type parameters. Concrete
FrontendRole/BackendRole/WorkspaceRole; any generic implementation stays private. A publicsealed role abstraction buys too little for three roles.
An active role has no configurable enable flag.
FrontendCapability.compilecan befalsebeside a working compile handle, and checking that boolean in a new constructor would only move
today's check. Instead the native authoring descriptor carries the language / IR-version / option
information and nothing else; the projection supplies
compile: true. Same forbackend.generate,and for workspace when it arrives.
Non-emptiness and the builder
At least one role must be present. Rather than encode that structurally — the smallest sum that does
so partitions by first-present-role into three variants, which imposes an arbitrary ordering and
makes accessors awkward — keep private optional slots and prove non-emptiness at construction.
A consuming builder is where type machinery pays. Each mechanism must remove a class of error, not
restate an invariant:
E: Frontend/E: Backendfinishonly on non-emptyStart with empty/non-empty. Add per-role present/absent states only if duplicate registration
should be a compile error; otherwise define repeated setters as replacement explicitly. Never
silently alternate between replacement and accumulation.
The generic boundary: the builder and the typed adapter constructors know
Eand share oneArc<E>; native construction requiresE: Send + Sync + 'static;finishreturns the singleconcrete
NativeExtension. Registry and session types gain no per-extension type parameter. Roleendpoints stay object-safe with concrete request and result types — clone the
Arcs, so neitherEnordyn NativeFrontendneedsClone.Do not erase to
Arc<dyn Extension>and recover roles later: the author traits carry static methods(
traits.rs:9) and are not suited to it. Build each typed adapter while its concrete bound is inscope.
Typestate cannot prove that a claimed language, IR version or cancellation support is truthful.
Those stay implementation contracts, and the daemon keeps validating them.
The guest side
export_extension!consumes one typed registration declaration, binding each role's authoringdescriptor, its
E: RoleTraitbound, and its SDK-generated adapter. Metadata exports and JSON-RPCdiscovery project from that declaration; dispatch uses its registered operations. The separate
__push_extension_types!and__push_extension_dispatchers!authoring paths go away.Registration semantics are shared with the native adapter; native storage is not. The guest
acquires no
Arc, noSend + Sync, and no native instance lifetime.Three guest behaviours must survive:
registrations without inventing new wire fields.
and gates operations afterwards. Do not extend the native "registered means enabled" policy to
guests by accident; if the stronger guest API keeps the distinction, model advertised-but-disabled
explicitly.
E::default(), operation requests create theinstance (
lib.rs:259).Five roles, three capability records, two native endpoints
ExtensionType(types.rs:12) has five variants. Only three carry a detailed capability record —FrontendCapability,BackendCapability,WorkspaceCapability. Only two have a native endpoint —NativeFrontend,NativeBackend. These two asymmetries are different in kind, and this designtreats them differently.
The missing capability records are not a defect. The daemon already gates transform and
validator, by role presence via
ExtensionInfo.types(controller.rs:50); what they lack isdetailed negotiation, not all negotiation. Detailed records earn their place by doing work —
frontend languages and backend targets drive provider resolution against normalized IR versions
(
registry.rs:114), and workspace gates per-request on protocol version. An emptyTransformCapabilitywould do none of that. Adding one for symmetry is explicitly rejected.This is not a permanent judgement.
Validator::validation_rules()andTransform::transformation_names()already exist (traits.rs:48) with no consumer anywhere, andtransform and validator requests carry IR plus an untyped options map with no rule-selection
contract (
types.rs:459). A record becomes worthwhile when the daemon uses it to select a provideror reject a request. Introducing one later is a compatibility change, not an additive one: it
touches the DTOs, the hand-written serializer and its reserved keys, discovery expectations,
validation and gating, and would reject existing peers unless compatibility were explicitly
retained. Promoting a name that extensions already use as an
extrakey carries its own hazard(
types.rs:148).The missing native endpoints are a real gap, and nothing about an IR transform or validator
requires it to run outside the host process. Closing it is additive: a typed endpoint, a concrete
role record, a private slot and pending registration, a builder method bounded on
E: Transform,the type-list and dispatch projections, and tests for single-role construction and
direct/protocol equivalence. It does not disturb
Empty/NonEmpty, the concrete return type, orexisting frontend/backend construction. It is therefore not scheduled in this spec's three PRs,
and should be added when a native consumer needs it.
Constraints this imposes on the work
role whose entire wire projection is a role tag. Any code that assumes every role contributes a
capability field is wrong.
Arc, noSend + Syncin the shared layer.coverage that exercises real macro expansion rather than only the native path.
compatibility distinction. Do not let it harden into a transport-specific definition of what a
role is.
a portable request (
traits.rs:113,file_tree.rs:64) that the devkit binds native filesysteminputs into (
workspace_discovery/mod.rs:207). Anything that genuinely cannot cross a processboundary — a borrowed handle, shared mutable host state, a termination guarantee — is modelled
explicitly rather than hidden, as
Stopped/Indeterminatealready does (transport.rs:54).Transport independence is a separate programme
Naming an adapter module after its transport is not a layering failure; choosing a WASM export macro
or a process launcher at the deployment boundary is correct. But transport does leak into places it
should not, and this work must avoid foreclosing the fix:
E::default()perrequest (
lib.rs:282); native adapters retain a shared instance (native.rs:49). An authorrelying on instance state must know the deployment path. This is a semantic difference.
hostmodule is target-gated (lib.rs:98) and reachesExtism imports (
host.rs:59); native logging macros silently do nothing (lib.rs:217).ResolvedFrontendoffersnative_frontend(),native_mep_session()andinstalled_snapshot()(registry/types.rs:214),and the parent repository's CLI branches across invocation modes as a result
(
finos/morphir,crates/morphir/src/extensions.rs:103).None of these block this refactor — it removes the independently authored declarations that would
otherwise make them harder to fix. The target seam is that role declarations and operation contracts
sit below deployment adapters, and an ordinary caller receives an invocable resolved role or session
while loading, launching, negotiation and transport selection happen beneath it. Direct native
invocation remains a legitimate implementation of that; transport independence does not mean
serializing an in-process call.
What must not change
ExtensionCapabilitieskeeps its wire shape. Project the role fields plus the cross-cuttingrecord back through the existing hand-written
Serialize(types.rs:148), which emits all fourbooleans, omits absent roles, flattens
extra, and rejects reserved keys. Top-levelincrementaland
frontend.incrementalstay distinct.Caveat:
extrais aHashMap, so byte-identical output was never guaranteed acrossindependently built maps. Preserve fields, values, omissions and current ordering behaviour — do
not introduce sorting or canonical JSON here.
ExtensionTypestays. It is a wire discriminator and includesValidatorandTransform(
types.rs:12). Locally registered types become its projection;info.typesis derived. Typesreceived over the wire remain untrusted data.
controller.rs:50gates advertised roles, operation flags andworkspace protocol versions;
validation.rs:389validates externally supplied negotiation dataincluding a legacy backend exception. Locally sound construction does not make a remote peer
trustworthy.
serialization errors, and their current precedence. Silently dropping an advertised backend when
frontend_onlywas requested would be a behaviour change.Sequence
A strictly behaviour-preserving change cannot also eliminate every legacy inconsistency, because the
guest macro can currently emit role metadata that disagrees with its dispatch, and refusing that is
observable. The compatibility change therefore needs its own commits, but no longer its own PR —
see the regrouping note below.
668067d). Internal rolerecords, projections, and native protocol dispatch through registered handles. A consuming
builder with
Empty/NonEmptytypestate replacedwith_extension; the three nativeconstructors survive as thin wrappers preserving every current rejection, including the
workspace one. Behaviour-preserving apart from
capabilities()returning an owned value.declaration, retiring
__push_extension_types!and__push_extension_dispatchers!; thenintroduce the stronger descriptors that carry a role's language / version / option information
with no configurable
compileorgenerateflag, the projection supplyingtrue. Finallyretire the independently supplied aggregate capabilities and role lists, and decide explicitly
whether malformed legacy guest declarations now fail at export or at discovery.
removal of the unconditional rejection in
validate_capabilities. No new constructor percombination. This unblocks step 3 of the single-file-synthesis spec.
Why PR 2 merges what were three steps
The original sequence split guest consolidation from the stronger descriptors, and put the authoring
migration last. They are one subsystem: what an author declares. Building the single registration
declaration and then rebuilding it to carry descriptors means constructing the same shape twice, and
the legacy retirement is only meaningful once there is a new declaration to retire it in favour of.
What the original split bought was the ability to say "this PR changed nothing observable", and that
is preserved at commit granularity rather than PR granularity: the consolidation lands in
behaviour-preserving commits, and the compatibility change — where malformed legacy declarations
begin to fail — lands in its own commits, labelled as such, so bisection still works. The PR
description must state which commits are which. Do not claim the legacy macro path is statically
sound.
Splitting
native.rsinto modules is deliberately not folded into either PR(finos/morphir-rust#196). Its acceptance criterion is that no test assertion changes — module paths
only — and landing it beside a feature makes that criterion unverifiable, which is precisely what
makes a mechanical move reviewable.
Later, optionally: extract the protocol contract into its own crate, then split the native and guest
adapters.
Testing
native.rs:780already covers the behaviours most at risk: direct/protocol equivalence, a singleshared non-
Cloneinstance, construction-time metadata snapshots, workspace rejection, andserialization failures. These are the regression surface for PR 1.
Add: compile-fail coverage for the builder guarantees, and a test that exercises actual
export_extension!expansion — native-only builds omit the exported functions, so the guest path isotherwise untested.
Traps
Vec<RoleEnum>as the authoritative set, without answering duplicates and emptiness.E::capabilities()per request, breaking snapshot semantics.into one PR.
Non-goals
Native workspace support (PR 2). Crate extraction. Any change to the negotiated wire format. Any
change to daemon gating or provider selection.
All reactions