System Architecture

Purpose and document map

This document defines the system's architectural boundaries, dependency direction, and the reasons behind the implemented structure. Read it in three parts:

  1. Implemented feature boundaries describe the catalog, order, checkout, email, and identity slices as they exist today.
  2. Architecture baseline defines the repository, backend, frontend, database, configuration, and testing responsibilities that every slice follows.
  3. Evolution rules explain dependency enforcement, simplicity, ADR usage, and how the architecture may change.

Implementation commands belong in the development guide, test selection belongs in the testing strategy, and completion evidence belongs in the Definition of Done. Specifications and ADRs are historical contracts, not living substitutes for this guide.

Implemented feature boundaries

Product catalog boundary

The MVP product slice persists catalog products behind a ProductRepository application boundary. PostgreSQL/Drizzle owns stable UUID identity, customer-facing name, gross price in integer SEK minor units, VAT percentage in integer basis points, and an optional explicit kitchen label. The application service exposes list, create, identity-preserving update, and hard-delete use cases; Fastify maps them to the authenticated GET /api/products, CSRF-protected POST /api/products, PATCH /api/products/{productId}, and DELETE /api/products/{productId} contracts inside the existing revocable session scope. The shared product policy trims names and explicit labels, requires a non-empty name, requires integer non-negative price within PostgreSQL integer range, and requires VAT basis points from 0 through 10,000. It runs before UUID assignment for creation and before repository access for updates; the update path preserves the UUID identity and returns a safe not-found result for an absent row. The create and update requests accept the canonical integer storage units, while the browser form converts human-facing SEK and percentage input by multiplying by 100 and rounding to the nearest integer. A missing or null label is persisted as null; consumers use the product name as the kitchen-facing fallback. The protected /products page supports creation, inline editing, and confirmed hard deletion, invalidates the catalog query after each successful mutation, and keeps the server authoritative for validation. Product deletion makes a product unavailable to future catalog reads. Finalized orders keep their own immutable product snapshots without a product foreign key, so later catalog edits or deletion cannot rewrite order contents. No role model, tenant, or ownership inference is introduced.

The protected /products page uses the normal page registry and route guard. Its catalog query is owned by the authenticated TanStack Query namespace, so established login, expiry, password-change, and logout cleanup also remove product data. The page combines the existing loading, empty, and failure states with creation, inline edit, and inline confirmation controls; successful mutations invalidate the catalog query and leave stored integer values unchanged for display. Invalid server issues are mapped through a trusted field-ID table to translated inline feedback, aria-describedby, and first-invalid-field focus; unknown server fields and issue messages are not rendered. Empty success, pending, and failure states remain distinct, and the browser formats stored integer values for display without changing their API semantics.

Order entry boundary

The authenticated /kds route is the order-entry region of the tablet kitchen display system. It is an ordinary page declaration in the frontend registry marked access: "authenticated", so it renders inside the shared application shell behind the existing revocable session guard; navigation visibility remains presentation, not authorization. The route owns a page-local, unpersisted current order: it is always present and starts empty, there is no start-draft action, and the persisted catalog is rendered as large, keyboard-reachable product card tap targets. Each card activation immediately adds one unit; repeated activations increment the same product line by stable product UUID, while different product UUIDs create separate lines. The cart control shows the sum of all quantities, and the current-order screen shows each quantity-derived line price plus the summed SEK gross total. Review controls increase or decrease a line without allowing decrement below one, and remove a line explicitly; every change immediately updates the item count, line price, and total. An inline confirmation distinguishes clearing the entire draft from finalization and states that it does not send or record anything. All calculations retain the catalog's integer minor-unit representation until display formatting. The same review owns optional queue-number and order-note strings plus an explicit required eatHere/takeAway choice. Queue number is a bounded display string, never parsed or used as identity, so leading zeroes and reuse across separate orders remain valid. Queue number is limited to 20 characters and note to 500; empty strings represent absent optional values in the local form and finalization normalizes them to nullable persistence values. Confirmed clear resets items and metadata together. The review offers two explicit finalization actions that send the same product identities, quantities, and metadata to two authenticated, CSRF-protected boundaries: commercial finalization, which charges through the neutral checkout, and non-commercial finalization, which bypasses it. Success on either path clears the local draft immediately and exposes the next already-ready empty cart; failure retains every reviewed value for retry. While either action is in flight both are disabled, so a repeated tap cannot start a second charge for the same current order. The browser never chooses a payment method, because the neutral checkout contract does not carry one.

Commercial checkout initiation uses a browser-generated UUID that remains attached to the reviewed draft across transport retries. Before any provider call, PostgreSQL inserts an append-only checkout_operations reservation containing that identity and a fingerprint of the priced request. The primary key admits one initiator across concurrent application instances; a racing duplicate cannot cross the provider boundary. A duplicate after successful finalization safely replays the same order, an unresolved duplicate replays the unresolved result, and a still-pending duplicate is reported as CHECKOUT_IN_PROGRESS. Reusing an identity for different contents is rejected. The UI also disables both finalization controls while the request is pending, but that presentation guard is only the first layer. These guarantees cover the application and deterministic fake; provider- side idempotency remains explicitly unclaimed until Surfboard discovery and adapter work establish it.

The identity is minted with crypto.getRandomValues rather than crypto.randomUUID, because randomUUID exists only in a secure context while the tablet reaches this application over the truck's plain-HTTP network. getRandomValues is the one Crypto member usable from an insecure context, so the payment control cannot fail on the device it is built for.

Operational read failures are visible without discarding last-known data. If a queue or current-day sales refresh fails after a successful read, the KDS keeps that data on screen and labels it as potentially stale; an initial failure has no invented empty value. Failure to read unresolved checkout attempts is shown explicitly and can never be interpreted as proof that no unresolved payment exists. Failed mutations retain the reviewed draft and show no success, offline acceptance, deferred synchronization, or provider verdict. There is no offline queue or automatic replay.

The page reads the same catalog as product administration through GET /api/products and the same productsQueryKey, so a created, edited, or deleted product is observed here through one authenticated TanStack Query namespace that existing logout and session cleanup already own. The current cart is owned by KdsPage and is discarded on unmount or reload. The first tap for a product captures its name, kitchen label, gross price, and VAT in a local line; later taps increment only the quantity and do not refresh that first-tap snapshot. The server remains authoritative at finalization: it reads current catalog rows by UUID inside the creation transaction and writes the durable snapshots. Loading, empty-catalog, and failure states reuse the shared asynchronous-state components and the same safe error mapping; a catalog failure never removes the current order. Stored integer money and VAT units are formatted for display by frontend/src/app/format/product-format.ts, which product administration shares.

KdsPage is the route composition root: it owns the page-local cart, operational metadata, and checkout orchestration, while feature-local modules own catalog and current-order presentation, operational status panels, and queue reads/mutations. Pure cart operations remain in kds/cart.ts; the sales, unresolved-checkout, and queue reads share the KDS-specific 15-second polling cadence. These module boundaries do not change state lifetime, query keys, or the HTTP/API ownership.

