Skip to main content

Semantic Log Example

The two transfer flows in core/semantic-log/flow/ are the library's end-to-end evidence: real HTTP between real processes, with every assertion made against the records each participant actually retained, rather than against objects a test built.

This page carries the sequence diagrams and annotates them leg by leg, so the diagrams can be read against the code that implements them and against the tests that pin them. The participant catalogue, the faults and the requirement mapping live in the pattern guide, and what the library is at all is the concept.

  • Pattern guide — the calls that change behaviour, the faults, how to run one
  • Concept — what the library is
  • Rationale — why it is shaped this way

These flows are a demonstration​

They are demonstration fixtures for the logging library. They are not an implementation of Mojaloop, not a payment system, and not a model of how settlement should be built. Their purpose is to generate realistic multi-service traffic — real HTTP between real processes, real failure paths, records read back out of each participant's own cache — so the library's behaviour can be watched, asserted and explained end to end.

They are loosely based on two published Mojaloop features:

FlowBased on
single — cross-currency, one schemeForeign Exchange — currency conversion: a Payer DFSP obtains a conversion rate from an FXP and then executes the transfer in the target currency
inter — cross-border, two schemesInterscheme: two schemes joined by a proxy participant that routes for adjacent DFSPs/FXPs in the other scheme

What is simplified or omitted, deliberately​

In the Mojaloop flowIn these fixtures
An Oracle resolves the party identifier, and GET /parties is answered with a PUT /parties/{ID}Three generic POST routes — /parties, /quotes, /transfers — with no directory; the payee answers directly
Two POST /quotes rounds (liquidity cover from the FXP, then the transfer terms from the payee), plus the Payer's own confirmation stepOne quote round: the provider's rate is threaded through the payee and returned to the payer once
An FX marketplace: several FXPs bid for the conversion and the DFSP selects oneOne provider for the corridor, by configuration
Real ILP cryptographic conditions and fulfilment preimagesPlaceholder strings (sha256:condition, sha256:preimage); nothing is cryptographically verified
Two-phase reserve/commit across ledgers, atomic settlement, clearing accountsA single withhold and its release on failure — which is the observability the library needs to demonstrate
Abort flowsA refusal status (409, 422, 503) and an error record
Inter-scheme discovery by broadcast, cached identifiers, stale-cache self-healing, proxy registration through an admin API, and GET /transfers resolved locallyAbsent: the corridor is configured rather than discovered, and the proxy's routing decision is a single recorded branch
Non-repudiation: the proxy routes but takes no part in the agreement of termsPreserved — the proxy forwards /quotes unchanged and adds no terms of its own

The rates, amounts and identifiers are invented. No participant holds state that survives a run, and nothing here is a statement about how a real scheme should behave.

What the fixtures add​

Three things in the fixtures are deliberately absent from the drawings, because they exist for the observability rather than for the protocol: the proxy's hold branch, the two identity headers on every hop, and the optional cluster-service sink. They are listed with their reasons under legs the fixture adds.

How to read the annotations​

The diagrams below are generated from a real run of the fixtures — every arrow is a call a record declared, and every label is the leg id the source declares. That is the point of the ids: hub.transfer.deliver in the diagram is a literal in flow/hub.ts, and the table under each diagram names the file and line that declares it, so a reader moves from a picture of a run to the line of code that made it without guessing.

Each diagram uses mermaid's autonumber, which numbers arrows only — the Note over … lines carry no number. The table is the diagram in full, and it says what a picture cannot:

ColumnMeaning
callthe leg id, as declared in the code
caller → receiverwho declared the call, and who they declared it to — the receiver the caller named, not one inferred from who answered
phasethe step the call was observed in (discovery, quote, transfer)
positionthe counter path the caller assigned: 2.2.1 is the first call made while answering 2.2
declaredhow many declarations were observed — one per execution, so a chatty call site is still one call
answeredof those, how many had their receiver observed too. 0 is the interesting case: the call was made and nothing answered
declared inthe file and line that declares the id

An arrow drawn with a cross (--x) is a call the caller declared that nothing answered — a receiver that is missing, failing, or wired to the wrong address. An arrow drawn dashed (-->>) is the receiver's own record of the leg: the answer on its way back, under the same id, drawn where it arrived — after everything that call itself called has been answered. A call that never leaves its own participant is the one call drawn without a pair, because the answer to it would repeat the same caller, the same label and the same step. A call seen only from its receiver's side is drawn as a note rather than an arrow, because an arrow needs a caller and the only one available would be an invention.

