- MiddlewareSpecExtension "edi" 1.0.0 under /qqq/v1/qbit/edi:
GET guides/{guideId}/tree, GET maps/{mapId}/target,
POST sandbox/preview (unsaved rule overrides, base64 uploads up to
5 MB, PHI suppression) and POST maps/{mapId}/validate.
- MapPreviewService shared by the Preview Map action and the endpoint;
MapRuleValidator for paths, transforms and arguments.
- Committed fragment src/main/resources/openapi/qqq-v1/edi.yaml with a
contract test; HTTP tests in strict raw-route mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 KiB
QBit EDI: design
Status: Approved (owner, 2026-10-13: spec first, then build) · drafted 2026-10-13 · Author: James Maes (drafted with Claude)
QRun-IO/qbit-edi (Apache-2.0) is a full EDI application qbit for QQQ. It handles the transports
(AS2, SFTP, VAN mailboxes, HTTPS, email), carries the EDI documents and their implementation guides
as data, runs inbound and outbound flows with 997, 999 and TA1 acknowledgements, and lets each
trading partner's customizations be expressed as versioned data rather than code.
Owner request (verbatim)
For EDI, i guess that would be more of a normal application qbit that would just handle all of the transport methods (AS2, etc) and then have all of the edi documents built in, with handling for edi flows, 997s etc and handling customizations of them, etc.
Owner decisions
Owner decisions (2026-10-12): all recommendations accepted. Every open question in section 17 is decided as this spec recommends ("they are approved. we will adjust as needed once we can view them working").
Owner decisions (2026-10-13):
- Spec first, then build, in the phases of section 16.
- First documents (X12):
- retail and supply chain: 850, 855, 856, 810, 846, 860;
- warehouse and logistics: 940, 945, 214, 204;
- healthcare (HIPAA): 837 (P and I), 835, 270/271, 276/277, 834;
- acknowledgements: 997, 999, TA1.
- Versions: X12 004010 and 005010, with the 005010 HIPAA TR3s for healthcare. EDIFACT is designed in now and built last.
- No files or filesystem: just the database and S3 ("there are no files or filesystem - just the database and s3"). Raw EDI documents, AS2 messages and MDNs, and certificates live in object storage and the database, never on local disk. Private keys come from secrets (environment variables). Every service is stateless, so any replica can process any message.
- Inbound SFTP comes through qbit-file-gateway (SFTP only in v1, no FTPS, owner decision),
using its published
apipackage (section 5.3). - No kof22 adoption. kof22 does not need EDI. The demo runs in
qqq-sample-project(section 15).
Mandatory standards that shape this design
- Data lives in standard, visible QQQ tables with full record screens, secured by permissions and
record locks. Tables are
edi_*; permissions use the full nameedi.*. - Processes are table or record actions with plain labels, never in the nav. Record actions use the standard streamed-ETL Review and Result summary lines; no custom dry-run forms.
- One app home that owns the EDI data, built from typed widgets (no
RawHTML, guardrail G5). The nav lists only the main tables; sub-tables are child lists on their parents. - The API matches the admin. Main tables publish ESB table events and are searchable in Quick Search.
- UI endpoints are spec'd
MiddlewareSpecExtensions under/qqq/v1/qbit/edi/...with a committed fragment and a contract test (G3). Machine endpoints (AS2, HTTPS receive, MDNs) are non-UI routes under/qqq/hooks/edi/.... - No dependency on another qbit's internals (G1). EDI depends only on the published
apipackages of qbit-object-storage and qbit-file-gateway, both optional. Alerts and notifications go through coreQAlerts. - No stand-in without
STAND-IN(core=..., removeBy=...)(G4). Pinned core baseline, no SNAPSHOT ondevelop(G7). - Readable at all widths, light and dark. Real data only on the home.
1. Goals and non-goals
Goals
- Exchange X12 with any trading partner over AS2, SFTP, a VAN mailbox, HTTPS or email, configured as data per partner.
- Never lose a document: store the raw bytes first (encrypted), then process from a durable inbox with retries and a dead letter. Every step is idempotent and replayable.
- Validate against implementation guides that are data (segments, elements, usage, conditional rules, code lists), per standard and version, with versioned per-partner overrides.
- Acknowledge automatically (TA1, 997, 999), and track the acknowledgements we expect from partners, alerting when they are overdue.
- Map documents to and from QQQ records with versioned, data-driven maps, and apply inbound documents through normal QQQ actions as a service account, so table permissions, validation, customizers, audit and ESB events all apply.
- Make operations visible: a document timeline, search by business keys (PO, ASN, invoice and claim numbers), an exception work queue, resend and reprocess, and a home showing volumes, overdue acks and errors.
- Carry PHI safely: classify healthcare content as PHI so the secured-data tiers and the canary egress tests apply, and encrypt raw documents at rest.
Non-goals (v1)
- A VAN service of our own. We connect to VANs as mailboxes.
- Business-level responses beyond acknowledgements (824 Application Advice, 864 text). The model leaves room for them (section 9.6).
- Shipping licensed X12 or TR3 text (section 7.4).
- CAQH CORE real-time 270/271 (SOAP and MIME envelope). Batch 270/271 is in scope; CORE connectivity is a later transport plugin (section 5.6).
2. Architecture
partner ──AS2──► /qqq/hooks/edi/as2/{transport} ─┐
partner ──SFTP─► qbit-file-gateway INBOX ("edi") ─┤
VAN mailbox ◄─poll── SFTP poller (scheduled) ─┤ 1. store raw bytes (object storage, encrypted)
partner ──HTTPS► /qqq/hooks/edi/https/{transport} ─┤ 2. insert edi_inbox row (PENDING), return
email (qbit-email inbound, later) ─┘ (MDN / 200 / FileAcceptance.accepted)
│
edi_inbox worker (scheduled, claims rows, retries, dead letter)
▼
split interchanges ─► envelope check, TA1 ─► partner + agreement lookup ─► duplicate check
─► per ST: guide validation (StAEDI + guide rules) ─► 997/999 ─► map ─► apply as service account
─► statuses, timeline, exceptions (edi_exception) ─► ack reconciliation for inbound 997/999/TA1
host record change ─► ESB table event / record-change listener ─► edi_outbound_request (same tx)
─► outbound worker: map ─► guide validate ─► envelope (control numbers) ─► store raw
─► edi_outbound_message ─► transport send (AS2 / SFTP push / gateway OUTBOX / HTTPS / email)
─► MDN or delivery ─► expected ack due date ─► 997/999 reconcile or overdue alert
Package layout (com.kingsrook.qbits.edi):
| Package | Holds |
|---|---|
| (root) | EdiQBitProducer, EdiQBitConfig, EdiRuntime (the configured services) |
metadata |
the only scanned package: tables, PVS, joins, widgets, processes, app |
model |
table entities (EdiPartner, EdiAgreement, ...) |
x12 |
StAEDI reading and writing, envelopes, separators, control numbers, splitting |
guide |
guide model, structural validator, rule engine, SNIP levels, built-in guide loader |
ack |
997, 999 and TA1 builders, inbound ack reconciliation, overdue monitor |
map |
map model, path language, transforms, inbound apply, outbound build, sandbox preview |
flow |
inbox and outbound workers, state machines, reprocess and resend |
transport |
EdiTransportPlugin SPI and the plugins: as2, sftp, gateway, https, email |
storage |
PayloadStore (object storage with envelope encryption; database fallback) |
security |
secrets by environment-variable name, certificates, classification, service session |
hooks |
the /qqq/hooks/edi/... route provider |
uiapi |
spec'd /qqq/v1/qbit/edi/... extension (phase f) |
edifact |
EDIFACT envelopes and CONTRL (phase g) |
3. Platform and dependencies
| Dependency | Scope | Why |
|---|---|---|
QQQ 4.1.0-RC.6 (then each new RC, G7) |
compile | core classification, QAlerts.raise/resolve/notify |
qqq-middleware-javalin |
optional | hooks route provider; UI API extension (RC.6) |
qqq-esb |
optional | outbound trigger from ESB table events |
qqq-backend-module-filesystem (SFTP) |
compile | SFTP push and VAN polling. This is core's SFTP client, the one qbit-sftp-data-integration uses; EDI uses it directly instead of depending on that qbit (G1). Remote SFTP only; nothing local. |
io.xlate:staedi 1.26.x (Apache-2.0) |
compile | streaming X12 and EDIFACT reader and writer with schema validation |
com.helger.as2:as2-lib 5.1.x (Apache-2.0) |
compile | AS2 send, receive, MDN, S/MIME via BouncyCastle |
qbit-object-storage (its api package only) |
optional | raw payload storage (section 10) |
qbit-file-gateway (its api package only) |
optional | inbound SFTP and partner-pull OUTBOX (section 5.3) |
Core features checked against the v4.1.0-RC.6 tag:
| Need | Core | RC.6 status |
|---|---|---|
DataClassification on fields (PHI) |
actions.ai.DataClassification (#1142) |
Uses AiFieldMetaData.withClassification and CONFIDENTIAL AiTableMetaData defaults; classification stand-in removed, including healthcare test fixtures |
| User notifications | QAlerts.notify (#1125) |
Assign notifies the assignee; new/reopened rejection and dead-letter exceptions notify current partner assignees (section 4.4) |
| UI API extension point | MiddlewareSpecExtension (#1128) |
Available in core; sandbox remains a record action until phase f |
| Contingent choice dependency behavior | PossibleValueDependencyBehavior, possibleValueSourceDependsOn |
Neither API exists in the tag; existing filters retain shared choices before a partner is selected |
| Secured-data tiers and log redaction | FieldAccessHelper, logging hook (#1147) |
Spec only; no implementation in the tag. Retain PhiLogScope, STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7) |
RC.6 baseline: classifications and notifications now use core APIs. The PHI logging bridge
remains because RDBMS query failures still log filters and QLogger has no classified-value
redaction hook; review/remove it by RC.7 under #1147. FULL and HIDDEN field-lock canaries run;
MASKED tiers await FieldAccessHelper. Host logging/audits outside PHI scopes still need protection.
The file-gateway api ships in that qbit's phase (a). Until it is on a published SNAPSHOT, EDI uses
GatewayStandIn with the identical shape, marked STAND-IN(core=QRun-IO/qbit-file-gateway, removeBy=qbit-file-gateway 0.1.0-RC.1).
4. Trading partners and agreements
4.1 Tables
All tables have id, createDate, modifyDate and, where people edit them, modifiedBy; audit is
on (field-level for configuration tables); record screens have sections. SQL table names equal QQQ
table names; fields are camelCase in QQQ and snake_case in SQL. Labels in brackets.
edi_partner [Trading Partner] (main, nav, Quick Search, ESB events)
name(unique),code(short slug, unique),status(TESTING, ACTIVE, SUSPENDED, RETIRED),contactName,contactEmail,contactPhone,notes.defaultTimezone(for date transforms),containsPhi(boolean; set for healthcare partners, drives classification defaults and the home's PHI badge).- Children: identities, agreements, transports, certificates, documents, exceptions.
edi_identity [EDI Identity] (child of partner; "ours" when partnerId is null)
partnerId(null = one of our own identities),standard(X12, EDIFACT),interchangeQualifier(ISA05/07, e.g.ZZ,01,12; EDIFACT UNB 0007),interchangeId(ISA06/08, padded to 15 on write; UNB 0004/0010),applicationCode(GS02/03),description,isActive.- Unique (standard, interchangeQualifier, interchangeId, applicationCode). Inbound routing matches on these.
edi_agreement [Agreement] (main, nav; child list on partner)
One agreement is one (our identity, partner identity, standard, usage) combination.
partnerId,localIdentityId,partnerIdentityId,standard(X12, EDIFACT),usageIndicator(T test, P production; ISA15). Test and production are separate agreements, so test traffic never touches production records unless the agreement says so.interchangeVersion(ISA12:00401,00501),elementSeparator(default*),segmentTerminator(default~),componentSeparator(ISA16, default:),repetitionSeparator(ISA11 for 00501, default^),lineEnding(NONE, LF, CRLF; outbound). Inbound separators are always read from the ISA itself; these settings are for outbound.requestTa1(ISA14),sendTa1(NEVER, ON_REQUEST, ON_ERROR, ALWAYS; default: on request and on error).inboundTransportId,outboundTransportId(section 5).controlNumberScope(AGREEMENT, LOCAL_IDENTITY; default AGREEMENT; section 4.3).duplicateWindowDays(default 365),onDuplicate(REJECT_TA1, IGNORE, PROCESS; default REJECT_TA1 with an exception row).maxInterchangeBytes(default 50 MB),applyTestDocuments(boolean, default false: T documents are validated and acknowledged but not applied).isActive,notes.
edi_agreement_document [Agreement Document] (child of agreement)
One row per document, version and direction that the agreement carries.
agreementId,direction(INBOUND, OUTBOUND),transactionSet(850,837, ...),functionalIdCode(GS01: PO, PR, SH, IN, IB, PC, OW, SW, QM, SM, HC, HP, HS, HB, HR, HN, BE, FA),version(GS08:004010,005010,005010X222A1, ...),guideId(a base or override guide),mapId,ackExpectation(NONE, 997, 999),ackDueMinutes(default 1440),ackLevel(GROUP_ONLY, ALL_TRANSACTIONS, ERRORS_ONLY; for the acks we send),snipLevel(1-7; section 11; default 2 for non-HIPAA, 4 for HIPAA),containsPhi(defaulted from the version),targetTableName(inbound) orsourceTableName(outbound),outboundTriggerandtriggerFilter(section 9.3),batchMode(IMMEDIATE, BATCH; outbound),batchSchedule(cron, when BATCH),isActive.- Unique (agreementId, direction, transactionSet, version).
edi_control_number [Control Number] (child of agreement)
agreementId(orlocalIdentityIdwhen the scope is LOCAL_IDENTITY),level(ISA, GS),lastValue,minValue(1),maxValue(999999999),wrapAllowed(default true).- ST02 is assigned per group (0001 upward), so it needs no row.
4.2 Inbound routing
- Read the ISA: 106 fixed characters; the element separator is character 4, the repetition separator ISA11 (00501), the component separator ISA16 and the segment terminator character 106.
- Find the partner identity by (ISA05, ISA06) and our identity by (ISA07, ISA08), then the agreement
by both plus ISA15. No match: an
UNKNOWN_PARTNERexception, and TA1 code006or007when we can address a reply. The raw document is always kept. - Per GS, match GS02/GS03 when application codes are configured; per ST, find the agreement
document by direction INBOUND, ST01 and GS08 (exact, then by the base version
005010). No match: the group is rejected in the 997/999 (AK5/IK5R, "transaction set not supported") and aNOT_CONFIGUREDexception is opened.
4.3 Control numbers
- Allocation is an atomic compare-and-set on
edi_control_number(UPDATE ... SET last_value = n+1 WHERE id = ? AND last_value = n, retried) in its own short transaction, so concurrent replicas never hand out the same number. PastmaxValueit restarts atminValuewhen wrapping is allowed; otherwise the outbound fails with a plain error. - Numbers are allocated when an interchange is enveloped, not when it is sent, so a resend of the same interchange keeps its numbers and the partner can detect the duplicate. "Resend As New" re-envelopes with new numbers (section 9.5).
- Inbound duplicate detection per agreement, within
duplicateWindowDays:- interchange: the same (sender, receiver, ISA13, ISA15). TA1 note code
025. - group: the same GS06 from the same sender. AK9/IK9
R. - transaction: the same ST02 within the group. AK5/IK5
R. - Byte-identical payloads (the same sha256 within the window) are caught earlier, at the inbox,
and marked
DUPLICATEwithout processing.
- interchange: the same (sender, receiver, ISA13, ISA15). TA1 note code
4.4 Acknowledgement expectations and overdue alerts
- Each outbound functional group whose agreement document expects a 997 or 999 gets
ackDueAt(sent time +ackDueMinutes). - The overdue monitor (a scheduled process, every 5 minutes) calls
QAlerts.raise(AlertSignal)with alert typeedi.ack.overdue, severity WARNING, dedup keyedi_functional_group:<id>, a record reference to the group and a link to the interchange. qbit-alerts delivers it (inbox, channels, quiet hours); without qbit-alerts, core's logging sink records it. - When the ack arrives,
QAlerts.resolve("edi.ack.overdue", dedupKey, note). - A rejecting ack (AK9/IK9
R, or partialP) raisesedi.ack.rejected(ERROR) and opens aREJECTED_ACKexception. - Assign sends the assignee a value-free
QAlerts.notifynotification after the update commits. New or reopenedREJECTED_ACK/DEAD_LETTERexceptions notify distinct assignees of OPEN/IN_PROGRESS exceptions for the same partner (including the reopened row's assignee). Existing active exception assignments define partner recipients; no new assignment model is introduced. Coalesced repeat occurrences do not notify again. Rollbacks discard notifications.
5. Transports
5.1 Model
Transports are data. edi_transport [Transport] (main, nav):
name(unique slug, used in hook URLs),type,direction(INBOUND, OUTBOUND, BOTH),partnerId(null for a shared transport such as a VAN),isActive,notes.- Typed settings in sections. Only the section for
typeis shown and validated (a pre-insert and pre-update customizer calls the plugin'svalidate):- AS2:
as2LocalId,as2PartnerId,as2Url,as2Subject,signAlgorithm(SHA256, SHA384, SHA512; SHA1 only when the partner requires it, flagged),encryptAlgorithm(AES128_CBC, AES256_CBC, AES128_GCM, AES256_GCM; 3DES only when required, flagged),compress,mdnMode(SYNC, ASYNC, NONE),mdnSigned,localCertificateId,partnerCertificateId,partnerTlsCertificateId(optional pinning),httpAuthUserEnvVar,httpAuthPasswordEnvVar. - SFTP (push and poll):
sftpHost,sftpPort,sftpUsername,sftpPasswordEnvVarorsftpPrivateKeyEnvVar,sftpHostKeyFingerprint(required; unknown host keys are refused),sftpOutboundPath,sftpInboundPath,sftpPollCron,sftpAfterPickup(DELETE, MOVE),sftpArchivePath,sftpFilePattern,sftpTempSuffix(upload as.tmp, then rename). - File gateway:
gatewayEndpointName(thefile_gateway_alias.name: an INBOX endpoint for inbound, an OUTBOX endpoint for partner-pull outbound). - HTTPS:
httpsUrl,httpsAuthType(NONE, BASIC, BEARER, HMAC, MTLS),httpsSecretEnvVar,httpsHeadersJson(non-secret headers),httpsInboundAuth(for/qqq/hooks/edi/https/{name}: BASIC, BEARER or HMAC, the secret by env var). - Email:
emailProviderName(qbit-email),emailTo,emailFromAlias,emailAttachmentName. - VAN: an AS2 or SFTP transport with
isVantrue andvanName. One VAN transport serves many partners (partnerIdnull); routing is by ISA identities, never by transport.
- AS2:
- Retry policy:
maxAttempts(default 8), backoff 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h.
edi_certificate [Certificate] (main, nav)
alias,partnerId(null = ours),usage(SIGNING, ENCRYPTION, SIGNING_AND_ENCRYPTION, TLS),certificatePem(the public certificate only),subject,issuer,serialNumber,fingerprintSha256,notBefore,notAfter(all parsed from the PEM on save),privateKeyEnvVar(ours only: the env var holding a PEM or base64 PKCS#12),privateKeyPasswordEnvVar,status(ACTIVE, NEXT, RETIRED).NEXTsupports rollover: inbound accepts either key, outbound switches when "Promote Certificate" runs.- Expiry: the monitor raises
edi.certificate.expiringat 30, 14 and 7 days (dedup per certificate and threshold), andedi.certificate.expiredat expiry. - Record actions: "Promote Certificate", "Export Public Certificate" (download the PEM to send to a partner). Private keys never enter a table, a log or a response.
5.2 Plugin SPI
public interface EdiTransportPlugin
{
String getType(); // "AS2", "SFTP", "GATEWAY", "HTTPS", "EMAIL"
void validate(EdiTransport transport, List<String> errors); // settings check on save
EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws EdiTransportException;
default void poll(EdiTransport transport, InboundSink sink) throws EdiTransportException { } // pull transports
default EdiTestResult test(EdiTransport transport) { ... } // the "Test Connection" record action
}
- Plugins register in
EdiTransportPluginsMetaData(aQSupplementalInstanceMetaData). The producer registers the built-in ones; a host can add its own (e.g. a VAN's proprietary API). EdiSendResult: SENT (with the transport message id), DELIVERED (sync MDN, or HTTP 2xx where the transport confirms delivery), RETRY (with a reason), FAILED (permanent, with a reason).InboundSink.receive(InboundPayload)stores the bytes and inserts the inbox row (section 9.1). Every inbound path, push or pull, ends in this one call.- Secrets: settings name environment variables (
...EnvVar).EdiSecrets.resolve(name)reads them at use time; values are never stored, logged, cached beyond the call, or returned by any API. Tests replace the resolver. A missing variable fails "Test Connection" and the send with "environment variable X is not set" (the name only).
5.3 SFTP through qbit-file-gateway (inbound) and partner pull (outbound)
Settled with the qbit-file-gateway agent (its DESIGN.md section "Consumer and outbox API"):
- EDI registers
EdiFileGatewayConsumer(nameedi) withFileGatewayConsumersMetaData.ofOrWithNew(qInstance).withConsumer(new QCodeReference(...)). An INBOX endpoint (afile_gateway_aliasrow withconsumer_name = edi) delivers each uploaded file, at least once, after upload close and scan. accept(InboundFile)runs on the gateway worker as the SFTP account's user (channelsftp).InboundFilecarriesuploadJobId,endpointName,username,userId,remoteAddress,path,fileName,sizeBytes,sha256,receivedAt,storageBucketAlias,storageKeyandopenStream()(always from object storage). EDI streams the bytes into its own encrypted payload store, inserts anedi_inboxrow keyed by (GATEWAY,uploadJobId) with the sha256, and returnsFileAcceptance.accepted("edi_inbox:<id>"); a storage failure returnsretryLater(reason, duration). It never parses insideaccept().- The inbox row records the endpoint, username, user id, file name and remote address. The partner
is still determined from the ISA. When the SFTP account is linked to a partner and the ISA sender
is a different partner, processing stops with a
PARTNER_MISMATCHexception, so one partner cannot send as another. - Outbound to partners that pull from us:
FileGatewayOutbox.put(endpointName, fileName, stream, sizeBytes)writes to the partner's OUTBOX endpoint and returns anOutboxFileRef;onDownloaded(OutboxFileRef)on our consumer marks the outbound message DELIVERED.FileGatewayOutbox.isAvailable()false makes a GATEWAY transport fail validation with "qbit-file-gateway is not installed".
5.4 SFTP push and VAN polling (remote servers)
For partners and VANs that host their own SFTP, EDI connects out with core's SFTP client
(qqq-backend-module-filesystem):
- Push: write
sftpOutboundPath/<fileName>.tmp, then rename. File names follow a configurable pattern, default<partnerCode>_<transactionSet>_<isa13>_<yyyyMMddHHmmss>.edi. - Poll: a scheduled process per transport lists
sftpInboundPathmatchingsftpFilePattern, streams each file straight intoInboundSink(no local file), and deletes or moves it only after the inbox row commits. A crash between commit and delete re-reads the file on the next poll; the inbox's sha256 dedupe absorbs it. - qbit-sftp-data-integration stays the tool for generic file imports and report exports. EDI does not depend on it (G1) but uses the same core client, so behaviour and host-key handling match.
5.5 AS2 (phase c)
Implemented by As2Transport (send, Test Connection) and As2RouteProvider (receive). All of it is
in memory; no keystore, key or temporary file is written, and as2-lib's file-backed pieces are not used.
- Libraries. as2-lib 5.1.8 supplies the algorithm tables (
ECryptoAlgorithmSign/Crypt), theMICandDispositionOptionstypes and its BouncyCastle S/MIME signer. as2-lib's decrypt and verify stage content throughAS2ResourceHelpertemporary files and its receive side needs servlet classes, so decryption, signature verification, compression, encryption and the MIC (RFC 4130, RFC 5751, RFC 5402) use BouncyCastle (bcpkix,bcjmail) directly inAs2Crypto, and MDNs are built and parsed inAs2Mdn. The MIC is the digest of the exact bytes of the signed MIME entity (headers included), or of the transmitted entity when unsigned, so any partner transfer encoding (binary or base64) agrees. - Send: the message is compressed (optional), signed, then encrypted;
Message-IDis stable per outbound message (edi-<id>-<digest>@<as2LocalId>) and recorded astransportMessageId, and the MIC we computed is kept inedi_outbound_message.transportMic. Configuration problems (a missing key variable, an expired certificate) are RETRY results that name only the problem. Sync MDN: verify the signature (required whenmdnSigned), compare Received-Content-MIC, and require dispositionprocessed; a mismatch or failed disposition is FAILED with the (sanitized) disposition text.NONEleaves the message SENT. Async MDN:Receipt-Delivery-Optionis{publicBaseUrl}/qqq/hooks/edi/as2/{transport}/mdn; the message stays SENT until the MDN arrives, then becomes DELIVERED or FAILED; an MDN not received within the agreement documentackDueMinutes(60 when unset) raises the warningedi.mdn.overdue, resolved by the MDN. - Receive:
POST /qqq/hooks/edi/as2/{transport}(outside/qqq/v1). The handler checksAS2-From/AS2-Toagainst the transport (403 otherwise), reads the body up to the largestmaxInterchangeBytesof the agreements on the transport (50 MB default, in memory), decrypts with any of our ACTIVE or NEXT keys, verifies the signature against the partner's ACTIVE and NEXT certificates (signed is required whensignAlgorithmis set, encrypted whenencryptAlgorithmis set), unwraps compression, computes the MIC, callsInboundSink.receive(transportTypeAS2,externalId= Message-ID, scoped by transport id if another partner already used the same id), and answers with the MDN: synchronous in the response (signed with our ACTIVE key when requested ormdnSigned), or, whenReceipt-Delivery-Optionis present, 200 at once and a queuededi_as2_mdnrow thatAs2MdnSender(schedulededi_as2_mdn_sender, every 15 seconds) posts with the send backoff andmaxAttempts, failing withedi.send.failed. An asynchronous receipt address must be HTTPS (loopback excepted) and, unless signatures are required, on the partner's own host; failures before the sender is authenticated are always answered synchronously. - A retransmitted Message-ID returns the original MDN again, byte for byte, and is not reprocessed
(an asynchronous one is queued again). Failures return MDNs with
processed/error: authentication-failed(bad or unverifiable signature),integrity-check-failed(digest mismatch),decryption-failed,insufficient-message-securityorunexpected-processing-error(storage, or a key we cannot load, so the partner retries); no inbox row exists for a rejected message. - Certificates:
CertificateServiceresolves our key from theprivateKeyEnvVarvariable (PEM PKCS#8 or base64 PKCS#12 withprivateKeyPasswordEnvVar), proves it matches the certificate, and never caches or logs it. Outbound signs and encrypts with the ACTIVE certificate (the referenced one when ACTIVE, otherwise the ACTIVE one of the same partner and usage), inbound accepts ACTIVE and NEXT. "Promote Certificate" makes NEXT ACTIVE and retires the previous ACTIVE one of the same partner and usage. A VAN transport (isVan, nopartnerId) has its partner certificate on the VAN operator's partner record, or with no partner for a shared certificate (no rollover candidates). - MDNs, sent and received, are stored in
edi_as2_mdn[AS2 MDN] (read-only; child list "AS2 MDNs" of the outbound message and the inbox row; record label<direction> MDN <originalMessageId>):direction(INBOUND = received, OUTBOUND = we sent it),messageId,originalMessageId,disposition,micAlgorithm,micValue,signed,signatureValid,payloadRef(the raw MDN, encrypted),receivedAt,transportId,partnerId,outboundMessageId,inboxId, and the queue columnsdeliveryUrl,deliveryStatus(NONE, PENDING, SENT, FAILED),deliveryAttempts,nextAttemptAt,deliveredAt. - TLS: outbound uses the JVM trust store, or only the pinned certificate when
partnerTlsCertificateIdis set. Inbound TLS terminates at the host's ingress. SHA1 and 3DES are accepted only when set explicitly and set the read-onlyweakCryptoflag on the transport.
5.6 HTTPS and email
- HTTPS out: POST the interchange (
application/edi-x12orapplication/EDIFACT) with the configured auth. 2xx is DELIVERED. Responses are not parsed as acks (acks arrive as documents). - HTTPS in:
POST /qqq/hooks/edi/https/{transport}with BASIC, BEARER or HMAC (Standard Webhooks signatures, verified with core's webhook signing utilities), a body size limit, thenInboundSink.receive. - CAQH CORE (real-time 270/271 and 276/277 over SOAP or MIME) is a later plugin that reuses the same parser and map engine with a synchronous reply. Not in v1.
- Email out: qbit-email's
EmailService.sendwith the interchange as an attachment, category TRANSACTIONAL, linked recordedi_outbound_message. Healthcare documents may only go through a provider markedphi_allowedand are refused otherwise. EDI calls qbit-email through its published API only; until that API ships, this transport is disabled. - Email in: through qbit-email's phase 2 inbound (an
InboundEmailHandlerregistered by EDI). Designed here, built when qbit-email inbound ships.
6. Parsing (X12 and EDIFACT)
- StAEDI (
EDIInputFactory,EDIStreamReader) tokenizes X12 and EDIFACT. Inbox framing buffers one interchange at a time, capped at 50 MiB, and each transaction retains a bounded segment list. - One payload may hold several interchanges; each ISA/IEA becomes an
edi_interchangerow. Each ST/SE is buffered as its own segment list (bounded by the guide's maximum segment count, default 100,000) for validation, ack building and mapping. - Envelope checks: ISA fixed width, IEA01 = group count, IEA02 = ISA13, GE01 = transaction count, GE02 = GS06, SE01 = segment count, SE02 = ST02. Envelope errors produce TA1 or AK9/IK9 codes.
- Character set: the X12 basic and extended character sets, and UTF-8 where the agreement allows it (005010 extended). Unrecognised bytes are an envelope error, never silently replaced.
- Writing uses an in-memory serializer with the agreement's separators, fixed-width ISA padding, component/repetition handling, and optional line endings.
- EDIFACT is built: StAEDI reads syntax 3/4 UNA/UNB/UNG/UNH envelopes. UNA advice and release characters survive round trips; UNT/UNE/UNZ counts and references produce findings. A payload may contain both X12 and EDIFACT interchanges. In-house D96A/D01B ORDERS/ORDRSP/DESADV/INVOIC and CONTRL guides use the shared composite and loop grammar.
- EDIFACT routing uses the identity's
interchangeId/interchangeQualifieras UNB identifiers; UNB11=1 selects test usage. The agreement's syntax identifier, version and service characters control output. UNB/UNH control references use the allocator's EDIFACT levels (up to 14 digits). CONTRL reconciles outbound status and resolves overdue alerts; rejected responses open REJECTED_ACK and raiseedi.ack.rejected. APERAK remains a later document type.
7. Implementation guides as data
7.1 Tables
edi_guide [Implementation Guide] (main, nav)
name,standard,version(004010,005010,005010X222A1, ...),transactionSet,functionalIdCode,partnerId(null = a base guide),parentGuideId(an override's base),revision(increments on activation),status(DRAFT, ACTIVE, RETIRED),source(BUILT_IN, IMPORTED, CUSTOM),description,compiledChecksum.- BUILT_IN rows are read-only (a record lock keyed on
source; only the loader writes them).
edi_guide_segment [Guide Segment] (child of guide): guideId, sequence, loopId (e.g.
2000A, N1, HL), parentLoopId, loopMaxRepeat, segmentId, position (the X12 position,
e.g. 0100), usage (REQUIRED, SITUATIONAL, NOT_USED), minUse, maxUse, name,
situationalRuleId, notes.
edi_guide_element [Guide Element] (child of guide segment): guideSegmentId, position (3
for BEG03), componentPosition (null or 1..n), elementId (the data element number), name,
dataType (AN, ID, N0-N9, R, DT, TM, B), minLength, maxLength, usage, codeListId,
fixedValue, repeatMax, situationalRuleId.
edi_guide_rule [Guide Rule] (child of guide)
- X12 syntax notes per segment:
typePAIRED (P), REQUIRED (R), EXCLUSION (E), CONDITIONAL (C) or LIST_CONDITIONAL (L), with the element positions. - Situational and inter-segment rules:
typeSITUATIONAL with anexpressionin the rule language (section 7.3),snipLevel(2, 4, 6 or 7),severity(ERROR, WARNING),message, and the 997/999 error codes to report.
edi_code_list [Code List] (main, nav) and edi_code_list_value [Code] (child): name,
elementId, source (BUILT_IN, CUSTOM, EXTERNAL), externalCodeSet (for licensed code sets,
section 11.4); values have code, description, isActive.
7.2 Guide validation
StAEDI tokenizes the stream and checks envelope syntax. The guide engine applies the guide data to each buffered transaction: segment order, nested loops, usage and repetition limits, element types and lengths, code lists, syntax notes, and situational and inter-segment expressions. Both JSON resources and database guide revisions use this same in-memory engine; guide validation does not generate a second StAEDI schema or write files.
- Every finding becomes an
edi_transaction_errorrow: segment position, segment id, element position, the X12 code for the ack (AK3/AK4 for 997, IK3/IK4/CTX for 999), the SNIP level and a plain message. Element values go invalueExcerptonly for non-PHI documents (section 12).
7.3 Rule language
A deliberately small expression language over the buffered transaction set, evaluated without a scripting engine:
- paths as in maps (section 8.2):
CLM02,2300/CLM05-1,HL[HL03=22]/NM1[NM101=IL]/NM109; - functions:
present(p),absent(p),count(p),sum(p),eq(a, b),in(p, 'A', 'B'),matches(p, regex),date(p),and(...),or(...),not(x),when(condition, then). - Example: "if the patient is not the subscriber, the 2000C loop is required" is
when(eq(2000B/SBR02, '18'), absent(2000C))together with its converse.
7.4 Built-in guides and licensing
X12 standards and the HIPAA TR3s are copyrighted and licensed by ASC X12. A public Apache-2.0 repo must not ship their text. So:
- Built-in guides are authored in-house for the documents in scope: segment and element structure, usage, lengths and the code values the maps use, with our own short names and no copied notes, descriptions or complete code lists. They are enough to parse, validate structurally, acknowledge and map typical traffic, and are labelled "QQQ baseline guide".
- Imported guides: the table action "Import Guide" on
edi_guideloads a guide that a licensee exports from their licensed tools (a documented JSON format, plus an adapter for a common X12 schema JSON). Imported guides live in the host's database, never in this repo. - Partner companion-guide differences are override guides (section 7.5).
- Built-in guides ship as JSON resources in the jar and are upserted by the loader at startup when their checksum changes (as a new revision; the previous revision stays for documents processed under it).
7.5 Partner overrides
- "Create Partner Override" (record action on a guide) copies the guide into a CUSTOM guide with
partnerIdandparentGuideId, in DRAFT. - Edits are ordinary record edits of its segments, elements, rules and code lists.
- "Activate Guide" requires that the guide compiles, increments
revision, and retires the previous ACTIVE revision for that partner. Documents record the guide id and revision they were validated with, so reprocessing an old document can use the original or the current revision (a choice on the reprocess action). - "Compare With Base" lists the differences as standard Result lines (segments, elements and rules added, removed or changed).
8. Maps
8.1 Tables
edi_map [Map] (main, nav)
name,direction(INBOUND, OUTBOUND),standard,version,transactionSet,guideId,partnerId(null = shared),tableName(the target or source QQQ table),matchKeyFields(comma list; the inbound upsert key, e.g.partnerId,poNumber),onMatch(UPDATE, SKIP, EXCEPTION),businessKeyPath,businessKeyType,secondaryKeyPath,revision,status(DRAFT, ACTIVE, RETIRED),parentMapId,description.
edi_map_rule [Map Rule] (child of map): sequence, ediPath, qqqPath (field,
association.field), transform, transformArgs, required, condition (rule language),
defaultValue, notes.
edi_map_sample [Map Sample] (child of map): name, payloadRef (stored like any payload,
encrypted), expectedResultJson, lastPreviewAt, lastPreviewStatus. Synthetic data only; the
record screen says so, and samples on PHI maps are classified PHI.
8.2 Paths and transforms
- EDI paths: segment plus element position (
BEG03), components with-(CLM05-1), loops by id (2300/CLM01) or by qualifier predicate (N1[N101=ST]/N3/N301), repeats with an index or*(PO1[*]/PO102). Inbound,*loops map to associations; outbound, associations drive loops. - QQQ paths: fields and associations of the map's table, checked against the table's metadata on activation.
- Transforms:
DATE(CCYYMMDD; YYMMDD with a century window),TIME(HHMM, HHMMSS and fractions),DATETIME(date + time + the partner's timezone),DECIMAL(implied decimals for N types),QUALIFIER(pick by a sibling qualifier),CODE(translate through a code list or a map-local table),UNIT(unit-of-measure conversion, EA, CA, PK, LB, KG, ..., with pack sizes from a host field),CONSTANT,CONCAT,SPLIT,SUBSTRING,TRIM,UPPER,LOOKUP(find a QQQ record id by a key, such as an item by UPC or a ship-to by N104, read as the service session),SEQUENCE(outbound line numbers),HL(outbound HL numbering for 856 hierarchies). Hosts can register their own transforms by name. - A rule failure is a mapping error naming the rule, the EDI path and the QQQ path (with no value for PHI documents).
8.3 Sandbox (phase a as an action, phase f as UI)
"Preview Map" (record action on edi_map and edi_map_sample): pick a sample or upload one (the
upload is stored as a sample), and the standard Review/Result lines show:
- the parse: interchanges, groups and transaction sets found;
- validation findings by SNIP level;
- the 997/999 that would be sent;
- the map result (records and associations that would be written), downloadable as JSON;
- the diff against the previous map revision's result and against
expectedResultJson.
Nothing is written to target tables: the preview runs the whole pipeline with a no-op applier.
Phase f1 provides the edi 1.0.0 MiddlewareSpecExtension, with component schemas named
Edi...V1 and a generated, contract-tested fragment at openapi/qqq-v1/edi.yaml:
GET /qqq/v1/qbit/edi/guides/{guideId}/treeGET /qqq/v1/qbit/edi/maps/{mapId}/targetPOST /qqq/v1/qbit/edi/sandbox/previewPOST /qqq/v1/qbit/edi/maps/{mapId}/validate
Preview Map and the HTTP preview share MapPreviewService. HTTP preview takes mapId, exactly
one of sampleId/sampleBase64, optional sampleName, a complete in-memory ruleOverrides
replacement, and guideRevision. Uploads are limited to 5 MiB decoded and stored encrypted as
samples; the host must allow at least 8 MiB HTTP bodies for base64 overhead. CURRENT (HTTP default)
selects the newest active revision in the pinned guide family and partner; ORIGINAL uses the
map's pinned guide, as the record action does.
The response contains parse counts, positional findings, acknowledgement type/text, mapped records
and associations, structured per-document diffs, and phiSuppressed. PHI returns null result/ack
text, empty diffs and generic finding messages. Mapping errors are value-free MAP findings.
The preview action permission is exactly edi.map.preview, with no .hasAccess suffix.
Guide, sample and rule reads explicitly enforce permissions and record locks; transforms run in
the caller session. Only classification and encrypted sample storage use the internal system writer.
8.4 No-code mapper UI (phase f1 API built; Next page follows)
A custom Next UI page (custom UIs may be nav entries):
- left, the guide tree (loops, segments, elements with usage and code lists); right, the target table's fields and associations; drag to create a rule, then choose a transform;
- a live preview pane on a chosen sample (the sandbox endpoint), with errors inline;
- revisions: edit a DRAFT, activate, compare revisions;
- reads and writes only through
/qqq/v1: the guide-tree and map-target GETs above, the sandbox preview POST and the map validate POST; writes remain core CRUD onedi_map_rule, enforcingedi.map_rule.insert|edit|deleteand record locks. - target metadata includes direct associations and transform argument JSON Schemas from the
registry. Validate accepts
{rules: [...]}, returns indexed per-rule errors against the pinned guide/target and never executes transforms or saves rules. Guide code lists return at most 200 active codes per element.
9. Flows
9.1 Inbox and inbound processing
edi_inbox [Inbound Message] (child list on the transport and the interchange; linked from the
home):
transportId,transportType,externalId(AS2 Message-ID, gatewayuploadJobId, poll path and modification time, HTTPS request id, email message id),payloadRef,payloadSha256,sizeBytes,fileName,remoteAddress,receivedAt,status(PENDING, PROCESSING, PROCESSED, RETRY_WAIT, DEAD_LETTER, DUPLICATE, IGNORED),attempts,nextAttemptAt,claimedAt,processedAt,lastError(no payload content),partnerId(once known).- Unique (
transportType,externalId); sha256 duplicates within the window become DUPLICATE. - The worker (a scheduled process, every 15 seconds, batches of 20) claims rows with a
compare-and-set on
statusandclaimedAt. Claims older than 5 minutes are reclaimed, so any replica can process and a crashed one loses nothing. Backoff 1m, 5m, 15m, 1h, 6h; then DEAD_LETTER, anedi_inbox_dead_letteralert and an exception.
Per interchange, group and transaction set (each transaction set in its own database transaction, so one bad document does not block the others):
- envelope checks and TA1 (section 6);
- partner and agreement routing (4.2), duplicate checks (4.3);
- validation (7.2) at the agreement document's SNIP level;
- ack building: one 997 or 999 per received group, sent per the agreement (9.4);
- if the transaction set is accepted (
AorE): map and apply (9.2); a rejected set is not applied; - statuses, timeline events and exceptions.
9.2 Apply as a service account
- The applier runs
InsertActionandUpdateAction(with associations) on the map's table under a service session fromEdiQBitConfig.withServiceSessionSupplier(Supplier<QSession>), which the host provides (a user with exactly the permissions EDI should have). Permission enforcement stays on, so a map cannot write to a table the service account cannot. When partner locks are on, that partner's security key is added to the session for the call. - Upsert by
matchKeyFields: query for an existing record as the service session;onMatchdecides. The 860 (PO change) is a map onto the same order table with UPDATE. - The target table's customizers, validation, audit and ESB events all run, because this is the normal action path.
- The target record id is stored on
edi_transaction(targetTableName,targetRecordId). A host can show a record's EDI documents with a child-list widget onedi_transactionfiltered by target. - A failure rolls back that transaction set only, sets status EXCEPTION, and opens an
APPLYexception with the error.
9.3 Outbound
- Trigger (per outbound agreement document,
outboundTrigger):ESB_EVENT(preferred when qqq-esb is configured): the source table publishes ESB table events; anEsbTriggersubscribed to them runs the processedi_outbound_enqueue, which appliestriggerFilter(a QQueryFilter as JSON, e.g.status = SHIPPED) and inserts anedi_outbound_requestrow (agreementDocumentId,sourceTableName,sourceRecordId,eventId,status,attempts,nextAttemptAt,claimedAt,lastError). It is idempotent on (event id, agreement document).RECORD_CHANGE(no broker): a coreRecordChangeListenerInterfaceregistered by EDI inserts the same row inside the writer's transaction (a transactional outbox: the request exists exactly when the source change commits).MANUAL: the table action "Send As EDI" on the source table, added when the host opts in, with the standard Review/Result summary.
- Outbound worker (claims requests like the inbox): loads the source record as the service
session, maps it (section 8), and validates it against the guide (an invalid outbound document is
never sent; it opens a
VALIDATIONexception). It then batches per agreement (IMMEDIATE or onbatchSchedule), envelopes (control numbers, 4.3), stores the raw interchange and creates anedi_outbound_message. edi_outbound_message[Outbound Message]:interchangeId,transportId,payloadRef,status(QUEUED, SENDING, SENT, DELIVERED, RETRY_WAIT, FAILED, DEAD_LETTER, CANCELLED),attempts,nextAttemptAt,transportMessageId,sentAt,deliveredAt,lastError. The sender claims rows the same way and retries per the transport's policy; DEAD_LETTER raisesedi.send.failedand opens aTRANSPORTexception.- Reconcile: an MDN or delivery confirmation sets DELIVERED; the expected 997/999 sets the
functional group's
ackStatus(4.4); the home and the timeline show both.
9.4 Acknowledgements we send
- TA1: per the agreement's
sendTa1, with codes from the envelope checks (e.g.001control number mismatch,006/007invalid sender or receiver,022invalid control structure,025duplicate). - 997 (non-HIPAA, and 004010) and 999 (005010 HIPAA,
005010X231A1): AK1, AK2, AK3, AK4, AK5 and AK9, or IK3, IK4, IK5 and CTX, at the agreement document'sackLevel, built from the validation findings. - Acks are outbound interchanges on the same agreement and transport, with their own control
numbers, and appear as
edi_transactionrows (997, 999, TA1) linked to what they acknowledge. - Acks are never themselves acknowledged.
9.5 Resend, reprocess, search and timeline
Record actions, all with the standard Review/Result summary:
- Reprocess (on
edi_inbox,edi_interchange,edi_transaction,edi_exception): run again from the stored raw payload, from the parse or from the map step, with the original or the current guide and map revisions. Idempotent: an applied document is matched by its target record and updated or skipped per the map'sonMatch. Acks are re-sent only when chosen. - Resend (on
edi_outbound_message,edi_interchange): send the same bytes again (the same control numbers). Resend As New: re-envelope with new control numbers (for partners that rejected a duplicate). - Cancel (queued outbound), Assign, Ignore and Mark Resolved (exceptions).
- Search by business keys:
edi_transaction.businessKey(PO, ASN, invoice, load and shipment numbers, from the map'sbusinessKeyPath, e.g. BEG03, BSN02, BIG02, B204, W0506),businessKeyType, andsecondaryKey(e.g. the PO on an 856 or 810). Healthcare keys (claim and patient control numbers, member ids) go tophiBusinessKeyinstead (section 12). All are in Quick Search; the PHI field matches only for users with FULL access to it (secured-data). - Timeline:
edi_event[Event] (child of interchange and transaction):at,type(RECEIVED, STORED, PARSED, VALIDATED, ACK_SENT, MAPPED, APPLIED, ENVELOPED, SENT, MDN, DELIVERED, ACK_RECEIVED, EXCEPTION, REPROCESSED, RESENT),detail(no payload values),userName. The interchange and transaction screens show it in time order next to the record audit.
9.6 Runtime tables
edi_interchange [Interchange] (main, nav, Quick Search on ISA13, ESB events): direction,
partnerId, agreementId, transportId, inboxId or outboundMessageId, standard,
senderQualifier, senderId, receiverQualifier, receiverId, controlNumber (ISA13),
interchangeVersion, usageIndicator, interchangeDate, groupCount, payloadRef,
payloadSha256, sizeBytes, status (RECEIVED, ACCEPTED, ACCEPTED_WITH_ERRORS, REJECTED,
DUPLICATE, ENVELOPED, SENT, DELIVERED, FAILED), ta1Status, containsPhi.
edi_functional_group [Functional Group] (child of interchange): functionalIdCode,
controlNumber (GS06), version (GS08), transactionCount, ackStatus (A, E, P, R, NONE,
PENDING), ackDueAt, ackReceivedAt, ackTransactionId.
edi_transaction [Document] (main, nav, Quick Search, ESB events): interchangeId,
functionalGroupId, direction, partnerId, agreementDocumentId, transactionSet, version,
controlNumber (ST02), businessKeyType, businessKey, secondaryKey, phiBusinessKey,
status (RECEIVED, VALID, INVALID, MAPPED, APPLIED, EXCEPTION, QUEUED, SENT, ACKNOWLEDGED,
REJECTED, IGNORED), ackStatus, errorCount, warningCount, guideId, guideRevision, mapId,
mapRevision, targetTableName, targetRecordId, segmentCount, containsPhi.
edi_transaction_error [Validation Error] (child of transaction): segmentPosition,
segmentId, loopId, elementPosition, componentPosition, snipLevel, ackCode, severity,
message, valueExcerpt (non-PHI documents only).
edi_exception [Exception] (main, nav; the work queue): type (UNKNOWN_PARTNER,
PARTNER_MISMATCH, NOT_CONFIGURED, DUPLICATE, ENVELOPE, VALIDATION, MAPPING, APPLY, TRANSPORT,
REJECTED_ACK, OVERDUE_ACK, DEAD_LETTER), status (OPEN, IN_PROGRESS, RESOLVED, IGNORED),
partnerId, inboxId, interchangeId, transactionId, outboundMessageId, summary, detail
(no payload values), assignedToUserId, resolvedBy, resolvedAt, resolution, occurrences
(repeated failures of the same item increment it instead of adding rows).
The 824 Application Advice and the 864 are later document types on the same model (outbound documents generated from an exception), not new tables.
10. Payload storage (database and S3 only)
PayloadStorewrites raw payloads (inbound files, outbound interchanges, AS2 messages and MDNs, samples) to qbit-object-storage: the bucket alias fromEdiQBitConfig.withPayloadBucketAlias(defaultedi), keyedi/<yyyy>/<MM>/<dd>/<kind>/<uuid>, throughObjectStorage.asSystem("edi", runId).- Envelope encryption, always: each payload is encrypted with AES-256-GCM under a random data
key; the data key is wrapped with the master key from env var
QQQ_EDI_PAYLOAD_KEY(base64, 32 bytes) and stored with its key id in the object's metadata.QQQ_EDI_PAYLOAD_KEY_PREVIOUSallows rotation (read with either, write with the current one); the table action "Rewrap Payloads" re-wraps old payloads. Bucket-side SSE is recommended as well, not relied on. payloadRefcolumns holdobjectstorage:<bucket>:<key>; the plaintext sha256 is stored next to it.- Database fallback: without qbit-object-storage, payloads go to
edi_payload(kind,ciphertextBLOB,keyId,sha256,sizeBytes,createDate), encrypted the same way. It suits the demo and tests; production should use object storage (startup logs a WARN, and the home shows it). - Nothing touches local disk: parsing streams from the store, AS2 uses memory and object storage, and certificates come from table rows and env vars. A test asserts that a full round trip creates no file under the JVM temp directory.
- Retention (
EdiQBitConfig.withRetention...; the nightly table action "Purge Payloads"): raw payloads are kept 7 years for healthcare (HIPAA documentation retention is 6 years) and 2 years otherwise, configurable. Rows and their sha256 stay after the payload is purged. The home shows the policy.
11. Validation levels (SNIP) and code sets
11.1 What each level checks and what we support
| SNIP | Meaning | Support |
|---|---|---|
| 1 | EDI syntax integrity: envelopes, segment and element syntax, separators | Full (StAEDI and the envelope checks) |
| 2 | HIPAA syntax requirements: TR3 usage (R/S/N), repeats, lengths, TR3 code values | Full for the rules in the active guide (the built-in baseline or an imported TR3) |
| 3 | Balancing: totals across segments (837 CLM02 equals the sum of the SV1/SV2 line charges; 835 BPR02 equals the sum of CLP04 less PLB adjustments; claim and line adjustments balance) | Full for 837P, 837I and 835 (coded checks) |
| 4 | Situational inter-segment rules ("if X then Y") | Full through guide rules (7.3); baseline guides carry the common ones, imported TR3s the rest |
| 5 | External code sets: ICD-10, CPT, HCPCS, NDC, CARC/RARC, taxonomy, ZIP | Partial: a pluggable ExternalCodeSetProvider (11.4); no licensed code set ships (CPT is AMA-licensed). Format checks (ICD-10 shape, NPI check digit, ZIP format) are built in. |
| 6 | Product type or line of business (ambulance, chiropractic, ...) | Through override guides and rules; no built-in content |
| 7 | Trading-partner-specific requirements (companion guides) | Through partner override guides (7.5) |
11.2 Findings and acks
SNIP 1-2 errors reject the transaction set (AK5/IK5 R) unless the agreement document downgrades a
rule to WARNING. SNIP 3-7 findings default to E (accepted with errors) plus an exception, because
business rejection belongs in an 824 or a 277CA, not in a 999. Each rule can set its severity.
11.3 Defaults
Non-HIPAA documents validate at level 2 by default and HIPAA documents at level 4. An agreement document can raise or lower its level.
11.4 External code sets
JSON element constraints support source: "EXTERNAL" and externalCodeSet. Normalized code-list
rows preserve both properties. Register the provider with
EdiQBitConfig.withExternalCodeSetProvider(...); it is excluded from configuration JSON.
Membership failures are positional SNIP 5 findings. The effective date uses a service date,
then a payment date, otherwise the configured clock. Provider failures disclose no values.
ExternalCodeSetProvider (SPI: boolean isValid(String codeSet, String code, LocalDate asOf)),
registered by the host, for example backed by a licensed table the host loads. Code lists with
source = EXTERNAL delegate to it. Without a provider they check the format only, and the home says
so.
12. HIPAA and PHI
- Classification (
DataClassification, core #1142; native RC.6 metadata):- PHI:
edi_transaction.phiBusinessKey,edi_transaction_error.valueExcerpt(never written for PHI documents, but classified anyway),edi_map_sample.expectedResultJson,edi_inbox.fileName(file names often carry member or claim ids), and the payloads themselves (encrypted, never in a table column). - PII: partner contact fields. CONFIDENTIAL: everything else in the
edi_tables. - Host target tables classify their own fields. The map writes through normal actions, so their classification applies to what it writes.
- PHI:
- With qbit-secured-data installed, PHI fields follow its tiers: a customer-service user sees
phiBusinessKeymasked and can find a claim by exact match only (REVEALABLE), which is how claim lookup is meant to work for non-FULL users. Without it, the fields are FULL to anyone with the table permission, so a host carrying PHI should install it (the home warns). - No PHI outside the encrypted payloads and PHI-classified fields: for documents with
containsPhi, errors, events, exceptions, alert texts, log lines and Result lines carry segment and element positions and codes, never values. The 999 we send carries positions and element references only. - Encryption at rest: payloads always (section 10). PHI columns rely on the database's encryption at rest (a host responsibility, stated in the README) plus secured-data masking.
- Minimum necessary: PHI maps write only the mapped fields. The raw document stays encrypted and
can be downloaded only with the record action "Download Raw Document" (permission
edi.raw.download), which is audited and, with secured-data, written to its access log. - Canary egress tests (phase e, the core SECURED-DATA procedure): two randomized markers per
patient-name, member-id, claim-number and diagnosis field run through gateway and HTTPS hooks,
validation with SNIP 2/3/4 failures, 999, map/apply, exceptions, reprocess/resend, every home and
child widget, every persisted
edi_table's QueryAction export, Review/Result, captured alerts and notifications and an in-memory DEBUG log appender. Every five-character window is scanned, detecting all longer substrings. Only explicit PHI value/display-value columns are exempt for FULL exports; ciphertext bytes are checked for plaintext. RC.6 HIDDEN field locks are separately exercised without exemptions. The storage-only download alias has no query surface. MASKED tiers and the optional Quick Search index await their host/core integration; they are not simulated. Long diagnosis markers use a widened test-only guide element so accepted-with-error fixtures can also reach mapping/apply; separate malformed monetary elements force structural rejection. - No real PHI anywhere in the repo: samples are synthetic, produced by a seeded generator.
13. App home and navigation
App edi [EDI], optionally under the host's parent app. Nav (main tables only):
- Activity: Documents (
edi_transaction), Interchanges, Exceptions. - Setup: Trading Partners, Agreements, Transports, Certificates, Implementation Guides, Code Lists, Maps.
Sub-tables (identities, agreement documents, control numbers, guide segments, elements and rules, code list values, map rules and samples, functional groups, validation errors, events, inbox, outbound requests and messages, MDNs) are child lists on their parents.
Home (typed widgets only, no HTML; every figure links to the filtered table):
- Statistics: documents in and out today and over 7 days, open exceptions, overdue acks, dead letters, certificates expiring within 30 days.
- Table: volume by partner and document type over 7 days (in, out, rejected), each cell linking to
edi_transactionfiltered. - Table: overdue acknowledgements (partner, group, sent, due, age).
- Table: recent errors (validation and transport, last 24 hours).
- Child-record list: the exception queue (OPEN and IN_PROGRESS, oldest first), with Assign and Reprocess actions.
- Statistics: system state (payload store: object storage or the database fallback; secured-data present; external code-set provider present; inbox backlog and the oldest pending age).
All counts run as the viewing user, so permissions and partner record locks apply. Real data only.
14. Security and permissions
- Table permissions use the full name:
edi.partner.read|insert|edit|delete, and likewise for each table (edi.agreement.*,edi.transport.*,edi.certificate.*,edi.guide.*,edi.map.*,edi.interchange.read,edi.transaction.read,edi.exception.read|edit, ...). Runtime tables are read-only to people (no insert or delete permission is offered; the qbit writes them as the system). - Process permissions:
edi.reprocess,edi.resend,edi.exception.assign,edi.guide.activate,edi.guide.import,edi.map.activate,edi.map.preview,edi.certificate.promote,edi.transport.test,edi.raw.download,edi.payload.purge,edi.admin. - Partner record locks (optional,
EdiQBitConfig.withPartnerLocks(true)): every partner-scoped table carries a READ_AND_WRITE lock onpartnerIdwith the security key typeedi_partnerand the all-access keyedi.admin, so a host can limit operators to some partners. The service session gets the needed partner key per call. - Hooks authenticate per transport (AS2 signature and certificate, HTTPS auth), not with user sessions; they run as the system only to store and enqueue.
- Client channels: the producer registers its own denies (architecture review F17): the assistant
channel is denied
edi_payload,edi_certificateandedi_inbox, and the PHI fields follow secured-data.
15. Demo
Phase b includes a test-scope EdiDemoServer with the Next UI and synthetic H2 seed;
see DEMO.md for launch and acceptance details. The broader host demo below remains
a deployment example.
No kof22 adoption. The demo is a module in qqq-sample-project (in QRun-IO/qqq): a small orders,
shipments and invoices schema; a retailer partner over the file gateway or SFTP and a 3PL over AS2
against a local as2-lib peer; seeded synthetic documents; and a healthcare clearinghouse partner with
synthetic 837/835 for the PHI walkthrough. It runs on H2 with the database payload fallback, or with
MinIO for object storage.
16. Phases and acceptance
| Phase | Scope | Acceptance |
|---|---|---|
| a | Partners, identities, agreements, agreement documents, control numbers, transports (model), payload store, inbox and worker, X12 parse and envelopes, guide model, compiler and rule engine, built-in guides for 850 (004010, 005010) and the acks, 997/999/TA1 generation, inbound SFTP through the file gateway (stand-in until its api ships), exceptions, events, home v1, Preview Map | An 850 from a gateway upload becomes an applied record and a 997; TA1 and 997 for envelope and syntax errors; duplicates caught at every level; control numbers unique under 8 concurrent allocators; every table has a record screen; home counts are real; ./mvnw verify green |
| b | Retail 850, 855, 856, 810, 846, 860 (004010, 005010) guides and maps; outbound (ESB and record-change triggers, batching, envelope, send); SFTP push and VAN polling; gateway OUTBOX; HTTPS in and out; inbound ack reconciliation; overdue-ack alerts; resend, reprocess, cancel | 855/856/810 built from host records, validated, enveloped and sent; a 997 reconciles and resolves the overdue alert, and a missing one raises it; resend keeps control numbers, resend-as-new does not |
| c | AS2 send and receive, sync and async MDN, certificates and rollover, expiry alerts, VAN over AS2 | Round trip against a local as2-lib peer: signed and encrypted, sync and async MDN, MIC verified, a bad signature refused, a retransmit gets the original MDN |
| d | Logistics 940, 945, 214, 204 guides and maps | Round trips per document |
| e | Healthcare 837P, 837I, 835, 270/271, 276/277, 834 (005010 TR3 baselines), 999, SNIP 3-4, PHI classification, canary egress tests; the email transport when qbit-email's API ships | 837 batches validated to SNIP 4 with balancing errors reported; correct 999s; canary test green |
| f | Sandbox endpoints and the mapper UI (UI API extension and a Next page) | Fragment committed and contract-tested; the mapper edits a map and previews a sample |
| g | EDIFACT (UNA/UNB/UNH, ORDERS, ORDRSP, DESADV, INVOIC, CONTRL) | EDIFACT round trips and CONTRL |
Each phase is one or more PRs into develop, with tests and, for UI-visible changes, screenshots of
the home and key records in light and dark at 390 and 1440 px.
16.1 Tests (all phases)
- Synthetic sample documents per type and version from a seeded generator; no real data.
- Round trips: parse, map and apply, then build the outbound document from the applied record and compare.
- 997, 999 and TA1 generation against expected acks for crafted errors at every level.
- AS2 against a local as2-lib peer, in-process, in both directions.
- Control-number concurrency, wrap and duplicate detection.
- Partner overrides change validation outcomes as intended; map revisions and the sandbox diff.
- No local files during a full round trip; no secret values in logs or responses.
- Canary PHI egress (phase e).
17. Open questions (decided as recommended, 2026-10-12)
| # | Question | Recommendation (accepted) |
|---|---|---|
| Q1 | Ship full X12 or TR3 guide content? | No. In-house baseline guides plus an "Import Guide" action for licensed content (7.4). |
| Q2 | Use qbit-sftp-data-integration for SFTP push and polling? | No code dependency (G1); use core's SFTP client directly, the same one it uses (5.4). |
| Q3 | Where do raw documents live without object storage? | Encrypted in edi_payload in the database, for the demo and tests; production uses object storage (10). |
| Q4 | Outbound trigger: ESB or a record-change listener? | Both: the ESB trigger when a broker is configured, otherwise the in-transaction record-change listener; MANUAL for one-offs (9.3). |
| Q5 | Which user applies inbound documents? | A host-supplied service session with enforcement on (9.2). |
| Q6 | Test (ISA15 = T) documents? | Separate agreements; validated and acknowledged, applied only with applyTestDocuments (4.1). |
| Q7 | Business rejections (824, 277CA)? | Later document types on the same model; v1 uses exceptions plus E acks (11.2). |
| Q8 | Are PHI business keys searchable? | Yes, in a PHI-classified field, by exact match for REVEALABLE users through secured-data (12). |
| Q9 | Payload retention? | 7 years for healthcare, 2 years otherwise, configurable (10). |
| Q10 | SHA-1 and 3DES for AS2? | Only when a partner requires them, flagged on the transport and the home (5.1). |
| Q11 | Partner record locks? | Optional (withPartnerLocks), off by default (14). |
| Q12 | Demo host? | qqq-sample-project, not kof22 (15). |
18. Owner steps (outside code)
- Protect
developand maketest_onlyrequired (the coordinator). - For production use: set
QQQ_EDI_PAYLOAD_KEY, an object-storage bucket aliasedi, the service user for the applier, AS2 keys and passwords as environment variables, and load licensed TR3 guides or code sets if healthcare validation beyond the baseline is needed.
Phase a2 implementation notes (2026-10-13)
The inbound pipeline, map/apply service boundary, typed app/home, actions, gateway stand-in and queued acknowledgements are implemented. These RC.5 adaptations refine the executable contract:
- Core alert metadata rejects underscore names: use
edi.inbox.dead-letter,edi.ack.overdueandedi.ack.rejected. - Queued replies emit
ACK_QUEUED; actual sending remains phase b. inboundPositionon interchange/group/transaction distinguishes repeated controls within a payload and makes retries resume the same occurrence.- Reprocessing a descendant restarts its containing inbox using original/current definitions, stable checkpoints and idempotent reply queuing; live claims cannot be reset.
- Targets and associations must share the EDI RDBMS backend for atomic ST application. Cross-backend application fails as APPLY.
- RC.5 binds UTC LocalDateTime. DATE_TIME DDL uses zone-less UTC TIMESTAMP to prevent host-timezone shifts in leases and due times. Existing timestamp-with-time-zone columns require migration preserving UTC instant values.
- Typed TABLE supports a table-level link rather than individual count-cell URLs. Volume/queue tables use filtered-table links; STATISTICS links each figure. No HTML widgets are used.
edi_preview_downloadis a storage-only metadata alias bridging standard streams to encrypted PayloadStore. It has no SQL table. Hosts configure it as the Javalin upload archive and avoid multipart disk staging. Samples are synthetic only; downloads require session-registered refs.- Preview compares structured before/after JSON against expected output and the previous revision. PHI map results and acknowledgement values are suppressed. Selection/upload use the standard Review.
- MAPS.md defines transform arguments. QUALIFIER selects via path predicates; CODE uses map-local translations; UNIT uses configured factors/pack sizes; DATETIME uses an explicit timezone. Host code-list/pack-field conversions use registered transforms. SEQUENCE/HL remain phase b.
- Both payload adapters buffer complete envelopes in memory and use no runtime filesystem storage. Object storage uses only its published API.
- Standard optional ESB publication follows RC.5. Document events use the core envelope/publisher with ID/status only because the standard factory copies every field, including PHI keys.
Synthetic H2 verification covers permissions, association rollback, retry/stale claims, gateway storage failures, original/current replay, 997/999, ack reconciliation, previews and home renderers. The object-storage facade is final with restricted constructors: key/encryption metadata are tested without provider internals. Live S3, deployed gateway/scheduler and light/dark browser layout remain deployment validation. Outbound messages intentionally stay QUEUED.
Phase b implementation notes (2026-10-13)
RC.5 adaptations implemented in this worktree:
- Record-change listeners are registered in instance metadata and receive the source writer's transaction. Successful enqueues participate in that transaction (rollback is tested). RC.5 catches listener exceptions, so a failed enqueue cannot veto a source write; hosts requiring strict delivery guarantees must use the explicit enqueue boundary in their transaction.
- RC.5 ESB consumption requires JMS.
edi_outbound_enqueueacceptssourceTableName,sourceRecordId, andeventId; a host EsbTrigger can invoke that process. Tests exercise that boundary without a broker. - The filesystem module supplies Apache MINA SSHD. SFTP uses its streaming client directly with mandatory pinned host-key verification, remote temporary upload/rename, and durable inbox receipt before remote cleanup. No runtime local files are created.
- HTTPS uses core
WebhookSigner/WebhookVerifierfor Standard Webhooks HMAC. The optional Javalin provider exposes the non-UI/qqq/hooks/edi/https/{transport}endpoint with a 50 MiB limit. - Worker claims use database row locks within the processing transaction. A crashed worker rolls back its claim; remote-send ambiguity remains at-least-once. Batch schedules are installed from active agreement documents during runtime initialization, with up to 100 transactions from one agreement document per envelope. Schedule changes require host scheduler refresh/restart. SFTP poll schedules are installed the same way.
- Acknowledgement deadlines start on the first successful send. Resend reuses the archived bytes; Resend As New re-envelopes the archive with new ISA/GS controls and linked tracking snapshots, without remapping changed source records.
- The test-scope demo uses
QApplicationJavalinServer, Next UI 1.0.0-RC.14, fresh in-memory H2, anonymous demo authentication, synthetic certificates generated in memory, and an in-memory gateway OUTBOX provider. SeeDEMO.md. A host scheduler and production transport credentials remain host responsibilities.
Phase d implementation notes (2026-10-14)
- Guides for 940, 945, 214 and 204 are in-house baselines in both releases, with the same structure in 004010 and 005010 (as in phase b). Code lists carry only the values the sample maps and tests use; ID elements without a list are unconstrained. Control totals (940 W7601 against the W0101 lines, 945 W0301 against the W1203 lines) and the 204 stop rules (first stop LD, at least one UL) are guide-level
SITUATIONALrules, because the rule evaluator reads whole-document values; per-segment relationships use syntax notes (AT7 status/reason and date/time pairing, W12 difference needs ordered and shipped) and the MS1 segmentcondition, which is evaluated per LX occurrence. - The 204 stop-level N1 loop has its own loop id (
S5N1, parentS5) so it does not merge with the header N1 loop; map paths name it (S5[*]/S5N1/N102). - Mapping needed two small engine additions, documented in
MAPS.md: inbound paths may join elements with+soDATETIMEreceives a date and a time from separate elements, and outboundDATEPART/TIMEPARTsplit a QQQ timestamp. Optional inbound DECIMAL/DATETIME rules use aconditionbecause a transform with no input still fails the rule (unchanged phase a behavior). InboundApplierassumed every target table has associations; a flat table (the 214 status sample) hit a null pointer. It is now null-safe.- The 214 sample map records the first LX event per transaction into a flat status table and always inserts (no match key), so repeated statuses form a history; a host that wants every LX event maps an association.
- Demo: Harbor Logistics gets a gateway OUTBOX transport for 945s (the partner pulls over SFTP, per 5.3) after the retail seed has run, so its earlier acknowledgements keep their visible retries; the fourth partner Ridgeline Freight is added, so
EdiDemoServerTestnow expects four partners.
Phase c implementation notes (2026-10-14)
- as2-lib's decrypt and verify write temporary files and its receive helpers need servlet classes, so only
its enum tables,
MIC,DispositionOptionsand signer are used; the rest is BouncyCastle in memory (section 5.5). Its dependency tree pullsbcjmail1.85 (Jakarta Mail), sobcpkixis pinned to 1.85 and promoted from test scope; no other QQQ dependency changed. - The MIC is computed over the exact signed bytes (headers included) instead of as2-lib's decode-then-re-encode, which differs for base64 content; verified against an independent BouncyCastle peer with base64 and binary transfer encodings.
- Encryption wraps the exact bytes that were hashed (CMS enveloped data over the serialized signed entity), so the MIC cannot drift between signing and encryption.
edi_as2_mdndoubles as the asynchronous MDN queue (deliveryStatus,deliveryAttempts,nextAttemptAt) instead of a separate table; the scheduled step isedi_as2_mdn_sender.- New columns:
edi_outbound_message.transport_mic(the MIC we sent, for asynchronous comparison) andedi_transport.weak_crypto. New config:EdiQBitConfig.publicBaseUrl. - The alert is
edi.mdn.overdue(RC.5 rejects underscores);SendWorkernow records the transport failure reason (value-free by contract: an HTTP status, an AS2 disposition or a variable name) inlastErrorinstead of a generic text. - Not implemented: EC and RSA-PSS signing keys (RSA only), MIC comparison under a different algorithm than
the one requested, CAQH/Peppol profiles, and AS2 over a host-mounted client certificate (
MTLS).
Phase e implementation notes (2026-10-14)
- See the RC.6 baseline in section 3. Map samples use record-level audits because expected-result
JSON can contain PHI. Raw downloads use a session-bound storage grant and an
edi_eventaudit; a secured-data access log remains host integration work. - Situational rules bind to their occurrence and inherit only direct ancestor segments, so a sibling line's service date cannot satisfy another line. Fixed loop-head qualifiers distinguish HL/NM1 roles; the same 2300/2400 names can occur under subscriber and patient parents.
- The 271 baseline requires benefits or an AAA rejection for a traced response; it does not perform cross-document 270/271 correlation. Provider differences that cannot be inferred from the submitted document belong in a companion guide or host rule; no rendering provider is invented from an absent identifier.
- Healthcare maps and sample target tables are test-only, per the phase-e scope. The 837 sample map is a single-claim example with service-line associations; the validator supports multiple claims and checks each independently. The 835 and 834 samples map multiple claim/member associations. These are baseline examples, not a complete licensed TR3 implementation.
- Child-list widgets explicitly enforce their nested queries on RC.5; the generic renderer otherwise bypasses field locks inside the already-authorized widget action scope.
- MASKED-user response scanning and Quick Search indexing are not available on the pinned RC.6 test classpath. FULL and actual HIDDEN-field-lock scans run now; no simulated masking is used.
Phase g implementation notes (2026-10-14)
- Outbound EDIFACT is ungrouped. Readers/writers preserve optional UNG/UNE groups. Ungrouped
interchanges have a logical
edi_functional_grouptracking row, keyed by UNB reference, so the existing send-time deadline/overdue monitor also covers CONTRL.groupCountremains zero for those wire envelopes. GROUP_ONLY means an interchange-level CONTRL in this case. - Payload framing buffers one interchange (hard cap 50 MiB) before StAEDI; retained message
segments respect
maxSegmentsPerTransaction. It uses no filesystem. This follows the existing buffered storage adapters rather than the original bounded-memory streaming aspiration. - Retail SG labels are local guide identifiers shared by the two baseline versions, as documented in GUIDE-FORMAT.md; the baselines are deliberately small, not complete directory content.
- Existing databases require the three agreement columns listed in README.md. Existing X12 separator defaults remain unchanged; EDIFACT agreements explicitly select their outbound UNA characters. CONTRL action 8 is receipt-only and does not clear a functional acknowledgement deadline.