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-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.