The protocol is deliberately simplified. The fixture collapses Mojaloop's published exchange (GET/PUT resource pairs, two quote rounds, separate FX settlement legs) onto three generic POST routes — /parties, /quotes, /transfers — keeping the published phase names (discovery, quote, transfer). What each fixture keeps, collapses and drops relative to the published protocol is stated in prose under each diagram, so the difference between what is drawn here and what the protocol says is a written decision rather than a gap a reader has to notice.

Regenerate both blocks with:

cd core/semantic-log
SEMANTIC_LOG_UPDATE_DIAGRAMS=1 ./node_modules/.bin/tap test/flow/observedFlows.test.ts

Cross-currency (single scheme)​

Four participants: payer, hub, fxp (the FX provider), payee. Wired by startFlow('single') in flow/flows.ts.

Relative to the published exchange, this fixture:

  • collapses the two quote rounds into one — the payer sends one POST /quotes that carries both the FX request and the main quote, and the hub threads the provider's rate through the payee and merges both answers into the single reply;
  • does not model the FX provider's transfer legs — the provider is consulted in the quote phase only, and the hub settles directly with the payee. A second settlement participant would add no new observability, so its absence is a decision rather than an oversight;
  • keeps the liquidity reservations out of the wire — the hub's withhold({liquidity}) is retained in the record and never transmitted (R10), which is why no leg carries it.

Before the generated block, this section carried a hand-written diagram of the published protocol and a table mapping each of its legs to the fixture. Both are gone: the diagram drifted from the code silently, and the table was the wrong answer to a reasonable request — what a reader needs is not a legend but a label that can be found in the source. The prose above is what the tables carried that a generated table cannot: which parts of the published exchange this fixture deliberately does not implement.

Participants: payer, hub, payee, fxp. 7 calls observed across 1 execution(s).

callcaller → receiverphasepositiondeclaredanswereddeclared in
discovery.partiespayer → hubdiscovery111flow/payer.ts:67
discovery.payeehub → payeediscovery1.111flow/hub.ts:56
quote.ratespayer → hubquote211flow/payer.ts:85
quote.fxhub → fxpquote2.111flow/hub.ts:57
quote.payeehub → payeequote2.211flow/hub.ts:58
transfer.submitpayer → hubtransfer311flow/payer.ts:124
transfer.deliverhub → payeetransfer3.111flow/hub.ts:59

Inter-scheme cross-currency​

Six participants: payer, hubA, proxy, hubB, payee, fxp. Wired by startFlow('inter'). One provider sits on the corridor and the receiving scheme's hub asks it (hub.quote.fx); the originating hub quotes nothing locally, so the corridor's price is the only price the run records — the shape the published flow has. See the additions below the block.

Participants: payer, hubA, proxy, hubB, payee, fxp. 13 calls observed across 1 execution(s).

callcaller → receiverphasepositiondeclaredanswereddeclared in
discovery.partiespayer → hubAdiscovery111flow/payer.ts:67
discovery.proxyhubA → proxydiscovery1.111flow/hubA.ts:53
proxy.discovery.corridorproxy → hubBdiscovery1.1.111flow/proxy.ts:45
discovery.payeehubB → payeediscovery1.1.1.111flow/hub.ts:56
quote.ratespayer → hubAquote211flow/payer.ts:85
quote.proxyhubA → proxyquote2.111flow/hubA.ts:88
proxy.quote.corridorproxy → hubBquote2.1.111flow/proxy.ts:46
quote.fxhubB → fxpquote2.1.1.111flow/hub.ts:57
quote.payeehubB → payeequote2.1.1.211flow/hub.ts:58
transfer.submitpayer → hubAtransfer311flow/payer.ts:124
transfer.proxyhubA → proxytransfer3.111flow/hubA.ts:122
proxy.transfer.corridorproxy → hubBtransfer3.1.111flow/proxy.ts:47
transfer.deliverhubB → payeetransfer3.1.1.111flow/hub.ts:59

Relative to the published exchange, this fixture:

  • routes the corridor through a proxy participant — hub A holds no directory of its own, so hubA.discovery.proxy is a real hop to a real participant rather than a URL change;
  • carries each phase end to end — one leg per hop per phase, which is what makes the counter paths read as a depth-first walk of the corridor (1.1.1.1, 2.2.1.2, 3.1.1.1).

Legs the fixture adds, and the diagrams do not draw​

The generated blocks draw what the service observed; this section is about the fixture's own additions relative to the published protocol — the records the both-ends discipline requires, which are calls rather than protocol legs.

The fixtures are not only a transcription of the diagrams. Three things they carry are absent from the drawings, and each is deliberate:

AdditionWhereWhy it is there
The proxy's hold branchproxy.ts (ROUTES, reachable)a proxy whose receiving link is not configured has no route and must hold (503) rather than forward into an address it was never given. Reachability is derived from the deployment, so the branch is real and not a guard against something that never happens
Two identity headers on every hopparticipant.ts (hop)x-semantic-trace (causal correlation) and x-semantic-flow (the execution ULID) — the diagram draws the protocol, not the observability envelope
The optional cluster-service sinkparticipant.ts, flows.tswith --service, each participant's records are shipped beside the local cache; without it the run is complete and offline (R18)

