Architectural Decision Records¶
A human-facing ledger of the durable architecture decisions behind Mercury
Composable — one entry per decision, capturing the why (context, alternatives,
consequences) rather than the what that holds now. The live constraints themselves are
maintained in the project's working memory (memory/continuity.md →
Architectural Invariants / Key Decisions); each ADR cross-links to the constraint it
formalizes via a formalizes: pointer, and each such constraint carries a matching
(ADR-NNNN) tag. This ledger is read on demand — it is not part of any per-session
read path.
Entries are listed newest first. Numbering is monotonic and entries are never deleted: a decision that no longer holds is marked Superseded (replaced by a newer ADR) or Deprecated (no longer relevant), with its text left in place. ADR-0001 is the foundational decoupling decision; the rest build on it.
Only decisions are entered here. This ledger is an immutable journey of what the
project decided, so an entry is written when a decision is accepted — never before. Work
still under consideration is raised in RFC.md as RFC-NNNN — the proposal
register that pairs with this ledger — and is written in here only on acceptance, which the
register records as Promoted → ADR-NNNN; a proposal that is withdrawn never appears here,
because it was never a decision. RFC-NNNN and ADR-NNNN are separate sequences: a
proposal does not reserve an ADR number. (Convention adopted 2026-09-18, and the same day
upstream as agent-memory protocol v4.41.2 / governance pair v4.42.0. The five entries
carrying Accepted (2026-09-18) predate it — they were raised as Proposed under the older
practice and were confirmed together when it changed.)
ADR-0001 to 0005 were seeded as a retrospective in 2026-06-22 from the decisions already
governing the codebase — verified against the source (platform-core,
event-script-engine, minigraph-playground-engine) and the published guides, which are
the source of truth in case of ambiguity. The narrative design reasoning behind each decision lives
in that ADR's own Rationale section.
ADR-0030 — The mini-scheduler's active environment: one site of an active-active deployment runs the jobs, decided by a data store read at every scheduled point¶
Status: Accepted · Date: 2026-10-09 · Serves: vision-mercury-composable · Formalizes: scheduler-active-environment-store
Abstract. A scheduler deployed in two sites, Production and DR, active-active, fires its jobs in one of them. The application
property scheduler.environment names the instance's own environment (default prod); the active environment - the one whose
scheduler runs the jobs - lives in a data store the application provides through the function named by scheduler.environment.store
(default route v1.environment.store), read at every scheduled point before a job runs. An instance whose environment is the active
one is active; any other stands by: a scheduled job is skipped with one log line, and an operator's manual run is still honoured. The
operations team moves the active environment around a maintenance window through the application's REST endpoint, which writes the
store, so one call moves every instance of every site and a pod restart changes nothing. Proposed as RFC-0008 in RFC.md
from Eric's problem statement of 2026-10-09, delivered the same day in PR #539 (squash 74212b1e) and accepted on 2026-10-09.
Context. A field installation runs 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 in an event-driven design where only the Production cluster is meant to carry traffic. The module's two
existing mechanisms are single-site: leader.election picks one instance inside a service mesh, and the state resolver dedupes job
runs inside one state store. Neither spans two sites with two Kafka clusters, so the decision needs a notion the sites share.
Decision.
- A declared environment, one property.
scheduler.environmentnames this instance's environment, one word of letters, digits, hyphens and underscores (${ENV_NAME:prod}in a deployment), compared case-insensitively with the active environment. The property is notapp.env, which already selects the Playground's dev mode (@OptionalService("app.env=dev")): a Layer 3 application that also schedules jobs must be able to be in dev mode and count as Production at once. - The store is the source of truth, and there is no second origin. The active environment is read from the store at every
scheduled point, before a job runs; a first draft's
scheduler.active.environmentproperty was dropped at review (Eric, 2026-10-09), because a startup value beside a store is a question the operator has to answer. Until an operator sets a value the active environment isprod, so an unconfigured installation runs as before; the last value read is kept only as the fallback for a store that fails to answer, and the failure is logged. When no store function is registered, the value lives in memory only and the scheduler warns at startup. - The store contract is two messages. Header
type=getanswers the persisted name as text, empty when none was set yet; headertype=setwithenvironmentandoperatorpersists the name and answerstrue. The application implements it against its own database or distributed cache; the scheduler example's file-backedEnvironmentStoreis the template, in the shape of its sampleStateResolver.scheduler.environment.storenames another route. - Standby skips at execution, not at scheduling. Quartz keeps firing; the job executor reads the store, compares, and either
runs the job's normal path (leader election or the state resolver, then the job) or skips it with one log line (
Standby: job demo-task skipped - the active environment is prod, this instance is DR). A switch takes effect at the next trigger with no restart, and the trace shows the skipped firing. - An operator's manual run is honoured on a standby instance, because it is explicit; the log says it ran on a standby instance.
- The switch is the application's REST endpoint. The module exposes
ActiveEnvironment(status, refresh, activate); the application wires the endpoint through rest.yaml, as the example does withGET/POST /api/scheduler/environmentand the body{"active": "DR", "operator": "..."}. Activate persists first, then updates memory, and refuses a malformed name.
Alternatives. (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 one 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. (f) A startup property
beside the store: two origins; dropped at review.
Consequences. The module is committed to the property, the store contract and its default route, skip-at-execution and the
honoured manual run; pinned by the claim scheduler-active-environment-standby. A scheduler that sets no environment runs as
before. Every scheduled point now costs one request to the store, bounded by a five-second timeout with the last value as fallback;
an application that wants the switch to survive restarts must implement the store, and one that registers none runs the module as
it always did, with a warning. The Rust port has no scheduler, and the language packs are not involved. Deferred: a built-in store
over the framework's own key-value extensions, and a per-job override of the environment rule.
ADR-0029 — The graph contract: a schema on the root and end nodes, discovered and declared, validated at the root by an assumed step, and served as OpenAPI 3.0 on demand¶
Status: Accepted · Date: 2026-10-09 · Serves: vision-mercury-composable · Formalizes: graph-contract-by-declaration
Abstract. A graph model carries an explicit, machine-readable contract: an optional schema property on the root node
describes the request (schema.body for input.body, schema.header for input.header) and an optional schema on the end node
describes the response, each in a closed subset of OpenAPI 3.0 keywords and stored through the Playground grammar's composite keys,
so the contract travels with the graph through export, import, pack and graph.extension. The engine discovers the data surface
the model reads and writes and as much typing as the model gives, and the declaration wins where both speak. A root schema turns
on input validation as a step the engine assumes at the root of every run, through the built-in function
graph.schema.validator, so a dry run in the Playground and a deployed run refuse the same bad request with the same message. A
dev-mode endpoint answers a minimal OpenAPI 3.0 document derived from the model each time it is requested and never stored, and
the Playground's Schema panel edits the contract as rows. Proposed as RFC-0007 in RFC.md from Eric's design of
2026-10-08, delivered in four work packages on both engines on 2026-10-09 (the Java engine's PRs #533, #534, #535 and #536; the
Rust engine's PRs #370 to #373, Increments 171 to 174) and accepted on 2026-10-09, the points left open in the register accepted as
implemented. The guide page is docs/guides/knowledge-graph/graph-contract.md.
Context. A graph is deployed as one endpoint, POST /api/graph/{graph_id}, and until now its request and response shapes
lived only in the mappings that read and write them: a viability study over the fourteen bundled tutorials found that of 57 output
mappings 15 carry a type and of 31 input references 2, and none reads a header. A consumer of the endpoint had no document to code
against, the api-playground had nothing to load, and a bad request failed wherever the first mapping met it, deep in the traversal,
with a message about a mapping rather than about the request. The Playground's Upload step gained mock request headers the same
day the design was settled (PR #531; the Rust engine's PR #369, Increment 170), so a dry run could satisfy a header requirement
before the sprint began. The shared describe graph scanner, byte-identical in both engines, already walked every mapping; the
graph set work (ADR-0027) had just established the pattern of one shared gate method, shared vector files and a left-slot panel.
Decision.
- The declaration. The root node may carry
schema, describing the request, and the end node may carryschema, describing the response; the node says which side it is. Each has two optional parts that mirror the state machine's namespaces:schema.body, a schema object forinput.bodyoroutput.body, andschema.header, an object schema whose properties are header names forinput.headeroroutput.header. Both are nested property maps written through the grammar's composite keys (schema.body.type=object,schema.body.required[]=a,schema.body.properties.a.type=number,schema.header.properties.x-api-key.type=string), soedit node,update nodeand the node editor carry them with no new grammar, and the root and end nodes stay skill-less in the common case. Query parameters are neither declared nor derived:/api/graph/{graph_id}is one endpoint URI for every graph, by design (Eric, 2026-10-08). - 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,nullableandadditionalProperties, withtitle,description,exampleandformatdocumentary and never validated. The gate refuses any other keyword, and a keyword that cannot apply to the declared type, 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, an integral float counts as an integer, and money istype: stringwith apattern, as theDECIMAL:guidance already says.additionalPropertieskeeps OpenAPI's default, so extra fields pass unless the author declaresfalse. Apatternruns aftermaxLength, is searched with Unicode-aware classes in the regex subset common to Java and the Rust crate, and a textual check refuses lookaround, atomic groups, backreferences and possessive quantifiers on both engines; a pattern comes from the author, never the caller. The values the grammar stores as text (minimum=0,nullable=true) are read as the schema expects. A schema compiles once per compiled model, the end node's by the same rule; a malformed schema fails the gate with its location, at deploy and at pack time. - Discovery, and the declaration wins. One contract class per engine, extracted from the shared
describe graphscanner, derives the surface from the path scan (names, nesting, arrays from[],[*]and[0]), direct evidence (typed constants and wrappers, known plugin results,for_eachsources, JSONPath star steps) and one-hop propagation through the model namespace (aCOMPUTEresult is a number, aCONDITIONa boolean, aDECIMALa string, a pass-through from a typed input its type, agraph.extensiontarget's declared output its result), and records which nodes reference each path. A discovered path the declaration lacks is appended untyped and flagged; a declared path the model never references is kept and flagged, because a declaration may describe what a whole-body passthrough forwards. The mismatch is a WARN in the gate at deploy and pack time and an issue in the contract view, never a refusal.describe graphprints the declaration with its types. - Validation is a step the engine assumes, never a property written into the node (Eric, 2026-10-08: an assumed property
leaves no room for a typo). When the root carries
schema, both walkers, the executor and the Playground's traveler, send one synchronous request tograph.schema.validatorwith{body, header, schema}on the run's first visit to the root, armed when the run begins and never on a resumed traversal; the request and response shape, the staging of a failure underroot.*and the error path are exactly a task node's, so a 400 becomes the run's abort status, the root'sexception=handler, or the console line of a dry run, and a root node's own skill runs only after a successful validation. The message carries up to ten violations with the rest counted:Input validation failed - input.body.a: expected number; input.body.b: required; input.header.x-api-key: required. Header names match case-insensitively, the engine's rule forinput.header.*; a header value is text, sotype: integer,numberorbooleanvalidates the parsed text. The traversal log and the console recordInput validated by graph.schema.validator in N ms. The application propertygraph.schema.validatornames a substitute function with the same contract. The end node'sschemais documentary at run time: the response is not validated. - The OpenAPI document is derived on demand and never stored. The dev-mode
GET /api/openapi/{graph_id}answers a minimal OpenAPI 3.0 document for a deployed graph as a YAML attachment (?format=jsonfor JSON):infofrom the root node'spurposeand the deployed set's version,serversfrom the request's Host header so a downloaded file points back at the engine that generated it, the onePOSTpath with the request-body schema, the200response schema, everyint(N) -> output.statusconstant as a response code, the declared or discoveredoutput.header.*as response headers, the declared or discoveredinput.header.*as header parameters, and the standard error shape.GET /api/openapi/session/{sessionId}answers the same for a session's draft, and?view=contractanswers the typed path list with its origins, users and issues for the panel. The file drops intoextensions/api-playgroundunchanged. - The Schema panel edits the contract as rows and saves through the existing command. A left-slot panel opened from the Tools
menu or from the root or end node editor shows Input and Output tabs, a body section with one row per path and a header section
with one row per header name, pre-filled from the contract view. It edits a row's path, type, requiredness, description and
example only; the other keywords of the subset show as a read-only summary chip and travel through Save unchanged, the node
editor being where they are edited. A chip reads
declared,discovered,from last runornew; Fill from last run infers types and examples from the instance's actualinputandoutputthrough the inspect endpoint, the only way to type a task's or a fetcher's result. Save is oneupdate nodewith the node's other properties re-sent as the node editor sends them, because the engine clears a node's property set on update, so every session member sees the change and an AI agent does the same by command. - The evidence standard. Two vector files, byte-identical in both repositories, pin the contract:
graph-contract-vectors.json(six models with their discovered contract and document) andgraph-schema-vectors.json(56 compile cases with their accept or reject message, 29 validate cases, 2 report cases); the Rust engine matched each on its first run. Five claims per engine pin the normative sentences of the guide page on named tests. A change to the vocabulary, the message shape or the document is a change to those files and to this decision, not a refactor.
Alternatives. (b) Discovery only, no declaration: the shape comes free and honestly, but it is never a real contract and nothing validates. (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, a dry run validates only after an export and an import); rejected by Eric on 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. (a), declaration plus discovery with validation assumed by the engine, was chosen: 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.
Consequences. Both engines are committed to the two schema properties with their body and header parts, the closed
vocabulary, validation by declaration at the root as an assumed step, the derived document and the gate rules. The change is opt-in
per graph: a model without schema runs as before, and a model that declares one now answers a bad request with status 400 at the
root, before any node runs, on the dry run and in production alike, so a graph certified with a contract is certified against it.
graph.schema.validator is a reserved route name in every application that deploys graphs, and a substitute named through the
property must honour the same request and response. The vocabulary grows only by decision: a keyword the field asks for is weighed
against the partial-gate lesson, added to both engines and to the vectors together. The mercury-python and mercury-nodejs
language packs are not involved. Deferred, deliberately: response validation at the end node, OpenAPI 3.1, schema reuse across
graphs, and query parameters, which the one-endpoint design excludes rather than defers.
ADR-0028 — The MessagePack codec is the engine's own: the zero-dependency minimalist-msgpack module replaces msgpack-core¶
Status: Accepted · Date: 2026-10-06 · Serves: vision-mercury-composable · Formalizes: minimalist-msgpack-codec
Abstract. The Java engine's MessagePack codec is a module of this repository, system/minimalist-msgpack
(org.platformlambda.mini.msgpack), written from the published specification for the eight type families the engine uses -
nil, bool, int, float, str, bin, array and map - and for no other: there are no extension types, and no dependency beyond
java.base. platform-core's MsgPack, CanonicalPackager and HTTP relay write and read through it, and
org.msgpack:msgpack-core left the reactor in PR #517 (squash 527ac5eb). Proposed as RFC-0006 in RFC.md on
2026-10-06 at Eric's investigation ask, built and verified the same day against his four criteria (at par or faster than
msgpack-core; virtual-thread friendly; no deprecated libraries; no unsafe operations), and accepted on 2026-10-06 after review
and green CI. The evidence is docs/test-reports/minimalist-msgpack-benchmark.md.
Context. The field's scanners flag msgpack-core 0.9.12 for CVE-2026-90472 (stack exhaustion in unpackValue()) and
CVE-2026-90473 (a map 32 count overflow in skipValue()); the advisories name 0.9.13 as the fix, and it is not published.
The engine reached neither method and had bounded its own reader at 64 levels (CHANGELOG items 9 and 25 of the release after
4.12.20), but a flagged library at the serialization foundation of every event can block a field release regardless of
reachability, and the wait had no date. The engine used a small part of MessagePack: a Map or List of scalars, strings and
bytes, with the value layer (_T/_D, serializer.null.transport, MsgPack.MAX_DEPTH) already its own. The Rust engine
uses rmp/rmpv, pure Rust and not affected, and the Python and Node.js language packs use their own MessagePack libraries
over Event-over-HTTP; the shared canonical package vectors (ADR-0026) already pinned the byte-level contract across engines.
Decision.
- Scope. The module supports the nil, bool, int, float, str, bin, array and map families, every width of each, and
nothing else: no extension types and no timestamp. The writer has no method for an extension value; the reader decodes
none but knows the ext framing, so
skipValue()steps over one and the value layer reads it asnull, as it always did. New MessagePack features need a new decision, not a quiet addition. - The writer's bytes are a contract. Every value is written in its smallest format, with the integer rule msgpack-core
and
rmpshare: a value from -32 to 127 is one byte, a larger non-negative value takes the smallest unsigned width, a smaller negative value the smallest signed width; afloatis a float32 and adoublea float64, bit for bit; text is UTF-8 under the shortest str header, bytes under the shortest bin header, and array and map headers take their shortest form. These choices make the bytes identical to msgpack-core's and the Rust engine's, which the canonical vectors and the 20,000-document differential prove; changing any of them breaks that identity and the signatures made over canonical packages (ADR-0027), so none changes without a decision here. - The reader is built for bytes from outside the process. Every read is bounded by the array window; the lengths of
str, bin and ext values and the counts of arrays and maps are 32-bit unsigned and checked against the remaining bytes
before anything is allocated or consumed (an array of N elements needs at least N bytes, a map of N entries 2N, so a
header that promises more than the input holds - the CVE-2026-90473 shape among them - is refused at the header);
0xc1, which the specification never uses, is refused;skipValue()is iterative, so nesting costs no stack; a failed read leaves the position unchanged; and every failure is one checkedMsgPackException extends IOException- nothing unchecked leaves the codec for malformed input. As the specification allows, a str may hold invalid UTF-8 and is decoded leniently (U+FFFD), and str and bin are accepted interchangeably on read, as msgpack-core did by default. - Implementation constraints.
java.baseonly; big-endian fields throughVarHandlebyte-array views, the sanctioned intrinsic, neversun.misc.Unsafe, reflection or a deprecated API; nosynchronized,ThreadLocalor lock, and a reader or writer is created per call, so a virtual thread never pins. The module compiles for the engine's Java 21 baseline. - The value layer stays in platform-core, unchanged.
MsgPack(the event payload codec and the general purposepackMapOrListpair) andCanonicalPackagerkeep their contracts, their null rule and the 64-level depth bound; the codec below them is streaming only and policy-free, and the depth rule lives where the recursion is. - Three behaviour changes, each pinned by a test.
org.msgpack.*classes no longer arrive transitively; every decoding failure ofMsgPack.unpackis anIOException, whichEventEnvelopereports asIllegalArgumentExceptionas its contract always said (msgpack-core also threw its own unchecked exceptions); auint 64aboveLong.MAX_VALUE, which another encoder may send, reads as aBigIntegerinstead of failing, asCanonicalPackagerand the Rust engine already did. - The module lives in
system/, besideminimalist-kafka: platform-core depends on it, andextensions/depend on platform-core, so an extension cannot be the home of a foundation (Eric, 2026-10-06). - The evidence standard for a change to the codec. The module's tests pin the specification's format table byte by
byte, every format boundary, every hostile-header shape and a random corpus whose every proper prefix is refused, under a
90% line-and-branch coverage gate; platform-core's canonical vectors and serializer tests must pass unchanged; and the
harness in
benchmark/msgpack-codec-harness/(outside the reactor on purpose, so msgpack-core never enters a scanned manifest) is the differential and benchmark method of record.
Alternatives. (b) Wait for msgpack-core 0.9.13: no date, and the next finding against the library costs the same wait.
(c) Another MessagePack library: msgpack-jackson depends on msgpack-core, and the rest are small or unmaintained; it 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. (a), the in-house codec, was chosen:
the dependency is gone for good, the codec is about nine hundred lines the team reads, the bytes are proven identical, and
the measured cost is negative - the event envelope packs and unpacks in about 55% of msgpack-core's time, an 8 KB payload
in 39% and 72%, and a list of 50 records at par.
Consequences. The project owns a wire codec. The format table and the smallest-format rule are now a contract with the
Rust engine and with every language pack's library; the canonical vectors are the guard, and a change to an encoding choice is
a decision, not a refactor. An application that imported org.msgpack.* through the engine's transitive dependency declares
msgpack-core itself or moves to the new reader and writer. The three behaviour changes above hold from the release that
carries PR #517. The next version sweep covers one more pom (the module) and one more literal version (platform-core's
dependency on it). The Rust engine, the language packs and the canonical package format are unchanged, and the
msgpack-core-cve-upgrade thread closed as moot. Deferred, deliberately: extension types and the timestamp, a reusable
writer pool, and a Java 21 runtime benchmark (the report's numbers come from a Java 27 runtime on one developer machine).
ADR-0027 — Graph sets: one or more graphs in a canonical .pack, gated when packed and deployed all or none¶
Status: Accepted · Date: 2026-10-06 · Serves: vision-mercury-composable · Formalizes: graph-set-pack-and-deploy
Abstract. A graph set is one or more graph models delivered as one canonical package (ADR-0026), the file <set>.pack,
so that a pipeline can sign, store and deploy them as one certified unit. A set may hold a single graph, on purpose: packing a
graph alone is how one graph is signed (Eric, 2026-10-06). Every graph passes the deployment gate's own static checks when the
set is packed, and a packaged set deploys through the deployment manifest (sets and unpack): it is unpacked before the gate
into readable files and a generated manifest that compiles as the next manifest in sequence, and it registers all of its graphs
or none. Signing and verification stay outside the engine. Proposed as RFC-0005 in RFC.md from Eric's design notes
of 2026-10-03, decided the same day (D1 to D9) and accepted on 2026-10-06, after the first two work packages shipped in both
engines: the gate as one shared method and the command-line packager (the Java engine's PR #508; the Rust engine's PR #356,
Increment 158). The loader, the Playground's pack endpoints and panel, and the documentation follow under this decision; the
review and plan are in draft-design-specs/graph-set-packaging-and-deployment.md.
Context. Graph models deploy as loose files: graph.model.automation lists manifests, each manifest lists graph ids, and
CompileGraph registers each graph that passes the gate (ADR-0011; since 4.12.19 a list of manifests, the later one owning a
duplicate id). A certified unit of several graphs, often cross-referenced through graph.extension, has no single artifact:
there is nothing to sign, and half of it can deploy. ADR-0026 provided the deterministic package and deferred its first
consumer, the folder tooling and a loader. Every check of the gate is static on the graph JSON (structure, the root node's
purpose, an end node, the suspend and resume rules); none consults the target application's function registry, so the
checks can run wherever a graph is packed.
Decision.
- The artifact. One package is one set, in the file
<set>.pack(D1); the file name without the extension is the set name. Each graph is one entry named<graph-id>.json, whose id follows the engine's file-name rule ([A-Za-z0-9_-]+) and equals the root node'snamewhen the root declares one, checked when a set is packed and when it is read (D6). The manifest holds the packager'sformatandformat_version, the set name inset, an optionalgraph_idnaming the set's entry-point graph and verified against the entries (D5), and caller fields as text (version,description,author, a ticket or a certification reference), recorded and never interpreted. - A set holds one graph or more. The only bound is the lower one: an empty set is refused (
a set needs at least one graph). A graph packed alone gets bytes of its own to sign; several graphs packed together are signed as one unit. - The bytes depend on the graphs and the manifest fields only. Nothing comes from the clock or the environment (a build time
is a field the operator passes), and a
${...}reference is packed as written, for the environment the set is deployed to, while the gate checks a resolved copy. A graph holds no null property:"key": nullis filtered out when a set is packed or read, whateverserializer.null.transportsays, and"key": ""is a value. The same graphs and fields therefore give the same bytes in both engines, and a signature made over one pack verifies a repack anywhere; both engines' tests pin the digest of the same single-graph set. - The gate runs when a set is packed (D2). CompileGraph's checks are one shared method (Java
GraphModelGate.validate, Rustmodel_gate::validate) that the startup gate and the packager call, and the pack endpoint will, so "passes the gate" means one thing everywhere; a set that breaks any rule is refused with every reason. The startup gate stays the authority, so a check at pack time is defense in depth. - The command line (D8). Java
helpers/graph-packager(an executable jar) and Rusttools/graph-packager(a binary), thin fronts over the engine's packager and the shared gate:pack(graph files, a folder, or the graphs a deployment manifest lists, with--from-manifest),unpack(readable JSON in canonical key order) andinspect [--json](the manifest, the graphs, the size and the SHA-256); exit codes 0, 1 for a refused input and 2 for an I/O or format error. - The Playground packs on the engine (D7, D9). A packaging panel calls the dev-mode
POST /api/graph-set/packandPOST /api/graph-set/unpack; there is no packager in the browser. (Amended 2026-10-06, when WP4 shipped: the paths were first/api/graph/packand/api/graph/unpack, which collided with the executor's/api/graph/{graph_id}.) - Deployment through the manifest. A deployment manifest may list
sets(each read from<location>/<set>.pack) beside itsgraphs, and then namesunpack: afile:/folder the application can read and write (classpath:is rejected, as are the Playground's temporary folder and a folder another manifest uses). Before the gate each set is read strictly, its names are checked before any path is built, its graphs are written to<unpack>/<id>.json, and a generated manifest<unpack>/graphs.yaml, written in the open and logged, compiles as the next manifest in sequence, so the automation list stays a list of manifests (the allowlist of ADR-0011). A set registers all of its graphs or none (D3). Compile order is a manifest's loose graphs, then its sets in listed order, then the next manifest; a later graph owns a duplicate id, logged as an ERROR when a set is involved and as a WARN between loose manifests, as today (D4). A set'sversionshows inlist graphsand in the deployment log. - Signing stays outside the engine. The convention for a separate signing tool is a detached
<set>.pack.sigbeside the package;packandinspectprint the SHA-256 of the exact bytes, and the loader's first step is where a verifying hook would sit.
Alternatives. (a) A generated manifest in the unpack folder, compiled in sequence — chosen: it reuses every manifest
mechanism and leaves readable files for an operator and for import graph from. (b) Compile the unpacked maps from memory:
simpler, but an operator cannot read what was deployed and import graph from cannot find it. (c) Treat the unpack folder as a
bare deployed location: it breaks the manifest-as-allowlist rule of ADR-0011. (d) Pack in the browser: a third implementation of
the wire contract to keep byte-identical. From the decision table: registration per graph (half of a certified set deployed is
worse than none), an ERROR for every duplicate (the loose-manifest override of the prototyping loop is intended), a required
graph_id (a set of independent graphs has no entry point), a warning for a root name that differs from its id, and a command line
inside the engine module rather than its own (the separate modules leave the engine artifact unchanged and let the Java jar leave
GraalVM out).
Consequences. Both engines are committed to the .pack artifact and its entry convention, the sets and unpack manifest
schema, registration all or none, the generated manifest and the shared gate method. The bytes of a set are now a contract with
every signature made over them: a change that alters them for the same graphs and fields (a field the packager adds, another
null or normalization rule, a change of profile) breaks the signatures made over the old bytes, and the single-graph digest that
both engines' tests pin is the guard. The Rust command line is not published to crates.io (publish = false) until a release
decision says otherwise. The mercury-python and mercury-nodejs language packs are not involved. Deferred, as in ADR-0026:
verification at startup, hot reload, two versions of a set side by side, compression and per-entry hashes.
ADR-0026 — A deterministic MsgPack packager: keys sorted by UTF-8 bytes, {manifest, maps}, integrity left to the application¶
Status: Accepted · Date: 2026-10-01 · Serves: vision-mercury-composable · Formalizes: canonical-packager-wire-contract
Abstract. A set of related documents (knowledge graphs first, but equally flows, rules or tables) is packed into one
byte array whose bytes depend only on its content, in the Java and Rust engines alike. The packager sorts every map's keys
at every depth in UTF-8 byte order, packs a MsgPack map {manifest, maps} under one canonical profile, and reads it
back as ordered maps, rejecting by default any bytes that are not the canonical form of their content. manifest carries
format and format_version (written by the packager) and caller-defined string fields (by convention graph_id); maps
holds the packed maps keyed by entry name in sorted order. Integrity is not part of a package: no hash, signature, key id
or timestamp lives inside it, and the framework carries no hash mode, no verifier seam and no key; the user application
decides whether to protect the exact bytes and with which algorithm. Shipped as org.platformlambda.core.serializers.CanonicalPackager
(Java, PRs #481, #482) and platform_core::canonical_packager (Rust, the mercury repository's PR #339, Increment 147);
proposed and revised as RFC-0002 in RFC.md.
Context. Loose files cannot be promoted as one artifact: each is deployed and validated alone, JSON text is not stable (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 encodings, so two packagings of the same content can differ and their hashes with them. Both engines already use MsgPack, so the smallest useful piece is a deterministic packager. Whether a package is then hashed, signed or neither is a deployment decision.
Decision.
- The ordering is the packager's own step. The Gson serializer has no ordered-keys option, and an ordered-keys option such as
Jackson's sorts by
Stringorder (UTF-16), which differs from another engine's byte order above U+FFFF. The packager sorts after any JSON parsing and before anything is written, whatever map type comes in, so the input's own order never reaches the bytes. It writes through the MsgPack library directly, not through the platform's generalMsgPack.pack, which drops null values unless a configuration switch is set, packs aFloatas float32 and writes aBigDecimalzero as"0.00". - The canonical profile is a wire contract, identical in every engine: keys are text sorted by UTF-8 bytes (a non-text key is
converted to text; a null key or a collision is an error); nulls are kept as nil; integers use the smallest encoding (signed
64-bit only); floats are finite float64 (a
Floatis widened through its shortest decimal text; NaN and Infinity are rejected); str and bin carry the shortest header; exact numbers travel as strings (a zero of any scale is"0", per RFC-0001) and dates as ISO-8601 strings; no extension types or timestamps; any other type is rejected naming its path. The packager is faithful to a value's type (the integer 1 and the float 1.0 are different content) and applies no Unicode normalization. - Amendment (2026-10-01, before the v4.12.20 release, Eric): a
Floatis accepted. The first implementation rejected aFloat; it now treats it like any other number and widens it to float64 through its shortest decimal text (0.1fis the float640.1), which is deterministic and identical in both engines; NaN and Infinity stay rejected and a float32 on the wire still fails the strict read. The vectors changed with it (aFloatvalue vector replaced the rejection). - The package structure is
{manifest, maps},manifestsorting beforemapsso a reader can read the metadata before decoding any map; entry names are ordered by UTF-8 bytes (never a file system listing or a locale); a duplicate entry name or a caller field namedformatorformat_versionis an error.graph_idstays a documented convention that the packager never interprets. - The read is strict by default: the decoded content is re-encoded and the package rejected if the bytes differ, so an accepted package has exactly one byte form. The decoder also rejects a non-text or duplicate key, an extension type, trailing bytes and nesting beyond 64 levels.
- Byte-for-byte compatibility is proven by one shared vector file, not by a cross-engine drive.
canonical-package-vectors.jsonis byte-identical in both repositories, and its expected bytes come from an independent encoder written from the specification, not from either engine: 73 values (every integer encoding boundary,Floatwidening, header boundaries, the UTF-8 versus UTF-16 key order, exact numbers), 6 packages with SHA-256, 25 rejection cases and a seeded 60-document differential corpus. Each engine matching the file means the engines match each other, and all of it passed on the first run in both. It also pins the corners where the libraries differ:rmpvspends two units of its depth counter per nesting level, so its limit of 64 accepted only 31 nested lists where Java accepts 64; the Rust decoder enforces the bound itself and two vectors (sixty-four nested lists decode, sixty-five are refused) hold both engines to it.
Alternatives. (a) a generic canonical packager with integrity external and optional — chosen. (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. (c) canonical JSON (RFC 8785) plus an external hash: readable, but it needs a canonicalizer
in each engine and its number rules conflict with strings for exact numbers (RFC-0001). (d) a zip of files plus per-entry hashes:
readable without a decoder, but zip bytes vary with entry order and timestamps, so it needs a two-level hash. (e) leave it to
adopters: each adopter re-solves canonical encoding. An earlier draft of (a) had the engine implement a none and a hash
mode and call a route-addressed verifier for signature; that was withdrawn (Eric, 2026-10-01) so the framework owns no
algorithm, key rotation or fail-closed policy.
Consequences. Every engine that carries the packager is committed to the canonical profile as a wire contract, the
{manifest, maps} structure, the rule that integrity never lives inside the package, and the shared vector file maintained
byte-identical in lock-step; it adds one packager per engine and no dependency. The mercury-python and mercury-nodejs language
packs do not carry it: they serve functions to a Java or Rust application over Event-over-HTTP and never read a graph package.
Deferred, each its own decision when the field asks: the folder pack and unpack tooling, trusted timestamps, per-entry
records in the manifest, and a graph-set loader — the packager's intended first consumer, which would register all graphs or
none, define precedence against graph.model.automation manifests and check that graph_id names a packed graph — to be raised
as its own RFC.
ADR-0025 — Exact decimal arithmetic is a DECIMAL: statement in graph.math, with canonical strings at rest¶
Status: Accepted · Date: 2026-09-30 · Serves: vision-mercury-composable · Formalizes: decimal-statement-exact-arithmetic
Abstract. graph.math gains a DECIMAL: var -> expression statement, the high-precision
COMPUTE:. It evaluates with exact decimal arithmetic (BigDecimal in flight) and stores the
result at {node}.result.{var} as a canonical decimal string: plain notation, the computed
scale kept, a zero of any scale written "0". Rounding is always explicit —
round(x, scale, mode) with one of seven named modes; / is exact when it terminates and
otherwise uses a fixed 34-digit HALF_EVEN context; operations that cannot be exact (sqrt, log,
trigonometry, random(), PI, E) are refused by name at run time. COMPUTE: keeps its
meaning. There is no graph-level mode, no application default and no CompileGraph check:
exactness is declared statement by statement, the way CONDITION: declares a boolean. Shipped in
PR #471 (Java graph.math); the f:decimal* plugins and the Rust twin follow against the same
shared conformance vectors. Proposed and revised as RFC-0001 in RFC.md.
Context. Knowledge graphs are a natural home for financial rules, where money and rate math
must be exact and reproducible across runs and engines. Every numeric path was an IEEE double or
a long (0.1 + 0.2 gave 0.30000000000000004; f:div(7, 2) gave 3). The earlier ruling
(2026-09-25) was that exact-decimal money belongs in a graph.task function and BigDecimal
stays out of the dialect. That keeps the math package minimal but hides the formulas in code,
while the point of a knowledge graph is that the product owner reads and certifies the rules on
the graph. This decision reopens that ruling deliberately and supersedes its decimal half;
its other rulings (typed, finite COMPUTE; booleans never numbers) stand.
Decision. Three findings from reading the code fixed the shape. (1) graph.math renders each
{selector} into the statement text before parsing it, so the evaluator sees literals and never
a Java type — the read rule lives in the evaluator. (2) The state machine does not keep Java
types across graph.suspend, graph.resume or any event hop (a BigDecimal comes back a string,
a small Long an Integer), so a decimal is stored as its canonical string and is the same
before and after. (3) A graph-level mode would carry the result type and a place to declare
exactness, which a statement-level tag does with nothing to configure and nothing to mix.
Consequent rules:
- Numeric strings compare as numbers in
== != < <= > >=everywhere (COMPUTE:,IF,CONDITION,DECIMAL:), exactly:'200' == 200; two distinct 20-digit ids never collapse into equal numbers. A READ item: a graph that compared numeric-looking strings as text changes. - A
Doubleoperand is accepted through the shortest decimal text it prints as, at its minimal scale. This is lenient by decision and declared in the guide, not enforced: a double already computed in floating point is only as exact as that computation, so sending money as strings is a conscious choice, andf:validateasserts it. - Parity is enforced by shared test vectors (151 in the first delivery) run in every executing engine, beside the drift-tested claims registry.
BigDecimalis never added toCOMPUTE:'s value model, and never travels as a JSON or MsgPack number.
Alternatives. (a) the DECIMAL: statement — chosen. (b) Replace double with decimal
everywhere: changes every result's type and rendering. (c) A decimal function library for
graph.task — the prior ruling: no engine change, but formulas become opaque code and each
adopter reinvents rounding and serialization. (d) Fixed-point integer minor units: awkward for
rates, percentages and mixed currency scales, and division still needs a rounding policy.
(e) A graph-level numeric: decimal mode: zero compatibility risk, but more to configure and a
mixed-mode limit between graphs that CompileGraph cannot see. (f) Type-directed arithmetic with
nothing declared: a graph passing numeric strings into arithmetic would flip from a number to a
string result.
Consequences. Every executing engine is committed to one written decimal specification
(statement, result scales, division context, rounding modes, canonical string form, strings at
rest, comparison rule) and to the shared vector file maintained in lock-step. The math package is
no longer minimal in the sense of the 2026-09-25 ruling; money that needs exact decimal
arithmetic or a stated rounding mode now belongs in a DECIMAL: statement, and a graph.task
function remains for what a statement cannot express. The dialect round was aligned to half up,
away from zero, the same as f:round (PR #472), closing the side finding. Open follow-ups: the
f:decimal* plugin family, the Rust twin (an arbitrary-precision crate; bigdecimal is the
candidate). The mercury-python and mercury-nodejs language packs need no engine change: they serve functions to a Java or Rust application over Event-over-HTTP and never evaluate a statement or a plugin.
ADR-0024 — The elastic queue spills to a dependency-free file FIFO, and that choice sets the dispatch model¶
Status: Accepted · Date: 2026-09-16 · Serves: vision-mercury-composable · Formalizes: elastic-queue-file-store
Abstract. Every route's back-pressure overflow buffer (ElasticQueue) holds the
first 20 events in memory and spills the overflow to a per-route sequence of
append-only segment files under the temp directory (FileElasticStore), with a segment
deleted as soon as it is fully read. The spill tier is transient — nothing survives a
restart — and carries no third-party dependency. The Berkeley DB JE store that preceded
it, and the elastic.queue.store switch that selected between them, are retired
(v4.12.10); so are the deferred.commit.log property and the elastic.queue.cleanup
reserved route, which existed only to serve it. ElasticStore remains as the interface
seam, now with a single implementation.
The second half of this decision is the consequence that makes it architectural rather than an implementation detail: because the file store's blocking I/O parks a virtual thread instead of pinning its carrier, every route dispatches off the event loop on its own virtual thread. The Vert.x consumer enqueues to a bounded per-route mailbox and a per-route virtual thread runs the state machine and the spill I/O. There is one dispatch mode; the store and the dispatch model are not independently configurable.
Rationale. Berkeley DB worked and, on a single isolated route, benchmarked
competitively — faster on some scenarios. That is precisely why the decision needed
evidence beyond a microbenchmark. In a real multi-route service sharing one Vert.x event
loop, a carrier-pinning store forces spill I/O to run inline on that shared loop, so a
burst on one route inflates the tail latency of unrelated latency-sensitive routes. The
measured comparison (retained at benchmark/benchmark-reporter/analysis/) showed +56%
throughput, a ~47× better write p99.9, and stalls over 20 ms falling from 90 to 3 — but
the decisive result was the mixed-workload probe, which isolates exactly the property a
single-workload benchmark cannot reveal.
Alternatives considered. Keep both stores permanently — rejected: two spill
implementations, two dispatch modes, and a configuration combination
(virtual threads + a pinning store) that had to be made unreachable by construction, all
to preserve a fallback the field never needed. Keep the switch but default to file —
this was in fact the transitional state, carried deliberately through one field-canary
period so installations could flip back with one property; it ended when no installation
reported an ElasticQueue issue on the file store. Make durability an option — rejected
as a category error: the buffer exists to absorb a slow consumer within one process
lifetime, and a durable spill tier would imply replay semantics the event system does not
offer.
Consequences. platform-core sheds its com.sleepycat:je dependency, narrowing the
dependency surface that field security scanning must clear. ServiceQueue loses its
dual-mode branch. An application that still sets elastic.queue.store is unaffected in
behaviour — the property is simply no longer read — because the value it would have
selected is what now runs unconditionally. The remaining tunables are
elastic.queue.segment.size.bytes and elastic.queue.dispatch.mailbox.size; the one
measured blemish of the file store, a rare OS dirty-page-flush outlier, is removed by
pointing the temp directory at tmpfs.
ADR-0023 — Claims-fixture gate: documentation behavior claims are drift-tested¶
Status: Accepted · Date: 2026-09-06 · Serves: vision-mercury-composable · Formalizes: claims-fixture-gate
Abstract. High-value prose claims about engine behavior (defaults, precedence,
thresholds, failure semantics) are registered in docs/guides/claims-registry.json. CI
verifies both sides of every claim: the normative sentence must still appear on its page
(doc-canon check 8, whitespace-normalized case-insensitive containment) and the named
engine test must still exist as a method definition — while the tests themselves pin the
behavior in the normal build. A registered claim states exactly what its test pins.
Context. The AI grammar coverage study (2026-09-06) found all ten of its documentation
drift items in ungated prose, while the gated surfaces (DSL catalogs, guide fixtures,
golden vectors) held almost perfectly. The Rust edition's divergence-note mechanism had
even observed one drift item and documented around it instead of failing a build. Ten
registry entries would have caught every finding. Alternatives considered: line-number
pins (rot with every edit) and AST/NLP documentation parsing (over-engineering); chosen:
distinctive quoted substrings plus test-definition existence checks — deterministic and
stdlib-only. The registry is itself a grammar asset: it ships inside the version-matched
contract and is listed in llms.txt, so an AI agent may treat registered claims as
source-verified.
Consequences. "Verified" extends from catalog shape to stated behavior. New claims
enter through the same friction → fix → gate feedback circuit that grew the grammar
(ten to twenty high-value claims is the intended steady state, not hundreds). The format
is engine-neutral: the Rust engine carries its own registry when the held sibling sweep
unblocks. Seeded with eleven claims pinned by seven new Claim*Test classes and four
existing tests.
ADR-0022 — graph.js is deprecated: backward compatibility only¶
Status: Accepted · Date: 2026-09-02 · Serves: vision-mercury-composable · Formalizes: graphjs-phase-out-direction
Abstract. The graph.js skill is deprecated. It remains shipped for backward
compatibility while existing field applications refactor off it, but new graphs must not
use it and AI agents are instructed never to generate it. The sanctioned pattern is
graph.math for decisions plus graph.task for anything the safe dialect cannot express.
Context. graph.js evaluates JavaScript at runtime (GraalVM) — code injection by another name, which fails enterprise security review; the field is already refactoring it out. A 2026-09 field AI-agent exercise additionally reported a silent-correctness defect (equality comparisons with quoted string literals evaluate to false without any error, mis-routing IF branches) and demonstrated that the graph.math + graph.task rebuild is more legible — rule order becomes visible nodes instead of a script. Per the standing phase-out direction (2026-07-31), the skill is contained, not hardened: the defect is documented, not fixed. The Rust engine never carried graph.js, so deprecation also closes an engine-parity gap. Removal lands in a future major release.
Consequences. Deprecation banners in the skills reference and AI agent guide; the CompileGraph gate continues to accept existing models; graph.js work remains excluded from Rust lock-step scope.
ADR-0021 — The companion endpoint is synchronous only: the fire-and-forget sibling is retired¶
Status: Accepted · Date: 2026-09-02 · Serves: vision-mercury-composable · Formalizes: (rides ADR-0008's constraint)
Abstract. POST /api/companion/{id} (fire-and-forget, {status:"accepted"}) is
retired; POST /api/companion/{id}/sync (ADR-0008) is the only companion endpoint. The
bare URL now answers 404 through REST automation; /sync is unchanged, so field drivers
need no migration.
Context. ADR-0008 added /sync as an additive sibling so AI agents see command
outcomes in-band. A 2026-09 field AI-agent exercise showed the async endpoint is an
attractive trap: the guide dedicated to the companion documented only the async form, and
an agent that followed it was blind to errors, scraped a browser console for results, and
sleep-padded every command (measured: 12 s vs 0.1 s for a 25-command build). The field has
driven /sync exclusively for multiple sprints, so the maintainer retired the async form
outright rather than keeping a documented-legacy hazard. Re-pointing the bare URL at sync
semantics was rejected: old fire-and-forget scripts would silently receive a different
response contract; a loud 404 is the unambiguous retirement.
Consequences. One endpoint, one contract, no ambiguity for agents or docs; a breaking change note rides the next release; the Rust engine retires its twin in lock-step.
ADR-0020 — Route pools: numbered singleton lanes as a first-class platform registration¶
Status: Accepted · Date: 2026-08-30 · Serves: vision-mercury-composable · Formalizes: route-pool-registration-design
Abstract. Platform.registerRoutePool(prefix, lambda, count) registers a set of
private singleton routes {prefix}.{n} for n = 0 to count-1 and returns the member
names in order; releaseRoutePool(prefix) removes the set symmetrically. Each member is
a strict FIFO lane (instances=1, one shared stateless lambda), so a caller that checks
out a lane gets per-conversation event ordering while other lanes serve concurrent
traffic — the pattern the HTTP edge's SSE reply lanes (async.http.response.stream.{n})
introduced, promoted from an open-coded loop in AppStarter to a platform API with
registry-level identity (a pool registry mapping prefix to lane count). Pools are always
private: lane checkout is an in-process rendezvous, so advertising members to the
service mesh would be meaningless. Registering an existing pool reloads it (the previous
member set is released first, house reload semantics); individual register/release calls
that touch a pool member are warned, never refused — range-checked, so a neighbor route
such as {prefix}.10 beside a count-3 pool is never misclassified. Pool mutations are
atomic under a ReentrantLock (virtual-thread-friendly on Java 21).
getLocalRoutingTable() is deliberately untouched: it remains the truthful live
registry consumed by mesh route advertising and Spring autowiring; the compact pool
rendering stays display-only in the actuator (compressRouteFamilies).
Rationale. The v4.12.0 streaming milestone shipped the lane-pool pattern without an
abstraction: 500 registerPrivate calls, no release counterpart, and no way for the
platform to tell a pool from 500 coincidentally numbered routes; upcoming consumers
(graph-run streaming, wrapper relay pools under the AI SDLC work) would each re-open-code
it. Alternatives rejected: naming the API registerStreams (one character from the
existing registerStream(String, StreamFunction) with entirely different semantics —
and the abstraction is a pool of ordered lanes, not a stream); collapsing pool members
inside getLocalRoutingTable() (the collapsed display key is not a valid route name,
and the table has functional consumers — the Kafka mesh advertises from it, rest-spring-4
autowires from it); an isPrivate flag (no remote use case exists, and the platform
expresses privacy as method pairs, never booleans); renaming the lane family to
http.response.stream.{n} (the lanes are sibling instances of async.http.response —
renaming the child while the parent stays would split the async.http.* namespace
family, and the name already shipped in traces, actuator views and docs).
ADR-0019 — The HTTP client consumes SSE progressively; Event-over-HTTP streams on the same call¶
Status: Accepted · Date: 2026-08-29 · Serves: vision-mercury-composable · Formalizes: async-http-client-sse-streaming-design
Abstract. async.http.request consumes a text/event-stream response progressively
and relays it as the platform's own streaming protocol: one x-event-stream: data
envelope per upstream SSE event to the caller's reply route (the event's data as body,
event: name as x-event-name, head control - upstream status plus the SSE content
type - on the first envelope), eof on a clean end, and an in-band exception on idle
expiry or a mid-stream disconnect. Activation is explicit and standard: the request must
declare Accept: text/event-stream, the response must actually be SSE, and the request
must carry a reply_to; anything else keeps the buffered single-shot behavior. For a
stream, the request timeout becomes the per-read idle allowance (any upstream bytes,
keep-alive comments included, reset it). Payloads are never interpreted - provider
conventions such as data: [DONE] forward verbatim, keeping the client vendor-neutral.
Because the streaming producer contract is the one the HTTP edge already consumes, a
streaming endpoint's function can forward its own reply_to and correlation id into the
client call and the application becomes an SSE-to-SSE relay by configuration. The same
enhancement is the transport for Event-over-HTTP peer streaming (python/node wrapper
functions and engine⇄engine): the peer's /api/event answers the SAME call with an SSE
response using a hybrid control/data framing - control signals (the head, eof,
exception, and any segment that cannot round-trip as text) ride base64-encoded MsgPack
envelope frames under the reserved SSE event name envelope, while token segments ride
raw SSE frames with near-zero overhead - negotiated by the same Accept contract, so
version skew degrades explicitly, never silently.
Rationale. Progressive delivery had reached the HTTP edge (ADR-0018) but not the two consumption paths AI-era workloads need: an engine function consuming an LLM provider's token stream, and a polyglot wrapper function streaming results back to the engine. One mechanism closes both because the Event-over-HTTP relay already flows through AsyncHttpClient. Alternatives rejected: an on-demand WebSocket channel (breaks the wrapper scope fence, grows four codebases, historically gateway-hostile, and drifts toward the standing-connection mesh the framework keeps opt-in); per-segment POSTs back to the reply lane (FIFO forces serialized posts - one round trip per token - and opens an inbound path to reply lanes); gRPC/HTTP-2 push (a foreign dependency stack); and long-polling (chatty and stateful). Streaming on the response of the engine's own request adds no inbound surface, is ordered by TCP for free, and rides the wire shape gateways already accommodate for LLM traffic.
ADR-0018 — HTTP response streaming rides the multi-shot reply route; the wire stays standards-only¶
Status: Accepted · Date: 2026-08-28 · Serves: vision-mercury-composable · Formalizes: http-response-streaming-design
Abstract. A function streams an HTTP response (LLM token segments, agent progress
events, live updates) by exercising the platform's native streaming pattern: the callee
sends a sequence of events to the caller-provided reply route until an
end-of-transmission signal. Each event carries the reserved envelope header
x-event-stream: data | eof | exception (the ObjectStream vocabulary); the marker is
internal protocol consumed by the REST automation edge — like x-stream-id and x-ttl,
it never appears on the wire. The public HTTP surface is standards-only: Server-Sent
Events framing when the content type is text/event-stream (typed events, a terminal
done event carrying trailing metadata, in-band error events, keep-alive comments),
chunked transfer with JSON Lines otherwise. A streaming endpoint is declared with
stream: true in rest.yaml, which checks out a dedicated ordered reply lane for the
request's lifetime — a single-instance route (async.http.response.stream.{n}) drawn
in FIFO rotation from a pool of 500 (the async.http.response concurrency; refined
2026-08-31 from the original LIFO stack so consecutive requests take successive lanes
and a released lane, rejoining at the tail, rests longest before reuse), returned when
the request context closes — a rotating variant of the "ready" signal pattern of the
reactive manager/worker design. All segments of one request ride its own lane (strict FIFO) while different
requests stream concurrently through their own lanes; an exhausted pool rejects further
streaming requests immediately with HTTP-503 (deterministic back-pressure, no
configuration knob). The first event commits the response head; each
arrival extends the idle timeout; stalls fail in-band; client disconnects turn late
segments into no-op drops; a bounded drain-aware buffer guards slow clients. Responses
without the marker are single-shot, exactly as before, and the legacy x-stream-id
relay is untouched.
Rationale. The prerequisite for AI-era workloads is progressive delivery over plain
HTTP — SSE is the de facto wire for chat token streams (OpenAI, Anthropic, Gemini and
every compatible server), agent progress protocols (MCP Streamable HTTP, A2A), and the
live-watch window of long-running workflows. The alternative — building on the existing
Flux/x-stream-id relay — was rejected: the producer API is Reactor-typed and
JVM-only (invisible to Event Script, knowledge graphs, and the polyglot wrappers, which
is exactly where LLM tokens will come from), structured segments buffered at the edge
instead of streaming, and the relay has no SSE framing or in-band terminal events. The
multi-shot reply route adds no new substrate — anything that can send an envelope to a
route can stream, which keeps the mechanism language-neutral by construction: flow
tasks, graph nodes, and Event-over-HTTP peers join by sending the same envelopes. A
custom HTTP header was rejected in favor of the envelope marker so the wire stays fully
standard (RFC 6648 discourages new X- wire headers; the envelope already has a reserved
x- vocabulary).
ADR-0017 — Spring Boot integration targets Boot 4 only; the Boot 3 lane is retired¶
Status: Accepted (2026-09-18) · Date: 2026-08-27 · Serves: vision-mercury-composable · Formalizes: stack-integration-spring-boot4
Abstract. The optional Spring Boot integration is provided by a single module,
system/rest-spring-4 (with examples/rest-spring-4-example as its reference
application), targeting Spring Boot 4. The Spring Boot 3 lane — system/rest-spring-3
and examples/rest-spring-3-example — is removed from the reactor. The integration
surface carries over unchanged: the RestServer bootstrap, the spring.boot.main
override, @PreLoad autowiring, and the same configuration keys — so migrating an
application is a dependency swap plus that application's own Spring Boot 3 → 4 upgrade.
Spring remains optional and is never required by platform-core: the lightweight built-in
non-blocking HTTP server stays the default.
Rationale. The Spring community stopped issuing security patches for Spring Boot 3,
and field deployment pipelines enforce that directly: dependency security scanning
(Snyk) rejects Spring Boot 3 dependencies outright and requires Spring Framework 7 or
newer, so a build carrying the Boot 3 lane blocks deployment. Continuing to ship a
Boot 3 integration would hand field installations a permanently-unpatchable web
dependency lane — the opposite of the framework's security posture, where field
deployments are gated by dependency scanners (cf. the netty and lz4 remediation
rounds). Maintaining two lanes also doubled every Spring upgrade sweep
(both spring-boot-starter-parent versions, two example applications) while exposing
the same integration surface. The alternative — freezing rest-spring-3 for legacy
consumers — was rejected: an EOL web stack is a liability regardless of freshness of the
rest of the build, and the migration cost is a dependency swap. Applications that must
stay on Spring Boot 3 can remain on Mercury releases up to this one.
ADR-0016 — Polyglot functions are Event-over-HTTP peers, not subprocesses or ports¶
Status: Accepted (2026-09-18) · Date: 2026-08-22 · Serves: vision-mercury-composable · Formalizes: polyglot-event-over-http-design
Abstract. Functions written in Python and Node.js join Event Script flows and
MiniGraph knowledge graphs as long-lived Event API peers: each official wrapper
(mercury-python,
mercury-nodejs) hosts POST /api/event
with the engines' exact semantics and speaks the standard envelope wire format, verified
against the golden conformance vectors shared by the Java and Rust engines. The engine
addresses a polyglot route through the existing declarative yaml.event.over.http map —
non-blocking on the JVM (the relay is an interceptor; no thread is held per in-flight
call) — so a flow task or graph.task node calls a Python or Node.js function exactly as
if it were local, with trace context, the my_cid → my_correlation_id injection, and
the portable error contract (handler errors ride HTTP 200 with envelope status; transport
errors keep 400/403/404/408 with engine-identical messages) intact. The wrapper scope is
fenced: envelope codec, Event API host, preload registry, thin PostOffice client, a
primitive in-process event bus (per-route FIFO mailboxes with faithful instances; no
spill tier, no queue cap), the engines' actuator endpoints, the minimalist utilities
(configuration with the resources/ convention and -Dkey=value overrides,
engine-format logging with log.format=text|json|compact, trace context), and a dev
runner — no orchestration: flows, graphs, persistence, and pub/sub stay on the
engines. The single engine change is the graph.task route-existence guard consulting
the Event-over-HTTP map (Java + Rust lock-step, shipped in v4.11.11). The wire
conformance vectors are the acceptance gate for every wrapper, and each wrapper release
extends the interop test report.
Rationale. The alternative designs were a full language port and an
engine-managed subprocess runner, and both were investigated. A full port re-implements
the composable core (event bus, flow engine, graph engine) per language — the Node.js
legacy port demonstrated the cost: it fell ~2 years behind and was retired. A
subprocess runner (functions as child processes over stdio) was prototyped and shelved:
on JDK 21 pipe I/O and Process.waitFor pin virtual-thread carriers, forcing
kernel-thread isolation per in-flight call, plus process-tree lifecycle management and
per-call interpreter startup — an operational stability surface the peer model does not
have, with the niche benefit (single-artifact embedded scripting) deferred until field
demand exists. The peer model reuses what already works: the function contract is
route-name + envelope (nothing in it is Java), the Event API endpoint already carries it
across instances and across the Rust engine, and the declarative map already abstracts
location. Keeping orchestration out of the wrappers preserves the architecture's one
boundary — the engine tier owns sequencing, retries, and back-pressure (a leaf host
fails fast by deadline instead of hoarding work) — and keeps each wrapper small enough
to stay in lock-step through a conformance suite rather than a porting effort.
Engine-consistent utilities and actuator endpoints are part of the decision, not
convenience: polyglot installations put every language's telemetry, logs, probes, and
dashboards in front of one DevSecOps team, so presentation parity is a field
requirement. The Node.js wrapper is also the sanctioned answer to the retired legacy
port — a fresh re-port was never going to stay current; a thin protocol wrapper can.
ADR-0015 — AI discovery is a standalone composable app, not a runtime dependency¶
Status: Accepted (2026-09-18) · Date: 2026-08-21 · Serves: vision-mercury-composable · Formalizes: thread-ai-contract-provider
Abstract. Mercury serves a version-matched operational contract for AI agents from a
standalone composable app, system/ai-contract-provider: read-only REST discovery endpoints
(port 8999) wired rest.yaml → Event Script flow → function, plus a local --export mode
that writes the offline mercury-platform Agent Skill with a hash manifest written last.
The dependency arrow points into the app — it depends on platform-core and
event-script-engine; no framework module depends on it, and no framework module changes.
Contract claims live in one contracts.yaml catalog whose behavior anchor classes are
resolved by Class.forName in the module's tests (MiniGraph anchors through a test-scope
dependency), so a renamed behavior class fails the reactor build. The served
mercury_version is read from the platform-core dependency's own pom.properties, and
startup refuses a mixed framework assembly.
Rationale. The motivating defect was documentation drift: the guides taught a
rest.yaml flow binding (flow: without service:) that the production RoutingEntry
parser rejects, and nothing tied the docs to the runtime. An earlier design packaged the
contract as a library that platform-core, event-script-engine, and the playground engine
all depended on, with ServiceLoader providers and playground chat commands. That inverted
the dependency graph (the lean core acquired a docs artifact), broke in Spring Boot
packaging (CodeSource-based resource reads), and leaked a Java-only command surface into
the playground webapp and command catalog that the Rust engine also ships — a cross-engine
parity break. A standalone app keeps the framework untouched, reads resources only through
the classloader (packaging-independent), keeps discovery off the shared playground surface
(no Rust lock-step obligation — the REST API itself is the portable contract), and is
itself a reference implementation of the composable pattern it documents. Since all
Mercury modules release together under one version, a build-verified static catalog gives
the same drift guarantee as runtime provider discovery with far less machinery; filesystem
export stays a local operator action rather than a remote capability.
ADR-0014 — Generic exception context: one handler serves every exception= route¶
Status: Accepted (2026-09-18) · Date: 2026-08-10 · Serves: vision-mercury-composable · Formalizes: thread-field-graph-scoped-state-and-error-context
Abstract. When a failed node routes to its exception= handler, the walkers
(GraphExecutor and GraphTraveler — one staging site each, covering every skill) stage a
generic exception context in the state machine: error.source (the failing node's
alias), error.code, error.message, and error.stack when the failing envelope
carries one. The names follow Event Script's flow exception contract
(error.code/message/stack) so both orchestration layers share one vocabulary; source
is the graph-side addition, since a graph handler — unlike a flow's — is a node that many
other nodes may name. The per-node error record ({node}.status/{node}.error, now plus
{node}.stack) is unchanged, so existing handlers keep working. error is a reserved
node alias (it always was, in the graph model's reserved-name list) — the namespace is a
first-class state-machine citizen like model, inspectable in a dry-run with
inspect error. A shared handler is island-anchored (root → island → handler, the
ADR-0012 anchoring idiom) because exception routing is a jump; it is entered at most once
per run unless RESET, and it may connect onward to further processing nodes.
Rationale. The field demo showed graphs cluttered with per-fetcher handler clones —
structurally forced, because the engine staged failures only under the failing node's own
scratch ({node}.status/{node}.error), so a handler's data mapping had to name its
failing node statically. Staging at the walkers' exception choke points (rather than in
each skill) covers graph.api.fetcher, graph.task, graph.extension and the
suspend/resume store failures in one move, with no per-skill drift. For an extension
node, error.source is the extension node in the parent graph — failures inside a
delegated subgraph route to that subgraph's own handlers, composing with ADR-0013's
self-containment. Event Script parity naming was chosen over graph-local naming
(error.status/error.error) for cross-layer consistency and because error.error
reads badly. The context is transient per run and never persisted across suspension.
ADR-0013 — A business transaction spans graphs: workflow-state records are scoped by graph + cid, and delegation inherits the correlation ID¶
Status: Accepted (2026-09-18) · Date: 2026-08-10 · Serves: vision-mercury-composable · Formalizes: thread-field-graph-scoped-state-and-error-context · Amends: ADR-0010, ADR-0012
Abstract. The suspend/resume state-store contract is scoped by graph + cid: the
persistence envelope gains a graph field ({cid, graph, node, ttl, model, seen, run}),
the retrieve body becomes {cid, graph}, and the Redis reference implementation keys
records graph:{graph_id}:{cid} (formerly graph:state:{cid}) — with a third :{index}
segment when the graph runs as one iteration of a parent's for_each fan-out. Key
composition remains store-internal, but every store MUST scope by graph + cid — a cid-only
key collapses all of a transaction's suspensions into one record — and MUST append the
iteration index when the caller supplies one. Complementarily, graph.extension stamps
the parent's business correlation ID (model.cid) on the delegated call — both
protocols (graph:// id and flow://), both branches (single and for_each) — exactly
as an Event Script sub-flow launch already did, closing the asymmetry where a subgraph's
model.cid was an unusable per-call random UUID. Together these make suspension
self-contained per graph by construction: one business transaction may suspend
independently in each domain's graph and in each subgraph, a resume only ever sees its
own graph's records, and an orchestrator parent can delegate independently resumable
subgraph paths (the documented orchestrator pattern, pinned by the
unit-test-orchestrator/unit-test-sub-suspend reference models). Breaking change
(accepted, dev-phase — no legacy fallback): records persisted under the old key are
invisible after upgrade and resume behaves as fresh; custom stores must adopt the
graph field.
Rationale. The field demo drove both halves: the same business correlation ID is used
across multiple domains, so graph:state:{cid} collided across domains sharing a Redis —
and their orchestrator use case (a parent graph delegating suspend/resume per processing
path) was structurally impossible, for two independent reasons the code study confirmed:
the key collision, and graph.extension minting a random UUID per call with no
business-cid header, so a subgraph's resume could never find its record even with scoped
keys. The graph ID is unique per domain, so graph + cid addresses both the domain and
the subgraph requirement with one convention. Scoping is enforced in the contract (the
reference stores fail fast on a missing graph) rather than by engine-composed opaque
keys, keeping key layout a store concern.
The for_each case was first left as a documented caveat — one graph × one cid = one
record, so a suspendable subgraph was assumed to be invoked once per cid per run. The
field disproved the assumption while this ADR was still proposed, and it is folded in here
rather than split into a separate decision, because the cid inheritance decided above is
exactly what makes the iterations collide: a parent invoking a suspending subgraph through
graph.extension with for_each forks N concurrent invocations under the one inherited
cid, so the suspensions raced and only the last survived. The array index therefore joins
the key as a third segment — carried on the invocation's header into a reserved
model.iteration_index, and appended only when present, so single delegations and records
written before the change are untouched. Index alone, not index-and-size: a size guard would
have failed the ordinary case (appending an item to a list) while never catching the
corrupting one (reordering). The accompanying constraint is declared, not enforced — the
parent's array must hold positional consistency (appending is safe; reordering, inserting or
removing is not) — and parent → for_each → flow:// → suspending graph is out of scope.
(Shipped 2026-09-18: spec PR #415, implementation PR #418, doc sweep PR #420.)
ADR-0010's cid-as-capability note extends naturally: one cid now unlocks resume in every graph that suspended under it, scoped per graph id, with endpoint authentication unchanged.
ADR-0012 — Suspension is a destination: edge-mode checkpoints and jump-mode decisions replace the suspend=true property¶
Status: Accepted · Date: 2026-08-07T22:50:34.000Z · Serves: vision-mercury-composable · Formalizes: thread-suspend-resume-rationalization · Amends: ADR-0010
Abstract. A suspension point is declared by how a node reaches the reserved
suspend node, not by a node property — the suspend=true property is retired
(accepted and ignored for one deprecation window, with a compiler WARN). Two modes,
discriminated purely by graph shape: in edge mode, a working node with a drawn
edge to suspend redirects there when its skill completes normally — the drawn edge is
the declaration; the node must keep at least one continuation edge, where a resumed run
continues without re-executing the node (byte-for-byte the prior suspensible
behavior). In jump mode, a decision (graph.math) returns suspend from its
IF-THEN-ELSE; on resume the decision is re-executed against the new request input, so
it re-decides on every resume — a wait-on-invalid-input loop is one jump with no
auxiliary nodes. A routing-skill node must not draw an edge to suspend (its drawn
edges are outcome alternatives; the gate rejects the shape with a teaching error), the
suspend node cannot be an exception handler (exception=suspend rejected —
checkpoint-on-failure would smuggle in retry semantics), and a jump-only suspend node
is anchored behind an island (root → island → suspend) to satisfy the no-orphan export
rule — an island's outgoing edges are never traversed. The persisted record contract
({cid, node, ttl, model, seen, run}) and the store put/get contract are unchanged.
Rationale. Two independent field teams hit the same conceptual wall within days: a
suspensible node ignores IF-THEN-ELSE routing and suspends unconditionally, which reads
as an inconsistency between decision-making and suspension — documenting the
decide-before-you-suspend rule (the first team's remedy) did not stop the second report,
so the constraint itself was the defect. The code study showed the property was only a
walker-level routing trigger — persistence was already predecessor-agnostic (the record
stores whoever routed in) and resume already continued along the persisted node's forward
links — and it was papering over two latent divergences: the documentation already
claimed "a plain edge into suspend is an unconditional suspension point" while the walker
actually fanned out (checkpoint and continuation in parallel), and a jump to suspend
half-worked but resume then fanned out a decision's outcome alternatives as if they were
branches. Shape discrimination was chosen over skill-class discrimination (both were
designed) because it is cheaper — one forward-link probe at resume, no statement
inspection at the gate — and because the classes coincide by construction: working
skills always return next so they can only be edge mode, and only routing skills can
jump, so re-execution can never touch a working node. Back-compat is structural: every
valid pre-change model necessarily drew its checkpoint edge (the prior gate required it),
so edge-inferred redirect reproduces the old behavior exactly and pre-change store
records replay correctly under the new engine — mixed-version fleets are safe in both
directions. Alternatives rejected: keeping the property alongside edge inference (two
declarations for one behavior — the confusion this fixes); statement inspection to let a
suspend-only decision pass the gate (computationally expensive and fragile); uniform
re-execution on resume (would double-execute working checkpoints, breaking the
at-most-once actor-step guarantee).
ADR-0011 — CompileGraph is the mandatory deployment gate for graph models (CompileFlows parity)¶
Status: Accepted · Date: 2026-07-29T19:03:28.000Z · Serves: vision-mercury-composable · Formalizes: compilegraph-mandatory-gate
Abstract. A deployed graph model is executable at POST /api/graph/{graph-id} only
when it is listed in the graph manifest (graph.model.automation) and passes the
CompileGraph quality gate at startup — a graph that fails the gate, or is not listed,
answers HTTP-404 as if the model does not exist, and the lazy, per-request loading of
deployed models is removed. Like flows.yaml, the manifest carries the location of its
own models (an optional location entry, default classpath:/graph) — there is no
separate application property. Validation follows two explicit lanes: production =
models → CompileGraph → deployed graphs → GraphExecutor, which trusts the gate and
drops per-request re-validation of gate-guaranteed rules, keeping only data-driven runtime
guards (store-record contents, dynamic jump targets, loop detection); dry-run = drafts
in the temp workspace → UI CLI input validation at node create/update → GraphTraveler with
full runtime validation. The gate's whole-graph rules live in a reusable
GraphModelValidator, which the playground's run command also invokes as a pre-run
quality check — draft authoring deliberately allows partial models, but the moment the
author asks to run, the contract must hold.
Rationale. This is the CompileFlows precedent applied to Layer 3: an invalid flow
never becomes executable, and the graph engine now gives the same guarantee — previously a
manifest graph that failed validation could still be resurrected by the lazy-load
fallback and executed unvalidated, which is untenable for field production. Compiled-or-404
(identical for failed and unlisted models) leaks nothing about why a model is absent, and
turning the deploy folder into a pure data directory removes it as a direct execution
vector. Startup-time rejection converts an entire class of runtime stalls and mid-run
errors (missing end node, checkpoint without a continuation edge, dead-end suspend node)
into immediate, logged deployment failures — while the same rules surface to graph authors
at dry-run run time, so the deployment contract is learned in the playground, not
discovered in the field. The consequences are accepted deliberately: the manifest is now a
requirement (a one-line migration for installations that relied on lazy loading, with
the classpath:/graph default preserving existing layouts and an obsolete-key warning for
the retired location.graph.deployed property), hot-dropping a JSON file into the deploy
folder no longer works (deployment is an explicit, restart-scoped act — consistent with
the governance lifecycle the Vision calls for), and the walkers' suspend/resume guards are
now exercised end-to-end only on the dry-run lane (the static validator carries the
per-rule coverage).
ADR-0010 — Graph workflow suspension: short runs + an external state store, encapsulated in skills¶
Status: Accepted (amended by ADR-0012: the suspend=true declaration vocabulary is retired in favor of edge/jump modes; short runs, the store contract and the record envelope stand) · Date: 2026-07-29T02:00:00.000Z · Serves: vision-mercury-composable · Formalizes: graph-suspend-resume-design
Abstract. A long-running business process with human checkpoints (approval,
intervention, inbox notification) is expressed as a sequence of short graph runs: at a
suspension point the run persists its workflow state — the model namespace plus
traversal bookkeeping — to an external state store keyed by the business correlation
ID with a designer-chosen TTL, then completes normally; a later request with the same
correlation ID restores that state and continues past the checkpoint without re-executing
it. The mechanics are encapsulated in two skills — graph.suspend and graph.resume,
supersets of graph.task that invoke a pluggable store function named by the node's
task property with a fixed put/get contract — so suspension nodes carry no data
mapping. The node alias suspend is reserved (the root/end pattern): traversal
routes to it by name when a node marked with the reserved property suspend=true
completes; node types (Suspend/Resume/Suspensible) remain visual convention —
the skill defines behavior. Store retrieval consumes the record atomically
(at-most-once resume); reserved model keys never persist; a suspension point must be the
sole active branch.
Rationale. Parking a live graph instance for a multi-day approval would pin memory,
defeat the flow ttl, and not survive a restart — the short-run model keeps the engine's
in-memory instance lifecycle untouched and makes cross-instance resume free (any pod
sharing the store can continue the workflow). Skill encapsulation was chosen over
node-level data mapping because the mapping variant required special-casing the mapping
grammar per node type and left the resume jump-target with no channel; a fixed store
contract also makes the persistence seam documentable and replaceable (Redis ships as an
optional extension module — never an engine dependency; engine tests use a temp-file
store). The reserved-alias routing reuses the existing jump-by-name directive vocabulary
instead of introducing edge classification, at the accepted cost of one suspend node per
graph. Consume-on-retrieve was preferred over keep-until-TTL so a duplicate resume cannot
double-execute a continuation; workflows needing stronger crash guarantees may implement
keep-until-ack semantics in a custom store. Alternatives rejected: engine-managed timers
or parked instances (memory + restart fragility); reusing the Event Script ext:
fire-and-forget external-state contract (durability requires a synchronous
acknowledgement); persisting {node}.result scratch (the model is the workflow's single
durable memory — an explicit, teachable rule).
ADR-0009 — Registration metadata is a cross-language contract; carriers are per-language idioms¶
Status: Accepted · Date: 2026-07-26T01:40:00.000Z · Serves: vision-mercury-composable · Formalizes: registration-metadata-contract
Abstract. Declarative registration — @PreLoad and its family (entry points, websocket
services, Event Script plugins, graph fetch features) — is governed by one canonical
metadata model with fixed semantics, specified in
docs/guides/registration-metadata-contract.md and proven by golden vectors shared
verbatim between engine repositories. How each language carries the metadata is an
idiom — Java annotations discovered by runtime classpath scan, Rust attribute macros
collected by link-time inventory, Python/Node decorators discovered by explicit
package/module walks — but the model and its semantics are the contract: attach at
definition / resolve at boot (envInstances); the OptionalService condition grammar;
order-free marker stacking; one conflict policy (explicit wins over declarative;
duplicates WARN + last-wins); extension-point naming (an explicit positional name, or
derivation from the declaration such that idiomatic declarations in every language yield
the same registered name); plugins are Event Script capabilities (flow vocabulary) and are
never conditionally gated, while features honor gating; the boot sequence
(discover → register → override → resolve → validate → route table); explicit
loud-failure discovery; and misuse as a first-class, tested error surface.
Rationale. The Rust port's first annotation pass proved that porting the mechanism without fixing the semantics produces drift invisible to any single repository: built-ins bypassing the extension points they exemplify, conflict policies diverging (skip-first-wins vs last-wins), gating support absent where the reference has it, and an attribute stack-order requirement Java never had. Each was individually small; together they meant a developer — or an AI agent — could not transfer knowledge between engines, and every future port would re-diverge independently. The same problem was already solved once for the wire format (ADR-referenced spec + golden vectors, v4.10.0): fixing the contract in a language-neutral artifact with executable conformance is what made the four-way interop matrix provable. This ADR applies that method to the declaration surface. The maintainer's two governing directives are part of the decision: developers must see consistent, decoupled registration in every language, and the Rust port is the best-practice template for the Python and Node ports.
Alternatives. (a) Per-port judgment calls documented in each repo — rejected: that is the drift this ADR eliminates; N-of-1 documentation cannot be conformance-tested. (b) A shared runtime registry service (as the original external blueprint's open item suggested for multi-process parity) — rejected: registration is process-local by design in a self-contained composable application; cross-process discovery is the service mesh's concern and stays opt-in. (c) Exporting the full live registry for byte comparison — rejected in favor of a fixed fixture set: engines legitimately differ in framework built-ins (no Spring in Rust, no Kafka mesh), so whole-registry comparison would pin incidental surface, not contract.
Consequences. New ports implement the carrier idiomatically, then pass the three
golden-vector suites (registration-vectors/core.json, plugin.json, feature.json)
before their declaration surface is considered done; every capability field a port cannot
honor is documented as N/A where developers would meet it, never silently dropped. The
engines accept a small ongoing cost: vector files are maintained verbatim in every
repository, and semantic changes to registration must update the contract page, the
vectors, and all engines in lock-step — which is precisely the point.
ADR-0008 — Synchronous AI-companion endpoint: in-band command outcome + live tee¶
Status: Accepted · Date: 2026-07-18T18:13:53.000Z · Serves: vision-mercury-composable
Abstract. Add an additive synchronous companion endpoint —
POST /api/companion/{session-id}/sync — that returns the command's outcome in-band as a
structured envelope { ok, command, output, error, result }, alongside the existing fire-and-forget
POST /api/companion/{session-id} (which returns only {status:"accepted"} and streams the real
outcome to the WebSocket console). The synchronous handler also tees each output line to the
session's WebSocket .out, so a human at the Playground — and, via the command service's existing
subscriber fan-out, any session subscribed session — sees the same output live. The existing
endpoint and the human console are unchanged. A reference implementation is proven in the Rust
port (acn-ericlaw/mercury); this ADR proposes adopting it in the Java engine.
Rationale. The current companion surface is a write-only command bus: PostCompanionCommand
dispatches the command fire-and-forget and returns an acknowledgement; the actual result — success
text and errors — reaches only the WebSocket console. An AI agent driving the endpoint over HTTP
is therefore blind to what happened: to learn the effect it must poll GET /api/graph/session/{id}
(shape) and GET /api/inspect/{id}/{key} (state), and a rejected command leaves the model unchanged
with no error at all — so polling cannot even distinguish "no-op" from "rejected". This was not
hypothetical: in an AI-companion validation exercise a capable agent posted an invalid graph.math
node, received HTTP 200, and never saw the engine's node … does not have if:, then: or else: →
graph traversal aborted; it only inferred failure from empty inspect state. A true AI companion needs
synchronous, self-describing feedback — send a command, get back what happened — so it can
self-correct autonomously instead of relying on a human to relay the console. The tee makes the
same endpoint a real-time human+AI collaboration surface: an architect and an AI draft a graph on
one live session while a product owner (subscribed) watches; work suspends/resumes across sprints via
export/import. Mechanism (proven in Rust, mirrorable in Java): the synchronous handler
dispatches the command to the command service via request/response RPC (Java po.request /
AsyncInbox) with a private capture route (platform.register) supplied as the command's out;
it drains the captured lines, classifies ok/error, folds a run/inspect result into result,
and returns the envelope — while fire-and-forget forwarding each line to the session's real .out for
the live view. It reuses existing primitives; the say()-based command functions are untouched.
Alternatives. An MCP tool server (typed tools) was considered and deferred — heavier, and it
forks the shared human/AI text surface into an AI-only one; the in-band envelope captures most of the
value while keeping one surface. Having the command handler return its transcript directly (no
capture route) is cleaner but threads an output sink through every command function; deferred.
Consequences. Additive and backward-compatible (the fire-and-forget route and the console stream
are unchanged). The envelope shape is a cross-vendor contract — the Rust and Java ports should
agree on it; open points: whether a large run output.body is inlined or spilled to
GET /api/inspect (mirror the existing large-payload rule), and whether inspect results fold the
same way. Refines the companion surface introduced with the MiniGraph Playground; bounded by ADR-0001
(decoupled functions — the endpoint is just another route) and ADR-0003 (Map-or-PoJo over
EventEnvelope — the envelope is a Map). Reference implementation (Rust port,
acn-ericlaw/mercury): the endpoint (post.companion.command.sync, dev-gated), an integration test
(companion_sync_returns_outcome_in_band), a design note (docs/design/ai-companion-sync.md), and a
live multi-party demo in which a fresh AI companion built + ran a decision graph autonomously via
/sync — self-correcting from in-band errors (including a retired-skill dead end) while an architect
and a subscribed product owner watched in real time. Now implemented in this Java engine:
PostCompanionCommandSync (route post.companion.command.sync, dev-gated) with CompanionSyncTest,
mirroring the Rust design — a private per-call capture route (registerPrivate) supplied as the
command's out, RPC to the singleton command handler, a FIFO sentinel to mark the buffer drained, and
a best-effort tee to the session's WebSocket .out. End-of-transmission refinement (both ports):
the sentinel is correct only for synchronous commands, which emit all output before the handler
replies. A traversal (run) is asynchronous — the handler launches the traveler and replies
immediately, then the traveler streams its output afterwards — so a post-reply sentinel races (and
usually beats) that tail and truncates the capture. A traversal is therefore drained on the traveler's
terminal line (Graph traversal completed in N ms | Graph traversal aborted), which is always
emitted last. To make that signal reliable, every run now ends with one terminal line: the
early-failure paths (no instance yet, missing root/end node) emit their reason then the canonical
Graph traversal aborted, so a companion mistake such as run before instantiate returns promptly
(ok:false) instead of waiting out the timeout. The bounded wait is only a safety net; correctness
comes from the signal. This keeps the REST contract byte-identical across the Rust and Java engines —
the companion surface is language-neutral. Amended 2026-09-23 (field issue #454, Eric's ruling):
every abort carries its reason in the terminal itself — Graph traversal aborted: {reason}, the shape
of the executor's log record — and the drain matches the prefix; the separate reason line before a
bare terminal is gone.
ADR-0007 — Event Script configuration is preferred over code for orchestration¶
Status: Accepted · Date: 2026-06-27T15:45:00.000Z · Serves: vision-mercury-composable
Abstract. When a step is orchestration — sequencing functions, branching on a condition,
handling a failure, or moving data between steps — express it as Event Script YAML (tasks,
execution types, input/output data mapping, exception handler), not as imperative code inside a
function. Code is reserved for the unit of work itself (the function body; ADR-0005). The
sync-over-async refactoring that produced this ADR converted an imperative facade — a
PostOffice.send publish buried in a function, with a hand-written try/catch mapping the failure to
an HTTP status — into a declarative flow: prepare → simple.kafka.notification → await, where a publish
failure is routed by the engine to the flow's exception handler (fail-fast → HTTP status) and a
decision task expresses the drop branch. The boundary holds in the other direction too: a genuinely
in-function concern — here, the synchronous blocking rendezvous that must bracket the publish —
stays in code. Not all code becomes YAML.
Rationale. Two properties make configuration the better home for orchestration. (1) It
communicates intent. The flow file is a single, legible statement of the event flow — a reviewer sees
the begin → publish → await sequence, the topic names (text(topic-1) / text(topic-2)), the
fail-fast path, and the no-reply branch without reading Java; the imperative version hid the topic and
the publish inside PostOffice calls and buried the control flow in try/catch. (2) It manages
dependencies. Event Script declares both control-flow dependencies (task order, decision branches,
exception routing) and data-flow dependencies (field-level mapping through model), and the engine
enforces them — so functions stay fully decoupled (ADR-0001), never importing one another, with the
only wiring in the flow. Reusable building blocks are composed by reference, not duplicated in code:
the one simple.kafka.notification function publishes the request in one flow and the reply in another.
Cross-cutting behavior (failure handling, status policy, ttl timeouts, trace propagation) becomes an
engine concern expressed in config rather than repeated boilerplate, and orchestration changes (add a
step, change a topic, re-route a branch) are reviewable config edits that need no recompile — the source
of the "roughly half the code" claim in ADR-0001. The accepted consequences are the cost of the
abstraction, not reasons to avoid it: the unit of work stays in code (a flat task chain cannot express a
blocking await that must wrap a publish — forcing it into config is contortion), and declarative routing
has its own vocabulary to learn — the decision type selects a next entry by value (true = 1 =
first entry, false = 2 = second; an integer is 1-based and enables a multi-way switch), and byte[]
payloads ride through model via the * whole-body passthrough (ADR-0003). This decision refines
ADR-0001 (orchestration as Event Script) and is bounded by ADR-0005 (one atom, four roles).
ADR-0006 — Cloud-native by default; service mesh for sync-over-async and service discovery only¶
Status: Accepted · Date: 2026-06-23T18:30:00.000Z · Serves: vision-mercury-composable
Abstract. The Kafka service mesh (cloud.connector=kafka + presence-monitor) is an opt-in
capability that solves two specific problems: (1) synchronous request-response between different
application instances over Kafka, and (2) service discovery between running pods. Applications that do
not need either capability must be designed cloud-native — each instance self-contained, stateless,
and horizontally scaled without cross-instance coupling. Enabling cloud.connector=kafka is a
deliberate architectural choice, not a default or a convenience. cloud.connector=none is the
framework default.
Rationale. Superimposing synchronous request-response over Kafka (an inherently asynchronous transport) is technically feasible — the same pattern appears in IBM MQ, Redis pub/sub for RPC, and other enterprise messaging systems — but it is architecturally expensive. Cross-instance synchronous RPC creates latency dependencies between otherwise independent scaling units: if one pod is slow, every caller waiting on it is slow; errors propagate across instance boundaries; horizontal scaling no longer provides isolation between workloads. Overuse of this pattern degrades a cloud application into a distributed monolith — all the operational complexity of a distributed system combined with the tight coupling of a monolith. Cloud-native design avoids these risks: inbound load is distributed at the infrastructure layer (load balancer / Kubernetes ingress), and each instance handles its share independently. The service mesh should be adopted only when one of its two genuine use cases applies: (a) cross-application synchronous RPC that cannot be decoupled further, or (b) distributed resilience patterns that require peer awareness (leader selection, failover, pod-aware broadcast). Consequence: documentation, tooling, and AI agent guides must treat the service mesh as an advanced, opt-in topic — not the standard deployment model — to avoid steering users toward the distributed monolith anti-pattern.
ADR-0005 — One atom, four roles¶
Status: Accepted · Date: 2026-06-22T22:47:23.000Z · Serves: vision-mercury-composable
Abstract. The sole building block of an application is the route-addressed
function — a plain Java class annotated @PreLoad implementing LambdaFunction or
TypedLambdaFunction, with Map/PoJo I/O, private by default. There is no second
primitive; the same unit is named by how it is wired:
- function — the atom itself (registered in the
Platformregistry by route name); - service — a function mapped straight to HTTP via
service:inrest.yaml(a narrow REST role, distinct fromflow:; seeRoutingEntry.javaSERVICE = "service"); - task — a step in an Event Script flow carrying an
executiontype, one ofCompileFlows.EXECUTION_TYPES(decision, response, end, sequential, parallel, pipeline, fork, sink); - skill — a function attached to an Active Knowledge Graph node via that node's
skill:property (GraphLambdaFunction.javaSKILL = "skill").
Rationale. One primitive means one mental model and one programming model regardless of which paradigm layer you are working in — learning to write a function transfers to every role, and a function can be promoted from a flow task to a graph skill without being rewritten. The alternative — distinct primitives per layer (an HTTP-handler type, a flow-step type, a graph-node type) — would fragment the model and break the decoupling guarantee that the whole framework rests on (see ADR-0001). Consequence: the role-names are kept precise in all documentation — "function" is the general atom, "service" is the narrow REST role and is not a synonym for it, and a task is a role of the atom, never a separate kind of thing.
ADR-0004 — Three-paradigm-layer architecture¶
Status: Accepted · Date: 2026-06-22T22:47:23.000Z · Serves: vision-mercury-composable
Abstract. The framework is organized as three ascending paradigm layers, each building on the one below:
- Event-driven foundation — Platform Core: decoupled functions over the in-memory event bus (ADR-0001, ADR-0002).
- Composable orchestration — Event Script: a YAML DSL choreographing those functions into transactions.
- Semantic — Active Knowledge Graph — MiniGraph: graph models that execute behavior through skills embedded on nodes.
These conceptual layers are distinct from the runtime request pipeline — whose stages run outside in: user / calling application → protocol boundary (REST automation for HTTP, a Kafka listener, or another protocol) → flow adapter → Event Manager / flow engine → in-memory event bus → composable functions. (For each protocol there is a corresponding flow adapter; for HTTP, REST automation is the boundary that invokes the built-in HTTP flow adapter.) The word "layers" is reserved for the three paradigms; the request flow is a pipeline with stages, never a layering.
Rationale. A single coherent ascent gives users both a mental model and an on-ramp: begin event-driven, compose with Event Script, model semantically with the Active Knowledge Graph (the user-facing surface per the Vision). Naming is locked to remove a recurring source of confusion: Active Knowledge Graph is the model, Knowledge Graph as Application the tagline, MiniGraph the engine, semantic an adjective only. The origin is told as part of the foundation — Scala/Akka actor model → Eclipse Vert.x event bus → Java 21 virtual threads. Human–AI collaboration is a cross-cutting capability across all three layers (agent-ready DSL specs + a companion endpoint), not a fourth layer. This entry supersedes the earlier framing that described the runtime as five separate layers, which conflated the conceptual layering with the request pipeline.
ADR-0003 — Function I/O contract: Map-or-PoJo over an immutable EventEnvelope¶
Status: Accepted · Date: 2026-06-22T22:47:23.000Z · Serves: vision-mercury-composable
Abstract. A TypedLambdaFunction<I, O>'s normal input and output type is a Map or a PoJo.
Key-by-key data mapping in Event Script (Layer 2) and the Knowledge Graph (Layer 3) maps fields
individually, so a List cannot serve as the mapping contract there — use a Map or a single PoJo.
However, the * whole-body passthrough (model.list -> *) is a special escape from key-by-key
mapping: it passes the entire state-machine value as the event body, bypassing field-level mapping.
Combined with inputPojoClass on @PreLoad, this enables a List<PoJo> at the function boundary
within an Event Script flow. Layer 1 (Platform Core) uses the same inputPojoClass mechanism to
ingest an incoming JSON-list payload directly from an external source (see Consequences). Functions
exchange the immutable EventEnvelope message container: headers are Map<String,String>, the body
is MsgPack-serialized on the wire, and PoJo↔Map conversion uses a customized Gson.
Rationale. Constraining key-by-key I/O to Map-or-PoJo keeps Event Script data mapping clean and
readable and avoids serialization edge cases. A PoJo enforces an interface contract; a Map gives
flexible structure — together they cover the spectrum without admitting ambiguous generic collections.
The * passthrough is the intentional escape hatch for List payloads (tested in the
event-script-engine suite). The accepted consequences are the serialization gotchas that follow from
the wire format: MsgPack downcasts a small Long to Integer on the wire (pin the type with a PoJo
when it matters); the customized Gson treats integers in a Map as Long (use util.str2int /
util.str2long for safe conversion); Map keys must be strings (non-string keys are auto-converted).
The List<PoJo> path (via * passthrough or Layer-1 external ingestion): declare
inputPojoClass = X.class on @PreLoad — the serializer deserializes the list of maps into the
typed list. (Outgoing list payloads need no special handling: Event Script's AsyncHttpClient and
the Knowledge Graph's API-fetcher skill do their own data mapping.) Functions may still return
Mono<T> / Flux<T> for reactive pipelines.
ADR-0002 — Virtual-thread event engine: sequential RPC at reactive performance¶
Status: Accepted · Date: 2026-06-22T22:47:23.000Z · Serves: vision-mercury-composable
Abstract. Functions execute on Java 21 virtual threads over an Eclipse Vert.x
in-memory event bus. A PostOffice RPC call (po.request(...)) appears synchronous and
sequential to the caller, while behind the curtain the virtual thread is suspended and
the carrier kernel thread is released — so blocking-style sequential code performs on par
with reactive code.
Rationale. The lineage is deliberate: Scala/Akka actor model (Mercury v1) →
Eclipse Vert.x event bus (v2) → a fully non-blocking engine with low-level execution
control (v3) → Java 21 virtual-thread integration (v3.1+). The goal is to keep the
clarity of sequential code — the code reads as the intent of the application, easy to
read and maintain — without paying the throughput cost of blocking a kernel thread per
in-flight request. Alternatives considered: a pure reactive API (Mono/Flux
everywhere), which is harder to read and maintain; and classic thread-per-request, which
caps concurrency. Consequences: synchronous PostOffice RPC ≈ reactive performance;
Mono/Flux remain available for genuinely reactive pipelines; the framework requires
Java 21.
ADR-0001 — Decoupled functions wired by route names; orchestration as Event Script¶
Status: Accepted · Date: 2026-06-22T22:47:23.000Z · Serves: vision-mercury-composable
Abstract. All application logic is packaged as self-contained functions —
@PreLoad-annotated classes implementing LambdaFunction or TypedLambdaFunction,
registered in the Platform registry and addressed exclusively by a route-name
string. Functions hold no direct reference to one another; they communicate only by
exchanging immutable EventEnvelope messages over the event bus. Orchestration — the
sequencing of functions into a transaction — is declared in YAML Event Script, not
written in code; the only link between a flow and a function is the route-name string.
Rationale. Full decoupling is the foundation the entire framework rests on: functions can be developed, tested, deployed, relocated across a service mesh, and recomposed into new flows without recompiling or knowing about each other. Moving orchestration out of code and into configuration makes the sequencing reviewable and changeable on its own, and roughly halves application code. The alternatives — direct method calls or dependency-injection wiring between components, and imperative orchestration code — were rejected because they reintroduce compile-time coupling and bury the transaction flow in control logic. The accepted consequence is that the route-name string is the whole contract between a flow and a function, so route-naming discipline matters and is enforced by convention. This decision is elaborated by ADR-0005 (the one function atom plays four wiring roles) and realized on the runtime of ADR-0002.