The route deliberately reuses the standard shell and its single-column content width. The three-region tablet composition (approximately 10% sales ticker, 45% order entry, 45% kitchen queue) and any separately configurable Order/Queue/Statistics screens are out of this slice, so no layout percentage, split-screen configuration, or tablet-only shell is introduced yet.

Active kitchen queue boundary

OrderRepository.listActive is the single read of the active kitchen queue, and OrderService.listActiveQueue exposes it as the application use case. The authenticated GET /api/orders/active is a read: it requires the session but no CSRF token and no Origin header, because it mutates nothing, and it rejects unexpected query parameters as an explicit 400 INVALID_REQUEST. The response is { "orders": [...] }, or an empty array when nothing is active.

Queue-entry time is the only ordering authority. The repository performs one inner join of orders and order_items ordered by orders.created_at ASC, orders.id ASC. The created_at column is the database time at which the order entered the queue; the id tie-breaker only makes the result deterministic when two orders share a timestamp, which PostgreSQL now() can produce across statements. The optional queue number is deliberately absent from the ordering and from every predicate: it is neither unique nor mandatory, so ordering by it would put the queue in the wrong sequence and filtering by it would drop valid orders. Delivery removes an order from the active set rather than from history, and the set of active statuses lives in one exported constant, ACTIVE_ORDER_STATUSES, so a later fulfillment task extends the definition in a single place.

Each queue entry carries the order's immutable item snapshots, so a later catalog edit or deletion cannot change what the operator is asked to cook. The HTTP layer adds no ordering and the frontend does not re-sort, so the sequence the server returns is the sequence the operator sees.

Delivered orders remain available through the authenticated GET /api/orders/history read model. The repository orders them by delivery transition time (newest first), joining immutable item and finalization snapshots with status, metadata, and checkout chronology for the expandable history view. This read is separate from listActive; delivered orders never re-enter the kitchen queue.

KdsPage renders a "Kitchen queue" region beside the order-entry region and shows the next upcoming orders as complete slips in the queue-entry order the server returned: the optional queue number, every item with its captured kitchen label, the optional note, and the fulfillment state. A missing queue number renders nothing in the number position, so no placeholder or fabricated number can be mistaken for a real one. Each slip is complete on its own — no drill-down, no abbreviation, no collapsed content — so an upcoming order is understandable at a glance.

Item lines name the captured kitchen label, not the live catalog. Finalization resolves an operator's optional kitchen label to the product name when none is set, and the slip shows that historical snapshot, so a later catalog rename, relabel, or deletion cannot change what the operator is asked to cook. A quantity of one is omitted as the ordinary case; a quantity above one is always spelled out.

The slip count is a presentation window, not a queue limit. The read is never truncated or filtered, and the response order is the queue order. The region shows the next three complete slips on the tablet target, which is the documented intent, and the application's existing md breakpoint adds more complete slips on a roomier viewport rather than compacting any of them. Every order beyond the window still reaches the operator through the compact representation below. This task owns no state controls or live push. Refresh is polling: the queue query uses a 15-second refetchInterval so an order finalized in another browser becomes visible without a reload, and it is additionally invalidated immediately after a successful finalization on the same screen. A later synchronization task may replace polling.

Every order beyond the window keeps its own compact entry. KitchenQueueCompactList renders one list item per remaining active order, so a long queue is compacted rather than truncated: no order disappears, and no maximum is assumed. The detailed window and the compact list are a split of the same read, so their union is exactly the active queue.

The entry prioritizes the optional queue number: when the order has one, the number is the whole entry, formatting preserved verbatim (01 stays 01), because that is what the operator calls out at the counter. When it has none, the entry shows a short fingerprint of the order's own captured items instead. orderItemFingerprint in frontend/src/app/format/order-fingerprint.ts builds that fingerprint deterministically from the immutable kitchen-label snapshots in snapshot order, never from the live catalog and never from internal order identity. A quantity above one is spelled out (2x HALLOUMI) and a single unit is bare, matching the detailed slip.

Truncation is safe, not lossy. A fingerprint longer than fingerprintMaxLength is cut on code-point boundaries so no character is split in half, a trailing separator is dropped rather than left dangling before the marker, and a trailing … states plainly that the line is shortened. An order with no items yields an empty fingerprint rather than a placeholder; the queue read inner-joins items, so that state is not reachable through it, and nothing is invented to cover it. No expansion control is offered: the entry is complete as shown, and no interaction is required to read it.

The visible active count comes from the same read as the representations. Once the active-queue read resolves, the queue heading shows orders.length, including zero, and announces later changes politely. It is not a separately maintained counter, so the number cannot drift from the detailed slips plus compact entries: those two presentation sets are slices whose combined length is the visible count. Fifteen orders is an expected service load, not a limit; a sixteenth order extends the compact list and increments the count. The UI renders every active order without pagination and relies on ordinary page scrolling to reach later entries. External changes become visible on the existing 15-second poll, while same-screen finalization keeps its immediate query invalidation.

Finalized operational metadata correction boundary

The authenticated PATCH /api/orders/{orderId}/metadata operation replaces only the queue number and order-level note of an active finalized order. Empty strings normalize to absence and queue number formatting remains verbatim. The repository locks the active order, updates its current values, and appends one database-timestamped order_metadata_changes fact per field that actually changed in the same transaction. Concurrent corrections therefore serialize and each recorded old value is the immediately preceding committed value; unchanged submissions append nothing.

The history table is protected by a database BEFORE UPDATE OR DELETE trigger. Item snapshots, checkout facts, classification, fulfillment mode, status, queue-entry time, and the original order_finalizations fact are outside this update. Missing and delivered identities share the same safe rejection, and there is no general finalized-order editor.

OrderStatusChange is the domain vocabulary for fulfillment transitions: a named { from, to } pair rather than a bare target status, because the state an order must currently hold is half of the rule. QUEUED_TO_PREPARING, PREPARING_TO_READY, and READY_TO_DELIVERED are the forward transitions; PREPARING_TO_QUEUED and READY_TO_PREPARING are the only backward corrections. Each application use case passes its one named pair to the repository; there is no "set this order's status" input anywhere in the application.

The transition is conditional, and that is the entire concurrency guarantee. PostgresOrderRepository.transitionStatus runs UPDATE orders SET status = <to> WHERE id = <id> AND status = <from> RETURNING *, and appends the order_status_transitions row in the same transaction. The source-state predicate is evaluated by PostgreSQL while holding the row lock, so of two concurrent requests for the same transition exactly one updates the row; the other matches nothing, writes nothing, and is reported as notInExpectedState. The history row is therefore written only when the state change actually committed, which makes "history and current state agree" true by construction rather than by convention: a repeated tap, a double-click, or a second browser cannot append a duplicate fact or drive an order past a state the transition does not allow.

