Skip to main content

Release v0.0.88

Β· 14 min read
vNext Team
Burgan Tech Engineering

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 HttpTaskInvocation used 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 HttpTask when 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:

ModeBehavior
pythonNetDefault. Initializes CPython once and executes each invocation in a fresh scope under the GIL, with configurable concurrency
processRuns python -I with a shared JSON runner protocol, kills the process tree on timeout, applies Linux resource limits through prlimit
containerDelegates 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.

warning

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 role header, and the returned operation set is evaluated locally against the existing grant engine. RoleGrantEvaluator and TransitionAuthorizationManager are untouched β€” transition.roles, availableIn[].roles, queryRoles, function.roles and schema x-roles all 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 Result failures (403), and the failure is memoized too β€” a failing scope stays denied and calls the IDM at most once.
  • A new Auth.ResolveRoles span 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-schema 0.0.53 does not yet cover task types 22 and 23. npm run validate rejects an ExternalHttpTask or PythonTask definition; 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 the MorphIdm block (BaseUrl, GetRolesPath, TimeoutSeconds, MaxRetryAttempts, RetryDelayMilliseconds, CircuitBreakerFailureThreshold, CircuitBreakerTimeoutSeconds, ValidateSsl). See the section above for the defaults.
  • Python:* on the Execution host β€” Enabled (default false), DefaultMode (pythonNet), EnabledModes (["pythonNet","process"]), MaxTimeoutSeconds (50), the size limits, AllowedModules, and the PythonNet / Process / Container (Docker and Kubernetes) sections. Every value is overridable through standard .NET configuration providers, e.g. Python__Enabled, Python__DefaultMode.
  • StateFunctionCache:ActiveSubflowTtlMilliseconds β€” default 500.

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 one HttpTaskInvocation implementation β€” and therefore its reserved-header, correlation, SSL, timeout and status-matching behaviour β€” with the remote HTTP task.
  • PythonTask (type 23) runs main(input) under pythonNet | process | container with 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.roles moves to the authorize function.
  • Every distributed event rides the outbox; EventHook infrastructure 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 SettledAt marker (migration AddSubflowSettlementMarker) 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-runner image.
  • 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