Appearance
POS Fire — Fiscal Write-Ahead and the Ambiguous-Sign Protocol
Scope:
upvendo-backendPosOrderOrchestrator::fire()(routePOST /pos/orders/{uuid}/fire) after befix/pos-fire-sign-safety. Belgium (GKS 2.0) only signs when the location has an FDM identity; elsewhere a fire is a kitchen ticket with no fiscal event.
Purpose
Sending an order to the kitchen ("fire") does two things: it appends a kitchen ticket for the lines changed since the last send, and — at a Belgian location with a fiscal data module (FDM) — it signs a P event (signOrder) for the fiscal part of that change. The P event is legally binding and append-only. This page describes how the backend guarantees that:
- a send that failed is never shown as sent, and a retry really sends it;
- a send whose FDM outcome is unknown (timeout, dropped connection) is never signed a second time;
- a signed P event always reaches the kitchen and always moves the "already sent" snapshot, so a later edit signs only the difference.
Key Concepts
- Fired snapshot —
last_fired_lines+last_fired_lines_hashon the open order: the priced lines already signed/sent. The next send signs the delta against it. - Fire attempt —
fire_attempton the open order, written only by a send that must sign a P event. States:claimed(persisted, request not yet frozen, nothing sent),signing(request frozen and possibly sent),ambiguous(the send failed in a way that may have been signed),signed,failed_unsigned,abandoned. The first three are unresolved. - Frozen request — the exact GraphQL bytes of the
signOrderrequest (including itsposFiscalTicketNoandposDateTime), stored encrypted on the attempt with their sha256 before they are sent (they contain the operator's INSZ). - The FDM's duplicate rule — the FDM keys a request on five fields:
posId,posDateTime,terminalId,eventLabel,posFiscalTicketNo. An identical resend of a request it already answered returns the original response with aDUPLICATE_REQUESTwarning and does not update its counters (FOD specspec22.txt:2238-2262). The FDM has no per-transaction status query. The pack documents onlyStatus(language)(device diagnostics,spec22.txt:3530), and the live introspectedQuerytype exposes onlydeviceandstatus. - Resend generation (
fire_attempt.resends): every resend of the frozen request first claims the attempt, setting itambiguousand incrementing this counter in one atomic write.
Business Rules
- Nothing reads as sent until it is. The claim writes only the attempt. The kitchen ticket, the fired snapshot and the signed attempt land together in one completion write. The till's sent/unsent chip reads
fired(true when a kitchen ticket exists), so it shows unsent until a ticket really exists. - A send with nothing to sign (no FDM at the location, or a change with no fiscal content such as a combo pick swap at the same price) writes the ticket and snapshot in one version-checked write, with no attempt. A lost race means nothing was written, and the retry is clean.
- All fallible reads happen before the claim. These are the quote, the booking period and the operator's INSZ. A failure there leaves nothing to undo.
- The ticket number is allocated after the claim, so a lost claim burns no number. The request is then frozen before it is sent.
- Post-claim writes are ownership checks on the attempt, not whole-order version checks. Another register editing the table, the lines or the staff can no longer make the fire's write-back miss. Every such write still bumps the order version, so a register holding an older copy gets the usual 409 and refetches.
- Three failure classes. Which class an FDM answer falls into is decided in one place,
FdmClient::throwForErrors()(see "How an FDMerrorsanswer is classified" below):- The FDM refuses the first send (a definitive refusal: an FDM refusal code, or a request error raised before anything ran): it processed and refused this request. The attempt becomes
failed_unsigned, but only through a compare-and-set on statesigningandresends == 0. If another register has already claimed a resend of the same bytes, that resend may have signed. The refusal then leaves the attempt unresolved, and no refusal from any sender can release it. (Astra review 2026-09-25: a lateFDM_LOCKEDon the original used to retire an attempt that a concurrent resend had signed, and the next send signed the same quantities again under a new key.) - The outcome is unknown: a transport failure after the request may have left (timeout, connection loss, malformed response), or an FDM answer that does not prove the request unsigned (
FdmAmbiguousOutcomeException, see below). The attempt becomesambiguousand keeps its frozen bytes. The API answers 502fire_unconfirmed/outcome_unknown(see the error contract below). The order reads unsent (unresolved_fireis set), and pressing Send again is safe: it resends the same bytes. - Journal or completion write failure after a successful sign: the attempt stays
signing.
- The FDM refuses the first send (a definitive refusal: an FDM refusal code, or a request error raised before anything ran): it processed and refused this request. The attempt becomes
- The next send resolves an unresolved attempt first, from evidence:
claimed: abandoned (provably unsent), then the send proceeds normally.- P already journaled (found by replay key, or by attempt uuid): completed from the journal with no FDM call.
- Not journaled: the frozen bytes are resent unchanged to the same FDM. If the original was signed, the FDM answers with a duplicate and the original data. If it was not, the FDM signs now, once. Either way there is exactly one P event, and it is then journaled and completed.
- A resend never lands in a closed booking period. Before resending, the backend checks that the business day of the frozen
bookingDateis stillopenand still carries the frozenbookingPeriodId. This is a read-only lookup that never auto-opens a day. The 04:00 robot Z closes a missed day without the open-orders check. So a request that never reached the FDM, resent after that Z, would be signed inside a closed period and be missing from its report. It is refused instead:fire_needs_reconciliation/booking_period_closed, and no FDM call is made. 8a. A replay is never sent to a different FDM. Duplicate detection only works on the FDM that answered the original. If the FDM URL or credential changed since the freeze, or there is no transport, the replay is refused and the attempt stays for manual reconciliation. - A refused resend never releases the attempt. It proves nothing about the original or about any other resend. How it is reported depends on the code.
FDM_LOCKED,BUFFER_FULL,FDM_NOT_OPERATIONALandINTERNAL_ERRORdescribe the device's momentary state, so the till getsfire_unconfirmed/fdm_unavailable(press Send again later). Any other refusal (INVALID_REQUEST,UNAUTHORIZED, an INSZ rejection, an unknown code) cannot be fixed by retrying, so the till getsfire_needs_reconciliation/fdm_refused_resendwith the FDM's fields. If the FDM answers a resend ambiguously (FdmAmbiguousOutcomeException), another press would send the same bytes and meet the same answer. The till therefore getsfire_needs_reconciliation/fdm_ambiguous_resend, never "press Send again". The same applies to an original send answered ambiguously after a concurrent resend was claimed. The pack also does not say whetherFDM_LOCKED/BUFFER_FULLare raised before duplicate detection. Releasing would let the next send sign under a new ticket number. The ciphertext is never deleted while the attempt is ambiguous. - Settle and void refuse while a fire is unresolved (409, "The last kitchen send on this order is not confirmed by the fiscal module yet. Press Send to finish it, then try again."). Settling would move the order out of OPEN, and a possibly signed P could then never be journaled. A void's negative P is computed from the fired snapshot, which the unconfirmed P may already have moved. Clearing all lines (void-on-empty) also treats an unresolved attempt as fired. The business-day Z close is already refused while any order is OPEN, so a replay can never land on a closed day.
Error contract (stable codes for the till)
POST /pos/orders/{uuid}/fire, …/settle and …/void answer every fiscal-fire failure with a shaped body. The till branches on code + reason, never on message, because the generic handler would otherwise mask 5xx messages as "An unexpected error occurred".
json
{ "message": "…", "code": "fire_unconfirmed", "reason": "fdm_unavailable",
"fdm_code": "FDM_LOCKED", "fdm_message": "FDM is locked", "show_pos": "MANDATORY" }code | reason | HTTP | Till action |
|---|---|---|---|
fire_unconfirmed | outcome_unknown | 502 | Show "not sent". Press Send again resends the same request |
fire_unconfirmed | fdm_unavailable | 503 | Show fdm_message, which is mandatory when show_pos is MANDATORY. Press Send again once the FDM is available |
fire_unconfirmed | signed_not_recorded | 500 | Press Send again. It finishes without a new signature |
fire_unconfirmed | send_pending | 409 | On Pay or Void: press Send first, then retry |
fire_refused_by_fdm | fdm_error | 503 | Nothing was sent. The FDM is only momentarily unable to sign (FDM_LOCKED, BUFFER_FULL, FDM_NOT_OPERATIONAL, INTERNAL_ERROR). Show fdm_message, then Send again when the FDM is available |
fire_refused_by_fdm | fdm_refused_needs_support | 502 | Nothing was sent, and the refusal is not a momentary one: any other FDM code or no code. UNKNOWN_POS, UNAUTHORIZED and INVALID_REQUEST refuse every retry the same way; for an unknown code or no code, retryability is unknown. Do not offer Send. Show message (it names the fdm_code) and send the operator to support |
fire_refused_by_fdm | operator_insz_invalid | 422 | Fix the operator's national number (POS staff in the Back Office), or send as another operator |
fire_needs_reconciliation | evidence_unreadable / fdm_addressing_changed / completion_lost / booking_period_closed / fdm_refused_resend / fdm_ambiguous_resend | 500 | Do not offer Send. Escalate for manual reconciliation (fdm_refused_resend also carries the FDM fields) |
fdm_code / fdm_message / show_pos are present whenever an FDM refusal is involved. fdm_message is the FDM's own text (errors[].extensions.message, pack example spec22.txt:2694-2708). Before it leaves the server it goes through SecretRedactor::redact(), and every national-number spelling is masked whether or not its checksum is valid. That means 11 bare digits, or YYMMDD / YY.MM.DD + XXX + CC with separators from .-_/ (also a non-breaking space), where the separator before CC is optional. Examples: 85.07.30-033.29, 85 07 30 033 29, 850730-03329. Other numbers are kept: amounts, dates, percentages, VAT numbers. fdm_code and the log-context codes must look like a code: upper-case letters, digits and _, starting with a letter, at most five digits in total. Anything else is replaced by NON_CODE_VALUE. FDM_LOCKED and BUFFER_FULL are Error / display M (spec22.txt:2540-2541), and M means the message "must be shown via the UI of the POS" (spec22.txt:2630).
Transient vs needs-support is one list. FdmException::TRANSIENT_CODES (FDM_LOCKED, BUFFER_FULL, FDM_NOT_OPERATIONAL, INTERNAL_ERROR) decides both whether a refused first send says "Send again" (fdm_error) or "contact support" (fdm_refused_needs_support), and whether a refused resend stays fdm_unavailable or becomes fdm_refused_resend. On a first send the INSZ check runs first: an INSZ rejection is always operator_insz_invalid. The list is an allowlist, so a code that is not on it asks a human. The reason: every new press of Send after a released attempt starts a new request with a fresh posFiscalTicketNo, so "Send again" on a refusal that no retry can fix only uses up ticket numbers. The fdm_refused_needs_support message is translated (EN/NL/FR/DE, resources/lang/*.json).
The FDM's error text never rides the exception. The FDM echoes submitted values into its error text (spec22.txt:2669-2683), and the fire request carries the operator's INSZ as employeeId. PosFireFiscalException's constructor removes any FdmException from the previous chain and keeps only its codes in the log context (fdm_category, fdm_code, fdm_subcode, fdm_show_pos). SecretRedactor only recognises a checksum-valid INSZ, and the number the FDM refuses is often checksum-invalid, so no later scrub could catch it.
On the order itself: OpenOrderResource.unresolved_fire is null, or {code: "fire_unconfirmed", state, started_at} while a send is unconfirmed. The till can show "not sent — press Send again" on any order read, even when it never saw the failing response.
How an FDM errors answer is classified
Since be#2363, every FDM errors answer goes through one function, FdmClient::throwForErrors(). It is used by both sign() and sendRawSerialized(), so no sign path can classify an answer differently.
Definitive →
FdmException(the caller may release its claim). This applies in only two cases:- every
errors[]entry carries one of the pack's Error-nature codes, listed inFdmAmbiguousOutcomeException::REFUSAL_CODES(tb_messages,spec22.txt:2540-2556); - the answer is a request error raised before execution: no
datamember, nopath, no code. This is the pack's own validation example,spec22.txt:2674-2683.
- every
Ambiguous →
FdmAmbiguousOutcomeException. This is anIntegrationException, deliberately not anFdmException, so every path lands in its existing ambiguous posture: stay claimed, fail closed, never release. Thereasonsays why:duplicate_request_in_errors:DUPLICATE_REQUESTas thecodeor theSUBCODEof any entry. The FDM already holds a mutation under these five key fields.sign_result_with_errors: an error returned beside a non-null SignResult, including{}.error_inside_sign_result: an error whosepathpoints inside the result, such as["signSale","digitalSignature"]. The mutation ran, and a non-null child then nulleddata.error_without_refusal_code: an uncoded or Warning-coded error after execution started.malformed_errors:errorsis not a list, or an entry has nomessage.
If the FDM returned a SignResult, it rides on the exception as reconciliation evidence (
getReturnedResult(), with its free-text members stripped). It is never journaled automatically: nothing in the answer proves the signature belongs to this request's content rather than to a colliding one. The logged context carries only shape-checked codes.
Why. The pack documents DUPLICATE_REQUEST only as a Warning (spec22.txt:2537-2539) that returns the original data (:2259-2262), and no fixture carries it. But the schema does not exclude the errors channel: one Code enum types both warnings and errors, and every sign mutation returns a nullable SignResult. Before be#2363, every errors answer was an FdmException. Settle, void, refund and the fire's first send released their claim on it, and the retry signed again under a fresh posFiscalTicketNo. Executed against the FDM simulator, that produced 2 P events for one order.
Not decided (escalated). INTERNAL_ERROR, UNDEFINED_ERROR and UNDEFINED_OTHER are still definitive, although the pack gives no rollback guarantee for them.
What the operator sees on each path when the answer is ambiguous
| Path | State left behind | What the till gets |
|---|---|---|
| Fire, first send | attempt ambiguous, frozen bytes kept | fire_unconfirmed / outcome_unknown (502). One press of Send resends the same bytes, which is safe and may resolve it. Exception: if another register has already claimed a resend of this attempt (stored fire_attempt.resends > 0), the answer is fdm_ambiguous_resend, because the next press would be yet another resend |
| Fire, any resend | attempt stays ambiguous | fire_needs_reconciliation / fdm_ambiguous_resend (500). Send is not offered, and Pay/Void/Z stay blocked (send_pending) until a human reconciles |
| Settle (N) | order stays SETTLING, settle_attempt ambiguous | 500 (generic "An unexpected error occurred" outside debug). A later settle answers 409 "Order is stuck in SETTLING with an unresolved sign attempt … manual reconciliation required" |
| Void (negative P) | order stays VOIDING, void_attempt ambiguous | 500 on the call. A later void answers 500 "Order is stuck in VOIDING …" (masked outside debug) |
| Refund (negative N) | refund_attempt ambiguous | 500 on the call. A later refund answers 409 "A previous refund attempt is unresolved … manual reconciliation required" |
| Attendance (S) dispatch | dispatch ambiguous | background: the sweeper's recovery replays the frozen bytes |
| Z close | day stays closing (irreversible phase) | reconciliation replays the frozen bytes |
| Cash F, legacy S at login, X report | no claim state | the error propagates (unchanged). For non-mandated locations the S at login stays best-effort |
There is no in-product release tool for a stuck SETTLING / VOIDING order or refund yet. A human reconciles from the journal and the FDM.
Declared behaviours (for the certification application)
- A resent P can carry a LOWER
posFiscalTicketNothan events signed after it. The ticket number is fixed when the request is frozen. If that original never reached the FDM and is resent after later events were signed, it arrives with its older, lower number. The handleiding checksposFiscalTicketNoas ascending ("Oplopend",handleiding.txt:1261). The spec only requires the number to be unique per terminal/event type (spec22.txt:1069-1074), and the identical resend is the pack's own idempotency mechanism (spec22.txt:2238-2266). This must be declared on the FOD application as intended behaviour, not discovered by the examiner. - The FDM's duplicate-memory window is undefined.
spec22.txt:2250-2254says the FDM checks whether it already answered a mutation with the same five key fields, but it does not say for how long it remembers. A resend days later relies on that memory. This is an open FDM-vendor question. Until it is answered, resends are also bounded by rule 8: never into a closed booking period.
Troubleshooting
| Symptom | Meaning | What to do |
|---|---|---|
Send fails with fire_unconfirmed (or the order shows unresolved_fire) | The FDM may or may not have signed. The attempt is unresolved | Press Send again. The same transaction is resent and the FDM deduplicates it |
| Pay or Void answers 409 "last kitchen send … not confirmed" | An unresolved fire attempt is on the order | Press Send first, then pay or void |
| Send keeps failing after the FDM URL or token was changed | The replay is refused on purpose, because a different FDM would sign it a second time | Manual reconciliation: point the location back at the original FDM, or escalate |
Send fails with fire_needs_reconciliation / evidence_unreadable | The frozen request failed decryption or hash verification | Manual reconciliation. Nothing is resent |
Send fails with fire_needs_reconciliation / fdm_ambiguous_resend | The FDM answered the resend in its errors channel without proving it unsigned, for example DUPLICATE_REQUEST as an error | Manual reconciliation. Pressing Send again would only meet the same answer |
Where it lives (for code readers)
app/Services/Orchestrators/PosOrderOrchestrator.php:fire(),resolveFireAttempt(),sendFrozenFire(),completeFire(),assertNoUnresolvedFire()app/Repositories/OpenOrderRepository.php:transitionFireAttempt()(the ownership write)app/Services/Pos/Fdm/FdmClient.php:serializeOrder()+sendRawSerialized()+throwForErrors()(the oneerrorsclassifier);app/Exceptions/FdmAmbiguousOutcomeException.phpapp/Services/Pos/Attendance/PosAttendanceDispatchPolicy.php: the shared strict decrypt, addressing fingerprints and refusal allowlist (also used by the attendance dispatch and the Z close)- Proof:
tests/Feature/Pos/PosOrderFireFdmSimulatorTest.php, which runs against the statefultests/Support/Pos/FdmSimulator.phpon real Mongo, on both a replica set and a standalone mongod