The domain rejects a missing order and a wrong state identically. The application maps every non-committed outcome to OrderTransitionRejectedError without carrying a reason, and the HTTP layer reports 409 CONFLICT for all of them. A caller therefore cannot use the endpoint to discover which order identities exist or what state they are in. That is also the correct operator outcome: the card simply has no forward control left, and the queue read — not the transition response — is the authoritative current state.

The transition is named by the path, not by a request body. The three forward operations plus POST /api/orders/{orderId}/requeue and POST /api/orders/{orderId}/resume-preparing are CSRF- and Origin-protected mutations inside the existing session scope. Each accepts no body and returns only its committed transition. The response names the state change and the database time it happened; it never echoes order contents. A 409 documents the conditional behavior for API consumers.

order_status_transitions is append-only at the database level. A BEFORE UPDATE OR DELETE trigger rejects any attempt to rewrite or remove a row, exactly as the order_items immutability trigger does, so a later correction must append rather than replace even when it comes from outside the application repository. from_status and to_status are drawn from the same status vocabulary as orders.status, so history can never claim a state the order could not hold, and transitioned_at is stamped by the database — the same clock that records queue entry — so a recorded time can be neither a client clock nor a second, disagreeing reading.

Advancing changes what the kitchen is doing; delivering changes what is in the queue. Preparing and Ready are active statuses, so an advanced order keeps its queue-entry ordering, stays counted in the visible active total, and keeps its own representation. Delivered is the one non-active status, and it is reached through the same named pair, the same conditional update, and the same append-only history insert as every other transition. Removal from the queue is achieved by that state change, never by deleting a row: the active read simply stops matching the order, while the finalized order, its item snapshots, its checkout facts, and its complete history remain durable and readable. The queue-entry time is never rewritten by a transition, so an order's position reflects when it was ordered rather than how far along the kitchen is.

Backward correction is restricted to active states. Preparing may return to Queued, and Ready may return to Preparing. Both corrections use the same conditional transaction as forward movement, so current state changes while every earlier fact remains in its original order. READY_TO_DELIVERED has no counterpart in this milestone: no transition leaves Delivered, and no endpoint reverses a hand-off. A mistaken hand-off is an operational event that needs an explicit recovery flow, and silently appending the opposite fact to an order the customer may already have taken would corrupt the audit trail the history exists to provide.

The UI offers one full-width primary forward control per detailed slip and, for Preparing and Ready, a full-width outlined backward correction. The primary button advances Queued to Preparing, Preparing to Ready, or hands a Ready order over, after which the slip is no longer part of the queue at all rather than showing a spent control. The button is disabled while its own request is in flight, which affects only that slip. On success the queue read is invalidated immediately rather than waiting for the 15-second poll, so the slip shows the new state at once and a delivered order disappears with the count already corrected. There is no optimistic state rewrite: the server read remains authoritative, and a rejected conflict leaves the current rendering in place with a plain translated notice until polling reconciles it. Live push remains out of scope.

The explicit detailed state selector is a modal dialog, not a gesture. Each detailed slip also carries one full-width text control that opens KitchenStateOptions, a dialog listing the whole fulfillment vocabulary: the order's current state is marked aria-current, every state reachable by a named operation of that current state is an enabled choice, and every other state is rendered as an aria-disabled row naming why it is unavailable. A dialog was chosen over a long-press or swipe because a gesture hides the choices from exactly the keyboard and assistive-technology users who have to read them; focus moves into the dialog, Escape and the backdrop close it, and focus returns to the control that opened it. An unavailable row is shown rather than hidden, so the operator learns which states exist and that this order may not enter them, instead of wondering whether the selector is incomplete — and activating it does nothing at all, so no request is sent and no transition is guessed.

The selector adds no new transition path. KitchenQueueCard renders the fast controls, the correction, and the selector's choices from one exhaustively typed stateOperationsByStatus table keyed by the active-status union, and every enabled choice calls the very same onAdvance or onCorrect callback the corresponding fast control calls. It therefore cannot offer a transition the fast controls would refuse, and it is not a state editor: there is no input that names a target status, and the server's conditional update remains the only thing that decides whether a transition applies. Choosing a state closes the dialog immediately so the slip stays the single place that reports the pending and the resulting state. The compact overflow entries are unchanged: they own no controls, so the selector exists only where a detailed slip does.

Finalized order boundary

Both finalization paths share one use case, one request shape, and one transactional persistence boundary; they differ only in whether commercial checkout is invoked. There is no parallel commercial order type. OrderService validates a non-empty, duplicate-free selection, positive whole quantities, nullable bounded metadata, and an explicit fulfillment choice identically for both paths, then generates the internal order UUID. PostgresOrderRepository.create(id, input, checkout) reads all selected products and inserts the orders row, the append-only order_finalizations fact, every order_items snapshot, and any confirmed checkout attempt in one PostgreSQL transaction. A missing product returns no order, so partial headers, partial item sets, and orphaned finalization facts cannot commit. The durable classification is derived from that same call: an attempt is present only after a confirmed payment, so its presence is what makes the order commercial and its absence keeps it nonCommercial.

The order row records initial status = Queued, optional queue display string and note, fulfillment mode, and database creation time. Queue display strings are neither parsed nor unique. Item rows capture product UUID, customer name, effective kitchen label, unit gross minor amount, VAT basis points, and quantity. They do not reference the mutable catalog row. A database trigger rejects item updates and deletes, making finalized contents and snapshots immutable even outside the application repository. There is no item/history mutation API.

Durable snapshots and significant history

orders is current state, not history. Its status is rewritten by every fulfillment transition, and from MVP-TASK-024 its queue_number and note are edited in place. A column that is expected to change cannot be the record of what an order originally was, so history lives beside the order rather than inside it. This is normal current state plus sufficient append-only facts, not event sourcing: there is no event log, no aggregate rehydration, and no replay path.

Four durable fact tables back a finalized order, and each is protected by its own BEFORE UPDATE OR DELETE trigger so a correction must append rather than replace, even from a caller outside the application repository:

OrderRepository.findFinalization reads the finalization fact back and OrderService.finalizationHistory exposes it as the application use case; it resolves to null for an unknown identity rather than inventing a record, and it mutates nothing. findCheckoutAttempt returns the recorded time alongside the other attempt facts. Neither read is exposed over HTTP and there is no history or search surface: the KDS still needs none, and a future statistics surface will query these durable facts directly.

Product edits and product deletion cannot rewrite history because no finalized fact is derived from the catalog after finalization. Delivery cannot either: Ready → Delivered appends a transition and moves the order to a state the active-queue read excludes, so the order leaves the operator's view without any row being removed.

Commercial finalization

OrderService.finalizeCommercial sequences price, charge, then one atomic commit. PostgresOrderRepository.grossTotalMinor prices the selection from the current catalog first, so an unavailable product, an empty cart, a free cart, or a total outside the chargeable integer range is rejected as a plain finalization error before any provider is contacted. The service then calls CheckoutService.charge with the internal order UUID reused as the stable application-owned operationId — one identity for the internal order and its checkout operation, never a provider identifier and never the queue number. Only a confirmed succeeded result reaches persistence. rejected and cancelled are definitive provider decisions that certainly captured no money, so they raise OrderCheckoutDeclinedError and create no order, no sales contribution, and no attempt fact; the HTTP layer reports that as 409 with the stable code CHECKOUT_NOT_PAID, and the operator may state that continuing is safe (MVP-TASK-028).

