For an issuer, correctness is not just a response code. It is the ISO 8583 message on the wire, the product rules that shaped the decision, the ledger entries that were posted, and the evidence an operator can inspect later.
The new Client Simulator demo shows that loop end to end on the jPOS Control Plane: a live jCard issuer, a real CMF channel, a functional test suite, raw logs, and a reconciled general ledger.
The video follows a complete functional test flow for a live jCard issuer:
jcard-acme issuer podjcard-auth suite: 39 cases and 133 stepsThe important part is that these are not separate demos. The same run connects product configuration, generated traffic, operational logs, and accounting state.
A simple ISO message blaster can tell you whether a host answered. It cannot tell you whether the test was staged correctly, whether the response matched the business rule, or whether the financial entries landed where they should.
The Client Simulator is a functional test harness. A case is made of typed steps:
That means a single case can prepare its own state, drive wire traffic, inspect the issuer response, and verify the books.
Every ISO step is built from explicit rules. Some fields are fixed literals, some come from helpers such as trace-number and timestamp generators, some come from the case variables, and others can be produced by small sandboxed scripts.
The response is graded the same way. In the demo, an authorization step expects response code 0000 and checks for the balance information in data element 54. The run captures the expected and received values, so the result is not just "green"; it is inspectable.
Because the test drives the live issuer, the traffic is also visible in the Log Viewer. The operator can inspect the actual request and response messages, such as 2100/2110 and 2200/2210, exactly as they crossed the channel.
That is the useful evidence trail: the test intent, the generated ISO message, the issuer response, and the structured log record all line up.
For card issuing, a passed response is not enough. The issuer also has to post the right entries.
The demo follows account 2.10.ACME.0000001, the cardholder account used during the run. With the relevant layers selected, the balance history shows settled and pending activity. The account statement shows every transaction touched by the suite.
Opening one authorization shows the general ledger legs behind it:
The simulator mocked none of this. It drove the real issuer, and the issuer posted to the real ledger.
The demo closes with where this is going next. The Control Plane already has preconfigured suites for jCard and jPTS, and the integrated assistant can help author or edit them.
The important detail is the review boundary. A prompt can ask for a larger fixture set, a mixed traffic stream, or a fan-out from one known-good authorization into thousands of variations. The assistant returns a reviewable change set. The operator still decides what becomes part of the suite and what runs.
That keeps the correctness workflow controlled while making the harness easier to expand.
Once the suite exists, the same idea scales beyond functional correctness. The Client Simulator runs inside the Control Plane, on Kubernetes. The same structured cases that prove behavior on one issuer pod can be scaled horizontally to drive larger traffic patterns for load, endurance, and soak testing.
Payment testing needs to connect intent with evidence. A useful test has to show what it meant to do, what it sent, what came back, and what financial state changed as a result.
That is the practical value of putting the Client Simulator inside the jPOS Control Plane. It uses live endpoints, real packagers, structured assertions, operational logs, and the same ledger the issuer writes to in normal operation.
The result is a testing surface that is not just faster to run, but easier to trust.
]]>For logs, that evidence is not a paragraph of generated text. It is the indexed event, the timestamp, the realm, the host, the trace identifier, the original structured payload, and the surrounding events that explain what happened before and after.
That is the design point of the latest Log Viewer demo. Chat is now part of the operator workflow, but it is not a replacement for the Log Viewer. It is a faster way to ask the first question, keep context, and move toward the same structured evidence an operator would inspect manually.
The video shows the jPOS Control Plane with the Log Viewer and the assistant working together:
The important part is the handoff. Chat can help get from intent to context, but the system still lands on inspectable state.
It is tempting to think of an assistant as the new operational console: ask a question, get an answer, move on.
That is not good enough for regulated systems. If an incident matters, the answer has to be grounded in something durable and inspectable. A generated summary can be helpful, but it cannot be the record.
The Log Viewer already gives MGL a structured log surface: event kind, realm, host, trace identifier, payload excerpt, full JSON source, time histogram, column facets, and trace linking. The assistant adds a conversational entry point on top of that, not a second source of truth beside it.
This is the right split:
That keeps the operator in control. The assistant can shorten the path, but it does not hide the path.
The chat service runs inside the same Control Plane session model as the rest of MGL. The WebSocket connection is authenticated from the user's session cookie, the active entity is validated, and permissions are resolved for that entity before tools execute.
That matters because AI features often fail by becoming a side door. If the assistant can see or do things the user cannot, it becomes a privilege-escalation surface. If it ignores tenant or entity boundaries, it becomes a data-leak surface.
MGL's chat flow is deliberately boring in this respect: the assistant receives the user's current entity context, tool calls are permission-gated, and failed authorization is not something the model is allowed to work around.
For an operator, that means the chat is another way into the system, not another security model.
One practical detail in the demo is that tool results can carry navigation hints. When the assistant uses a tool, the chat layer can point the UI to the related page: a chart account, an account statement, a transaction, a report, or a posting draft.
That pattern is important for logs too. An operational answer should not end at "I found something." It should lead to the place where the operator can inspect, filter, compare, and copy the evidence.
The Log Viewer remains the right place for that work:
Chat makes the first step more natural. The viewer makes the conclusion verifiable.
The useful version of AI in operations is not a chatbot that talks about the system from the outside. It is an authenticated client of the system, using the same tools, the same permissions, and the same structured data as the rest of the application.
That is the direction MGL keeps moving toward: operational workflows where AI can help, but where every important result remains tied to durable, inspectable application state.
For Log Viewer, that means the assistant can help an operator ask better questions and get to the right place faster. The evidence still lives where it should: in the structured log index, visible through the same UI an operator can trust without AI.
]]>jPOS now splits that responsibility into a dedicated log ingestion path: workloads emit structured jPOS JSONL logs to standard output, Fluent Bit transports labelled pod logs, a log-ingestor service owns the write side, and the jPOS Control Plane reads the shared index read-only.
The video starts with the architecture and then switches to a live Log Viewer tour:
controlplane.jpos.org/log-collect=true labellog-ingestor receives batches on /admin/logs/ingest/data/log-index and /data/log-payloadThe important part is not just that logs show up in a UI. The important part is the ownership model.
Lucene indexes have a clear rule: there should be one writer. The new log-ingestor service makes that rule explicit.
In Kubernetes, the ingestor runs as a single replica with a Recreate deployment strategy. It owns the index writer and the BinLog payload writer. Fluent Bit posts events to the ingestor's HTTP endpoint, and the ingestor appends them to the shared storage.
That keeps write locking simple. There is no race between Control Plane replicas, no accidental second index writer, and no need for every UI pod to perform ingestion work.
The Control Plane now has a separate read-only backend for the Log Viewer. LogReaderService opens the shared Lucene index and BinLog payload store without taking the writer role, refreshes its reader periodically, and lets the existing Log Viewer handlers resolve that read backend.
That means Control Plane pods can be scaled horizontally. Each replica can serve the same operational view, but none of them owns the write path. If one Control Plane pod restarts, ingestion continues. If the Control Plane scales from one replica to three, the log index still has one writer.
This is the same shape we want elsewhere in regulated infrastructure: separate the mutation path from the observation path, make the owner of each side obvious, and keep the operational contract easy to reason about.
Fluent Bit is deliberately used as transport, not as the source of truth.
The DaemonSet tails /var/log/containers, applies Kubernetes metadata, filters for the jPOS log-collection label, and sends matching records to the ingestor. It does not parse business meaning into a separate observability schema, and it does not own the index.
The structure still comes from jPOS log events. That is why the viewer can facet by kind, realm, host, and trace identifier, and why opening an event can show the original structured payload instead of a guessed text parse.
The ingestor writes the Lucene index and the BinLog payload store to a persistent volume. The Control Plane mounts the same claim read-only.
For production, the chart is designed around a real ReadWriteMany storage class such as NFS, CephFS, EFS, or Longhorn RWX. For local single-node development, the chart can run on local-path style storage with pod affinity so the writer and readers land where the shared path is available.
The storage contract is intentionally boring: one component writes; many components read.
The chart already carries the next shape: one ingestor and one isolated storage volume per tenant.
Today, an empty tenant list renders the system-wide ingestor. When tenant-specific deployments are enabled, the same template structure can render a separate ingestor, service, and persistent claim per tenant. That keeps the scaling model aligned with the data-isolation model instead of turning logs into a shared cross-tenant bucket.
The Log Viewer started as a useful way to search structured jPOS events. The log ingestor turns it into a deployable architecture for clustered systems.
It gives jPOS a clean pipeline:
That is the point of the new architecture: not more moving parts for their own sake, but a smaller responsibility for each moving part.
]]>That may sound counterintuitive if your organization treats -SNAPSHOT as a synonym for "unstable", but that is not how we operate jPOS and jPOS-EE. We work hard to keep the latest snapshots production-ready. We run them in production ourselves. They are where the current fixes, optimizations, dependency updates, security improvements, and newly certified behavior land first.
In practice, the latest snapshot is often the most advanced, optimized, and feature-rich jPOS version available.
jPOS is used in real payment systems, by real institutions, under real certification pressure. That gives us a continuous stream of feedback:
When those improvements are made, they go into the active development line first.
That is why staying close to the latest jPOS and jPOS-EE snapshots is usually the healthiest way to develop. You get the benefit of the current work instead of discovering months later that your project has drifted away from the version the rest of the ecosystem is already validating.
The official source for these artifacts is the jPOS Maven repository:
https://jpos.org/maven
That is the repository we publish to, and that is the one customers should use when consuming jPOS and jPOS-EE snapshots.
There is an important distinction here.
We strive to keep the latest snapshots production-ready, and we run them that way. But a snapshot is still a moving artifact. For short periods of time, a particular snapshot build may contain a problem. When that happens, we treat it with urgency and fix it with the highest priority.
That is engineering reality. It is also one of the reasons staying in touch during development matters.
If you develop against the current snapshot and report issues when you find them, we can usually respond quickly, while the change is still fresh and before your project has accumulated a large version gap.
If you freeze development for a long time on an older build, then later jump forward just before production, you may face a much larger set of accumulated changes at once. That is avoidable technical debt.
Our recommendation is simple:
-SNAPSHOT during development.During development, depending on:
implementation "org.jpos:jpos:3.0.2-SNAPSHOT"
keeps you on the moving development line. Your builds will pick up the current snapshot published under that version.
That is what you want while actively developing, testing, and certifying. It keeps your project in the loop.
For production, however, you usually want repeatability. The artifact deployed today should be the same artifact deployed tomorrow, unless you intentionally change it. That means pinning the exact timestamped Maven snapshot build.
A pinned dependency looks like this:
implementation "org.jpos:jpos:3.0.2-20260509.153915-15"
That version is no longer a moving target. It identifies one concrete build.
Maven snapshots are published with metadata. For example, the metadata for the current 3.0.2-SNAPSHOT jPOS line is available at:
https://jpos.org/maven/org/jpos/jpos/3.0.2-SNAPSHOT/maven-metadata.xml
Inside that file you will find entries like:
<snapshot>
<timestamp>20260509.153915</timestamp>
<buildNumber>15</buildNumber>
</snapshot>
and, more explicitly:
<snapshotVersion>
<extension>jar</extension>
<value>3.0.2-20260509.153915-15</value>
<updated>20260509153915</updated>
</snapshotVersion>
The value element is the exact version you can pin in your build.
So this development dependency:
implementation "org.jpos:jpos:3.0.2-SNAPSHOT"
can become this production dependency:
implementation "org.jpos:jpos:3.0.2-20260509.153915-15"
jPOS-EE artifacts are published the same way. For example, the jposee-dbsupport metadata for the current 3.0.2-SNAPSHOT line is available at:
https://jpos.org/maven/org/jpos/ee/jposee-dbsupport/3.0.2-SNAPSHOT/maven-metadata.xml
That metadata contains a timestamped artifact value such as:
<snapshotVersion>
<extension>jar</extension>
<value>3.0.2-20260511.202033-12</value>
<updated>20260511202033</updated>
</snapshotVersion>
So this development dependency:
implementation "org.jpos.ee:jposee-dbsupport:3.0.2-SNAPSHOT"
can become this production dependency:
implementation "org.jpos.ee:jposee-dbsupport:3.0.2-20260511.202033-12"
The same approach applies to other jPOS and jPOS-EE artifacts. Use the Maven metadata for the snapshot line you are consuming, find the timestamped artifact value, and pin that value for production.
A good workflow looks like this:
This gives you both sides of the tradeoff:
That is much better than freezing too early, drifting for months, and then having to absorb a large update under release pressure.
We are also fully aware of modern supply-chain expectations, including ISO/IEC 20243-style concerns around secure engineering, provenance, and dependency management.
jPOS and jPOS-EE are not just source repositories with occasional releases. They are maintained production components. We keep dependencies current, respond to reported issues, and produce appropriate SBOMs so customers can integrate jPOS artifacts into their own governance, scanning, and audit processes.
Dependency updates are not cosmetic. In many cases, they are the reason the latest snapshot is the right place to be: it includes the current security posture of the project, not the posture from the last final release.
Use -SNAPSHOT while you are building.
Pin the timestamped snapshot when you are deploying.
That is the balance we recommend: stay close to the latest and greatest during development, avoid accumulating technical debt, report issues early, and then lock down the exact artifact that your QA process approved for production.
The latest jPOS snapshot is not a random nightly build. It is the active, production-oriented development line of the project.
Treat it accordingly.
]]>start, stop, deploy, connect, disconnect, txn, and so on.
That structure is what makes tools such as the jPOS Log Viewer possible. The viewer can filter, facet, correlate, and render events because it is not guessing meaning from text. It is reading fields.
Until now, however, those typed audit events were effectively limited to the event classes shipped inside jPOS itself. That was fine for core runtime events, but it was not enough for real applications.
A jPOS-EE module, an application module, or a customer-specific extension may have its own operational event worth logging in a structured way:
Those should not have to be flattened into strings. They should be allowed to live next to the built-in jPOS audit events.
That is now possible.
Structured audit log events are serialized polymorphically. A typical payload contains a short type discriminator named t:
{
"t":"warn",
"warn":"disk space is low"
}
or:
{
"t":"txn",
"name":"authorization",
"id":123456
}
The t value is intentionally stable and compact. It lets a reader—human or machine—know what shape the rest of the object has.
Previously, the list of known subtypes was declared directly on AuditLogEvent using Jackson annotations. That meant adding a new event type required changing jPOS itself.
That does not scale. jPOS-EE and application modules need to define their own events without sending every type back to the jPOS core repository.
AuditLogEvent is still the marker interface for typed structured log payloads:
packageorg.jpos.log;
publicinterfaceAuditLogEvent{}
The difference is that the mapping between a stable type id and its Java class now lives in a registry.
External modules contribute mappings by implementing:
packageorg.jpos.log;
publicinterfaceAuditLogEventProvider{
Collection<AuditLogEventType>types();
}
Each mapping is represented by:
packageorg.jpos.log;
publicrecordAuditLogEventType(
String name,
Class<?extendsAuditLogEvent> clazz
){}
The registry loads built-in jPOS event types first, then discovers external providers using Java's ServiceLoader:
AuditLogEventRegistry.register(objectMapper);
jPOS' JSON and XML log renderers already call this registry, so modules usually only need to provide the event class and the provider.
Built-in type ids remain unchanged:
warn, start, stop, deploy, undeploy, msg, shutdown,
deploy-activity, throwable, license, sysinfo,
connect, disconnect, listen, session-start, session-end, txn
External providers cannot shadow those names. If a provider tries to register a conflicting type id, startup fails fast instead of silently producing ambiguous logs.
Suppose an application wants to log a structured event every time it imports a settlement file.
First, define the event:
packagecom.acme.settlement;
importorg.jpos.log.AuditLogEvent;
publicrecordSettlementImport(
String file,
int records,
int accepted,
int rejected,
long durationMs
)implementsAuditLogEvent{}
Pick a stable type id. Keep it short, descriptive, and unlikely to collide with another module. A project prefix is a good habit:
acme-settlement-import
Then provide the mapping:
packagecom.acme.settlement;
importorg.jpos.log.AuditLogEventProvider;
importorg.jpos.log.AuditLogEventType;
importjava.util.Collection;
importjava.util.List;
publicclassSettlementAuditLogEventProviderimplementsAuditLogEventProvider{
@Override
publicCollection<AuditLogEventType>types(){
returnList.of(
newAuditLogEventType(
"acme-settlement-import",
SettlementImport.class
)
);
}
}
Finally, register the provider using Java's standard service-provider mechanism. Add this file to the module JAR:
META-INF/services/org.jpos.log.AuditLogEventProvider
with one line:
com.acme.settlement.SettlementAuditLogEventProvider
Now the event can be added to a regular jPOS LogEvent payload:
importorg.jpos.util.LogEvent;
importorg.jpos.util.Logger;
LogEvent evt =getLog().createInfo();
evt.addMessage(newSettlementImport(
"settlement-2026年05月06日.csv",
1280,
1274,
6,
842
));
Logger.log(evt);
When written through the structured JSON log writer, the payload remains typed:
{
"ts":"2026-05-06T15:00:00Z",
"kind":"info",
"tags":{
"realm":"settlement.import"
},
"payload":[
{
"t":"acme-settlement-import",
"file":"settlement-2026年05月06日.csv",
"records":1280,
"accepted":1274,
"rejected":6,
"durationMs":842
}
]
}
That is the important part: no fragile parsing, no regular expressions, no guessing that the third token in a line is a status. The event has fields.
The immediate use case is QRest.
Today QRest can be noisy at connection boundaries: accepted connection, closed connection, timeout, and so on. That is useful at times, but it is not the same as an access log.
What we really want from an HTTP server is a single event per request, similar in spirit to what Apache or NGINX access logs provide, but structured from the start:
{
"t":"http-access",
"method":"POST",
"path":"/api/cards",
"status":201,
"bytes":348,
"durationMs":37,
"remoteAddr":"203.0.113.10",
"userAgent":"curl/8.7.1"
}
Once QRest emits that as an AuditLogEvent, the same structured log pipeline can ingest it. The Log Viewer can filter by status code, path, method, duration, or remote address. Operators can find slow requests, failed requests, noisy clients, and deployment regressions without scraping text.
This also creates a pattern for other jPOS-EE modules and application modules. They can publish domain-specific operational events while remaining compatible with the same structured log tools.
When defining your own audit events:
Structured logging is only useful if the structure is boring and predictable.
The SPI is the foundation. It lets jPOS remain small while allowing the ecosystem around it to grow structured events independently.
The next natural step is to use it in jPOS-EE, starting with QRest access events. After that, other modules can follow: authentication, sysconfig changes, crypto-service operations, scheduler activity, settlement workflows, and application-specific events.
The payoff is visible in the jPOS Log Viewer demo: once logs are structured at the source, operational tools no longer need to reverse-engineer text. They can index, filter, render, and correlate the data directly.
That was always the point of jPOS logging. This SPI simply opens that model to the rest of the application stack.
]]>kubectl context, pasting the right kubeconfig into the right terminal, or manually reconstructing which Helm values were used last time. The deployment path is part of the control surface. It needs the same auditability, separation, and repeatability as the ledger itself.
The jPOS Control Plane brings Kubernetes deployment into the operator console. It stores target cluster credentials encrypted at rest, registers Helm charts from OCI registries, turns JSON Schema-backed chart values into typed forms, binds everything into reusable release plans, and drives dry-run, preflight, apply, resources, and logs from one audited UI.
The video walks through the full deployment workflow from a fresh jPOS/MGL instance:
The demo deploys mgl-iso-sim, a jPOS-based ISO 8583 autoresponder, into an iso-sim namespace. The important part is not the sample workload; it is the deployment contract around it.
Kubernetes credentials are powerful. If an application stores them, it has to treat them as production secrets.
The jPOS Control Plane starts by bootstrapping an encryption keyring. The private half of the keyring is protected by an unlock passphrase, and the keyring itself is stored durably so it can survive redeployments. Cluster credentials are encrypted at rest under this keyring.
The demo also shows backup and verification. A .kbk backup file captures the keyring and wrapped data keys in a passphrase-protected envelope. Verification decrypts the backup and displays metadata—fingerprint, capture timestamp, matching-keyring status—without exposing key bytes.
This matters operationally. Losing the keyring means losing access to encrypted deployment credentials. Backups are not an afterthought; they are part of the deployment lifecycle.
A target cluster defines where the control plane is allowed to deploy. In the demo, jPOS/MGL is running inside the same Kubernetes cluster, so it can use in-cluster credentials: the pod's ServiceAccount token is read at operation time and used to construct a kubeconfig on the fly.
The target also carries a namespace allowlist. That allowlist is the first guardrail. A release plan cannot casually point at an arbitrary namespace; it must stay inside the cluster and namespace boundaries defined for that target.
This is deliberately narrower than "give the UI a kubeconfig and hope operators are careful." The target definition is an authorization boundary.
The chart catalog stores immutable references to Helm charts in OCI registries. Registering a chart pulls its metadata, records its version, and captures the chart-layer digest.
The demo registers mgl-iso-sim from an in-cluster OCI registry. Once registered, the chart becomes a selectable deployment artifact. Operators do not need to remember a registry URL, chart version, or digest each time they deploy. They choose from the catalog.
Charts that ship a JSON Schema get a typed values form. Instead of editing YAML by hand, operators see fields with appropriate input types, defaults, and validation. Raw YAML remains available as an escape hatch, but the default path is structured.
A release plan ties everything together:
Plans are reusable. Applying a plan creates a release record, but the plan remains as the stable definition of what should be deployed and where.
This is useful for audit and operations. "What did we deploy?" is not hidden in shell history. "Which values were used?" is not a pasted YAML fragment in a ticket. The plan is explicit, stored, and reviewable.
The jPOS Control Plane requires a successful dry-run before the first real apply on a release plan. Dry-run renders the chart and asks the Kubernetes API server to validate the manifests without creating resources.
After dry-run succeeds, the preflight panel becomes available. Preflight checks cluster reachability and namespace-scoped RBAC before Helm is allowed to perform the real operation. If the credentials cannot create or update the resources Helm needs, the operator learns that before the apply starts.
This sequence catches the common deployment failures early:
The goal is not to make deployment magical. The goal is to make failure visible before it becomes a half-deployed production incident.
The apply path uses Helm upgrade/install with atomic behavior. If the release cannot come up cleanly, Helm rolls it back instead of leaving a partial state behind.
Once the release succeeds, the control plane shows the resources owned by the release and the Helm history behind it. The pod logs panel reads logs through the same encrypted target credentials. Operators can inspect current or previous container instances, choose tail size, and filter client-side while troubleshooting.
That last part is important: deployment does not end when Helm exits. Operators need immediate visibility into what was created and whether it is alive.
The jPOS Control Plane is not trying to replace Kubernetes, Helm, or GitOps. Those tools remain the substrate. What it adds is an operator-facing control plane around a specific class of deployments: regulated systems where credentials, approvals, repeatability, and audit trail matter.
A terminal can deploy a chart. A CI job can deploy a chart. But neither automatically gives a back-office operator a safe, constrained, auditable workflow with encrypted cluster credentials, schema-driven values, required dry-run, RBAC preflight, atomic apply, and live logs in one place.
That is the point of the deployment plugin: make deployment an application workflow, not a shell ritual.
]]>ISO 8583 has long defined certain fields as composite containers—variable-length binary fields that carry structured sub-fields rather than a single atomic value. DE-55 (ICC data) is the most widely known: it holds raw BER-TLV data from the EMV specifications. Fields like DE-34 (electronic commerce data), DE-43 (card acceptor), and DE-49 (verification data) carry structured content using a similar pattern, formalized under the dataset model.
A dataset is a self-describing envelope inside a composite field. Each dataset has:
0x01–0xFE) that says what kind of data it containsMultiple datasets can appear in sequence inside a single field. DE-49, for example, can carry a dataset 0x01 for TLV-encoded currency data and a dataset 0x71 for bitmap-structured verification data, back to back in the same wire bytes.
ISO 8583 defines two encoding formats for dataset payloads.
TLV (Tag-Length-Value) is BER-TLV encoding, the same format used by EMV and ISO/IEC 7816. Tags are 1, 2, or 3 bytes: a leading byte whose low 5 bits are all set (0x1F) signals that more tag bytes follow, with bit 7 (the high bit) of each subsequent byte acting as the continuation flag. Lengths use BER definite form: values up to 127 fit in a single byte; for longer values, the first byte has bit 7 set and the lower 7 bits indicate how many additional bytes encode the actual length (so a 300-byte value needs two additional length bytes after the indicator byte). Values are raw bytes. DE-34 dataset 0x01 (authentication request data) and DE-55 (ICC data) both use TLV.
DBM (Dataset Bitmap) carries structured fields using an ISO 8583-style bitmap. The payload starts with a 2-byte bitmap whose bits announce the presence of the corresponding elements; element values follow in order. Bit 1 of each word is a continuation bit—if set, the next byte extends the bitmap. DBM datasets can also carry trailing TLV elements after the bitmap section for extended or proprietary data.
DE-34 datasets 0x73–0x77 (authentication response data) and DE-49 dataset 0x71 (verification data) use DBM. DE-55 uses TLV throughout.
CMF v3 support in jPOS introduces ISODatasetField, ISODataset, and DatasetPackager. The ISOMsg API extends to dataset paths using dot notation: "field.datasetId.elementId". The same path syntax works for both with() and get().
ISOMsg msg =newISOMsg("0100");
msg.setPackager(newGenericPackager("jar:packager/cmfv3.xml"));
msg.with("55.0x9F26",ISOUtil.hex2byte("1122334455667788"))// ICC: TLV tag 9F26
.with("55.0x9F10",ISOUtil.hex2byte("06011203A0B800"))// ICC: TLV tag 9F10
.with("55.0x95",ISOUtil.hex2byte("0000000000"))// ICC: TLV tag 95
.with("49.0x71.1","1")// Verification: DBM dataset 0x71, element 1
.with("49.0x71.2","1234");// Verification: DBM dataset 0x71, element 2
The path "55.0x9F26" addresses field 55, TLV element with tag 0x9F26. The path "49.0x71.2" addresses field 49, dataset 0x71, DBM element at bit position 2.
getString() and getBytes() accept the same paths:
ISOMsg unpacked =newISOMsg();
unpacked.setPackager(packager);
unpacked.unpack(packed);
// TLV elements from DE-55
byte[] cryptogram = unpacked.getBytes("55.0x9F26");
byte[] iad = unpacked.getBytes("55.0x9F10");
// DBM elements from DE-49 dataset 0x71
String flag = unpacked.getString("49.0x71.1");
String amount = unpacked.getString("49.0x71.2");
No casting, no intermediate objects. The path resolves through the field, the dataset, and the element in one call.
A single field can hold multiple datasets, mixing TLV and DBM in the same wire encoding:
msg.with("49.0x01.0x5F2A",ISOUtil.hex2byte("0840"))// TLV dataset 0x01: transaction currency code
.with("49.0x71.1","1")// DBM dataset 0x71: element 1
.with("49.0x71.2","USD");// DBM dataset 0x71: element 2
Reading back:
byte[] currency = unpacked.getBytes("49.0x01.0x5F2A");
String element1 = unpacked.getString("49.0x71.1");
String element2 = unpacked.getString("49.0x71.2");
One design goal of CMF v3 is that the wire bytes for DE-55 are identical between the legacy packager (cmf.xml) and the new dataset packager (cmfv3.xml). The ICC data that was previously an opaque ISOBinaryField containing raw BER-TLV is now an ISODatasetField containing a TLV ISODataset—but the bytes on the wire are unchanged. Existing integrations that write DE-55 with the old packager can be read by a system using the new one without protocol negotiation.
CMF v3 also defines DE-114 as the ISO 20022 transport field—a slot for carrying a complete ISO 20022 XML document (UTF-8 encoded, full Document element with namespace URI identifying the message type) alongside the ISO 8583 message. This enables jPTS to act as a bridge between ISO 8583 and ISO 20022 messaging without a separate protocol translation layer.
The CMF v3 draft covers the dataset model in detail and the DE-114 transport mechanism. It is a work in progress—sections are still being written and some field definitions are pending—but it is already usable as a reference for implementation work.
Early-access draft: jpos.org/doc/jPOS-CMFv3.pdf
]]>Freeform posting can handle all of these, but it puts the entire burden of correctness on the operator: right accounts, right sides, right layer, right formula—every time, by hand. Templates solve this. A template captures the invariant structure of a transaction and exposes only the parts that actually vary. Everything else is handled by the ledger.
A template is a versioned posting pattern. It defines:
When a template is used, the operator provides only the parameters. The rest is pre-wired. The ledger produces a balanced, validated transaction with a permanent link back to the template ID and version that created it.
The most visible use of parameters is amounts—but accounts can be parameterized too, and this is where templates become genuinely powerful.
A bill payment template might have a fixed debit to the customer's clearing account and a credit to whichever payee account the operator specifies. An accounts receivable payment template fixes the credit side to AR income and takes the debtor account as a parameter—so the same template handles every customer. A funds transfer template takes both the source and destination accounts as parameters, turning a two-sided entry into a single form with two lookup fields.
This means you can define one template for an entire class of transactions rather than one per account or counterparty. The structure is guaranteed; only the addressable parts are exposed.
Amount modes give you four options for each posting line:
| Mode | What it does |
|---|---|
| Fixed | A constant hardcoded into the template |
| Parameter | A named value the operator provides |
| Formula | An expression derived from parameters, balances, or other values |
| Balance | The current balance of an account—for closing or sweep entries |
Formulas unlock the most interesting cases. A fee template can compute the charge automatically:
fee = round(amount * fee_rate, 2)
One posting line debits the customer account for amount + fee, another credits the service account for amount, and a third credits the fee income account for fee. The operator enters one number; the template derives the rest.
Foreign exchange works the same way. An FX conversion template takes the source amount and a rate parameter:
target_amount = round(source_amount * fx_rate, 2)
Debit the foreign currency account for source_amount, credit the local currency account for target_amount. No manual arithmetic, no rounding errors, no mismatched entries.
A revolving credit interest template can compute the monthly charge from the outstanding balance:
interest = round(outstanding * monthly_rate, 2)
Run this as a batch job at end of month across every eligible account—same template, different parameter values, thousands of balanced transactions generated without operator involvement.
Every edit to a template creates a new version. The original is never modified. Every posted transaction stores the template ID and the exact version number that produced it—FEE_CHARGE:3, FX_CONVERSION:1, REVOLVING_INTEREST:2.
If a fee rate changes, you update the template. Transactions before the change are still accurately described by the version that created them. There is no ambiguity about what formula produced a given entry, even years later.
Templates cover a wide range of use cases across the transaction lifecycle:
Operator-driven transactions—emergency card funding, manual adjustments, dispute credits, goodwill reversals. An operator opens the template palette (⌘K), selects the template by name, enters the variable parts, and posts. Accounts are pre-wired, amounts are validated, and the result is auditable.
Back-office workflows—fee collection, interest accrual, foreign exchange settlement, interbank reconciliation. These follow fixed patterns with computed amounts. Templates make them fast and consistent.
Batch and scheduled jobs—end-of-month interest posting, revolving credit charges, account maintenance fees, dormancy charges. A job iterates over eligible accounts, calls the template with the appropriate parameters for each, and posts. The template enforces structure; the job provides the data.
Integration points—payment processors, card networks, and external systems often need to trigger specific ledger entries. Templates give these integrations a stable, versioned contract. The external system provides parameters; the ledger handles the accounting.
Templates prevent structural errors at entry time—wrong accounts, unbalanced entries, missing fields. Dynamic rules prevent business logic violations at commit time—balance limits, date restrictions, amount caps.
They are complementary. A template guarantees the form of a transaction; a dynamic rule guarantees the outcome satisfies your business constraints. Together they give you both automation and control.
]]>MGL solves this with Dynamic Rules. A dynamic rule is a CEL expression that the system evaluates on every posting, in real time, before the transaction is committed. If the expression returns false, the transaction is denied. No code change required. No deployment.
The video walks through the full dynamic rules lifecycle from scratch:
max_balance rule with the expression balance_after <= 100000.00auditor user, log in as them, and certify the rule; the system verifies the certifier is not the author and computes a cryptographic hash of the expressionThe most important design decision in dynamic rules is the separation between authoring and certification. A rule in DRAFT status is visible but inert—it cannot be assigned or enforced. Only a different user can certify it.
This is the Clark-Wilson integrity model applied to ledger rules: the person who writes a rule cannot activate it. Certifying a rule transitions it to CERTIFIED status, computes a cryptographic hash of the expression, and permanently records who approved it and when. Any subsequent tampering with the stored expression—directly in the database—is detected at evaluation time by comparing the hash, and the rule is refused.
The practical effect is that no single person can introduce a posting rule without review. This is the same control you apply to payment approvals and journal entries; it applies equally to the rules that govern them.
Rules are written in CEL (Common Expression Language), a safe, statically-typed expression language developed at Google. CEL expressions cannot access the network, the filesystem, or any state outside the activation context—they are pure functions of their inputs.
Every expression receives these variables:
| Variable | Type | Description |
|---|---|---|
balance | double | Account balance before this entry |
balance_after | double | Projected balance after this entry (balance + entry_amount) |
entry_amount | double | Signed impact of this entry (positive = increases balance) |
entry_layer | int | Layer number |
entry_is_debit | bool | True if debit |
entry_is_credit | bool | True if credit |
account_code | string | Account code |
account_type | string | "DEBIT" or "CREDIT" |
txn_post_date | string | Posting date (ISO format) |
txn_detail | string | Transaction memo |
txn_tags | string | Transaction tags |
param | string | Extra parameter from the rule assignment |
The param variable is particularly useful for reuse. A single entry_amount <= double(param) rule can be assigned to different accounts with different thresholds—one assignment might carry "5000", another "50000".
A certified rule does nothing until it is assigned. Assignments can be scoped at three levels:
Layer filtering is also available: you can restrict an assignment to specific layers, leaving others unchecked.
One rule can have multiple assignments. Assignments can be removed without modifying or re-certifying the rule.
When a transaction is denied, the error message includes everything needed to understand why:
Rule 'max_balance:1' denied transaction on account 1111
balance=40000.00, balance_after=110000.00
expression: balance_after <= 100000.00
The rule code, its version, both balances, and the expression are all present. No digging through logs.
]]>Both approaches have the same flaw. The derived figures live in a different place than the authoritative entries. They get stale. They diverge. Reconciling them back to source is always someone's problem.
MGL solves this with virtual layers. A virtual layer carries a formula instead of entries. Its balance is computed on the fly from physical layers—always derived from the same source of truth, always accurate to the query date, with no batch job required.
The video walks through virtual layer configuration and use from end to end:
L0 - L1), a Budget Execution layer (pct(L0, L1)), and an FX conversion layer (fx(L840, "USD/UYU"))The virtual layers appear in italic with a "(v)" suffix throughout the interface, making it clear at a glance which columns are derived and which are stored.
A physical layer stores entries. Every debit and credit posted to that layer is recorded in the database and contributes to its running balance. Layer 0 is the default; layer 840 typically holds USD amounts; layer 1 is often used for budget.
A virtual layer holds no entries. It defines a formula, and whenever its balance is queried the engine evaluates that formula against the balances of the physical (or other virtual) layers it references. The result is accurate to any query date you provide, including historical dates.
The practical effect is that physical layers stay clean—they contain only what was actually posted. Derived figures are always recomputed from that clean source, which means they can never drift out of sync with the underlying data.
Formulas reference layers using L followed by the layer number: L0, L1, L840. Standard arithmetic applies. A handful of built-in functions handle the cases that come up repeatedly in financial work:
| Expression | What it computes |
|---|---|
L0 - L1 | Actuals minus budget (variance) |
pct(L0, L1) | Actuals as a percentage of budget |
pct(L0 - L1, L1) | Relative variance percentage |
fx(L840, "USD/UYU") | USD layer converted to Uruguayan pesos |
L0 + fx(L840, "USD/UYU") | Local currency plus converted foreign amount |
max(L0 - L1, 0) | Overspend only — variance floored at zero |
if(L0 > L1, L0 - L1, 0) | Conditional: only positive variance |
pct() deserves a specific mention. Division in a formula (L0 / L1) will throw if the denominator is zero — which is the right behavior when a formula author explicitly divides. pct() returns zero on a zero denominator. In financial reporting, zero denominators are routine: new accounts, unfunded budget lines, inactive cost centers. A report that crashes on those cases is not usable in production.
FX conversion via fx() uses the rate for the query date, fetched from MGL's rate store. Historical balance queries use historical rates automatically—no separate configuration required.
Virtual layers can reference other virtual layers. A formula like if(L0 > 0, pct(L0, L1), 0)—show budget execution only when there are actuals—works because virtual layer resolution is recursive, with circular reference detection. If layer A references layer B and layer B references layer A, the engine throws rather than looping.
This composability means complex analytical dimensions can be built up incrementally from simpler ones, using the same formula language throughout.
Virtual layers participate in the same interfaces as physical layers:
GLSession.getBalance() accepts virtual layer IDs the same way it accepts physical ones; the distinction is invisible to callersThe layer administration table shows a Virtual badge on virtual layers. Elsewhere in the UI, italic text and the "(v)" suffix are the visual markers.
The alternative to virtual layers is materialization: running a job that writes derived values into the database so they're available at query time without computation. Materialization has its place—for large aggregations where query time matters more than freshness. But for the kinds of derived dimensions that appear in ledger reporting (variance, percentages, FX consolidation), the latency budget is generous and the freshness requirement is strict. A variance figure that was correct at last night's batch run is not the same as one that reflects this morning's postings.
Virtual layers resolve on the fly. They are always fresh. The formulas are defined once, in the journal configuration, and evaluated wherever a balance is needed. No batch window, no reconciliation step, no derived table to keep in sync with the source.
]]>jPOS 3.0.2 changes that.
ISO 8583:2023 defines several data elements—DE-034, DE-043, DE-049, DE-055, DE-071, DE-104, and others—as composite fields. Rather than having a fixed structure, a composite field contains one or more independent sections called datasets. This design lets a single field carry different categories of information in a flexible, extensible way.
Each dataset follows one of two encoding formats:
TLV datasets (identifier 0x01–0x70): sub-elements are encoded as BER-TLV tag-length-value pairs and can appear in any order. Multi-byte tags following ISO/IEC 7816-6 conventions are supported.
DBM datasets (identifier 0x71–0xFE): sub-elements are indexed by a second-level bitmap (similar in structure to the message bitmap). A DBM dataset can also carry a TLV continuation for rarely-used elements not covered by the bitmap.
Each dataset is preceded by its one-byte identifier and a two-byte big-endian length, giving the full wire structure:
[ id (1 byte) ][ length (2 bytes, big-endian) ][ content ]
DE-055 (ICC/EMV data) is a special case: it carries raw BER-TLV directly, with no dataset identifier or length envelope.
Core classes:
Dataset — interface representing a single datasetISODataset — mutable implementation with fluent builder supportDatasetElement — holds one decoded sub-elementISODatasetField — top-level field component that holds one or more datasetsPackagers:
DatasetPackager — reads and writes composite fields with full TLV and DBM supportICCDataPackager — special-case packager for DE-055 raw BER-TLVMessage API additions:
ISOMsg.with(field, value) — set and return this for fluent chainingISOMsg.without(field...) — unset and return this for fluent chainingmsg.with("55.0x9F26", bytes) / msg.without("55.0x9F26")Packager:
cmfv3.xml — a CMF packager that enables dataset-aware handling for DE-034, DE-043, DE-049, DE-055, DE-071, and DE-104. The original cmf.xml is unchanged for backward compatibility.With cmfv3.xml, you can build and parse composite fields using the same dot-path style used for nested sub-messages:
GenericPackager packager =newGenericPackager("jar:packager/cmfv3.xml");
ISOMsg msg =newISOMsg("0100");
msg.setPackager(packager);
// Dataset paths are: field.datasetId.elementId
msg.with(3,"000000")
.with(11,"123456")
.with(41,"TERMID01")
// ICC data (DE-055): field.elementTag — no dataset wrapper for EMV
.with("55.0x9F26",ISOUtil.hex2byte("1122334455667788"))
.with("55.0x9F10",ISOUtil.hex2byte("06011203A0B800"))
.with("55.0x9F36",ISOUtil.hex2byte("0022"))
.with("55.0x95",ISOUtil.hex2byte("0000000000"))
// Verification data (DE-049): TLV dataset 0x01
.with("49.0x01.0x5F2A",ISOUtil.hex2byte("0840"))
// Verification data (DE-049): DBM dataset 0x71
.with("49.0x71.1","1")
.with("49.0x71.2","1234");
byte[] packed = msg.pack();
Reading values back after unpacking:
ISOMsg unpacked =newISOMsg();
unpacked.setPackager(packager);
unpacked.unpack(packed);
ISODatasetField field55 =(ISODatasetField) unpacked.getComponent(55);
ISODataset icc =(ISODataset) field55.getDataset(55);// keyed by field number for ICC
byte[] cryptogram = icc.getBytes(0x9F26);// Application Cryptogram
byte[] iad = icc.getBytes(0x9F10);// Issuer Application Data
If you already have ICC data as raw TLV bytes from the legacy cmf.xml packager, cmfv3.xml produces byte-for-byte identical output. This is verified directly in the test suite:
// Legacy CMF: set DE-055 as raw bytes
ISOMsg legacyMsg =newISOMsg("0100");
legacyMsg.setPackager(newGenericPackager("jar:packager/cmf.xml"));
legacyMsg.set(newISOBinaryField(55, rawTLVBytes));
byte[] legacyPacked = legacyMsg.pack();
// CMFv3: set DE-055 using the fluent path API
ISOMsg datasetMsg =newISOMsg("0100");
datasetMsg.setPackager(newGenericPackager("jar:packager/cmfv3.xml"));
datasetMsg.with("55.0x9F26",ISOUtil.hex2byte("1122334455667788"))
.with("55.0x9F10",ISOUtil.hex2byte("06011203A0B800"))
.with("55.0x9F36",ISOUtil.hex2byte("0022"))
.with("55.0x95",ISOUtil.hex2byte("0000000000"));
byte[] datasetPacked = datasetMsg.pack();
assertArrayEquals(legacyPacked, datasetPacked);// identical wire bytes
Existing systems continue to interoperate without any changes.
ISOMsg.clone() now deep-clones dataset fields, so modifying a cloned message does not affect the original. The without() method also auto-removes the parent field when all its dataset elements are cleared:
ISOMsg clone =(ISOMsg) original.clone();
clone.without("55.0x9F10")// remove one ICC element
.with("55.0x95", zeroes);// add another
// Field 55 in clone is independent of field 55 in original
assertNotSame(original.getComponent(55), clone.getComponent(55));
Switch your packager from cmf.xml to cmfv3.xml:
<beanid="packager"class="org.jpos.iso.packager.GenericPackager">
<propertyname="configuration">
<value>jar:packager/cmfv3.xml</value>
</property>
</bean>
Existing code that treats composite fields as opaque binary fields continues to work unchanged with cmf.xml. No migration is required unless you want to take advantage of structured access.
The DatasetPackager is designed to be subclassed. You can define custom packagers with their own set of bitmap sub-elements for any composite field used in a specific network or scheme.
DE-018 (Message Error Indicator), which uses the same dataset addressing model to report parsing errors, will be wired up in a subsequent release.
]]>Complacency is obviously dangerous. But panic is expensive too. Teams start treating version labels, scanner output, and policy shortcuts as if they were a substitute for engineering judgment. They rush suppliers into unnecessary releases, create change-management noise, and sometimes end up with more operational risk, not less.
This post is about a calmer approach.
The failure mode is familiar.
A scanner lights up a dependency in red. The report says CRITICAL. Someone forwards it to management. The supplier gets an urgent request for a new jPOS final release. Change windows get compressed. Engineering judgment disappears.
That is not security. That is dashboard-driven operations.
Dependency scanners answer a worst-case question:
"Can this component be vulnerable somewhere under some conditions?"
Production engineering has to answer a different question:
"Is this system actually exposed here, now, in this environment?"
Those are not the same question, and confusing them is how teams end up doing high-risk work for low-value findings.
Panic patching has its own incident history.
A rushed dependency upgrade can break message flows, change serialization behavior, alter TLS defaults, trigger classpath conflicts, or pull in a new transitive tree that was never qualified in your environment. In regulated systems, the blast radius is larger because every rushed change also creates test pressure, approval pressure, and rollback pressure.
The risk is not hypothetical:
Mature teams evaluate both sides. They do not assume "change" is automatically the safer option.
A vulnerability in a library is not automatically a critical vulnerability in your system.
Component severity is often assigned without knowing:
That context matters more than the scanner color.
If a parser bug only matters when an attacker can feed crafted input into a reachable interface, and in your deployment that parser is only used for privileged internal configuration during controlled startup, then the component finding may be real while the system-level exploitability is negligible.
That is not denial. That is classification.
I would avoid the phrase "ignore" because auditors hate it and because it encourages sloppy thinking. The real question is whether you can defer, override, or accept the risk with evidence.
Use this checklist in order:
Use the answers to make a decision:
That last bucket is where many "critical" findings belong, and that is precisely why severity labels should not drive release policy by themselves.
Also: you do not always need a new upstream jPOS release. If the issue is a transitive dependency and jPOS is otherwise compatible with the fixed library version, a dependency override in Maven or Gradle is often the lightest effective remediation.
That is not a hack. That is basic dependency hygiene, and it is one of the reasons build tools exist.
Suppose a scanner flags jackson-core 2.20.1 pulled transitively by jPOS 3.0.1, and your policy requires consuming a newer approved version.
That does not automatically mean you need to stop and wait for a new jPOS final release. If your qualification shows jPOS works correctly with the newer jackson-core, you can override it in your own build and move forward.
dependencies {
implementation("org.jpos:jpos:3.0.1")
}
configurations.all {
resolutionStrategy.eachDependency { details ->
if(details.requested.group =="com.fasterxml.jackson.core"&&
details.requested.name =="jackson-core"){
details.useVersion("2.21.2")
details.because("Override transitive dependency to approved version")
}
}
}
If you prefer constraints:
dependencies {
implementation("org.jpos:jpos:3.0.1")
constraints {
implementation("com.fasterxml.jackson.core:jackson-core:2.21.2"){
because("Use approved jackson-core version")
}
}
}
<dependencies>
<dependency>
<groupId>org.jpos</groupId>
<artifactId>jpos</artifactId>
<version>3.0.1</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.21.2</version>
</dependency>
</dependencies>
</dependencyManagement>
Or declare it directly:
<dependencies>
<dependency>
<groupId>org.jpos</groupId>
<artifactId>jpos</artifactId>
<version>3.0.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.21.2</version>
</dependency>
</dependencies>
Regulated environments do not give you permission to think less. They require you to think more clearly and document it better.
PCI DSS, external scans, and audit programs create pressure, but they do not eliminate the difference between a false positive, a non-reachable finding, and a materially exploitable defect. The mistake is treating every scanner result as self-proving.
What auditors usually need is not panic. They need a defensible record:
What to tell an auditor:
"The scanner detected component X and mapped it to CVE-Y. We validated the version and reviewed exploitability in this deployment. The vulnerable function is not reachable from external interfaces, exploitation requires privileged access, and compensating controls include authentication, segmented network access, and controlled configuration management. We have documented the risk, assigned an owner, and scheduled review at the next qualified release window."
That is a serious answer. "The scanner is noisy" is not.
If you need an exception, write it like an engineer, not like a lawyer hiding a problem. Be explicit about the conditions that make the risk acceptable and the trigger that would change the decision, such as public weaponization, changed exposure, or a low-risk patch becoming available.
That is how you avoid checkbox security without picking a fight with compliance.
One of the more persistent misconceptions is that a jPOS SNAPSHOT means "random untested code" while a final release means "safe."
That is nonsense.
In a disciplined project, a jPOS snapshot and a jPOS final release can come from the same codebase, pass the same CI pipeline, and differ mostly in publication semantics. If the build is reproducible, the commit is pinned, the artifact is immutable in your internal repository, and you run your own qualification, a snapshot is not inherently reckless.
A snapshot is acceptable when all of this is true:
It is not acceptable when:
The real comparison is not jPOS SNAPSHOT versus "perfectly blessed release." It is often pinned, tested snapshot versus rushed jPOS release cut under pressure.
And in that comparison, the rushed release is frequently the riskier object. It may include unrelated changes, abbreviated testing, approval theater, and false confidence created by a prettier version label.
A version suffix is not a security property.
Your scanner flags a CRITICAL issue in a logging dependency. The affected function is a network appender your deployment does not enable. Logs are written locally, configuration is managed through infrastructure code, and only privileged operators can change it.
Framework result:
Decision: defer or accept with documentation, then upgrade in a normal release window. If an attacker can already alter production logging configuration, the logging CVE is not your primary problem.
A transitive dependency has a parser vulnerability. The scanner reports HIGH. In your deployment the parser is only used by an admin-only batch import tool, not by the online transaction path. The batch function sits behind VPN, SSO, role-based access control, and change approval.
Framework result:
Decision: do not demand an emergency jPOS release. Override the dependency in your own build, qualify it, document the interim risk posture, and move on.
Security work gets worse when people outsource judgment to scanner colors, version suffixes, and ceremony.
Patch real risk quickly. Defer low-value findings with evidence. Use dependency overrides when that is the cleanest fix. Accept that a pinned, tested jPOS snapshot can be safer than a rushed final release. And stop pretending that "critical in a library" automatically means "critical in production."
Good security engineering is not about reacting faster to noise. It is about being able to prove why something matters, why something does not, and what trade-off you are making when you change a running system.
]]>In that time, AI agents have gone from research curiosity to production infrastructure. The Model Context Protocol gave language models a standard way to call tools. Autonomous agents that read messages, query databases, push commits, and manage tasks are now deployed in organizations running payment systems.
The standard has nothing to say about any of this.
To its credit, the PCI Security Standards Council has moved. In March 2025 it published Integrating Artificial Intelligence in PCI Assessments—its first AI-related guidance document. It's a serious piece of work.
But it addresses a narrow question: how should QSAs use AI tools during assessments? It says AI is a tool, not an assessor. It requires human oversight. It asks assessors to disclose AI use to clients and to document their policies. All reasonable.
What it doesn't address is the question payment practitioners are actually wrestling with: what happens when you deploy an AI agent in your own environment?
An AI agent in a payment environment is not an abstract risk. It is a concrete software system with:
PCI DSS was designed around the concept of human users and service accounts. Requirements 7 and 8 define access control and authentication in those terms. An AI agent is neither a human nor a conventional service account—it is an autonomous system capable of initiating actions across multiple systems, adapting to responses, and being manipulated through its inputs.
This is new territory, and the standard doesn't map to it cleanly.
The most dangerous AI agent deployment pattern I've seen is also the most common: an agent running on an operator's personal workstation.
It starts innocuously. Someone installs an AI assistant on their laptop, connects it to their credentials, and begins using it. The assistant is useful, so it gets more access. Before long, it's running with an active SSH agent loaded with private keys, a VPN session open to internal networks, browser cookies and stored tokens, and access to cloud storage and development databases.
From a PCI standpoint, this is a significant finding waiting to be written. A prompt injection attack—malicious instructions embedded in data the agent processes, like a Mattermost message or a task description—could leverage all of those credentials. The blast radius is the same as a fully compromised operator workstation, but the attack surface is broader because the agent processes untrusted external data continuously.
The fix is architectural: agents that touch production environments must run on dedicated hosts with dedicated identities. Not a developer's laptop. Not a shared server. A machine provisioned for that purpose, with scoped credentials, no loaded SSH keys beyond what the agent specifically requires, and no active VPN sessions outside its declared scope.
When an AI agent communicates with a human operator, the choice of channel matters for PCI purposes.
Signal is end-to-end encrypted by default. Telegram encrypts messages in transit but stores them on vendor servers by default (end-to-end encryption requires Secret Chats, which aren't available for bots). WhatsApp stores messages on Meta's infrastructure and is subject to Meta's data retention practices—if operational data touching the CDE passes through it, Meta enters scope as a Third Party Service Provider.
This is not a hypothetical. Instructions to an AI agent are operational data. If those instructions cause the agent to take actions in a payment environment, the channel carrying those instructions is relevant to PCI scope analysis.
The Model Context Protocol changed the shape of this problem. MCP servers expose tools—file access, database queries, API calls, shell execution—to a language model. When an MCP server is deployed with access to a payment system, the language model invoking it inherits that access.
From a Requirement 6 perspective, MCP server software must be treated as a system component: included in the SBOM, subject to vulnerability scanning, and developed with input validation applied to tool parameters—because the language model calling those tools is, from a security standpoint, an untrusted input source.
Prompt injection is the AI-era equivalent of SQL injection. An attacker who can influence the data an AI agent processes can sometimes influence what it does.
In a payment environment, the surfaces for injection are everywhere: Mattermost channels, task descriptions, document contents, API responses. An agent that reads external data and takes consequential actions based on it is exposed to this class of attack.
The best mitigation is scope restriction. An agent that can only read cannot exfiltrate data or modify systems even if successfully injected. Every write operation—every action that creates, modifies, or deletes data in a production system—should require explicit human confirmation. Not because the agent is untrustworthy, but because the inputs it processes are.
The standard will catch up. Standards always do, eventually. In the meantime, a practical baseline for AI agents in payment environments:
If you are building jPOS-based payment infrastructure, we've added detailed guidance on this topic to the jPOS PCI DSS Compliance Guide. Requirement 12 now includes a full section on AI agents as a new category of privileged system, covering deployment architecture, messaging channel evaluation, LLM provider TPSP classification, and a governance controls table. Requirement 6 addresses MCP server security requirements and prompt injection as a vulnerability class.
The industry is figuring this out in real time. The more practitioners who publish what they're actually doing, the faster the collective practice advances.
]]>This guide is the result of that experience, made freely available to the entire jPOS community.
The PCI DSS 4.0.1 Compliance Guide for jPOS-Based Systems is an 850-page, audit-ready reference document that maps every requirement, sub-requirement, and sub-sub-requirement of PCI DSS 4.0.1 to concrete jPOS implementations. It covers the full scope of The Payment Platform—jPOS, jPOS-EE, jCard, jPTS, and tpp-commons—and extends to supply chain security, cryptographic key management, and operational governance.
Each section is written for a mixed audience: developers who need implementation specifics, CISOs who need governance evidence, and QSAs who need to understand how controls are realized in practice. Every requirement includes three perspectives—customer, auditor, and adversary—so the guidance is grounded in how controls actually get tested and, more importantly, how they actually get attacked.
The guide includes:
Download the PCI DSS Guide (PDF)
The document is free to use, adapt, and build on. Use the policy templates as a starting point, replace the Transactility branding with your own, and tailor the jPOS-specific guidance to your deployment. No strings attached.
Reading a compliance guide and operating a secure payment system are two different things. If your organization is preparing for a PCI DSS assessment, responding to a security incident, or building a payment platform and wants the team that built jPOS directly involved—in architecture reviews, policy development, QSA preparation, or implementation—we are available.
Nobody knows the jPOS codebase, its security model, or its real-world failure modes the way the people who built it do.
When it matters most, bring the core team into the room.
transactility.com
grep. Filtering is awk. Correlation is copy-pasting timestamps and hoping for the best. The infrastructure to make that tolerable — log shippers, ingestion pipelines, index clusters — adds cost and complexity, and the result is still a flat text interface.
jPOS' Log Viewer takes a different approach, and it can do so because jPOS's logging is different at the source.
jPOS has always had a typed, structured event model at its core. Every log event is a self-describing object—not a formatted string, but a record with well-defined fields: timestamp, kind, realm, host, trace identifier, and a typed payload that varies by event type.
This wasn't retrofitted onto the system. It's the foundation. When jPOS 3.0.0 was released, structured audit logging was formalized as a first-class feature—typed events with PCI-aware field protection, flat metadata, and a JSONL serialization that makes log events portable without losing structure.
The Log Viewer indexes these structures directly. That's what makes faceted search, histogram drill-down, and trace correlation possible without any additional transformation layer.
The video above walks through the viewer from an operator's perspective:
Histogram and time navigation. The event volume histogram at the top is interactive. Clicking a bar zooms into that time window—the date range updates and results narrow to that slice. During incident investigation, this is the fastest way to go from "something happened around 14:30" to "here are all the events in that two-minute window."
Faceted filtering. Column headers act as facet controls. Clicking Kind opens a checkbox dropdown for event types—info, warn, send, receive, deploy, and so on. Clicking Realm narrows to a specific subsystem. Facets compose: combining kind and realm gives an operator exactly the signal they need, without writing queries.
Full-text search with Lucene syntax. The search box accepts field-specific queries: kind:send OR kind:receive, realm:channel, or plain text matched across the payload. Results update immediately.
Multi-event comparison. Selecting multiple rows opens them in a modal with a Plain tab and a JSON tab. The Plain view shows time deltas between events—you can read the exact duration from channel receive through transaction manager processing to response. The JSON tab exposes the raw structured source, ready to copy into an incident report or pipe into another tool.
Trace correlation. Many events carry a trace identifier that spans the entire lifecycle of a request. Clicking the trace link on any row filters to all events sharing that trace—the full journey from channel to transaction manager and back, in one view.
Offline forensic import. The DevTools workspace includes a log ingestor. Drag and drop JSONL log files—from production, from staging, from a colleague's environment—and analyze them with the same faceted search, histogram, and trace tools. Multiple files can be imported together.
Generic log aggregation tools treat every system the same way. They expect text, they parse it heuristically, and they provide generic search. That works, but it stops there.
Because jPOS' Log Viewer is built on top of jPOS's typed event model, it can go further. A tag carrying a transaction ID can link directly to data in the general ledger. A deploy event renders with the full list of deployed components, not a raw XML blob. A channel event shows connection state and timing in a purpose-built renderer.
This is the practical difference between indexing structured data and parsing text that was never meant to be parsed.
The viewer is already useful for operations and debugging. The next steps are deeper integration with jPOS' transaction data — linking log events to their corresponding GL postings, surfacing cost and timing information in context, and making the operational picture and the financial picture visible from the same interface.
]]>Notable in this release:
HttpClient API (JEP 517)No source changes were required in jPOS or jPOS-EE. The tutorial projects needed a minor
import path correction: ISOUtil had moved from org.jpos.util to org.jpos.iso in an
earlier jPOS release and the tutorials hadn't caught up yet.
Most ledger designs treat currency as an afterthought — a field on a transaction, a conversion applied at reporting time, a problem deferred to the spreadsheet team. MGL treats it differently. Exchange rates are a first-class part of the data model, imported automatically, stored with full history, and wired directly into the layer architecture so that multi-currency consolidation happens at query time, not at batch time.
The short demo above walks through the FX module from the perspective of someone setting up and operating a live system:
It is a deliberately short tour. The interesting part is not the UI — it is what the UI is sitting on top of.
MGL imports rates from two sources out of the box: the Frankfurter API, which sources data from the European Central Bank, and the Banco Central del Uruguay for USD/UYU and EUR/UYU pairs. Both importers run continuously in the background, backfilling historical data and polling for new rates at a configurable interval.
Each rate is stored with its currency pair, date, rate value, and source identifier. The unique constraint on (pair, date) means upserts are atomic and idempotent — you can run importers in parallel without creating duplicates or races.
The most recent stored date is always available to each importer, so if the system goes offline for a few days it will automatically resume backfilling from where it left off, without manual intervention.
Adding a new rate source is also straightforward: implement a QBean that calls FXRateManager.upsert() with your pair, date, rate, and a source identifier. The rest of the system picks it up automatically.
The real reason to care about FX rates in MGL is virtual layers.
Physical layers in a journal hold actual posted entries — transactions with real debit and credit amounts in a specific currency. Virtual layers hold no entries. Instead, they carry a formula that computes a balance on demand from other layers, and that formula can invoke fx() to convert amounts using a rate from the database.
So if a journal has three physical layers — layer 0 for Uruguayan pesos, layer 840 for US dollars, layer 978 for euros — a single virtual layer can consolidate all three into a reporting currency:
L0 + fx(L840, "USD/UYU") + fx(L978, "EUR/UYU")
When you query that virtual layer's balance for a given date, the engine reads the balances of the three physical layers, looks up the exchange rates for that date, applies the conversions, and returns the sum. The original postings are never touched. The ledger stays clean. You get a consolidated balance that reflects the actual exchange rates in effect on any historical date you care to query.
This matters for two reasons. First, it removes the need for re-valuation entries or synthetic postings whenever rates change. Second, it means you can go back to any point in time and get an accurate consolidated view using the rates that were actually in effect that day, not today's rates applied retroactively.
One small but practical detail: if you store a rate for USD/UYU, the engine automatically makes UYU/USD available as 1 / rate. You only need to store rates in one direction. The inverse lookup is handled in the DBFXProvider, which is the component that GLSession uses when evaluating virtual layer formulas.
There is nothing glamorous about exchange rate management. But it is the kind of thing that quietly breaks accounting systems that were not designed for it from the start.
MGL's approach is simple: import rates from authoritative sources, store them with full history, handle gaps by falling back to the most recent available rate, and make them directly addressable from the layer formula language. No spreadsheets, no manual lookups, no batch jobs that need to run before month-end close.
More to come as the virtual layer documentation and formula reference take shape.
]]>The core ledger is already in place, but what makes it interesting is how the pieces are starting to come together: multi-layer accounting, virtual layers for reporting and FX-driven consolidation, high-volume controller-style account structures, and an AI assistant that can operate the system through the same tools and permission model used by the web UI.
The short demo above starts from a blank system and goes through the basic flow:
That flow matters because it shows the direction of the project. MGL is not starting from scratch. It is based on miniGL, a previous generation that, despite the "mini" name, has been used in production as the system of record for very large jCard deployments, including at least two known cases with more than 1.5 billion cards on file.
So the point of MGL is not to prove that a ledger can exist. That part was settled a long time ago. The point is to address the pain points observed in those very large systems, while pushing the model forward with better layering, better operational tooling, and better integration points, including AI-assisted workflows.
One of the most interesting features is the virtual layer support.
Physical layers let us post independent values inside the same journal, typically by currency or reporting dimension. But virtual layers are where things get really interesting: instead of storing entries, they compute balances on demand from formulas.
That gives us a very clean way to build consolidated reporting views. You can keep native postings in their original layers and then define a virtual layer that combines them, applies FX rates, or derives reporting values without polluting the base ledger with synthetic entries.
This keeps the original postings intact while still making it possible to answer questions such as:
That's an important distinction. This is not just a matter of attaching currencies to entries; layers are treated as a first-class accounting dimension, and virtual layers make it possible to compute on top of that model.
Another important piece is support for controlled accounts, implemented in the engine through controller-style account structures.
Traditional chart hierarchies are fine for ordinary financial statements, but they start to hurt when you need to group very large numbers of subsidiary accounts. Customer wallets, merchant positions, vendor balances, or other high-cardinality structures need something more scalable than a tree that eagerly loads children.
MGL's controller-oriented model is meant for exactly that scenario. The parent can represent the controlled aggregate, while the underlying final accounts can grow into the thousands or millions without turning balance queries into a disaster. That opens the door to using the same ledger engine both for classical accounting and for much more operational, high-volume sub-ledger work.
This is one of the areas where the project starts to separate itself from simpler GL implementations.
The AI integration is also becoming much more concrete.
The demo is not using AI as a decorative chatbot. It is using the assistant to do useful ledger work:
What matters here is not the chat UI itself, but the execution model behind it. The assistant uses the same backend tools and respects the same authorization model as the rest of the application. That means the AI is not a side channel; it is another client of the platform, with the same need for permissions, validation, and auditability.
This is the only way AI belongs in systems like this. It has to help operators move faster without bypassing the controls that make the ledger trustworthy in the first place.
There is still a lot to do, of course, but the project already has a recognizable center of gravity:
That is enough to start seeing where MGL wants to go.
The old miniGL idea was always useful, but this is starting to feel like something much broader. MassiveGL is a better name for the current ambition.
More demos and documentation will follow as the model continues to settle down.
]]>extraPaths for distnc / zipnc tasks The distnc and zipnc tasks produce a "no-config" distribution archive — binaries and JARs, without environment-specific cfg/ or deploy/ files. That's useful for packaging the immutable part of your app separately from configuration.
Until now those tasks only included src/dist/bin/. If your project ships additional static assets — documentation, HTML files, static resources, or web content — they were silently left out.
You can now declare extra directories to include:
jpos {
extraPaths =['html','docs','webroot']
}
Each entry is a path relative to src/dist/. Token replacement and the same binary-file handling (images, PDFs, WARs are copied raw; text files go through @token@ substitution) apply to extra paths exactly as they do to the rest of the distribution.
GitRevisionTask — the task that writes revision.properties — previously used Git.open(projectDir), which requires the Gradle project root to be exactly the git repository root. This caused silent revision=unknown output (or worse, task failures) in multi-module setups where the Gradle root sits inside a larger repository.
The task now uses FileRepositoryBuilder.findGitDir(), which walks up the directory tree to locate .git, exactly the way the git CLI itself does. Worktrees and nested modules all work correctly. And when no git repository is found at all (non-git projects), the task now logs a WARN instead of silently swallowing the error.
The plugin itself now builds with Java 25.0.2-amzn and Gradle 9.4.0.
plugins {
id 'org.jpos.jposapp' version '0.0.17'
}
If you already have a local copy of jPOS-EE (master or next), please note there are REQUIRED ACTIONS at the end of this blog post.
Following the same branch model we adopted for jPOS last December, jPOS-EE is now aligned with JEP-14 — The Tip & Tail Model of Library Development.
The rename is straightforward:
next branch (jPOS-EE 3.x) becomes main — the tip, where active development happens.master branch (jPOS-EE 2.x) becomes tail — the stable series, receiving only critical fixes.next branch: git branch -m next main
git fetch origin
git branch -u origin/main main
git remote set-head origin -a
master branch: git branch -m master tail
git fetch origin
git branch -u origin/tail tail