Release v0.0.88
Overview
This release adds two task types and moves authorization behind a provider seam. ExternalHttpTask (type 22) runs an HTTP call directly in the Orchestrator rather than routing it through the Execution service, sharing the existing HTTP configuration, scripting surface and reserved-header hardening with the remote HTTP task (#880). PythonTask (type 23) executes a main(input) contract across Python.NET, an isolated process, or a hardened Docker/Kubernetes container, with strict JSON in and out, centrally configurable limits and no silent fallback between modes (#921). Caller-role resolution becomes provider-based — default keeps today's in-process behaviour, morph-idm calls an external IDM once per request scope and fails closed — while the grant engine, transition.roles, availableIn[].roles, queryRoles, function.roles and schema x-roles all keep their semantics (#927). All distributed events now ride the transactional outbox: the EventHook infrastructure is removed, subflow terminal events additionally settle the parent immediately as a post-commit command with the Inbox handler as a durable deduplicated backup, and a loss-tolerant Dapr nudge wakes the poll loops after a commit — measured relay gap p99 65.9 ms (#927). Polling a parent with an active subflow gets a cache and a 304 for the first time, terminal settlement gains a durable SettledAt marker, and parent loads shrink to exactly the correlation they act on (#928). This release runs on component schema 0.0.53.
Features
External HTTP task — type 22 (#880)
Tracked under milestone v0.0.85; shipped in v0.0.88.
ExternalHttpTask is registered as TaskType.ExternalHttp = 22 and is executed directly by the Orchestrator, in process, rather than being dispatched to the Execution service. It takes the existing HTTP task configuration and the same scripting surface, so a definition is a type change away from its remote counterpart:
{
"key": "fetch-fx-rate",
"type": "22",
"config": {
"url": "https://rates.example.com/api/v1/fx/{{currency}}",
"method": "GET",
"acceptedStatusCodes": [200, 204],
"timeoutSeconds": 10,
"validateSsl": true
}
}
- Behavioral parity with the remote HTTP task is the design goal, not a coincidence: request execution is centralized in a shared
HttpTaskInvocationused by both hosts, so reserved-header filtering, trusted correlation headers, SSL selection, timeout handling, response parsing, accepted-status matching and error handling are one implementation rather than two that drift. - Transport failures and cancellations return structured task results and are logged with dedicated events, including a distinct event when SSL validation is disabled.
- Pooled instances are registered so cloning and cached task creation preserve both the HTTP configuration and the runtime type.
- The trade-off is explicit: the call runs in the Orchestrator's process, so it consumes orchestration resources and its failure modes land there. Use it when the extra hop to Execution is not worth paying; use type
HttpTaskwhen you want the call isolated.
Reference: PR #880 — see also External HTTP task.
Python task — type 23 (#921)
Tracked under milestone v0.0.87; shipped in v0.0.88.
PythonTask is registered as TaskType.Python = 23 with JSON discriminator "23", routed to the Execution service through the python task route. Scripts follow a single main(input) contract and exchange strict JSON values with the runtime — there are no Orchestration InputHandler or OutputHandler mappings.
{
"key": "calculate-order-summary",
"type": "23",
"config": {
"script": {
"location": "calculate_order_summary.py",
"code": "import numpy as np\n\ndef main(input):\n values = np.asarray(input['values'], dtype=float)\n return {'total': float(values.sum()), 'values': values.tolist()}",
"type": "LOC",
"encoding": "NAT"
},
"executionMode": "pythonNet",
"input": { "values": [2, 3, 5] },
"timeoutSeconds": 30
}
}
The complete config.input value is passed directly to main(input). The return value must serialize under strict JSON semantics with allow_nan=False; NumPy and pandas values need .item(), .tolist() or .to_dict(). Scripts may be natural text (NAT) or Base64 (B64); reference-based scripts and filesystem paths are rejected.
Three execution modes, no silent fallback:
| Mode | Behavior |
|---|---|
pythonNet | Default. Initializes CPython once and executes each invocation in a fresh scope under the GIL, with configurable concurrency |
process | Runs python -I with a shared JSON runner protocol, kills the process tree on timeout, applies Linux resource limits through prlimit |
container | Delegates to the explicitly configured docker or kubernetes driver |
A disabled or unavailable mode fails the task rather than falling back to another. Syntax errors, a missing or non-callable main, Python exceptions, invalid JSON results, NaN values, output overflow, timeout and cancellation all enter the existing task failure and error-boundary flow.
Default limits: 30-second execution timeout with a configurable maximum of 50 s; 256 KiB decoded script; 2 MiB input and output; 32 KiB captured stdout and stderr; Python.NET concurrency 1, process and container concurrency 2.
Hardening applies across modes: non-root execution, read-only root filesystem, dropped capabilities, no privilege escalation, isolated /tmp, configurable CPU/memory limits and network isolation. The Kubernetes driver creates one batch/v1 Job per invocation with backoffLimit: 0 and an independent activeDeadlineSeconds, sends JSON input through pods/attach rather than ConfigMaps, Secrets, environment variables or command-line values, disables service-account token mounting, runs as UID/GID 65532 under RuntimeDefault seccomp with a memory-backed /tmp, and deletes the Job in a finally path with TTL cleanup as a safeguard. Kubernetes has no portable per-Pod PID limit field, so the requested limit is recorded as metadata and must be enforced through node, runtime, RuntimeClass, admission or cluster policy.
Dependencies are hash-locked: a committed transitive requirements file (NumPy 2.5.1, pandas 3.0.5, scikit-learn 1.9.0) installed with --require-hashes in both the Execution virtual environment and the runner image. Runtime package installation is disabled. AllowedModules defaults to ["*"]; when narrowed it acts as an administrative dependency policy and is not a security sandbox.
Observability records the execution mode, duration, result, task type and runtime version — never the script, input or output content.
Python task execution is disabled by default (Python:Enabled = false) and should be treated as experimental. Enable it deliberately, per host, after reviewing the limits and hardening for your deployment.
Reference: PR #921 — see also Python task and Python configuration.
Provider-based caller-role authorization (#927)
Authorization resolved caller roles from a single in-process source — ICurrentUser.Roles, falling back to the role header — and that shape leaves no room for an external IDM, which needs I/O.
A new ICallerRoleResolver seam has two implementations: DefaultCallerRoleResolver (today's behaviour, unchanged) and MorphIdmCallerRoleResolver (a typed HttpClient). It mirrors the existing IAuthorizeGateway / RemoteAuthorizeGateway pattern. The provider is chosen once, in configuration:
{
"CallerRoleProvider": {
"Provider": "default",
"MorphIdm": {
"BaseUrl": "",
"GetRolesPath": "/api/1/morph-idm/functions/get-roles",
"TimeoutSeconds": 5,
"MaxRetryAttempts": 1,
"RetryDelayMilliseconds": 200,
"CircuitBreakerFailureThreshold": 20,
"CircuitBreakerTimeoutSeconds": 30,
"ValidateSsl": true
}
}
}
- The default stays
default, so upgrading changes no behaviour until a host opts in. - Morph-IDM is called once per request scope, without the
roleheader, and the returned operation set is evaluated locally against the existing grant engine.RoleGrantEvaluatorandTransitionAuthorizationManagerare untouched —transition.roles,availableIn[].roles,queryRoles,function.rolesand schemax-rolesall keep their semantics. - Memoization is a single
Lazy<Task<Result<…>>>on a scoped instance. A boolean flag would not have been enough: the instance query service fans subflow reads out concurrently, so two callers would have raced into two IDM calls. - Fail-closed. Provider errors propagate as
Resultfailures (403), and the failure is memoized too — a failing scope stays denied and calls the IDM at most once. - A new
Auth.ResolveRolesspan makes the round trip visible on both memo hit and miss.
The role gate is removed from custom function invocation. Authorizing a custom function call is a middle-tier concern; vNext's job is visibility plus the authorize function. The scope gate (Domain / Flow / Instance) stays, because that is call-shape validation rather than authorization, and function.roles is still honoured — now solely by the authorize function.
Reference: PR #927 — see also Caller role provider configuration.
Outbox-only event publishing, subflow terminal relay and a wakeup nudge (#927)
The EventHook infrastructure is removed entirely — IEventPublishHook<T>, IEventHookInvoker, EventHookAttribute, EventHookMode. Every distributed event now rides the transactional outbox, and the event bus is reduced to trace stamping. This removes internal infrastructure only: no authored component, contract or API surface changes shape.
On top of that, the three subflow terminal events implement ISubflowTerminalEvent and get a fast path: post-commit, SubflowTerminalRelay settles the parent immediately as a command, while the Inbox handler remains a durable backup, deduplicated by ISubItemTerminalGuard. A loss-tolerant Dapr wakeup nudge wakes the Outbox and Inbox poll loops after a commit, so the common case does not wait out the idle poll interval. Measured relay gap p99 65.9 ms.
Read-path tracing gains Subflow.Descend/{flow} across all seven built-in-function descent points, with an ambient depth carried by AsyncLocal in process and X-Subflow-Depth across domains. Task, function, extension and invoker envelopes get phase spans; fact events root a linked delivery trace while commands continue the producer's trace; and worker idle polling no longer mints root Db.* spans.
Reference: PR #927 — see also Event-driven workflows.
Active-subflow state cache, durable settlement and slimmer parent loads (#928)
Three packages, all targeting costs measured in load-test traces.
Active-subflow state polls are cached. A poll on a parent with an active subflow bypassed both the 304 fast path and the body cache, paying a full parent load plus a live subflow walk on every poll. That snapshot is now cached for a short window — StateFunctionCache:ActiveSubflowTtlMilliseconds, default 500 ms — validated by a parent-only fingerprint ETag, so any subflow change that reaches the correlation row invalidates it immediately. Concurrent rebuilds coalesce behind a per-key build gate. A 304 becomes possible inside the subflow window for the first time.
Terminal settlement is durable. A SettledAt marker on the correlation is written by a set-based update, only after the blocking parent resume commits (migration AddSubflowSettlementMarker, a nullable column). The lock-free terminal guard can now answer duplicate deliveries conclusively for blocking SubFlows too — previously SubProcess-only — dropping the distributed lock and the full parent aggregate load for every duplicate Inbox delivery. Reverting a correlation clears the marker, and an outcome-matched predicate keeps a stale delivery from settling a different outcome.
Parent loads shrink. Subflow start, completion and post-commit settlement now load latest data only plus exactly the correlation they act on (FindForSubflowStart / Completion / PostCommitSettlement) instead of the full detail aggregate. Output-mapping compilation and workflow resolution move outside the terminal lock, so a cold script compile no longer extends the critical section.
Reference: PR #928 — see also Caching configuration.
Behavior Changes
Two items change observable behavior. The role gate is removed from custom function invocation — a caller who was previously denied by function.roles at invoke time is now denied only by the scope gate and by whatever the middle tier enforces; function.roles is still evaluated, but by the authorize function (#927). And the EventHook infrastructure is removed; anything implementing IEventPublishHook<T> or relying on EventHookMode must move to the transactional outbox (#927). Each item, its impact direction and the migration steps are documented in the v0.0.88 breaking changes announcement.
Fixes
- A stale terminal delivery could settle a correlation belonging to a different outcome — settlement now uses an outcome-matched predicate (#928).
- Reverting a correlation left its settlement marker set, so a later legitimate delivery was treated as a duplicate (#928).
- A cold script compile extended the subflow terminal lock — output-mapping compilation and workflow resolution now run outside the critical section (#928).
- Concurrent caller-role resolution could issue two IDM calls per request scope — the instance query service fans subflow reads out concurrently, so memoization had to be a single
Lazy<Task<…>>rather than a boolean flag (#927).
Configuration Updates
Configuration for v0.0.88:
{
"runtimeVersion": "0.0.88",
"schemaVersion": "0.0.53"
}
Note: Schema version is unchanged at 0.0.53, and
@burgan-tech/vnext-schema0.0.53 does not yet cover task types 22 and 23.npm run validaterejects anExternalHttpTaskorPythonTaskdefinition; publishing to the runtime and executing it are unaffected. A schema release covering both types is required before a domain project can validate cleanly against this runtime.
New settings in this release:
CallerRoleProvider:*—Provider(default|morph-idm) plus theMorphIdmblock (BaseUrl,GetRolesPath,TimeoutSeconds,MaxRetryAttempts,RetryDelayMilliseconds,CircuitBreakerFailureThreshold,CircuitBreakerTimeoutSeconds,ValidateSsl). See the section above for the defaults.Python:*on the Execution host —Enabled(defaultfalse),DefaultMode(pythonNet),EnabledModes(["pythonNet","process"]),MaxTimeoutSeconds(50), the size limits,AllowedModules, and thePythonNet/Process/Container(Docker and Kubernetes) sections. Every value is overridable through standard .NET configuration providers, e.g.Python__Enabled,Python__DefaultMode.StateFunctionCache:ActiveSubflowTtlMilliseconds— default500.
Aether 1.0.39 is required.
Migration AddSubflowSettlementMarker adds a nullable SettledAt column on the correlation.
Packaging changes. The Execution image base moves from Alpine to .NET 10 Noble, so the Python environment can consume glibc/manylinux wheels — check any Alpine-specific assumptions (busybox tooling, apk steps, musl-linked native dependencies) in derived images or init containers. A new python-runner image joins the multi-architecture build with the same digest, SBOM, signing, attestation and release-note handling as the other images, and is what the container execution modes pull.
On v0.0.89. A 0.0.89 tag exists but is a re-cut of the same code: it changes only the Execution Dockerfile. There is no runtime behaviour difference between 0.0.88 and 0.0.89, and no separate release note.
Container images: published at tag 0.0.88 under ghcr.io/burgan-tech/vnext/*, Cosign-signed (keyless OIDC) with SBOM + provenance. Immutable digests are listed in the GitHub release.
Issues Referenced
- vnext #880 — External HTTP task type executed directly by the Orchestrator.
- vnext #921 — Python task execution across Python.NET, process and container runtimes.
- vnext #927 — Provider-based caller-role authorization, outbox-only event publishing, end-to-end trace coverage.
- vnext #928 — Cache active-subflow state polls, settle terminals durably, shrink parent loads.
Summary
ExternalHttpTask(type 22) runs an HTTP call in the Orchestrator, sharing oneHttpTaskInvocationimplementation — and therefore its reserved-header, correlation, SSL, timeout and status-matching behaviour — with the remote HTTP task.PythonTask(type 23) runsmain(input)underpythonNet|process|containerwith strict JSON, hash-locked dependencies, hardened containers and no silent mode fallback. Disabled by default; treat as experimental.- Caller roles resolve through a provider (
default|morph-idm), once per request scope, fail-closed, with the grant engine untouched. The role gate is removed from custom function invocation;function.rolesmoves to theauthorizefunction. - Every distributed event rides the outbox;
EventHookinfrastructure is removed. Subflow terminal events additionally relay post-commit, with the Inbox as a deduplicated durable backup and a wakeup nudge — relay gap p99 65.9 ms. - Active-subflow polls are cached (
ActiveSubflowTtlMilliseconds, default 500 ms) with a parent-only fingerprint ETag; 304 is possible inside a subflow window for the first time. - Durable
SettledAtmarker (migrationAddSubflowSettlementMarker) lets the lock-free guard answer duplicates for blocking SubFlows; parent loads narrow to the acting correlation. - Requires Aether 1.0.39; the Execution image base moves Alpine → .NET 10 Noble; new
python-runnerimage. - Schema stays at 0.0.53 and does not yet cover types 22 and 23. v0.0.89 is a Dockerfile-only re-cut of this release.
vNext Runtime Platform Team Released September 2, 2026