unknown is the third case and is deliberately not a flavour of either the refusal or the generic incomplete error. Before reporting it, OrderService.finalizeCommercial records it through OrderRepository.recordUnresolvedCheckoutAttempt and then raises OrderCheckoutUnresolvedError, which HTTP reports as 409 CHECKOUT_UNRESOLVED (MVP-TASK-029). If that record cannot be written the persistence failure propagates rather than being reported as a clean unresolved result, because losing the fact while claiming it was kept would be the silent downgrade the case exists to prevent.

A confirmed charge that could not be committed is the remaining case and raises OrderCheckoutIncompleteError. Keeping the three separate is what lets the copy differ honestly: a refusal may promise "unpaid and safe to pay again", a confirmed-but-uncommitted charge may promise nothing, and an unresolved attempt must claim neither and must not be retried.

order_checkout_unresolved_attempts records what is not known. It holds the application-owned operationId (unique), the unknown outcome, the provider's optional providerReference, the gross amount that was requested, the currency, and the database time. It has no foreign key to orders and is protected by the same append-only trigger as the other fact tables. The absence of an order is the mechanism, not an omission: because no order, no finalization fact, and no item snapshot exist, an unresolved attempt cannot enter the kitchen queue and cannot join a commercial sales figure, which is reconstructed from commercial finalizations joined to item snapshots (MVP-SALES-001-S07). A database check constraint restricts the stored outcome to unknown, mirroring the succeeded constraint on order_checkout_attempts.

Nothing resolves, retries, or settles such an attempt. Reconciliation semantics do not exist yet, so no repository or HTTP operation attempts one, and the KDS disables paying again for the affected draft. The read-only GET /api/checkout/unresolved exists so the uncertainty survives a reload, a second screen, and a restart; a store that has never had an indeterminate attempt simply returns an empty list.

The confirmed attempt is written to order_checkout_attempts in the same transaction as the order and its items, so a committed attempt and a queued commercial order cannot diverge. The row keeps the application-owned operationId (unique), the succeeded outcome, the provider's optional providerReference, the charged grossAmountMinor, and the currency, all associated with the internal order identity through its primary/foreign key. A database check constraint restricts the stored outcome to succeeded, so no other outcome class can ever be durable. A provider reference proves that an attempt exists; it is never proof of payment and never changes the outcome.

Commercial sales aggregation selects only classification = commercial; the durable classification therefore excludes non-commercial gross amounts and product quantities by construction, and a confirmed commercial order contributes exactly once through its own immutable snapshots. This slice exposes the current-day gross ticker by summing those snapshots once per finalized order. PostgreSQL derives a half-open local-day interval from its clock and STORE_TIME_ZONE; fulfillment state and session-local state do not participate.

The same read also groups the day's snapshots by order_items.product_id and returns one per-product quantity beside the gross. Identity is the product UUID, never a display value, so a catalog rename, relabel, or reprice relabels an existing line instead of splitting one product's quantity into unrelated counters; the browser is given productId alongside the label so it cannot regroup by text either. The displayed name and kitchenLabel come from the product's latest snapshot inside the day, ordered by finalized_at DESC, order_id DESC: that is a deliberate display policy — a mid-day rename shows the newest name the kitchen actually used while the summed quantity still covers every earlier sale — and the order_id tie-breaker only keeps it deterministic when two orders share a now() timestamp. kitchenLabel is the effective kitchen-facing label, resolved once at finalization as kitchenLabel ?? name, so the label preference and the name fallback are decided in one place rather than re-derived per consumer. The headline total and the breakdown are produced by that single grouped read, so they can never disagree even when an order is finalized mid-request; a day with no commercial sale is a zero total and an empty breakdown, never a list of zero rows. Rows are ordered by descending quantity with product_id as the tie-breaker, and the HTTP layer adds no ordering and the browser does not re-sort. A sales history screen remains a later slice.

Crash and atomicity assumption (requires follow-up). The provider charge necessarily happens outside the database transaction, so the charge and the commit are two separate durable effects. A process crash after a confirmed charge but before the commit — or a product deleted between pricing and the commit — leaves captured money with no queued order and no attempt row. The service reports OrderCheckoutIncompleteError rather than claiming a finalized sale, so the failure is visible and never a false paid order, but nothing durably records the unresolved attempt yet. Reconciling that window (a durable unresolved-attempt record and a safe retry policy) belongs to MVP-TASK-029 and MVP-TASK-030 and must not be treated as solved by this slice.

Checkout boundary

Commercial checkout is expressed through the narrow, provider-neutral CheckoutGateway contract in backend/src/application/checkout.ts. The request carries a stable application-owned operationId, a gross integer minor-unit amount, and the single supported currency. It deliberately carries no line items, VAT breakdown, payment method, customer reference, or provider field, because MVP-TECH-002 discovery has not established that any provider supports them; adding them later must not be a guess. The result is one of four provider-neutral outcome classes — succeeded, rejected, cancelled, and unknown — plus a providerReference that is null when no stable reference is available. A confirmed adapter may also return the original provider payment reference and neutral payment method; those facts are persisted with the confirmed attempt for the refund flow. Missing facts on legacy orders are not reconstructed or guessed, and make those orders ineligible for refund. A reference proves that an attempt may exist; it is never proof that money was captured and never changes the outcome.

CheckoutService in backend/src/application/checkout-service.ts owns the boundary. It validates the neutral request before any adapter is contacted, delegates to the injected CheckoutGateway, and normalizes the result. A thrown adapter failure, a malformed adapter result, or an outcome outside the contract all become an explicit unknown result, never a success and never a silent definitive failure. The service holds no persistence: order finalization, sales contribution, and the durable attempt record belong to the finalization slice that calls it.

backend/src/integrations/ holds every adapter and the single composition point, createCheckoutGateway, which maps validated configuration onto an adapter. The deterministic FakeCheckoutGateway returns the configured outcome with no network access, timing, or randomness, and derives its stable reference from the operation identity, so local and automated acceptance are reproducible without a provider account. It additionally accepts optional amount-keyed refusals, so one deterministic process can serve a definitively declined checkout and a confirmed sale in the same run; a refusal is a pure function of the requested gross total, never of call order, and both settings are absent unless an acceptance run opts in. CHECKOUT_MODE=disabled resolves to UnavailableCheckoutGateway, which reports unknown because no provider was contacted; that keeps unavailable checkout visible and incapable of creating a false paid order. Hosted staging and production configurations reject CHECKOUT_MODE=fake at startup, so the fake cannot be enabled by accident. Discovery is complete: ADR 004 records the dated Surfboard evidence, the environment topology, and the two-port design — the existing neutral payment port plus a smallest separate fiscal port (FiscalGateway) for register state, shift lifecycle, end-of-day, and receipt facts. MVP-TASK-034 now contains the repository-side payment adapter and signed webhook ingress, but remains blocked on its human-owned Demo/ECR prerequisites and real smoke evidence; no fiscal adapter or receipt write is claimed. The selected topology is deterministic fake for local/test and automated acceptance, Surfboard Demo for staging, and the same provider adapter with independent Live configuration for a future production release — configuration, never a source edit. Surfboard payment, ECR, receipt, terminal, and webhook payloads remain in backend/src/integrations/; payment and fiscal capabilities cross into application code only as neutral requests, outcomes, references, and lifecycle facts. Provider base URLs, keys, secrets, merchant/store/terminal/register identifiers, and webhook secrets are server-side deployment configuration, never source constants, browser data, or log fields. Card data never enters the application.

