diff --git a/AGENTS.md b/AGENTS.md
index ceda55d..f019f87 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcd0b81..0c503a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/CLAUDE.md b/CLAUDE.md
index f5cdfe6..dfdd39a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index 9c835d3..b83e44b 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index d1b2447..84cf838 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -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)
diff --git a/pom.xml b/pom.xml
index c8f0297..1997b82 100644
--- a/pom.xml
+++ b/pom.xml
@@ -213,7 +213,7 @@
true
- 4.1.0-RC.5
+ 4.1.0-RC.6
diff --git a/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java b/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java
index ad9c83c..bba58d5 100644
--- a/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java
+++ b/src/main/java/com/kingsrook/qbits/edi/flow/EdiJournal.java
@@ -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 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");
}
}
diff --git a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java
index af208bc..5027392 100644
--- a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java
+++ b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiContingencies.java
@@ -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);
}
}
}
diff --git a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java
index 6f6c475..6769f9e 100644
--- a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java
+++ b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiTables.java
@@ -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);
diff --git a/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java b/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java
index 17616ac..1a69597 100644
--- a/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java
+++ b/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java
@@ -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
{
diff --git a/src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java b/src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java
deleted file mode 100644
index 9cf0186..0000000
--- a/src/main/java/com/kingsrook/qbits/edi/security/EdiClassifications.java
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java b/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java
index 38e1274..8c018c6 100644
--- a/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java
+++ b/src/main/java/com/kingsrook/qbits/edi/security/PhiLogScope.java
@@ -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))
diff --git a/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java b/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java
index 8f81665..6056a55 100644
--- a/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java
+++ b/src/test/java/com/kingsrook/qbits/edi/ContingenciesTest.java
@@ -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.of(), Map.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.
*******************************************************************************/
diff --git a/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java b/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java
index 99c4736..cf52c2e 100644
--- a/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java
+++ b/src/test/java/com/kingsrook/qbits/edi/EdiPresentationTest.java
@@ -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.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");
diff --git a/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java b/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java
index bbd02d3..5684eb2 100644
--- a/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java
+++ b/src/test/java/com/kingsrook/qbits/edi/HealthcareSupport.java
@@ -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())
diff --git a/src/test/java/com/kingsrook/qbits/edi/InboundTest.java b/src/test/java/com/kingsrook/qbits/edi/InboundTest.java
index 63c8e0f..1cf11ca 100644
--- a/src/test/java/com/kingsrook/qbits/edi/InboundTest.java
+++ b/src/test/java/com/kingsrook/qbits/edi/InboundTest.java
@@ -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 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 raised = new ArrayList<>();
static final List 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);
}
/*******************************************************************************
diff --git a/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java b/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java
index 200f8ff..eb24832 100644
--- a/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java
+++ b/src/test/java/com/kingsrook/qbits/edi/PhiCanaryTest.java
@@ -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 outputs = new ArrayList<>();
+ static final List 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.