Development Guide

Purpose and workflow map

This guide is the operational entry point for setting up, running, changing, and validating the repository. Start with prerequisites and repository setup, then use the application-area sections for daily work. Feature-specific operational recipes are grouped at the end of this introduction so they do not obscure the normal setup path. Two features carry extra conceptual weight and each has its own primer there: payments and commercial checkout and Mocking Bird email interception.

Architecture and dependency rationale belong in architecture.md, test-level selection in test-strategy.md, CI mechanics in ci.md, container details in docker.md, and completion requirements in definition-of-done.md.

Feature-specific local operations

Payments and commercial checkout

Taking money is the riskiest thing the application does, so checkout is deliberately isolated behind one narrow seam. The rest of the system never talks to a payment provider directly; it depends on a single neutral interface and lets configuration choose which provider sits behind it. This section explains the concepts and the reasoning. The exact environment variables live in §6, the boundary contract in architecture.md, and the provider evidence in ADR 004.

The checkout gateway seam

Commercial checkout flows through one interface, CheckoutGateway (backend/src/application/checkout.ts). The application asks it to charge a specific amount for a specific operation and gets back a single outcome. The interface is intentionally tiny: it carries only what a charge needs — a stable application-owned operation identity, a gross amount in minor units, the currency, and (once the provider requires it) neutral priced line items with their VAT. It deliberately carries no provider field, header, endpoint, or status word. Everything provider-specific stays behind the adapter, so adding or swapping a provider never changes the ordering, kitchen, or sales code.

The gateway returns one of exactly four outcome classes, and the distinction between them is the whole safety model:

Outcome Meaning What the application may do
succeeded The provider confirmed the payment. Finalize the order, queue it, count it in sales.
rejected / cancelled A definitive refusal — known unpaid, no money moved. Report the failure; the operator may deliberately start a new attempt.
unknown Certainty was lost — timeout, transport error, 5xx, or an unreadable response. Never act on it. Record the attempt as unresolved, keep it visible, and forbid a blind retry.

The critical idea is that rejected/cancelled and unknown are not the same thing. A refusal is a fact the application can trust ("nothing was charged, so retrying is safe"). An unknown outcome is the absence of a fact ("we do not know whether money moved, so we must not assume either way"). A stable provider reference, when one is returned, only shows that an attempt may exist; it is never proof that money was captured.

How the adapter is selected

One function, createCheckoutGateway (backend/src/integrations/checkout-composition.ts), maps validated configuration onto exactly one adapter. This is the only place that knows about provider adapters, which is why adding one later is a local change here and nowhere else. The CHECKOUT_MODE setting is the switch:

CHECKOUT_MODE Adapter How it works Intended use
disabled (default) UnavailableCheckoutGateway Contacts nothing and always reports unknown. Any environment with no provider configured. Keeps checkout honestly unresolved instead of faking a paid order.
fake FakeCheckoutGateway (fake-checkout-gateway.ts) Returns a configured outcome deterministically, with no network, clock, or randomness. Automated acceptance and tests. Rejected in hosted staging/production.
mocking-bird MockingBirdCheckoutGateway Parks each request for a developer to select its outcome in the console. Local manual checkout and end-to-end integration development; rejected in hosted environments.
surfboard SurfboardCheckoutGateway (surfboard-checkout-gateway.ts) Real provider calls: create-and-initiate an order, then poll it to a terminal state. Staging with test/Demo values and production with independent Live values, supplied outside the repository.

The fake is the automated testing tool because it is offline, repeatable, fast, and needs no provider account. It maps its outcome to the requested amount rather than to call order, so one deterministic backend process can serve a confirmed sale, a definitive refusal, and an unconfirmed attempt in the same run — which is exactly what the browser and integration acceptance evidence need. Local manual development uses Mocking Bird instead. See test-strategy.md for the automated suite and STANDALONE-002 for the environment requirement.

How each outcome is handled

When checkout succeeds, the order is finalized as commercial, enters the kitchen queue as Queued, and contributes to today's sales; the cart resets to a fresh empty one. When it is definitively refused or cancelled, no order is created, nothing is counted, and the operator keeps an editable draft — the draft is unchanged so they can correct and retry deliberately. When the outcome is unknown, the attempt is stored durably in a separate append-only record (with no order row, so it cannot enter the kitchen queue or any sales figure by construction), shown on every order-entry view in a "payments that could not be confirmed" list that survives a reload, and the pay control for that draft is disabled so the same operation cannot be charged twice by accident. Nothing automatically resolves an unresolved attempt — surfacing it and blocking the blind retry is the entire safety model.

Adding a new payment integration

Because the seam is narrow and the composition point is singular, onboarding another provider is a contained change. The shape of the work, in order:

  1. Discover before you build. Fetch the provider's current machine-readable documentation and record dated evidence, explicit unknowns, and the environment topology in a decision record — the way ADR 004 does for Surfboard. Do not implement a capability the documentation does not prove.
  2. Implement an adapter in backend/src/integrations/ that implements CheckoutGateway. Keep every provider field name, header, endpoint, and status string inside that file. Map provider results onto the four neutral outcomes conservatively: confirmed payment is succeeded; a definitive pre-payment refusal is rejected/cancelled; and every timeout, 5xx, malformed response, or unverifiable state is unknown. Never auto-retry a money-moving create.
  3. Route outbound calls through an injectable transport (as the Surfboard adapter does), so contract tests drive the exact request/response mapping with no network and no credential.
  4. Add validated server-only configuration in backend/src/config.ts and document it in .env.example with empty values. Secrets are supplied only through the deployment secret store, never committed, logged, or returned to the browser.
  5. Wire the adapter into createCheckoutGateway behind a new CHECKOUT_MODE value. This is the only selection logic, so no other layer changes.
  6. Test the mapping offline with adapter tests that carry the relevant MVP-PAY-001 scenario IDs, and keep ordinary application acceptance fake-backed. Update the OpenAPI contract and this guide in the same task if any externally visible surface changes.