Refund-operation persistence foundation

Epic 007 adds the provider-neutral refund contracts in backend/src/domain/refund.ts and a durable claim/result boundary in RefundOperationRepository. PostgresRefundOperationRepository locks the order while checking commercial classification and the confirmed checkout references, so concurrent claims serialize before any application caller may contact a provider. A partial unique index also prevents more than one pending, unresolved, or successful operation per order. Pending and unknown operations block re-issuance; a definitive refusal permits a new numbered attempt. The refund operation record stores only neutral reasons/outcomes, stable provider references/error codes, and database timestamps.

Only a confirmed successful result changes current order status to terminal Refunded; that update, the operation result, and the appended fulfillment transition are committed in one database transaction. The order's original fulfillment history is retained, and Refunded remains outside active queue reads. Unknown outcomes persist without completing the operation and cannot be retried as a new money-moving request.

RefundApplicationService owns issuance and status resolution. It validates the provider-neutral reason, asks the repository to atomically claim the order and return immutable checkout/line facts, then calls the gateway associated with the original checkout. When a provider reference becomes available, the service persists it before waiting for a terminal provider result. Repeated claims reuse the existing durable operation; they do not call the money-moving adapter again. Resolution uses only the status-lookup port and an already-persisted provider reference, so it cannot issue a second refund.

The authenticated POST /api/orders/{orderId}/refunds route is protected by the same Origin/CSRF mutation guard as other state changes. Authenticated GET reads an operation, while the separate protected /resolve POST performs status-only reconciliation. HTTP inputs cannot choose a provider, amount, payment reference, or order contents. Public operation responses omit provider references and raw provider messages; only stable error codes are retained.

The durable order-history read includes both delivered and refunded terminal orders. It appends redacted refund attempt facts to the original fulfillment and checkout history, omitting provider payment/order references and payment method. deliveredAt is nullable for an order refunded before delivery. The OpenAPI schema and frontend history projection follow that same redaction boundary.

The active queue read also supplies a server-computed refund-eligibility flag and a redacted summary of the latest refund operation. Eligibility requires a commercial order, confirmed checkout, supported original payment facts, and an exact match between the recorded total and immutable item snapshots. Provider references never enter the queue response. The KDS renders the refund action only inside the order's detailed state-options dialog, after explicit confirmation; the fast fulfillment controls remain refund-free. Pending and unknown attempts suppress new issuance, and a resolvable attempt can call only the status-resolution endpoint. Confirmed success invalidates both queue and history reads; the order stays visible until the server confirms the refunded state on its next queue read.

The delivered-history read uses the same server-side eligibility policy and safe operation summary. Only a delivered commercial order that is not already refunded and has complete supported payment facts can offer Refund, and the control exists only while its details are expanded. Refunded history remains durable: its gross total is visibly struck through with an accessible refund icon, a prominent message identifies the status, and the timeline includes the confirmed refund time. History mutations invalidate that read so the order remains visible with its authoritative refunded state.

Transactional email boundary

Application use cases depend on the narrow EmailSender contract in backend/src/application/email.ts. The Resend adapter and its single injected outbound HTTP transport live in backend/src/integrations/; server.ts selects disabled, real, or local Mocking Bird transport at startup. The browser cannot select a provider destination, sender, headers, or body. Mocking Bird intercepts only registered email traffic at that seam and stores a redacted, bounded summary. Its synthetic preview is a separate capped local/test memory collection restricted to configured fixture recipients. Neither collection is persisted.

The Mocking Bird console is a normal authenticated page declaration compiled only into explicitly enabled developer builds. Its frontend flag controls bundle and navigation visibility only. The backend independently registers console endpoints only for local/test mock mode with explicit Mocking Bird enablement. Reads expose bounded safe projections; Origin, CSRF, authentication, and rate limits protect scenario/reset mutations. Logout and route cleanup cancel and remove console queries under the authenticated TanStack Query root.

Password recovery is an application use case behind a dedicated repository contract. It generates a 256-bit opaque credential with Node cryptographic randomness, persists only its SHA-256 digest, and composes the browser link from validated APP_PUBLIC_URL; request headers never influence the link. The raw value is present only in the URL fragment and the deliberately guarded local/test synthetic preview. The reset page captures it into component memory, removes the fragment with replaceState before rendering, and never places it in query state, Redux, or browser storage.

PostgreSQL holds at most one reset row per user: issuance locks the user, verifies the credential version, and replaces any earlier row. Password change locks/mutates the same user and deletes reset rows. Reset locks in the same user-first order, then atomically marks the valid unexpired row used, replaces the Argon2id hash, increments the credential version, and deletes every session. This makes issuance safe against concurrent password mutation and permits only one simultaneous consumer. Request responses use one generic acknowledgement and a minimum asynchronous padding budget across known/unknown outcomes. This reduces simple enumeration signals but cannot equalize provider, database, network, process scheduling, or multi-instance limiter behavior exactly.

Identity and session boundary

Global users and revocable sessions are persisted through PostgreSQL/Drizzle. The application user service owns login-address normalization, password policy, Argon2id hashing and safe user mapping. Fastify owns cookie signing, the PostgreSQL session-store adaptation, CSRF/origin enforcement and HTTP translation. Session rows contain only plugin/session state, a user ID, and a credential version; credential hashes never enter session data or HTTP DTOs. Current-user lookup always rechecks that the durable user still exists, is enabled, and has the session's credential version. Password replacement conditionally updates the still-current hash, increments that version, deletes every account session, and invalidates reset credentials in one concrete repository transaction. Authenticated session writes revalidate the credential version, so a late login/session save cannot recreate a valid revoked session. Identity does not imply business ownership, tenancy, membership, role, or authorization. See ADR 002.

registerAuthHttp is the Fastify composition point for authentication HTTP concerns. It delegates session, account, recovery, and developer-email routes to internal route-family registrars, each typed only with the collaborators it consumes; product and order routes remain in their existing registration path. Shared session authentication, origin enforcement, account-attempt throttling, and session-save behavior live in one auth HTTP support module where route families require the same semantics. The registrars are implementation details, not package API, and composition order preserves the existing hook, route, cookie, and diagnostics behavior.

