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:
- 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.
- Implement an adapter in
backend/src/integrations/that implementsCheckoutGateway. Keep every provider field name, header, endpoint, and status string inside that file. Map provider results onto the four neutral outcomes conservatively: confirmed payment issucceeded; a definitive pre-payment refusal isrejected/cancelled; and every timeout, 5xx, malformed response, or unverifiable state isunknown. Never auto-retry a money-moving create. - 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.
- Add validated server-only configuration in
backend/src/config.tsand document it in.env.examplewith empty values. Secrets are supplied only through the deployment secret store, never committed, logged, or returned to the browser. - Wire the adapter into
createCheckoutGatewaybehind a newCHECKOUT_MODEvalue. This is the only selection logic, so no other layer changes. - Test the mapping offline with adapter tests that carry the relevant
MVP-PAY-001scenario 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:
- Frontend bundle gate. Vite development always registers the local
/developer/mocking-birdroute. The local Compose image defaults toVITE_DEPLOYMENT_ENV=localandVITE_ENABLE_DEVELOPER_TOOLS=true. Static builds include the page only for that explicit local classification and flag; staging, production, and unclassified builds omit it even if the flag is true. These build-time values cannot be changed in nginx at runtime. - Backend gate. The
/api/developer/mocking-bird*endpoints exist only when Mocking Bird is enabled (MOCKING_BIRD_ENABLED=true), independent of mail mode. Without it, the routes are simply not registered and return404.
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.
- Compose at the route; split by responsibility. Keep route-lifetime state and orchestration in
the page, with cohesive feature modules alongside it.
KdsPage.tsxcomposespages/kds/catalog, current-order, queue, and operational modules;ProductsPage.tsxcomposes product forms and presentation. Keepapp/pages.tsxas the single route/navigation declaration. - Compose backend registrars at the HTTP boundary. Routes validate and translate HTTP, then
delegate to application services; they do not access SQL directly.
registerOrderHttpcomposes its active-order, finalization, sales, and checkout registrars, whileregisterAuthHttpcomposes session, account, recovery, and developer-email registrars. Give each registrar only the collaborators it consumes, as inorder-http-options.ts. - Name modules for their main responsibility and preserve owned vocabulary. Frontend endpoint
modules are capability-based (
active-order-api.ts,order-finalization-api.ts,sales-api.ts,unresolved-checkout-api.ts); query keys live with their corresponding reads. Keep domain and externally meaningful names stable, as withFinalizeOrderInput, and prefer a specific filename such aspostgres-order-read-models.tsover a catch-all helper.typedoc.jsonand the documented public API identify intentional exports: do not export implementation details solely for tests; keep module-local constants private and export cross-module contracts that have real consumers. - Share only the same invariant. Similar-looking values or conversions may belong to different boundaries. Order metadata limits have backend application authority, frontend-local input bounds, an OpenAPI contract, and separate presentation mappings; keep those owners explicit rather than merging their types or policies. Extract a helper when the same rule has the same owner and meaning.
- Organize tests and fixtures by evidence and ownership. Backend unit tests sit beside their
modules, while PostgreSQL suites use
*.integration.test.ts; frontend component tests are colocated, such aspages/kds/queue.test.tsx; browser journeys live intests/e2e/*.spec.ts. Keep fixtures with their owner (auth-fixture.ts,kds-fixtures.ts) and promote them only when multiple suites share the same setup responsibility. Prefer user-facing role and accessible-name locators, as inproducts.spec.tsandkds-order-entry.spec.ts; use a stable explicit selector only when semantics cannot identify the target. Do not change accessible behavior or copy merely to make a test selector convenient. - Keep records in their authoritative home. Put architecture, testing, and documentation policy
in their linked guides;
current-state.mdis the current snapshot, whileai-handover.mdrecords only the latest continuation. Preserve stable specification identifiers and decision history. Record a material dependency-boundary change in a new ADR; do not create ADRs for file moves, helper extraction, test fixtures, or naming. To supersede a decision, add a new ADR that links to the prior record and explains the replacement; do not rewrite the old ADR.
3. Prerequisites
- Git; Node.js 24.x; pnpm 10.34.5 (root manifest/workflow baseline).
- Docker and Docker Compose v2 for local infrastructure/full-system validation.
- Playwright Chromium and required Linux browser libraries for E2E.
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:
NODE_ENV: development/test/production; default development.HOST: non-empty, default0.0.0.0.PORT: integer 1–65535, default 3000.LOG_LEVEL:fatal,error,warn,info,debug,trace, orsilent. The default isdebugfor development,silentfor tests, andinfofor production/staging builds. An explicit value overrides that default; invalid values fail startup without echoing other environment values.DATABASE_URL: required valid URL; startup validation does not itself prove PostgreSQL connectivity or enforce provider-specific TLS settings.STORE_TIME_ZONE: IANA timezone defining the sales ticker's local calendar day; defaults toEurope/Stockholm. Invalid timezone identifiers fail startup.CORS_ALLOWED_ORIGINS: empty by default, disabling CORS. Otherwise use exact HTTP(S) origins separated by commas, without paths, wildcards, or trailing slashes.DEPLOYMENT_ENV:local,test,staging, orproduction. Hosted Render services must setstagingorproduction; this enables secure session cookies and hosted configuration checks.PUBLIC_BROWSER_ORIGIN: exact browser-visible frontend origin used for authentication Origin checks, with no path or trailing slash. It is required in hosted mode.APP_PUBLIC_URL: exact browser-visible application origin used to compose recovery links. Hosted mode requires an explicit HTTPS value; local/test defaults toPUBLIC_BROWSER_ORIGIN.RECOVERY_ACK_DELAY_MS: minimum generic recovery acknowledgement duration, 0–5000 ms; default 300. It is asynchronous padding, not a delivery timeout or strict timing guarantee.MAIL_MODE,MAIL_FROM,RESEND_API_KEY, andMAIL_REQUEST_TIMEOUT_MS: recovery delivery mode, sender, private provider key, and bounded provider timeout. Hosted mode requiresreal, a sender, and a key; local/test may use explicitly enabled mock mode.SESSION_SECRET: stable secret of at least 32 characters used to sign session cookies. It is required in hosted mode, must be stored only in the hosting provider's secret environment, and must not be reused between staging and production.TRUSTED_PROXIES: optional comma-separated addresses/CIDRs for explicitly trusted reverse proxies. Do not guess provider ranges or use a blanket value; verify the deployed secure-cookie behavior against the documented provider boundary.CHECKOUT_MODE:disabled(default),fake,mocking-bird, orsurfboard.disabledconfigures no checkout adapter, so checkout stays unresolved instead of reporting a payment outcome.fakeselects the deterministic no-network adapter for local and automated acceptance and is rejected in hostedstaging/production.mocking-birdselects the Mocking Bird checkout adapter so each local checkout parks until a developer selects its outcome; it is also rejected in hostedstaging/production.surfboardselects the server-side payment adapter and requires the completeSURFBOARD_*payment connection. Staging supplies Demo values and a future production deployment supplies independent Live values without a source change. The fiscal path and real Demo evidence remain blocked as recorded in MVP-TASK-034.CHECKOUT_FAKE_SCENARIO:succeeded(default),rejected,cancelled, orunknown; used only whenCHECKOUT_MODE=fake. It selects the outcome the deterministic fake returns for every request, so acceptance runs are reproducible. The canonical local E2E environment andpnpm validate:dockersetfake/succeededso the commercial finalization acceptance evidence runs against the real stack without any provider account or network call.CHECKOUT_FAKE_REJECT_AMOUNT_MINOR/CHECKOUT_FAKE_CANCEL_AMOUNT_MINOR/CHECKOUT_FAKE_UNKNOWN_AMOUNT_MINOR: optional exact gross totals in integer minor units that the deterministic fake resolves specially, regardless ofCHECKOUT_FAKE_SCENARIO; used only whenCHECKOUT_MODE=fake. The first two are definitively rejected/cancelled and the third is left unconfirmed (unknown). Each must be a chargeable positive integer, and no two may be equal, so the same total cannot be claimed as two different outcome classes. All three are absent by default, so an ordinary run never declines or strands a payment; Compose forwards an empty value as "not configured". Keying an outcome to the amount rather than to call order is what lets one deterministic backend process serve an unconfirmed attempt, a definitive refusal, and a confirmed sale in the same run, which is what the unknown-outcome (MVP-PAY-001-S03) and definitive-failure (MVP-PAY-001-S02) acceptance evidence require. The canonical local E2E environment andpnpm validate:dockerset7777/7779/7781.
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:
createwrites the current order, original finalization fact, item snapshots, and any confirmed checkout attempt atomically.transitionStatusconditionally updates current status and appends its transition fact in the same transaction.updateMetadatalocks an active order, updates its current metadata, and appends field changes atomically.claimCheckoutOperationcommits the idempotency reservation before crossing the provider boundary;recordUnresolvedCheckoutAttemptwrites only the durable unresolved fact and never an order.- Active-queue and current-day-sales projections are independent reads over durable snapshots and can live in a private query module without a new application-facing repository capability.
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:
- Dependencies/build/coverage/site output and
pnpm-lock.yaml. - Generated
packages/db/drizzle/SQL/metadata, which must not be reformatted. - Dockerfiles, shell scripts, nginx configuration, and
.envare outside the integrated lint baseline; validate changed behavior through the relevant tools. - Backend
src/test-support/**modules are test-only infrastructure (excluded from coverage and typedoc entry points) and share the documented test-file rule exceptions, notablyrequire-awaitfor inertasynccollaborator stubs that model promise-returning services. - Prettier environment-file exclusions are listed in
.prettierignore.
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
security:dependenciesrunspnpm audit --audit-level high --jsonand fails on unexpired high/critical advisories; audit-service failure fails verification. Narrow exceptions record advisory/package scope, owner, reason, and expiry insupply-chain/audit-exceptions.json; expired entries fail.secrets:checkruns the pinned Gitleaks container over tracked working-tree files locally and PR history in CI; findings are redacted and never uploaded raw. A real finding blocks and requires credential rotation, not suppression.containers:lintruns pinned Hadolint across the four Dockerfiles.db:checkruns the actualdrizzle-kit generatein an isolated temporary copy and fails on pending generated changes or untracked migration output; it never mutates tracked files.pnpm supply-chain:diagnostics(weekly/manual workflow) collects informational redacted all-severity audit, full-history secret, Trivy image, and license inventories intosupply-chain/reports/(7-day artifact, not a merge gate).
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.