Secrets, card data, and provider vocabulary stay behind the adapter; a static provider-boundary guard (tests/ci/checkout-boundary.test.mjs) enforces that the neutral contract and the application never import anything provider-specific.

Surfboard specifics (staging and the fiscal path)

Surfboard is the selected provider for payments and for the Swedish fiscal cash-register (ECR) service. It has a Demo (test) environment and a Live one; the application chooses between them purely by configuration. The payment adapter creates and initiates an order in one call, then polls order status to a terminal state; a separate signed webhook ingress (POST /webhooks/surfboard, HMAC-SHA512 over the raw body) is registered only when a webhook secret is configured. Note two current limitations: the webhook verifies and acknowledges deliveries but defers processing (reconciliation does not exist yet), and the ECR/fiscal adapter plus the separately triggered Demo smoke test are not yet built — those remain blocked on human-owned Demo/ECR prerequisites recorded in MVP-TASK-034. Until then, payment can be exercised through Surfboard Demo on staging, but the legal receipt/end-of-day fiscal evidence cannot yet be produced.

Local email proof

Email defaults to MAIL_MODE=disabled. For local/test interception, set MAIL_MODE=mock, MOCKING_BIRD_ENABLED=true, a valid MAIL_FROM, and MAIL_TEST_RECIPIENTS to one or more synthetic fixture addresses. MAIL_REQUEST_TIMEOUT_MS defaults to 10000 and accepts 100–30000. A provisioned test user whose email exactly matches the allowlist may call POST /api/developer/email-test with its session cookie, exact Origin, and CSRF token; the route accepts no body or query. It sends a fixed harmless message and returns 202 when accepted. The interceptor never contacts Resend.

Real mode requires privately supplied RESEND_API_KEY and MAIL_FROM. A local manual proof send also requires MAIL_REAL_TEST_SEND_ENABLED=true and an explicit MAIL_TEST_RECIPIENTS allowlist; this is an owner-authorized operation and automated tests never use it. Staging/production reject developer enablement and mock mode. No startup connectivity check or automatic retry is performed. History and previews exist only in process memory and reset on startup. Never include real recipient addresses in MAIL_TEST_RECIPIENTS or publish preview content as an artifact.

Password recovery is public at /forgot-password and /reset-password. Set APP_PUBLIC_URL to the browser-visible application origin used in email links; do not use a backend, proxy, or Docker service hostname. Local HTTP is supported and defaults to PUBLIC_BROWSER_ORIGIN. Hosted staging and production require an explicit HTTPS APP_PUBLIC_URL, MAIL_MODE=real, MAIL_FROM, and RESEND_API_KEY; disabled/mock delivery is rejected at startup. RECOVERY_ACK_DELAY_MS defaults to 300 ms and sets the minimum generic acknowledgement padding, not delivery completion. A 202 response means only that the generic request path completed; provider acceptance is not inbox delivery.

In local mock mode, recovery addressed to an allowlisted synthetic fixture appears in Mocking Bird previews. Open its /reset-password#credential=… URL. The fragment is removed immediately. Refreshing afterward loses the in-memory credential by design; reopen the email link or request a new one. Never copy preview output into logs, test reports, or CI artifacts, and never configure a real address as a synthetic recipient.

Mocking Bird email interception

Mocking Bird is a local, network-free interceptor for outbound email. It exists so email-dependent flows — password recovery above all — can be exercised end to end on a laptop without a real mailbox, a real provider account, or any outbound network call. It is a development and testing tool only; it never contacts a real provider or processes live payments, and it is never active in hosted staging/production.

Conceptually it is a small stateful service (backend/src/integrations/mocking-bird.ts) that sits in place of the real Resend transport. When mail runs in mock mode, an outbound send is captured instead of delivered: the request is recorded as a redacted capture (method, destination, header names with the Authorization value masked, byte size, and outcome) and, when the recipient is an allowlisted synthetic fixture, a plain-text preview of what would have been sent. It can also be told to simulate provider behavior — success, provider-error, or timeout, with an optional artificial latency of 0–5000 ms — so failure and slow-delivery paths can be tested deterministically. It keeps at most 100 captures and previews in process memory; a restart clears everything, and it never contacts Resend.

Two independent switches must both be on before the console is reachable, and this two-gate design is the usual reason the menu item seems to appear only sometimes:

Neither gate is an authorization boundary — the backend configuration stays authoritative, and every developer endpoint additionally requires an authenticated session (with Origin/CSRF protection on the state-changing ones). Being logged in is what makes the drawer show the authenticated entry at all, but it is the two gates above that decide whether the page exists.

Developer console

The /developer/mocking-bird page requires both sides of this enablement matrix:

Concern Required local/test value Hosted/standard value
Frontend bundle Vite dev mode, or image build classified local with flag true staging/production classification; flag cannot enable it
Backend deployment DEPLOYMENT_ENV=local/test staging/production rejects
Mocking Bird MOCKING_BIRD_ENABLED=true explicit true rejected
Mail transport any (disabled/mock/real) — independent of console gate disabled or configured real
Synthetic identity current user's normalized address in MAIL_TEST_RECIPIENTS none

The page selects only success, provider-error, or timeout, with integer latency from 0 to 5000 ms. It polls safe state/history/previews every five seconds only while visible, renders previews as text, and offers no export, copy, arbitrary request, replay, or runtime mode switch. Clearing history also clears previews and resets the scenario and latency. One backend process owns the capped 100-entry memory state; restart clears it. The fixed test-send action uses POST /api/developer/email-test. Standard pnpm build omits the page module and declaration.