1. Architecture baseline

This document defines the software architecture of the Foodtruck Order application.

It describes:

This document is an architectural source of truth.

Implementation details that are likely to change should not be documented here unless they have architectural significance. Technology choices and their rationale are documented separately in docs/techstack.md.


2. Architectural Goals

The architecture should support the following goals:

  1. Simple development

    • The system should be understandable by a small development team and by AI coding agents.
    • Common functionality should follow predictable patterns.
  2. Clear separation of concerns

    • UI, HTTP, application logic, persistence, and infrastructure concerns should not become unnecessarily coupled.
  3. Incremental development

    • Features should be implementable as vertical slices.
    • The architecture must work for a very small application and scale as more functionality is added.
  4. Testability

    • Business and application logic should be testable without requiring the entire system.
    • Integration with external systems and infrastructure should be testable separately.
    • Important user workflows should be testable end-to-end.
  5. Replaceability

    • Infrastructure-specific details should not unnecessarily spread throughout the application.
    • Replacing a database implementation, external service, or UI implementation should not require rewriting unrelated business logic.
  6. Operational simplicity

    • The initial system should avoid unnecessary infrastructure.
    • Additional infrastructure should be introduced only when there is a demonstrated need.
  7. AI maintainability

    • Architectural boundaries should be explicit enough that AI coding agents can reliably determine where new functionality belongs.

3. High-Level System Architecture

The application consists of three primary runtime components:

┌──────────────────────────────────────────┐
│                 Browser                  │
│                                          │
│              React Frontend              │
└────────────────────┬─────────────────────┘
                     │
                     │ HTTP / JSON
                     ▼
┌──────────────────────────────────────────┐
│                Backend                   │
│                                          │
│  API → Application → Repository          │
│                                          │
└────────────────────┬─────────────────────┘
                     │
                     │ Database protocol
                     ▼
┌──────────────────────────────────────────┐
│                PostgreSQL                │
└──────────────────────────────────────────┘

Additional infrastructure or external services may be added later.

Examples include:

These are not part of the architectural requirements of the first vertical slice.

The architecture should not introduce such components until a concrete requirement justifies them.


4. Repository Architecture

The project is a pnpm monorepo.

The repository contains separate applications and shared project infrastructure.

The expected high-level structure is:

/
├── frontend/
├── backend/
├── packages/db/
├── docs/
├── tests/
├── docker/
├── .github/
├── package.json
├── pnpm-workspace.yaml
└── ...

The exact directory structure may evolve.

The important architectural rule is that directory organization must reflect responsibility and dependency boundaries.

The following principles apply:

If the project later introduces shared packages, they should have a clearly defined responsibility and dependency direction.

A shared package must not become a dumping ground for unrelated functionality.


5. Backend Architecture

The backend uses explicit logical layers. Runtime calls pass from HTTP to the application service and repository implementation. Source dependencies point at application/domain contracts; domain code does not import infrastructure. The composition root in backend/src/server.ts wires implementations.

The intended dependency direction is:

HTTP/API -> Application -> Domain rules (when needed)
                 |
                 v
          Repository contract
                 ^ implements
                 |
      Infrastructure implementation -> Database / External Systems

However, the project should not introduce domain abstractions merely for the sake of having a "domain layer".

For simple functionality, application and domain logic may be colocated when that keeps the implementation clearer.

The important requirement is separation of responsibilities, not the number of directories.


The documented boundaries have a blocking executable baseline in pnpm architecture:check. ESLint rejects frontend production imports of backend/database code; application/domain imports of Fastify, provider adapters, or PostgreSQL implementations; and page/component use of global fetch or raw API request/base-URL functions. It deliberately permits composition-root wiring, HTTP adapters, provider/repository implementations, feature-level frontend API operations, and test fakes. This narrow gate supplements rather than replaces review of responsibilities and dependency direction.

5.1 API / HTTP Layer

The API layer is responsible for communication with HTTP clients.

Responsibilities include:

The API layer must not contain substantial business logic.

It must not directly perform arbitrary database operations.

For example, avoid:

HTTP route
    ↓
SQL query

Prefer:

HTTP route
    ↓
Application service
    ↓
Repository
    ↓
Database

HTTP-specific concepts such as request/response objects, status codes, and HTTP headers should not leak into the domain/application logic unless there is a compelling architectural reason.


5.2 Application Layer

The application layer coordinates application use cases.

Responsibilities include:

The application layer should describe what the application is trying to accomplish rather than how HTTP or SQL works.

For example:

GetCurrentOrder
CreateOrder
UpdateOrderStatus
ListProducts

are appropriate application-level concepts.

The application layer should not need to know whether the request came from:

Those are interface concerns.


5.3 Domain Logic

Domain logic represents rules that are intrinsic to the foodtruck ordering domain.

Examples that may eventually belong here include:

Domain logic should not depend on:

Not every operation needs a domain object or domain service.

Simple CRUD functionality does not automatically require a complex domain model.

Introduce domain abstractions when they clarify and protect actual business rules.


5.4 Data Access / Repository Layer

The repository/data-access layer is responsible for persistence.

Responsibilities include:

Database-specific implementation details must remain within this boundary as much as reasonably possible.

Application logic should not contain raw SQL or depend directly on database client implementation details.

The repository layer should expose operations meaningful to the application rather than exposing the entire database API.

For example:

getOrderById(id)
createOrder(order)
updateOrderStatus(id, status)

is preferable to exposing arbitrary database operations throughout the application.

Order persistence keeps one OrderRepository contract and one PostgresOrderRepository adapter. Independent active-queue and current-day-sales projections live in the adapter's private postgres-order-read-models.ts query module; this does not create new application capabilities. Keep writes whose invariants commit together inside the transactional adapter: finalization with its snapshots and confirmed checkout fact, status with transition history, and metadata with change history. Checkout-operation claims remain committed before provider calls, and unresolved attempts remain independent facts with no order. Do not split these capabilities into repositories per table or a public read/write topology without evidence that ownership changes justify it.


6. Frontend Architecture

The frontend is responsible for the user interface and interaction with the backend API.

At a high level:

UI Components
     ↓
Pages / Features
     ↓
API / Data Access
     ↓
HTTP
     ↓
Backend API

The exact frontend structure may evolve as the application grows.

The frontend should separate:

React components should not become containers for arbitrary networking and business logic.

Prefer a structure in which API communication is centralized or encapsulated in an appropriate frontend data-access layer.

For example:

OrderPage
    ↓
orderApi
    ↓
HTTP
    ↓
Backend API

rather than having multiple unrelated components independently implement HTTP requests.

The current implementation makes this boundary concrete with two parts:

Pages and components call the endpoint functions. They must not call fetch, construct backend URLs, inspect HTTP status codes, or parse raw responses themselves. Endpoint operations explicitly choose JSON or 204 no-content transport; a 204 is never cast to an arbitrary response type. The API boundary does not depend on React or backend implementation details. Successful JSON bodies are not runtime-schema validated, but invalid JSON is normalized as ApiResponseError.

