Requests for Comments (RFC) — the proposal register¶
For humans. Work under consideration at the Design altitude: proposals that may become an Architecture Decision Record, be reshaped, merge with another, or be withdrawn. It is the sibling of
ADR.mdand exists so that the ledger records decisions only — an ADR is written when a proposal is accepted, never before (memory/PROTOCOL.mdWork from intent,DECAY.md§12). Read on demand, not part of the per-session agent read path (zero default token cost, the same footing asADR.md).
Rules¶
- Separate sequences.
RFC-NNNNandADR-NNNNnever share numbers: a proposal does not reserve an ADR number, because proposals and decisions do not map one-to-one — some merge, some split, some die. - Two exits, both recorded here. Promoted: the human accepted it; the ADR is written in
ADR.mdand this entry keeps a pointer (Promoted → ADR-NNNN, date). Withdrawn: the entry stays with the reason. An entry is never deleted; a proposal may be revised freely while open, and only its final form reaches the ledger. - Status:
Open(under consideration) ·Parked(deliberately deferred — reactivate on demand) ·Promoted → ADR-NNNN·Withdrawn. - Map, don't duplicate. The live work item stays in memory — an Open Thread in
memory/open-threads/carrying a→ proposal: RFC-NNNNpointer — exactly as an accepted ADR is pointed to by a(ADR-NNNN)tag on its continuity fact. The register holds the proposal's reasoning (options, trade-offs, what a decision would commit to); the thread holds its state. - The human decides. The agent raises and revises proposals here; promotion is the
Design-altitude human gate (
DECAY.md§12). Newest first.
Format¶
## RFC-NNNN — <Title>
**Status:** Open · **Raised:** YYYY-MM-DD · **Serves:** <vision-id> · **Thread:** `<thread-id>`
<!-- id: rfc-NNNN | status: open | thread: <thread-id> -->
**Proposal.** What would change, and what a decision would commit the project to.
**Options.** The alternatives on the table, with their trade-offs.
**Resolution.** Empty while open; `Promoted → ADR-NNNN (date)` or `Withdrawn (date): <reason>`.
RFC-0011 — The agent loop: llm.run in the engines maps tool names to routes; model turns and MCP bindings in the language packs¶
Status: Open · Raised: 2026-10-28 · Serves: vision-mercury-composable · Thread: agent-loop-llm-run
Reviewed and approved by Eric on 2026-11-03. The promotion to an ADR is held until the end-to-end test from the engines to the language packs for AI SDLC is finished.
Proposal. The foundation of Track 2 (the graph as AI SDLC) for a node whose model calls tools while it reasons — the
concept's adapter family C, built on families A (llm.chat) and B (MCP, route-per-tool). The design spec is
draft-design-specs/llm-helper-agent-loop.md, and it carries Eric's rulings of six review rounds on 2026-10-28 and 2026-10-29. What a
decision would commit the project to:
- Narrow loops, composed by the graph. Each agent loop has a narrow scope — one question, a few tools — so it runs few turns. A graph may hold several loop nodes, each with its own catalog, mode and turn limit. A use case grows by adding loop nodes, not by widening one.
- The agent loop is engine-side function code, route
llm.run, in a new opt-in moduleextensions/agent-loop. Java first, then the Rust port catches up. It is not engine core, and it links no provider SDK, no MCP client and no store client. The function is not an agent — the model is — and thellm.prefix marks every node whose result is model output. Build plan step 2 built it in Java on 2026-11-03; the spec's sections 4 to 6.1 mark what building it settled, accepted by Eric on 2026-11-04 (round 7). - The model turn stays
llm.chatin the language packs, and it grows a loop contract. The pack keeps the transcript in an access-controlled store of its own (Redis first) and returns a reference to it, together with the calls normalized as{id, name, input}. The loop answers with{id, output | error}and the expected turn count. The pack builds the one tool-result message and appends it, refusing a stale turn count with 409. The transcript stays in the provider's format, append-only, which the 5.5 models' thinking blocks require. It never crosses an engine codec, its model is pinned at the first turn (no server-side fallback is sent on a loop turn), and any pack replica can serve any turn. Build plan step 1 built this in both packs on 2026-11-03; the spec's sections 3.2 and 3.3 carry the full contract. Compaction, if a loop ever needs it, is the provider's server-side compaction in the pack'sllm.chat, never the loop's; deferred (Eric, 2026-11-04). - A tool name maps to a route. The catalog is a skill-less dataset node under the graph's island, certified on the
graph. Each row holds an API-safe name, a description written on the graph, the input schema, the route,
strict, andwrite: truefor a tool that writes. The loop checks the catalog before the first turn: names, schemas, write marks, and that every route is reachable. It validates each input before dispatch. An unknown name or a failed call goes back to the model as an error result; only the loop's own machinery fails the node. - Approval comes in two kinds. The node's mode governs the use of tools:
writeby default holds a marked write for a person,autoruns every call,manualholds every call, andunattendedturns a marked write back as a refusal (Eric, 2026-11-04: a write runs unasked only under a declaredmode=auto). A turn holding a waiting call runs none of its calls. Every call of the turn, with its arguments, rides the outcome envelope (status: approval). The graph suspends, and on resume the loop takes the calls from the restored state and matches the decisions to them by call id. Separately, a business decision (true/false, or a 1-based integer) rides the verdict, and the graph's decision node routes it to a suspension or onward. The product owner anticipates both at design time. A choice that must return into the same conversation is a question for a person: a catalog row withask: trueand no route, whose call waits and whose resume carries the person'sansweras the call's result (Eric, 2026-11-04). - State stays small, and the settings are on the node.
model.*holds the status, the decision, the usage, the references and any waiting calls. The settings are constant input mappings on the loop's node, each with a global default inapplication.properties: max_turns, the run's budget and a proxy for its cumulative input tokens; there is no separate token limit;result_limit;transcript_ttl;retention, the audit policy,0by default.
Telemetry is mandatory and carries no content.
6. A graph with an agent loop is asynchronous.
- Unattended: graph.extension gains async=true. The node starts a subgraph or a flow and returns at once,
taking no output[], which CompileGraph refuses. With for_each, it fans out in parallel: concurrency bounds the
active runs, and the for_each suspension rules apply unchanged. The parent answers an HTTP 202 advice, and the
subgraph notifies at its end, spanning days with suspend and resume. Event Script's deferred response
(execution: response) is the model for this. Build plan step 3 built it in Java on 2026-11-04; the spec's
section 8.1 marks what building it settled.
- Watched: progress events (progress, then a terminal result or error) carry seq per sender, time, cid,
the graph and the node, and llm.run's run, turn, tool and approval events. They never carry content. They go to the
caller's return route:
- for REST, async.http.response (final result only) or the progressive-rendering reply lane;
- for any event, the route in its x-return-path header.
`x-return-path` is generic: a function route name, served locally, over Event-over-HTTP, or by a wrapper that
publishes to a Kafka topic. The engine sends and never waits; a receiver orders a sender's events by `seq`; and only
routes the application declares (`return.path.routes`) are honoured (rules accepted in round 6). An entry ending
with `*` declares a family by prefix (`order.status.*`), as `invocation.observer.include` does, and a bare `*` is
refused. A node opts in with `progress=<text>`, and only then does its function receive `x-return-path`. Build plan
step 3 built it in Java on 2026-11-04, with the WebSocket entry; the spec's sections 8.2 and 8.4 mark what
building it settled.
- MCP bindings live in the language packs (Python, then Node.js). They use the official MCP SDK client, never a child process of an engine: a stdio server runs as a subprocess of the pack, which is why the MCP client lives there. Servers are named in the pack's configuration; there is one route per allowlisted remote tool, and boot fails on a missing tool or a changed input schema. The model sees the graph's descriptions, never the server's.
- No nesting, and no A2A. An agent is a graph node; the graph sequences agents. An external A2A peer, when one is required, is a route and a catalog row.
Options. (a) The loop in the packs, beside llm.chat, with the MCP client inside it: an earlier staged draft.
It is one process and needs no new contract. But it puts orchestration in the packs, keeps the tool table and the approval
logic away from the graph that certifies them, and reaches Mercury routes only by a second hop. Rejected in review.
(b) The MCP connector or a provider-hosted agent: the provider runs the loop. It is not available on every platform the
field uses, it reaches only public MCP URLs, and the per-call controls are thin. Rejected. (c) One generic mcp.call
route: this hides the server and the remote tool inside the arguments, where the catalog cannot certify them. Rejected.
(d) The loop in the engines (proposed). It keeps orchestration with the graph, the tool table on the graph, and
provider and MCP code in the packs. (e) The transcript held by the engine and sent to llm.chat inline each turn: the
pack stays stateless, but the transcript crosses the engine's codec and the wire twice per turn. Rejected in round 3 in
favour of the pack's own store. (f) A progress route owned by the engine, which stamps every event: rejected in round
4, because the caller already names its return route. Open questions: none. Rounds 3 and 4 answered Q1–Q11:
- the defaults are 10 turns, a 64 KiB result limit, a 7-day transcript TTL and retention 0;
- the transcripts may share the suspend store's Redis under their own prefix.
The RFC is ready for promotion when Eric accepts it.
Resolution.
RFC-0010 — An in-house JSONPath engine to RFC 9535: system/minimalist-jsonpath replaces Jayway JsonPath¶
Status: Promoted → ADR-0031 · Raised: 2026-10-22 · Serves: vision-mercury-composable · Thread: minimalist-jsonpath
Proposal. Replace Jayway JsonPath (com.jayway.jsonpath:json-path 3.0.0) with a module of this repository that implements
RFC 9535, the IETF JSONPath standard (February 2024), with no dependency beyond
java.base. The module has two parts:
- a parser that compiles a query into an immutable, thread-safe form;
- an evaluator that runs it directly over the engine's own Map/List trees, with no conversion step.
platform-core's MultiLevelMap is the only caller; it evaluates every $ path, so Event Script and the knowledge graph inherit
the change unchanged.
A decision commits the project to three things: - RFC 9535 as Mercury's one JSONPath grammar, on both engines. Eric's preference (2026-10-22): the standard gives cross-language predictability. Jayway was built long before it, on Stefan Gössner's 2007 proposal, so for Java the standard is a transition. - Owning a parser and an evaluator. - The behaviour changes below.
Why.
1. Supply chain. json-smart 2.6.0 is reported vulnerable (CVE-2026-113602, 5.3), by the IDE's dependency scan on
2026-10-22; no details are published.
- No upgrade exists: 2.6.0 is the latest release (August 2025).
- json-path 3.0.0, the latest release (February 2026), calls json-smart's JSONParser directly in its filter engine
(ValueNode.isJson, ValueNodes$JsonNode.parse), whatever JSON provider is configured. So an exclusion cannot remove it.
- json-smart's earlier advisories (CVE-2023-1370, and the incomplete fix of it in 2.5.0 to 2.5.1) were in that same parser.
- The ASM 9.7.1 node that came through accessors-smart was closed by an exclusion in #576. This proposal removes the
whole chain.
2. Parity. The Rust engine evaluates $ paths with serde_json_path 0.7, an RFC 9535 implementation, and shapes the
result the way Jayway does (crates/event-script/src/mlm.rs).
- A path both grammars accept gives the same answer on both engines.
- A Jayway-only path is evaluated by Java but silently answers null on Rust, which discards the parse error. Examples: in,
nin, subsetof, anyof, noneof, size, empty, =~, or a trailing .length(), .min(), .sum() or .keys().
- One grammar on both engines removes that difference.
3. Control. The engine decides:
- the grammar it supports;
- the errors it reports;
- the limits it enforces;
- how a substituted value is checked before the query is parsed (see The design).
The precedent is RFC-0006 (ADR-0028): a flagged library at a foundation is replaced by a module written from its specification.
Scope today.
- The code. One call, JsonPath.read(multiLevels, path) in MultiLevelMap.getElementFromMap, which also converts
json-smart's JSONArray to an ArrayList. Two exception types: PathNotFoundException there, and InvalidPathException in
the Playground's GraphCommandService. Nothing else in the code or the tests imports json-path.
- The guides. They document dotted paths, [*] and .*, an index, a slice, deep scan .., unions and == filters. They
pin Jayway's result shape: the claim json-path-result-shape, and the shared fixture unit-test-jsonpath-1 on both engines.
- All of it is RFC 9535 syntax. Jayway's [?(@.team == 'red')] is a filter whose expression is parenthesized, which RFC 9535
accepts.
The design.
- Module. system/minimalist-jsonpath, beside minimalist-msgpack, because platform-core depends on it; package
org.platformlambda.mini.jsonpath (answer 1). Its API, with the class names settled in WP1:
- a compile call that returns an immutable query;
- a select call that returns the nodelist;
- one JsonPathException extends IllegalArgumentException, carrying the position and the reason.
MultiLevelMap keeps a bounded cache of compiled queries, because a flow evaluates the same path on every event, as Jayway's
path cache does.
- Grammar: RFC 9535 in full.
- Selectors: name, wildcard, index (negative too), slice and filter.
- Segments: child and descendant.
- Filter expressions: ||, &&, !, parentheses, the comparisons == != < <= > >=, existence tests, and
relative (@) and absolute ($) queries.
- Literals: strings (single or double quoted), numbers, true, false and null.
- Functions: the standard's five, length, count, match, search and value, with its type checking. match and
search take I-Regexp (RFC 9485), mapped onto java.util.regex and pinned
by tests.
- Comparisons are type-strict, as the standard specifies: numbers compare by value (1 == 1.0), strings by Unicode scalar
value, and "5" == 5 is false.
- Two Mercury rules are kept, declared and identical on both engines.
1. The result shape is Jayway's rule, as Mercury documents it. RFC 9535 returns a nodelist and leaves shaping to the
application, so this is Mercury's shaping layer, as it already is on Rust:
- a definite path yields the value, or null;
- an indefinite path always yields a list;
- a missing member name before the first indefinite step yields null.
2. A dot-notation name may contain a hyphen ($.fetcher-ext.result), because graph node names carry hyphens. RFC 9535's
shorthand does not allow one, so this is the one declared extension. Rust already rewrites such names to bracket notation.
- Refusal, never a silent null. A query outside the grammar fails by name, with its position:
- when it is evaluated;
- at deployment, because CompileGraph and the flow compiler parse every $ source, so a graph with an unsupported path is
refused, and a flow loses the task that carries one, before it runs (the WP3 rulings below).
The Rust engine moves from its silent null to the same refusal; it already has the parse error.
- Limits. A nesting bound on filter expressions (parentheses, nested queries) and an iterative descendant walk, so that no
query text exhausts the stack.
- Substitution first, then parsing (answer 4). A reference in a $ source is replaced by its value before the query is
parsed, as today, so existing graphs and flows keep their queries. Both kinds of reference work this way: Event Script's
{model.key} and [model.i] (TaskExecutor.substituteDynamicIndex), and the knowledge graph's {namespace.key}
(GraphLambdaFunction.substituteMappingSource). For example, $.input.body.{model.key} with model.key = item is the
query $.input.body.item.
Each substituted value is checked where it lands, so that it cannot change the query's structure:
- in a path, it must be a plain key path or an integer index. A plain key path is member names joined by single dots,
each name RFC 9535's shorthand plus the hyphen of rule 2.
- inside a string literal the author wrote ('{model.team}'), it is escaped for that literal: its quote and its
backslash.
- bare inside a filter, a text value becomes a string literal, as the knowledge graph quotes it today, and a number or a
boolean is inserted as written.
- a list or a map cannot be substituted into a $ source, because RFC 9535 has no array or object literals.
A value that fails its check fails the mapping by name.
This closes two gaps found on 2026-10-22:
- A value next to a dot is inserted as written, so a value carrying query syntax can add segments, a wildcard or a filter
to the query. In a graph, that value can come from the request. The guides' advice to put a fixed prefix before a request
value does not prevent it.
- The knowledge graph's quoting escapes the quote but not the backslash, so a value that ends with \ escapes the closing
quote, and the literal runs on into the rest of the query.
Behaviour changes a decision accepts.
1. Jayway-only syntax is refused.
- The refused forms: the filter operators in, nin, subsetof, anyof, noneof, size, empty and =~; the path
functions .length(), .min(), .max(), .avg(), .stddev(), .sum(), .keys(), .first(), .last(), .index(),
.concat() and .append(); and array or object literals in a filter.
- Where the standard has a form, it replaces them: match(@.code, '…') or search(…) for =~; length(@.list) and
count(@.list[*]) inside a filter; @.code == 'a' || @.code == 'b' for a small in.
- All of these already answer null on Rust.
2. Filter comparisons are type-strict.
3. A substituted value cannot change a query's structure (see The design). Every value a working flow or graph
substitutes today (a key name, a key path, an index, a filter value) gives the same query as before. A value carrying query
syntax, or a list or a map in a $ source, now fails the mapping by name.
4. Queries both grammars accept but implementations answer differently. Examples: a filter applied to an object rather than
an array, the order of descendant results, and slices at the edges. The differential run (Evidence plan) lists every such
difference before the swap, and each is declared in the guide or fixed.
5. Transitive dependencies go. com.jayway.jsonpath, net.minidev, and ASM through accessors-smart no longer arrive
transitively. An application that uses json-path directly declares it itself. The engine still declares ASM, because Event
Script's plugin loader reads bytecode with it.
6. The Playground's error for a bad path changes: it states the position and the reason, in place of Jayway's message.
Evidence plan.
1. The compliance suite. The JSONPath Compliance Test Suite
(jsonpath-standard/jsonpath-compliance-test-suite,
BSD-2) holds 715 cases; 249 of them are invalid selectors that must be refused.
- It is vendored as one file in the module's tests, with its licence: the independent oracle, the RFC-0006 method.
- The Rust repository runs serde_json_path against the same file, so the two engines answer from one test file.
2. The shared fixture. unit-test-jsonpath-1 and the claim json-path-result-shape stay unchanged on both engines.
3. A differential run. Jayway and the new engine evaluate every $ path in the repository's flows, graph models, fixtures,
tutorials and tests, over a seeded corpus of documents, and every difference is listed. The run is kept outside the reactor,
as benchmark/msgpack-codec-harness/ was, because it needs json-path on its classpath.
4. A benchmark against Jayway on the engine's own paths: compile once, evaluate many.
5. A JaCoCo line and branch gate on the module.
Size. The precedent, minimalist-msgpack, is 1,136 lines of code and 1,058 lines of tests. This module is estimated at 1,500 to 2,000 lines of code (a parser, an evaluator and the five functions), with the compliance suite carrying most of the test weight.
Work packages.
- WP1. The module and the compliance suite.
- WP2. The swap in MultiLevelMap and the Playground, the dependency removal, the differential run and the benchmark.
- WP3. Refusal at deployment (CompileGraph and the flow compiler), on both engines.
- WP4. Substitution first, then parsing, with the checks where each value lands: in Event Script and the knowledge graph,
on both engines (answer 4).
- WP5. The guides, the help pages, the claims and the upgrade note.
WP3 rulings (Eric, 2026-10-22).
1. A flow keeps its compiler's unit. A task whose $ source is not RFC 9535 is dropped, as for any other invalid mapping
entry, with an ERROR that names RFC 9535 and the position. A graph is rejected: CompileGraph, the packager and the
Playground's run refuse it.
2. One scanner finds where each reference lands. WP3 builds it, to parse a source with a stand-in for each reference;
WP4 reuses it to check each value before it is inserted.
3. A simple plugin's $ argument stays supported, as the Event Script guide promises. The argument splitter ends a $
argument at the first comma outside its brackets and string literals, on both engines, so a union ($['a','b']) or a
filter's text reaches the plugin whole.
4. A {reference} never holds a JSONPath query ({$.input.body.amount} in a graph.math expression or a mapping
source). JSONPath belongs to data mapping, where a source and its target are separated by ->; nothing used the form and
no guide promised it. A data dictionary's default value is a source, the first use case of JSONPath in the engine, and is
checked like one.
WP4 rulings (Eric, 2026-10-22). The checks where each value lands follow The design, with two refinements that keep every
query that works today:
1. Bare inside a filter, Event Script keeps a text value that is already one literal as written - a number, true,
false, null, or a quoted string - and quotes any other text as a string literal. Event Script has always inserted text as
written, so @.price > {model.min} with the text 10 compares as a number, and quoting it would make the comparison
silently false. The knowledge graph keeps quoting all text, as it always has.
2. In a name, the value is Mercury's dot-bracket key path: names joined by single dots, each optionally followed by [n]
indexes (items[0].name). Such a value adds only child and index steps, never a wildcard, a filter or a descendant segment.
Found on the way: a nested reference in a $ source ({model.{model.k}}) is refused on both engines, at deployment and at run
time, although the two engines' brace scans differ outside $ sources; and the knowledge graph's quoting of a text value in a
graph.math expression escapes the backslash too, because the expression lexer reads a backslash as an escape.
Options. - (a) The in-house RFC 9535 engine. Recommended. One standard grammar on both engines, the json-smart chain gone for good, and the limits and the substitution rule under the project's control. Cost: owning a parser and an evaluator, with the compliance suite as their guard. - (b) Keep Jayway and record the json-smart advisory as unfixed, as the MsgPack 0.9.12 entry in 4.12.18 did. No work, but the finding recurs on every scan, and the engines keep two grammars. - (c) A third-party RFC 9535 library for Java. One grammar without writing it, but another dependency at the engine's foundation for the field's scanners to judge. None was evaluated for this draft. - (d) Wait for a json-smart fix. It has no date, and json-path would still carry the parser.
Open questions, answered (Eric, 2026-10-22).
1. Names. The module is minimalist-jsonpath (system/minimalist-jsonpath). Its package follows the minimalist-msgpack
precedent: org.platformlambda.mini.jsonpath, because a Java package name cannot carry the hyphen.
2. Release vehicle: v4.12.26, the next release. One release swaps the engine and refuses Jayway-only syntax, with no
warning release first, because the JSONPath surface is under the project's control. The differential run's findings are
still listed and declared before the swap.
3. RFC 9535 is adhered to. There is no Mercury-declared alternative to in or any other Jayway-only form; the standard's
forms are the replacements. The two declared Mercury rules (the result shape, and a hyphen in a dotted name) stay.
4. Substitution first, then JSONPath parsing, for backward compatibility (Eric's adjustment, the same day). Existing
knowledge graphs and event scripts must not break. A model reference yields a concrete value, and it is replaced before the
query is parsed: $.input.body.{model.key} with model.key = item is the query $.input.body.item. This holds in both
layers and on both engines.
- The flow-compiler refusal first proposed under this question is dropped.
- The checks where each value lands (The design) keep that compatibility, while preventing a value from changing the
query's structure. Accepted by Eric the same day.
5. Agreed. The thread on json-path's version under the Spring Boot parent (json-path-boot-bom-override, raised with #576)
becomes moot, and closes when the swap ships.
Promotion to an ADR follows review and green CI, the RFC-0006 method.
Resolution. Promoted → ADR-0031 (2026-10-22, Eric): option (a), the in-house RFC 9535 engine. Accepted after PR #579 (squash
68637dd6) merged with green CI, with the Rust engine's mercury #391, #392 and #393 (Increments 185 to 187): WP1 the module (the
compliance suite 715 of 715, 779 tests, 100% of lines and 95.7% of branches), WP2 the swap with the differential and the benchmark, WP3
the refusal at deployment, WP4 the substitution checks and WP5 the guides, the help pages and seven claims. The behaviour changes are
accepted as listed in the CHANGELOG; the WP3 and WP4 rulings above are part of the decision; the json-path-boot-bom-override thread
closed as moot and the minimalist-jsonpath thread closes with the promotion. Ships in v4.12.26.
RFC-0009 — Live invocation observers: a vendor-neutral seam in platform-core for an APM agent's code-level view¶
Status: Promoted → ADR-0032 · Raised: 2026-10-21 · Serves: vision-mercury-composable · Thread: dynatrace-oneagent-field-experiment
Proposal. A field installation validated the OpenTelemetry forwarder against Dynatrace, but OneAgent's code-level
view does not show the hops between composable functions, and its log fields do not tie to the trace it shows. The cause:
Mercury hands an event from the sender to the receiving function through the event bus and a per-route queue, so an agent's
thread-bound context is lost at every hop. The forwarder cannot help, because it reports a finished span after the fact, and
an agent needs a tracer that brackets the real call. (1) The seam. org.platformlambda.core.spi in platform-core, with
no vendor dependency: an InvocationObserver with begin (on the thread that runs a function) and send (on the thread that
sends an event), an InvocationScope closed on its own thread (error, then end; several observers close LIFO), and an
InvocationContinuation that a Mono result resumes exactly once on its completion thread (or abandons on timeout). The
linkage between hops rides as a string tag on the envelope, never as thread state, and is serialized across
Event-over-HTTP and the cloud connector; across minimalist-kafka it travels as x-mercury-tag-* headers
(InvocationObservers.export on publish, the tags restored and the delivery opened as a hop on consume). (2) The hooks. WorkerHandler.executeFunction; EventEmitter's direct dispatch
of Event Script's event.script.manager and task.executor; and the five EventEmitter send entry points (send, two
asyncRequest, two eRequest), each on the engine's own copy. A traced request is observed on every route but the
telemetry routes and the RPC inbox; invocation.observer.include/exclude adjust that, and
invocation.observer.unlinked is the experiment's negative control: the route alone is a separate fragment, because its
reply keeps the caller's tag (Eric, 2026-10-23; before, the rest of the flow followed the route into its new trace). (3) Two optional adapters, off by default.
extensions/dynatrace-oneagent (dynatrace.oneagent.enabled=true) models each hop as a message with the OneAgent SDK: an outgoing
message's Dynatrace tag on the envelope, an incoming message that consumes it, and an in-process link for a Mono. All hops
share one messaging system, and the destination is the application by default, so routes do not become Dynatrace services.
extensions/opentelemetry-tracer (otel.tracing=true) opens a live CONSUMER span per hop through the vendor-neutral API,
with W3C context in the tag; OneAgent's OpenTelemetry (Java) feature or the OpenTelemetry Java agent records it, and a
span may end on another thread, so a reactive hop shows its real duration. (4) One fix found by the linkage test:
Event Script's kick-off event to task.executor carried no trace id (the executor reads it from the flow instance), so it
was unobserved and a flow's first task started a new trace; it now carries the flow's trace. Built on branch
feature/dynatrace-oneagent-compatibility. Tested from a dev machine, not in the field (Eric, 2026-10-23). The plan
of 2026-10-21 was to certify the integration end to end in the field, on a snapshot built locally, with no PR before
that. With the support of Dynatrace's engineering team, the experiment runs instead from a dev machine against a
Dynatrace sandbox environment, so a dev cycle is a rebuild rather than a field release. The first run (2026-10-22)
proved enough to lift the gate: with the OneAgent adapter, a request was one connected, nested PurePath through every
hop, the RPC and the Mono included, and every log line carried dt.trace_id and dt.span_id. The feature moves from
experimental to regular feature development, and the branch goes to main through its PR; the run's findings are on the
thread. The OpenTelemetry adapter was removed (Eric, 2026-10-23), as option (b) had warned: under OneAgent its spans were
recorded but never linked across the event bus, because OneAgent links an API span only through its own context on the
thread and ignores a W3C parent set by the caller (the SERVER kind behaves the same, INTERNAL yields no span), and its
PurePath is not OpenTelemetry's current context at the REST entry. The seam stays vendor-neutral, so an adapter for an agent
that honours W3C parents can come back with that agent to test against. One trace (Eric, 2026-10-23): a REST request or a
Kafka message without a traceparent or a trace-id header continues the agent's trace context through
InvocationObserver.traceparent, as if the caller had sent it, so a Mercury trace id is the Dynatrace trace id and the
span lineage is continuous across applications.
Options. (a) The OneAgent SDK adapter alone: native PurePath nodes, but SDK tracers are bound to one thread, so a Mono
completion is a linked node, not a long hop. (b) The OpenTelemetry adapter alone: not tied to one vendor, and spans are not
bound to a thread, but it depends on an agent capturing OpenTelemetry API spans, and Dynatrace documents that spans may
display flat. (c) Both, chosen on the field's evidence (this branch ships both). (d) An SDK inside the engine itself:
rejected, because the engine core stays vendor-free.
Resolution. Promoted → ADR-0032 (2026-10-24, Eric): option (a), the OneAgent SDK adapter, over the vendor-neutral seam.
Accepted after PR #583 (squash a09a1535) merged with green CI, with #584 and #585 found on the way. The OpenTelemetry API
adapter, option (b), was built and removed: under OneAgent its spans were never linked across the event bus. The evidence is
docs/test-reports/dynatrace-one-agent-integration.md.
RFC-0008 — The mini-scheduler's active environment: one site runs the jobs of an active-active deployment¶
Status: Promoted → ADR-0030 · Raised: 2026-10-09 · Serves: vision-mercury-composable · Thread: scheduler-active-environment
Proposal. A field installation deploys the scheduler in Production and DR active-active. The DR scheduler fires the same
jobs, whose flows publish to the DR Kafka cluster, and the DR applications - which must stay idle while Production is up - consume
them: unintended side effects (Eric's problem statement, 2026-10-09). The module's existing mechanisms are single-site:
leader.election picks one instance inside a service mesh and the state resolver dedupes inside one state store; neither spans two
sites with two Kafka clusters. (1) A declared environment. scheduler.environment names this instance's environment (an
application property, e.g. ${ENV_NAME:prod}, default prod); the active environment is the one whose scheduler runs the jobs,
prod until an operator sets another, so an unconfigured installation behaves as before. Not app.env, which already selects the
Playground's dev mode (@OptionalService("app.env=dev"), 18 services): a Layer 3 application that also schedules jobs could not be
in dev mode and count as Production at once. (2) Standby skips at execution, not at scheduling. Quartz keeps firing; the job
executor compares the two values before anything runs and logs one line (Standby: job demo-task skipped - the active environment
is prod, this instance is DR), so a switch takes effect at the next trigger with no restart. An operator's manual run is explicit
and is honoured on a standby instance, with a log line saying so. (3) The value is persisted through a function the application
implements (Eric, 2026-10-09: Kubernetes restarts a pod without notice, so an in-memory value would reset): the route named by
scheduler.environment.store (default v1.environment.store), the contract type=get → the persisted name as text, type=set +
environment + operator → true; the scheduler example's EnvironmentStore is the file-backed template, in the shape of the
sample StateResolver, to be replaced by a database such as PostgreSQL or MongoDB. The store is the source of truth (Eric,
2026-10-09, dropping a first scheduler.active.environment property: a startup value has no place beside a store): it is read at
every scheduled point, before a job runs, so one call moves every instance of every site; the last value read is kept only as the
fallback for a store that fails to answer; without a registered store the value lives in memory only and the scheduler warns at
startup. (4) The switch is the application's REST endpoint, per the
rest.yaml rule: the module exposes ActiveEnvironment (status, activate) and the example wires GET/POST
/api/scheduler/environment ({"active": "DR", "operator": "..."}) through v1.environment.admin; the operations dashboard calls it
before and after a Production maintenance window. A decision would commit the module to the two properties and their defaults, the
skip-at-execution semantics, the store as the source of truth read at every scheduled point, its contract and the honoured manual run.
Options. (a) Declared environment, skip at execution, persisted through an application function - recommended: the
dashboard's one call reaches every instance through the store, no restart, the field keeps its own data store. (b) Pause the
Quartz scheduler on standby - no trigger log, and a resume races the first firings. (c) Reuse app.env - collides with dev
mode. (d) Keep the value in memory per instance - the dashboard's call reaches one instance behind a load balancer, and a pod
restart resets it; rejected by Eric. (e) Extend the state resolver's contract with the environment - changes a contract the field
has implemented for job state; a separate function keeps the two concerns apart.
Resolution. Promoted → ADR-0030 (2026-10-09, Eric): option (a), the declared environment with the store as the source of truth,
read at every scheduled point. Accepted after PR #539 (squash 74212b1e) merged with green CI: ActiveEnvironment and the executor's
standby path in the module, the example's EnvironmentStore template and GET/POST /api/scheduler/environment, the configuration
reference, the claim scheduler-active-environment-standby; the scheduler.active.environment property of the first draft dropped at
Eric's review. The release vehicle is the next release (CHANGELOG Unreleased, Added item 7).
RFC-0007 — The graph contract: a schema on the root and end nodes, discovery, OpenAPI on demand, input validation and a Schema panel¶
Status: Promoted → ADR-0029 · Raised: 2026-10-08 · Serves: vision-mercury-composable · Thread: graph-contract-schema
Proposal. Give a graph model an explicit, machine-readable contract and put it to three uses, from Eric's design of
2026-10-08 (the viability study is in the session log memory/sessions/ of that date). (1) Declaration. An optional schema
property on the root node describes the request and an optional schema on the end node describes the response (the node says
which side it is - Eric's naming). Each has two optional parts that mirror the state machine's namespaces: schema.body, a
schema object in the OpenAPI 3.0 dialect for input.body or output.body, and schema.header, an object schema whose
properties are header names for input.header or output.header - a graph instance receives the whole input of the flow
that invokes graph.executor, headers included, and the Playground's Upload step mocks them since the same day (Eric,
2026-10-08, second round). Both parts are stored as nested property maps through the Playground grammar's composite keys -
schema.body.type=object, schema.body.required[]=a, schema.body.properties.a.type=number,
schema.header.required[]=x-api-key, schema.header.properties.x-api-key.type=string - so edit node, update node and the
node editor carry them with no new grammar, and they travel with the graph through export, import, pack and graph.extension. The root and end nodes are skill-less in the common case, so the map already sits in every run's state
machine by reference, as purpose does; no per-run cost. (2) Discovery. One contract class per engine, extracted from the
describe graph scanner that both engines already share byte for byte, derives the data surface and as much typing as the model
gives: the path scan (names, nesting, arrays from [], [*] and [0]), direct evidence (typed constants and wrappers, known
plugin results such as f:now and f:listOfMap, for_each sources, JSONPath star steps) and one-hop propagation through the
model namespace (a COMPUTE result is a number, a CONDITION a boolean, a DECIMAL a string, a mapper constant its own type, a
pass-through from a typed input its type, a graph.extension target's declared output its result); it records which nodes
reference each path. The declaration wins; discovered paths it lacks are appended untyped and flagged; declared paths the model
never references are kept (a declaration may describe what a whole-body passthrough forwards) and flagged. (3) OpenAPI on
demand. A dev-mode GET /api/openapi/{graph_id} answers a minimal OpenAPI 3.0 document for a deployed graph as a YAML
attachment (?format=json for JSON): info from the root node's purpose and the deployed set's version, servers from the
request's Host header so a downloaded file points back at the engine that generated it, POST /api/graph/{graph_id} with the
request-body schema, the 200 response schema, every int(N) -> output.status constant as a response code, the declared or
discovered output.header.* as response headers, the declared or discovered input.header.* as header parameters (a declared
header carries its required and its schema), and the standard error shape. Query parameters are not part of the graph API -
/api/graph/{graph_id} is one endpoint URI for every graph, by design (Eric, 2026-10-08) - so they are neither declared nor
derived. A draft variant through the
session shows the spec while authoring, and a discovery endpoint answers the skeleton and its evidence as JSON for the panel. The
file drops into extensions/api-playground unchanged, and Swagger UI's explore bar can load the engine URL directly - the dev
routes and the executor route carry the wildcard CORS entry. (4) Input validation. When the root node carries schema, both
walkers - the executor and the Playground's traveler - validate input.body against schema.body and input.header against
schema.header as the first thing at the root on every run; a root node's own skill, if it has one, runs after a successful
validation, and a failed validation never reaches it. Header names match case-insensitively, the engine's rule for
input.header.*; a header value is text, so type: integer, number or boolean validates the parsed text; required names
the headers that must be present; the Playground's Upload step supplies mock headers (POST /api/mock/{id}?namespace=header,
mercury-composable #531 and mercury #369), so a dry run can satisfy a header requirement. The
step is assumed by the engine, nothing is written into the node (Eric, 2026-10-08: assumed properties leave no room for a
typo): the
engine invokes the built-in function graph.schema.validator through the task skill's own request and response machinery with
{body, header, schema}; 200 passes, 400 carries every violation up to a cap in one message (Input validation failed -
input.body.a: expected number; input.body.b: required; input.header.x-api-key: required), which the standard task error path turns into the run's abort status, the root's
exception= handler, or the console line of a dry run; the application property graph.schema.validator names a substitute
function with the same contract. The vocabulary is a closed subset of OpenAPI 3.0 keywords by their OpenAPI names - type
(object, array, string, number, integer, boolean), properties, required, items, enum, minimum, maximum,
exclusiveMinimum, exclusiveMaximum, minLength, maxLength, pattern, minItems, maxItems, nullable,
additionalProperties - with title, description, example and format documentary and never validated. The gate refuses any
other keyword, because a constraint the validator silently ignores teaches that unflagged means safe (the partial-gate lesson of
2026-09-18). Types are strict JSON types - a numeric string is not a number; money is type: string with a pattern, as the
DECIMAL: guidance already says. A pattern runs after maxLength, in the regex subset common to Java and the Rust crate, and
comes from the author, never the caller. The schema compiles once per compiled model. The end node's schema stays documentary, headers included.
(5) The Schema panel. A left-slot panel like the packaging panel, opened from the Tools menu or from the root or end node
editor: Input and Output tabs, each with a body section (one row per path) and a header section (one row per header name) -
path or name, type, required, description, and a source chip reading declared, discovered or from last run - pre-filled from
discovery; a "Fill from last run" action infers types and examples from the instance's actual input.body, input.header,
output.body and output.header through the inspect endpoint, the only way to type a task's or a fetcher's result; Save writes
the two properties through the existing update-node command, so every session member sees it and an agent does the same by
command; Download fetches the draft spec. A decision would commit the project to the two schema properties with their body
and header parts and the closed keyword subset on both engines (a shared vector file pins accept and reject with their messages, the method of
ADR-0026), to validation by declaration at the root as an assumed step, to the OpenAPI document as a derived artifact that is
never stored, and to the gate rules: a malformed schema fails, an unknown keyword fails, a mismatch between declaration and
surface warns.
Options. (a) Declared schema, discovery, and validation assumed by the engine - the file stays as authored, the dry run and
the deployed run behave identically, the function stays the atom, and the root keeps a skill of its own, run after the
validation. Recommended.
(b) Discovery only, no declaration - the structural minimum the viability study measured over the bundled models (the shape
comes free; of 57 output mappings in the 14 tutorials 15 carry a type, of 31 input references 2; none reads a header or a query
parameter): honest, but never a real contract and no validation. (c) Validator lines written into the root node at export or
by the panel - visible in the file, but two sources of truth: a re-imported graph carries them as draft properties, a removed
schema leaves a dangling validator, the dry run validates only after an export and an import, and a task node needs its two
input[] mappings as well, four lines a developer can mistype. Rejected by Eric, 2026-10-08. (d) A dedicated validation skill -
one more built-in skill for a step every graph with a contract wants; the assumed step through the task machinery gives the same
traversal-log line and error path without it. (e) Sample a dry run only - accurate types and examples, but per session and only
after an execution; kept as the panel's "Fill from last run", not as the contract. (f) JSON Schema 2020-12 with OpenAPI 3.1 -
fuller, but the api-playground's Swagger UI and the field's tooling target 3.0; revisit when a 3.1-only keyword is needed.
Settled (Eric, 2026-10-08). The property is named schema on both nodes; the validator is assumed, never inserted; the
vocabulary borrows a minimal set of OpenAPI 3.0 syntax; headers are declared and validated beside the body, in schema.header,
their names case-insensitive (the second round of the same day, with the Upload step's mock headers shipped ahead of the sprint).
Open: the closed subset above as the first cut; strict JSON types; additionalProperties left to OpenAPI's default (extra
fields pass unless the author declares false); the cap on reported violations (proposed 10). Settled in WP1: the draft route is
GET /api/openapi/session/{sessionId}, and schema.query is out (no query parameters on the graph endpoint). Settled in WP2 (2026-10-09): the assumed step is one synchronous request from the walker to the validator function on the run's first visit to the root (armed when the run begins, never on a resumed traversal), reported as Input validated by graph.schema.validator in N ms in the traversal log and on the console; the four open points are implemented as proposed - the subset as listed, strict JSON types with an integral float counting as an integer, additionalProperties left to OpenAPI's default, and ten violations per message with the rest counted - and remain Eric's to confirm at promotion; a keyword that cannot apply to the declared type is refused like an unknown one, the end node's schema compiles by the same rule, the gate warns at deploy and pack time about a declared path the model never reads or a read path the declaration lacks, a pattern is searched with Unicode-aware classes and a textual check refuses lookaround, atomic groups, backreferences and possessive quantifiers on both engines, and the values the Playground grammar stores as text (minimum=0, nullable=true) are read as the schema expects. The vectors are graph-schema-vectors.json. Settled in WP3 (2026-10-09): the panel edits a row's path, type, requiredness, description and example, and shows the other keywords of the subset (enum, minimum, pattern, …) as a summary chip carried through Save unchanged - the node editor edits those; a Save is one update node with the node's other properties re-sent as the node editor sends them (the engine clears the property set on update); a chip reads declared, discovered, from last run (the run supplied the type) or new; a reload that answers after a row was edited is dropped, an explicit Reload discards the edits. Work packages: WP1 the contract class, discovery, the OpenAPI and
discovery endpoints, and describe graph printing the declaration (both engines, one shared fixture); WP2 the validator
function for the body and the headers, the assumed step in both walkers, the gate rules and the shared accept/reject vectors
(both engines); WP3 the Schema panel with its body and header sections, fill-from-run and download (one source, two deploy
targets); WP4 the help page, the command reference,
the AI agent guide recipe, the api-playground round trip and the claims. Promotion follows the implementation, as RFC-0001,
RFC-0002 and RFC-0005 did.
Resolution. Promoted → ADR-0029 (2026-10-09, Eric): option (a), the declared schema with discovery and validation assumed by the
engine. Accepted after all four work packages merged with green CI on both engines (the Java engine's PRs #533, #534, #535 and #536;
the Rust engine's PRs #370 to #373, Increments 171 to 174), with the shared graph-contract-vectors.json and graph-schema-vectors.json
passing in both. The points left open above are accepted as implemented: the keyword subset as listed, strict JSON types with an
integral float as an integer, additionalProperties at OpenAPI's default, ten violations per message with the rest counted, the panel
editing path, type, requiredness, description and example only, and a Save re-sending the node as the node editor does. The release
vehicle is the next release (CHANGELOG Unreleased, Added items 2 to 5).
RFC-0006 — An in-house MessagePack codec: system/minimalist-msgpack replaces org.msgpack:msgpack-core¶
Status: Promoted → ADR-0028 · Raised: 2026-10-06 · Serves: vision-mercury-composable · Thread: minimalist-msgpack
Proposal. Replace the engine's one third-party MessagePack dependency with a module of this repository, written from
the published specification (spec.md, last modified 2017-08-09):
a streaming MsgPackWriter and MsgPackReader (org.platformlambda.mini.msgpack, with MsgPackFormat, MsgPackType and
one checked MsgPackException extends IOException) for the nil, bool, int, float, str, bin, array and map families, with no
extension types - Eric's "minimalist" rule: no custom types - and no dependency beyond java.base. platform-core's MsgPack
(the event payload codec and the general purpose packMapOrList pair), CanonicalPackager and the HTTP relay read and write
through it; the value layer keeps its _T/_D encoding, its null-transport rule and its 64-level depth bound unchanged. The
module lives in system/, beside minimalist-kafka, because platform-core must depend on it and extensions/ depend on
platform-core (Eric agreed, 2026-10-06). A decision would commit the project to owning a wire codec - the format table, the
smallest-format rule it shares with msgpack-core and the Rust engine's rmp, the robustness checks and their tests - and to the
three behaviour changes below.
Why now. The field's scanners (Mend, Snyk) flag msgpack-core 0.9.12 for CVE-2026-90472 (stack exhaustion in
MessageUnpacker.unpackValue()) and CVE-2026-90473 (a map 32 count overflow in skipValue()); the advisories name 0.9.13
as the fix, which is not published (2026-10-06: Maven Central 404, GitHub's latest release v0.9.12). The engine reaches neither
method (#513) and bounds its own reader at 64 levels (#514), but a flagged library at the serialization foundation can block a
field release regardless of reachability, and the wait has no date. The engine uses a small part of MessagePack, so that part
is written here. Eric's criteria for a replacement (2026-10-06): at par or faster than msgpack-core; virtual-thread friendly;
no deprecated libraries; no unsafe operations.
What was built (branch feature/minimalist-msgpack, merged as PR #517, squash 527ac5eb). The writer uses the smallest format for every value
with the integer rule msgpack-core and rmp share (-32..127 one byte, larger non-negative values the smallest unsigned
width, smaller negative values the smallest signed width), float32 and float64 bit for bit, UTF-8 under the shortest str
header, the shortest bin, array and map headers; short ASCII text is written straight into the buffer. The reader is a
cursor: nextFormat() peeks, the read methods consume one value or header each, and the robustness rules hold for bytes
from outside the process: every read is bounded by the array window; str, bin and ext lengths and array and map counts are
32-bit unsigned and checked against the remaining bytes before anything is allocated or consumed (N elements need N bytes,
N entries 2N, so the CVE-2026-90473 header df 80 00 00 00 is refused at the header); 0xc1 is refused; skipValue() is
iterative (100,000 nested arrays skip without stack); a failed read leaves the position unchanged; every failure is the one
checked exception. Extension values are recognized and skipped, never decoded. Multi-byte fields go through VarHandle
byte-array views (big-endian), the sanctioned intrinsic that replaced sun.misc.Unsafe; there is no reflection, no
synchronized, no ThreadLocal, no lock, and a reader or writer is created per call. Tests: 134 in the module (the
specification's format table pinned byte by byte, hand-derived vectors at every format boundary, non-minimal encodings,
every hostile-header shape, every ext format skipped, a seeded random corpus that round-trips and whose every proper prefix is
refused, JaCoCo 90% line and branch gate) and three regression tests in platform-core for the behaviour changes.
Evidence.
- Byte identity. The canonical package vectors (an independent oracle, byte-identical with the Rust engine) pass unchanged
on the new codec, as do MsgPackTest, CanonicalPackagerTest and EventEnvelopeTest. A 20,000-document seeded random
corpus (16.9 MB; every integer boundary, all four UTF-8 widths, unpaired surrogates, nulls in maps and lists, nesting to
depth 4) packs to byte-identical output through the old and the new MsgPack, and each backend decodes the other's corpus
with 0 mismatches (the harness is benchmark/msgpack-codec-harness/, outside the reactor because it needs msgpack-core on
its classpath).
- Performance. Separate JVMs per backend, same machine otherwise idle, three runs each, best of 5 rounds of 2 s after 3 s
of warm-up, three payload shapes through MsgPack.pack/unpack (the value layer included, so this is what the engine
sees); the table, method and environment are in docs/test-reports/minimalist-msgpack-benchmark.md.
Best of three runs: the 395-byte event envelope packs in 0.54 and unpacks in 0.55 of msgpack-core's time, the 8 KB payload packs in 0.39 and unpacks in 0.72, and the 7 KB list of 50 records is at par (pack 0.96, unpack 1.03, within the run-to-run spread of about 4%).
- Virtual threads and safety. No shared or thread-local state, no lock, no
Unsafe, no deprecated API,java.baseonly, compiled for Java 21. msgpack-core allocated an 8 KBMessageBufferpernewDefaultPackercall andMsgPack.packMapOrListwrote it through a synchronizedByteArrayOutputStreamand copied twice; the small-envelope gain is mostly that overhead.
Options. (a) The in-house codec. Recommended: the dependency is gone for good, the codec is 900 lines the team
reads, the bytes are proven identical, and the next scanner finding against a MessagePack library is not the engine's
problem. Cost: owning a wire codec (its tests are the guard). (b) Wait for msgpack-core 0.9.13 (the open thread's plan): no
date, and the next CVE costs the same wait. (c) Another library (msgpack-jackson depends on msgpack-core; the rest are
small or unmaintained): swaps one supply-chain exposure for another and keeps an extension-type surface the engine never
uses. (d) Suppress the finding in the field's scanner: not this repository's decision, and it recurs on every scan.
Behaviour changes a decision accepts (each pinned by a test in MsgPackTest): (1) org.msgpack.* no longer arrives
transitively; an application that used it directly declares msgpack-core itself or moves to the new reader and writer.
(2) Every decoding failure of MsgPack.unpack is an IOException, which EventEnvelope reports as IllegalArgumentException
as its contract always said; msgpack-core also threw its own unchecked exceptions, which AsyncHttpClient caught by name and
now need not. (3) A uint 64 above Long.MAX_VALUE, which another encoder may send, reads as a BigInteger instead of
failing - as CanonicalPackager and the Rust engine (rmpv keeps a u64) already do. Unchanged: an extension value reads
as null, nulls in maps follow serializer.null.transport, the 64-level limit, a Float as float32 in the event codec,
lenient UTF-8 and str/bin cross-acceptance on read (msgpack-core's defaults).
Open questions. (1) Release vehicle: the next patch release, or after the field accepts 4.12.20? (2) The Rust engine needs
no change (rmp/rmpv are pure Rust and not affected); the shared vectors stay the parity proof. (3) The msgpack-core-cve-upgrade
thread becomes moot if this is accepted. Promotion to an ADR follows review and green CI (the RFC-0001/RFC-0002 method).
Resolution. Promoted → ADR-0028 (2026-10-06, Eric): option (a), the in-house codec. Accepted after PR #517 (squash
527ac5eb) merged with green CI: the module and the swap, 134 module tests under a 90% line-and-branch gate, three regression
tests for the behaviour changes, the two-way differential and the benchmark (docs/test-reports/minimalist-msgpack-benchmark.md).
The three behaviour changes are accepted as listed; the Rust engine needs no change; the msgpack-core-cve-upgrade thread
closed as moot. The release vehicle is the next release (CHANGELOG Unreleased, Changed item 10).
RFC-0005 — Graph sets: a pack/unpack CLI, a Playground packaging panel, and sets + unpack in the deployment manifest¶
Status: Promoted → ADR-0027 · Raised: 2026-10-03 · Serves: vision-mercury-composable · Thread: graph-set-packaging
Proposal. The first consumer of the canonical packager (ADR-0026), from Eric's design notes of 2026-10-03; the review and
the sprint plan are in draft-design-specs/graph-set-packaging-and-deployment.md. (1) A command-line packager — pack,
unpack, inspect — that is a thin front over the engine's own packager in each repo (Java helpers/graph-packager, Rust
tools/graph-packager), never injects a timestamp, and runs the gate's static checks at pack time; signing and verification
stay a separate utility, with a detached <set>.pack.sig as the shared convention. (2) A Playground panel that assembles a set
from dropped or imported graph JSON files with manifest key-values and (3) downloads <set>.pack, packing on the engine through a
dev-mode POST /api/graph/pack (and inspecting through /unpack) rather than in the browser. (4) The deployment manifest
gains sets (packed files read from the manifest's location) and unpack (a file:/ folder with read and write access —
classpath: is rejected, Eric's ruling): before the gate, each set is unpacked into readable JSON files and a generated
manifest in that folder, which compiles as the next manifest in sequence — so the automation list stays a list of manifests
(the allowlist of ADR-0011) and the later-wins rule of 2026-09-25 applies unchanged; a set registers all of its graphs or none; a
duplicate involving a set logs an ERROR; names are validated before any path is built. A decision would commit the project to the
.pack artifact and its entry convention (<graph-id>.json, root name = id, optional graph_id entry point), to the sets +
unpack manifest schema, to all-or-none registration per set, and to the generated-manifest mechanism, on both engines.
Options. (a) Generated manifest in the unpack folder, compiled in sequence — reuses every manifest mechanism, leaves
readable files for operators and for import graph from. Recommended. (b) Compile the unpacked maps from memory, no files
— simpler, but the operator cannot read what was deployed and the import fallback cannot find it; Eric's concept asks for
files. (c) Treat the unpack folder as a bare deployed location — breaks the manifest-as-allowlist rule. (d) Pack in the
browser — a third implementation of the wire contract to keep byte-identical. The nine open choices (extension, pack-time
gate, all-or-none, log levels, graph_id, name agreement, where packing happens, module homes, inspect in the Playground) are
tabled in the spec with recommendations.
Settled (Eric, 2026-10-03). The artifact is <set>.pack (D1, simplicity); D2 to D9 as recommended: the gate's static checks
run at pack time, a set registers all of its graphs or none, a duplicate involving a set logs an ERROR while the loose-manifest
override keeps its WARN, graph_id is an optional entry point verified against the entries, the root node's name must equal
the entry's id at pack and at unpack, the panel packs on the engine through POST /api/graph/pack, the module homes are
helpers/graph-packager (Java) and tools/graph-packager (Rust), and inspecting a dropped .pack in the Playground is in
scope. The open questions: pack --from-manifest <graphs.yaml> is in; the generated manifest is documented in the open as the
implicit next entry of the automation list; a set's version shows in list graphs and in the deploy INFO line. unpack
is a file:/ folder with read and write access, classpath: rejected. Promotion to an ADR follows the implementation
(the RFC-0001/RFC-0002 method: implement on a branch, promote after review and green CI).
Resolution. Promoted → ADR-0027 (2026-10-06, Eric): option (a), the generated manifest in the unpack folder, with D1 to D9 and the three answers above. Accepted after the first two work packages shipped, ahead of the rest of the sprint: the gate as one shared method and the command-line packager (the Java engine's PR #508 and the Rust engine's PR #356, Increment 158). Added at promotion (Eric): a set holds one graph or more, because packing a graph alone is how one graph is signed; both engines' tests pin a set of one graph to the same bytes. The loader, the endpoints, the panel and the documentation (WP3 to WP6) follow under the ADR.
RFC-0004 — A bracket-lookup plugin for tariff tables (f:decimalBracket)¶
Status: Withdrawn · Raised: 2026-10-01 · Serves: vision-mercury-composable · Thread: none
Proposal. A plugin in the f:lookup family, f:decimalBracket(amount, bands, scale, mode), taking an ordered table of { upTo, rate } bands (and an optional open final band) and returning the banded amount as a canonical decimal string, so a tariff schedule that arrives as data stays on the graph. Raised by an external review of the DECIMAL statement.
Options. (a) the plugin; (b) leave a fixed schedule unrolled in DECIMAL with min and max, one line per band, and a schedule that arrives with each request in a graph.task function.
Resolution. Withdrawn (2026-10-01, Eric): option (b). A static schedule is graph data that the product owner certifies on the graph, and it unrolls into lines that can be read; bands supplied at run time are not graph data and belong in a function. The plugin would also force rulings the engine has no need to own: marginal bands or the whole amount at the band's rate, an inclusive or an exclusive upper bound, and the open final band — the edge-case chase clean-knowledge-design-over-engine-coverage warns against. The unrolled pattern is documented in the DECIMAL guide.
RFC-0003 — Expose the for_each iteration index to the statements of a graph.math loop¶
Status: Parked · Raised: 2026-10-01 · Serves: vision-mercury-composable · Thread: none
Proposal. GraphMath.executeForEach counts the iteration and does not store it. Store it for the duration of the iteration, for example model.<node>.index (0-based) and model.<node>.count, overwritten on each pass and absent after the loop, so a statement can address the last line or the Nth line ({model.index} == {model.count} - 1 ? residual : share, inside a DECIMAL ternary; a taken IF would end the walk). Raised by an external review of the DECIMAL statement; both engines would carry it.
Options. (a) store index and count; (b) store nothing and compute an allocation residual outside the loop (amount - share * n, which already goes to a named party without an index); (c) a graph.task function for the rare per-line output list.
Resolution. Parked (2026-10-01, Eric): defer until a field case needs a residual on a specific line of a per-line output list. The loop-free residual covers allocation to a named party today. Reactivate with that case in hand; the change is small and additive, but it is one more engine surface that both engines must carry for ever.
RFC-0002 — Canonical MsgPack packager: sorted-key maps and a manifest as one deterministic byte array¶
Status: Promoted → ADR-0026 · Raised: 2026-09-30 · Serves: vision-mercury-composable · Thread: canonical-msgpack-packager
Motivation. Some installations must promote a set of related documents (knowledge graphs today, but equally flows, rules or tables) as one artifact whose identity can be recorded, compared and, where the field wants it, protected. Loose files cannot do that. Each one is deployed and validated alone, JSON text is not a stable artifact (key order and whitespace vary), and there is no single thing to hash. MsgPack does not fix this by itself: a map has no guaranteed order, and one value has several valid byte encodings, so two packagings of the same content can differ, and their hashes with them. Both engines already use MsgPack (Java through msgpack-core, platform-core/.../serializers/MsgPack.java; Rust through rmpv, crates/platform-core), so the smallest useful piece is a deterministic packager. Whether a package is then hashed, signed or neither is a deployment decision and stays outside it.
Proposal.
- The packager. A small utility beside
MsgPackin platform-core (and its Rust twin) that turns maps into one byte array. It first converts every map to an ordered map with keys sorted recursively (at every depth, including maps inside lists; list order is kept), then writes the result as MsgPack under the canonical profile (item 3). The ordering is done by the packager itself (Eric, 2026-10-01): the Gson serializer has no ordered-keys option, and an ordered-keys option such as Jackson's sorts byStringorder (UTF-16), which differs from another engine's byte order above U+FFFF. The input map may be of any kind; its own order never reaches the bytes. The bytes are written through the MsgPack library directly, not through the platform's generalMsgPack.pack, which drops nulls unless configured, packs aFloatas float32 and writes aBigDecimalzero as"0.00"- none of which the profile allows. The same content gives the same bytes, in either engine. - Package structure: a manifest metadata map plus the maps. The package is one MsgPack map with two parts:
manifest, a metadata map. The packager writesformatandformat_version; everything else is caller-defined string fields, recorded but never interpreted by the engine. For graphs, the convention is thatgraph_idholds the graph ID of the graph the package delivers (the id the deployment manifest lists,POST /api/graph/{graph_id}serves, and the file<graph_id>.jsonholds), with the other maps as its subgraphs. A set name, a version or a certification state can sit beside it;maps, the packed maps, keyed by entry name.
{ "manifest": { "format": "mercury-package", "format_version": "1", "graph_id": "quote", ... },
"maps": { "quote-fees.json": { ...keys sorted recursively... }, "quote.json": { ... } } }
Multiple maps are saved in sorted filename order. Entry names are file names, ordered by their UTF-8 bytes (never OS listing order or locale collation). Because maps is keyed by name and every map is key-sorted, that order needs no separate rule. manifest sorts before maps, so a reader can read the metadata before decoding any map. A duplicate entry name, or a caller field named format or format_version, is an error.
3. Canonical MsgPack profile, identical in every engine.
- Keys are text, in ascending order of their UTF-8 bytes (not Java's UTF-16 String order, which differs for supplementary characters). A non-text key is converted to text, as the existing serializer does; a collision after conversion, or a null key, is an error.
- Null values are written as nil and never dropped (the general serializer omits null-valued entries unless supportNulls is set).
- Integers use the smallest encoding, fixints first.
- Floats are float64 and finite only: NaN and Infinity are rejected. (Amended 2026-10-01: a Float, which MsgPack.java packs as float32, is accepted and widened through its shortest decimal text; see the Resolution.)
- Text and bytes are MsgPack str and bin, each with the shortest header.
- Exact numbers travel as strings, following RFC-0001 including its zero rule, a zero of any scale being "0" (the existing serializer already packs BigInteger and BigDecimal as strings, but writes a BigDecimal zero as "0.00", so the packager applies the rule itself), and a date is an ISO-8601 string. No extension types, no timestamps; booleans and nil use their single forms.
- Any other type is rejected, naming its path, rather than stringified.
4. Integrity is outside the packager, entirely the user application's decision (Eric, 2026-10-01). Nothing inside the package refers to how it is protected: no signature, hash, key id or timestamp, and the framework carries no hash mode, no verifier seam and no key. The packager produces deterministic bytes; the application chooses whether to protect those exact stored bytes, with which algorithm (SHA-256 recorded in a release record or a sidecar, a detached signature from its own signer, a KMS, openssl, or nothing), and where the proof is kept. An earlier draft had the engine implement none and hash and call a route-addressed verifier for signature; that is withdrawn so that the framework owns no algorithm, key rotation or fail-closed policy, which belong to the field.
5. Reading. unpack returns ordered maps (insertion order preserved, never a hash-ordered map) and the manifest. A strict read, the default, also re-encodes the decoded content canonically and rejects the package if the bytes differ, so an accepted package has exactly one byte form; a non-strict read decodes anything that is valid in the profile's types. The decoder rejects a non-text key, a duplicate key, an extension type, bytes after the value and nesting beyond a fixed bound (64), so untrusted bytes cannot exhaust the stack.
6. Tooling. Deferred. A pack that builds a package from a folder or a list of files (JSON to start with) and an unpack that writes the maps back as readable JSON, exact numbers kept as text, are the natural next step; the source files remain what people read and certify, and the package is only the deployment artifact.
7. Parity, and the Rust interop. One vector file, canonical-package-vectors.json, is byte-identical in the Java and Rust repositories and run by both engines' suites. Its expected bytes come from an independent encoder written from this specification, not from either engine, so an engine that matches the file matches every other engine that does. It holds:
- values: integers at every encoding boundary, float64 including -0.0, str and bin header boundaries, Unicode (and an unnormalized e + combining accent), map and list header boundaries (15 and 16), nulls kept, keys sorted at depth, keys whose UTF-8 and UTF-16 orders differ, exact numbers as strings (the RFC-0001 zero rule included);
- packages: the graph-set example in both entry orders (same bytes), entry names ordered by UTF-8 bytes, manifest fields sorted, an empty package, each with its expected hex and SHA-256;
- a generated corpus of 60 packages of random nested documents from a seeded generator, a differential test that shows a divergence in a rarely used corner of the profile as a byte difference;
- rejection cases: Float, NaN, Infinity, an unsupported type, a duplicate entry name, a caller field named format or format_version; and on read, non-canonical integer width, out-of-order keys, a float32, trailing bytes, a non-map, a missing or extra top-level key, a wrong format or version, a non-text manifest field, an entry that is not a map, a duplicate key, a non-text key, an extension type, a truncated package, and the nesting bound (sixty-four nested lists decode, sixty-five are refused). The cases only a strict read rejects are marked.
The Rust twin runs the same file; an engine-to-engine drive (the same documents packed by each engine, the bytes compared, and each engine's strict read accepting the other's bytes) follows with it.
A decision would commit every engine to the canonical profile as a wire contract, the manifest + maps structure, and the rule that integrity never lives inside the package. It adds one packager per engine and no dependency.
First consumer, deliberately out of scope. A graph-set loader is the intended first consumer and would be raised as its own RFC. It would register all graphs or none, define precedence against graph.model.automation manifests (the later-loaded source wins, as for manifest lists), verify that graph_id names one of the packed graphs, and could also check static graph.extension targets at load, declaring that it cannot see through flow:// or targets computed at run time.
Deferred until field use asks for it: the folder pack and unpack tooling; trusted timestamps; per-entry records or hashes in the manifest, to show which map changed between versions; hot reload; two versions loaded side by side; compression.
Options.
- (a) A generic canonical packager, integrity external and optional (above). One artifact, and the framework owns no signature scheme. Recommended.
- (b) An embedded signature envelope, [payload, signature]. One file carries its own signature, but the format is bound to one scheme and the framework owns algorithms, key rotation and timestamps; the none and hash modes need a degenerate envelope.
- (c) Canonical JSON (RFC 8785) plus an external hash. Human-readable and diffable, but it needs a canonicalizer in each engine, its number rules conflict with strings for exact numbers (RFC-0001), and MsgPack is already the bus format in Java and Rust.
- (d) A zip of files plus a manifest of per-entry hashes. Readable without a decoder, but it needs a two-level hash scheme, because zip bytes vary with entry order and timestamps.
- (e) Leave it to adopters. No engine change, but each adopter re-solves canonical encoding.
Settled (Eric, 2026-10-01). Digital signature, the hash mode and the verifier seam are out of the packager and left to the user application (item 4); the work is the key ordering and the packaging into MsgPack; byte-for-byte compatibility with the Rust engine is a requirement and is enforced by the shared vector file and a later engine-to-engine drive (item 7).
Implemented as proposed in the first Java PR, to confirm at review:
- graph_id stays a documented convention that the packager never interprets, because a graph-set loader can check it against the packed entry names.
- A strict read is the default.
- Non-finite doubles are rejected; a Float was rejected here and is now widened (see the Resolution). The packager is faithful to a value's type, so the integer 1 and the float 1.0 are different content, and strings are written as given (no Unicode normalization); both are declared in the class documentation.
- msgpack-core and rmpv both choose the smallest integer encoding: checked on 21 integers at every encoding boundary and the str headers, all identical bytes (the vectors run the same cases in both engines).
- The Java packager is org.platformlambda.core.serializers.CanonicalPackager beside MsgPack: a builder (manifest, add, build), encode, decode and unpack; it needs no new dependency.
- The mercury-python and mercury-nodejs language packs do not carry it: they are minimalist LLM extensions that serve functions to a Java or Rust application over Event-over-HTTP, as if local, and carry no event script or minigraph, so they never read a graph package. (mercury-go is the agent-memory tool, not a language pack.)
Resolution. Promoted → ADR-0026 (2026-10-01): option (a), a generic canonical packager with integrity left to the user application. Delivered in the Java engine's PR #481 (the packager, 14 + 5 tests and the shared vector file), PR #482 (the nesting bound pinned) and PR #483 (the Sonar findings), and the Rust engine's PR #339 (Increment 147); all of the shared file passes in both engines. The folder pack / unpack tooling and the graph-set loader RFC remain deferred. Amended (2026-10-01, Eric, before the v4.12.20 release): a Float is accepted and widened to float64 through its shortest decimal text instead of being rejected (the open question about widening a Float); NaN and Infinity stay rejected and a float32 on the wire still fails the strict read.
RFC-0001 — Exact decimal arithmetic: a DECIMAL: statement for graph.math and an explicit plugin family¶
Status: Promoted → ADR-0025 · Raised: 2026-09-30 · Serves: vision-mercury-composable · Thread: decimal-mode
Motivation. Knowledge graphs are a natural home for financial rules (settling contracts, rates, fees). Money and rate math must be exact, and reproducible bit-for-bit across runs and across engines. Today every numeric path is IEEE double or long:
| Where | Current behaviour | Consequence |
|---|---|---|
| Math dialect values | Value.NumberValue(double); literals go through Double.parseDouble (minigraph/math/Value.java, Parser.java:333) |
0.1 + 0.2 gives 0.30000000000000004 |
| Dialect functions | MathFunction.apply(double...); round is Math.round (EvalContext.java:33) |
round(-2.5) gives -2 |
| Arithmetic plugins | SimplePluginUtils.promoteNumber: integers become Long, strings try Long then Double; reduceNumbers uses long math when every input is whole, double otherwise |
f:div(7, 2) gives 3 (integer truncation); the Rust twin asserts the same |
f:round |
BigDecimal.valueOf(double).setScale(dp, HALF_UP).doubleValue() (RoundNumbers.java) |
Returns a double again. f:round(-2.5) gives -3, disagreeing with the dialect's round(-2.5) = -2 |
| Rendering | BigDecimal.valueOf(d).stripTrailingZeros().toPlainString() (Evaluator.java:239) |
Deterministic, but renders the double's value, not the intended decimal |
This reopens an earlier ruling, deliberately. The current guidance (ruled 2026-09-25, recorded with the typed-arithmetic decision) is that exact-decimal money belongs in a graph.task function and that BigDecimal is not added to the dialect, keeping the math package minimal. That works, but it hides the formulas in code, while the point of a knowledge graph is that owners read and certify rules on the graph itself. This RFC asks whether that trade still holds; option (c) below is the existing ruling.
Proposal. Add a DECIMAL: statement to graph.math, the high-precision COMPUTE:, with identical semantics in every engine that executes graphs. COMPUTE: keeps its meaning, so existing graphs are untouched by construction: there is no graph-level mode, no application default and no CompileGraph check. Exactness is declared where it is needed, statement by statement, the way CONDITION: declares a boolean instead of letting COMPUTE: infer one from its operators. One rule does reach existing statements: numeric strings compare as numbers (item 3), a READ item at release.
- The statement.
DECIMAL: var -> expressionevaluates the expression with exact decimal arithmetic and stores the result at{node}.result.{var}as a canonical decimal string (item 6). The tag joinsCOMPUTE:andCONDITION:in the statement recognizer, and it is valid ingraph.mathnodes only (graph.js, which is deprecated, already rejects any tag it does not know). ADECIMAL:statement computes a number: a comparison may appear inside a ternary test, but a boolean result is an error naming the statement (CONDITION:is the boolean statement). - Value model and reading. In Java, add
DecimalValue(BigDecimal)and a decimal literal node beside the double ones (ValueandExprare sealed with exhaustive switches, so the compiler finds every site) and evaluate aDECIMAL:statement with a parallel decimal evaluator, leaving the double path untouched.graph.mathrenders each{selector}into the expression text and then parses it, so the parser never sees a Java type: numeric literals, substituted numbers included, parse from text straight to a decimal (the lexer already reads fraction and exponent forms), never through a double. ALongorIntegervalue is exact. ADoubleorFloatvalue is inexact by nature, and its Java type is gone once a value is text, soGraphMathconverts such a value before it renders it: through the shortest decimal text it prints as, at its minimal scale and in plain notation (5.0E-4is0.0005,100.0is100), so a number and the same decimal written as a string give the same answer. This is the lenient choice, and it is declared, not enforced: the guide says that a double already computed in floating point (aCOMPUTE:result, a client's own arithmetic, a JSON number longer than a double holds) is only as exact as that computation, so sending money as strings is a conscious decision, andf:validateasserts it for callers who insist. Every value, number or string, is rendered into the statement text, so the evaluator sees literals and not sources: a substituted string is indistinguishable from one the author typed. The read rule therefore lives in the evaluator, and substitution does not change. A decimal read from the state machine works in either form: aBigDecimalrenders as a number literal, and its canonical string renders as a number literal in an arithmetic position and as a string literal where text is quoted (IF, CONDITION, a comparison). ADECIMAL:statement computes numbers, so its selectors always render in the arithmetic (unquoted) form, even inside a ternary test, and+always adds; a string literal the author types in the expression converts if it is a canonical decimal (plain notation: an optional minus sign, digits without leading zeros, an optional fraction), and any other non-numeric operand is an error naming it. Outside aDECIMAL:statement a string that is not a canonical number is never converted: in an arithmetic position it still renders unquoted and parses as a number, exactly as today, and in a boolean position it stays text. - Operators.
+ - *are exact, with a specified result scale (+/-: the larger operand scale;*: the sum of scales) so both engines agree; a scale is never negative (a literal such as1e3parses to1000, scale 0)./never truncates (7 / 2gives3.5): a quotient that terminates is exact, at the smallest scale that represents it and is not below the dividend's scale minus the divisor's (BigDecimal.divide), and a non-terminating one uses a fixed division context of 34 significant digits (decimal128) with HALF_EVEN, until the author applies an explicit scale; division by zero is an error naming the operator.%is defined on decimals.**andpowwith a whole-number exponent are exact, the exponent bounded (proposed 999) so an expression cannot exhaust memory. Comparisons use numeric value (2.0 == 2.00is true). Numeric strings compare as numbers, everywhere (Eric, 2026-09-30). In==,!=,<,<=,>and>=, in IF, CONDITION,COMPUTE:andDECIMAL:alike, a string operand that is a canonical number is converted to a number before the comparison, so'200' == '200',200 == 200and200 == '200'are the same comparison, and'9.5' > '10.25'compares 9.5 with 10.25. A string that is not a canonical number keeps today's behaviour: two strings compare as text, and a number against such a string is an error naming the operands. Today200 == '200'and'9.5' > 10.25are errors,'9.5' > '10.25'is true (text order) and'1.0' == '1'is false. The comparison is exact in both evaluators: a number operand is taken as the decimal text it prints as (BigDecimal.valueOf(double)) and a string as the decimal it spells, so two distinct 20-digit ids never collapse into equal numbers, and no precision guard is needed. READ at release: a graph that compared two numeric strings as text now compares them as numbers. - Rounding is always explicit.
round(x, scale, mode)takesHALF_UP | HALF_EVEN | HALF_DOWN | UP | DOWN | CEILING | FLOORand returns a decimal at exactly that scale. The one- and two-argument forms are refused in aDECIMAL:statement, so there is no hidden default to configure;COMPUTE:keepsMath.round.floor,ceil,abs,minandmaxare exact. - Operations that cannot be exact are refused, at run time. In a
DECIMAL:statementsqrt,log,log10,exp, trigonometry,powwith a fractional exponent,random(), and the constantsPIandEraise an error naming the node and the statement. They are refused at run time rather than gated at compile time: a compile-time check would be a partial gate that cannot see values arriving through mapped inputs. An author who needs them keeps that step in aCOMPUTE:statement or uses agraph.taskfunction. - Strings at rest,
BigDecimalonly in flight. ADECIMAL:result is stored in the state machine as its canonical string, never as aBigDecimal. The state machine does not keep Java types across a serialization boundary:graph.suspendpersists themodelnamespace to a pluggable store andgraph.resumemerges it back without conversion, and everygraph.taskandgraph.extensionhop is an event envelope. The platform's MsgPack serializer writes aBigDecimalas a string (a zero at scale 2 as"0.00") and reads it back as aString, and a smallLongcomes back as anInteger, so aBigDecimalkept in memory would change type at a suspension point, and IF, which quotes text, would compare it differently after the resume than before. A string is the same before and after, and the read rule in item 2 makes it a number wherevergraph.mathmeets it. The JSON serializer also writes aBigDecimalas a plain-notation string, so a decimal never travels as a JSON or MsgPack number, which standard parsers and MsgPack's float types would round-trip through a double. That covers graph JSON, the exported graph, the run'soutput.*, a suspension record, and a canonical MsgPack package (RFC-0002); literals in node properties are text already and parse exactly. Canonical string form: plain notation, never scientific; keeps its computed scale (round(10.5, 2, HALF_UP)gives"10.50"); a zero of any scale is"0"(Eric, 2026-09-30). The JSON serializer already writes that; the MsgPack serializer writes aBigDecimalzero as"0.00", which is one more reason to store the canonical string rather than aBigDecimal, and the canonical packager in RFC-0002 applies the zero rule itself. This is also the grammar item 2 reads, so what aDECIMAL:statement writes is always what it reads back. ABigDecimalhas no negative zero, so nothing further needs normalizing. - No mode to mix. There is no graph-level mode, so there is nothing to mix, nothing for
CompileGraphto check and nothing for a subgraph to inherit: exactness travels with the data (canonical strings at rest), and each statement declares its arithmetic. The hazards are an author usingCOMPUTE:on decimal strings, which computes in binary floating point exactly as it does today, and a double that was computed in floating point reaching aDECIMAL:statement (item 2). The guide says to useDECIMAL:for money, to send money as strings, and to keep aCOMPUTE:result out of aDECIMAL:statement, and it namesCOMPUTE:as the floating-point statement; the limits are declared in the guide, not enforced. - Plugins. Add an explicit decimal family for flows and mapper nodes:
f:decimalAdd,f:decimalSubtract,f:decimalMultiply,f:decimalDiv,f:decimalMod,f:decimalRound(x, scale, mode)andf:decimalCompare(camelCase, like every simple plugin; Eric, 2026-09-30). The existingf:addfamily keeps its behaviour, so nothing changes silently. Explicit names beat a hidden mode for plugins, which run outside any one graph's context. TodaypromoteNumberrejects aBigDecimalby name, so thef:addfamily fails loudly on a decimal instead of degrading it to a double. The plugins are the second delivery step (see Delivery). - Parity, enforced by shared test vectors. A committed JSON file of
{statement or plugin call, inputs, expected canonical string or expected error}(an error is an engine-neutral code that each engine matches against its own message) runs in every executing engine's test suite, alongside the drift-tested claims registry. It must cover: the classic traps (0.1 + 0.2,1.005at scale 2,±2.5and±0.5in every rounding mode,1/3and2/3in the division context); scale propagation through+ - * /; zero at several scales; large magnitudes and the exponent bound; how aLong, aBigDecimal, a canonical string, a non-canonical string and a convertedDoublebehave in aDECIMAL:statement; the comparison matrix in both evaluators (a number, a canonical string, a non-canonical string and a non-numeric string on each side of the six operators, long digit ids included); a MsgPack round trip of amodelholding decimal strings, asgraph.suspendandgraph.resumedo it; the refused-operation errors; and unchangedCOMPUTE:results. - A side fix independent of decimal arithmetic: document, or align, the dialect
round(-2.5) = -2versus pluginf:round(-2.5) = -3discrepancy.
Delivery (Eric, 2026-09-30). The first implementation is Java graph.math only: items 1 to 7 and 9. It is the DECIMAL: statement in GraphMath and the statement recognizer, the decimal evaluator in the minigraph-playground-engine math package, the docs (skills-reference.md, the in-Playground help and minigraph-commands.json), a claim for the statement (the math-expression-dialect claim keeps describing COMPUTE:), and the shared vectors; item 3's comparison rule is part of it and reaches the double evaluator. The f:decimal* plugins (item 8) and the Rust twin follow as separate PRs against the same vectors. graph.data.mapper arithmetic and f:lookup numeric keys are unchanged.
A decision would commit every executing engine to one written decimal specification (the DECIMAL: statement, result scales, division context, rounding modes, canonical string form, strings at rest, the numeric-string comparison rule), a shared conformance-vector file maintained in lock-step, and one new Value variant plus a plugin family per engine.
Options.
- (a) A DECIMAL: statement (above). Explicit and declared where it is needed, like CONDITION:; no graph-level state, and existing graphs untouched. Recommended.
- (b) Replace double with decimal everywhere. Simplest to reason about, but it changes every statement's result from a number to a string and the rendering of every existing result.
- (c) Leave the core unchanged and ship a decimal function library for graph.task (the existing ruling). No engine change, but formulas become opaque code again, and every adopter reinvents rounding and serialization.
- (d) Fixed-point integers (minor units, e.g. cents, as long). Exact and fast for sums, but awkward for rates, percentages, per-period amounts and currencies of different scale, and division still needs a rounding policy.
- (e) A graph-level numeric: decimal mode. Zero compatibility risk, but one more thing to configure, and a mixed-mode limit between graphs that CompileGraph cannot see; the statement declares exactness where it is needed.
- (f) Type-directed arithmetic with nothing declared. No new syntax, but a graph that passes numeric strings into arithmetic would flip from a number to a string result.
Settled (Eric, 2026-09-30). A fixed decimal128 division context (34 digits, HALF_EVEN) with no configuration; the computed scale kept in canonical output, and a zero of any scale written "0"; a Double accepted in a DECIMAL: statement through its shortest decimal text, declared in the guide as a conscious decision (lenient); scope stays graph.math, so graph.data.mapper arithmetic and f:lookup numeric keys are unchanged; delivery in three separate PRs against the same vectors, Java graph.math first, then the f:decimal* plugins, then the Rust twin; decimals stored as canonical strings at rest, because graph.suspend and every hop serialize the state (item 6); numeric strings comparing as numbers (item 3); and the DECIMAL: statement, the high-precision COMPUTE:, in place of a graph-level mode (item 1), with rounding always explicit and no graph-level default.
Still open:
- The comparison rule and existing statements. Implemented as proposed in the first PR: IF, CONDITION and COMPUTE: get the rule too, so '9.5' > '10.25' and '1.0' == '1' change for existing graphs (a READ item at release). The alternative leaves them as they are today, and {model.status} == '200' keeps failing there; confirm at review.
- Rust type. The Rust twin would use an arbitrary-precision crate. bigdecimal matches Java BigDecimal semantics best and is already in that workspace's lockfile (0.4.10, through apache-avro); a fixed 96-bit type (e.g. rust_decimal, about 28 digits) cannot reproduce a 34-digit context. (verify bigdecimal's rounding-mode coverage and result-scale behaviour against the shared vectors when the Rust PR starts)
- Exponent bound. Proposed 999 for ** and pow; confirm when the evaluator lands.
- Release. Which version carries each delivery step? The language packs (mercury-python, mercury-nodejs) need no engine change for the decimal work: they host functions over Event-over-HTTP and never evaluate a statement or a plugin; a function of theirs that returns money returns a plain-notation string, which a DECIMAL statement or a decimal plugin reads (mercury-go is the agent-memory tool, not a language pack).
Resolution. Promoted → ADR-0025 (2026-09-30): option (a), the DECIMAL: statement. Delivered in PR #471 (Java graph.math, with the numeric-string comparison rule) and PR #472 (item 10, the round alignment); items 8 (the f:decimal* plugins) and the Rust twin follow as separate PRs against the shared vectors.