build: move to QQQ 4.1.0-RC.6 and remove RC.5 stand-ins #14

Merged
KofTwentyTwo merged 1 commits from feature/qqq-rc6 into develop 2026-10-14 14:26:58 +00:00
17 changed files with 325 additions and 156 deletions

No files matched your search

+1 -1
View File
@@ -52,7 +52,7 @@ The package layout per phase is in `docs/DESIGN.md`.
## Dependencies
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
## Release
+12
View File
@@ -45,8 +45,20 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
- Repository scaffold to the QRun qbit repository standard: `qbit-build-parent` 2.0.0, QQQ 4.1.0-RC.5, Maven wrapper, Checkstyle, JaCoCo gate, CircleCI qqq-orb 0.6.8, release-candidate workflow, Dependabot, community files.
- `EdiQBitProducer` and `EdiQBitConfig` (table prefix `edi_`, permission base `edi`).
### Changed
- QQQ baseline is now 4.1.0-RC.6. EDI tables/fields and healthcare test fixtures use core `DataClassification` through `AiTableMetaData`/`AiFieldMetaData`, preserving PHI, PII, CONFIDENTIAL and PHI AI-exposure restrictions.
- Exception Assign and new/reopened partner rejection/dead-letter exceptions send value-free core notifications after commit, with capturing-sink and PHI canary coverage.
- Retain `PhiLogScope` for core #1147 until RC.7: RC.6 still logs RDBMS query filters and has no redaction hook or `FieldAccessHelper`. Contingent dependency-behavior APIs are also absent; shared-choice filters remain tested.
### Removed
- Reflective `EdiClassifications` RC.5 stand-in. File-gateway stand-ins remain until its API is published; the object-storage API integration is unchanged.
### Fixed
- Shared-choice filters group partner-or-shared alternatives beneath an AND root, so core search criteria cannot admit another partner's transports.
- 999 now accepts SNIP 3+ findings with `E` instead of rejecting every ERROR. HIPAA acknowledgement selection follows the implementation version and enabled agreement expectation; outbound HIPAA ST carries its implementation identifier.
- Qualified HL/NM1 anchors distinguish healthcare hierarchy levels; repeated loop names under different parents retain separate occurrences. Situational rules inherit direct ancestor segments without borrowing values from sibling occurrences.
- Review/Result records expose identifiers only. A marked RC.5 PHI logging bridge prevents core upsert query diagnostics from copying claim/member values into logs.
+1 -1
View File
@@ -52,7 +52,7 @@ The package layout per phase is in `docs/DESIGN.md`.
## Dependencies
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
## Release
+31 -24
View File
@@ -34,7 +34,7 @@ ALTER TABLE edi_agreement ADD COLUMN syntax_identifier VARCHAR(255);
## Requirements and installation
Java 21, QQQ **4.1.0-RC.5**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
Java 21, QQQ **4.1.0-RC.6+**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
Apply [the PostgreSQL schema](docs/ddl/edi-postgres.sql). Targets and their associations must
share the EDI backend so each transaction set can commit or roll back atomically.
@@ -57,7 +57,7 @@ a least-privilege `QSession` with target and association permissions. Applying w
produces an APPLY exception: `no service session configured`.
The `edi` app has Activity/Setup sections, typed home widgets, record screens and standard
Review/Result actions. Main tables have RC.5 search-field metadata; `EdiIntegrations.mainTables()`
Review/Result actions. Main tables have RC.6 search-field metadata; `EdiIntegrations.mainTables()`
also exposes the list for host search registration.
## Tables
@@ -73,7 +73,7 @@ There are **26 persisted tables**, whose SQL names equal their QQQ names.
| Storage/queue | `edi_payload`, `edi_outbound_request`, `edi_outbound_message` |
`edi_preview_download` is a **storage-only metadata alias**, not a SQL table. Supporting tables
appear as child lists. Partner-scoped form choices use RC.5 contingent filters, including shared
appear as child lists. Partner-scoped form choices use RC.6 contingent filters, including shared
transports/guides and local certificates/identities.
## Inbound operation
@@ -111,7 +111,7 @@ Outbound maps support nested association paths; inbound target paths retain the
Preview shows parse counts, SNIP findings, the would-be ack, mapped JSON, expected-result and
previous-revision comparisons. It writes no target records. PHI previews suppress document values.
A session-authorized encrypted JSON report is downloadable. Select a stored sample or upload
synthetic EDI on the standard Review step. Browser uploads require the host's RC.5 Javalin
synthetic EDI on the standard Review step. Browser uploads require the host's RC.6 Javalin
`uploadedFileArchiveTableName` to be `edi_preview_download` and multipart handling without disk
staging. The alias encrypts streams and persists samples; this process accepts no filesystem archive.
Samples must contain synthetic data only.
@@ -165,29 +165,35 @@ in memory. Database storage remains the default for demos/tests.
For object storage, add/install optional `qbit-object-storage:0.1.0-RC.2`, configure its bucket
alias and select `OBJECT_STORAGE`. EDI uses only its public API and system access, with keys
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.5.
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.6.
Optional `qqq-esb` publishes setup/activity events after commit. Document events contain only ID
and status because RC.5's standard factory otherwise copies PHI fields. Alert names are
and status because RC.6's standard factory otherwise copies PHI fields. Alert names are
`edi.inbox.dead-letter`, `edi.ack.overdue`, `edi.ack.rejected`, `edi.send.failed`,
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.5 requires dotted names).
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.6 requires dotted names).
RC.5 lacks the planned core `DataClassification` API. `EdiClassifications` marks PHI/PII/confidential
fields and PHI fields deny AI exposure. Hosts must supply secured-data protection, database
encryption at rest and access policies for PHI columns; markers alone do not mask fields.
Core `DataClassification` metadata marks PHI/PII/CONFIDENTIAL fields through `AiFieldMetaData`,
with a CONFIDENTIAL table default through `AiTableMetaData`. PHI fields deny AI exposure.
Hosts must supply secured-data protection, database encryption at rest and access policies for PHI columns; markers alone do not mask fields.
The home reports installed integration and storage state.
Assign sends a value-free `QAlerts.notify` notification to the assignee after commit. Newly opened
or reopened REJECTED_ACK/DEAD_LETTER exceptions notify distinct assignees of OPEN/IN_PROGRESS
exceptions for the same partner; these existing assignments define the partner's recipients.
Repeated occurrences do not send another notification. Titles and bodies contain no document,
partner or exception-summary values. Without an alerts integration, core uses its logging sink.
## Outbound and transports
Active OUTBOUND agreement documents select the source table, map, guide and trigger. RECORD_CHANGE
uses core's instance listener and the writer's transaction. Source and EDI tables must share the
backend. `triggerFilter` is a serialized QQQ `QQueryFilter`; MANUAL also honors that filter.
RC.5 catches listener errors without failing the source write: successful enqueues roll back with
RC.6 catches listener errors without failing the source write: successful enqueues roll back with
the source transaction, but hosts must monitor failed listener execution.
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.5 `EsbTrigger` to invoke it with
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.6 `EsbTrigger` to invoke it with
`sourceTableName`, `sourceRecordId` and `eventId`; broker setup belongs to the host. Event IDs are
unique per agreement document. RC.5's consumer runner uses JMS, so broker-free acceptance calls
unique per agreement document. RC.6's consumer runner uses JMS, so broker-free acceptance calls
this process directly.
`edi_outbound_worker` and `edi_send_worker` run every 15 seconds when a scheduler is configured.
@@ -299,17 +305,18 @@ host tables live **only in test sources/resources** and use seeded synthetic pat
Their NPI `1234567893` is a checksum test fixture, not an asserted provider identity.
**The host must encrypt the database at rest and should install qbit-secured-data.** Payloads
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.5's
`EdiClassifications` stand-in marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
mask values. Without secured-data policies, a user with table access can read PHI columns.
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.6 core
classification metadata marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
provide secured-data field tiers. Without secured-data policies, a user with table access can
read PHI columns.
The home explicitly reports this limitation. Use record-level audits on host PHI target tables
until core classified-value audit protection is available.
RC.5 also logs query criteria verbatim. The marked `PhiLogScope` bridge suppresses synchronous
logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
remain persisted. It changes no logger levels and does not suppress other threads. Replace both
bridges with core secured-data support at 4.1.0-RC.6. Map samples use record-level audits to avoid
copying expected-result PHI into audit details. Host code outside these scopes must protect its
RC.6 still includes query criteria in RDBMS failure logs and has no logging redaction hook.
The marked `PhiLogScope` bridge suppresses synchronous logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
remain persisted. It changes no logger levels and does not suppress other threads. The bridge
tracks core #1147 (secured-data logging), with a removal/review deadline of 4.1.0-RC.7.
Map samples use record-level audits to avoid copying expected-result PHI into audit details. Host code outside these scopes must protect its
own logging, audits and asynchronous work.
Download Raw Document uses a session-bound process grant, rechecks `edi.raw.download` and record
@@ -317,10 +324,10 @@ read/partner access at download time, streams decrypted bytes without local file
`RAW_DOWNLOADED` with the user and row references, never content. Download filenames are generic.
`PhiCanaryTest` scans every five-character marker window through gateway/HTTPS intake, forced
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts, DEBUG logs, all EDI
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts/notifications, DEBUG logs, all EDI
query exports and all home/child widgets. FULL exports exempt only PHI-classified columns;
RC.5 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
qbit require the newer secured-data integration and are not claimed by these RC.5 tests.
RC.6 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
qbit require the newer secured-data integration and are not claimed by these RC.6 tests.
## Build and verification
+25 -19
View File
@@ -130,7 +130,7 @@ Package layout (`com.kingsrook.qbits.edi`):
| Dependency | Scope | Why |
|---|---|---|
| QQQ `4.1.0-RC.5` (then each new RC, G7) | compile | core, `QAlerts.raise/resolve` (RC.5) |
| 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. |
@@ -139,13 +139,20 @@ Package layout (`com.kingsrook.qbits.edi`):
| `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 that arrive in RC.6, and how EDI bridges until then:
Core features checked against the `v4.1.0-RC.6` tag:
| Need | Core | Until RC.6 |
| Need | Core | RC.6 status |
|---|---|---|
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | `EdiClassifications` stand-in, `STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6)`, sets the classification reflectively when the class is present |
| User notifications | `QAlerts.notify` (#1125) | alerts only via `QAlerts.raise` (RC.5); notifications added with the RC.6 bump |
| UI API extension point | `MiddlewareSpecExtension` (#1128) | the sandbox is a record action (section 8.3); spec'd endpoints come in phase f |
| `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,
@@ -247,7 +254,11 @@ One row per document, version and direction that the agreement carries.
- When the ack arrives, `QAlerts.resolve("edi.ack.overdue", dedupKey, note)`.
- A rejecting ack (AK9/IK9 `R`, or partial `P`) raises `edi.ack.rejected` (ERROR) and opens a
`REJECTED_ACK` exception.
- From RC.6, assignees of exceptions also get a `QAlerts.notify` notification.
- Assign sends the assignee a value-free `QAlerts.notify` notification after the update commits.
New or reopened `REJECTED_ACK`/`DEAD_LETTER` exceptions 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
@@ -770,7 +781,7 @@ so.
## 12. HIPAA and PHI
- **Classification** (`DataClassification`, core #1142; the stand-in until RC.6):
- **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
@@ -794,10 +805,10 @@ so.
- **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
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.5 HIDDEN field locks are separately exercised
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
@@ -942,7 +953,6 @@ queued acknowledgements are implemented. These RC.5 adaptations refine the execu
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.
- The a1 classification stand-in and host responsibility for PHI field protection remain unchanged.
Synthetic H2 verification covers permissions, association rollback, retry/stale claims, gateway
storage failures, original/current replay, 997/999, ack reconciliation, previews and home renderers.
@@ -998,13 +1008,9 @@ RC.5 adaptations implemented in this worktree:
## Phase e implementation notes (2026-10-14)
- Core RC.5 logs full query criteria, including PHI upsert keys. `PhiLogScope` is a scoped,
marked bridge to suppress synchronous diagnostic logging during PHI map/apply and previews;
it leaves unrelated threads and persisted value-free events intact. Remove with the core
classification/logging bridge at RC.6. Map samples use record-level audits on RC.5 because their
expected-result column can contain PHI. Host asynchronous code, target audits and queries outside
these boundaries still need host secured-data protection. Raw downloads use RC.5's existing
session-bound storage grant and an `edi_event` audit rather than a not-yet-available access log.
- 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_event` audit;
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.
@@ -1018,7 +1024,7 @@ RC.5 adaptations implemented in this worktree:
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.5
- 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)
+1 -1
View File
@@ -213,7 +213,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -24,6 +24,9 @@ import java.util.List;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -54,16 +57,33 @@ public final class EdiJournal
filter.withCriteria(pair[0].toString(), pair[1] == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, (Integer) pair[1]);
}
List<QRecord> matches = EdiRecords.query("edi_exception", filter);
if(matches.isEmpty())
QRecord exception;
boolean opened = matches.isEmpty();
if(opened)
{
SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
exception = SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
.withValue("partnerId", partner).withValue("type", type).withValue("summary", summary).withValue("status", "OPEN").withValue("occurrences", 1));
}
else
{
QRecord row = matches.getFirst();
exception = row;
opened = List.of("RESOLVED", "IGNORED").contains(row.getValueString("status"));
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("occurrences", row.getValueInteger("occurrences") + 1).withValue("status", "OPEN"));
}
if(opened && partner != null && List.of("REJECTED_ACK", "DEAD_LETTER").contains(type))
{
String[] assignees = EdiRecords.query("edi_exception", EdiRecords.equal("partnerId", partner)
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")).stream()
.map(row -> row.getValueString("assignedToUserId")).filter(user -> user != null && !user.isBlank()).distinct().toArray(String[]::new);
if(assignees.length > 0)
{
QAlerts.notify(new NotificationSignal("EDI exception opened")
.withBody("An acknowledgement rejection or dead-letter exception needs attention.")
.withAudience(NotificationAudience.users(assignees))
.withSourceName("edi").withSourceTableName("edi_exception").withSourceRecordId(exception.getValueString("id")));
}
}
event(inbox, interchange, transaction, "EXCEPTION");
}
}
@@ -27,7 +27,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** RC.5 form filters; no dependency on later dependency-behavior metadata.
** RC.6 form filters; no dependency on later dependency-behavior metadata.
*******************************************************************************/
public final class EdiContingencies
{
@@ -75,7 +75,9 @@ public final class EdiContingencies
}
if(filter != null)
{
field.withPossibleValueSourceFilter(filter);
// Core appends its search criteria to the root; keep them ANDed with partner scope.
field.withPossibleValueSourceFilter(filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR
? new QQueryFilter().withSubFilter(filter) : filter);
}
}
}
@@ -51,10 +51,10 @@ import com.kingsrook.qbits.edi.model.EdiPayload;
import com.kingsrook.qbits.edi.model.EdiTransaction;
import com.kingsrook.qbits.edi.model.EdiTransactionError;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.security.EdiClassifications;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -234,9 +234,10 @@ public final class EdiTables
.withPermissionRules(new QPermissionRules().withPermissionBaseName("edi." + name.substring(4)).withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
.withAuditRules(new QAuditRules().withAuditLevel(readOnly || name.equals("edi_map_sample") ? AuditLevel.RECORD : AuditLevel.FIELD));
QRecordEntity.registerTable(entity, table);
AiTableMetaData.ofOrWithNew(table).withClassification(DataClassification.CONFIDENTIAL);
if(List.of("edi_payload", "edi_certificate", "edi_inbox").contains(name))
{
table.withSupplementalMetaData(new AiTableMetaData().withExposure(AiExposure.NONE));
AiTableMetaData.ofOrWithNew(table).withExposure(AiExposure.NONE);
}
if(name.equals("edi_exception"))
{
@@ -253,13 +254,14 @@ public final class EdiTables
}
for(QFieldMetaData field : table.getFields().values())
{
String classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? "PHI"
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? "PII" : "CONFIDENTIAL";
field.withSupplementalMetaData(new EdiClassifications(classification));
if(classification.equals("PHI"))
DataClassification classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? DataClassification.PHI
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? DataClassification.PII : DataClassification.CONFIDENTIAL;
AiFieldMetaData metadata = new AiFieldMetaData().withClassification(classification);
if(classification == DataClassification.PHI)
{
field.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
metadata.withExposure(AiExposure.NONE);
}
field.withSupplementalMetaData(metadata);
}
EdiTablePresentation.apply(table);
EdiRecordLabels.configure(table);
@@ -32,6 +32,9 @@ import com.kingsrook.qbits.edi.map.MapPreview;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
@@ -316,13 +319,25 @@ public final class EdiProcesses
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("status", operation.equals("assign") ? "IN_PROGRESS" : operation.equals("ignore") ? "IGNORED" : "RESOLVED");
if(operation.equals("assign"))
{
update.withValue("assignedToUserId", input.getValue("assignedToUserId"));
String assignee = input.getValueString("assignedToUserId");
if(assignee == null || assignee.isBlank())
{
throw new IllegalArgumentException("An assignee is required");
}
update.withValue("assignedToUserId", assignee);
}
else
{
update.withValue("resolvedBy", user).withValue("resolvedAt", EdiRuntime.current().config().getClock().instant());
}
SystemWrites.update(table, update);
if(operation.equals("assign"))
{
QAlerts.notify(new NotificationSignal("EDI exception assigned")
.withBody("An EDI exception has been assigned to you.")
.withAudience(NotificationAudience.user(update.getValueString("assignedToUserId")))
.withSourceName("edi").withSourceTableName(table).withSourceRecordId(row.getValueString("id")));
}
}
else
{
@@ -1,82 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.security;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6). Field classifications on RC.5.
*******************************************************************************/
public class EdiClassifications extends QSupplementalFieldMetaData
{
private final String classification;
/*******************************************************************************
** Retain classification as metadata until the core enum is available.
*******************************************************************************/
public EdiClassifications(String classification)
{
this.classification = classification;
}
/*******************************************************************************
** Stable supplemental metadata key.
*******************************************************************************/
@Override
public String getType()
{
return ("edi.classification");
}
/*******************************************************************************
** Read the classification without requiring an RC.6 class.
*******************************************************************************/
public String getClassification()
{
return (classification);
}
/*******************************************************************************
** Bridge when a host supplies the newer field classification API.
*******************************************************************************/
@Override
public void enrich(QInstance instance, QFieldMetaData field)
{
try
{
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.DataClassification");
Object value = type.getField(classification).get(null);
field.getClass().getMethod("setDataClassification", type).invoke(field, value);
}
catch(ClassNotFoundException e)
{
// RC.5 keeps the explicit supplemental classification below.
}
catch(ReflectiveOperationException e)
{
throw new IllegalStateException("Update the EDI classification bridge for this core version", e);
}
}
}
@@ -35,8 +35,8 @@ import org.apache.logging.log4j.message.Message;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6).
** RC.5 logs query criteria verbatim. Suppress synchronous diagnostic logging only
** STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7).
** RC.6 logs query criteria verbatim. Suppress synchronous diagnostic logging only
** while PHI mapping/application runs; value-free EDI events remain in the database.
** This does not replace host masking or protection of host code outside this scope.
*******************************************************************************/
@@ -60,7 +60,7 @@ public final class PhiLogScope implements AutoCloseable
}
/*******************************************************************************
** Open a scope around code that may pass clinical values to RC.5 core actions.
** Open a scope around code that may pass clinical values to RC.6 core actions.
*******************************************************************************/
public static PhiLogScope open(boolean enabled)
{
@@ -74,7 +74,7 @@ public final class PhiLogScope implements AutoCloseable
{
if(!(LogManager.getContext(false) instanceof LoggerContext context))
{
throw new IllegalStateException("PHI processing requires the RC.5 Log4j protection bridge");
throw new IllegalStateException("PHI processing requires the RC.6 Log4j protection bridge");
}
Configuration configuration = context.getConfiguration();
if(!INSTALLED.containsKey(configuration))
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueSearchFilterUseCase;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -39,7 +40,7 @@ import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Contingent metadata and real RC.5 possible-value searches with form values.
** Contingent metadata and real RC.6 possible-value searches with form values.
*******************************************************************************/
class ContingenciesTest
{
@@ -123,7 +124,7 @@ class ContingenciesTest
{
var field = instance.getTable(pair[0]).getField(pair[1]);
QQueryFilter interpreted = field.getPossibleValueSourceFilter().clone();
// RC.5 middleware interprets the field filter before calling the search action.
// RC.6 middleware interprets the field filter before calling the search action.
interpreted.interpretValues(Map.of("partnerId", 1));
var result = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(field.getPossibleValueSourceName())
.withDefaultQueryFilter(interpreted).withOtherValues(Map.of("partnerId", 1)).withSearchTerm("").withLimit(100));
@@ -133,6 +134,41 @@ class ContingenciesTest
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getPossibleValueSourceFilter()).isNull();
}
/*******************************************************************************
** RC.6 lacks dependency behavior metadata; shared transports still work with no partner.
*******************************************************************************/
@Test
void sharedTransportBeforeAndAfterPartnerSelection() throws Exception
{
var instance = H2Support.create();
SystemWrites.call(() ->
{
for(Integer partner : java.util.Arrays.asList(null, 1, 2))
{
SystemWrites.insert("edi_transport", new QRecord().withValue("name", "van-" + partner).withValue("partnerId", partner)
.withValue("type", "GATEWAY").withValue("direction", "BOTH").withValue("gatewayEndpointName", "synthetic-outbox"));
}
return (null);
});
for(var values : List.of(Map.<String, java.io.Serializable>of(), Map.<String, java.io.Serializable>of("partnerId", 1)))
{
var field = instance.getTable("edi_agreement").getField("outboundTransportId");
var filter = field.getPossibleValueSourceFilter().clone();
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
var result = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter)
.withOtherValues(values).withSearchTerm("").withLimit(100));
assertThat(result.getResults()).extracting(value -> value.getId().toString()).contains("1").doesNotContain("3");
assertThat(result.getResults()).hasSize(values.isEmpty() ? 1 : 2);
filter = field.getPossibleValueSourceFilter().clone();
filter.interpretValues(Map.of("input", values), PossibleValueSearchFilterUseCase.FORM);
var unrelated = new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(field.getPossibleValueSourceName()).withDefaultQueryFilter(filter)
.withOtherValues(values).withSearchTerm("van-2").withLimit(100));
assertThat(unrelated.getResults()).isEmpty();
}
}
/*******************************************************************************
** Test fixture operation.
*******************************************************************************/
@@ -26,9 +26,10 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.edi.metadata.EdiTables;
import com.kingsrook.qbits.edi.security.EdiClassifications;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
@@ -52,6 +53,34 @@ import static org.assertj.core.api.Assertions.assertThat;
*******************************************************************************/
class EdiPresentationTest
{
/*******************************************************************************
** Core classification resolution preserves PHI/PII and the confidential default.
*******************************************************************************/
@Test
void coreClassifications() throws Exception
{
var instance = H2Support.create();
for(var table : EdiTables.tables(new EdiQBitConfig().withBackendName("edi")))
{
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofTable(table)).isEqualTo(DataClassification.CONFIDENTIAL);
}
for(String[] pair : List.of(new String[] {"edi_transaction", "phiBusinessKey"}, new String[] {"edi_transaction_error", "valueExcerpt"},
new String[] {"edi_map_sample", "expectedResultJson"}, new String[] {"edi_inbox", "fileName"}, new String[] {"edi_payload", "ciphertext"}))
{
var table = instance.getTable(pair[0]);
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(table, pair[1])).isEqualTo(DataClassification.PHI);
assertThat(AiFieldMetaData.of(table.getField(pair[1])).getExposure()).isEqualTo(com.kingsrook.qqq.backend.core.actions.ai.AiExposure.NONE);
}
for(String field : List.of("contactName", "contactEmail", "contactPhone"))
{
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("edi_partner"), field)).isEqualTo(DataClassification.PII);
}
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("edi_partner"), "code")).isEqualTo(DataClassification.CONFIDENTIAL);
HealthcareSupport.tables();
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("sample_claim"), "patientName")).isEqualTo(DataClassification.PHI);
assertThat(com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications.ofField(instance.getTable("sample_claim_line"), "parentId")).isEqualTo(DataClassification.CONFIDENTIAL);
}
/*******************************************************************************
** All registered tables have readable labels and linked internal references.
*******************************************************************************/
@@ -79,8 +108,8 @@ class EdiPresentationTest
}
for(String name : table.getRecordLabelFields() == null ? List.<String>of() : table.getRecordLabelFields())
{
var classification = (EdiClassifications) table.getField(name).getSupplementalMetaData().get("edi.classification");
assertThat(classification.getClassification()).as(table.getName() + "." + name).isNotEqualTo("PHI");
var classification = AiFieldMetaData.of(table.getField(name));
assertThat(classification.getClassification()).as(table.getName() + "." + name).isNotEqualTo(DataClassification.PHI);
}
}
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getLabel()).isEqualTo("Assigned To");
@@ -37,6 +37,8 @@ import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12Separators;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
import com.kingsrook.qbits.edi.x12.X12Writer;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -93,7 +95,7 @@ public final class HealthcareSupport
{
String[] parts = field.split(":");
table.withField(new QFieldMetaData(parts[0], parts.length > 1 ? QFieldType.valueOf(parts[1]) : QFieldType.STRING).withBackendName(QInstanceEnricher.inferBackendName(parts[0]))
.withSupplementalMetaData(new com.kingsrook.qbits.edi.security.EdiClassifications(parts[0].equals("parentId") ? "CONFIDENTIAL" : "PHI")));
.withSupplementalMetaData(new AiFieldMetaData().withClassification(parts[0].equals("parentId") ? DataClassification.CONFIDENTIAL : DataClassification.PHI)));
}
instance.addTable(table);
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) instance.getBackend(backend)); var statement = connection.createStatement())
@@ -67,6 +67,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Synthetic H2 vertical slice, including permission, retry and UI boundaries.
@@ -337,6 +338,15 @@ class InboundTest
*******************************************************************************/
public static class CapturedAlerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
{
static final List<com.kingsrook.qqq.backend.core.alerts.NotificationSignal> notifications = new ArrayList<>();
/*******************************************************************************
** Capture actual core notification dispatch.
*******************************************************************************/
@Override
public void notify(com.kingsrook.qqq.backend.core.alerts.NotificationSignal signal)
{
notifications.add(signal);
}
static final List<String> raised = new ArrayList<>();
static final List<String> resolved = new ArrayList<>();
/*******************************************************************************
@@ -409,6 +419,9 @@ class InboundTest
return (null);
});
QRecord exception = rows("edi_exception").getFirst();
CapturedAlerts.notifications.clear();
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
for(String action : List.of("assign", "ignore", "resolve"))
{
input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName("edi_exception_" + action).withRecords(List.of(exception))
@@ -417,6 +430,85 @@ class InboundTest
}
assertThat(rows("edi_exception").getFirst().getValueString("status")).isEqualTo("RESOLVED");
assertThat(rows("edi_exception").getFirst().getValueString("assignedToUserId")).isEqualTo("synthetic-user");
assertThat(CapturedAlerts.notifications).singleElement().satisfies(signal ->
{
assertThat(signal.getAudience().getValues()).containsExactly("synthetic-user");
assertThat(signal.getTitle()).isEqualTo("EDI exception assigned");
assertThat(signal.getBody()).isEqualTo("An EDI exception has been assigned to you.");
assertThat(signal.getSourceTableName()).isEqualTo("edi_exception");
assertThat(signal.getSourceRecordId()).isEqualTo(exception.getValueString("id"));
});
input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName("edi_exception_assign")
.withRecords(List.of(exception)).withValues(Map.of("assignedToUserId", " "));
output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
execute.runOnePage(input, output);
assertThat(execute.getProcessSummary(output, true).toString()).contains("record could not be processed");
assertThat(CapturedAlerts.notifications).hasSize(1);
assertThat(rows("edi_exception").getFirst().getValueString("status")).isEqualTo("RESOLVED");
}
/*******************************************************************************
** Newly opened partner failures notify current assignees once, after commit only.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"REJECTED_ACK", "DEAD_LETTER"})
void notifyPartnerAssignees(String type) throws Exception
{
setup("004010");
CapturedAlerts.notifications.clear();
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(QContext.getQInstance())
.withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
Integer partnerId = partner.getValueInteger("id");
SystemWrites.call(() ->
{
for(String status : List.of("OPEN", "IN_PROGRESS", "RESOLVED", "IGNORED"))
{
SystemWrites.insert("edi_exception", new QRecord().withValue("partnerId", partnerId).withValue("type", "APPLY")
.withValue("status", status).withValue("assignedToUserId", List.of("OPEN", "IN_PROGRESS").contains(status) ? "current-user" : "closed-user"));
}
SystemWrites.insert("edi_exception", new QRecord().withValue("partnerId", partnerId + 1).withValue("type", "APPLY")
.withValue("status", "OPEN").withValue("assignedToUserId", "other-partner-user"));
return (null);
});
SystemWrites.call(() -> SystemWrites.transaction(() ->
{
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Synthetic private text");
assertThat(CapturedAlerts.notifications).isEmpty();
return (null);
}));
assertThat(CapturedAlerts.notifications).singleElement().satisfies(signal ->
{
assertThat(signal.getAudience().getValues()).containsExactly("current-user");
assertThat(signal.getTitle()).isEqualTo("EDI exception opened");
assertThat(signal.getBody()).isEqualTo("An acknowledgement rejection or dead-letter exception needs attention.");
assertThat(signal.getSourceTableName()).isEqualTo("edi_exception");
assertThat(signal.getSourceRecordId()).isNotBlank();
});
CapturedAlerts.notifications.clear();
SystemWrites.call(() ->
{
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Repeated failure");
com.kingsrook.qbits.edi.flow.EdiJournal.exception(101, null, null, null, type, "No partner");
com.kingsrook.qbits.edi.flow.EdiJournal.exception(102, null, null, partnerId, "VALIDATION", "Other type");
com.kingsrook.qbits.edi.flow.EdiJournal.exception(103, null, null, partnerId + 2, type, "No assignees");
return (null);
});
assertThat(CapturedAlerts.notifications).isEmpty();
assertThatThrownBy(() -> SystemWrites.call(() -> SystemWrites.transaction(() ->
{
com.kingsrook.qbits.edi.flow.EdiJournal.exception(104, null, null, partnerId, type, "Rolled back");
throw new IllegalStateException("Synthetic rollback");
}))).isInstanceOf(Exception.class);
assertThat(CapturedAlerts.notifications).isEmpty();
assertThat(EdiRecords.query("edi_exception", EdiRecords.equal("inboxId", 104))).isEmpty();
SystemWrites.call(() ->
{
QRecord row = EdiRecords.query("edi_exception", EdiRecords.equal("inboxId", 100)).getFirst();
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("status", "RESOLVED"));
com.kingsrook.qbits.edi.flow.EdiJournal.exception(100, null, null, partnerId, type, "Reopened");
return (null);
});
assertThat(CapturedAlerts.notifications).hasSize(1);
}
/*******************************************************************************
@@ -44,7 +44,6 @@ import com.kingsrook.qbits.edi.flow.InboxWorker;
import com.kingsrook.qbits.edi.flow.SendWorker;
import com.kingsrook.qbits.edi.metadata.EdiTables;
import com.kingsrook.qbits.edi.processes.EdiProcesses;
import com.kingsrook.qbits.edi.security.EdiClassifications;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadCrypto;
import com.kingsrook.qbits.edi.storage.PreviewDownloadBackend;
@@ -55,6 +54,8 @@ import com.kingsrook.qbits.edi.transport.gateway.EdiFileGatewayConsumer;
import com.kingsrook.qbits.edi.transport.gateway.standin.FileGatewayOutbox;
import com.kingsrook.qbits.edi.transport.gateway.standin.InboundFile;
import com.kingsrook.qbits.edi.transport.gateway.standin.OutboxFileRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -78,7 +79,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** End-to-end PHI egress scanner. Only explicit PHI columns may retain sentinels.
** RC.5 has no secured-data tier resolver; FULL exports and positional egress run here.
** RC.6 has no secured-data tier resolver; FULL exports and positional egress run here.
*******************************************************************************/
class PhiCanaryTest
{
@@ -120,8 +121,8 @@ class PhiCanaryTest
{
for(var field : QContext.getQInstance().getTable(table).getFields().values())
{
var classification = field.getSupplementalMetaData("edi.classification");
if(classification instanceof EdiClassifications classified && classified.getClassification().equals("PHI"))
var classification = AiFieldMetaData.of(field);
if(classification != null && classification.getClassification() == DataClassification.PHI)
{
values.remove(field.getName());
}
@@ -158,6 +159,7 @@ class PhiCanaryTest
demo.seed();
var instance = demo.defineQInstance();
CapturedAlerts.outputs.clear();
CapturedAlerts.notifications.clear();
com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData.ofOrWithNew(instance).withSink(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(CapturedAlerts.class));
// A test-only widened diagnosis field allows the long marker through mapping;
// its invalid code shape still exercises SNIP 5, and separate documents force SNIP 2.
@@ -250,6 +252,13 @@ class PhiCanaryTest
}
for(QRecord exception : rows("edi_exception"))
{
process("edi_exception_assign", exception);
SystemWrites.call(() -> SystemWrites.transaction(() ->
{
com.kingsrook.qbits.edi.flow.EdiJournal.exception(exception.getValueInteger("inboxId"), exception.getValueInteger("interchangeId"),
exception.getValueInteger("transactionId"), exception.getValueInteger("partnerId"), "DEAD_LETTER", "Synthetic PHI failure");
return (null);
}));
process("edi_exception_resolve", exception);
}
while(new SendWorker().runOnce() > 0)
@@ -266,6 +275,8 @@ class PhiCanaryTest
SystemWrites.call(() -> { SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id")).withValue("ackStatus", "PENDING").withValue("ackDueAt", Instant.now().minusSeconds(60))); return (null); });
new AckMonitor().runOnce();
assertThat(CapturedAlerts.outputs).isNotEmpty();
assertThat(CapturedAlerts.notifications).anyMatch(json -> json.contains("EDI exception assigned"));
assertThat(CapturedAlerts.notifications).anyMatch(json -> json.contains("EDI exception opened"));
CapturedAlerts.outputs.forEach(output -> scan(output, "alerts"));
for(var table : instance.getTables().values())
{
@@ -340,7 +351,7 @@ class PhiCanaryTest
}
/*******************************************************************************
** RC.5 HIDDEN field locks are real enforcement, not test-side JSON redaction.
** RC.6 HIDDEN field locks are real enforcement, not test-side JSON redaction.
*******************************************************************************/
private void hiddenExportsAndWidgets() throws Exception
{
@@ -358,7 +369,7 @@ class PhiCanaryTest
viewer.withPermissions("edi." + table.getName().substring(4) + ".read");
for(var field : table.getFields().values())
{
if(field.getSupplementalMetaData("edi.classification") instanceof EdiClassifications classified && classified.getClassification().equals("PHI"))
if(AiFieldMetaData.of(field) != null && AiFieldMetaData.of(field).getClassification() == DataClassification.PHI)
{
field.withFieldSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock().withSecurityKeyType("synthetic_phi_access"));
}
@@ -401,6 +412,10 @@ class PhiCanaryTest
private RunBackendStepOutput process(String name, QRecord record) throws Exception
{
var input = new RunBackendStepInput().withProcessName(name).withRecords(List.of(record));
if(name.equals("edi_exception_assign"))
{
input.withValues(Map.of("assignedToUserId", "synthetic-canary-user"));
}
var review = new EdiProcesses.Review();
var preview = new RunBackendStepOutput();
review.runOnePage(input, preview);
@@ -428,6 +443,19 @@ class PhiCanaryTest
public static class CapturedAlerts implements com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface
{
static final List<String> outputs = new ArrayList<>();
static final List<String> notifications = new ArrayList<>();
/*******************************************************************************
** Scan the complete notification payload for PHI, including attributes.
*******************************************************************************/
@Override
public void notify(com.kingsrook.qqq.backend.core.alerts.NotificationSignal signal)
{
String json = JsonUtils.toJson(signal);
notifications.add(json);
outputs.add(json);
}
/*******************************************************************************
** Retain every serialized signal for the substring scanner.