What the flow code demonstrates​

Every feature below is exercised where it naturally occurs — these are calls a real service would make at that point, not demonstration code — and each is pinned by a test that reads the participants' own retained records.

FeatureWhere the fixture does itPinned by
One real process per participant, over real HTTPparticipant.ts (createParticipant, listen, hop)test/flow/participant.test.ts
A trace across every participant and every hophop sets x-semantic-trace; traceFrom mints only at the entry pointtest/flow/happy.test.ts — "every participant logs in one trace"
One execution is one flow id, carried unchangedflowFrom mints the ULID once; every later participant forwards ittest/flow/happy.test.ts — "one execution is one flow id…"
The flow id is not the trace idparticipant.ts binds them separately (bindTrace + withFlow)test/flow/happy.test.ts — "…and it is not the trace (D1/D3)"
The flow kind is the drift keyflows.ts FLOW_KIND → createParticipant({kind}) → withFlow({id, kind})test/flow/happy.test.ts — the asserted kind values transfer.single / transfer.inter
Phases come from the diagramsparticipant.phase(name, fn) → step(name, fn) for discovery/quote/transfertest/flow/participant.test.ts — "phase steps the bound flow and refuses to step outside one"; F4
Intent is bound once, at the entry participantflows.ts passes intent for the payer onlytest/flow/participant.test.ts — "an entry participant runs every request under its declared intent"
The causal chain is walkable from the recordsrememberRecord → refs.parent → the sink's toEvent → LineageIndex.chaintest/flow/happy.test.ts — "the causal chain is walkable…" and "…reconstruct the chain through the service"
References are minted locally, with no servicewithIdentity, mintRecordRef (r), refs.template (t), refs.trace (x)test/flow/happy.test.ts — "references are locally minted and carry a template-id prefix"
The parent link is rendered, not just storedrenderHuman renders p=<id> in the reference grouptest/flow/happy.test.ts — the p=${parent} assertion against the shipped renderer
Withholding, and its release on failurehub.ts/hubA.ts withhold(…) then an error or a fataltest/flow/faults.test.ts F1; test/flow/happy.test.ts — the inter-scheme refusal
Withheld detail belongs to the execution that withheld itstepLogger() — a request-scoped logger.child({})test/flow/guards.test.ts — "withheld detail belongs to the execution that withheld it…"
Branch rationale, exactly where a service branchesdecide(…) in payer (quote-acceptable), fxp (rate-within-limit), proxy (route-selection)test/flow/faults.test.ts F5; test/flow/happy.test.ts — the proxy's route-selection rationale
A refusal stops the chain and travels as a statusevery route sets reply.status(result.status), never returns the envelopetest/flow/faults.test.ts F1; test/flow/guards.test.ts — a refusal with no stated reason
A declined quote cannot be mis-pricedthe payer folds the provider's status into quote-acceptabletest/flow/faults.test.ts F5
Faults are deployment properties, not test branchesstartFlow(kind, {faults}) → install* optionsthe whole of test/flow/faults.test.ts
Offline: nothing needs to be runningno serviceUrl → readable output, references and the local cache alonetest/flow/happy.test.ts — "the flow runs with no cluster service anywhere (R18 acceptance)"
A service sink is a second destinationserviceUrl → createServiceWriter, beside the local cachetest/flow/participant.test.ts — "a participant with a service URL ships its records there…"
The last record is flushed before the cache closesclose() flushes loggers, then sinks, then the cachetest/flow/happy.test.ts — every test reads the store after the run's records were flushed
An identity is a single token, or it is not oneidentityHeader rejects absent, blank and comma-joined valuestest/flow/participant.test.ts — "only a non-empty string identity header is propagated", "a header repeated on the wire is not an identity"
Caller misuse throws; a weird identity on the wire does notwithFlow rejects a malformed ULID or an empty kind; the wire path reportstest/flow/participant.test.ts — "run refuses a malformed flow id and an empty kind"
The library is a front door, not an addition (R17)the participants import semantic-log and fastify and nothing elsetest/flow/happy.test.ts, test/flow/coverage.test.ts

Running and testing the flows​

node flow/run.ts --flow single|inter runs a flow by hand; see the pattern guide for the runner's flags, and the package's own README (core/semantic-log/README.md) for the cluster service the --service flag ships to.

Links to the package's sources are given as paths rather than hyperlinks: the docs site is built from docs/blong/docs/ alone, so a link escaping this tree would be a broken link in the published site.