For local development, start the backend with the server settings above and run pnpm dev; Vite registers the console automatically. The normal local Compose build also includes the page and uses Mocking Bird checkout by default. Canonical automated Docker validation explicitly overrides checkout to the deterministic fake.

User provisioning and authentication configuration

Run pnpm user:create to provision independent users. Interactive password input is hidden. For controlled automation, pipe exactly three lines (email, display name, password); passwords are never accepted as command-line arguments. Existing normalized addresses fail rather than changing credentials. Login addresses are trimmed/lowercased for this application's uniqueness rule.

That command needs a direct PostgreSQL connection to its target database. When the machine running it cannot reach the hosted staging database (for example outbound TCP 5432 is blocked and only 443 is reachable), run the same command from a GitHub-hosted runner through the manual staging-user-provisioning.yml dispatch, which pipes the identical three-line contract and is documented in CI §13.1.

The browser login is /login; Settings is protected. An anonymous direct request for a protected route is restored after successful login through a validated internal return destination. External, protocol-relative, backslash-containing, control-character, and recursive login destinations are discarded in favor of /settings. The public login page retains the device-language selector. Sessions use HttpOnly cookies and in-memory CSRF state; do not put credentials, CSRF/session values, or current-user data in preference storage.

Authenticated /settings has separate Account/Preferences/Security sections. Email is read-only; display name and supported language/appearance values are saved through PATCH /api/users/me. Form drafts stay local, and a failed save does not change the applied theme/language or query DTO. On success the returned DTO becomes the TanStack current-user value and its non-null account preferences override device values. A later /auth/me refetch reapplies the same merge. Null account values keep the device choice. Logout restores the device values in localStorage.

Password change uses the current password and a new 15–256 character passphrase. Success revokes all sessions, invalidates outstanding recovery links, and returns the browser to login; sign in again with the new password. Recovery success has the same all-session effect and never logs in automatically. There is no email change, avatar, role/admin, business, billing, or other-user editing operation.

pnpm user:seed:test is only for synthetic fixtures. It requires DEPLOYMENT_ENV=local|test, an allowlisted dedicated test database name, and the same three-line stdin input. The main local Compose database additionally requires ALLOW_LOCAL_APP_TEST_FIXTURES=true; hosted classifications are always rejected. Normal db:seed still manages only message id 1.

Set PUBLIC_BROWSER_ORIGIN to the browser-visible exact origin (not an internal Docker hostname). Set APP_PUBLIC_URL to the same browser-visible origin unless the supported deployment deliberately uses another validated application origin. Cross-origin browser use also places exact origins in CORS_ALLOWED_ORIGINS; credentialed wildcard CORS is unsupported. In hosted staging/production, PUBLIC_BROWSER_ORIGIN, APP_PUBLIC_URL, and every entry of CORS_ALLOWED_ORIGINS must use HTTPS; startup rejects HTTP origins so browser credentials and provider secrets never travel over plain HTTP. SURFBOARD_API_URL must use HTTPS whenever it is configured, in any environment. Local/test may keep HTTP origins, including the localhost defaults. Hosted staging/production requires SESSION_SECRET of at least 32 characters and emits Secure cookies. Local/test can omit it, but the generated secret logs all sessions out whenever the backend restarts. TRUSTED_PROXIES accepts only explicitly selected addresses/CIDRs. Login defaults to 10 attempts per 15 minutes for both IP and a hashed normalized account key; its in-process state resets on restart and is not shared across instances. Expired account-throttle entries are reclaimed on each attempt, so hashed account keys are not retained for the life of the process.

1. Purpose

This guide describes the current setup and commands from the repository root. Architecture, technology choices, testing policy, and CI requirements remain in architecture.md, techstack.md, test-strategy.md, and ci.md.

2. Development principles

Deliver the smallest coherent change across its needed layers. Reuse existing patterns, add appropriate behavior/regression tests, and update affected guides. The Definition of Done selects proportional local checks; the command catalogue is not a requirement to run every command manually.

2.1 Requirements-to-evidence workflow

From Epic 003 onward, start behavior work from a small implementation task linked to a user story and stable Given/When/Then scenarios:

product intent → story → acceptance scenario → executable evidence → implementation → passing evidence

Acceptance scenarios are the behavioral contract; tests are traceable executable evidence. Include each scenario ID literally in the test title or native metadata and update the epic traceability record with the concrete test reference. Do not renumber committed IDs casually. pnpm traceability:check mechanically rejects duplicate or dangling IDs, scenarios without an evidence declaration, and index rows that are missing, duplicated, or Planned; it cannot judge whether the evidence proves the scenario.

Headline journeys are a few long demonstrations and remain distinct from detailed scenarios. Use Playwright for externally observable workflows, but prefer integration tests for persistence, history, concurrency, and domain invariants that a browser would prove awkwardly. Architecture checks and sourced discovery/manual records are valid only where the scenario declares them.

Behavior that is not owned by a larger epic may be specified in Standalone Requirements. Allocate the next STANDALONE-NNN ID and create docs/specs/standalone/NNN.md; keep scenario IDs stable in the form STANDALONE-NNN-S01. Follow the same status, Given/When/Then, required-evidence, and implementation record conventions used by epic stories and tasks. Include each scenario ID in executable test titles or native metadata, then link the evidence and validation results from the requirement record. Add the requirement to the standalone index. A standalone requirement may extend behavior an epic excluded, but must not silently rewrite that epic's scope.

A story is normally too large for one implementation task. Prefer independently understandable thin vertical behaviors that can be completed and handed over within one working session. Update task status in both its document and index using Idea, Draft, Ready, In progress, or Done. Story completion requires all specified evidence, intact traceability, relevant lower-level tests, and the project DoD.

