News and noteworthy - phax/phoss-ap GitHub Wiki

Releases

v0.13.2 - work in progress

  • The HTTP forwarding modes http_post_sync and http_post_async now send the phoss-ap transaction ID in the new header X-phoss-AP-Transaction-ID, next to X-SBDH-Instance-ID. Unlike the SBDH Instance ID it is unique per transaction, so the Receiver Backend no longer needs a REST lookup to correlate a forwarded document. The value stays the same for all retries and replays of a transaction. See #105

v0.13.1 - 2026-10-02

  • Fixed: outbound transactions failing in the SMP lookup were retried forever, ignoring retry.sending.max-attempts. The maximum number of attempts was only checked for failures of the AS4 transmission. A transaction that never got past the SMP lookup - e.g. because the SMP is unreachable or signs its responses with an untrusted certificate - stayed in status failed with an ever growing attempt_count. The same applied to transactions that hit an open circuit breaker for longer than circuit-breaker.defer-max-duration: from then on the attempts were counted, but never limited. All of them now end as permanently_failed once retry.sending.max-attempts is reached, and onOutboundPermanentSendingFailure is invoked. Unchanged: an MLS response whose (default SPID) receiver is not registered in the SMP is still retried without limit, as required by PNP rule MLS-4.
  • The outbound transaction error_details now contain the real reason of a failed SMP lookup or AS4 transmission. Previously only the outermost exception message was stored, e.g. Failed to resolve SMP endpoint (...). Now the messages of all causes are appended as . Technical details: <SimpleClassName>: <message> - <SimpleClassName>: <message>, so that for example the untrusted SMP signing certificate is visible without looking into the sending report.
  • The phorm document verification now uses the phorm-client library instead of its own HTTP and JSON code. PhormDocumentVerifier talked to the verification service with a hand written HttpPost, parsed the answer with JsonReader and converted it with PhiveJsonHelper. All of that moved into com.helger:phorm-client 1.0.0, and the verifier keeps only what is specific to the AP: deciding whether a document goes to the regular dd_and_validate API or - for a Factur-X style non-XML payload - to the hybrid PDF API, extracting that PDF from the SBDH BinaryContent element, and mapping the phive findings to VerificationIssues. Three defects of the hand written code are fixed by the move. A rejected X-Token (HTTP 403) was reported as "Failed to parse Phorm response as JSON", because the check only ever tried to read the body as a phive result; it is now named as what it is. A 400 with a text/plain body - what phorm answers for a payload that is not XML or an unresolvable VESID - lost that body entirely; it is now carried in the logged message. And the response charset was assumed instead of being taken from the Content-Type header. The verifier also distinguishes the three failure modes the client reports - a faulty request, an unavailable service and an unusable answer - which is what the EVerificationFailMode handling has always needed. A document is still never rejected because the verification service itself did not work.
  • The verification service is no longer contacted with a fresh connection pool per document. Every call created and closed its own HttpClientManager, so every single inbound and outbound document paid a full TCP and TLS handshake. A process wide PhormClient is now shared by all verifications, in the same way SMPClientManager already shares one for the SMP queries. It is rebuilt when verification.phorm.url or verification.phorm.token changes, so a configuration change is never masked by a cached client. The document is streamed to phorm as before - phorm-client takes the payload as an IHasInputStream, so a large document is not read into memory to be sent. A document verifier may now implement AutoCloseable to release such a resource; APCoreMetaManager.shutdown () closes every loaded verifier that does. Nothing changes for a verifier that does not.
  • Requires ddd 0.9.0 (was 0.8.10): phorm-client depends on the new ddd-model artifact, which carries DocumentDetails since the ddd 0.9.0 split, so keeping 0.8.10 would put that class on the classpath twice. com.helger:ddd itself is unchanged for its users.
  • Updated to phase4 4.8.0, which contains security relevant fixes:
    • Updated to WSS4J 4.0.2 - fixes CVE-2026-89238
    • The Peppol inbound message handling now compares the AS4 CollaborationInfo/Service, CollaborationInfo/Service/@type and CollaborationInfo/Action against the Process Identifier and the Document Type Identifier of the SBDH, as mandated by section 4.6 of the Peppol AS4 profile. A mismatch currently only emits a warning - in one of the next phase4 versions it will be elevated to an error that rejects the message
    • The new phase4 configuration property phase4.incoming.security.enforcepmode (default false) can be set in the AP configuration. If enabled, an incoming UserMessage must be signed and encrypted if the effective PMode leg defines a signature respectively an encryption algorithm - which the Peppol PMode does. Otherwise it is rejected with EBMS:0103 (Policy Noncompliance) before it reaches the AP
    • Fixed the size of the encryption session key, which did not follow the configured encryption algorithm, so messages labelled as AES-256 were effectively encrypted with a 128-bit key. Peppol mandates AES-128-GCM, so the Peppol traffic of the AP was not affected
    • The PMode setting ReceptionAwareness.DuplicateDetection is now honoured for incoming messages. The default behaviour is unchanged
    • The sending report now contains the details of the TLS connection to C3 as connection (JSON) respectively Connection (XML), also in case of success
  • Updated dependencies:
    • peppol-commons 13.1.0 → 13.1.1
    • AWS SDK 2.55.3 → 2.55.8
    • Apache Commons Lang 3.20.0 → 3.21.0
    • Flyway 13.7.0 → 13.8.1
    • H2 2.5.250 → 2.5.252
    • Apache MINA sshd-sftp 2.19.0 → 2.20.0