Order HTTP routes retain one registerOrderHttp composition point and delegate to internal registrars for active queue/fulfillment, finalization, sales, and unresolved checkout. Each registrar receives only the order-service operations and HTTP collaborators it needs. The frontend mirrors those capabilities in active-order-api.ts, order-finalization-api.ts, sales-api.ts, and unresolved-checkout-api.ts; each query key stays beside its corresponding read. These are application-level module boundaries, not a shared frontend/backend contract package.

6.1 Frontend state and localization ownership

TanStack Query is the sole owner of API/server state, including the database-backed message and the current user under the stable auth/me query key. User-owned feature queries use the explicit authenticated key root. Login/logout cancel session-owned work before identity transitions; private data is removed immediately, and confirmed transitions also remove current identity. Thus a late response from an old user cannot restore private cache data. A session 401 clears private cache data and transient CSRF state; transport/server failure remains a recoverable error rather than being represented as logout. Redux Toolkit owns device and currently applied language (en, sv, or hu) and appearance (light, dark, or system) values, but never a duplicate user DTO. Only device values are persisted in localStorage. Anonymous startup uses stored device, supported browser language, then default values. When authenticated, each non-null account value from the TanStack current-user DTO overrides its device value; null keeps the device value. A current-user refetch reapplies this merge in the background. Confirmed logout/password change restores device values, so one account's choices never become another account's device defaults. AppProviders resolves system appearance changes and supplies the matching central MUI theme and reset. Local UI state such as drawer visibility and form drafts stays in components or a narrow React context. Server responses, request status, errors, credentials, and current-user data must not be copied into Redux or localStorage.

Transient notifications use the narrow notification context in AppProviders; a new notification replaces the current one rather than creating a global event queue. Expected query failures remain inline and are not duplicated in a snackbar. Shared loading, empty, inline-error, validation-summary, and field-error components provide distinct semantics. Validation summaries focus the first invalid labeled field through a caller-controlled field-ID mapping. A localized React error boundary and React Router error surface provide safe recovery for unexpected rendering/route failures.

AppProviders composes isolated Redux, i18next, and TanStack Query instances. Redux is authoritative for the effective language; one provider synchronization component applies it to i18next and the document language/title without dispatching back to Redux. i18next resources are bundled with the frontend, use stable semantic keys, and fall back to English. Database/backend content crosses the API boundary unchanged and is not translated by the browser.

Locale resources are grouped first by locale (en, sv, hu) and then into matching feature-oriented source modules (shell, auth, products, kds, developer, shared). Locale indexes compose these branches into the existing single i18next translation namespace in its stable key order; resources.ts composes the locale set and retains the TranslationResources and TranslationKey types. These files are eagerly bundled and do not change fallback behavior or translation keys.

6.2 Shell, theme, and page registration

The mobile-first shell uses MUI and Emotion. One static identity configuration supplies the product name, fallback mark, base document title, Azeret Mono font family, and theme palette inputs; one theme factory owns palette, typography, spacing, focus, touch, and reduced-motion defaults. Font files are self-hosted through Fontsource. Pages consume theme values rather than distributing brand constants.

frontend/src/app/pages.tsx is the single typed page declaration. It supplies route path, localized label key, optional navigation icon/visibility, route element, access metadata, and optional developer-only metadata. React Router routes and visible navigation are derived from it. Hidden, detail, and fallback routes remain declarations without menu entries; menu visibility is not an authorization boundary.

Authenticated declarations are nested under a small React Router guard. It resolves /auth/me before rendering protected content, redirects a known unauthenticated session to the hidden public /login page with a validated same-origin return destination, and renders recoverable session failures separately. The shell filters authenticated navigation entries from the same declaration; the backend remains the authorization authority. Settings is protected while Home and unknown-route handling remain public.

The shell uses a temporary modal drawer below MUI md and a permanent, offset drawer from md upward. The main document owns page scrolling. Drawer state stays local, while route changes close mobile navigation and move focus to the main landmark.

The product page keeps create and edit components in frontend/src/pages/ProductForm.tsx, with shared required-field and server-issue mapping in the adjacent product-form-validation.ts. Component exports retain their descriptive CreateProductForm and EditProductForm names. Local presentation constants use lower camel case; uppercase is reserved for exported invariants.


7. Frontend/Backend Boundary

The frontend communicates with the backend through the documented API.

The backend is the authoritative source for business data and business rules.

The frontend may:

The frontend must not be treated as the authoritative implementation of business rules.

If a rule affects the correctness of an order or other business operation, the backend must enforce it.

Frontend validation may still be used for usability.

For example:

Frontend:
    "The user cannot currently submit an empty form."

Backend:
    "An order cannot be created without at least one valid item."

The backend rule must remain valid regardless of which client sends the request.


8. API Contract

The API is a contract between the frontend and backend.

API behavior should be:

The API should use appropriate HTTP semantics and structured data formats.

The initial vertical slice should establish the project's conventions for:

These conventions should be documented more extensively if the API grows.

8.1 GET /api/message — retrieve the vertical-slice message

The first vertical slice establishes the concrete API contract below.

Successful response:

Relevant error responses (all in the standard API error format from §13):

The contract is covered by automated API tests (backend/src/app.test.ts) and by a real-database integration test that exercises the complete path HTTP → application service → repository → PostgreSQL.

The authoritative machine-readable contract for this endpoint is the version-controlled OpenAPI 3.1 document docs/api/openapi.yaml; the description above is its human-readable summary, and Swagger UI renders it in the generated documentation site. An automated contract test (backend/src/openapi-contract.test.ts) keeps the document and the implemented routes in agreement. Tooling and update workflow: docs/documentation.md.

8.2 GET /health and CORS