The current product read model uses UUID identifiers, gross price in integer SEK minor units, VAT percentage in integer basis points, and a nullable explicit kitchen label. GET /api/products and /products and /kds are authenticated reads; no product write tooling is part of MVP-TASK-001.

2.2 Maintainability and module boundaries

Use the architecture guide for dependency direction, the testing strategy for evidence levels, and the documentation guide for source-of-truth placement. These established patterns guide module placement; they do not require extra layers.

3. Prerequisites

For the Compose application alone, only Docker/Compose is needed; host Node/pnpm is needed for development, quality checks, and the automated test commands.

4. Repository setup

pnpm install --frozen-lockfile
cp .env.example .env
pnpm --filter @foodtruck-order/e2e exec playwright install chromium

The root .env supplies backend/host database tooling and Compose defaults. Keep it ignored. Do not copy frontend/.env.example unless an API base URL override is needed; local same-origin/proxy behavior works without one.

5. Package management

Use pnpm workspaces, committed manifests, and pnpm-lock.yaml. Keep dependency changes deliberate and consistent with the stack. CI always installs with the frozen lockfile. Exact resolutions live in the lockfile, not a documentation list.

6. Environment configuration

Backend config.ts loads current-directory then root .env, preserving supplied process environment values, and validates through Zod:

With CHECKOUT_MODE=disabled a commercial checkout has no adapter at all, so its result is never confirmed: the attempt is recorded as unresolved and the caller receives 409 CHECKOUT_UNRESOLVED. With the fake in unknown scenario the same applies to every charge.

Mocking Bird checkout inspection

When CHECKOUT_MODE=mocking-bird, the developer console shows a checkout section listing parked and settled checkout requests newest-first. Each entry carries the full priced CheckoutRequest (operation ID, gross amount, currency, line breakdown), the developer-selected trigger, the settled outcome, the provider reference, timestamps, and the recorded wait duration.

The five developer-selectable triggers are succeeded, rejected, cancelled, timeout, and provider-error. A charge parks as pending and does not reach a provider decision until a developer settles it with one of the five triggers. succeeded reports a confirmed charge; rejected and cancelled report a definitive refusal (certainly unpaid); timeout and provider-error report lost certainty (unknown). Settled entries remain visible with their timestamps and outcomes until cleared; the store retains capped settled history and never evicts a pending entry. Clearing the checkout state settles every pending charge as unknown (cleared while pending) before emptying the store, and it does not touch email captures, history, scenario, or latency — email and checkout clearing are independent.

Surfboard configuration is server-only. SURFBOARD_API_URL, SURFBOARD_API_KEY, SURFBOARD_API_SECRET, SURFBOARD_MERCHANT_ID, SURFBOARD_STORE_ID, and SURFBOARD_TERMINAL_ID are required when CHECKOUT_MODE=surfboard; the cash-register and webhook secret settings are optional until their respective paths are enabled. Every value in .env.example is empty. Request timeout, overall status deadline, and poll interval use the bounded SURFBOARD_REQUEST_TIMEOUT_MS, SURFBOARD_STATUS_DEADLINE_MS, and SURFBOARD_STATUS_POLL_INTERVAL_MS settings. Never expose or log connection values.

Compose settings (POSTGRES_*, GATEWAY_PORT) configure local infrastructure, not the backend schema. The Compose backend receives its container URL separately.

6.1 Staging repository deployment contract

Use Node 24/pnpm 10.34.5 and a full frozen install, not --prod before building or running migration tooling. Staging infrastructure is configured outside this repo.

Staging frontend host build:

VITE_DEPLOYMENT_ENV=staging VITE_ENABLE_DEVELOPER_TOOLS=false VITE_API_BASE_URL=https://api.example.com pnpm --filter @foodtruck-order/frontend build

Publish frontend/dist/, retain /config/app-config.js, and configure SPA fallback to index.html. VITE_* values are public; never put secrets there. Containers instead support the public runtime API_BASE_URL override. When an absolute API base URL is configured (build time or runtime), it must use HTTPS: the frontend fails fast on a non-loopback HTTP base URL. Same-origin relative paths and loopback origins remain valid for local/test work.

Hosted authentication requires the public frontend and backend to be same-site. Do not use two distinct generated *.onrender.com URLs for this browser/API pair: onrender.com is listed in the Public Suffix List, so browsers treat those hosts as different sites and will not send this application's SameSite=Lax session cookie for cross-site API requests. Use same-site custom domains such as app.staging.example.com and api.staging.example.com, or expose the API on the frontend origin. Point the frontend API base URL at that public API address before building it.

Use VITE_DEPLOYMENT_ENV=production for a production frontend build. The developer page is compiled only for a local-classified build, even if its opt-in flag is accidentally set in a hosted environment.

Backend build/start:

pnpm --filter @foodtruck-order/backend... build
pnpm --filter @foodtruck-order/backend start

Supply these backend runtime values in Render. The committed .env.example is for local setup and does not populate an existing Render service.

Variable Hosted value
NODE_ENV production
DEPLOYMENT_ENV staging for staging, otherwise production
HOST 0.0.0.0
PORT Leave platform-managed
LOG_LEVEL info, unless another level is intentional
DATABASE_URL Secret target connection URL, including provider-required TLS options
SESSION_SECRET Stable provider secret of at least 32 characters
PUBLIC_BROWSER_ORIGIN Exact public frontend origin; HTTPS in hosted mode, for example https://app.example.com
APP_PUBLIC_URL Exact HTTPS application origin used in recovery links
MAIL_MODE real
MAIL_FROM Verified sender identity
RESEND_API_KEY Secret provider key
CORS_ALLOWED_ORIGINS Exact frontend origins; HTTPS in hosted mode
TRUSTED_PROXIES Only verified reverse-proxy addresses/CIDRs; otherwise leave empty

