Skip to content

POS Fire — Fiscal Write-Ahead and the Ambiguous-Sign Protocol ​

Scope: upvendo-backend PosOrderOrchestrator::fire() (route POST /pos/orders/{uuid}/fire) after be fix/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:

  1. a send that failed is never shown as sent, and a retry really sends it;
  2. a send whose FDM outcome is unknown (timeout, dropped connection) is never signed a second time;
  3. 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_hash on the open order: the priced lines already signed/sent. The next send signs the delta against it.
  • Fire attempt — fire_attempt on 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 signOrder request (including its posFiscalTicketNo and posDateTime), 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 a DUPLICATE_REQUEST warning and does not update its counters (FOD spec spec22.txt:2238-2262). The FDM has no per-transaction status query. The pack documents only Status(language) (device diagnostics, spec22.txt:3530), and the live introspected Query type exposes only device and status.
  • Resend generation (fire_attempt.resends): every resend of the frozen request first claims the attempt, setting it ambiguous and incrementing this counter in one atomic write.

Business Rules ​

  1. 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.
  2. 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.
  3. 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.
  4. The ticket number is allocated after the claim, so a lost claim burns no number. The request is then frozen before it is sent.
  5. 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.
  6. Three failure classes. Which class an FDM answer falls into is decided in one place, FdmClient::throwForErrors() (see "How an FDM errors answer 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 state signing and resends == 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 late FDM_LOCKED on 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 becomes ambiguous and keeps its frozen bytes. The API answers 502 fire_unconfirmed / outcome_unknown (see the error contract below). The order reads unsent (unresolved_fire is 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.
  7. 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.
  8. A resend never lands in a closed booking period. Before resending, the backend checks that the business day of the frozen bookingDate is still open and still carries the frozen bookingPeriodId. 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.
  9. 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_OPERATIONAL and INTERNAL_ERROR describe the device's momentary state, so the till gets fire_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 gets fire_needs_reconciliation / fdm_refused_resend with 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 gets fire_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 whether FDM_LOCKED / BUFFER_FULL are 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.
  10. 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" }
codereasonHTTPTill action
fire_unconfirmedoutcome_unknown502Show "not sent". Press Send again resends the same request
fire_unconfirmedfdm_unavailable503Show fdm_message, which is mandatory when show_pos is MANDATORY. Press Send again once the FDM is available
fire_unconfirmedsigned_not_recorded500Press Send again. It finishes without a new signature
fire_unconfirmedsend_pending409On Pay or Void: press Send first, then retry
fire_refused_by_fdmfdm_error503Nothing 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_fdmfdm_refused_needs_support502Nothing 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_fdmoperator_insz_invalid422Fix the operator's national number (POS staff in the Back Office), or send as another operator
fire_needs_reconciliationevidence_unreadable / fdm_addressing_changed / completion_lost / booking_period_closed / fdm_refused_resend / fdm_ambiguous_resend500Do 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 in FdmAmbiguousOutcomeException::REFUSAL_CODES (tb_messages, spec22.txt:2540-2556);
    • the answer is a request error raised before execution: no data member, no path, no code. This is the pack's own validation example, spec22.txt:2674-2683.
  • Ambiguous → FdmAmbiguousOutcomeException. This is an IntegrationException, deliberately not an FdmException, so every path lands in its existing ambiguous posture: stay claimed, fail closed, never release. The reason says why:

    • duplicate_request_in_errors: DUPLICATE_REQUEST as the code or the SUBCODE of 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 whose path points inside the result, such as ["signSale","digitalSignature"]. The mutation ran, and a non-null child then nulled data.
    • error_without_refusal_code: an uncoded or Warning-coded error after execution started.
    • malformed_errors: errors is not a list, or an entry has no message.

    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 ​

PathState left behindWhat the till gets
Fire, first sendattempt ambiguous, frozen bytes keptfire_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 resendattempt stays ambiguousfire_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 ambiguous500 (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 ambiguous500 on the call. A later void answers 500 "Order is stuck in VOIDING …" (masked outside debug)
Refund (negative N)refund_attempt ambiguous500 on the call. A later refund answers 409 "A previous refund attempt is unresolved … manual reconciliation required"
Attendance (S) dispatchdispatch ambiguousbackground: the sweeper's recovery replays the frozen bytes
Z closeday stays closing (irreversible phase)reconciliation replays the frozen bytes
Cash F, legacy S at login, X reportno claim statethe 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 posFiscalTicketNo than 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 checks posFiscalTicketNo as 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-2254 says 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 ​

SymptomMeaningWhat to do
Send fails with fire_unconfirmed (or the order shows unresolved_fire)The FDM may or may not have signed. The attempt is unresolvedPress 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 orderPress Send first, then pay or void
Send keeps failing after the FDM URL or token was changedThe replay is refused on purpose, because a different FDM would sign it a second timeManual reconciliation: point the location back at the original FDM, or escalate
Send fails with fire_needs_reconciliation / evidence_unreadableThe frozen request failed decryption or hash verificationManual reconciliation. Nothing is resent
Send fails with fire_needs_reconciliation / fdm_ambiguous_resendThe FDM answered the resend in its errors channel without proving it unsigned, for example DUPLICATE_REQUEST as an errorManual 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 one errors classifier); app/Exceptions/FdmAmbiguousOutcomeException.php
  • app/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 stateful tests/Support/Pos/FdmSimulator.php on real Mongo, on both a replica set and a standalone mongod