# AGENTS.md — Working conventions for AI agents Working conventions for **any** AI coding agent operating in this repository — Claude Code, GitHub Copilot, OpenAI Codex / ChatGPT, or anything else. This file is **canonical**: when a client-specific file disagrees with it, this file wins. This project adopts the Flux76 baseline in [`shared/guidelines/project-baseline.md`](https://github.com/Flux76HQ/App-Guidance/blob/main/shared/guidelines/project-baseline.md) (repository `Flux76HQ/App-Guidance`), which is enforceable and remains the source of truth for *what* is required. This file describes how to *work* here. ## Client surfaces | Client | Entry file it reads automatically | Client-specific extras | |---|---|---| | OpenAI Codex / ChatGPT | `AGENTS.md` (this file) | none | | Claude Code | [`CLAUDE.md`](CLAUDE.md) | none | | GitHub Copilot | [`.github/copilot-instructions.md`](.github/copilot-instructions.md) | none | `CLAUDE.md` and `.github/copilot-instructions.md` are deliberately thin: they add only what is specific to their client and defer to this file for everything else. A rule stated twice is a rule that will drift, and the stale copy reads exactly as authoritative as the current one. ## About this repository 3D Print Pricing is a self-hosted **PWA with a backend** for a 3D-print business: it calculates the cost price of a print (filament, parts, charger, printer depreciation, energy) and keeps the inventory those prices come from (filament rolls, printers, energy tariffs). A few people can sign in (the owner who set it up, other administrators and regular users, section 6f), with passkeys or a password. It is still a single-tenant app: one business, so there is no per-user data scoping and everything people make is shared. If that ever changes, every query in `backend/app/api.py` needs a user filter first. Stack: Flask + Gunicorn, SQLite (one file, WAL mode), a no-build vanilla-JS frontend served by the same process, Docker Compose. Layout: `backend/` (API, auth, migrations, tests), `frontend/` (static PWA), `deploy/` (container entrypoint), `scripts/` (repo tooling), `homeassistant/` (the Home Assistant app, section 6g). ## 1. Before you change code - Money is `Decimal` in `backend/app/calc.py`; each line is rounded to cents *before* summing so the lines on screen add up to the total. Never use floats for money, and never compute totals in the frontend: it displays what `/api/calculate` returns. - Machine time (depreciation and energy lines) is always charged in whole hours, rounded up (`whole_hours` in `calc.py`); this is a business rule, not a display choice, so it lives in the calculator and not in the frontend. VAT is charged on the selling price (cost + margin); purchase prices are entered excl. VAT. With a price rounding, the VAT-inclusive price is authoritative: round up, take the VAT out, and the margin becomes a minimum (the loop in `calc.price_from_cost` exists because cent-rounding the VAT can otherwise land a cent under that minimum). - A saved project stores the prices as they were when saved (`projects.data`). Do not make a - Packaging, parts and accessories (`packaging_items`, `part_items`, `accessory_items`) are price lists a calculation line copies from, except packaging: that is added on the order (a normal line at cost price, margin 0, from `Verpakking toevoegen` in the order editor), because what is needed depends on the order, not on the model. The calculation editor no longer adds `verpakking` lines; one saved with them keeps them (the calculator still sums the `verpakking` group, so no saved price changes) and shows the section until they are removed. The line's `group` (`verpakking`, `onderdeel`, `oplader`) is what the calculator sums on and is stored in saved projects, so it is never renamed even though `oplader` is shown as *Accessoires*; the link (`packaging_id`, `part_id`, `accessory_id`) is only a convenience, an order line made from a calculation puts that calculation's `oplader` lines on lines of their own with `optional` set, at cost price (they are billed, but the invoice lists `optional` lines apart, after the others), and a unit is `stuks`, `m` or `g` (price per kg) everywhere. project read live prices from spools, printers or tariffs: that rewrites history. - `backend/tests/test_calc.py` pins the owner's real spreadsheet example (total 2,74). If that test has to change, the calculation changed: say so in the pull request. - WebAuthn is checked by the open-source `webauthn` package (py_webauthn); `backend/app/webauthn.py` is a thin adapter that fixes the policy (ES256 only, user presence required, user verification not, because password managers such as Enpass do not report it) and translates the library's errors. Do not write ceremony checks ourselves (baseline §19). Do not widen what is accepted without a test that fails first. - Uploaded print files are untrusted. All parsing goes through `backend/app/printfile.py` (defusedxml, size caps, readable `PrintFileError`s); never open an upload with another parser. A sliced 3MF is exact, geometry is only an estimate, and print time is never derived from geometry. Test with the synthetic fixtures in `backend/tests/printfiles.py`; a real slicer file is the final check, since the 3MF/slice_info layout is the slicer's, not ours. - All data lives in one host folder, `DATA_PATH`: the database is the SQLite file `DATA_PATH/db/printpricing.sqlite3` and the uploads are in `DATA_PATH/uploads` (the app sees `DATA_PATH` as `/data`; `DATA_PATH/postgres` is the database of the releases before SQLite, which `db` still mounts until the importer goes). They are plain folders, never Docker volumes (`test_deploy_layout.py` fails on a named volume or a changed mount), and `DATA_PATH` is an absolute folder of the Docker host, never `./data` (a relative path is resolved by the tool that starts the stack; under Dockhand on Unraid it landed in the host's RAM disk). `docker-compose.yml` requires it, `deploy/entrypoint.sh` warns about a relative one, `scripts/init_env.py` refuses one, and `scripts/migrate_data.sh` (run by `scripts/update.sh`) moves data that sits in another folder than `DATA_PATH`; it never deletes the old folder and refuses when `DATA_PATH/postgres` already holds a database (it reads where the `db` service keeps it, so it goes with that service). `deploy/migrate_uploads.sh` moves the uploads of releases before this layout (`DATA_PATH/app/uploads`) on start; never remove it while such installations may exist, and never `chown` the whole of `/data` (it is the host's folder and holds `db/`, and an old `postgres/`); only `UPLOAD_DIR` and the database folder are handed to `app`. `scripts/update.sh` copies the database to `DATA_PATH/backups` before an update (SQLite's online backup through `backend/backup_db.py`; never a plain `cp` of a file that is being written, the newest data is in the `-wal` file). Changing where the database lives needs a move of the existing folder first: a different path makes a running installation start on an empty database, which is why `migrate.py` refuses to start when it finds an old `DATA_PATH/postgres` and nothing to import it with. - Uploads live under `UPLOAD_DIR` (`/data/uploads`, i.e. `DATA_PATH/uploads`), never in the image. Stored names are generated; the original filename is only a download name. - Every page starts with a `.page-head` header: it is the sticky title bar (safe-area aware on phones) and holds the page's main button, so keep a page's intro text outside it (`.page-intro`). `html, body` use `overflow-x: clip`, not `hidden`: `hidden` makes the body a scroll container and breaks `position: sticky`. A roll's colour is `filament_spools.colour_hex` (`#rrggbb`, empty = guess from the colour name in `filament-ui.js`). - The screenshots in `docs/assets/` (shown by `README.md` and `README.nl.md`) are generated, never edited by hand: `python scripts/generate_screenshots.py` (needs `playwright` and `pillow`; `CHROMIUM_PATH` if the browser is elsewhere) starts a throwaway app on a scratch database with Dutch demo data and re-takes them as desktop, iPhone (iOS-style bar) and Android (Android-style bar) frames. Re-run it, and commit the PNGs, when a change alters how a screen looks; it never touches a real installation. - Navigation icons are the Material Design Icons (`frontend/js/mdi.js`, licence in `frontend/vendor/mdi/`); the bottom bar has two styles (`html[data-nav]`: `ios` floating glass: the slider value 0-100 is turned into plain `--nav-alpha` / `--nav-blur` values by `applyNavGlass` (`ui.js`, mirrored in `theme-boot.js`) because Safari drops a `backdrop-filter` or colour that has `calc()` / `color-mix()` around a variable, or `android`), both set from the account preferences `navbar_style` (`auto` = the device's: `theme-boot.js` sets `data-platform` before the first paint, `navStyle()` in `ui.js` resolves it) / `navbar_glass` and cached for `theme-boot.js`. - Customers (`backend/app/customers.py`, migration 0014) own the klantnummer (unique, case-insensitive; empty = next free whole number, claimed in a transaction, which holds the database's write lock). An order always has a `customer_id` (required by the API) and keeps a copy of the customer's details; saving the order or editing the customer refreshes that copy, except in orders that already have an invoice number. A customer with orders cannot be deleted. Backups carry customers; an older file without them gets customers derived from its orders. - Margin and rounding are decided per order, not in the calculation (the calculation editor saves `price_rounding` as `none`; the account preference `price_rounding` is only the default for new orders). An order has `price_rounding`; a line made from a calculation keeps that calculation's cost price (excl. VAT) and has its own `margin_pct`, and its price per piece is derived on the server by `calc.price_from_cost` (the same code the calculation uses), never in the browser. A line without a cost price (a free line, or one from before migration 0017) keeps its typed price. A loose accessory (picked from `accessory_items` in the order editor, no calculation) is an `optional` line with its cost price and margin 0; the sync button next to a model line re-reads the calculation's cost price in the browser (`modelLines`, the same rule that added it) and only changes the line's `cost_price`, never its margin, VAT or quantity. - Orders (`backend/app/orders.py`) keep the price per piece *including* VAT (derived as above, or copied/typed when the line has no cost price), and work out the totals on the server per VAT rate (`compute_totals`); the browser only displays them. An invoice number (a template in the business preferences, default `{JJJJ}-{NNNN}`, see `invoice_numbers.py`; counted per year in `invoice_counters`, or under year 0 when the template has no year) is claimed atomically once per order and never reused. Invoice templates (`invoice_docs.py`, `invoice_template.py`) are untrusted uploads like print files: capped in size and unpacked size, parsed only by `python-docx` / `pypdf`, stored under `UPLOAD_DIR`, and part of the backup together with orders and calculation numbers. The invoice logo (`invoice_logo.py`) is an upload too (PNG/JPEG only, size and pixel caps). Template, logo and the made invoice PDFs (`invoice_pdfs`) are stored as `BLOB` in the database, never as files under `UPLOAD_DIR` (a rebuilt container without its volume must not lose them); the invoice is always a PDF, a Word template is converted with LibreOffice (`docx_to_pdf`, `SOFFICE_BIN`, installed in the image). The built-in invoice follows the owner's example layout (`invoice_docs.py`); the payment QR is a SEPA/EPC code, not an iDEAL link, unless the owner sets an own https payment link (`invoice_qr_link`, fields from `invoice_docs.LINK_FIELDS`, validated on save and on restore) and it is off when `invoice_qr` is false; Open Sans lives in `backend/app/fonts/` with its licence. - AI product images (`backend/app/ai_images.py`, `frontend/js/ai-images.js`) are optional and exist only with `AI_IMAGES_ENABLED=TRUE`: the flag is checked on the server for every route (404 when off) and the browser only follows `ai_images` in `/api/auth/status`. The OpenAI key is typed under Settings, kept sealed with `passwords.seal` (own `info` label) and never returned or put in a backup (`test_backup.py` and `test_ai_images.py` assert it); photos and results are re-encoded and live in the database (`project_images`), not under `UPLOAD_DIR`. One picture per picture, made by a background thread of the web worker that took the request (`ai_image_jobs` is the state the browser polls, so leaving the page stops nothing; a job that has not moved for 5 minutes is failed as interrupted when it is next read), every event and error goes to `ai_image_log` (message key + parameters, worded when read; text from OpenAI is scrubbed of anything key-like and cut short; never the key), neither is in a backup. The host is fixed, and `test_ai_images.py` mocks `requests.request` and runs the thread on the spot: never call OpenAI from a test. A frontend child that may be `null` goes through `fill()`, not a bare `replaceChildren`. - The filament library (`filament_library`, migration 0021, `backend/app/filament_library.py`, `bambu_invoice.py`, `bambu_store.py`) is a standalone list of the filaments that *exist* (specs, reference price excl. VAT); it never touches `filament_spools`, and nothing done to it (invoice, sync, JSON import) ever creates, changes or deletes a roll. A roll is only made *from* an entry by copying (the roll form's picker), never linked, so editing the library never rewrites a roll; a roll typed in by hand may be added to the library (`add_roll`, the `add_to_library` flag of `POST /api/spools`) and may complete an existing entry, and `POST /api/filament-library/from-rolls` (the *Rollen toevoegen aan bibliotheek* button) does the same for every roll that is not archived, through the same `add_roll`; it only reads the rolls. A contribution to the central file (`POST /api/filament-library/contribution`, *Bijdragen*) is only computed here (the entries the shared file lacks by `keys()`, without `notes`) and leaves by the person's own hand: the browser opens a pre-filled GitHub issue (form `05-filament-library.yml`, `nl-` twin) and the app holds no GitHub token and calls only the fixed `FILAMENT_LIBRARY_URL`; the button exists only while that is the default. The workflow `library-contribution` turns the issue into a pull request into `beta` through `scripts/apply_library_contribution.py`, which treats the issue text as untrusted data (an environment variable and a file, never a shell line), validates it with `_parse_library`, caps it (`MAX_ENTRIES`, `MAX_BODY_BYTES`), only *adds* what `keys()` does not know and never edits an existing entry; nothing is merged without a maintainer. **One rule says what the same filament is** (`keys()`: SKU, barcode, supplier `filament_id` with `refill`, or brand/material/type/colour/refill/weight/diameter, case-insensitive) and every write goes through it, serialised by the write lock every transaction holds: the generic create/update (`before_create` / `before_update`), `_merge` (JSON import and sync), the invoice apply, `add_roll` and the backup restore (`first_clash`). Never add a way into the table that skips it. A copy is refused (409 `library.duplicate`); only a *new* clash counts (`Index.clash`), so entries that were already alike can still be edited. An existing entry is only ever *completed* (`complete`: the empty fields in `FILLABLE`, plus the reference price from an invoice), never overwritten; the JSON import and the sync overwrite only the fields the file lists, and never delete. A Bambu Lab invoice is an untrusted upload (pypdf only, size and page caps; `tests/bambu_invoices.py` is the synthetic fixture: never commit a real invoice, it holds a name and an address); its prices are incl. VAT and are turned into excl. VAT, and a discount is not part of the price. The sync address is `FILAMENT_LIBRARY_URL`, checked in `config.py` (https, a GitHub raw host), fetched by the server without following redirects and never taken from a request; `library/filaments.json` is the shared file that sync reads by default and must stay a valid library (tested). The roll form's Bambu Lab panel (`bambu_catalog.py`, `GET /api/bambu/products[/]`) lists the colours of one product of the fixed list `bambu_store.CATALOG` (a slug that is not in it is never fetched; a new product is one line). The invoice import also looks each filament up on the Bambu Lab web shop (`bambu_store.py`): the `id=` of its product address, which the owner uses as the *barcode*. Only `eu.store.bambulab.com` is ever contacted (a redirect to another host ends the lookup, because the other regions' IDs differ), the page's JSON-LD is the only thing read, a failure is a warning and never stops the import, and tests never reach the shop (`tests/bambu_invoices.py:Shop`). - Spoolman (`backend/app/spoolman.py`, migration 0025, `SPOOLMAN_URL`, `frontend/js/spoolman.js`) is an optional source of the rolls. A sync mirrors every spool as a `filament_spools` row keyed on `spoolman_id` (calculations keep pointing at the local roll, so prices and saved projects are untouched); the mirrored fields are Spoolman's, so `api.update` / delete refuse a mirrored roll except `notify_low` (409 `spoolman.managed`) and `propagate` skips it, and a spool gone from Spoolman is archived, never deleted. Hand-typed rolls and the library are never touched by a sync. Booked filament goes back with `PUT /spool//use` through an outbox (`spoolman_pending_use`, written in the booking's own transaction, sent after the commit, sent first by the next sync, and subtracted from the weight Spoolman reports until it is sent), so Spoolman being away never fails a booking and nothing is sent twice by design; the outbox is work in progress and not in a backup, `spoolman_id` is (a restore stays linked). *Library to Spoolman* (`push_library`) only *creates* the vendors and filaments Spoolman lacks (matched on vendor + article number, else vendor + name + material), never updates or deletes there, creates at most `MAX_CREATED` per run and has a dry run like the other syncs. *Rolls to Spoolman* (`push_rolls`) makes a spool of every hand-typed, non-archived roll that has a name or barcode and links the roll (`spoolman_id`) in the same per-roll transaction, under the sync's lock, so a failure half way never leaves a spool that a second run would make again; both pushes share `Catalogue` (the vendors and filaments Spoolman has, only ever added to). The address is `SPOOLMAN_URL` when .env sets it (then the screen shows it read-only and `PUT /api/spoolman/settings` answers 409), else the one an administrator types in Settings -> Filaments (`spoolman_settings`, migration 0026, not in a backup: it describes one network); both go through `config.check_spoolman_url` (http(s), no login, query or fragment; a path is fine) and `address()` reads it once per request, before the request holds a connection, so the server only ever calls the fixed `/api/v1/...` paths of that one host, `POST /api/spoolman/test` tests the *saved* address and passes on only a verdict, and a regular user never gets the address; redirects are not followed, answers are size-capped, and Spoolman's data is clamped before it reaches a column. Tests never reach a Spoolman: `tests/test_spoolman.py` replaces `requests.request`. Syncing and pushing are administrator-only (`/api/spoolman` in `access.ADMIN_ONLY`, writes only); the status is for everybody. - Third-party browser code is vendored under `frontend/vendor/` (unmodified, with its licence and a README saying how to update), never loaded from a CDN: the CSP only allows scripts from this origin. Currently only ZXing, for barcode scanning where the browser has no `BarcodeDetector`. - The installed-app surface (iOS icons, launch images, no-zoom viewport, manifest) is generated and guarded: change `frontend/icons/icon-mark.svg` or `scripts/generate_pwa_assets.py`, re-run the script, and never hand-edit the `splash:` block in `index.html`. `test_pwa_assets.py` fails on a launch image whose size does not match its media query, an orphaned PNG, or an icon with transparency. Installed (standalone) navigation must use `goTo()` from `js/pwa.js`, never `location.hash =`, so no history entries are added. - Web push goes through one function, `push.send_one`, and every kind (the test button included) goes through `push.notify`. TTL, `Urgency` and `Topic` are stated there, with the human reason next to the number; `high` urgency, a zero TTL, `requireInteraction` and vibration are forbidden and each is asserted by `test_push.py` (see project baseline section 11). Quiet hours are wall-clock plus IANA timezone, wrap midnight, defer rather than discard, and fail open. - `frontend/sw.js` contains the line `const APP_VERSION = 'dev';` exactly: the server replaces it with the running release so every release changes the worker's bytes. Do not rename or reformat it. - Never copy `ctx.prefs` (or any setting) into a local variable that outlives a save; read it when needed. `setPrefs` rolls back and returns false when the server refuses a change. - UI text is Dutch. Build DOM with the `h()` helper and `textContent`; never `innerHTML` with data (the CSP also forbids inline script and style). ## 2. Versioning discipline - Version source of truth is the `VERSION` file (`MAJOR.MINOR.PATCH`). - **A pull request never changes `VERSION`.** Two pull requests that each raise it collide as soon as the other one merges, so the pipeline raises it after the merge to `main`, i.e. when `beta` is promoted: the `docker` workflow runs `scripts/bump_version.py` on every push to `main`, commits the new number (`Release X.Y.Z`) and builds the image from that commit. `enforce-version-bump` fails a pull request (into `beta` or `main`) that touches `VERSION`. A push to `beta` never raises it and publishes no release, only the `beta` image (section 5). - A promotion that changed protected paths raises the **patch** number; put the label `version:minor` or `version:major` on the **promotion pull request** (`beta` → `main`, the pull request the bump reads) to raise that part instead. A promotion of documentation only (`*.md`, `*.txt`) keeps the number. - Protected paths: `backend/`, `frontend/`, `deploy/`, `homeassistant/`, `repository.yaml`, `scripts/`, `.github/workflows/`, `.github/copilot-instructions.md`, `Dockerfile`, `docker-compose.yml`, `docker-compose.build.yml`, `.env.example` (`scripts/check_version_bump.py` holds the list). - The workflow needs to push to `main`: github-actions must be allowed to bypass the branch protection, if there is any. Without it the bump fails before anything is published, with the reason in the log. ## 3. Local enforcement and CI controls - Activate hooks once per clone: `python scripts/setup_hooks.py`. - Every MUST rule needs an automated enforcement point (CI or hook). - CI jobs on every pull request into `beta` or `main` (`only-from-beta` only on `main`): | Job name | What it checks | |---|---| | `only-from-beta` | a pull request into `main` comes from `beta` of this repository (the promotion); every other one fails | | `enforce-version-bump` | the pull request changed `VERSION` (it is raised after the merge, not in the pull request) | | `agent-instructions-lint` | that this file stays canonical and unduplicated | | `backend-tests` | calculator, WebAuthn, migrations, API; fails if any test was skipped | | `frontend-syntax` | every frontend module parses, checked as an ES module (a copy named `.mjs`: `node --check` on a `.js` file that uses `import` reports nothing, even for a broken one); the manifest is valid JSON | | `docker-build` | the image builds (also for arm64), a Word invoice template is turned into a PDF inside it (LibreOffice, as the unprivileged user), and both compose files are valid for `.env.example` | - A control has to be findable on the pull request under the name a document cites it by. - Migrations are forward-only: never edit an applied `NNNN_*.sql`; add a new one. The runner refuses to start on a changed checksum. ## 4. Secrets and configuration hygiene - Never commit `.env` or real secrets; only `.env.example` is tracked. - Keep `.env.example` complete and safe (placeholders only). A new environment variable is added to `backend/app/config.py`, `.env.example` and the configuration table in `README.md` and `README.nl.md` in the same change. - Rotate `SESSION_SECRET` and `SETUP_CODE` before any public release. ## 5. Branch topology and pull request workflow - **Pull requests go to `beta`, never directly to `main`.** `beta` is the integration branch: feature branches start from it and merge into it. Set GitHub's Default branch to `beta` so a new pull request targets it, and because GitHub reads issue forms and `issues`-triggered workflows (`library-contribution`) only from the default branch. - `main` is the stable line. It only receives a pull request **from `beta`** (the promotion), when the owner asks for one, and that pull request is merged with a **merge commit**, never squashed or rebased (a squash leaves `beta` diverged from `main`). `only-from-beta` fails every other pull request into `main`. The owner makes `only-from-beta` required, and forbids pushing to `main` directly, in the branch protection of GitHub; that cannot be done from the code. - A push to `beta` builds `ghcr.io/flux76hq/3dprint_pricing:beta` (and `beta-`, `docker-beta` workflow). It writes no stable tag, no release and no `VERSION`. A promotion builds `latest`, raises `VERSION`, and publishes `X.Y.Z` with its GitHub Release (`docker` workflow), which then merges `main` back into `beta` so `beta` carries the new `VERSION` (best effort; if it warns, merge `main` into `beta` by hand). github-actions must be allowed to push to `beta` as well as to `main`, if either is protected. - **One variable picks the build: `PRINTPRICING_TAG`** (`latest` = stable, the default; `beta`; or a version number to pin), set in `.env`. `docker-compose.yml` uses it for the image (`ghcr.io/flux76hq/3dprint_pricing:${PRINTPRICING_TAG:-latest}`) and passes the same value into the container, where `config.build_channel()` turns it into the channel (`beta` when the tag contains `beta`). `BUILD.channel` in `js/build.js` (stamped by the server), `/api/version` and `` carry it, and a beta build shows a **beta** label next to the app name (`betaBadge()` in `ui.js`, sidebar and login screen) and installs on a home screen as "PrintPricing beta". There is no `APP_IMAGE` any more. Same set-up as MealieShopper (`MEALIESHOPPER_TAG`); `test_deploy_layout.py` and `test_static_build.py` pin it. - Develop on a feature branch off `beta`; never commit directly to `beta` or `main`. - Give a new feature its own worktree and its own agent session. - Check for an existing open PR before opening a new one; never push without a PR; never add commits to a branch whose PR is already merged — rebase and open a new one. - Issues and pull requests use the templates in `.github/ISSUE_TEMPLATE/` and `.github/PULL_REQUEST_TEMPLATE.md` (adopted from App-Guidance's `shared/templates/github/`). The pull request template is where the deployment-file and limit statements are answered: fill every section, and write "nothing to do" rather than leaving one blank. Every English form has a Dutch twin (`nl-*.yml`, and `.github/PULL_REQUEST_TEMPLATE/nl.md`, opened with `?template=nl.md`); change both together, with the same field ids and labels. There is no Change Spec block: release notes are generated (see the promotion bullet below). - Keep a branch current with `python scripts/update_branch.py` (base `origin/beta`), not a hand-made `git merge origin/beta`: it merges the base and settles the conflicts that are only bookkeeping (a migration whose number `beta` took meanwhile is renumbered, a screenshot in `docs/assets/` becomes the base's, a stray `VERSION` change becomes the base's). Run it before the first push of a new branch, before opening the PR, and whenever the PR turns "dirty"; anything it leaves in conflict is a real one. - Make a conflict less likely to start with: keep a PR to what it needs, do not reflow or reformat lines you are not changing, add language keys at their alphabetical place, and regenerate the screenshots only when a screen changed. - Carry your own PR into `beta` to merge: watch it, drive failing checks green, merge at all green without asking again. The promotion to `main` is the exception: open and merge it only when the owner asks. - **Promoting `beta` to `main`** follows App-Guidance's [`shared/guidelines/promotion-runbook.md`](https://github.com/Flux76HQ/App-Guidance/blob/main/shared/guidelines/promotion-runbook.md). Here: this app is on the pipeline-bump model, so the promotion does not change `VERSION` and the number is only known after the merge (section 2); the release notes are generated by the `docker` workflow (`gh release create --generate-notes`), not written beforehand, so step 2 of the runbook (curated notes first) is skipped and the promotion pull request states "since the previous stable version" in its body instead; the owner's request covers both opening and merging it, merged with a merge commit once the checklist is green; the full test command is `cd backend && PYTHONPATH=. python -m pytest -q`, plus the frontend syntax check and `python -m unittest discover -s scripts -p "test_*.py"` from `ci.yml`. After the merge, check that release `vX.Y.Z` exists and is *latest* (the update check reads it, section 6e) and that the `latest` and `X.Y.Z` images were built. ## 6. Testing - Backend: `cd backend && PYTHONPATH=. python -m pytest -q`. Every test gets its own empty SQLite file (`fresh_db` in `conftest.py`); `connect()` there is a plain connection (tuple rows) for arranging and checking data. Only `test_pg_import.py` needs PostgreSQL (the source of the one-off import): set `TEST_DATABASE_URL`, or run as root with the system PostgreSQL installed and the fixtures start a throwaway cluster. - `backend/tests/fake_authenticator.py` is a software passkey; use it for any auth test. - Test a guard by breaking what it guards before trusting it. ## 6a. Password login and relying party Follows the reference in App-Guidance's `shared/framework/technical-framework/auth-passkeys-password-mfa.md` (MealieShopper): a password is a door of its own, two-step verification (TOTP) is a second, separate choice, and `RP_ID` / `RP_ORIGINS` are optional (`backend/app/passwords.py`, `password_api.py`, `relying_party.py`). - **A password alone signs in** (`password_credentials` row, `enabled`); with an authenticator app (`totp_secret` not NULL) it takes the password and then a code, and **no session exists before both pass** (`/password/login` answers `mfa_required` and a token that is no session; `/password/mfa` finishes). Never weaken the lockout (5 failures, then 15 minutes and doubling, per person, plus the per-address limiter) or the TOTP replay guard (`last_totp_step`), and keep refusals generic ("Inloggen mislukt."). The TOTP secret being set up waits in `pending_totp_secret` until a code is confirmed; the confirmed one keeps working until then. - **Turning a thing on needs only the session** (a password: `POST /password/setup`; the app: `/totp/start` + `/totp/confirm`; it must work on a device without a passkey). **Anything that weakens or changes it needs a fresh confirmation**: `password_api._confirm` takes a passkey assertion bound to the action (`auth._purpose`), or the password plus a code *when there is an authenticator app* (`_reauthenticate`: same lock, counter and replay guard as logging in). That covers changing the password, turning it or the authenticator app off, new backup codes, new recovery codes (`/recovery/regenerate`), promoting and resetting users, and the factory reset. - **Nobody is locked out by their own hand**: the password cannot be turned off without a passkey (409 `pwapi.last_method`), and the last passkey cannot be deleted without a password (409 `auth.last_passkey`). - **The owner may start with a password** (`POST /api/auth/onboarding/password`: same setup code, same limiter, same write lock as the passkey path), for an address where no passkey can work. Invitations offer both ways too (`/api/invites/password` is one step; no pending state is kept). Both paths hand out the 8 recovery codes. - **`RP_ID` and `RP_ORIGINS` are optional.** `relying_party.py` is the only place that decides them: configured values win; empty, the relying party is the host of the request and the origin is `scheme://host` as the browser used it (`X-Forwarded-Host` / `X-Forwarded-Proto` first, so a proxy that terminates TLS is believed), and the session cookie's `Secure` follows the request. Everything that used `cfg.rp_id` / `cfg.rp_origins` goes through it; do not read the config directly. The CSRF check in `create_app`'s `_guard` is `relying_party.origin_allowed`: pinned, the origin must be one of `RP_ORIGINS`; unpinned, it must be the address the page was served from. `/api/auth/status` reports `passkey_problem` (a message key, only for what is certain: an IP address, or no https outside localhost) and `passkey_login` (is any passkey registered), and the screens use them to open the password and explain why. Push needs a contact: `VAPID_SUBJECT`, else `mailto:admin@<RP_ID>`, else `mailto:admin@localhost`. - The login view shows the password option only when `/api/auth/status` reports `password_login`, and the passkey button only while a passkey exists (or there is no password). ## 6b. Texts No user-visible sentence is written in the code. Screens use `t('key')` with the text in `frontend/lang/*.json`; the server raises `ApiError(status, "key", name=value)` with the text in `backend/app/lang/*.json`. Add a key to every language file in the same commit (`backend/tests/test_i18n.py` fails otherwise) and give `{placeholders}` the same names in each. ## 6c. Backup and restore `backend/app/backup.py`. A backup never contains credentials (passkeys, recovery codes, the password and its TOTP secret, push subscriptions), users or invitations: `tests/test_backup.py` and `tests/test_users.py` check the file for them. Restore validates the whole file with the same validators as the normal endpoints, recalculates calculations, re-reads print files, and swaps everything in one transaction. A new table or column that holds user data belongs in the backup and in its round-trip test. With print files the download is a ZIP (`backup.json` plus every model under `files/`, referenced by `archive_path`); without them it is one JSON file, and a JSON file with the models inline (`content_b64`) still restores. A ZIP is never extracted to disk: a member is read by name, capped at `MAX_UPLOAD_MB`, and stored under a generated name. The screen sends the file once to `POST /api/backup/stage` (checked, kept as `UPLOAD_DIR/.restore-<token>`), and after the owner confirms `POST /api/backup/jobs` restores it in a thread of the web worker, so leaving the page stops nothing; the state is `backup_jobs` (polled with `GET /api/backup/jobs/latest`, not in a backup, marked done in the same transaction as the swap). Never make the restore depend on the request that started it, and never let a refused or finished restore leave its staged file behind. ## 6d. Factory reset Follows App-Guidance's [`shared/guidelines/factory-reset.md`](https://github.com/Flux76HQ/App-Guidance/blob/main/shared/guidelines/factory-reset.md). `backend/app/system.py` (`POST /api/system/reset`, Settings → Systeem). It empties *every* table in the file except `schema_migrations` (read from `sqlite_master`, so a table added later is covered without a change here; a table another one points at with RESTRICT waits for its turn), restarts the counters, compacts the file (`VACUUM`, so passkeys and invoices do not linger in free pages), removes the files under `UPLOAD_DIR` and the session cookie. It is the only thing that also deletes the credentials, so it takes two things in the request itself, in this order: the typed word (`CONFIRM_WORD`, `RESET`; `RESET_WORD` in `settings.js` is the same word and `tests/test_system.py` pins both), checked first so a missing word spends no passkey confirmation and no attempt; then the same proof as turning off password login (`password_api._confirm`, action `factory-reset`, purpose `system:reset`): a fresh passkey confirmation, or the password plus a code, counted against the same lockout as signing in. Never let it run without both, and never keep a table out of it unless the app cannot start without it (`tests/test_system.py` checks that no row is left). The screen shows the warning (what goes, the owner's own passkeys too, what stays: `.env`, the image, `DATA_PATH/backups`, accounts at other services) before the word field, asks one last question after it, and disables the form while it runs. **No restart**: nothing is held in the process (every transaction opens its own connection to the file, the secrets are in `.env`), and a test sets the app up again in the same process. The only trace is one warning in the process log: how many tables were emptied and how many files removed, with their names (never contents). This adds no variable and changes no deployment file. ## 6e. Update check Follows App-Guidance's [`shared/guidelines/update-check.md`](https://github.com/Flux76HQ/App-Guidance/blob/main/shared/guidelines/update-check.md). `backend/app/updates.py` (`GET /api/update`, `POST /api/update/check`, administrators only: `access.py`), `frontend/js/release.js` (banner, dot on the entries that lead to Settings, dismissed per version in `localStorage`) and the *Updatecontrole* card in Settings → Systeem (next to the factory reset in *Opruimen*). It reads the redirect of `github.com/Flux76HQ/3dprint_pricing/releases/latest` (never `api.github.com`: 60 anonymous requests an hour per IP address), so it sees stable releases only, which `docker.yml` creates as `vX.Y.Z` at every promotion; a beta never hears of a newer beta. It announces and nothing else: it never updates, restarts or downloads, and the request carries a fixed `User-Agent` and nothing about the installation. **It is switched off with `PRINTPRICING_UPDATE_CHECK=false`** (default on; documented in `.env.example`, both READMEs and the handleiding; `docker-compose.yml` needs no change, it passes `.env` through). The server answers with a reason *key* (`ERROR_KEYS`), never a sentence (section 6b); every key has a `release.err.*` text. This is not `frontend/js/updates.js`, which notices that the *server* was updated since the page loaded and offers to reload. ## 6f. Users and roles Follows App-Guidance's [`shared/guidelines/user-management.md`](https://github.com/Flux76HQ/App-Guidance/blob/main/shared/guidelines/user-management.md) (invitation links, reset links, removing a user), plus the one thing that guideline leaves out and this app needs: an administrator can **promote** a regular user to administrator. Code: `users.py` (administrator routes), `invites.py` (the guest's half, no session), `access.py`, `preferences.py`; migration `0024`; tests `tests/test_users.py`. - **Two roles, `admin` and `user`, and an owner.** The first user (onboarding) is `is_owner`: always an administrator, never reset, demoted or removed (the database refuses a second owner or a non-administrator owner), so there is always one. Nobody changes their own role. A role is read from the database on every request (`auth.load_session`), so a promotion or demotion works at once, and so does a removal. - **What is for administrators is one list: `access.ADMIN_ONLY`**, checked in `create_app`'s `_guard` before any handler runs. A new route under one of its prefixes needs nothing else; a new administrator-only area is a new line there **and** a line in `ADMIN_ONLY_CALLS` in `test_users.py`. Hiding a tab in `settings.js` (`ADMIN_SECTIONS`) is courtesy, never the control. Everything not listed is for any signed-in person. Handler-level checks use `http.require_admin` / `admin_required` (for what a path cannot say, like `PUT /api/preferences` with a company key). - **Shared or personal.** `preferences.SHARED` (`business`, `vat_pct`, `price_rounding`, `low_stock`) is the company's, in `shared_preferences`, written by administrators only; everything else in `users.preferences` is the person's own. A new preference key is one or the other: decide when you add it, and put a company-wide one in `SHARED`. Code that needs the company's details reads `preferences.shared(conn)`, never one user's row. `GET /api/preferences` returns both as one document, a backup holds both under `preferences` (the restoring administrator's own plus the company's), and a backup from before the split still restores. - **Sessions carry the user's `session_epoch`** (`ep` claim; a session without one is epoch 0). A reset bumps it, which ends that person's sessions on every device. `issue_session` reads the epoch as it is now, so call it after the transaction that changed the user has committed. - **Invitations (`invitations`).** 32 random bytes, only the SHA-256 stored, shown once; the link is `<origin>/#invite=<token>` (a fragment: never sent, never logged; the page sends it in `X-Invite-Token` and removes it from the address bar). One use (`_finish` claims it by deleting it), 7 days, one `reset` per person, caps of 50 users (open invitations for new people count) and 50 invitations. Unknown, expired and used tokens are one 404 (`invite.invalid`), checked **before** the body is looked at. A reset changes nothing until the guest has finished (the password path is one step, `/api/invites/password`). The origin in the link is the request's own `Origin` when it is in `RP_ORIGINS`, else the first one (with no `RP_ORIGINS`: the address the administrator opened the app on, `relying_party.public_origin`): never a host the request merely claims. A passkey goes through the same `_registration_options`, `_verify_registration`, `_save_credential` as onboarding (baseline §19): no second WebAuthn path. - **Confirmations.** Promoting someone and resetting an *administrator* each need a fresh passkey confirmation (or password plus code) bound to that person (`auth._purpose` `user-promote` / `user-reset`, `password_api._confirm(body, action, target)`); demoting, removing and resetting a regular user do not. - **Names are the sign-in name for a password login**, unique case-insensitively (`users_name_unique`), also against names reserved by an open invitation. `POST /api/auth/password/login` takes `name`; without one it only works while there is a single person (`status.login_name_required` tells the screen). - **Removing a person is the foreign keys.** Every table with a `user_id` must reference `users` with `ON DELETE CASCADE` (or `SET NULL` for an author); `test_every_table_with_a_user_id_is_cleaned_by_removing_the_person` fails otherwise. What a person made belongs to the business and stays. - Backups and invitations: a backup never holds users, invitations or credentials (6c), and a restore does not touch who can sign in. A factory reset (6d) removes everybody. - Test a guard by breaking what it guards: `test_users.py` was checked by removing each guard in turn (the list entry, the owner check, the epoch check, the confirmation, the company-preferences check, the single use) and watching a test fail. Do that for the next one too. ## 6g. Home Assistant app `repository.yaml` (root) and `homeassistant/` make this repository an app repository for Home Assistant OS / Supervised: the user adds `https://github.com/Flux76HQ/3dprint_pricing` under Settings > Apps > Repositories. Home Assistant runs one container per app, so `homeassistant/Dockerfile` builds on the published image (`FROM ghcr.io/flux76hq/3dprint_pricing:${BUILD_VERSION}`) and adds PostgreSQL 17, which `run.py` starts (only while `/data/postgres` is there and the SQLite file is not, for the one-off import) before the image's own `entrypoint.sh`; it stops both if either dies. Operational detail: `docs/handleiding.md`, section *Home Assistant-app*. - **`version` in `homeassistant/config.yaml` is the image tag it is built on.** It equals `VERSION`, is raised by `scripts/bump_version.py` in the same release commit, and is never edited by hand or in a pull request (`scripts/test_homeassistant_addon.py` fails when it differs from `VERSION`). The tag exists only once the `docker` workflow has pushed it, so an update can fail for the minutes between the release commit and the end of that build. - **The options are the variables of `.env.example`**, not a second configuration: a variable that must be settable in Home Assistant is added to `config.yaml` (`options` + `schema`), `run.py` (`settings()`), and both `translations/` files in one change; the test checks them against each other. Secrets that `.env` would hold are made once by `run.py` and kept in `/data/secrets.json`; never log one except the generated setup code. - `/data` is `DATA_PATH` with the layout of `docker-compose.yml` (`db/`, `uploads/`, and an old `postgres/`): the data rules of section 1 hold here too, and the PostgreSQL major version in the Dockerfile follows `docker-compose.yml` for as long as the importer exists. `backup: cold` stays: a SQLite file that is being written (its `-wal` file) is not a consistent copy. - No Ingress (passkeys are bound to the domain the app is served on). `arch` follows the platforms `docker.yml` builds; today that is amd64 only. - The `docker-build` job builds the app on the fresh image and starts it; keep that step working when the Dockerfile, `run.py` or the entrypoint changes. ## 6h. The database (SQLite) `backend/app/db.py`; schema in `backend/app/migrations_sqlite/` (the baseline `0001_init.sql`, then forward-only `NNNN_*.sql`, checksum-guarded; the PostgreSQL migrations of the earlier releases are kept in `migrations_legacy/` only for `pg_import.py` and its test). Never edit an applied file. - **Every transaction is `BEGIN IMMEDIATE`** and holds the file's single write lock from its first statement, so a read-then-write sequence cannot lose a race. That is why there is no `SELECT ... FOR UPDATE`, advisory lock or `SKIP LOCKED` here: claiming a number, a row or a name is simply done inside the transaction. Keep transactions short and **never hold one across a call to another service** (Spoolman, OpenAI, the push services, LibreOffice): other requests wait for it (`PRINTPRICING_DB_BUSY_MS`, 15 s), then fail with *database is locked*. Never open a second connection while one is open in the same thread (it would wait for the first; `issue_session(response, user_id, conn)` takes the open one). A long job that needs mutual exclusion takes `db.named_lock(pool, name)` (a row in `app_state` with an expiry) around its *non*-transactional work; work that must be claimed row by row uses a lease: `UPDATE ... SET deliver_after/claimed_until = <later> WHERE id IN (SELECT ... LIMIT n) RETURNING ...` (`push.run_outbox_once`, `spoolman._claim_use`). - **Types are declared in the schema** and come back as Python types: `UUID TEXT`, `TIMESTAMP TEXT` (UTC, fixed width: compare and sort it as text), `DATE TEXT`, `JSON TEXT`, `BOOLEAN`, `NUM1`..`NUM4` (a `Decimal` with that many digits behind the point; money is `NUM2`). Only bare columns are converted: an expression (`SUM(price)`, `EXISTS(...)`, a scalar sub-select) comes back as SQLite computed it, so add the amounts in Python and wrap an `EXISTS` in `bool()`. A new column picks one of these types; an id default and a timestamp default are in the schema, so an `INSERT` without them works. - **Write SQL once, for SQLite**, with `%s` placeholders and `now()` as everywhere in the code. `db.translate` turns `%s` into `?` and rewrites `ILIKE`, `lower(` (a Unicode-aware `lower_u`) and `::jsonb`; the rest has to be SQLite (`json_each` for an `IN` list: `db.id_list`; `INSERT ... ON CONFLICT`; `RETURNING`). The unique indexes on names use `lower_u`, which only the app's own connection registers. - **Project and order numbers** come from `db.next_number(conn, "project" | "order")` (the `counters` table): never reused, also after a delete, as the sequences were. A restore carries them on with `backup._restart_numbers`. - **The one-off import** (`app/pg_import.py`, called by `migrate.py`; the address is `LEGACY_DATABASE_URL`, or `DATABASE_URL` when it names a PostgreSQL, so a docker-compose.yml that was never replaced still works) goes away, with `psycopg`, `migrations_legacy/`, the `db` service and `POSTGRES_*`, in the release after the one that introduced it. ## 7. Work done in one client leaves the other clients correct A change to a working convention goes in this file, never in a client's entry file. If a client surface is added (a skill, an MCP server), add its row to the client-surface table above in the same commit. `agent-instructions-lint` checks this. ## 8. Commit checklist - [ ] `VERSION` is untouched (the promotion to `main` raises it; label the promotion pull request `version:minor` / `version:major` if it must not be a patch). - [ ] The branch is based on `beta` and the pull request targets `beta`; it is current with it (`python scripts/update_branch.py`). - [ ] No secrets or `.env` files are staged. - [ ] The operational guide (`docs/handleiding.md`) is updated when run/deploy/backup behaviour changed. - [ ] `README.md` and `README.nl.md` stay in sync (same sections, same order, same screenshots from `docs/assets/`). - [ ] Tests added or updated; `pytest` passes.