Do not put SESSION_SECRET in GitHub Actions: Actions performs the staging database migration but does not deploy or run the backend. After adding or changing Render runtime values, deploy the service again and verify /health, then exercise CSRF/login and confirm the session cookie is issued with Secure, HttpOnly, SameSite=Lax, and Path=/. A missing secure cookie behind TLS means the provider proxy boundary still needs an explicitly verified TRUSTED_PROXIES value; do not weaken secure-cookie mode to work around it.

The runtime needs production dependencies and the compiled db workspace; backend/dist/ alone is not a self-contained deployment. Cross-origin API use permits GET/HEAD/POST, credentialed requests, Accept/Content-Type/X-CSRF-Token, and exposes X-Request-ID and Retry-After. The configured frontend origin must be exact; wildcard credentialed CORS is not supported. CORS is not authentication.

Backend GET /health returns 200 {"status":"ok"} independently of database/seed state. Trusted main CI runs pnpm db:migrate using repository secret STAGING_DATABASE_URL only after all validation passes. Existing Render auto-deploy is expected to wait for those checks; verify hosted discovery/timing externally. See CI §13.

Staging seeding is deliberate manual work, not a deployment requirement. The message endpoint returns 404 after migrations until id 1 exists. Never run db:reset, validate:docker, or automatic seed/reset against staging. Review SQL compatibility with the currently deployed application before release.

7. Local development

Canonical complete application:

pnpm dev:start
# Open http://localhost:8080/
pnpm dev:stop

These two root commands are the canonical local start/stop path and preserve the database across runs. Use them instead of raw docker compose for day-to-day observation; docker compose itself remains available for inspection and one-off tooling against the same pinned project.

Host inner loop with hot reload:

docker compose up -d --wait postgres
pnpm db:migrate
pnpm db:seed

Then run pnpm dev:backend and pnpm dev in separate terminals. The default frontend is http://localhost:5173, backend is port 3000. Vite proxies /api to http://localhost:3000; set API_PROXY_TARGET in the Vite process environment if backend PORT changes. A running canonical backend can also use port 3000 inside Docker without conflicting with the host server; it is not published individually.

8. Frontend development

frontend/src/app/ owns providers, routes, layout, and API transport/endpoint modules. pages/ owns page behavior; components/ owns shared presentation. HomePage calls getMessage() through TanStack Query and renders loading/success/error. Components must not implement raw fetch/URL/status handling. The typed app/pages.tsx declaration is the single source for routes and navigation.

To add an ordinary page, create its component, add one declaration with its path, localized label key, MUI icon, element, access metadata, and showInNavigation, add the same locale key in English, Swedish, and Hungarian, then add component/route/navigation tests. Hidden detail or fallback routes omit navigation visibility and icon. Do not add a second menu list. Theme and brand changes belong in app/theme.ts and app/identity.ts, not individual pages.

frontend/src/app/preferences/ owns Redux device/effective preference state, initial resolution, and the guarded localStorage adapter. The one storage key is foodtruck-order.preferences; its versioned value contains only validated language (en/sv/hu) and appearance (light/dark/system). Saved values take precedence over supported browser language bases, then English/system. Storage denial and corrupt/unsupported values are non-fatal. Do not add credentials, user data, query state, request failures, or unrelated UI state to this record.

frontend/src/app/i18n/ owns bundled resources, the isolated i18n factory, and resource-parity validation. Add user-visible application copy under a semantic key in en, sv, and hu; keep the key sets, non-empty values, and interpolation variables identical. English is the fallback. New language support requires registering and translating a complete resource, then extending the supported preference type. Use the selected base language with Intl for locale-aware numbers, dates, lists, and similar formatting. Never translate backend/database content merely because it is rendered beside localized UI.

Product administration is an authenticated vertical slice. frontend/src/app/api/product-api.ts owns the strict typed JSON and explicit no-content requests and the CSRF acquisition used by the product page/form. Creation and editing accept human-facing SEK and VAT percentages, multiply both by 100, and round to the integer units defined by the backend contract. POST /api/products, PATCH /api/products/{productId}, and DELETE /api/products/{productId} remain behind the normal Fastify request validation, Origin/CSRF, authentication, and error boundary. Deletion is a confirmed inline action in the page, returns 204, and invalidates the authenticated catalog query; the browser never treats a deleted product as available for a future addition. The backend is authoritative for the product policy: trimmed non-empty names (1–100 characters), non-negative integer SEK minor-unit prices within PostgreSQL integer range, VAT basis points from 0 through 10,000, and an optional trimmed 1–40-character kitchen label (omitted/null becomes null). The browser does not duplicate that matrix; it performs only a minimal required-field preflight for forms, maps safe server issues to localized field feedback, focuses the first invalid control, and preserves the draft for correction/retry. The protected /products route and authenticated TanStack Query namespace remain the only browser entry points; successful create/edit/delete operations invalidate that catalog query, while logout and session cleanup retain ownership of all product data. The page also renders the product name in the kitchen-label display when the nullable label is absent, making the name fallback visible without inventing a stored value. Product deletion is currently a hard delete from the catalog; finalized snapshots are independent of mutable catalog rows, while history presentation remains the later MVP-TASK-023 boundary.

