Task Registration
Task registration lets applications discover, inspect, and construct tasks by stable catalog identity. It is the first layer of Genja's task catalog model: CLIs, MCP servers, provider manifests, and future remote catalogs can inspect a task descriptor before deciding whether to construct and run the task.
Registration is opt-in. Existing Rust tasks that use #[genja_task(...)]
without registration(...), and existing Python tasks that use @task(...)
without registration=..., continue to work as normal runtime tasks.
When To Use Registration
Use registration when a task should be addressable by a stable catalog identity instead of by directly constructing the concrete task type in application code.
Registration is useful for:
- listing locally available registered tasks
- looking up task metadata by ID and version
- showing input schemas to a CLI, UI, or MCP client
- constructing a task from JSON input
- exporting descriptor metadata into future provider manifests
Direct construction is still the simplest path when the caller already knows the concrete task type:
let task = BackupConfig {
backup_path: "/tmp/configs".to_string(),
compress: true,
};
task = BackupConfig(
backup_path="/tmp/configs",
compress=True,
)
Registration is the API-style path when the caller knows a task identity and has JSON-compatible input:
let task = create_compiled_task_by_identity(
"acme.examples.backup_config@1.0.0",
serde_json::json!({
"backup_path": "/tmp/configs",
"compress": true,
"rules": []
}),
)?;
task = create_registered_task_by_identity(
"acme.examples.backup_config@1.0.0",
{
"backup_path": "/tmp/configs",
"compress": True,
"rules": [],
},
)
Discovery And Stable IDs
Rust tasks annotated with #[genja_task(...)] are submitted to the compiled
task registry. Python tasks are submitted to the Python registry when the module
containing the decorated class is imported.
Rust tasks without explicit registration receive a generated ID:
auto:<rust-type-path>
Generated IDs are useful for local Rust discovery, but they are derived from
Rust implementation details and should not be treated as stable public
contracts. Renaming a type or moving a module can change the generated ID.
Python task registration is explicit-only; Python classes without
registration=... remain executable but do not appear in the Python task
registry.
Registered tasks use an explicit stable ID and a semantic version. The public identity is:
<task-id>@<task-version>
For example:
acme.examples.backup_config@1.0.0
Task IDs are namespace-friendly lowercase identifiers made of . separated
segments. Versions must be semantic versions.
For Rust, version is optional. If it is omitted, the macro uses the provider
crate version from CARGO_PKG_VERSION.
For Python, version is optional in the API but only works when Genja can map
the decorated class's top-level module to exactly one installed Python
distribution and read that distribution's package metadata version. If the task
lives in a script, loose module, test file, namespace package, or any layout
where that mapping is missing or ambiguous, registration fails with a
validation error. In those cases, provide an explicit semantic version in
TaskRegistration. For example, use version="1.0.0" for the first stable
version of the task contract.
The examples below set version explicitly in both languages:
#[genja_task(
name = "backup_config",
registration(
id = "acme.examples.backup_config",
version = "1.0.0"
)
)]
impl BackupConfig {
// task methods
}
@task(
name="backup_config",
registration=TaskRegistration(
id="acme.examples.backup_config",
version="1.0.0",
),
)
class BackupConfig:
# task methods
...
Duplicate id + version registrations return a deterministic registry error
when the compiled registry is built.
Default JSON Construction
The normal factory maps JSON-compatible input into the task constructor.
The default factory for registered Rust tasks is serde deserialization.
When factory is omitted, Genja treats the JSON input passed to the
registry as the task struct's construction input. The JSON object must
match the task fields.
use genja::genja_core::inventory::Host;
use genja::genja_core::task::{
HostTaskResult, TaskError, TaskRuntimeContext, TaskSuccess,
};
use genja::genja_task;
#[derive(serde::Deserialize)]
struct BackupConfig {
backup_path: String,
compress: bool,
}
#[genja_task(
name = "backup_config",
connection_plugin_name = "ssh",
registration(
id = "acme.examples.backup_config",
version = "1.0.0",
description = "Backs up selected paths from a network device"
)
)]
impl BackupConfig {
async fn start_async(
&self,
_host: &Host,
_context: &TaskRuntimeContext,
) -> Result<HostTaskResult, TaskError> {
Ok(HostTaskResult::passed(
TaskSuccess::new().with_summary(format!(
"backing up to {} compress={}",
self.backup_path,
self.compress
)),
))
}
}
use genja::genja_core::task::{TaskInfo, create_compiled_task_by_identity};
use serde_json::json;
let task = create_compiled_task_by_identity(
"acme.examples.backup_config@1.0.0",
json!({
"backup_path": "/tmp/configs",
"compress": true
}),
)?;
assert_eq!(task.name(), "backup_config");
Python uses TaskFactory.KWARGS for the same shape: Genja calls
TaskClass(**input).
from genja.task import (
TaskFactory,
TaskRegistration,
TaskSuccessResult,
task,
)
@task(
name="backup_config",
connection_plugin_name="ssh",
registration=TaskRegistration(
id="acme.examples.backup_config",
version="1.0.0",
description="Backs up selected paths from a network device",
factory=TaskFactory.KWARGS,
),
)
class BackupConfig:
def __init__(self, backup_path: str, compress: bool) -> None:
self.backup_path = backup_path
self.compress = compress
async def start_async(self, task, host, context):
return TaskSuccessResult(
summary=(
f"backing up to {self.backup_path} "
f"compress={self.compress}"
)
)
from genja.task import create_registered_task_by_identity
task_definition = create_registered_task_by_identity(
"acme.examples.backup_config@1.0.0",
{
"backup_path": "/tmp/configs",
"compress": True,
},
)
For Rust serde construction, the input:
{
"backup_path": "/tmp/configs",
"compress": true
}
maps directly to the task struct:
BackupConfig {
backup_path: "/tmp/configs".to_string(),
compress: true,
}
The registry returns a TaskDefinition, which is Genja's runtime wrapper around
the constructed task. Conceptually, the factory result is:
TaskDefinition::new(BackupConfig {
backup_path: "/tmp/configs".to_string(),
compress: true,
})
For Python TaskFactory.KWARGS, the same JSON-compatible mapping is expanded
into keyword arguments with TaskClass(**input).
This is the normal path when the public JSON input has the same shape as the
task constructor. Rust task types must implement
serde::de::DeserializeOwned; nested input types must be deserializable too.
If the JSON does not match the Rust struct fields, construction fails with
TaskRegistrationError::InvalidInput. Python constructor errors are reported
as task input-validation errors with the task identity included.
Input Schemas
Add input schema metadata when callers should be able to inspect the expected JSON-compatible input shape.
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct BackupConfig {
backup_path: String,
compress: bool,
}
#[genja_task(
name = "backup_config",
registration(
id = "acme.examples.backup_config",
input_schema = "schemars"
)
)]
impl BackupConfig {
// task methods
}
input_schema = "schemars" requires the task type and any nested input
types to implement schemars::JsonSchema.
let descriptor =
get_compiled_task_descriptor_by_identity("acme.examples.backup_config@1.0.0")?;
if let Some(schema) = descriptor.input_schema {
println!("{}", serde_json::to_string_pretty(&schema)?);
}
from genja.task import ExplicitInputSchema, TaskRegistration
registration = TaskRegistration(
id="acme.examples.backup_config",
version="1.0.0",
input_schema=ExplicitInputSchema(
value={
"type": "object",
"required": ["backup_path", "compress"],
"properties": {
"backup_path": {"type": "string"},
"compress": {"type": "boolean"},
},
},
),
)
Python can also derive schema from Pydantic:
from pydantic import BaseModel
from genja.task import PydanticInputSchema, TaskRegistration
class BackupConfigInput(BaseModel):
backup_path: str
compress: bool
registration = TaskRegistration(
id="acme.examples.backup_config",
version="1.0.0",
input_schema=PydanticInputSchema(model=BackupConfigInput),
)
descriptor = get_registered_task_descriptor_by_identity(
"acme.examples.backup_config@1.0.0"
)
print(descriptor.to_dict()["input_schema"])
Run the complete Rust schema example from the repository root:
cargo run -p genja --example task_registration
Listing And Looking Up Tasks
Use list APIs when you need a local task list:
use genja::genja_core::task::list_compiled_tasks;
for descriptor in list_compiled_tasks()? {
println!("{}@{} {}", descriptor.id, descriptor.version, descriptor.name);
}
from genja.task import list_registered_tasks
for descriptor in list_registered_tasks():
print(descriptor.identity, descriptor.name)
Use identity-based lookup when a CLI, API, or MCP tool receives a single task identity string:
use genja::genja_core::task::get_compiled_task_descriptor_by_identity;
let descriptor =
get_compiled_task_descriptor_by_identity("acme.examples.backup_config@1.0.0")?;
from genja.task import get_registered_task_descriptor_by_identity
descriptor = get_registered_task_descriptor_by_identity(
"acme.examples.backup_config@1.0.0"
)
Lookup by ID without a version succeeds only when exactly one version exists. If multiple versions are registered for the same ID, Genja returns an ambiguous version error so callers do not accidentally run the wrong contract.
Rust Construction APIs
The compiled registry exposes two construction paths. Use the direct registry path when application code already has a task identity and JSON value. Use a task spec when the caller supplies a YAML or JSON text payload that includes both the task identity and input.
| Use case | API |
|---|---|
| List compiled task descriptors | list_compiled_tasks() |
| Inspect one descriptor by identity | get_compiled_task_descriptor_by_identity(...) |
| Construct from identity plus JSON value | create_compiled_task_by_identity(...) |
| Parse a YAML/JSON task spec without constructing | TaskSpec::parse_auto(...) |
| Construct from a YAML/JSON task spec string | create_compiled_task_from_spec_str(...) |
Rust Construction From JSON
Use create_compiled_task_by_identity(...) when the caller supplies JSON input:
use genja::genja_core::task::{TaskInfo, create_compiled_task_by_identity};
use serde_json::json;
let task = create_compiled_task_by_identity(
"acme.examples.backup_config@1.0.0",
json!({
"backup_path": "/tmp/configs",
"compress": true
}),
)?;
println!("created task {}", task.name());
Construction returns a TaskDefinition. Most task authors do not need to build
or inspect this wrapper directly; it exists so registry, task-list, and runtime
APIs can store and execute different task types through one interface.
Rust Declarative Task Specs
Use TaskSpec when the caller supplies a single task invocation as text, such
as a CLI file, API request, or MCP tool payload. A task spec contains the
registered task identity, the JSON-compatible input passed to that task's
factory, and optional runtime policy overrides.
YAML:
task: acme.examples.backup_config@1.0.0
input:
backup_path: /tmp/configs
compress: true
rules:
- path: /etc/network
recursive: true
overrides:
retry:
allow: true
max_attempts: 3
delay_ms: 500
session_verification:
max_attempts: 2
delay_ms: 1000
JSON:
{
"task": "acme.examples.backup_config@1.0.0",
"input": {
"backup_path": "/tmp/configs",
"compress": true,
"rules": [
{
"path": "/etc/network",
"recursive": true
}
]
},
"overrides": {
"retry": {
"allow": true,
"max_attempts": 3,
"delay_ms": 500
},
"session_verification": {
"max_attempts": 2,
"delay_ms": 1000
}
}
}
Parse a spec without constructing the task when you need to inspect or validate the request first:
use genja::genja_core::task::TaskSpec;
let spec = TaskSpec::parse_auto(source)?;
println!("requested task {}", spec.task);
Construct directly from a spec string when the caller wants the registered task:
use genja::genja_core::task::{TaskInfo, create_compiled_task_from_spec_str};
let task = create_compiled_task_from_spec_str(source)?;
println!("created task {}", task.name());
parse_auto(...) tries JSON first, then YAML. If neither parser accepts the
input, Genja returns an auto-parse error that includes both parser messages. If
the text parses but is not an object with task, optional input, and optional
overrides, Genja returns a task-spec shape error instead of treating it as a
registry failure.
Overrides are narrow per-run runtime policy controls. The first supported fields are:
| Override | Effect |
|---|---|
retry |
Overrides the constructed task's retry policy for this run. |
session_verification |
Overrides post-change session verification policy for this run. |
Overrides do not rewrite authored task behavior. The spec intentionally does not support overriding processors, connection plugin names, execution mode, task identity, factory strategy, input schema, or registration metadata. In particular, processor overrides are rejected because processors can affect startup hooks, result handling, and side effects selected by the task author.
This is a single-task construction spec. It is not a workflow DSL or task list. Future work can build those features on top of the same registered task identity and JSON-compatible input contract.
Run the complete task spec example from the repository root:
cargo run -p genja --example task_registration_spec
Default Factory
Use the default factory for registered tasks that do not accept construction input.
#[derive(Default)]
struct CollectFacts;
#[genja_task(
name = "collect_facts",
registration(
id = "acme.examples.collect_facts",
factory = "default"
)
)]
impl CollectFacts {
// task methods
}
The task type must implement Default. Callers must pass null or {}
as construction input.
from genja.task import TaskFactory, TaskRegistration, task
@task(
name="collect_facts",
registration=TaskRegistration(
id="acme.examples.collect_facts",
version="1.0.0",
factory=TaskFactory.DEFAULT,
),
)
class CollectFacts:
async def start_async(self, task, host, context):
...
Genja calls CollectFacts(). Callers may omit input or pass an empty
mapping; non-empty input is rejected.
Custom Factory
Use a custom factory when the public JSON-compatible input should differ from the task constructor or when construction requires input preparation.
Use factory = custom(path) to point at a Rust factory function.
use genja::genja_core::task::TaskRegistrationError;
use serde_json::Value;
struct ConfigureAcl {
acl_name: String,
secret_token: String,
}
fn create_configure_acl(input: Value) -> Result<ConfigureAcl, TaskRegistrationError> {
let acl_name = input
.get("acl")
.and_then(Value::as_str)
.ok_or_else(|| TaskRegistrationError::InvalidInput {
id: "acme.examples.configure_acl".to_string(),
version: "1.0.0".to_string(),
message: "`acl` is required".to_string(),
})?;
let token_obfuscated = input
.get("token_obfuscated")
.and_then(Value::as_str)
.ok_or_else(|| TaskRegistrationError::InvalidInput {
id: "acme.examples.configure_acl".to_string(),
version: "1.0.0".to_string(),
message: "`token_obfuscated` is required".to_string(),
})?;
Ok(ConfigureAcl {
acl_name: acl_name.to_string(),
secret_token: token_obfuscated.chars().rev().collect(),
})
}
Then reference the function from the task macro:
#[genja_task(
name = "configure_acl",
registration(
id = "acme.examples.configure_acl",
version = "1.0.0",
factory = custom(create_configure_acl)
)
)]
impl ConfigureAcl {
// task methods
}
Use CustomTaskFactory(callable=...) to point at a Python factory
callable.
from genja.task import CustomTaskFactory, TaskRegistration, task
def create_configure_acl(input):
acl_name = input.get("acl")
token_obfuscated = input.get("token_obfuscated")
if not isinstance(acl_name, str):
raise ValueError("`acl` is required")
if not isinstance(token_obfuscated, str):
raise ValueError("`token_obfuscated` is required")
return ConfigureAcl(
acl_name=acl_name,
secret_token="".join(reversed(token_obfuscated)),
)
@task(
name="configure_acl",
registration=TaskRegistration(
id="acme.examples.configure_acl",
version="1.0.0",
factory=CustomTaskFactory(callable=create_configure_acl),
),
)
class ConfigureAcl:
def __init__(self, acl_name: str, secret_token: str) -> None:
self.acl_name = acl_name
self.secret_token = secret_token
async def start_async(self, task, host, context):
...
The callable receives the parsed input mapping and must return an instance of the registered task class.
Custom factories should return sanitized errors. Identify the affected task and field, but do not include raw input values or decoded secret material in error messages. Genja wraps factory failures with the registered task identity.
Run the complete custom factory example from the repository root:
cargo run -p genja --example task_registration_custom_factory
Descriptor JSON
Descriptors serialize to JSON for future provider manifests, remote catalogs, and MCP tooling:
{
"id": "acme.examples.backup_config",
"id_source": "explicit",
"name": "backup_config",
"version": "1.0.0",
"description": "Backs up selected paths from a network device",
"execution_mode": "async",
"connection_plugin_name": "ssh",
"processor_names": [],
"retry": null,
"input_schema": null,
"constructible": true
}
Python Import Boundary
Python task registration uses the same descriptor JSON contract as Rust task
registration. Registration is opt-in: existing Python @task(...) classes
without registration=... continue to work with
TaskDefinition.from_python_class(...) and normal runtime execution.
registration=... is handled by the Python @task(...) decorator itself. The
decorator still attaches normal execution metadata to the class. When
registration is also supplied, the decorator validates the registration
metadata, builds the language-neutral descriptor, records the selected factory,
and adds the class to the in-process Python task registry.
That decorator work happens only when Python executes the class definition. In normal Python programs, that means when the module containing the decorated class is imported.
# acme/tasks/network.py
from genja.task import TaskRegistration, task
@task(
name="configure_acl",
registration=TaskRegistration(
id="acme.network.configure_acl",
version="1.0.0",
),
)
class ConfigureAcl:
async def start_async(self, task, host, context):
...
The ConfigureAcl descriptor appears after the module is imported:
import acme.tasks.network
from genja.task import list_registered_tasks
descriptors = list_registered_tasks()
Genja does not scan Python files looking for decorators. A task module that exists on disk but has not been imported has not executed its class definition, so its decorator has not run and its descriptor is not in the registry yet. Provider manifests, entry points, or task-directory discovery can import known task modules automatically in future features; this API only lists Python tasks that are already imported in the current process.
Python Design Constraints
Python registration is automatic after import, not automatic from files on disk. This follows from how Python creates classes and applies decorators.
Keep these constraints in mind when designing Python providers, CLIs, or task catalog tooling:
- The Python registry is in-process. Each process that wants to list, inspect, or construct Python registered tasks must import the task modules first.
- Importing a package only executes that package's
__init__.py. It does not automatically import every module below the package unless__init__.pyimports those modules. - A Rust or PyO3 implementation of the Python decorator would not remove the
import requirement. Rust
inventorycan collect compiled Rust registration records, but Python task classes are created dynamically when Python executes their modules. - Python
versioninference only works when the decorated class's top-level module maps to exactly one installed Python distribution with readable package metadata. Scripts, loose modules, tests, and ambiguous package layouts should setversionexplicitly. - Python task bodies still execute as Python code. Rust and Tokio can
coordinate the surrounding Genja runtime, but
start(...)andstart_async(...)run through the Python runtime. input_schemais descriptor metadata. It describes JSON-compatible construction input for CLIs, UIs, MCP tools, and catalogs; it is not a separate task invocation format.
Future provider manifests, package entry points, or explicit CLI flags can solve discovery by declaring which Python modules to import before reading the registry.
Python Factories
Python registration supports three construction strategies:
| Factory | Behavior |
|---|---|
TaskFactory.KWARGS |
Calls TaskClass(**input). Input must be a JSON-compatible mapping. |
TaskFactory.DEFAULT |
Calls TaskClass() and rejects non-empty input. |
CustomTaskFactory(callable=...) |
Calls the custom callable with the input mapping. The callable must return an instance of the registered task class. |
Factory and input errors identify the affected task identity, such as
acme.network.configure_acl@2.0.0.
Python Input Schemas
TaskRegistration(input_schema=...) describes the parsed JSON-compatible input
shape. It is not the input payload itself. Input may come from JSON, YAML, or
another source, but once parsed it is described with JSON Schema and serialized
in descriptors as input_schema.
Python input schema options are:
| Input schema | Behavior |
|---|---|
| omitted | Descriptor input_schema is null. |
ExplicitInputSchema(value={...}) |
Uses the supplied JSON Schema mapping. |
PydanticInputSchema(model=Model) |
Calls Model.model_json_schema() and stores the generated schema. |
For custom factories or early registration adoption, omitting input schema is valid. Public provider tasks should include input schema when practical so CLIs, UIs, and catalog tooling can present expected input fields.
Related Examples
genja/examples/task_registration.rsshows the baseline registration, descriptor listing, schema, and direct construction path.genja/examples/task_registration_custom_factory.rsshows a custom factory for prepared JSON input and sanitized validation errors.genja/examples/task_registration_spec.rsshows YAML/JSON task spec parsing, construction, and retry/session verification overrides.genja/examples/python/task_registration.pyshows Python imported-module registration, descriptor listing, and construction by<id>@<version>.