Anonymous backend GET /health returns 200 {"status":"ok"}. It checks HTTP process liveness only, without database/seed access. The local gateway routes only /api/* to backend; gateway /health reaches frontend SPA fallback, so backend liveness must be checked at the backend origin/container.

Optional @fastify/cors allowlisting is configured from validated CORS_ALLOWED_ORIGINS. Unset/empty disables CORS; configured exact origins permit GET/HEAD and Accept/Content-Type without credentials. CORS is not authentication. Details belong in development §6.1 and OpenAPI. Unknown /api and /api/* routes use the safe standard 404 error body. Non-API backend 404 behavior and the gateway-owned SPA fallback remain unchanged.


9. Database Architecture

PostgreSQL is the primary application database.

The database is responsible for durable application state.

Database schema changes must be reproducible.

Schema changes should be represented through migrations or the equivalent mechanism selected by the project.

A new developer or CI environment should be able to create the database schema from a clean database without manually reproducing historical changes.

The database is not treated as a general-purpose application logic layer.

Business rules should not be implemented in database triggers, stored procedures, or other database-specific mechanisms unless there is a clear architectural reason to do so.

Database constraints should nevertheless be used where they provide strong data-integrity guarantees.

For example:

Database integrity and application business logic are complementary rather than mutually exclusive.


10. External Services

External services are infrastructure dependencies.

Examples include:

External-service-specific APIs must not become pervasive throughout the application.

Where an external service represents an important application capability, the backend should introduce an appropriate abstraction around it.

Conceptually:

Application
    ↓
Payment capability
    ↓
Payment provider adapter
    ↓
External provider

rather than:

Application
    ↓
Surfboard-specific API calls everywhere

The exact abstraction should be proportional to the complexity of the capability.

Do not create elaborate abstractions for integrations that do not yet exist.


11. Real-Time Communication

The product vision includes functionality where order status changes may need to be visible on other screens.

Real-time communication is therefore a future architectural concern.

It is not required for the first vertical slice.

When introduced, the implementation should preserve the existing architectural boundaries.

For example:

Order state change
        ↓
Application layer
        ↓
Persistence
        ↓
Real-time notification mechanism
        ↓
Connected clients

The application should not make business logic dependent on a particular browser communication mechanism.

The exact technology for real-time communication should be selected when the requirement is implemented.


12. Dependency Rules

Dependencies must point toward stable, higher-level application concepts rather than causing business logic to depend directly on infrastructure.

The following conceptual dependency direction should be preserved:

Browser frontend -- HTTP contract --> Backend API
                                         |
                                         v
                                  Application -> Domain
                                         |
                                         v
                                  Repository contract
                                         ^ implements
                                         |
                                  Infrastructure -> Database

The following are prohibited unless explicitly justified:

Domain → HTTP framework
Domain → React
Domain → PostgreSQL client
Domain → Docker
Domain → external provider SDK

Similarly:

Frontend component → direct SQL
Frontend component → backend database

must never occur.

The goal is not to eliminate every dependency. The goal is to prevent infrastructure concerns from spreading into unrelated parts of the system.


13. Error Handling

Errors should be handled at the layer where they can be meaningfully interpreted.

For example:

Database error
    ↓
Repository
    ↓
Application-level interpretation
    ↓
API-level HTTP response

The API layer is responsible for converting application errors into HTTP responses.

Internal implementation details such as SQL statements, stack traces, or database credentials must not be exposed to clients.

The system distinguishes the currently handled errors and should preserve these categories as new behavior is introduced:

The Message API establishes the project's API error-handling convention. Future API endpoints follow the same pattern:

The frontend maps known codes to bundled translations and uses a safe generic fallback for unknown, legacy, or malformed bodies. UI code never displays arbitrary server error/issue messages or field values. Business data such as the database message remains literal. Unexpected React/route and transport failures use one sanitized console reporter containing only category, public app version, and a bounded correlation ID when available.


14. Configuration and Environment

Configuration must be externalized from application code.

Examples include:

Secrets must never be committed to the repository.

Development, test, CI, staging, and production environments may use different configuration values, but they should follow the same application configuration model wherever practical.

Environment-specific behavior should not be hidden in arbitrary application code.


15. Testing Architecture

Testing follows the architectural boundaries.

The test strategy is documented in docs/test-strategy.md.

The conceptual testing structure is:

Unit tests
    ↓
Component/module tests
    ↓
Integration tests
    ↓
API tests
    ↓
End-to-end tests

Each level has a different purpose.

Unit tests

Verify isolated logic without requiring the complete application.

Component/module tests

Verify interactions within an application component where isolated unit tests are insufficient.

Integration tests

Verify that real components work together.

Examples include:

Backend
    ↓
Repository
    ↓
PostgreSQL

API tests

Verify the externally visible backend HTTP contract.

End-to-end tests

Verify important user-visible workflows against a running system.

The testing strategy should avoid duplicating the same test at every level without reason.

The lowest practical test level should normally be preferred for isolated behavior.


16. First Vertical Slice

The first architectural milestone is deliberately minimal.

Its purpose is to prove that the complete architecture works.

The first slice should follow this flow:

PostgreSQL
    ↓
Repository
    ↓
Application service
    ↓
API endpoint
    ↓
Frontend API client
    ↓
React page
    ↓
Browser

The feature itself can be trivial.

For example:

Database:
    Message(id, text)

API:
    GET /api/message

Frontend:
    Displays the message

The value of this feature is not the message itself.

The value is proving that:

No payment functionality, external integration, authentication, multi-device functionality, or significant business logic is required for this milestone.


17. Vertical Slice Principle

The project should generally be developed through vertical slices.

A vertical slice should cross all application layers necessary to deliver a coherent piece of behavior.

For example:

Feature
  │
  ├── Database schema
  ├── Repository
  ├── Application logic
  ├── API
  ├── Frontend data access
  ├── UI
  ├── Tests
  └── Documentation

Avoid implementing an entire architectural layer before implementing any real functionality.

For example, avoid:

Build entire database layer
        ↓
Build entire backend
        ↓
Build entire frontend
        ↓
Finally connect them

Prefer:

Tiny feature
    ↓
DB → Backend → API → Frontend
    ↓
Tests
    ↓
Docker
    ↓
CI

and then repeat this pattern for subsequent functionality.

This approach provides earlier feedback about architectural problems.


18. Simplicity and Abstraction

The architecture should be as simple as possible while preserving useful boundaries.

Do not introduce abstractions solely because they are common in architectural patterns.

For example, a trivial operation does not automatically require:

Controller
→ UseCase
→ Service
→ DomainService
→ RepositoryInterface
→ Repository
→ Mapper
→ Entity
→ DTO

if most of these provide no meaningful separation.

Instead, introduce abstractions when they solve a concrete problem such as:

The architecture should evolve when the requirements demonstrate the need.


19. Architectural Decision Records

Significant architectural decisions should be recorded in docs/decisions/.

An architectural decision record should normally contain:

Context
Decision
Rationale
Consequences

Examples of decisions that may deserve an ADR:

Do not create an ADR for every small implementation choice.

The purpose of ADRs is to preserve significant architectural reasoning that would otherwise be lost.


20. Architecture Evolution

This architecture is intentionally designed to evolve.

The initial vertical slice is not expected to contain every future architectural component.

Future requirements may introduce:

When these requirements appear, extend the architecture based on the actual problem.

Do not introduce infrastructure in anticipation of hypothetical future requirements unless the expected cost of introducing it later clearly justifies doing so.

Architectural complexity should be driven by demonstrated requirements.


21. Source of Truth

The following documents have distinct responsibilities:

Document Purpose
AGENTS.md AI-agent rules and high-level project conventions
docs/architecture.md System architecture and architectural boundaries
docs/techstack.md Technology choices and rationale
docs/development.md Local development and development workflow
docs/definition-of-done.md Completion requirements and proportional validation
docs/test-strategy.md Testing strategy and test technologies
docs/ci.md CI pipeline and quality gates
docs/docker.md Docker and containerization
docs/current-state.md Current implementation state and next steps
docs/decisions/ Significant architectural decisions

When information belongs in one of these documents, do not duplicate the complete explanation in another document.

AGENTS.md may summarize a rule and point to this document, but this document is the authoritative source for the detailed architecture.