Order entry is the second authenticated vertical slice. frontend/src/pages/KdsPage.tsx renders the /kds route: the current order is always present and starts empty, with no start-draft action, and the persisted catalog is rendered as product card tap targets that reuse the shared LoadingState/ErrorState/EmptyState components and the same productsQueryKey as product administration. Cards are ordinary MUI buttons, so the theme's 44 CSS-pixel minimum target, focus handling, and axe baseline apply unchanged. frontend/src/app/format/product-format.ts owns the shared SEK/VAT display formatting so the administration and order-entry pages cannot diverge. The KdsPage now owns an unpersisted current cart: one card activation adds one unit, repeated activations increment by stable product UUID, and different products remain separate lines. It captures product fields on the first tap and retains that local snapshot for repeated taps; a reload or unmount discards the draft. The cart control derives total units by summing line quantities; the current-order screen multiplies each integer minor-unit gross price by its quantity and sums those line values before locale-aware SEK formatting. Its review controls explicitly increase, decrease, or remove lines and derive all displayed totals from the resulting cart. Decrement stops at one so removal remains intentional. Clearing the whole current order requires an inline confirmation, performs only a local state reset, and immediately exposes the always-ready empty cart; it sends no API mutation. The review also captures an optional queue number (maximum 20 characters), optional order-level kitchen note (maximum 500 characters), and an explicit eat-here/take-away choice. Queue number remains a string so values such as 01 are not reformatted; it is display metadata and has no uniqueness role. Empty optional controls become null at finalization, and confirmed clear resets metadata with the items. The review offers two finalization actions over one shared draft: commercial finalization sends the same product identities, quantities, metadata, and fulfillment choice to POST /api/orders/commercial, where the server prices the selection, charges it through the neutral checkout, and finalizes the order only after a confirmed payment; non-commercial finalization sends the same body to POST /api/orders/non-commercial and bypasses checkout entirely. Success on either path resets the page-local draft and immediately exposes the always-ready empty cart. A 409 commercial response means checkout did not confirm a payment, so nothing was queued and the reviewed draft is retained; the definitive-failure, unknown-outcome, and duplicate-protection presentation remains the later checkout-safety tasks. While either request is in flight both actions are disabled, so a repeated tap cannot start a second charge. Keep draft order state local until finalization, and keep the route on the standard shell until the three-region tablet composition is implemented.

Order contract ownership and drift

Keep order metadata length limits as one invariant with backend application authority: ORDER_QUEUE_NUMBER_MAX_LENGTH and ORDER_NOTE_MAX_LENGTH in backend/src/application/order-service.ts validate both finalization and metadata correction. The frontend keeps its own boundary-local values in frontend/src/app/order-metadata-limits.ts for immediate input bounds; both KDS entry and queue correction use them. The backend OpenAPI contract remains the external HTTP source of truth, while PostgreSQL check constraints in packages/db/src/schema/orders.ts remain a final persistence guard. backend/src/openapi-contract.test.ts pins the OpenAPI metadata limits to backend policy, and frontend KDS tests pin the rendered controls to those same documented values.

Do not merge backend domain types with frontend wire types: backend timestamps are Date values, while the JSON contract and frontend projections use serialized strings. Keep OpenAPI as the publicly documented contract and frontend API types local to their HTTP boundary. Similarly named KDS status selectors and operation maps describe presentation affordances, not a second domain transition policy; keep them local to the presentation layer.

Order persistence transaction ownership

Keep one OrderRepository interface and one PostgresOrderRepository adapter. Its writes share important aggregate invariants but have distinct, intentional commit boundaries:

Prefer that private query-module extraction over read/write interfaces or one repository per table: the latter would change application dependency topology without evidence of independent ownership and could obscure which facts must commit together. Keep the interface, SQL, transaction scope, ordering, mapping semantics, and connection lifecycle unchanged during the move. The real-PostgreSQL repository, history, and restart suites characterize these guarantees; a later interface/topology change requires its own user approval and an ADR.

app/api/order-api.ts owns both order read paths. getActiveOrders is a plain GET to /api/orders/active under the activeOrdersQueryKey namespace, which is rooted at authenticatedQueryKey so existing logout and session cleanup already cancel and remove it with the rest of the session's data. It is a read, so it acquires no CSRF token. The finalization functions acquire one and post to their own endpoints. The kitchen-queue read returns null for a missing queue number as null; the browser must omit such a number rather than render a placeholder for it, and must not re-sort the response, because the server owns queue ordering.