v0.13.0 - 2026-09-24

  • Fixed: starting with otel.enabled=true failed with IllegalStateException: GlobalOpenTelemetry.set has already been called (issue #102). The first GlobalOpenTelemetry.get () of a JVM permanently installs the official no-op instance as the global one, and every later GlobalOpenTelemetry.set (...) then throws. Up to 0.12.0 the SDK was installed by OtelConfig on the ApplicationStartedEvent - which fires after the context refresh - while the Flyway migration runs during the refresh, when the AS4 servlet bean is created, and is wrapped in a telemetry span by ph-db-flyway. So the no-op was always registered first and the AP could not start at all with OpenTelemetry enabled. OtelConfig is replaced by OtelBootstrapInitializer, an ApplicationContextInitializer registered in META-INF/spring.factories. Those run after the environment is prepared but before the first bean is created, so the SDK is now in place before anything can take a span. The same otel.enabled property and the same autoconfigure-based configuration apply - nothing changes for an existing deployment except that it starts. Resetting the global with GlobalOpenTelemetry.resetForTest () is deliberately not used as the fix. It makes the startup succeed, but the ph-telemetry OTel bindings used to cache the tracer and the meter they resolved from GlobalOpenTelemetry on first use, so every span would silently stay a no-op for the lifetime of the JVM - and on an agent-instrumented deployment it would discard the agent's own SDK. The other half of the defect was fixed in the library instead, in ph-telemetry 1.1.1: OtelTelemetryTracerSPI and OtelTelemetryMeterSPI now resolve via GlobalOpenTelemetry.getOrNoop () - the API that OpenTelemetry documents for instrumentation and that has no side effect on the global slot - and only cache the result once an instance is really registered. Taking a span before the SDK bootstrap can therefore no longer break that bootstrap, and no longer pins the no-op forever. The two fixes are independent: the library one makes an early span harmless, and the initializer makes it actually recorded. The SDK is now built without setResultAsGlobal () and registered in a separate, guarded step. If a global instance is already present - realistically only when the OpenTelemetry Java Agent is attached - the AP keeps it, shuts down the SDK it just built and logs a warning, instead of aborting the startup and leaking the built SDK. With the agent, set otel.enabled=false. New: OtelBootstrapInitializer with the constant CONFIG_KEY_OTEL_ENABLED; removed: OtelConfig.
  • The circuit breakers no longer suspend healthy SMPs, and a suspension no longer burns retry attempts. Operators saw outbound transactions fail with SMP access limited by Circuit Breaker 'smp$<host>', always for the same large SMPs, while the SMP operators saw no failed requests at all - and changing the circuit-breaker.* properties had no visible effect. Four independent defects contributed to that. A permit acquired in the half-open state was never released if a RuntimeException occurred. Failsafe releases such a permit only in recordSuccess / recordFailure; there is no timeout. With the default circuit-breaker.half-open-max-attempts=1 a single leak therefore left the circuit breaker stuck in HALF_OPEN forever: every call rejected, no further state change event in the log, and no way out but a restart - which is also why changing the properties appeared to do nothing. All three usages - the SMP lookup, the AS4 sending and the inbound forwarding - now record exactly one result for every acquired permit on every code path, including the "no document forwarder configured" branch that returned without recording anything at all. "The receiver is not registered" counted as an SMP failure. Every Phase4Exception from the lookup called recordFailure, so five lookups for unregistered receivers in a row suspended a large SMP for all of its participants. The new SmpLookupFailureClassifier tells a negative answer (HTTP 404, or no endpoint matching the process and transport profile) from a real infrastructure failure (connection failure, connect or read timeout, unknown host, HTTP 5xx, unparsable or badly signed response). A negative answer now records a success - the SMP answered, that is a valid answer - and reports the receiver as not registered. With the current phase4 version those two cases can only be recognised by their exception message, because both arrive as a cause-less Phase4SMPException with retryFeasible=true; the string matching is isolated in the classifier, marked with a TODO and covered by tests using the exact current messages. An SMP timeout permanently failed the transaction. phase4 classifies SMPClientSMPUnavailableException and SMPClientHttpException as "retry not feasible", because neither matches its list of retryable exceptions - so a read timeout against a busy SMP did not lead to a retry but straight to permanently_failed. The classifier now overrides the retry decision for both of the cases above: a negative answer is never retried against the same SMP, an infrastructure failure always is, independent of what isRetryFeasible () says. This also fixes the MLS SPOG section 5.4 fallback in both directions: an unreachable custom MLS_TO receiver now correctly falls back to the default SPID receiver, and a transient SMP problem no longer triggers that fallback. A rejection consumed a retry attempt. A lookup or a send that the circuit breaker rejects never reaches the remote system, but it still incremented attempt_count and wrote a failed attempt row - so a transaction could reach permanently_failed without a single real attempt, and the inflated count made the first real AS4 error permanent. The outbound direction now does what the inbound forwarder already did: no attempt row, unchanged attempt_count, and next_retry_dt set to now + max (remaining open duration, retry scheduler interval), so the retry happens as soon as the circuit breaker may grant a permit again. The rejection stays visible in error_details and in the sending report. The new property circuit-breaker.defer-max-duration (default 12h) caps this: an older transaction falls back to the regular attempt counting, so a permanently unreachable SMP or AP cannot defer a transaction forever. No database migration is needed - updateStatusAndRetry already takes the attempt count as a parameter.
  • Circuit breaker rejections now say what is wrong and for how long. Instead of SMP access limited by Circuit Breaker 'smp$https://...', the log, the transaction error_details and the sending report carry e.g. SMP access to 'https://smp.example.org' suspended by circuit breaker (state OPEN since 2026-09-14T10:12:03Z, 42s remaining) after 5 failures; last failure: SocketTimeoutException: Read timed out after 10000 ms. CircuitBreakerManager tracks per key when the circuit breaker was opened and what the last failure was; the new recordFailure (String, Throwable) overload carries the cause (the old signature is kept), stored as the simple exception class name plus its message and truncated to 200 characters. onOpen moved from INFO to WARN and includes the last failure cause; onHalfOpen and onClose stay at INFO.
  • The failure thresholding can be made tolerant of short load peaks. Five consecutive failures - the default - are easily reached while an otherwise healthy SMP is under load. Three new optional properties are added, all unset by default so that nothing changes unless they are configured: circuit-breaker.failure-executions measures the threshold over the last N executions instead of requiring the failures to be consecutive, circuit-breaker.failure-period measures it over a rolling time window, and circuit-breaker.failure-rate turns the absolute count into a failure rate in percent. Note that the first two alone make the circuit breaker more sensitive, not less, precisely because the failures no longer have to be consecutive - only the failure rate tolerates isolated failures. A tolerant configuration is circuit-breaker.failure-rate=50, circuit-breaker.failure-executions=20, circuit-breaker.failure-period=5m: open only if at least half of the last 20 or more executions within 5 minutes failed. An invalid combination is logged as an error and falls back to the consecutive-failure default, because a circuit breaker that cannot be built would take the whole sending down.
  • The SMP timeouts are configurable, via the new peppol.smp.timeout.connect (default 5s) and peppol.smp.timeout.response (default 10s). Both defaults equal the values that were in effect before: SMPClientManager builds the shared HttpClientManager from a plain SMPHttpClientSettings, and because that shared manager takes precedence over the per-client settings, the HttpClientSettings defaults were the only timeouts an SMP query ever used - there was no way to change them. Raising the response timeout is the direct lever against an SMP circuit breaker that opens because of a slow, but working, SMP.
  • Circuit breakers are observable and resettable at runtime. GET /api/ops/circuit-breakers lists every circuit breaker that was used at least once with its key, state, open-since timestamp, remaining delay, failure count and last failure cause; POST /api/ops/circuit-breakers/{circuitKey}/reset forgets a single one, so that the next call creates a new, closed one from the current configuration. That is also the way to apply a changed circuit-breaker.* property without a restart, because the configuration is only read when a circuit breaker is created. Both are part of the existing API token protected Operations API. Two new counters, both tagged with phoss.ap.circuit_breaker.key and phoss.ap.circuit_breaker.state: phoss.ap.circuit_breaker.rejections and phoss.ap.circuit_breaker.state_changes. The circuit breaker key is a deliberate exception to the "no high cardinality attributes on metrics" rule - it is bounded by the number of SMPs and remote APs this AP talks to, and without it the counters are not actionable. New: the code list ECircuitBreakerState (closed / open / half_open), the record CircuitBreakerInfo, the DTO CircuitBreakerResponse, CircuitBreakerManager.getAllInfos (), reset (String), getRemainingDelay (String) and recordFailure (String, Throwable), the lookup state CIRCUIT_OPEN, the properties APConfigurationProperties.CIRCUIT_BREAKER_DEFER_MAX_DURATION, CIRCUIT_BREAKER_FAILURE_EXECUTIONS, CIRCUIT_BREAKER_FAILURE_PERIOD, CIRCUIT_BREAKER_FAILURE_RATE, PEPPOL_SMP_TIMEOUT_CONNECT and PEPPOL_SMP_TIMEOUT_RESPONSE with the matching APCoreConfig accessors, and the constants CPhossAPOtel.METRIC_CIRCUIT_BREAKER_REJECTIONS, METRIC_CIRCUIT_BREAKER_STATE_CHANGES, ATTR_CIRCUIT_BREAKER_KEY and ATTR_CIRCUIT_BREAKER_STATE.
  • The positive MLS can now be triggered by the Receiver Backend instead of by the successful forwarding, via the new mls.sending.trigger property (issue #76). For a C3 that forwards to a middleware in front of C4 - the normal deployment, not the exception - the AP has always equated "the forwarding HTTP call returned 2xx" with "the document was delivered", and sent the MLS AP at that moment. In such a setup that MLS states a fact that has not happened yet: the middleware accepted the document, the delivery to C4 is still pending, and a later failure between middleware and C4 can no longer be reported at all, because the MLS for that message is already closed. The party that knows when the document reached C4 is the backend, and the AP already offers exactly this handshake for Peppol Reporting via POST /api/inbound/report - only the MLS counterpart was missing. The property is deliberately separate from mls.type, which maps 1:1 onto the Peppol enum EPeppolMLSType and should keep doing so - the trigger is an implementation concern of this AP, not a Peppol concept. auto (default) sends the positive MLS immediately after the successful forwarding, byte for byte the behaviour of all versions before 0.13.0; api defers it until the backend calls the new endpoint. Only the success path is deferred. The negative MLS (RE) of a failed inbound verification and the acknowledging MLS (AB) after exhausted forwarding retries are always sent automatically - in both cases the document never reached the backend, so the backend cannot report anything about it. Note that delaying the forwarding response until C4 confirms is not an alternative to this mode: the forwarding runs synchronously inside the AS4 request, so a pending HTTP response means no AS4 Receipt for C2 and therefore a retransmission. The new endpoint POST /api/mls/send takes the SBDH Instance Identifier plus the MLS status to report - AP, AB or RE, with an optional response text and any number of issues, which are mandatory for RE and optional for the positive codes, since MLS allows line responses there as well. It is secured by the existing API token filter and documented in the OpenAPI output. The answers are 200 with the status sent (or recorded, if mls.type=FAILURE_ONLY suppressed a positive MLS - unchanged semantics), 400 for an invalid body, 404 for an unknown SBDH Instance Identifier, 409 if an MLS was already determined for the transaction, 422 for an inbound MLS or MLR document and for a transaction that is not in the status forwarded - the backend can only have an outcome for a document that actually reached it - 500 if the MLS could not be created and 503 if mls.sending.enabled=false. The endpoint is not limited to the trigger mode api: in auto mode it works as well, as long as no MLS was determined yet, which is what lets a backend report an RE for a document it received but cannot process. The endpoint returns as soon as the outbound MLS transaction is persisted, and names its ID in the response message; the AS4 transmission and its retries then run in the background through the normal outbound path. That required splitting the MLS creation from the MLS sending: the new MlsHandler.createInboundResultMls (...) returns the new MlsCreationResult, and MlsHandler.sendCreatedMlsAsync (...) puts the transmission on the worker pool. MlsHandler.triggerSendingInboundResultMls (...) is unchanged in signature and behaviour - it is now the composition of both - so the three call sites in the receive path and their telemetry are untouched. A silent backend must not push the AP out of MLS-1 compliance - 99.5 % of all MLS responses within 20 minutes, measured from the reception of the business document - so the trigger mode api adds a watchdog to the retry scheduler cycle: every transaction in status forwarded without an mls_response_code whose as4_timestamp is older than mls.sending.api.timeout is answered with mls.sending.api.timeout.code. The default timeout is 5m and not the 15 minutes originally proposed in the issue, because the fallback MLS still has to be built, sent and possibly retried inside the remaining SLA budget; whoever raises it takes on that risk. The default fallback code is AB, the semantically correct one - the document was accepted, the delivery was never confirmed - and RE is rejected as a value, because a rejection is a statement only the backend can make. The window is deliberately measured from the reception of the document and not from its forwarding, because that is where the MLS-1 clock starts. Anchoring on completed_dt would add the forwarding duration on top of the SLA budget instead of consuming it from it - a document that only reached C4 after 15 minutes of forwarding retries would then be answered at minute 20 at the earliest, and no configuration of the timeout could prevent that. A slow forwarding now shortens the window of the backend, and in the extreme case the fallback goes out with the first cycle after the forwarding, which is the correct answer when there is no budget left. A transaction with mls.type=FAILURE_ONLY is excluded from the watchdog. It never gets a positive MLS at all, so the fallback would put nothing on the wire and only record a response code - which would then block the RE the backend may still want to report for it. With FAILURE_ONLY the backend therefore keeps its window open indefinitely, exactly as in the trigger mode auto. Exactly one MLS per business document is now enforced atomically. Five parties can want to answer the same inbound transaction - the receive path after a successful forwarding, the exhausted forwarding retries, the verification rejection, the watchdog and the Receiver Backend - and they all create their MLS through the same method, which now claims the MLS slot before it builds anything: UPDATE ... SET mls_response_code=? WHERE id=? AND mls_response_code IS NULL. The first writer wins and every later one is told that the MLS is already decided. A check of the current response code could not do this: the transaction the receive path works with is a snapshot read before the forwarding call, so an outcome the backend reported in the meantime is not visible in it. The case that made this necessary: the middleware receives and processes the document but the HTTP response is lost, the AP goes to forward_failed and schedules a retry, the backend reports the outcome through the endpoint, and the exhausted retries would then send a second, contradicting MLS. A claim whose creation then fails is given back, so a later attempt can still answer C2 - and a response code that already belongs to a real MLS outbound transaction can never be erased by that. Both the endpoint and the watchdog honour the 0.12.0 rule that a document forwarded despite a verification rejection gets exactly one MLS, namely the RE of that rejection, and neither of them ever answers an inbound MLS or MLR. No database migration is needed - the existing status, completed_dt, mls_response_code and verification_result columns carry everything the watchdog selection needs - and GET /api/mls/missing already lists the transactions waiting for an API triggered MLS, so the backlog is observable without a new endpoint. New: the code list EMlsSendingTrigger (auto / api), the constants APConfigurationProperties.MLS_SENDING_TRIGGER, MLS_SENDING_API_TIMEOUT (+ _DEFAULT) and MLS_SENDING_API_TIMEOUT_CODE, the accessors APCoreConfig.getMlsSendingTrigger (), getMlsSendingApiTimeout () and getMlsSendingApiTimeoutResponseCode (), the DTOs MlsSendRequest and MlsSendIssue, the manager methods IInboundTransactionManager.getAllForMlsApiTimeout (int, OffsetDateTime), claimMlsResponseCode (String, EPeppolMLSResponseCode) and releaseMlsResponseCodeClaim (String), the record MlsCreationResult - with the outcome alreadyDetermined (...) next to created (...), suppressed (...) and failure (...) - and the scheduler telemetry name mls-fallback on the phoss.ap.scheduler.cycle span. InboundOrchestrator.isMlsSuppressedAfterRejection (IInboundTransaction) widened from package visibility to public, because the endpoint and the watchdog need the same verdict as the receive path.
  • Configured participant IDs can now be excluded from Peppol Reporting, via the new peppol.reporting.exclude.participant-ids property (issue #73). Synthetic monitoring transactions - a daily automated end-to-end health check that is sent through the real Peppol network, with real document types and real participant IDs, so that SMP lookup, AS4 transport, validation and forwarding are all covered - ended up in the reporting database like any other transaction, and therefore in the monthly TSR and EUSR. The property takes a comma separated list of participant identifiers, each of them either URI encoded (iso6523-actorid-upis::9915:test) or in the default scheme notation (9915:test). A transaction is excluded if the configured identifier matches the sender or the receiver, in the inbound as well as in the outbound direction, so a monitoring participant is treated the same no matter which side it is on. The comparison is done on parsed identifiers and therefore respects the scheme specific case sensitivity - it is not a string comparison. A matching transaction is not stored in the Peppol Reporting backend and gets the new reporting status excluded instead of reported, so that the skip stays visible in the transaction table and via the status API. Archival treats excluded exactly like reported, so such a transaction is archived normally. An excluded inbound transaction is closed with excluded even if no C4 country code could be determined - the country code is only needed for the EUSR entry that is not written anyway. Such a transaction therefore no longer shows up in GET /api/inbound/missing-c4-country-code and no longer waits for an asynchronous C4 country code report before it can be archived. This is opt-in - nothing is excluded unless the property is set. The effective list is logged once at startup, and a value that cannot be parsed as a participant identifier is logged as an error once and ignored - meaning matching transactions are counted, which is the safe direction. Note: Peppol Network Reporting is meant to cover every transaction exchanged over the Peppol network, and a synthetic monitoring transaction is real network traffic. Excluding it is a deliberate deviation that the operator of the AP owns. New: APConfigurationProperties.PEPPOL_REPORTING_EXCLUDE_PARTICIPANT_IDS, APCoreConfig.getPeppolReportingExcludedParticipantIDs (), APPeppolReportingHelper.getAllExcludedParticipantIDs () and the code list value EReportingStatus.EXCLUDED.
  • Updated dependencies:
    • ph-telemetry 1.1.0 → 1.1.1 (the OpenTelemetry bindings no longer claim the global OpenTelemetry slot when a span is taken before the SDK bootstrap, and no longer cache the no-op tracer / meter forever - see issue #102 above)

v0.12.0 - 2026-09-02

  • Every verifier and every document forwarder now has its own ID (issue #80). IDocumentVerifier and IDocumentForwarder extend IHasID <String> now, so getID () must be implemented by every verifier and every forwarder. Contrary to the existing IDocumentVerifier.getVerifierName () - which is a human readable name that defaults to the local class name - the ID is a short, stable, machine readable identifier that is meant to end up in dashboards and alerting rules, so it should not change between releases. The IDs are checked for uniqueness at startup: verifier IDs must be unique over all inbound and all outbound verifiers, and forwarder provider IDs must be unique over all providers. A duplicate or an empty ID aborts the startup with an InitializationException. A verifier class that implements both the inbound and the outbound SPI - like the bundled phorm verifier - is loaded once per SPI and therefore contributes the same ID twice; that is explicitly not treated as a duplicate. The bundled verifier uses the ID phorm (PhormDocumentVerifier.VERIFIER_ID), and the built-in forwarders use the ID of their EForwardingMode - http_post_sync, http_post_async, s3_link, sftp and filesystem. A forwarder created by an IDocumentForwarderProviderSPI uses the ID of that provider. Incompatible change: IInboundDocumentVerifierSPI and IOutboundDocumentVerifierSPI are no longer functional interfaces, because of the second abstract method. That is irrelevant for real implementations - a ServiceLoader SPI must be a class with a public no-argument constructor anyway - but a lambda used in a test must become an anonymous class.
  • The IDs are included in the telemetry, via the two new attributes phoss.ap.verifier.id (CPhossAPOtel.ATTR_VERIFIER_ID) and phoss.ap.forwarder.id (CPhossAPOtel.ATTR_FORWARDER_ID). The verifier ID is set on the phoss.ap.verification span of the inbound and of the outbound direction whenever a verifier rejected the document or was unavailable - a passed verification has no single responsible verifier and therefore no ID. It is carried there by the new verifierID component of the VerifierResult record, which is also available to every IAPNotificationHandlerSPI. The forwarder ID is set on the phoss.ap.forwarder.dispatch span, next to the existing phoss.ap.forwarder.type. The type keeps naming the kind of forwarder, so existing dashboards continue to work, whereas the ID identifies the concrete forwarder - which is what tells two forwarders provided via SPI apart.
  • A configured outbound HTTP proxy is now also installed as the JVM wide default ProxySelector at startup, via the new method APBasicConfig.installGlobalProxySelector (). Only the connections made through Apache HttpClient used the configured proxy so far. The certificate revocation check that the JSSE PKIX trust manager performs during a TLS handshake - SMP lookups as well as AS4 sending - downloads the CRL and contacts the OCSP responder through the JDK internal URLConnection instead, and that code path only honours the default ProxySelector. In a network where outbound traffic is only possible through a proxy, the TLS handshake therefore stalled on the CRL download and eventually failed with a CertPathValidatorException, even though the proxy was configured correctly. The proxy is derived from the existing http.proxy.* properties, so no new configuration is needed. Nothing is installed if no proxy host and port are configured, or if http.proxy.enabled is explicitly false. http.proxy.nonProxyHosts is honoured with the same exact-host-name matching as for the Apache HttpClient connections, and if http.proxy.username is set, the credentials are registered as the JVM default Authenticator for proxy authentication requests only.
  • A document that failed the inbound verification can now optionally still be forwarded to C4, controlled by the new verification.inbound.rejection-forwarding property (issue #82). A verification failure used to be a dead end: the document was never forwarded and only C2 learned about it via the negative MLS. The UAE Peppol DCTCE / TDD-TDS model needs the opposite - C3 must be able to act on an invoice that failed the content validation, by generating a Tax Data Summary instead of a Tax Data Document, which means the failed document has to reach C4 while the rejection stays recorded. The property is deliberately an enum and not a boolean, following the verification.verifier-fail-mode idiom, so the delivery strength is selectable without adding a second key later: none (default) never forwards the document and ends in status rejected - byte-for-byte the pre-0.12.0 behaviour; best-effort dispatches a fire-and-forget copy to the primary and to all secondary forwarders and still ends in status rejected, without a forwarding attempt row and without any retry; retry runs the document through the regular forwarding state machine including the retries, so the transaction ends in forwarded or permanently_failed while verification_result stays rejected. This is opt-in - nothing changes unless the property is set. The mode applies to every rejection verdict, so also to a document rejected because a verifier was unavailable and the fail mode is closed, and to one whose verification.deferred.max-duration was exceeded. In the forwarding modes C2 receives exactly one MLS, namely the RE of the rejection: the positive AP / AB after a successful forwarding and the acknowledging MLS on exhausted retries are both suppressed. The suppression is derived from the persisted verification_result and not from a call parameter, so forwardDocument (...) keeps its signature and the retry scheduler and the operations endpoint behave exactly like the initial receive path. In mode retry a successfully forwarded rejected document is counted for Peppol Reporting, as any other forwarded document - the document was received over the Peppol network. Enabling that mode therefore moves the reported TSR figures upwards. POST /api/ops/inbound/{sbdhInstanceID}/reverify-and-forward is deliberately not widened: it still only accepts transactions in status verification_deferred, so a rejected document stays terminal for the operator API. In mode retry it ends in forwarded or permanently_failed anyway, and a deliberate re-delivery is still possible via the replay endpoint. New code list EVerificationRejectionForwarding (none / best-effort / retry) with the constant APConfigurationProperties.VERIFICATION_INBOUND_REJECTION_FORWARDING and the accessor APCoreConfig.getVerificationRejectionForwarding (). An unsupported value falls back to none with a single warning, as for the fail mode. The best-effort dispatch emits the new OpenTelemetry span phoss.ap.inbound.forward.rejected, one per forwarder, with the attribute phoss.ap.forwarder.index - the same shape as the existing secondary forwarder span.
  • A copy of every MLS this AP generates itself can now be forwarded to C4 (issue #85), controlled by the new forwarding.mls-copy.enabled property. The asymmetry was easy to miss: an MLS received from another AP is forwarded to C4 like any other inbound document, but the AP / AB / RE that this AP sends back to C2 never left the AP. The payload has always existed on disk - MlsHandler stores it as a bare ApplicationResponse and links it via inbound_transaction.mls_outbound_transaction_id - it is exactly the input a Tax Data Summary builder needs, only the dispatch was missing. The sink is deliberately configured separately from the primary forwarder, because a reporting integration is normally not the same endpoint as the invoice inbox. It supports every EForwardingMode including spi under the forwarding.mls-copy. prefix; if no forwarding.mls-copy.mode is set, the whole primary forwarder configuration is reused, so forwarding.mls-copy.enabled=true alone is a valid setup. This is opt-in - disabled by default, with no configuration required and no log output at all in that case. The dispatch is fire-and-forget from the moment the outbound MLS transaction is created, so a failing sink affects neither the MLS sending to C2 nor the inbound transaction status, and is never retried. Note that the copy is therefore the MLS that was generated, not proof that C2 received it. The payload is the bare ApplicationResponse and deliberately not wrapped in an SBDH: the AP never materializes an SBD itself - phase4 adds the envelope during the AS4 transmission - so wrapping would mean generating and storing a second document per MLS. A consumer that handles received and sent MLS documents alike distinguishes them by EForwardableKind (inbound-mls versus outbound-mls-copy). For the same reason no metadata JSON sidecar is written for a copy. New: APConfigurationProperties.FORWARDING_MLS_COPY_ENABLED / FORWARDING_MLS_COPY_MODE / FORWARDING_MLS_COPY_PREFIX, APCoreMetaManager.getMlsCopyForwarderOrNull (), ForwardableDocument.fromOutboundMlsCopy (IOutboundTransaction) and the OpenTelemetry span phoss.ap.mls.forward.copy.
  • The name under which a forwarded document is stored is configurable, for the sftp, the filesystem and the s3_link forwarding (issue #90, PR #91). A downstream routing service that picks the documents up from a shared landing directory usually routes them by the receiver participant ID. Doing so meant opening every XML payload or reading the metadata sidecar, only because the name was hard-wired - yyyyMMddHHmmss_(Incoming ID) for SFTP, the SBDH Instance Identifier for the filesystem and for S3. The new properties forwarding.sftp.filename-pattern, forwarding.filesystem.filename-pattern and forwarding.s3.filename-pattern support the placeholders {datetime}, {incoming-id}, {sbdh-instance-id}, {receiver-id}, {receiver-value}, {sender-id}, {sender-value}, {doctype-id} and {process-id}. Note that only the {name} syntax works: ${name} is the variable syntax of the configuration itself - such a value is replaced before the forwarder ever sees it - and is therefore rejected at startup with a dedicated error message. The resolved value is the base name, so the .xml of the document and the .json of the optional metadata sidecar are appended to it - and in the per-transaction filesystem layout it is the name of the subdirectory. The *-value placeholders drop the identifier scheme, so everything up to and including the ::, and keep the ICD - 0151:35747532810 and not 35747532810, because the same number in a different ICD is a different participant. Every default is the previous layout of that forwarder, so the configuration of an existing installation keeps producing the same names. Every character of the resolved name that is not a letter, a digit, ., - or _ is replaced with _, and leading dots are removed. That is deliberately stricter and, contrary to the general filename sanitization, independent of the operating system - : is illegal on Windows only - so that the same document is named the same everywhere, and so that a sender provided value like the SBDH Instance Identifier can never point outside of the target directory. Note: this sanitization now also applies to the filesystem and to the S3 forwarding, which passed the SBDH Instance Identifier through almost unchanged before - a document whose Instance Identifier contains e.g. a : or a / is therefore stored under a slightly different name than in v0.11.0. Names longer than 200 characters - 900 for an S3 object key - are truncated. Only for s3_link the literal part of the pattern may contain /, so that an object key can span "folders" like {receiver-value}/{datetime}_{incoming-id}. A placeholder value never contributes a path separator, no matter which forwarder. The pattern is validated at startup: an empty pattern, an unknown placeholder, unbalanced braces, a ${name} and - except for S3 - a path separator abort the startup with a configuration error, instead of ending up in every name. A pattern that contains neither {incoming-id} nor {sbdh-instance-id} is accepted but logs a warning, because it cannot guarantee unique names; the SFTP and the S3 upload silently overwrite an existing file, and only the filesystem forwarder resolves a collision itself by appending a numeric suffix. New in phoss-ap-api, and therefore usable by a forwarder provided via IDocumentForwarderProviderSPI as well: the class ForwardingFilenamePattern - which parses, validates and resolves a pattern - and the enum EForwardingFilenamePlaceholder, where each constant carries its ID as it is written in the pattern plus the function that takes its value out of the IForwardableDocument. Adding a placeholder is therefore a single enum constant. isUniquePerDocument () marks the placeholders that make a resolved name unique, which is what the warning above is derived from. Plus APConfigurationProperties.FORWARDING_SFTP_FILENAME_PATTERN_DEFAULT, FORWARDING_FILESYSTEM_FILENAME_PATTERN_DEFAULT and FORWARDING_S3_FILENAME_PATTERN_DEFAULT.
  • BREAKING — a document forwarder no longer receives an IInboundTransaction, but the new neutral IForwardableDocument (issue #84). Forwarding was hard-wired to a received document, which blocks forwarding anything the AP generates itself - a copy of an outbound MLS, for instance, where the thing to forward is an IOutboundTransaction. The new phoss-ap-api record ForwardableDocument describes a document to forward independently of where it came from: id, kind, sbdhInstanceID, documentPath, documentSize, docTypeID, processID, senderID, receiverID, timestamp, localID, verificationResult, verificationDetails and metadataJson. The new code list EForwardableKind (inbound-document / inbound-mls / outbound-mls-copy) carries the one thing the document itself cannot tell the receiver: C4 can distinguish an MLS it received from an MLS this AP sent, which matters as soon as both land on the same endpoint. documentPath is deliberately absolute, so the same forwarder serves the inbound and the outbound storage root without having to know which one it is. metadataJson is a Supplier and may be null, so the JSON sidecar of the s3, sftp and filesystem modes is only rendered by the forwarders that actually write one - as before, it is the very same InboundTransactionResponse. Note that the supplier makes two independently built records never equals (). The contract of IDocumentForwarder is deliberately broken rather than kept compatible: forwardDocument (IForwardableDocument) is abstract, so a third-party forwarder that implements only forwardDocument (IInboundTransaction) no longer compiles. A default that silently does nothing for a self-generated document would have been the worse outcome - a compile error says exactly what needs attention. The adaptation itself is a one-liner, because ForwardableDocument.fromInbound (IInboundTransaction) is public API: an existing forwarder keeps its body and only changes what it reads its values from, and any code that holds an IInboundTransaction turns it into an IForwardableDocument with that single call. forwardDocument (IInboundTransaction) therefore remains as a default method that does exactly this and delegates, so every caller stays untouched - only implementors have to act. The wiki has the full before/after porting table of the accessors. The four built-in forwarders http, s3, sftp and filesystem were migrated; there is deliberately no functional change at all, the forwarded bytes and the sidecar content are identical.
  • BREAKING — the verification outcome is now exposed to the forwarders and to the SPI callbacks (issue #83). Once the verdict is persisted, a downstream consumer that builds a report from it - e.g. a Tax Data Summary for a rejected invoice - should not have to call the REST API a second time to learn why the document was rejected. Two SPI signatures therefore changed; both are breaking for external implementors, and there is deliberately no default-method fallback, because a silently unimplemented callback is worse than a compile error: IAPNotificationHandlerSPI.onInboundVerificationRejection (...) gained the MlsOutcome of the rejection - the response code plus the individual issues, i.e. the very same data that is sent to C2 as the negative MLS. It is never null and is also handed out for a document that receives no MLS at all (an MLR or an MLS itself), because it describes the verdict and not the message that was sent. IAPLifecycleEventSPI.onInboundDocumentForwarded (...) gained the EVerificationResult, which is null if no verification was performed. A value of rejected means the document was forwarded despite its rejection, which only happens in the rejection forwarding mode retry. The verdict is available to every document forwarder, including a third-party IDocumentForwarderProviderSPI implementation, as verificationResult () and verificationDetails () on the IForwardableDocument it is handed (see issue #84 above). HttpDocumentForwarder now sends X-Verification-Result (passed, rejected or unverified) next to the existing X-SBDH-Instance-ID, as soon as a verdict exists - the absence of the header means that the verification is disabled or still deferred, so C4 can tell the three cases apart without a lookup. The findings themselves are opt-in via the new forwarding.http.verification-details property: if enabled, they are sent as base64 encoded JSON in X-Verification-Details. A header value is size bounded, so if the issue list does not fit into 4096 characters, the trailing issues are dropped until it does - what remains is still a valid JSON array - and X-Verification-Details-Truncated: true marks that case. The REST API stays the authoritative source for the complete list. New metric phoss.ap.inbound.verification.rejections.forwarded, a subset of phoss.ap.inbound.forwarded, counting the documents that reached C4 despite their rejection. It only counts the mode retry, because a best-effort copy never runs through the forwarding state machine. All in-tree implementations were adapted: APMetricsNotificationHandler, APMetricsLifecycleEventHandler, SafeNotificationHandler, SafeLifecycleEventHandler, APNotificationHandlerSentry, DemoNotificationHandlerSPI and DemoLifecycleEventSPI.
  • A Factur-X / ZUGFeRD PDF is now verified with the phorm hybrid validation (issue #93). Such a document was effectively unverifiable before: the bundled PhormDocumentVerifier always POSTed the stored document to /api/dd_and_validate/ as application/xml, and that endpoint needs XML - its document type detection cannot look into a BinaryContent business message and answers with an HTTP 400 and a plain text body. The AP could not read that as a validation result and reported the verifier as unavailable, so with a fail-closed or deferred configuration a perfectly fine Factur-X invoice was rejected or parked, and outbound sending of a PDF payload was blocked outright. The endpoint is now chosen per document: a document type ID with a non-XML syntax specific ID - urn:peppol:doctype:pdf+xml of the French Factur-X document types - whose payload is a PDF goes to /api/hybrid_validate, everything else keeps going to /api/dd_and_validate/. phorm validates the PDF carrier (PDF/A-3 conformance and the BR-HYBRID-* rules via kaltblut) plus the invoice XML embedded in it and returns both layers in the very same phive ValidationResultList, so PhiveToVerificationMapper, the MLS projection and the REST API are unchanged. All three storage shapes are covered: the bare PDF of an outbound payload-only submission is streamed as-is, and the base64 encoded PDF of an inbound document as well as of an outbound prebuilt SBD is taken from PeppolSBDHData.getBusinessMessageAsBinaryContent (). The BinaryContent MIME type must be application/pdf. If the SBDH carries a syntactically valid C1 country code, it is passed on as the country query parameter - that is what activates the country specific BR-HYBRID-DE-* / BR-HYBRID-FR-* rules. It is deliberately not filtered against the countries phorm knows today, so a country added there later works without a change here. An outbound payload-only PDF has no SBDH yet - phase4 only creates the envelope during the AS4 transmission - so no country is sent and phorm falls back to its default. Anything that is not understood - an unparseable SBD, a business message that is not BinaryContent, a different MIME type - falls back to /api/dd_and_validate/ with a warning, so no document is ever silently validated with fewer rules than before. No configuration is needed and nothing changes for an XML document. Note that this needs a phorm 2.1.0 or newer, because /api/hybrid_validate does not exist in older versions.
  • Fixed: an inbound document was stuck forever when the forwarder circuit breaker was open. No forwarding was attempted in that case, but the transaction status was left untouched - and neither the retry scheduler (which only looks at forward_failed) nor the startup recovery (which only resets forwarding) ever picked such a transaction up again, so C2 never received an MLS for it. The transaction is now set to forward_failed with a next_retry_dt of one retry.forwarding.initial-backoff, and the situation is logged as a warning. No inbound_forwarding_attempt row is written and the attempt_count stays unchanged, because nothing was actually tried - so an open circuit breaker no longer consumes the retry budget either. With the deferred verification this was even more visible: a document that passed its re-verification while the breaker was open stayed in verification_deferred with a next_retry_dt in the past and was re-verified in every scheduler cycle, without ever reaching the maximum deferral duration.
  • Fixed: if no document forwarder was configured, the inbound transaction went to permanently_failed silently - without an MLS to C2, without the onInboundPermanentForwardingFailure (...) notification and without error details in the database. All three now happen, as for any other permanent forwarding failure. The reason is stored as No document forwarder configured.
  • Fixed: POST /api/ops/inbound/{sbdhInstanceID}/reverify-and-forward accepted transactions in any status and now answers 409 Conflict for anything but verification_deferred. Re-verifying an already forwarded document delivered it to the Receiver Backend a second time, and - because the endpoint really re-runs the verifiers - a verifier that had become reachable again could send an MLS RE for a document for which an MLS AP had already been sent. A deliberate re-delivery is still possible via the replay endpoint.
  • Fixed: a Peppol Reporting item was created a second time when an already reported inbound transaction was forwarded again - e.g. via the replay API - which over-reported the received document to the Peppol network. APPeppolReportingHelper.createInboundPeppolReportingItem (...) now returns success without storing anything, if the transaction is already in reporting status reported.
  • Fixed: a completed inbound transaction kept the next_retry_dt and the error_details of the previous failure or deferral, so a successfully forwarded document still looked like it had a retry pending. Both columns are now cleared together with the status.
  • Fixed: a re-verification was scheduled a full verification.deferred.retry-interval beyond verification.deferred.max-duration, so the final rejection could arrive one interval later than documented. The next re-verification is now clamped to the deadline.
  • An inbound document verifier that violates its @NonNull contract and returns null no longer produces a NullPointerException that ends up as an AS4 error towards C2. Such an outcome is treated as a passed verification, as the pre-0.12.0 API defined for null.
  • The re-verification cycle of the retry scheduler now opens a phoss.ap.scheduler.cycle OpenTelemetry span with phoss.ap.scheduler.name = reverify, like the inbound and the outbound retry cycles. It deliberately does not fire IAPLifecycleEventSPI.onRetrySchedulerCycle (...), because that callback only distinguishes inbound from outbound.
  • The verdict of the inbound document verification is now persisted in the two new inbound_transaction columns verification_result and verification_details (Flyway migration V5, mirrored into the archive table). Previously the verdict only existed as the transaction status rejected plus free text in error_details. That conflated two different things: the status describes the lifecycle of a transaction and is overwritten by every forwarding attempt, and since this release updateStatusCompleted (...) also clears error_details - so a verdict could not survive a forwarding at all. verification_result is one of passed, rejected or unverified, or NULL if no verification was performed (yet). verification_details carries the findings as a JSON array of VerificationIssue objects - the transport-neutral form, so it is the same shape the outbound submit API returns and the validation errors are available without a second lookup. It is filled for any verdict: on a passed verification the findings are the warnings. A document forwarded under verification.verifier-fail-mode=open is now recorded as unverified and is therefore no longer indistinguishable from a properly verified one; previously only a log line mentioned it. Both values are exposed by the REST API on the inbound transaction resource. This is the groundwork for forwarding a rejected document to C4 (issue #82) - the status is then free to follow the forwarding state machine while the rejection stays recorded.
  • BREAKING — outbound document verification no longer discards the validation detail (issue #74). IOutboundDocumentVerifierSPI.verifyOutboundDocument (...) returned ESuccess, so a rejected outbound document produced nothing but the generic API answer "Failed to submit outbound transaction". The actual phive validation errors existed inside the verifier and were thrown away, which made automated error handling on the client side impossible. It now returns the same VerificationOutcome as the inbound counterpart. Migration: return ESuccess.SUCCESS becomes return VerificationOutcome.passed (), return ESuccess.FAILURE becomes return VerificationOutcome.rejected (sMessage, aIssues).
  • BREAKING — a verifier no longer reports its findings as Peppol MLS data. VerificationOutcome now carries a list of the new transport-neutral VerificationIssue instead of an MlsOutcome. MLS is how C3 answers C2 about a received document; it is meaningless for an outbound submission, and forcing phive results into it damaged them in several ways: the machine-readable rule ID was mashed into the human text as [PEPPOL-EN16931-R001] ..., severity and rule kind were crushed into a single status reason code (an XSD warning and an XSD error both became SV), an unknown location became the MLS sentinel "NA", and every warning was dropped as soon as the document was valid. A VerificationIssue keeps level (error / warning), type (syntax / business_rule), code, location and description apart. A verifier now states what is wrong with the document and the AP decides how to report it — as an MLS response to C2 for an inbound document, and as JSON to the submitter for an outbound one. MlsOutcome itself is unchanged and is built from the issues via the new MlsOutcomeIssue.fromVerificationIssue (...); the resulting MLS wire format is identical to before, because the rule ID is prefixed back into the description. Migration: VerificationOutcome.rejected (MlsOutcome) becomes VerificationOutcome.rejected (sMessage, aIssues). The MLS-specific PhiveToMlsMapper became PhiveToVerificationMapper.toVerificationIssues (...).
  • BREAKING — the outbound submit methods return OutboundSubmitResult instead of a nullable IOutboundTransaction. OutboundOrchestrator.submitRawDocument (...) and submitPrebuiltSBD (...) returned null for "a verifier rejected the document" and "the payload could not be parsed" and "an internal error occurred" — indistinguishable, which is why all three ended up as the same generic API message. OutboundSubmitResult separates them and carries the verification detail. A verification rejection deliberately creates no outbound transaction: the document is never sent, so there is nothing to track, retry or report to the Peppol network.
  • The outbound submit endpoints now answer with a JSON object carrying errorMessage, verificationPerformed and verificationIssues instead of a bare JSON string with a generic message. verificationPerformed distinguishes "the document is invalid" from "a verifier could not make a verdict at all" — outbound verification has no fail mode, so an unreachable verifier still blocks the submission, but the submitter is now told which of the two happened. The HTTP status codes of the individual endpoints are unchanged, including the pre-existing inconsistency that /submit and /submit-s3 answer 422 while /submit-sbd and /submit-auto answer 400.
  • Verification warnings on an accepted document are no longer dropped, in both directions. Outbound they are reported back as verificationWarnings on the sending report; if there are none, the response is byte-identical to before. Inbound they are sent to C2 as line responses of the positive MLS — MLS allows details on the AP and AB response codes as well, so a warning does not require a rejection. MlsOutcome.acceptance (...) and acknowledging (...) accept an issue list for this. Previously every warning was discarded as soon as the document was valid, and the aggregation across multiple verifiers dropped them too.
  • Fixed: the projection of verification findings onto MLS turned any finding into a rejection reason. A rejection is now only built from the findings if at least one of them has severity error; a verifier that rejects while reporting warnings only gets a synthesized fatal line response naming it, with the warnings appended. This only matters for contradictory verifier results, but it also means a passed verification can never be turned into a rejection by accident.
  • The aggregated verification result — the outcome plus the name of the decisive verifier — is the new VerifierResult record in phoss-ap-api, shared by both directions. It was an implementation detail nested inside InboundOrchestrator before. The MLS projection deliberately did not move with it and stays in InboundOrchestrator: MLS is how C3 answers C2 about a received document, so putting it on a type that the outbound path also uses would reintroduce exactly the layering problem this release removes. As a result the outbound rejection now names the verifier that objected — in the log, in the verifierName field of the submit error body and in onOutboundVerificationRejection (...), which carries the VerifierResult. Inbound already did this via the transaction error details and the MLS text.
  • The two verifier SPIs share the new base interface IDocumentVerifier, which carries getVerifierName (). This is not a merge — the verification methods stay separate, because inbound and outbound differ in fail modes, deferral and MLS. It only means that a verifier implementing both (like the bundled phorm verifier) inherits one name method instead of two unrelated defaults. It carries no SPI suffix on purpose: unlike its two sub-interfaces it is never loaded via ServiceLoader.
  • BREAKING — IInboundDocumentVerifierSPI.verifyInboundDocument (...) returns the new VerificationOutcome instead of @Nullable MlsOutcome. MlsOutcome is the MLS response DTO; using it to say "I could not check this document" required a REJECTION response code for something that is not a rejection. The new return type states the three cases explicitly: VerificationOutcome.passed (), VerificationOutcome.rejected (aMlsOutcome) (its issues still flow into the MLS response; there is also a message-only variant without MLS details) and VerificationOutcome.serviceUnavailable (sMessage) for "the verifier backend service was unavailable, so nothing was verified". The latter is deliberately not a rejection of the document any more - see the fail mode below. Migration: return null becomes return VerificationOutcome.passed (), return MlsOutcome.rejection (...) becomes return VerificationOutcome.rejected (MlsOutcome.rejection (...)). MlsOutcome itself is unchanged. The SPI additionally got a getVerifierName () method with a default implementation returning the local class name. That name is used in log messages, in the transaction error_details and in the generated MLS response, so a verifier can now present a stable name independent of its class name.
  • Added a configurable fail mode for unavailable inbound document verifiers, controlled by the new verification.verifier-fail-mode property: closed (default, unchanged behaviour) rejects the document and sends a negative MLS to C2; open forwards the unverified document to C4 and logs a warning per document (the onInboundVerificationAccepted callback is deliberately not fired, because nothing was verified); deferred parks the document in the new inbound status verification_deferred and re-verifies it later, without sending an MLS in the meantime. All registered verifiers are now evaluated before the fail mode is applied, so an explicit rejection always wins over an unavailability - previously the first failing verifier ended the evaluation. In deferred mode the re-verification runs in the regular retry scheduler cycle every verification.deferred.retry-interval (default 5m); after verification.deferred.max-duration (default 12h, relative to the reception of the document) the document is rejected anyway, so that C2 always gets a final answer. Once the verification passes, the processing continues exactly where it was interrupted: an incoming MLS is correlated with its outbound transaction, the document is forwarded to C4 and the positive MLS is sent to C2. The forwarding attempt count is never modified by a deferral - deferred verification and forwarding retries are counted and scheduled independently. POST /api/ops/inbound/{sbdhInstanceID}/reverify-and-forward triggers the same resume manually. Unlike the replay endpoint it does not look into the archive. New status verification_deferred (no DB migration needed - the status column is untyped text) is included in GET /api/inbound/in-processing. See #71 and #72 - thx @dmaus2018, and the Receiving Process wiki page.
  • BREAKING — new IAPNotificationHandlerSPI.onInboundVerificationDeferred (...) callback. It is deliberately not a default method, so every implementation must handle it - the SPI has other breaking changes in this version anyway. It carries the transaction ID, the SBDH Instance Identifier, the name of the verifier that made no verdict, the date and time of the next scheduled re-verification and the error details, and it is fired on every deferral, i.e. also on every unsuccessful re-verification. A deferred document is neither forwarded nor rejected and no MLS was sent to C2 yet, so this callback is the signal that a verifier needs operator attention - if nobody reacts within verification.deferred.max-duration, the document is rejected and the existing onInboundVerificationRejection (...) fires. The bundled implementations were extended accordingly: the OpenTelemetry handler increments the new phoss.ap.inbound.verification.deferred counter, the Sentry handler logs it as a warning (not an error - the document is not lost yet) and the demo extension logs it.
  • The built-in PhormDocumentVerifier now reports a failed phorm call as "service unavailable" instead of rejecting the document itself. With the default fail mode closed the outcome is unchanged - the document is rejected and the MLS keeps the status reason code BUSINESS_RULE_VIOLATION_FATAL - but the new fail modes now work out of the box for the shipped verifier. phoss AP never sends the status reason code FAILURE_OF_DELIVERY: the PNP reserves it for a permanent inability to deliver, and every case in which phoss AP cannot deliver is assumed to be resolvable later. A call that could not be made at all (phorm not reachable, misconfigured URL or token, unreadable payload) is reported as unavailable as well, so in deferred mode a configuration error also parks the affected documents - see the Receiving Process wiki page.
  • (Docker) The official image can now run as an arbitrary non-root UID, so it works in Kubernetes namespaces enforcing the restricted PodSecurity profile (runAsNonRoot / runAsUser) and on OpenShift. /app.jar was added from a remote URL and therefore had mode 0600 root:root, so a non-root UID could not read it and startup failed with cp: cannot open '/app.jar' for reading: Permission denied - it is now added with --chmod=0444. Additionally /ext and /var/phoss-ap/data are now group owned by GID 0 with the group permissions mirroring the owner permissions, which is the group Kubernetes uses when runAsGroup is not set. The image still runs as root by default, so existing deployments are unaffected. See #79 - thx @walles12
  • (Docker) The image now sets GLOBAL_DATAPATH=/var/phoss-ap/data/, so documents and AS4 dumps are written to the /var/phoss-ap/data volume that the image declares. Previously the global.datapath=generated/ default of the bundled application.properties was resolved relative to the working directory /, so everything ended up in /generated inside the container - a location that is not covered by any volume and is therefore lost when the container is removed, and that is not writable for a non-root UID. Deployments that mounted /var/phoss-ap/data but did not set GLOBAL_DATAPATH themselves should check the old location for data before upgrading.
  • Added a minimal root page (index.html) to the web application, so that opening the AP base URL in a browser no longer results in a "Whitelabel Error Page". It briefly states that the AP has no user interface and links to /management/status, /actuator/health and the OpenAPI specification at /openapi/v3/api-docs.
  • Improved the End User ID determination for Peppol Reporting (EUSR), so that a single End User addressed via different Peppol identifier schemes is no longer counted multiple times. The End User ID (C1 when sending, C4 when receiving) is no longer the participant identifier as-is, but is now derived via the new PeppolEndUserHelper of peppol-commons 12.8.1 (through the new method APPeppolReportingHelper.getEffectiveEndUserID (...)). It unifies the identifier and maps the Belgian VAT numbers (9925) onto the Belgian enterprise numbers (0208, leading BE removed), the German VAT numbers (9930) onto the German Electronic Business Addresses (0246) and the removed Finnish OVT identifiers (0037) onto the Finnish OVT codes (0216) - so 9925:BE0123456789 and 0208:0123456789 now count as one single End User. The unification uses the identifier factory selected via peppol.identifier.mode, and deployments can add or remove mapping rules via PeppolEndUserHelper. Note: End User IDs of previously stored reporting items are not migrated, so a month that was partially recorded with the old logic may still contain both variants. See phax/peppol-commons#80 and the Peppol Specifics wiki page.
  • Updated to peppol-commons 12.8.1
  • Breaking change: the method InboundTransactionResponse.getAsJson () was renamed to InboundTransactionResponse.toJson (). The old name matched the JavaBean getter pattern, so Jackson picked it up as an additional asJson property, recursed into the returned ph-json IJsonObject and failed with Could not write JSON: Conversion from type java.lang.String to type java.lang.Byte failed!. Because of that all inbound read APIs returned HTTP 500 — GET /api/inbound/status/{sbdhInstanceID}, GET /api/inbound/missing-c4-country-code, GET /api/inbound/missing-c4-country-code/{sbdhInstanceID}, GET /api/ops/inbound/history, POST /api/ops/inbound/{sbdhInstanceID}/replay and GET /api/inbound/in-processing (the latter only as soon as it had rows, which made an idle AP look healthy). The @Schema (hidden = true) marker added in 0.11.0 only removed the derived property from the OpenAPI document, but not from the runtime JSON serialization. Outbound APIs were never affected, because OutboundTransactionResponse has no such method. The JSON metadata sidecar files written by the filesystem, SFTP and S3 forwarders are unchanged. See #77 - thx @StefanKert
  • The API Specification now documents the actual JSON property names of the inbound and outbound transaction responses. They are derived from the JavaBean getters, so the internal transaction ID is serialized as ID (and not as id), the AS4 Message ID of an inbound transaction as AS4MessageID (and not as as4MessageID), and the two inbound duplicate flags as duplicateAS4 and duplicateSBDH (and not as isDuplicateAS4 and isDuplicateSBDH). The JSON metadata sidecar files written by the filesystem, SFTP and S3 forwarders are not affected and keep using id, as4MessageID, isDuplicateAS4 and isDuplicateSBDH. Additionally the documented response field lists were aligned with the fields the endpoints really return. The outbound submission endpoints, the inbound reporting endpoint and the Peppol Reporting endpoints now document their real response bodies and status codes — the outbound submissions return the phase4 Phase4PeppolSendingReport as JSON and not a queued / not_queued status — and the /management/status example was completed with version.ddd, database.type, sentry.enabled, otel.enabled and dns.config.servers.
  • The outbound submission APIs now reject incomplete SBDH parameters for non-XML document types with a 400 instead of letting the transmission fail deep inside phase4. Document types whose syntax specific ID is not of the form <root NS>::<local name> — like urn:peppol:doctype:pdf+xml, used by the French Factur-X and the eB2B hybrid document types — carry neither an XML root element namespace URI nor a local name, so phase4 can derive none of the SBDH Standard, TypeVersion and Type values from them, and it must be told explicitly that the payload is not XML. Submitting such a document type without payloadMimeType previously failed with Failed to parse payload InputStream to a DOM node, and omitting sbdhTypeVersion produced an INVALID_PARAMETERS sending result with no exception attached at all — up to 0.10.3 both were additionally masked by A Peppol Reporting item can only be created AFTER sending. POST /api/outbound/submit/... and POST /api/outbound/submit-s3 now require all four of payloadMimeType, sbdhStandard, sbdhTypeVersion and sbdhType for such document types and name the missing one in the error message. Providing a payloadMimeType for an XML document type is still accepted, but now logs a warning. See #70 - thx @RemyMarin

v0.11.0 - 2026-08-05

  • Fixed the SMP client caching, so that repeated lookups of the same receiver no longer query the SMP server for every single message. Previously each outbound message created its own CachingSMPClientReadOnly with an instance local cache, so a cache hit was impossible. All SMP clients are now created by the new class SMPClientManager, which shares one process wide SMP response cache (SMPClientCache of peppol-commons 12.6.2) and one process wide HTTP connection pool between all SMP clients, so an SMP query no longer needs to establish a new TLS connection either. The cache can be configured via the new properties peppol.smp.cache.enabled (default true), peppol.smp.cache.ttl (default 15m) and peppol.smp.cache.max-size (default 1000 entries per cache) - see Configuration Properties. Note: inbound receiver checks in peppol.receiver-check.mode=sml still create one SMP client per message inside phase4 and are therefore not cached yet. See #68
  • Added a new set of Operations APIs below /api/ops for transaction history auditing, payload inspection and manual re-forwarding: GET /api/ops/inbound/history and GET /api/ops/outbound/history return the active transactions page-wise and newest first (offset / limit query parameters, default 0 / 50); GET /api/ops/inbound/size and GET /api/ops/outbound/size return the number of active (non-archived) transactions; GET /api/ops/inbound/{sbdhInstanceID}/payload and GET /api/ops/outbound/{sbdhInstanceID}/payload return the stored raw document as application/octet-stream (looking into the archive table as well); POST /api/ops/inbound/{sbdhInstanceID}/replay re-forwards an existing inbound transaction through the regular forwarding pipeline. Backing this, IInboundTransactionManager and IOutboundTransactionManager received the new methods getAllTransactions (int nOffset, int nLimit) and getTransactionCount (). The endpoints require the X-Token header like all other /api/** endpoints, but they expose complete document payloads and can trigger duplicate deliveries to the Receiver Backend — see the API Specification and Security Considerations wiki pages. See #67 / #69 - thx @dmaus2018
  • The generated OpenAPI document no longer contains the derived asJson property in the inbound transaction schema (InboundTransactionResponse.getAsJson () is now marked as hidden). Additionally the sbdhInstanceID path variables of the inbound status and "missing C4 country code" endpoints are now named explicitly, so their binding no longer relies on the compiler's -parameters flag.
  • Implemented the MLS MLS_TO fallback for SMP lookup, following the Peppol Service Provider Operational Guideline on MLS (SPOG, sections 5.1 and 5.4). When acting as C3, an incoming MLS_TO is now only accepted if its SPID Main ID correlates to the sending C2's Main ID (from the AP certificate CN); a non-correlating MLS_TO is ignored (redirecting an MLS to a different Service Provider is not allowed) and the default SPID receiver is used. When a custom MLS_TO receiver cannot be resolved via SMP, the AP now fires the new IAPNotificationHandlerSPI.onSpecialMlsToNotReachable() notification and falls back to the default SPID receiver (0242:<Main ID>); if even the default SPID is not resolvable, the MLS is queued and retried (per PNP Rule MLS-4) instead of failing permanently. Retries reconstruct the same fallback automatically for mls_response transactions. The OpenTelemetry metrics handler exposes a new phoss.ap.outbound.mls.special_to_not_reachable counter. Regular business-document sending is unaffected (an unresolvable receiver remains a permanent failure). See #66 and the Message Level Status wiki page.
  • Enriched the IAPLifecycleEventSPI.onInboundMLSCorrelated() lifecycle callback so integrations get the full context when an MLS is received. It now additionally provides the correlated outbound transaction ID, the resolved EMlsReceptionStatus (RECEIVED_AP / RECEIVED_AB / RECEIVED_RE), the MLS document ID, and the AS4 reception timestamp. The OpenTelemetry metrics handler now also breaks the phoss.ap.inbound.mls.correlated counter and the phoss.ap.mls.roundtrip.duration histogram down by the new phoss.ap.mls.reception_status attribute. See #63.
  • Added three optional custom fields (custom1, custom2, custom3, max 255 characters each) to outbound transactions. They can be supplied on all outbound submit endpoints (/api/outbound/submit/..., /submit-sbd, /submit-auto/... as query parameters; /submit-s3 in the JSON body), are stored in the outbound_transaction table (and mirrored into outbound_transaction_archive), and are returned by the outbound status APIs when set. Values longer than 255 characters are rejected with HTTP 400. Existing database rows are filled with NULL via a new Flyway migration (V4). See #64.

v0.10.4 - 2026-07-21

  • The deprecation warning for a legacy .ms configuration key (in APCoreConfig and APDirSenderConfig) is now logged only once per key, instead of on every access.
  • Fixed outbound sending masking the real transport error with Phase4PeppolException: A Peppol Reporting item can only be created AFTER sending. OutboundOrchestrator called createPeppolReportingItemAfterSending(...) unconditionally right after the AS4 send — on a failed send (e.g. a TLS handshake failure) that call threw, and the resulting exception overwrote the actual transport exception (captured in aCaughtSendingEx) in the sending report, so the sending report and logs only showed the reporting error instead of the underlying PKIX path building failed / SSLHandshakeException. The Reporting item is now only created when sending actually succeeded, so failures surface the real transport exception and retry decisions are made on the real error. Affected both the PAYLOAD_ONLY and PREBUILT_SBD outbound paths. See #58 - thx @ajay2280
  • Added a startup check that the TLS trust store used for outbound HTTPS connections (SMP lookup and AS4 sending) actually contains trust anchors. Since phase4 4.5.0 the AP HTTP client uses the JVM default trust store (honoring the javax.net.ssl.trustStore system property); if that store is overridden with an empty or invalid file, every outbound SMP lookup and AS4 transmission fails with PKIX path building failed. The application now fails fast at startup with a clear message instead of failing silently on every transmission. See #58
  • Security fix: the API token check was skipped for every /api/** request when the AP is deployed under a non-root servlet context path (server.servlet.context-path), leaving all API endpoints (outbound submit, status queries, MLS SLA reports, inbound reporting) unauthenticated in such deployments. ApiTokenFilter matched the context-absolute request URI (getRequestURI(), e.g. /ap/api/...) against /api/; it now matches the context-relative servlet path (getServletPath()), so the token check applies regardless of the context path. Root-context deployments were unaffected. See #62 - thx @karelkryda
  • Added a runtime extension mechanism that lets you add custom SPI implementations (notification handlers, verifiers, document forwarders, …) and additional JDBC drivers to a deployment without rebuilding the application jar. The phoss-ap-webapp Spring Boot jar is now repackaged with the ZIP layout (using Spring Boot's PropertiesLauncher), and the Docker image sets LOADER_PATH=/ext and creates an /ext directory whose contents are appended to the runtime classpath on startup. Any thin jar dropped into /ext — bind-mounted from the host or baked into a derived image — is discovered via the standard java.util.ServiceLoader, with no further wiring. See the new Runtime Extensions wiki page. Addresses #60.
  • Added the new phoss-ap-extension-demo module — a minimal, logging-only example extension (implementing IAPLifecycleEventSPI and IAPNotificationHandlerSPI) with an example Dockerfile and assemble.sh / run-docker-ph.sh helper scripts, as a copy-paste starting point for writing your own extension.
  • Added experimental support for IBM DB2 (LUW) as a persistence backend, selected via phossap.jdbc.database-type=db2, including DB2 Flyway migration scripts. Because the IBM jcc driver is proprietary, it is not bundled: the driver and the flyway-database-db2 dialect are supplied at runtime via the new /ext extension mechanism (see Runtime Extensions), keeping the default distribution lean and Apache-licensed. Timestamp parameters are converted to java.sql.Timestamp only when running on DB2 (whose JDBC driver rejects OffsetDateTime via setObject()); PostgreSQL, MySQL and H2 continue to bind OffsetDateTime unchanged, so their behavior is unaffected. The Peppol Reporting SQL backend also supports DB2 (since peppol-reporting 4.2.0), so the reporting database may run on DB2 too. See #56 / #57 - thx @dmaus2018
  • Added a generic custom document storage backend extension point. Set storage.mode=spi and supply an IDocumentPayloadManagerProviderSPI implementation (selected by the storage.spi.id property, matched against the provider's getID()) to store document payloads in any backend — a relational database, an object store, a message queue, etc. The AP core makes no assumption about the storage technology; any technology-specific setup (schema, migrations, bucket provisioning) is the provider's own responsibility. The implementation is loaded via the /ext extension mechanism, exactly like the other provider SPIs (see Runtime Extensions). See #59 - thx @dmaus2018

v0.10.3 - 2026-07-05

  • Updated to ph-commons 12.3.2 - security improvements on XML processing and bug fixing in background cache eviction
  • Updated to peppol-reporting 4.1.5 - exclude MLS from EUSR

v0.10.2 - 2026-07-02

  • Added an outbound dev loopback mode for local/test environments. When outbound.dev-loopback.enabled=true and peppol.stage=test, outbound sending skips the SMP/SML lookup and sends the generated AS4 message back to this AP's own phase4.endpoint.address, using the AP's own AS4 certificate as the receiver certificate. Disabled by default, opt-in only, and ignored on the production stage — production behaviour is unchanged. See #49 - thx @fmarslan
  • HTTP forwarding now sends the SBDH Instance Identifier as the X-SBDH-Instance-ID request header, so the Receiver Backend can obtain it without deserializing the SBD stream (relevant e.g. for French PPF e-reporting). See #51 - thx @shetty-tejas
  • SFTP forwarding can now optionally write a JSON metadata sidecar next to each uploaded SBD, mirroring the filesystem forwarder's metadata. Enabled via the new forwarding.sftp.write-metadata property (default false). The sidecar is written with the same base name as the SBD ({name}.json) and contains the full inbound transaction metadata (the same JSON as the inbound transaction REST API).
  • S3 forwarding can now optionally write a JSON metadata sidecar object next to each uploaded SBD, mirroring the SFTP and filesystem forwarders' metadata. Enabled via the new forwarding.s3.write-metadata property (default false). The sidecar uses the same object key as the SBD but with a .json extension (<key-prefix><sbdhInstanceID>.json) and the same content.

v0.10.1 - 2026-06-25

  • Fixed Phorm call result handling to handle negative validations properly
  • MLS messages are no longer using status reason code FD but AB as we assume a temporary problem only
  • Filtering out Saxon "transformation warnings" when creating MLS errors
  • Fixed potential issues with S3 connection under load. See #47 - thx @gaboull

v0.10.0 - 2026-06-22

  • Added new IAPNotificationHandlerSPI.onInboundDuplicateRejected(...) callback that is invoked when an inbound AS4 message is rejected because it was detected as a duplicate before an inbound transaction could be persisted. Carries the Peppol sender ID (C1), receiver ID (C4), document type ID, process ID, sender provider ID (C2, derived from the signing certificate common name), AS4 Message ID, SBDH Instance Identifier, the bIsDuplicateAS4 / bIsDuplicateSBDH flags and the error detail sent back as the AS4 error. Declared as a default-method, so existing in-tree and external IAPNotificationHandlerSPI implementations remain source- and binary-compatible. See #42 - thx @fmarslan
  • BREAKING — IInboundDocumentVerifierSPI signature changed. verifyInboundDocument now returns @Nullable MlsOutcome instead of @NonNull ESuccess. null (or any non-REJECTION outcome) means the verifier passes; a non-null MlsOutcome with response code REJECTION rejects the document and its MlsOutcomeIssue entries flow into the outbound MLS line responses. External implementations of this SPI must be updated; the in-tree PhormDocumentVerifier has been migrated. See #38.
  • Added PhiveToMlsMapper in phoss-ap-validation that maps a phive ValidationResultList to a detailed MlsOutcome. Inbound rejections now carry one MLS line response per failed phive entry instead of a single generic BV/NA line: XSD validation failures become SV (Syntax Violation); Schematron and other failures become BV (Business Rule Violation, fatal) or BW (Business Rule Warning) based on phive severity; each issue's errorField carries the phive errorFieldName (typically the failed assertion's XPath context) with fallback to the formatted error location or NA; the issue description prefixes the rule ID where available (e.g. [BR-CO-15] Sum mismatch). Warnings are dropped when no errors are present (acceptance MLS responses do not carry line issues). See Message Level Status — Verifier-driven issue mapping.
  • Fixed Peppol Reporting outbound endUserID being set to the receiver participant ID instead of the sender participant ID — affected both the PEPPOL_SBDH and PREBUILT_SBD outbound paths in OutboundOrchestrator, so EUSR reports submitted by an AP that also sends documents contained the wrong end-user identifier. See #40 - thx @DmitriiKhudiakov
  • Added a machine-readable OpenAPI 3 specification of all REST endpoints, served by the running application at GET /openapi/v3/api-docs (JSON) and GET /openapi/v3/api-docs.yaml (YAML). Generated at runtime by springdoc-openapi from controller annotations — no Swagger UI is bundled (only the raw spec is exposed). Each endpoint carries an operation summary/description, parameter descriptions with examples, and the documented success / 4xx / 5xx response codes. The referenced request/response DTOs (OutboundS3SubmitRequest, InboundTransactionResponse, OutboundTransactionResponse, MlsSlaReportResponse, MlsSlaEntryResponse, ReportResponse) carry field-level descriptions, examples, required-vs-optional markers and allowed-value lists for enum-like fields (status, MLS response codes, …) so generated clients reflect the real contract. Declares the X-Token API-key security scheme on all /api/** endpoints so the auth requirement is visible to spec consumers and client generators; /management/** is unprotected. The base path is configurable via springdoc.api-docs.path (defaults to /openapi/v3/api-docs).
  • All sending reports, also in case of error, now at least contain the SBDH Instance Identifier and the country of C1 to make sure a later alignment is possible. See #44 - thx @Robcio35
  • Not sending MLS on MLR messages
  • Not placing MLS_TYPE and MLS_TO SBDH parameters on MLR or MLS messages

v0.9.1 - 2026-05-29

  • Replaced the in-tree tracing facade (APTrace, IAPSpan, EAPSpanKind, IAPTracerSPI in phoss-ap-api) with the new ph-telemetry submodule from ph-commons 12.2.6. phoss-ap-otel now provides the tracer via the ITelemetryTracerSPI service-loader contract instead of the in-tree SPI. Inbound / outbound / forwarding / MLS / scheduler / reporting code now uses com.helger.telemetry.Telemetry directly — no behavioural change, but consumers that only depended on the SPI now depend on ph-commons instead of phoss-ap-api.
  • Updated dependencies:
    • Parent POM 3.0.3 → 3.0.5
    • ph-commons 12.2.5 → 12.2.6 (new ph-telemetry submodule providing a vendor-neutral tracing facade; EErrorLevel.SUCCESS deprecated in favour of INFORMATION)
    • ph-web 11.4.0 → 11.4.1 (proxy configuration via http.proxy.host / http.proxy.port no longer requires http.proxy.enabled=true; http.proxy.enabled now only acts as an explicit kill-switch)
    • phase4 4.5.0 → 4.5.1 (improved backwards compatibility around the AP TLS truststore default change and clearer error messages on TLS / PKIX failures pointing at the migration guide)
    • ddd 0.8.7 → 0.8.8 (added value-provider entries for the French CTC Extended-CTC-FR profile on cii, ubl2-invoice and ubl2-creditnote; added support for the French CTC CDAR D22B syntax)
    • peppol-reporting 4.1.3 → 4.1.4
    • AWS SDK 2.44.4 → 2.45.0
    • Flyway 12.6.1 → 12.6.2
    • JAXB 4.0.8 → 4.0.9
    • OpenTelemetry Instrumentation 2.27.0 → 2.28.1
    • Sentry 8.41.0 → 8.43.0
    • Apache MINA sshd-sftp 2.17.1 → 2.18.0
    • Added OkHttp 5.3.2 as a centrally managed dependency for the OpenTelemetry OTLP exporter runtime

v0.9.0 - 2026-05-17

  • Added support for secondary forwarders — fire-and-forget fan-out of every successfully forwarded inbound document to any number of additional endpoints. Useful for duplicating from a TEST AP to both a Dev and a QA backend, or for adding a filesystem archive alongside an HTTP forward. Configured via indexed keys forwarding.secondary.{n}.mode / forwarding.secondary.{n}.* (up to 10 secondaries). Each secondary may use any supported mode (http_post_*, s3_link, sftp, filesystem) independently of the primary. Secondaries run only after the primary succeeded, have no retries, no SLA, no effect on the inbound transaction status, MLS response or circuit breaker; failures are logged only. Each dispatch is wrapped in a phoss.ap.inbound.forward.secondary OpenTelemetry span. See #23 and Forwarding Process — Secondary Forwarders.
  • Added optional OpenTelemetry integration in the new phoss-ap-otel module — emits metrics (failure counters, lifecycle counters and SLA histograms for forwarding / sending / MLS round-trip / scheduler cycles) and distributed traces across the inbound, outbound, MLS, SMP, verification, reporting, scheduler and archival flows. Disabled by default; enabled by setting otel.enabled=true and configuring the standard OTel environment variables (e.g. OTEL_EXPORTER_OTLP_ENDPOINT). Adds two new SPIs in phoss-ap-api: IAPLifecycleEventSPI (positive lifecycle events, complementing the existing failure-side IAPNotificationHandlerSPI) and IAPTracerSPI (tracing facade behind APTrace / IAPSpan / EAPSpanKind, so in-tree modules record spans without depending on the OpenTelemetry API jar). See #33
  • AS4 incoming duplicate detection is now backed by the configured database (PostgreSQL, MySQL, H2). Survives restarts and is shared across multiple AP instances. Adds Flyway V2 migration creating the as4_duplicate_item table. See #19
  • CRL downloads now use the dedicated Phase4PeppolCRLHttpClientSettings (pre-tuned timeouts) from phase4
  • Added new configuration property peppol.revocation.soft-fail (default false) to globally enable certificate revocation soft-fail mode — when set, certificate validation succeeds even if the CRL/OCSP status cannot be determined (e.g. responder unreachable due to a network error). Maps to CertificateRevocationCheckerDefaults.setAllowSoftFail(boolean).
  • /api/outbound/submit-sbd now also runs Phorm validation when verification.outbound.enabled=true, matching the behavior of /api/outbound/submit and /api/outbound/submit-auto. See #21 - thx @roiLeo
  • Added optional cleanup scheduler that periodically deletes archived inbound and outbound transactions (database row + document file) whose completed_dt is older than the configured retention. Operates only on archive tables; requires archival to be enabled. Configured via new properties cleanup.scheduler.enabled (default false), cleanup.scheduler.interval (default 24h), cleanup.scheduler.retention (default 90d, minimum 2d), and cleanup.scheduler.batch-size (default 100). Adds Flyway V3 migration creating indexes on completed_dt of the archive tables. See #20 - thx @thordw
  • Switched all time-related configuration properties to use the duration grammar (e.g. 5s, 1m 30s, 2d 12h). The legacy .ms / .millis keys continue to be honored for backwards compatibility (with a deprecation warning) and will be removed in v1.0.0. See Configuration Properties for the duration grammar. Affected properties:
    • retry.sending.initial-backoff(.ms)
    • retry.sending.max-backoff(.ms)
    • retry.forwarding.initial-backoff(.ms)
    • retry.forwarding.max-backoff(.ms)
    • retry.scheduler.interval(.ms)
    • circuit-breaker.open-duration(.ms)
    • archival.scheduler.interval(.ms)
    • dirsender.scan-interval(.ms)
    • dirsender.initial-delay(.ms)
    • shutdown.timeout(.ms)
    • phossap.jdbc.pooling.max-wait(.millis)
    • phossap.jdbc.pooling.between-evictions-runs(.millis)
    • phossap.jdbc.pooling.min-evictable-idle(.millis)
    • phossap.jdbc.pooling.remove-abandoned-timeout(.millis)
    • phossap.jdbc.execution-time-warning(.ms)
  • GET /api/outbound/status/{sbdhInstanceID} and GET /api/inbound/status/{sbdhInstanceID} now accept the optional query parameter includeArchive=true (default false). When set, the corresponding archive table is consulted if the transaction is no longer present in the active table. Adds the new DB API methods IOutboundTransactionManager.getBySbdhInstanceIDIncludingArchive(String) and IInboundTransactionManager.getBySbdhInstanceIDIncludingArchive(String). See #29 - thx @Robcio35
  • Removed the obsolete &serverTimezone=UTC parameter from the default MySQL JDBC URL in application.properties — the parameter has been a no-op since MySQL Connector/J 8.x and emits a deprecation warning. See #18
  • Fixed missing return statement in PeppolReportingController that caused the success response of the manual report trigger endpoint to be dropped, so a successful run was reported as an internal server error. See #25 - thx @gkoszegi
  • Updated dependencies:
    • PostgreSQL JDBC driver 42.7.10 → 42.7.11 (#26, #27)

v0.2.3 - 2026-04-19

  • Updated to BouncyCastle 1.84 (fixing CVE-2026-5588, CVE-2025-14813 and CVE-2026-5598)
  • Made sure all time related activities use UTC timezone only

v0.2.2 - 2026-04-12

  • Recommendation to use phorm 2.0.3+ as it support to extract payloads from SBDH payloads
  • Added support for custom S3 endpoints and path-style access across all three S3 integration points (storage, outbound, forwarding), enabling use of S3-compatible providers like MinIO or Garage. See #16
  • Added new configuration properties: storage.s3.endpoint, storage.s3.path-style-access, outbound.s3.endpoint, outbound.s3.path-style-access, forwarding.s3.endpoint, forwarding.s3.path-style-access
  • Added H2 support for integration tests, including a Flyway migration script for H2. See #17
  • Switched JAXB runtime from com.sun.xml.bind:jaxb-impl to org.glassfish.jaxb:jaxb-runtime (the maintained coordinates). Pinned jaxb-impl to 4.0.6 because 4.0.7 contains a regression.
  • Added new configuration property phossap.flyway.history-table for customizing the Flyway schema history table name (from ph-db 8.1.3)
  • Added new configuration properties phossap.flyway.debug-mode and phossap.flyway.repair-mode for Flyway diagnostics and history table repair (from ph-db 8.2.0)
  • Updated dependencies:
    • peppol-commons 12.4.0 → 12.4.1 (eDEC Code Lists v9.6; PeppolMLSBuilder now has a customizable identifier factory)
    • phive 12.0.1 → 12.0.3 (avoid NPE; avoid infinite ZIP size handling; avoid automatic owner document selection)
    • ph-db 8.1.2 → 8.2.0 (customizable Flyway history table name; customizable JDBC pooling parameters; Flyway debug and repair modes)
    • ddd 0.8.2 → 0.8.5 (support for new Peppol TDD formats; ability to capture inner element; wrappers in result details)
    • peppol-reporting 4.1.2 → 4.1.3
    • peppol-ap-support 2.1.2 → 2.1.3
    • AWS SDK 2.42.22 → 2.42.32
    • Flyway 12.2.0 → 12.3.0
    • Jackson 3.1.0 → 3.1.1
    • Log4j 2.25.3 → 2.25.4

v0.2.1 - 2026-04-01

  • Added MySQL 8.0+ as a supported database backend alongside PostgreSQL, including Flyway migration scripts for MySQL and MySQL JDBC driver dependency (#14)
  • Fixed phase4 and SMP Client not sharing the same configuration sources
  • Fixed missing MLS_TO and MLS_TYPE parameters when passing outbound transaction as params
  • Fixed using the correct MLS_TO value from the inbound transaction
  • Added field database.type to the status data

v0.2.0 - 2026-03-30

  • Added new REST endpoint POST /api/outbound/submit-auto/{senderID}/{receiverID}/{c1CountryCode} for submitting raw XML documents with automatic document type and process detection via the DDD (Document Details Determinator) library — no need to specify docTypeID or processID
  • Added DDDHelper utility in phoss-ap-core for DDD integration
  • Added new phoss-ap-dirsender module — optional directory-based batch sender that watches a directory for pre-built SBD XML files, submits them via the normal outbound pipeline, and moves processed files to success/ or error/ subdirectories with JSON result files. Includes crash-safe pending/ intermediate directory with startup recovery and periodic cleanup.
  • Added new configuration properties: dirsender.enabled, dirsender.directory, dirsender.scan-interval.ms, dirsender.initial-delay.ms
  • Added new forwarding mode filesystem — writes received SBDs to a local directory with a metadata JSON file alongside each document. Supports flat and per-transaction layout modes via forwarding.filesystem.layout. Atomic write pattern (.tmp → rename) prevents partial file reads.
  • Added support for custom HTTP headers on forwarding requests via forwarding.http.headers.{n}.name / .value indexed configuration — useful for Bearer tokens, API keys, or custom metadata headers
  • AS4 message dumps now include a metadata.json file with incoming message metadata (remote address, timestamps, unique ID, etc.) — applies to both direction and grouped dump modes
  • Changed default document storage paths from /var/phoss-ap/inbound and /var/phoss-ap/outbound to ${user.home}/phoss-ap/inbound and ${user.home}/phoss-ap/outbound
  • The management status endpoint now includes the DDD library version
  • Added ddd library dependency (v0.8.2) for document type auto-detection

v0.1.3 - 2026-03-29

  • Added configurable C4 country code determination modes via forwarding.c4countrycode.modes — supports receiver_pid (derive from receiver participant ID scheme) and business_card (look up from Peppol Directory Business Card via SMP). Multiple modes can be chained as a fallback sequence. The asynchronous reporting API always remains available as the final fallback.
  • Added new REST endpoint GET /api/inbound/missing-c4-country-code to list all forwarded transactions still missing a C4 country code
  • Added new REST endpoint GET /api/inbound/missing-c4-country-code/{sbdhInstanceID} to check if a specific transaction is missing a C4 country code (200 = missing, 204 = already set, 404 = not found)
  • Added c4CountryCode field to the InboundTransactionResponse DTO — previously omitted from the JSON response
  • Added management status endpoint GET /management/status returning non-sensitive configuration, version information, and runtime metadata as JSON (#13)
  • Added new configuration property peppol.receiver-check.mode with values none (default), smp, and sml to explicitly control receiver check behavior. The sml mode uses dynamic per-participant SMP resolution via the SML (#12, requires phase4 4.4.2+)
  • Added new configuration property peppol.dns.servers to specify custom DNS servers for Peppol NAPTR lookups
  • Added new configuration property management.status.enabled (default true) to disable the status endpoint
  • Changed default retry.sending.initial-backoff.ms from 60 seconds to 180 seconds (3 minutes)
  • Fixed HttpDocumentForwarder not applying configured HttpClientSettings (proxy, timeouts, TLS) — the settings were loaded from configuration but never passed to the HttpClientManager
  • Updated dependencies:
    • phase4 4.4.1 → 4.4.2

v0.1.2 - 2026-03-27

  • Added new configuration property mls.sending.enabled (default true) to globally disable all outbound MLS sending — useful for testing or during initial rollout
  • Added "retry": "none" support in http_post_sync forwarding responses — the Receiver Backend can now suppress retry attempts for permanently unprocessable documents
  • Added new configuration property archival.scheduler.batch-size (default 100) to control how many transactions are archived per scheduler cycle; values < 1 are rejected at startup
  • Added new configuration property retry.scheduler.batch-size (default 50) to control how many transactions are retried per scheduler cycle; values < 1 are rejected at startup
  • Added MLS correlation tracking: incoming MLS responses now record the inbound transaction ID (mls_inbound_transaction_id) for full audit trail
  • The peppol.receiving.enabled=false flag is now enforced — incoming AS4 messages are rejected immediately when receiving is disabled
  • Added logging configuration template in application.properties with commented-out options for file output, daily rotation and retention
  • Improved error diagnostics: AS4 private key loading failures now include the attempted key alias in the error message
  • Improved Phase4PeppolSendingReport to always include the AS4 sending result code, even on failure
  • Fixed NPE in OutboundOrchestrator when AS4 sending returns a negative result code without an accompanying exception
  • Fixed NPE in OutboundOrchestrator error message construction when exception is null
  • Fixed test backend to use configured country code for http_post_async forwarding responses instead of hardcoding AT (#10)
  • Moved all client-relevant REST API DTOs from phoss-ap-webapp to new com.helger.phoss.ap.api.dto package in phoss-ap-api, so that API clients can depend on phoss-ap-api alone without pulling in the webapp or Spring Boot
    • OutboundS3SubmitRequest — S3 submission request body
    • OutboundTransactionResponse — outbound transaction status (includes fromDomain())
    • InboundTransactionResponse — inbound transaction status (includes fromDomain())
    • ReportResponse — inbound reporting response
    • MlsSlaReportResponse (with nested MlsSlaEntryResponse) — MLS SLA compliance report
  • Updated dependencies:
    • Upgraded Jackson from 2.x to 3.1.0 (new tools.jackson.core groupId)
    • Spring Boot 4.0.3 → 4.0.5
    • phase4 4.3.2 → 4.4.1
    • peppol-commons 12.3.12 → 12.4.0
    • ph-web 11.2.4 → 11.2.5
    • Flyway 12.1.0 → 12.2.0
    • AWS SDK 2.42.13 → 2.42.22
    • Sentry 8.35.0 → 8.37.1
    • JAXB 4.0.6 → 4.0.7
    • commons-compress 1.28.0, commons-lang3 3.20.0
    • Imported parent POMs for ph-db, phase4 and peppol-reporting for centralized dependency management

v0.1.1 - 2026-03-18

  • Added S3 as an alternative document storage backend via storage.mode=s3 (new DocumentPayloadManagerS3 implementation of IDocumentPayloadManager)
  • Document paths in the database are now opaque strings — either absolute filesystem paths or s3://bucket/key URIs depending on the configured storage mode
  • Added new EStorageMode enum (FILE_SYSTEM, S3) for type-safe storage mode configuration
  • Added new REST endpoint POST /api/outbound/submit-s3 for submitting outbound documents by S3 reference instead of inlining the payload
  • Added new OutboundS3SubmitRequest JSON DTO for the S3 submission endpoint
  • Added configuration properties: storage.mode, storage.s3.* (bucket, region, access-key-id, secret-access-key), outbound.s3.* (enabled, bucket, region, access-key-id, secret-access-key)
  • Changed IDocumentPayloadManager.renameFile() return type from File to String to support non-filesystem storage backends
  • storage.inbound.path and storage.outbound.path are now used by all storage modes as base path components
  • Added AWS S3 SDK dependency to phoss-ap-basic module

v0.1.0 - 2026-03-17

  • Initial release as open-source
  • Requires Java 21 or later
  • Based on Spring Boot 4.x
  • Uses phase4 4.x for AS4 message exchange
  • Uses peppol-commons 12.x for Peppol identifiers, SMP client and MLS
  • Uses peppol-reporting 4.x for Peppol Reporting
  • PostgreSQL is the required backend database, schema managed via Flyway 12.x
  • Inbound AS4 document reception with duplicate detection on AS4 Message ID and SBDH Instance Identifier
  • Outbound document sending with automatic SMP lookup and SBDH creation
  • Configurable document forwarding to backend systems via HTTP POST, AWS S3 or SFTP
  • Retry with exponential backoff and per-endpoint circuit breaker (via Failsafe 3.x) for both sending and forwarding
  • Optional document validation via external phive Validation Service
  • MLS (Message Level Status) support with integrated SLA-1 and SLA-2 reporting
  • Peppol Reporting with scheduled EUSR and TSR submission
  • Automatic archival of completed transactions
  • REST API for outbound submission, status queries and inbound reporting
  • API token authentication
  • Multi-instance safe via PostgreSQL SKIP LOCKED for concurrent job processing
  • (Docker) Multi-stage Dockerfile with health check on /actuator/health
  • (Docker) Docker Compose setup with PostgreSQL included
  • Optional Sentry integration for error tracking
⚠️ **GitHub.com Fallback** ⚠️