Skip to content

Releases: Accenture/mercury-composable

v4.12.20

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 01 Oct 03:11
Immutable release. Only release title and notes can be modified.

Version 4.12.20, 10/1/2026

Exact decimal arithmetic for money and rates, and a deterministic package format for related documents, both on both engines
in lock-step (Rust 4.12.20, Increments 144, 145 and 147). graph.math gains a DECIMAL statement and Event Script gains seven
f:decimal* plugins, one specification and one set of shared vectors; a new CanonicalPackager turns a set of maps into one
byte array whose bytes depend only on its content, proven byte for byte against the Rust engine by a shared vector file.
Upgrade action: read items 4 and 5. A numeric-looking string now compares as a number, and round of a negative exact
half moves away from zero; a graph that never says DECIMAL is affected by both.

Added

  1. DECIMAL statement for graph.math — the high-precision COMPUTE (RFC-0001). DECIMAL: var -> expression
    computes in exact decimal arithmetic and stores a canonical decimal string at {node}.result.{var} (plain notation,
    the computed scale kept, a zero of any scale written "0"), so the value survives graph.suspend/graph.resume and
    every event hop unchanged. + - * are exact, / is the exact quotient when it terminates and otherwise 34 significant
    digits half-even, % and **/pow with a whole-number exponent (-999 to 999), and round(x, scale, mode) always names
    its mode (HALF_UP, HALF_EVEN, HALF_DOWN, UP, DOWN, CEILING, FLOOR). What cannot be exact (sqrt, log,
    trigonometry, random(), PI, E) and a boolean result fail by name. An operand may be a string or a JSON number; a
    number is taken through the shortest decimal text it prints as, so send money as strings. COMPUTE is untouched.

    Upgrade action: none for the statement itself — COMPUTE is unchanged; read item 2 for the comparison change.

  2. f:decimalAdd, f:decimalSubtract, f:decimalMultiply, f:decimalDiv, f:decimalMod, f:decimalRound and f:decimalCompare simple plugins (RFC-0001 item 8, ADR-0025).
    Exact decimal arithmetic for Event Script flows and mapper nodes, with the same rules as the DECIMAL statement: a
    canonical decimal string result, scales propagated (+ - the larger, * the sum), / exact when it terminates and
    otherwise 34 digits half-even, and decimalRound(x, scale, mode) with an explicit mode only. An operand is a whole
    number, a canonical-number string or a double (through its shortest decimal text); a boolean, a null and a
    non-canonical string are errors. The f:add family is unchanged. The shared plugin vectors
    (decimal-plugin-vectors.json) run in the engine tests and cross-check the statement, so the two cannot drift.

    Upgrade action: none — new plugin names only.

  3. CanonicalPackager — a deterministic MsgPack packager (RFC-0002, ADR-0026; #481, #482). org.platformlambda.core.serializers,
    beside MsgPack: the same content always gives the same bytes. Every map is written with its keys sorted at every depth in
    UTF-8 byte order (the ordering is done by the packager, since Gson has no ordered-keys option and a String-order sort
    differs from another engine's bytes above U+FFFF); a package is {manifest, maps} with format and format_version
    written by the packager, caller-defined string manifest fields (by convention graph_id) and the maps keyed by entry
    name in sorted order. The canonical profile: nulls kept, smallest integers, finite float64 only (a Float is widened
    through its shortest decimal text; NaN and Infinity are rejected), shortest str and bin headers, exact numbers as strings (a BigDecimal in plain notation, a zero
    of any scale "0"), dates as ISO-8601 strings, nothing else. unpack returns ordered maps and, by default, re-encodes
    the content and rejects bytes that are not canonical. Integrity is not part of it: a hash or a signature, and the algorithm,
    are the user application's decision. 21 tests and a shared vector file, canonical-package-vectors.json, whose expected
    bytes come from an independent encoder written from the specification (73 values, 6 packages with SHA-256, 25 rejection
    cases, a seeded 60-document differential corpus, and the nesting bound of 64 levels): the Rust twin (Increment 147) passes
    the same byte-identical file, so the two engines agree byte for byte. Upgrade action: none — a new class.

Changed

  1. A canonical numeric string compares as a number in == != < <= > >= (RFC-0001). Every value is rendered into
    the statement text before it is parsed, so a substituted string was indistinguishable from one the author typed; a
    string in plain numeric notation now compares as a number ('200' == 200, {price.result.rounded} > 100), exactly:
    two distinct 20-digit ids never collapse into equal numbers.

    Upgrade action: read if a graph compares numeric-looking strings and relied on the string comparison.

  2. graph.math round is half up, away from zero, the same as f:round (#472). The dialect used Math.round
    (half toward positive infinity), so round(-2.5) was -2 while the f:round plugin gave -3. It now rounds through
    BigDecimal HALF_UP: round(-2.5) is -3; positive halves and non-halves are unchanged. NaN and Infinity still reach
    the finite check.

    Upgrade action: read if a graph rounds a negative value that is exactly x.5 — the result moves away from zero.

Documentation

  1. The DECIMAL guide gains the money loop, the transaction patterns and the compatibility correction. The command reference
    adds an engine-verified DECIMAL twin of the line-totals example (the COMPUTE version accumulates in double; money uses the
    twin), the skills reference adds Transaction patterns (what stays on the graph, what is a graph.task, the zero rule, the
    remainder's sign, the decimal plugins in a mapping, the input rules), and the DECIMAL intro no longer says a graph that never
    says DECIMAL behaves exactly as before: the numeric-string comparison and round half-up reach it, as items 4 and 5 say. The
    in-Playground help and minigraph-commands.json carry the same. RFC-0003 (the for_each index, Parked) and RFC-0004 (a bracket
    lookup plugin, Withdrawn) record two suggestions from an external review.

  2. The graph.math expression dialect is documented as a closed set and pinned (#467). The skills reference lists every
    operator, the eighteen functions and the two constants, the command grammar no longer says "no function calls", and the
    in-Playground help and minigraph-commands.json (expression_dialect) carry the same catalog; a claim and a test
    (ClaimMathExpressionDialectTest) fail the build if the evaluator and the documentation drift apart in either direction.

  3. The decisions are recorded. RFC-0001 (exact decimal arithmetic) and RFC-0002 (the canonical packager) were raised in the
    RFC register (#470) and promoted to ADR-0025 (#473) and ADR-0026 (#484); RFC-0003 (expose the for_each index, Parked) and
    RFC-0004 (a bracket-lookup plugin, Withdrawn) record two review suggestions.

  4. The packager is documented: a language-neutral spec and the Java API. The new guide page Canonical Package Format is
    self-contained (the package structure, the canonical profile, key ordering by UTF-8 bytes, the strict read and its
    rejections, a worked example whose bytes a test pins, and the shared vector file that proves Java and Rust write identical
    bytes), and the API overview gains Deterministic packaging with CanonicalPackager (builder, unpack, why it writes
    through msgpack-core and not MsgPack.pack, and that integrity is the application's decision). Both are in llms.txt.

Build

  1. Internal, no behavior change. SimpleMapper takes explicit imports, pins its pretty-print style (two-space indent, LF)
    and suppresses Sonar java:S2143 on the class, with byte-identical output (#469); test-only changes: the dialect claim
    test split into cohesive methods (#468), the f:round agreement test (#477), the Sonar cleanups on the new code (#474,
    #478, #483).

v4.12.19

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 25 Sep 23:58
Immutable release. Only release title and notes can be modified.

Version 4.12.19, 9/25/2026

The rapid-prototyping deploy lane, completed on both engines. graph.model.automation now accepts a comma-separated list
of graph manifests, each with its own location, so an exported graph deploys beside the bundled ones without a rebuild —
and when two manifests list the same graph id, the later manifest wins. Born at a live demo the same day: a single external
manifest replaced the bundled set, so a prototype that delegates through graph.extension to a bundled graph could not run.
Both engines in lock-step (Rust 4.12.19, Increment 142). Upgrade action: none unless a graph id is listed in two manifests —
read item 1.

Added

  1. graph.model.automation accepts a comma-separated list of manifests; the later manifest wins (#465). The
    yaml.flow.automation convention: each manifest carries its own location, they compile in the order listed, and a
    manifest that cannot be loaded is skipped with a warning so the others still compile (Loading graph manifest … and
    Deployed graph model folder - … per manifest in the startup log). When two manifests list the same graph id, the
    later manifest owns it: its copy replaces the earlier one (Graph {id} from {B} replaces the copy from {A}), and if
    that copy is rejected by the CompileGraph gate the id answers 404 rather than silently serving the copy the operator
    meant to replace. CompiledGraphs records each graph's source location; list graphs enumerates every location, and
    the import graph from fallback searches the compiled-from location first, then all, naming the location it found.
    Entries are manifests, never bare folders — a manifest is the gate's allowlist (ADR-0011 unchanged).

    Upgrade action: none for a single manifest — it behaves exactly as before. Read if you list a graph id in two
    manifests: the later one now wins, and its rejection makes the id 404.

Documentation

  1. Rapid prototyping — deploy without a rebuild (#465). The AI agent guide gains #deploy-without-rebuild: export,
    stage /tmp/graph/deploy/ with its own graphs.yaml, restart with
    -Dgraph.model.automation='classpath:/graphs.yaml, file:/tmp/graph/deploy/graphs.yaml', verify the gate in the log,
    then curl — with the two rules to read (later manifest wins; a deployment is still a restart, CompileGraph runs once
    at startup), the broker's new-session-id choreography after a restart, and the loop for iterating on a graph that is
    already deployed: import graph from the deployed copy, correct, dry-run, export, stage, restart with both manifests,
    curl, then bundle. The same recipe in the first-graph walkthrough (Step 4), the Playground guide's restart callout,
    the configuration reference (graph.model.automation: comma-sep manifest paths, the per-run JVM override) and
    llms.txt; claim graph-manifest-list-later-wins pins the rule to CompileGraphTest. Production keeps the manifest
    inside the artifact.

    Upgrade action: none.

v4.12.18

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 25 Sep 20:56
Immutable release. Only release title and notes can be modified.

Version 4.12.18, 9/25/2026

Two field reports answered. A field installation's page of nine graph.math "behaviours that return a wrong answer"
(measured on 4.12.7) is ruled and closed — four engine changes, the rest documentation or already fixed in 4.12.16 — and
the field's Snyk scan of the 4.12.17 artifacts is answered with four dependency bumps. Both engines in lock-step (Rust
4.12.18, Increment 141). Upgrade action: read item 1 — a graph that relied on a boolean computing as 1/0 or on an
overflowed Infinity propagating now fails at that statement with a named message; the dependency bumps need nothing.

Changed

  1. graph.math: a boolean is never a number; an unknown function, an overflow and a division by zero fail by name
    (#462).
    The evaluator coerced a boolean to 1/0 in arithmetic, in </> comparisons and as a function argument
    while equality type-checked — three outcomes for the same JSON true, and in the field a boolean threshold negated
    into a number produced a large overcharge with no error. Now one uniform rejection, mapped back to the selector that
    supplied it: Boolean operand: model.flag (true) in '{model.flag} + 1' - a boolean is not a number; store a boolean with CONDITION or assert the type with f:validate; a COMPUTE whose whole result is a boolean variable is rejected
    the same way instead of storing 1.0. A misspelled or unsupported function reports its name (Unknown function: mn;
    a callee that is a value, 'PI' is not a function) instead of the generic "Attempting to call a non-function". Every
    arithmetic result is checked finite — Arithmetic overflow in '*' (result Infinity), Division by zero or arithmetic overflow in '/', a NaN by name — instead of Infinity traveling on to fail a later node as Unknown identifier: Infinity. Arithmetic stays IEEE double by design: exact-decimal money (a rounding mode, integer cents) belongs in a
    small composable function on graph.task with BigDecimal; the math package does not grow.

    Upgrade action: a graph that relied on true/false computing as 1/0 in a COMPUTE, on a boolean COMPUTE
    result storing 1.0, or on an overflowed Infinity propagating now fails at that statement with a named message; a
    graph whose numbers are numbers is unaffected. Assert an untrusted slot's type with f:validate at the input, and
    store decisions with CONDITION (item 2).

Added

  1. CONDITION: var -> expr — the declared boolean statement in graph.math (#462). Evaluated as a boolean whatever
    operators it carries (CONDITION: ok -> {model.a} < {model.b}, CONDITION: same -> {model.flag}) and stored as a
    boolean in the node's result namespace; an IF may test it directly (IF: {node.result.ok}). COMPUTE keeps its
    documented behaviour — an expression with a comparison or boolean operator yields a boolean — now stated in the
    guide, the command reference, the command JSON and the in-Playground help graph-math.

    Upgrade action: none; a new statement type, counted by the compile gate like COMPUTE.

Documentation

  1. The rulings that were documentation, not engine (#462). A run on the same Playground instance keeps model.* —
    a MAPPING onto model.x[] appends to the list the previous run built — and instantiate graph (alias start) is
    the reset; a deployed graph gets a fresh instance per request. A taken IF inside a for_each body ends the whole
    walk (an IF is a traversal jump, not a per-row branch), so per-row rules are arithmetic gates and the IF follows
    the loop. The end node is the terminus: its mappings run last, so the last writer to an output.* key wins. The
    for_each rules gain the append/reseed bullet, and the guide's new Numbers and booleans paragraph states the
    numeric model (IEEE double, 2^53, round = Math.round).

Build

  1. Dependency bumps for the field's Snyk gate — Jackson 2 BOM 2.22.3, Jackson 3 BOM 3.2.3, Netty 4.2.18.Final,
    MsgPack 0.9.12 (#463).
    The field's scan of the 4.12.17 artifacts reported transitive findings with no upstream
    remediation path (reactor-netty-http 1.3.7, vertx-core 5.1.6, Confluent 8.3.0 and Avro 1.12.1 have not moved), so the
    pins are ours. Netty 4.2.18.Final clears two CWE-770 findings on netty-codec-http (CVSS 8.7, CVE-2026-93491, and
    8.2); Jackson 2.22.3 clears CWE-770 CVE-2026-89425 on jackson-core, reached through minimalist-kafka's
    Confluent/Avro path; Jackson 3.2.3 is the current line's patch. MsgPack 0.9.12 is best effort: CWE-674 CVE-2026-90472
    (medium) has no released fix — OSV lists msgpack-java through 0.9.12 as affected — and Mercury's serializer never
    calls the library's unpackValue(), the vulnerable method. Versions only, 37 poms; the resolved trees verified with
    dependency:tree.

    Upgrade action: none — patch releases of the four libraries; an application that pins its own versions of them
    should move to at least these.

v4.12.17

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 25 Sep 00:19
Immutable release. Only release title and notes can be modified.

Version 4.12.17, 9/24/2026

A field-reported gap in the Kafka building blocks, closed on both engines in lock-step (Rust 4.12.17, Increment 139):
the Kafka flow adapter can carry its own Schema Registry identity, so an installation whose Confluent registry grants
access per direction — a produce identity pool and a consume identity pool — serves both directions from one service.
Opt-in by the presence of one key on each cluster; nothing changes for an application that never sets it. Upgrade
action: none unless you opt in; read item 1 before you do.

Added

  1. A separate Schema Registry identity for the consumer side — schema.registry.consumer.properties (#458).
    KafkaFlowAutoStart built one SchemaCodec per JVM, shared by simple.kafka.notification (produce) and every
    flow-adapter binding (consume), so both directions carried one registry identity — and where CSFLE key (KEK) access
    is granted per identity pool, no single identity could decrypt everything a service consumes. When the new key
    names a registry client template, the flow adapter decodes with its own codec, built under the
    schema.registry.consumer prefix through the existing multi-registry seam: the same schema.registry.url (a
    consumer decodes messages whose ids were minted by the registry its producers use), that template (the producer's
    file or a second one), schema.registry.consumer.serde.* overrides on top of it, and its own caches
    (schema.registry.consumer.cache.ttl). Presence is the opt-in and a blank value counts as unset, so the
    ${ENV_VAR:} idiom switches it per environment; schema.registry.url stays the feature switch. Unset, the adapter
    shares the producer's codec exactly as before. The codec-ready log line reports serdeOverrides=<n> instead of
    csfle=<boolean>, which read csfle=true for a bare identity override.

    Upgrade action: none unless you opt in. Read before you do: a schema.registry.consumer.serde.* override
    reaches the Confluent deserializer's configuration — and through it the DEK-registry client CSFLE builds from that
    configuration, where key access is decided — but not the codec's own schema-by-id lookups, which keep the
    template's identity; when the consume identity must cover those too, point the key at a second template that
    carries it. And the consumer codec reads only its own prefix: a schema.registry.serde.* KMS driver credential the
    producer needs is repeated under schema.registry.consumer.serde.*.

  2. The same opt-in on twin-kafka's secondary cluster — secondary.schema.registry.consumer.properties (#460).
    The policy is one public, prefix-parameterized method, SchemaCodec.forConsumer(config, registryUrl, keyPrefix, producerCodec): minimalist-kafka's auto-start delegates to it under schema.registry, and
    SecondaryKafkaAutoStart hands the secondary flow adapter the codec it resolves under secondary.schema.registry,
    so the secondary key (with secondary.schema.registry.consumer.serde.* and .consumer.cache.ttl) is the exact
    twin of the primary one and the secondary producer keeps secondary.schema.registry.*.

    Upgrade action: none unless you opt in; the rules of item 1 apply unchanged.

Documentation

  1. The opt-in as a sample, and in the guides (#458, #459, #460). The sync-over-async demo ships a commented sample:
    its application.properties shows both variants — the producer's template plus an identity-pool override, and a
    second template — and schema-registry-consumer.properties next to it is the second-template form (in the
    application, never the library jar, where a same-named resource would collide by classpath order). The
    minimalist-kafka guide gained A separate registry identity for the consumer side, the twin-kafka guide its
    secondary-cluster paragraph, the configuration reference both entries, and the two registry templates carry a
    note.

v4.12.16

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 24 Sep 00:08
Immutable release. Only release title and notes can be modified.

A correctness round from two field reports on the knowledge-graph engine, shipped in lock-step with the Rust port
(4.12.16, Increment 136): the null-source mapping rule is now the same in Layer 2 and Layer 3, a graph.math failure
names the variable it could not resolve, and every dry-run abort carries its reason. Upgrade action: read items 1
and 3; nothing to configure.

Changed

  1. A null mapping source clears a model.* target and leaves any other target untouched — the same rule as
    Event Script (#456; field issue #453).
    Until 4.12.15 the graph engine removed any target whose source resolved
    to null, which deleted a default written just before an absent overlay and failed a later node with
    Unknown identifier: null. One helper now applies Event Script's rule to mapping[] entries
    (graph.data.mapper, and the MAPPING statements of graph.math and graph.js), for_each entries, the model.*
    half of fetcher and extension input[] parameters (a parameter mapped from a null source is not supplied, as
    before) and the output mapping of graph.task, graph.extension and graph.api.fetcher, which used to skip a null
    result even for a model.* target: a model.* target is removed (set to null when the source key exists with a
    null value, or when the target is indexed, so list positions stay stable); an output.* or node-alias target is
    left as it was. The command reference (Namespaces), the command JSON, the in-Playground data-mapper help and
    the claim null-source-removes-target state the rule; the Event Script guide now states its own rule in
    Input/Output data mapping.

    Upgrade action: read — a null source no longer removes an output.* or node-alias target, so a graph that
    relied on that removal must clear the target explicitly; an output mapping to model.* with a null result now
    clears the variable (it was left untouched); a default for a model variable still comes from the source side —
    f:defaultValue(...), or a plugin's own default such as f:lookup(table, value, text(unknown)) —
    default-then-overlay is unsupported in both layers.

  2. A graph.math COMPUTE or IF over an unresolved variable names it (#456; field issue #453). A {selector} is
    rendered into the expression as the text null before the math package evaluates it, so the failure could only
    read Unknown identifier: null — one node away from the node that failed to set the variable. The expression is
    now checked before substitution and every unresolved selector is named,
    Unknown identifier: model.threshold or model.factor (unresolved variable in '{model.threshold} * {model.factor}');
    when the evaluator itself meets null (a variable holding the text "null"), the selectors are re-rendered and the
    culprits named. RESET, DELAY, jump targets and MAPPING keep the documented null rendering.

    Upgrade action: none — a failing expression fails as before, with a message that names the variable.

  3. Every dry-run abort carries its reason: Graph traversal aborted: <reason> (#456; field issue #454). The
    Playground's traveler printed a node's error and then a bare Graph traversal aborted — and for an arithmetic
    plugin given a null argument it printed nothing at all, because the plugin threw a message-less exception. The
    traveler now follows the executor's log record on every failure path — a node's thrown error naming the node, a
    node's staged error, the run deadline (timed out after N ms), a failure before the walk starts, the pre-run gate
    (Unable to run - …) — and the arithmetic plugins report Cannot convert null to a number. The synchronous
    companion endpoint drains on the prefix and the Playground web app classifies the terminal by prefix (bundle
    rebuilt). What a plugin does with a null argument is plugin-specific by design and is now documented in the Event
    Script guide's plugin section: f:defaultValue and f:isNull accept null, the arithmetic family throws, and an
    optional source is guarded in a preceding entry because plugin calls do not nest.

    Upgrade action: read — a script or companion that matched the bare Graph traversal aborted line by equality
    must match the prefix; the line now ends with the reason.

Documentation

  1. The embedded Redis prerequisite per platform (#455). The redis-server binaries bundled by embedded-redis
    1.4.3 (and 1.4.4) differ per platform: the macOS arm64 binary is linked against Homebrew's OpenSSL 3
    (brew install openssl@3), the Linux x86-64 binary needs the OpenSSL 3 shared libraries and glibc 2.34 (present
    on current distributions and on the CI runners), and the Intel macOS, Linux arm64 and Windows binaries are
    self-contained; without it a reactor build fails in extensions/redis-connection with "Failed to start Redis
    service". The redis-standalone README carries the platform table and the symptom; Getting Started and
    Build/Test/Deploy point to it. No upgrade action.

Build

  1. JaCoCo 0.8.15 (#452). The coverage plugin understands JDK 27 class files, for contributors whose local JDK
    has moved ahead of the Java 21 toolchain — under JDK 27 the previous version left JDK and library classes
    uninstrumented without failing the build. No upgrade action; the toolchain stays on Java 21.

v4.12.15

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 23 Sep 01:35
Immutable release. Only release title and notes can be modified.

Version 4.12.15, 9/22/2026

The connected-trace release, and a lock-step round on all four runtimes: the Rust port ships 4.12.15 in the
same round (its Layer 3 starter gains dev mode, the restart-aware Redis retry, the Kafka shutdown twin, the
Playground E0 twin and the connected edge spans twin), and the python and node language packs move from
4.12.1 to 4.12.15 with the OpenTelemetry forwarder, the span-kind rule and, on node, the llm.chat /
llm.stream AI nodes — one number on all four. Upgrade action: read items 1 and 3; nothing to configure.

Changed

  1. A traced HTTP request is one connected span tree whose root is the edge's round-trip span (#444; #447 is
    the Sonar sweep of the same round).
    REST automation mints a span when a request arrives and records
    service=http.request when the response completes — buffered, streamed, error page or housekeeper timeout —
    with exec_time the round trip and parent_span_id the inbound traceparent span; the first function and
    the authentication service parent onto it. The Event-over-HTTP stream relay's client leg
    (async.http.request) now parents onto its sender, and a streamed response is traced at its head and its
    tail, never per token: the writer stamps the producer's trace and span on the first segment and the terminal,
    the reply lane annotates the terminal's record with frames (the number of data segments it rendered), and
    raw token frames carry no trace. The OpenTelemetry forwarder maps SERVER iff service == http.request; every
    function execution is INTERNAL. skip.rpc.tracing is documented for what it always did — suppress the
    caller-side RPC round_trip record only. Found by the maintainer's Dynatrace review of the four-runtime
    certification traces; the report (docs/test-reports/otel-dynatrace-certification.md) gained Scenarios 7–9
    — two engines one trace, four runtimes one trace, and the connected trees confirmed in the backend UI on
    error traces and on token-bearing streams.

    Upgrade action: read — one more span per traced request; a backend's response time for a service is now
    the request's round trip, not the first function's few milliseconds; the first function is INTERNAL, so a
    dashboard keyed on kind=SERVER moves to the http.request record; an Event-over-HTTP callee edge records
    its own round trip between the caller's span and event.api.service. Guides: observability (The edge's
    round-trip span
    ), HTTP streaming (Tracing a stream), Event-over-HTTP.

  2. minimalist-kafka joins the graceful shutdown (#440, #441). KafkaRuntime.shutdown() runs on
    Platform.onShutdown: the flow adapter's consumers close first — an explicit LeaveGroup the broker observes,
    so a rolling restart rebalances at once instead of waiting out the session timeout — then the producer
    flushes and closes. Both halves are bounded by the 10 s KafkaRuntime.SHUTDOWN_GRACE, and a producer close
    the grace could not drain reports what it left undelivered instead of waiting without bound. Pinned by
    KafkaShutdownTest. No upgrade action.

  3. The home page outside dev mode is a plain page (#449). The Playground web app's index.html was the
    engine jar's static public/index.html, so a Layer 3 application served the Playground UI at / in every
    environment — with the dev-only services absent outside dev, a production home page that reads as a broken
    workbench. The entry page is now template/playground.html, served by get.index.html only when
    app.env=dev (the same gate as every Playground service); the plain "MiniGraph Service" page is both the
    static public/index.html and the routed page for any other app.env, an absent one included (the function
    used to default to dev). The starter template gains the get.index.html route (its dev mode showed the UI
    only through the static fallback) and a / test, and the classpath-order collision with platform-core's
    placeholder page no longer decides what a browser shows.

    Upgrade action: read — a Layer 3 application that never routed get.index.html now gets the plain page
    at / in dev mode too: add the route (the AI guide's playground-enabled profile always listed it). An
    application with no app.env gets the plain page where it used to get the Playground. The webapp's
    npm run release now writes the assets to public/assets/ and the entry page to
    template/playground.html.

Lock-step. The Rust port's 4.12.15 (mercury #312–#321) carries the twins of items 1–3 plus its own: the
Layer 3 starter's dev mode (its P10, Increment 134), the restart-aware Redis retry in the shared foundation — a
heartbeat monitor (redis.heartbeat.ms, Rust engine only) and one retry per lost connection for idempotent
commands (Increment 135; Lettuce needs no twin because it requeues unwritten commands), the Playground E0 twin
(the support-triage graph and the llm.stream relay, Increment 132) and the Tracing a stream guide section.
The python (#33–#36) and node (#101–#104) packs adopt 4.12.15 with the OpenTelemetry forwarder twin (opt-in,
dependency-free), the SERVER-iff-http.request span-kind rule and, on node, the llm.chat / llm.stream AI
nodes; their per-release interop evidence is the four-runtime certification drive of 2026-09-22 recorded in each
repository's docs/test-reports/otel-dynatrace-certification.md.

v4.12.14

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 22 Sep 02:11
Immutable release. Only release title and notes can be modified.

The lock-step release — one number on both engines. On the Java side it carries one change (below); it is
also the number the Rust port adopts on catching up: the completed minimalist-kafka port (the Confluent
Schema Registry wire format for JSON Schema and Avro, the sync-over-async facade tasks over Kafka, the
sync-over-async-demo mirrored with its raw, JSON Schema and Avro legs, the AI-contract guide twin), its
lock-step round with 4.12.12/4.12.13 (the CompileGraph task↔skill gate, the case-insensitive
input.header.* fallback, the snake_case log-context keys with the automatic UTC timestamp), and the
first crates.io publication of mercury-minimalist-kafka and mercury-sync-over-async. No upgrade
action for Java applications beyond the note under item 1.

Changed

  1. group.protocol=auto is the bundled Kafka consumer template's default (#436). The template now
    ships group.protocol=${KAFKA_GROUP_PROTOCOL:auto} uncommented, so an application that never set the
    key joins a KIP-848 cluster (Apache Kafka 4.0+ with group.version finalized) with the consumer
    rebalance protocol — incremental reassignment, only the interrupted member's partitions move — and an
    older cluster keeps classic. Java resolves auto with one group.version feature probe per cluster;
    the Rust port resolves it optimistically (start with consumer, rebuild the binding once as classic
    when the broker refuses the join) — same outcome, stated in the guide. The conflict guard stands:
    session.timeout.ms, heartbeat.interval.ms or partition.assignment.strategy in the template
    resolve auto to classic with a WARN naming the keys. Found by the Rust K4 interop drive, which ran
    both engines classic on a Kafka 4.3.1 broker that already finalized group.version=1 — automatic
    switching had been opt-in and nobody had opted in.

    Upgrade action: read, not configure — an application that never set group.protocol now uses the
    consumer rebalance protocol on a KIP-848 cluster; set KAFKA_GROUP_PROTOCOL=classic (or the key in your
    own template) to keep the classic protocol regardless of cluster support.

Lock-step. The Rust port catches up to 4.12.14 at this release (mercury: the K5 branches, the docs
twin and the lock-step round); the two engines' Kafka building blocks were driven against each other on
one broker, one Redis and one Schema Registry — the Confluent frames decoded in both directions by both
engines, a mixed Java+Rust consumer group, and a Java facade delivering replies to a waiting Rust facade
through the Redis return route (docs/test-reports/minimalist-kafka-interop.md in the Rust repository).

v4.12.13

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 21 Sep 04:16
Immutable release. Only release title and notes can be modified.

Mercury Composable v4.12.13 — release notes

The accumulated-improvements release: eleven items on top of 4.12.12, three of which carry an upgrade
note to read (nothing to configure): the application log context now emits snake_case keys with an
automatic UTC timestamp, a function's error status comes from its cause chain (an in-function RPC
timeout is a 408, not a 500), and the distributed cache answers 408 for a Redis command timeout and 503
for an unreachable Redis instead of 500. New: the f:lookup simple plugin and the static-decision-table
recipe, per-iteration suspend/resume under for_each, and the hello.rpc demo. Fixed: the shared Redis
connection resets after a command timeout so recovery is bounded by redis.timeout.ms, not Lettuce's
30-second backoff. No new runtime dependency; BouncyCastle (test scope) aligned at 1.86. Java 21 remains
the baseline and Java 25 the recommended runtime. Every port adopts this number at its next catch-up.

Added

  1. f:lookup(table, value[, default]) — a static decision table resolved with no function (#431).
    A simple plugin for Event Script and MiniGraph data mapping. The table is a map (or JSON text) whose
    keys lists the rule names in priority order and whose rule fields list the values that select them —
    lists, or JSON arrays written as text (community-property=[ "CA", "TX" ] on a graph node); values
    compare as text, case-insensitively; a miss returns the optional third argument, or null. The common
    case of a decision table is therefore one graph.data.mapper entry —
    f:lookup(state-rules, input.body.state, text(unknown)) -> output.body.rule — and a composable
    function is kept for a ruling that needs more than a lookup. Errors: Expected two or three input values - actual=N, Missing keys in input, Missing key in input: X, Input is not a list of values, First argument must be a decision table as a map or JSON text. The graph.task recipe
    (#430) shows both paths over one skill-less DecisionTable node whose properties are graph data,
    handed whole to a function by one input entry (state-rules -> table) — the product owner certifies
    the rules on the graph and a new table is a new graph version, never a code change. Pinned by
    unit-test-task-9 and unit-test-lookup-1; catalogued for Event Script with a Layer 2 example.

    Upgrade action: none — a new plugin; existing plugins and mappings are unchanged. Plugin authors:
    a plugin class may not reference SimpleMapper directly (the loader's bytecode gate skips such a
    class silently); reach the serializer through SimplePluginUtils.

  2. Per-iteration suspend/resume under for_each (#418, #420; design draft-design-specs/ subgraph-suspend-resume-for-each.md, #415). A parent that invoked a suspending subgraph through
    graph.extension + for_each[] wrote one store record for all iterations — every child inherits
    the business correlation id by design — so N concurrent suspensions collided and which one survived
    was a race. The array index now rides the invocation header, is lifted to the reserved
    model.iteration_index (never persisted, never mappable), and joins the store key as a third
    segment — graph:{graph_id}:{cid}:{index} — appended only when an index is present, so a single
    delegation keeps its two-segment key and pre-upgrade records stay reachable. The design rules are
    declared in the workflow-suspension guide, not enforced by the engine: positional consistency
    (appending to the array is supported); parent → for_each → flow → suspending graph is unsupported
    (let the subgraph call the flow instead); nested for_each with suspension is a non-goal.

    Upgrade action: none for the bundled Redis store (minigraph-state-redis). A third-party state
    store
    must honour the optional index field now present in the persist body (type=put) and the
    retrieve body (type=get) by appending it to its key, as the state-store contract in
    workflow-suspension.md states; a store that ignores it keeps the pre-4.12.13 collision under
    for_each.

  3. Application log context: an automatic UTC timestamp and snake_case keys (#414). The context
    block on every log line written inside a traced worker now always carries a machine-parseable UTC
    time: when app-log-context.yaml maps $utc to no key, the engine inserts it as timestamp
    (falling back to utc if timestamp is taken, and leaving the template alone with a warning if
    both are) — the record's top-level time is a local timestamp with no offset, and log-to-trace
    correlation resolves on a time window, so a line parsed in the wrong zone can be correctly
    correlated and still invisible on its trace. The default template's output keys are now cid,
    trace_id, trace_path, span_id, parent_span_id, service, timestamp — snake_case, matching
    the distributed-trace block on the same record. PostOffice.updateContext refuses the reserved
    names in both spellings with an IllegalArgumentException, and a developer key can no longer
    shadow a template key (developer keys render first; the template wins).

    Upgrade action: the emitted key names changed. A saved query or dashboard keyed on
    context.traceId / spanId / parentSpanId / tracePath moves to the snake_case names — or keeps
    the old names by writing them on the left side of your own app-log-context.yaml, which remains
    your choice. Delete any hand-written timestamp: $utc line; the engine supplies it. Code that
    called updateContext("trace_id", …) or updateContext("traceId", …) now fails fast instead of
    silently overwriting the real trace id.

  4. hello.rpc in examples/lambda-example (#434) — the request-response idiom as a runnable
    demo: build the event, po.request(request, timeoutMs).get(), check the reply's status before
    reading its body
    (a callee that throws replies with its error status and message), return the
    result; POST /api/hello/rpc?timeout=300 with {"sleep_ms": 1500} shows the 408 a timeout
    produces. No upgrade action.

Changed

  1. A function's error status comes from its cause chain, not the outermost wrapper (#427).
    EventEnvelope.setException and WorkerHandler mapped the outermost exception class while the
    message came from the root cause — so an in-function po.request(..).get() timeout
    (ExecutionException wrapping TimeoutException) replied 500 "Timeout for N ms", and a
    CompletionException around an AppException(404) lost its 404. One rule now
    (Utility.getStatusFromException, shared with EventStreamWriter.fail): walking the cause chain,
    the first AppException (its status), TimeoutException (408) or IllegalArgumentException (400)
    wins; 500 only when none is present. The event-envelope reference's status table states the rule.

    Upgrade action: read, not configure — an application that relied on a 500 for a wrapped carrier
    now sees the inner status: an in-function RPC timeout is 408, a joined CompletableFuture failure
    keeps its AppException status. Accepted edge: an IllegalArgumentException deliberately wrapped in
    an IOException now reports 400.

  2. v1.cache.redis classifies its Redis failures — 408 for a command timeout, 503 for an unreachable
    Redis (#429).
    Layers 2 and 3 answered 500 for a Redis outage: the flow and graph engines pass a
    task's status through faithfully, and Lettuce's exceptions carry none. RedisFailure.classify
    (redis-connection) walks the cause chain — RedisCommandTimeoutException → 408 with Lettuce's
    message; RedisConnectionException, ConnectException, ClosedChannelException or a closed/rejected
    connection → 503 Redis unavailable - …; a server answer such as WRONGTYPE stays 500 — and
    RedisCache applies it around the store call. Proven by the Java ⇄ Rust distributed-cache interop
    drive (docs/test-reports/distributed-cache-interop.md, #426/#428: 122 of 122 checks; no outage
    probe on any layer of either engine answers 500).

    Upgrade action: read, not configure — a caller that keyed on 500 for a Redis outage now sees 408
    or 503.

  3. The ADR ledger records decisions only; proposals live in docs/arch-decisions/RFC.md (#421,
    #424).
    ADR-0013 to ADR-0017 accepted; the proposal register uses RFC-NNNN ids and the status
    vocabulary Open · Parked · Promoted → ADR-NNNN · Withdrawn, and is packaged into the AI-contract
    skill snapshot next to ADR.md, so an agent reading the ledger can follow the pointer. No upgrade
    action.

  4. Documentation. MiniGraph: a static decision table is graph data (#430); a null mapping source
    removes the target — in Event Script a null source applies only to model.* targets, where it
    removes the key, and any other entry is ignored (#432, claim null-source-removes-target); the
    for_each store key stated on every page that quotes it (#420, #425); the log-shipping boundary —
    application log forwarding is the platform's job, not the engine's (#416). Guides corrected by a
    source-blind fresh-agent probe (#434): a function bound directly to a REST endpoint receives the
    whole AsyncHttpRequest (the tutorials had shown a Map input), a typed function sets status and
    headers by returning an EventEnvelope, the /health response shape and the info/health contract
    (info must be a Map; health may be a String or a Map), the List<PoJo> rule (inputPojoClass), and
    the graph.task output-mapping source rule. The OTel certification's Scenario 6 confirmed in the
    Dynatrace UI at scope version 4.12.11 (#412); the 4.12.12 CHANGELOG entry completed (#411).

Fixed

  1. **The shared Redis connection resets after a command ti...
Read more

v4.12.12

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 17 Sep 22:03
Immutable release. Only release title and notes can be modified.
ebdd2e3

Patch release. Three fixes, two of which shipped in the artifact without being written up
until now. One can change a deployment outcome — see Upgrade below.

⚠️ Upgrade — one action, for MiniGraph users only

A graph model containing a node with a task route but no skill deployed successfully
before and is now rejected at deployment
, with an error naming the node and its unreachable
route. That node was already inert — the graph traversed it and the function was never called —
so the rejection surfaces a defect that was previously silent rather than removing working
behaviour. A model that relied on the node doing nothing must have it removed or completed.

Everything else in this release needs no action: no wire, API or configuration key changed.

Fixed

1. A MiniGraph node that names a composable function must name the skill that runs it.
CompileGraph accepted a task route with no skill and treated the node as structural: the
model looked correct and did nothing. It is now a hard error rather than a warning — a warning
on a silently inert node is easy to scroll past, which is how the original case survived. The
rule is bidirectional: only graph.task, graph.suspend and graph.resume consume a task
route (the latter two are documented supersets whose task names the pluggable state-store
function), so a task under any other skill is equally unreachable, and one of those three
skills without a task is the same inert node from the other side. Keyed on task alone and
deliberately not on input/output — Provider and Dictionary nodes carry those with no
skill by design.

2. Event Script resolves input.header.* against Kafka headers in their original casing.
Event Script lowercases a header reference, which matches the HTTP adapter because that adapter
ingests headers lowercased. The Kafka flow adapter delivers record headers in wire casing, so a
producer-sent Content-Type could not be addressed by any input.header.* mapping — the
reference silently resolved to null. The lookup now falls back to a case-insensitive scan, and
only when the direct lookup missed, so the HTTP path is untouched. Fixed in the lookup rather
than by normalizing at the Kafka adapter, which would change what the * whole-body
passthrough hands a function.

3. kafka.health answers correctly on a produce-only leg. With
kafka.consumer.enabled=false, the v4.12.11 classloader fix covered only half the path and
/health returned a raw HTTP 500. Kafka consults the context classloader earlier than client
construction — inside the static initializer of its own configuration classes, which resolve a
Type.CLASS setting's default the moment the key is defined. Since
sasl.oauthbearer.jwt.retriever.class defaults to a class name, merely initializing
ConsumerConfig is a classloading event, with no broker, no SASL and no network involved; the
produce-only path reaches it while resolving its template. The probe now runs entirely under
the module's classloader, and a LinkageError renders as a 503 naming the configuration
instead of escaping as an Error. secondary.kafka.health inherits the fix.

Java only — a JVM classloading concern has no Rust analogue, so no port lockstep is needed.

Full detail: CHANGELOG

v4.12.11

Choose a tag to compare

@acn-ericlaw acn-ericlaw released this 16 Sep 21:06
Immutable release. Only release title and notes can be modified.
0675021

Mercury Composable v4.12.11

A field fix plus a certified feature. On some deployments kafka.health could not build its probe
client at all — and reported a passing status while failing every five seconds; both halves are
fixed, the classloading cause and the leniency that hid it. Alongside it, OpenTelemetry trace
forwarding becomes an opt-in feature with a master switch, certified end to end against a real
Dynatrace backend. Three PRs (#402, #403, #404) since v4.12.10.

Fixed

  • kafka.health builds its probe client regardless of the thread context classloader. A field
    deployment logged Class org.apache.kafka.common.serialization.StringDeserializer could not be found for hours while the same JVM's real producers and consumers used the same kafka-clients
    jar without trouble.

    Kafka resolves a class-name configuration value through Utils.getContextOrKafkaClassLoader(),
    which prefers the thread context classloader and falls back to Kafka's own loader only when the
    TCCL is null
    — so a non-null but wrong context loader fails a lookup that a null one would
    have completed. kafka.health is a @KernelThreadRunner and warms up on the platform's
    kernel-thread executor, where a pooled thread need not have inherited the application's loader.
    KafkaFlowAdapter builds a consumer from the identical configuration on an ordinary thread and
    was never affected — that difference was the diagnosis.

    Client construction now runs with the module's own loader as the context loader, scoped and
    restored so nothing leaks back to the pooled thread's next task. secondary.kafka.health
    inherits the fix. Passing deserializer instances instead — the first remedy proposed — is
    provably incomplete: it removes two lookups and the very next class-name setting fails in their
    place, because Kafka resolves metric reporters, the partition assignor, interceptors, SASL
    callback handlers and the OAuth JWT retriever the same way.

Added

  • OpenTelemetry trace forwarding is opt-in, and certified against Dynatrace. otel.forwarding
    (default false) gates the forwarder through @OptionalService, so carrying the
    opentelemetry-forwarder dependency registers nothing. One artifact ships and DevOps decides per
    environment — in properties, or -Dotel.forwarding=true at launch with no rebuild.
    composable-example demonstrates that shape and doubles as its regression harness.

    Credentials resolve per export rather than once at construction, so a token published later by
    a vault bootstrap takes effect without a restart. Export failures diagnose themselves: the
    span, the trace, the HTTP status, the backend's own message, and the configuration key to look at
    — with explicit hints for the two rejections that actually happen (a 404 is usually the signal
    path missing from the endpoint; a 401/403 is the credential).

    Certified end to end against Dynatrace SaaS and confirmed queryable in the Dynatrace UI: six
    spans in one trace, parent/child reconstructed from the propagated W3C context, span kinds mapped
    (server at the HTTP edge, internal downstream), instrumentation scope reporting the running
    release's version. An A-B-A credential experiment (real token 0 of 6 export failures, bogus token
    6 of 6, real token 0 of 6) establishes that a clean run means the backend accepted the spans
    rather than the forwarder silently skipping them — and the application returned HTTP 201 in all
    three legs, so a telemetry backend outage degrades observability and nothing else. Full record in
    the Test Report — OpenTelemetry forwarder against Dynatrace. Splunk's header form is documented
    and parsed but has not been run live.

Changed

  • A Kafka template that can never work now fails /health instead of reporting healthy forever.
    The passing Waiting for Kafka connection status exists for exactly one situation — a credential
    a later bootstrap will publish, where failing would invite the orchestrator to restart a pod that
    cannot produce the value. A class absent from the classpath will not appear because we waited, so
    that case answers 503 with Kafka client configuration is unusable - <reason>, naming the
    configuration rather than the network so the reader looks at the classpath instead of the
    cluster.

  • Class-valued Kafka client settings are supplied as class objects, not names — they
    short-circuit Kafka's config parsing so no classloader is consulted at all, converging
    minimalist-kafka with what kafka-connector has always done. A template that names its own
    partitioner.class still wins.

  • The opentelemetry-forwarder now releases its exporter through Platform.onShutdown(...) instead
    of a hand-rolled shutdown thread, so its final flush is ordered with the rest of the teardown.

Removed

  • otel.trace.forwarder.enabled, superseded by otel.forwarding. With the @OptionalService
    gate in front of it, the only way to use it became the contradictory pair
    otel.forwarding=true + otel.trace.forwarder.enabled=false.

Upgrade notes

No configuration change is required, and nothing new is turned on by upgrading. No wire format
or API changed. otel.forwarding defaults to off, so an application that adds the forwarder
dependency exports nothing until it opts in.

One behaviour change is worth a glance if you run minimalist-kafka or twin-kafka: an application
whose Kafka template genuinely cannot build a client has been reporting healthy and will now report
503 with Kafka client configuration is unusable. That is the correction this release exists for —
the previous behaviour is what let a real deployment defect stay invisible — but it is the one change
a running deployment can notice. The start-up grace period for a credential that has not landed yet
is unchanged, and detection defaults to the lenient waiting status, so a future Kafka rewording
degrades to the old semantics rather than to spurious outages.

Retiring otel.trace.forwarder.enabled cannot surprise anyone into exporting: an application that
set it to false lands on the new master switch's default of off.

Who should upgrade first: anyone running minimalist-kafka or twin-kafka with kafka.health
in mandatory.health.dependencies, particularly where application classes and engine jars are
loaded by different classloaders.

Java only — no port lockstep needed. A JVM classloading concern has no analogue in the Rust
port. The outstanding lockstep is still v4.12.9's distributed cache.