The kitchen queue region is split into a detailed window and a compact overflow, and both come from that one read. frontend/src/components/kitchen-queue-card.tsx renders the complete slip for each of the next three upcoming orders on the tablet target (six at or above the shell's md breakpoint). frontend/src/components/kitchen-queue-compact-list.tsx renders one list item per order past that window, so a long queue is compacted rather than truncated: no order disappears and no maximum is assumed. Each compact entry shows the queue number when the order has one, formatting preserved verbatim, and otherwise the short fingerprint from frontend/src/app/format/order-fingerprint.ts. That fingerprint is deterministic, is built from the order's own immutable kitchen-label snapshots in snapshot order, spells out only a quantity above one, and truncates on code-point boundaries with a trailing … after dropping any dangling separator. Keep the maximum length in that one module rather than in the component, so the same width governs every compact entry and the unit tests pin the truncation rules. Never put the internal order identity in a compact entry, and never offer an expansion control for one: the entry is complete as shown.

The queue heading displays the active count directly from that same query array after the read resolves, including zero; do not introduce a separate count state or endpoint. The count is a polite live region so a poll or same-screen invalidation announces the updated total. The detailed and compact entry counts must continue to add up to this visible total. Around 15 orders is the test load, not a maximum: all entries remain rendered and reachable through normal page scrolling, with no pagination or speculative virtualization.

AppProviders is the production and test provider hierarchy. It applies the selected MUI light/dark/system theme and listens to system appearance changes. Its optional injected store, query client, and i18n instance support isolated tests; omitted instances are created once per provider mount and survive ordinary rerenders. renderApp uses this hierarchy and may accept controlled preferences or instances. Tests must not introduce module-singleton provider state.

app/api/auth-api.ts owns /auth/csrf, /login, /logout, /me, current-user update, and password-change transport details. app/auth/session.ts owns the stable current-user key plus cancellation/removal of session-owned queries. Route components use useCurrentUser; they do not mirror its DTO into Redux. Add future private server data below the authenticated query-key root so logout, expiry, and account changes can cancel and remove it coherently.

API endpoint modules choose apiJsonRequest for typed JSON results or apiNoContentRequest for an explicit 204 result. Pass JSON inputs through the transport's json option so serialization and headers stay centralized. Pages map normalized failures with apiErrorMessage; do not display ApiError.message, server issue messages, raw field names, or exception values. Expected query failures use the shared inline error state, not a duplicate notification.

Use LoadingState, EmptyState, and ErrorState for distinct asynchronous outcomes. Form routes define strict, size-constrained Zod schemas at the backend HTTP boundary and provide stable allowlisted field/issue codes. Frontend forms map those fields to trusted control IDs and labels, render FieldErrorText beside each labeled input, and render ValidationSummary when a summary is needed; the summary moves focus to the first mapped invalid field after submission. Transient action confirmation uses useNotification; notification state stays in its narrow React context rather than Redux. The application and router error surfaces are reserved for unexpected failures.

The browser chooses runtime API config, then VITE_API_BASE_URL, then same-origin. A host frontend/.env Vite variable is baked into its build. Docker runtime config is distinct; see Docker §4.

9. Backend development

pnpm dev:backend runs tsx watch. src/server.ts wires repository → service → HTTP and registers database close on Fastify close. It handles SIGTERM/SIGINT once and closes Fastify before the process exits. Backend routes call the service; SQL/Drizzle stay in the repository/db package. API contract and basic errors belong in OpenAPI and architecture §8/§13. JSON form endpoints use parseJsonBody with a strict Zod schema and explicit public field/code mapping; the global body limit is 16 KiB. Never pass Zod/Fastify diagnostics or received values into an API error.

Backend logs are newline-delimited Pino JSON on stdout/stderr for Compose and platform collection. Inspect locally with docker compose logs backend. Each completed request records the server-owned request ID, method, registered route template (or unmatched), status, and duration. Unexpected failures add only a stable operation/category event; they never serialize the caught exception. Do not add raw URLs/query strings, bodies, headers, email/account data, credentials, provider payloads, or arbitrary errors to log calls. backend/src/diagnostics.ts is the typed allowlisted event/context contract for future authentication and email work.

The browser uses reportUnexpectedError for unexpected boundary, transport, and server failures. Its console event contains only a stable category, public app version, and validated correlation ID. Expected 4xx behavior and cancellations are not reported. Reporting happens at the boundary where the failure is observed, never during component rendering.

10. Database development

packages/db owns messages, the Drizzle client, SQL/journal, seed, and readiness. Id 1 is the seeded message; its text is Hello from the database!. Seed uses an upsert, restoring the deterministic text on rerun.

pnpm db:generate
pnpm db:migrate
pnpm db:seed

Schema edits are followed by db:generate; review and commit the generated SQL and metadata. Do not manually reformat generated migration files. Host tools load the root .env; real integration tests initialize their own databases and need no manual migration of development data first.

11. Docker development

See docker.md for service wiring, images, readiness, initialization, and cleanup. Compose uses network app, not the obsolete default network.

docker compose ps -a
docker compose logs migrate seed backend frontend gateway
pnpm dev:start
pnpm dev:stop
pnpm db:reset

dev:start/dev:stop preserve local data and pin the foodtruck-order Compose project. db:reset is the only command that destroys the development volume, and it rebuilds and restarts; use it only when intentionally discarding local data. validate:docker also deletes data, but only its own isolated foodtruck-order-validate volume on separate ports, so it never affects the development database.

12. Testing

The complete command/profile/data mapping is in test-strategy.md.

pnpm test
pnpm test:ci
pnpm test:unit
pnpm test:integration
pnpm test:all
pnpm test:coverage
pnpm e2e
pnpm e2e:headed
pnpm e2e:docker
pnpm validate:docker

test includes artifact and application fast tests; test:all contains application fast/integration tests only, without CI tests or E2E. Integration/aggregate coverage needs PostgreSQL; E2E needs Chromium. Docker validation contains integration and Docker E2E once each; dev E2E is optional feedback, not another CI gate.

Watch commands exist in frontend/backend workspaces:

pnpm --filter @foodtruck-order/frontend test:watch
pnpm --filter @foodtruck-order/backend test:watch

The dev E2E ports are 3100/5174. Docker E2E defaults to gateway 8080 and accepts E2E_GATEWAY_URL; it does not automatically follow GATEWAY_PORT.

12.1 Responsive and accessibility review

Develop mobile-first at 360×800 and use focused 320px and 768×1024 checks rather than duplicating every journey across a large device matrix. The shared Playwright suite also retains 1280×800 desktop behavior. Prefer semantic role/name locators, observable focus, overflow and bounding-box checks; do not couple behavior tests to generated classes or pixel-perfect screenshots.

The blocking axe baseline is necessary but cannot establish phone, virtual-keyboard, zoom, screen-reader, or general usability. Before a release decision, follow and update the manual responsive/accessibility review, recording equipment and missing evidence honestly. Use only synthetic local/test data and never retain reset credentials in reports.

13. Static checks and formatting

pnpm format
pnpm format:check
pnpm lint
pnpm lint:fix
pnpm typecheck
pnpm quality
pnpm i18n:check
pnpm docs:links:check
pnpm spellcheck
pnpm architecture:check
pnpm repository:check
pnpm traceability:check

quality builds db first so type-aware lint resolves generated db types, then runs format check, lint, all-workspace typecheck, locale parity, tracked Markdown links, spelling, architectural imports/API-boundary rules, tracked-file hygiene, mechanical specification traceability, and docs:check. It is offline and is the mandatory AI-agent local gate. format and lint:fix modify files; CI checks do not.

traceability:check runs the deterministic offline checker in scripts/traceability-check.mjs over tracked files only. It rejects duplicate scenario/journey definitions, test-declared IDs that name no defined scenario or journey, scenarios without a required - **Evidence:** declaration, scenarios absent from an epic scenario-to-evidence index, index rows referencing undefined scenarios, scenarios covered by more than one index row, and index rows with empty or Planned references. --report prints the generated per-scenario index instead of failing. The checker judges no semantics: whether the named evidence proves the scenario stays a review responsibility. Its own fixture file is the single documented exclusion from the tracked test-file scan, because those fixtures intentionally declare synthetic IDs that no story defines; every other tracked test file must name defined IDs.

Prettier uses 100 columns (printWidth: 100). Markdown uses proseWrap: always to wrap paragraphs and lists predictably for VS Code side-by-side editing. This is a target, not a hard maximum for unbreakable URLs, command/code blocks, headings, or small tables. Avoid wide multi-paragraph tables; use sections/lists when their cells require substantial prose. The convention applies to README, AGENTS, and all Markdown docs, including the developer log. Run pnpm format after editing.

ESLint uses strict type-aware TypeScript plus hooks/a11y/import hygiene/unused rules. Its targeted architecture rules reject frontend-to-backend/db imports, application/domain imports of Fastify/provider/PostgreSQL implementations, and presentation-layer fetch or raw API transport calls. Composition roots, API/integration/repository implementations, and test fakes retain their intended responsibilities. The following exclusions are intentional:

14. Production builds

pnpm build
pnpm --filter @foodtruck-order/frontend build
pnpm --filter @foodtruck-order/backend... build

The root builds db → frontend → backend. E2E is typechecked, not built. A working Vite/tsx development server does not establish a production build succeeded.

The root package.json version is the sole application release version. Change it as part of a release; workspace package versions and OpenAPI info.version have separate meanings. Vite reads the root manifest and injects public build metadata. Set APP_BUILD_SHA to a 7–40 character hexadecimal revision when a build should display its seven-character commit; leave it unset for a local version-only label. No frontend source or environment example owns a duplicate release number.

15. Supply-chain and container quality

Blocking commands (network/container-dependent; not part of offline pnpm quality):

pnpm security:dependencies
pnpm secrets:check
pnpm containers:lint
pnpm db:check

Triage: update the dependency deliberately, verify compatibility with the affected tests/builds, scope any exception narrowly with an expiry, and keep informational diagnostics distinct from blocking failures. Do not auto-fix, auto-deploy, or invent a license denylist.

16. Complete-system verification

pnpm validate:docker is the authoritative clean Docker system check. For isolated logic/component work, run affected tests and builds locally and rely on full CI for remaining gates. Add targeted migration-upgrade/configuration checks if the baseline does not exercise changed behavior; no blanket manual screenshot/curl collection is needed for every change.

17. Git workflow

Inspect git status, preserve unrelated work, review the final and staged diff, and keep each change focused. Never commit secrets, generated build/site/report output, or local configuration. Generated migration SQL/metadata are required source-controlled inputs, not disposable output.

When asked to create, write, or amend a commit, follow .agents/skills/commit-message/SKILL.md before drafting the message. The skill owns the detailed title, body, validation-exclusion, and traceability rules.

18. AI-assisted development

Follow AGENTS.md. Read specifications and relevant guides, inspect actual code, and use current-state/handover as checkpoints rather than substituting them for inspection. Record actual executed checks and missing evidence. Never infer a command exists from documentation alone. For traceable behavior, select one small specification task, preserve its stable IDs, put applicable scenario IDs in tests, and update the scenario-to-evidence record and task status before handoff.

19. Reproducibility

Use the frozen lockfile, documented environment examples, Compose, and committed migrations. No host PostgreSQL, undocumented global npm dependency, manual schema patch, or developer-specific path is required. Choose matching host/container connection settings when overriding Compose ports or credentials.

20. Troubleshooting

Check versions, configuration, service readiness/logs, host port conflicts, and migration state before modifying implementation. Run the smallest failing command. Do not hide failures by weakening checks or introducing another setup mechanism.

21. Current state and handover

current-state.md is the single current implementation snapshot, validation record, unresolved external checks, and next action. ai-handover.md records only the latest task's changes and continuation notes, pointing to that snapshot instead of duplicating history. Earlier execution history is available in Git; the developer log is informal human notes, not policy.

22. Completion

Review acceptance, boundaries, affected tests/builds, documentation, artifacts, and required CI for the delivered revision using the DoD. Locally validated work awaiting required hosted results is Ready for CI, not Done.

23. Documentation commands

pnpm docs:check
pnpm docs:openapi:check
pnpm docs:typedoc:check
pnpm docs:build
pnpm docs:api
pnpm docs:typedoc
pnpm docs:site
pnpm docs:site:check
pnpm docs:serve
pnpm docs:clean

docs:check is inside quality; docs:build cleans and regenerates the entire site. Preview defaults to http://127.0.0.1:4175; process HOST/PORT override it. OpenAPI is the contract, Swagger UI is presentation, and TypeDoc documents configured intentional public APIs. Meaningful comments remain a review requirement. Cloudflare publishing and required secrets belong in CI §12; generated docs are never committed. Details live in documentation.md.