Files
dtf-system/docs/ROADMAP.md
Cauê Faleiros 37715ef223
All checks were successful
Build and deploy / Validate source (push) Successful in 1m23s
Build and deploy / Integration suite on a real stack (push) Successful in 3m13s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m25s
feat: the server checks the grade against the files before approving
The price depends on the grade, which the browser worked out and the API
took on trust. Before a cart is approved at checkout the API now recomputes
it from the uploaded files by the Site's own rules: the pixel size in a
PNG, JPG or WebP header across the printed width (rotation included), and
the area-weighted DPI of the images placed in a PDF of up to 150 MB, 300
for vectors. Sheets take the worst grade, artworks the average. A claim more
than 2 points above the file's grade, or a discount on a file the server
cannot grade, waits for an operator, with the reason on the Kanban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:24:37 -03:00

1025 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DTF System — Working Roadmap
> Internal engineering tracker. Not a client document, not a promise sheet.
> The client-facing narrative lives in `docs/roadmap-cliente.pdf` and in the weekly
> report, which is a deliverable and is not versioned here.
>
> Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step (2026-09-25, last day of Week 2):** Still waiting on the client
for Mercado Pago credentials and webhook access (1.1) and freight data (1.2).
Tiny is connected in production and reads orders, but the connected user
cannot read contacts (403) and the client's catalogue has no per-metre UV
product; both wait on the client (1.3). Built and deployed without them: server-side print-file
generation (1.4), the delivery address (3.8), the Kanban's payment-issue and
print-file views and its redesign (1.5), the Site redesign (5.17), and Mercado
Pago and Tiny adapters written from the public API documentation and tested
against fake transports. None of the provider work is a verified integration.
Every change passed the full integration sequence locally (Docker Engine in
WSL) and on the Gitea runner, which publishes images on green pushes to `main`;
the user verified each release in production after redeploying in Portainer.
Operator and Site guides are in `docs/` (see Reporting).
**Left for today:** the Week 2 client report. Waiting on the client: the
Contatos permission and the product decision, then product ids and one
supervised real order (1.3); Mercado Pago credentials for the sandbox PIX and
card payments (1.1); freight data (1.2, the one Week 2 item that slips, on
client inputs).
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
the local order-correctness work in 3.6/3.9 have passed integration checks.
Production specification v2 now records each copy's film coordinates and is
kept through the approved order; 4.6 now pages pending and approved unpaid
quotes, including a tested 101st pending quote. Operational entrypoints in
5.12 are repaired and locally exercised. Image decoding, mixed-sheet grading,
rotation-sensitive DPI, and PDF page geometry are corrected in 3.9/4.4.
`main` pushes now validate without publishing; manual release requires a passing
source preflight. The containerized browser gate passes locally, pending a Gitea
runner run.
Next address the upload/scanner safety gate and unsupported PDF image evidence.
The customer/API upload admission now stops above the scanner's effective limit
before transfer; the 5 GiB large-file product path still needs agreement and
implementation.
Unfinished upload reservations now expire after one hour or can be cancelled
explicitly; anonymous admission and browser resource bounds stay open.
Generated print output, lifecycle/recovery, and provider work remain open.
Obtain decisions for unattended pricing, print-file acceptance and large files,
plus sandbox inputs for freight and Mercado Pago. Week 2 delivery items 1.1–1.5
remain open; 1.6 is complete.
> Paths in closed items are written as they were when the finding was made.
> The repository was laid out by role on 2026-09-21 (`local/` became `app/`,
> with `tests/`, `ops/`, `infra/` and `web/` beside it); the history is left
> as recorded rather than rewritten.
**Last audit:** 2026-09-18, full read of the then-current tree. The 2026-09-21
full review is `docs/REVIEW-2026-09-21.md`; the 2026-09-22 review and payment
probes added new findings to Blocks 2–5 below. Historical paths in closed items
remain as recorded.
**Full remediation register:** `docs/REMEDIATION-2026-09-22.md` maps every one
of the 37 review findings to an action and a release gate. Use it alongside
this Week 2 tracker; a green milestone here does not close the production gate.
| Status | Meaning |
|---|---|
| `[ ]` | not started |
| `[~]` | in progress |
| `[x]` | done and verified |
| `[?]` | blocked on a decision (product or client), not on code |
---
## Week 2 execution sequence
This sequence keeps the client commitments in Block 1 visible while correcting
defects that would make those commitments unsafe or impossible to operate.
Do not mark a provider item complete from a fake-adapter test or a healthy page.
| Order | Work | Exit evidence |
|---|---|---|
| 1. Immediate safety — done 2026-09-22 | Close 2.13 and 2.14; cover foreign quote IDs, missing/invalid amounts, duplicates and valid approvals. | Local integration checks pass and no other customer's order is returned. |
| 2. Order correctness | Fix 3.6 and 4.6: one current cart/quote snapshot, versioned per-file production instructions, correction/final revision binding, visible actionable quotes. | The approved quote, order and final file can be traced back to the same reviewed layout; edits cannot buy an old cart. |
| 3. Resolve product contracts | Decide 3.1–3.3: which quotes may auto-approve, what generates the print file, and which sizes the upload and scanner can release. | Written acceptance rules and representative artwork/large-file cases before enabling unattended payment. |
| 4. Week 2 integrations | Add destination data and real freight first, then Mercado Pago payment intents/webhooks/reconciliation, then Tiny/Olist order creation. Keep the four agreed WhatsApp events in the same delivery contract. | Sandbox flows and failure/retry cases pass; no fake provider is presented as production ready. |
| 5. Operability and release | Repair 5.12–5.14, signatures, proxy trust and backup/restore; gate browser tests and the exact deployed images. | Fresh install, upgrade, recovery and deployed release checks pass with alert ownership recorded. |
Client inputs needed for steps 3–4 are listed in `docs/PRODUCTION_INPUTS.md`.
Engineering can complete steps 1–2 and repair local operational commands while
those inputs are gathered. The full disposition of architecture, security,
quality, operational, and delivery findings is in
`docs/REMEDIATION-2026-09-22.md`; all release gates there must be met before
accepting real customer work.
---
## Block 0 · Broken right now
Nothing in this block is optional. Until it is closed, the system cannot be
demonstrated, and the week-1 claims cannot be defended.
### `[x]` 0.1 — API returns 500 on every session, login and registration `(F1)`
`local/auth.py:46` calls `os.environ` and the module never imports `os`.
Reproduced: `NameError: name 'os' is not defined`. `/api/session` calls
`new_session()` whenever there is no cookie, so the Site checkout bridge, cart
recovery and the customer portal all fail on first visit. Introduced in `e3e37f6`.
- Add `import os` to `local/auth.py`.
- Move the `COOKIE_SECURE` read to a module constant so it is evaluated once.
- **Accept:** a fresh browser hits the Site and `/api/session` returns 200 with a
`cart_scope`; `local/smoke_test.py` and `local/workflow_test.py` pass.
### `[x]` 0.2 — "Arquivo por metro" is unreachable in practice
Two independent causes, both from 2026-09-18 commits. Verified in a browser.
**a. Every entry point hard-routes to loose artwork** (`483a083`)
| Control | Currently opens |
|---|---|
| Nav "Impressão DTF" | `avulsa` |
| "DTF Têxtil · 57 cm" | `avulsa` |
| "DTF UV · 28,5 cm" | `uv` |
| Hero "Enviar minha arte" | `avulsa` |
Only the price card reaches `file`. Revert the three `data-modo-cta` attributes
so navigation links land on the product chooser, not on a product.
**b. Dropping a PNG/JPG in `file` mode silently switches the order** `(F25)`
`dtf-site.html` `sel()` — from `abrir('file')`, dropping `imagem-teste.jpg` gives
`modo: "avulsa"`, header "Artes avulsas", price `R$ 29,90/m`. Meanwhile the same
screen says *"Arraste suas folhas montadas · PNG, JPG ou PDF"*, marks
*"PNG, JPG ou PDF · a partir de R$ 14,90"* as the recommended path, and sets
`input.accept=".png,.jpg,.jpeg,.pdf"`. The page invites the drop and then
reprices the order 50% higher.
The escape hatch `#imagemComoFolha` sits above the drop zone as small text inside
an informational notice, defaults to unchecked, and must be ticked *before* the
drop. After the switch fires, `pintaModo()` sets `trocarParaAvulsa.hidden = true`,
so the checkbox disappears and there is no way back in place.
Net effect: the only formats that survive `file` mode are PDF/TIFF/PSD/AI/CDR —
the path the UI itself marks as the worse option. A mixed drop (JPG + PDF) is
rejected and accepts nothing.
- Make the ready-sheet choice an explicit two-option control **inside** the drop
area, styled like the existing `.cam` selector — not a checkbox in a notice.
- Stop advertising PNG/JPG in the by-metre drop zone while rejecting them.
- When a switch does happen, show the price change and offer one-click undo.
- **Accept:** a customer can complete a by-metre order with a PNG from any entry
point, and no product/price change ever happens without a visible confirmation.
### `[x]` 0.3 — Ready-sheet declaration is unverified and worth money `(F22 related)`
Ticking `#imagemComoFolha` is an honour-system claim that moves the price from
R$ 29,90/m to R$ 19,90/m (R$ 14,90 with a good grade). `medirFolha` then derives
sheet height purely from aspect ratio × 57 cm — the original bug, now opt-in.
Measured with `imagem-teste.jpg` (466 × 659 px):
| Route | System behaviour | Billed |
|---|---|---|
| Ticked | treated as a 57 × 80,6 cm mounted sheet | 1 m × R$ 19,90 = **R$ 19,90** |
| Not ticked | 20 cm wide, packs to 28,3 cm of film | 1 m × R$ 29,90 = **R$ 29,90** |
- Validate the claim: declared width must be ≈ film width (57 / 28,5 cm) at a
plausible DPI before the sheet model is accepted.
- Re-check server-side in `/api/operator/quotes/{id}/approve` before pricing.
- **Accept:** a small single artwork declared as a ready sheet is rejected with a
clear message; a genuine 57 cm sheet passes; the operator sees the verdict.
### `[x]` 0.4 — Documented local startup fails `(F2)`
`LOCAL_SETUP.md` says `docker compose up --build` with no `.env`.
`docker compose --env-file .env.example config` exits 1: `R2_ENDPOINT`,
`R2_ACCESS_KEY_ID`, `SITE_DOMAIN`, `KANBAN_DOMAIN` missing. `docker-compose.yml`
became a production/R2 stack in `e3e37f6`; `.env.example` is still the MinIO one
and there is no MinIO service left.
- Decide: keep one production compose and add `compose.local.yaml` with MinIO, or
restore a local default. Recommend the former.
- **Accept:** a clean clone reaches a working Site + Kanban with the documented
command, and `LOCAL_SETUP.md` matches what actually runs.
### `[x]` 0.5 — App DB role shares the admin password `(F3)`
`docker-compose.yml:65` sets `APP_DB_PASSWORD: ${POSTGRES_PASSWORD}` — the same
value as `dtf_admin`. `bootstrap.py` grants the app role DML-only and then hands
it a credential that also logs in as the owner. Anyone reading the API container
env has admin on the database.
- **Accept:** distinct secrets; connecting as `dtf_app` with the admin password fails.
### `[x]` 0.6 — A paid order showed the customer nothing (found while closing Block 0)
`#checkoutStatus` and `#checkoutActions` lived inside `#carr`, and the success path
in `checkout.js` clears the cart (`pedido=[]; limpaPaineis()`) before writing the
confirmation — `.carr{display:none}` then hid the panel holding it. Present since
the first commit; it only surfaced once 0.1 made a payment reachable at all.
Both elements now sit in their own always-visible `.checkout` container.
### How Block 0 was verified
A live stack (`compose.local.yaml`), then the full suite:
| Check | Result |
|---|---|
| `/api/session` on a cold browser | 200 with `cart_scope` + session cookie |
| smoke · workflow · security · scanning | pass |
| retention · runtime security (in-container) | pass |
| `artwork_browser_test.mjs` | pass, updated to the new declared-product behaviour |
| `browser_test.mjs` end-to-end | pass — upload → quote → operator approval → paid order → all Kanban states |
| 4 CI unit tests | pass |
Ports 8090/8091/8010 were used; 8080 was held by an unrelated preview server.
---
## Block 1 · Week 2 — committed to the client
From the report already sent. These are dated promises, not backlog.
- `[~]` 1.1 — Mercado Pago transparent checkout, signed and idempotent webhooks.
**Current foundation (2026-09-22):** a fake signer exercises signature rejection,
event-ID deduplication, amount comparison and transactional order creation.
This is not a Mercado Pago integration. Complete a durable payment intent,
provider payment ID and currency binding, real verification and status lookup,
delayed/duplicate event handling, refund/cancellation rules and reconciliation.
A refused paid event must be visible for operator resolution rather than silently
treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
administration, event mapping and an approved refund policy.
**Groundwork (2026-09-24):** `app/mercadopago.py` creates PIX or card-token
payments with the quote as idempotency key, verifies `x-signature` as
documented (HMAC-SHA256 over `id;request-id;ts`, 30-minute replay window),
and treats the notification as a pointer: the payment is fetched from the
API and only a BRL amount in whole centavos is compared. `payment_intents`
binds each provider payment to its quote; `/api/payments/intent` starts a
PIX and the Site shows its QR code. Refused paid events and refunds on
existing orders now stay on the Kanban until an operator records a
resolution. Unit-tested against a fake transport only.
**Card form (2026-09-24):** Mercado Pago's Card Payment Brick on the Site,
shown when `MP_PUBLIC_KEY` is set; the card becomes a one-time token in
Mercado Pago's secure fields. Each card attempt has its own idempotency key
(a decline can be retried with another card) and the intent route refuses
any new attempt once a payment is approved or a card is in review, so a
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
against a real public key; sandbox run and refund policy remain.
**Account setup (2026-09-28):** the client's application is Checkout
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
the fake adapter), so the sandbox runs on production with test credentials;
the Site has no real customers yet. A verified notification whose payment
does not exist (the panel's "Simular notificação") is acknowledged instead
of answering 500, which would have made Mercado Pago retry it.
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
packaging weight/dimensions per length, subsidy policy.
**Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
home delivery, billed by contract. Still missing: packaging weight and
dimensions per length and how freight is charged to the customer (asked
2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3);
`python -m app.jadlog_probe` prices test weights to six regions, read-only,
to confirm token, account and contract on the client's account. Not yet run.
**Adapter (2026-09-29):** `FREIGHT_ADAPTER=jadlog` prices "Receber em casa"
with Jadlog from the order's billed metres (`JADLOG_PESO_BASE_KG` plus
`JADLOG_PESO_POR_METRO_KG` per metre) and value, adds
`FREIGHT_PRODUCTION_DAYS` to Jadlog's time, re-quotes the cart when it
changes, and quotes again at approval from the priced items. It refuses to
start without the credentials and the weights, which have no default. The
production stack now takes the Jadlog settings, so the probe runs from the
worker's console. Cubed weight is not computed: the client's box sizes will
tell whether it is needed.
- `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
Confirm endpoints, tag behaviour and rate limits first.
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
Keycloak. An operator starts the connection from the Kanban; the callback is
authorised by a single-use state (the operator cookie is SameSite=Strict and
does not survive Tiny's cross-site redirect). Tokens live in
`provider_tokens`; the refresh token rotates under a row lock and the worker
keeps the connection alive. Orders: contact found by CNPJ or created, then
`POST /pedidos` with product ids from `TINY_PRODUCT_TEXTIL_FOLHA` / `_TEXTIL_AVULSA` / `_UV_FOLHA`
/ `_UV_AVULSA` (not `_<MODE>`: a name ending in `_FILE` is read as a secret
file path by `app/core/secrets.py`) and
`numeroOrdemCompra = DTF-<number>`; a retry searches the customer's last
seven days of orders for that number first. Production passes the app
credentials through but keeps `TINY_ADAPTER: fake`. Tested against fake
transports (`tests.test_tiny`) and, for OAuth, the real database
(`tests.tiny_oauth_test`). Tiny has no sandbox: the first real test creates
real orders. Still to confirm on the client's account: plan (Construa+),
product ids, token lifetimes, whether pickup needs a transportador, and
rate limits.
**Supervised run (2026-09-25):** the payload and query parameters were
checked against Tiny's published v3 OpenAPI spec (`GET /pedidos` accepts
`cpfCnpj` and `dataInicial`; `GET /pedidos/{id}` returns
`numeroOrdemCompra`). "Testar conexão" on the Kanban now also reads the four
configured products and requires each to be active. `python -m
app.tiny_probe` runs from the worker console: `produtos` and `conferir` are
read-only; `pedido` shows the test order and creates it only with
`--confirmar`, through the worker's own `deliver`, then proves the duplicate
guard by search first and only then by a second delivery. Not yet run
against the client's account.
**Staying connected (2026-09-25):** Tiny documents a 4-hour access token and
a 1-day refresh token; the worker renews about every 4 hours. The connection
now asks for `offline_access` (listed by Tiny's Keycloak; if refused for this
application the callback retries once without it). Renewal failures are
stored: a refused refresh token marks the connection lost and is not retried,
a transient failure shows as a warning until the next success, and a
session grant with under 12 hours left is flagged. Previously a refused
refresh still showed "Tiny conectado". Whether Tiny grants offline access,
and whether a session grant has an undocumented maximum, is only known on
the client's account. There is no alert channel yet (no e-mail; WhatsApp
is fake): problems show on the Kanban only.
**Client account (2026-09-25):** connected in production with the
developer user the client provided. "Testar conexão": `pedidos` ok,
`contatos` 403, unchanged after reconnecting. The application's permissions
are correct (Contatos, Pedidos: read and include/edit; Produtos: read);
Olist documents that v3 calls also depend on the logged-in user's module
permissions, and that user's Cadastros menu shows only Produtos. Orders
need Contatos (search by CNPJ, create when new; delete never). **Client
to decide:** grant that user Clientes e Fornecedores, or reconnect with a
user that has it (a dedicated integration user is recommended).
**Products (2026-09-25),** from the client's product export: none of the
36 "DTF" products matches the four Site products. `951438842` "IMPRESSÃO
DTF PERSONALIZADO 57X100 (1 METRO)" (MT, R$ 19,90, active) fits Têxtil;
there is no per-metre UV product (the UV one is 27 cm, per 10 cm), and no
separate "artes avulsas" product. **Client to decide:** create four per-metre
products (recommended, copying `951438842`), or share `951438842` between
the two Têxtil modes; UV needs a product either way. The item note already
carries the Site mode and grade. No product ids are set in production yet;
both questions go to the client with the Mercado Pago credentials request.
**Customer notices through Tiny (2026-09-25, Week 3 head start):** the client
already sends WhatsApp notices from Tiny's order situação (Tiny webhook ->
the `api-tiny-n8n` middleware, which reads the order through API v2 -> n8n
-> WhatsApp templates): Aprovado, Pronto para envio with forma de envio `X`
(v2 "Customizada", their pickup), Enviado, Entregue. So the system's own
WhatsApp sender stays off and Tiny drives the notices. With
`TINY_STATUS_UPDATES=true` a paid order is created "Aberta" and set to
"Aprovada" (a retry finishes a half-done approval; an order already moved on
is left alone), and moving a pickup order to Finalizado sets "Pronto para
envio" by the Tiny id from the sale's receipt, searching only when it is
missing and retrying while the sale has not reached Tiny. Pickup orders
carry `TINY_FORMA_ENVIO_RETIRADA` (`tiny_probe formas-envio` lists the ids;
"Testar conexão" now checks it). Delivery orders get "Enviada" with 1.2.
Off by default: while n8n's `isDTFIMP` branch exists, "Aprovado" on a
`DTFIMP` product sends the designer message, and the Site's Têxtil product
code starts with `DTFIMP`. **Go-live together:** `TINY_ADAPTER=tiny`,
`TINY_STATUS_UPDATES=true`, n8n's `isDTFIMP`/"DTF Aprovado - Designer"
removed with "Mapear Whatsapp do Vendedor" connected to `If6`, and the
middleware's `numero_ecommerce` falling back to the purchase order so the
message shows `DTF-<n>`. **Unverified on the account:** that an API status
change fires Tiny's webhook, that Tiny accepts Aprovada -> Pronto para envio
without Faturada, and the v2 field name for the purchase order. Correção
necessária and Produção iniciada have no Tiny situação; client to decide
whether they need messages.
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
must survive checkout before an output engine can reproduce the approved job).
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
the length of the approved layout, with every copy at its reviewed position,
rotation and mirror (`app/printfile.py`, rendered by the worker from
`app/printjobs.py`). Sources are embedded once at original resolution; JPEG
bytes pass through, PNG alpha becomes a soft mask, EXIF orientation is
honoured. A file whose proportions differ from the quote, a layout longer than
billed, or PDF/PSD/AI/CDR artwork goes to hand preparation with the reason.
The operator approves the generated PDF as the final file through the
existing review. Unit tests include a raster check of every rotation and
mirror, and `tests.print_file_test` passes on the running stack (generate,
download, approve as final, queue; hand-preparation routing and retry).
**Still open:** a FlexiPRINT import of real
generated files (including one longer than 5 m, which uses `UserUnit`).
**PDF artwork (2026-09-24):** a single-page PDF source is placed as a vector
form through pikepdf (MPL-2.0; PyMuPDF was rejected for its AGPL licence),
using the CropBox and inherited `/Rotate` the Site measured with pdf.js.
Raster tests cover crop, page rotation, placement rotation and mirroring,
and were shown to fail when the rotation or crop handling is broken.
Multi-page and protected PDFs go to hand preparation.
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
their transitions are unchanged; cards now show the delivery address, the
print-file status per item, and a panel lists payments that need a person
(money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing.
**2026-09-24:** a mistaken move can be undone one stage back (`BACK` in
`app/runtime.py`) with an internal reason, flagged in the history
(`movements.back`), without notifying the customer; "production started" and
"ready" are enqueued once per order. Previously the only way back was
Correção, which messages the customer and invalidates approved finals.
**Redesign (2026-09-24):** tabs for Produção, Cotações, Pagamentos and
Integrações; an order panel with stages, items, print files, final files and
history; numbered pagination (20 per page) on quotes, payment issues and the
integration log, which also has filters; messages dismiss themselves. The
operator guide (`docs/guia-operador-kanban.pdf`) documents the flow for the
operation to confirm.
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
and ships only fake adapters, so the deployed system cannot take an order at all.
Real freight, payment initiation and verified provider events are required to
unblock it; the fake webhook alone does not.
---
## Block 2 · Security — before any public exposure
### `[x]` 2.1 — Rate limiting and audit logs are blind to the client `(F5)`
uvicorn runs without trusted proxy headers (`forwarded_allow_ips` defaults to
`127.0.0.1`; nginx is a different container IP), so `request.client.host` is nginx
for every request. `rate_limit('auth-source', ...)` at 60/15min becomes a single
global bucket — **60 failed logins lock out every customer** — and every `audit()`
record has no attacker IP.
- Set `--proxy-headers` with `FORWARDED_ALLOW_IPS` scoped to the nginx service, or
read `X-Forwarded-For` explicitly at the edge.
- **Accept:** two clients on different IPs have independent buckets; audit rows
carry the real IP.
### `[x]` 2.2 — The public site throttles itself `(F6)`
`local/app.py:115` — `rate_limit('guest-sessions', ENVIRONMENT, 120, 900)` is keyed
on the environment name: 120 new visitors per 15 minutes **site-wide** (~8/min).
Normal traffic 429s. Key per source IP (after 2.1) and raise the ceiling.
### `[x]` 2.3 — `deploy/stack.yaml` cannot boot `(F7)`
It passes `DATABASE_URL_FILE`, `AWS_ACCESS_KEY_ID_FILE`, `OPERATOR_PASSWORD_FILE`,
`OPERATOR_USER`. The code reads `DATABASE_URL`, `AWS_ACCESS_KEY_ID`,
`OPERATOR_PASSWORD`, `OPERATOR_EMAIL`, and no `_FILE` loader exists.
`app.py:58` does `os.environ['OPERATOR_PASSWORD']` → `KeyError` → 500 instead of 503.
- Implement `local/secrets.py` (the preflight already expects it) reading `*_FILE`
with env fallback. Reconcile `OPERATOR_USER` vs `OPERATOR_EMAIL`.
- **Accept:** the stack renders and boots against Swarm secrets; missing operator
config yields 503, not 500.
### `[x]` 2.4 — The documented release gate does not exist `(F8)`
`PORTAINER.md` and `SECURITY_REPORT.md` claim the workflow runs the full isolated
suite, Trivy HIGH/CRITICAL image gates, secret scanning and the source preflight
before calling Portainer. `.gitea/workflows/deploy.yml` runs `py_compile` plus four
unit tests, then builds, pushes `latest` and calls the webhook **unconditionally**.
`deploy/production_preflight.py` is never invoked — only its unit test runs.
- Either implement the gate or correct both documents. Do not leave the gap.
### `[x]` 2.5 — The preflight has silently decayed `(F9)`
It blocks by string-matching source. **4 of 6 markers are dead** after the R2
refactor: `'This runtime only supports APP_ENV=local'`,
`'Only local S3 storage is supported'`, `"allowed_hosts=['localhost', '127.0.0.1']"`,
`"'environment': 'local'"`. String gates weaken without failing.
- Replace marker matching with behavioural assertions (import the module, assert
the adapter classes in use).
### `[x]` 2.12 — Two divergent stack definitions; the docs named the wrong one
Found 2026-09-21 by asking which file Portainer deploys. `PORTAINER.md` called
`deploy/stack.yaml` "the production stack"; the deployed file is the repository's
`docker-compose.yml`. `deploy/stack.yaml` came from the first commit and was never
deployed — it supplied credentials as Docker secrets where the deployed file uses
plain environment variables.
**Decision (2026-09-21): keep `docker-compose.yml`, delete `deploy/stack.yaml`.**
The gain from Docker secrets here is narrower than it sounds. It keeps values out
of `docker inspect` and the Portainer UI, but `local/secrets.py` loads them into
the process environment anyway, and anyone who can read `docker inspect` is
already root or in the docker group and could read the secret files directly. The
operator is the only Portainer user, so the main benefit — limiting what a
lower-privileged console user can see — does not apply. Maintaining two
definitions that drift was the larger real cost.
`local/secrets.py` stays. It is inert against the deployed file and costs nothing,
and it means a stack can switch to Docker secrets later without a code change.
Still open: **rotate the R2 secret key.** Not because of Portainer, but because it
grants read and write over every customer's artwork and has been readable from the
stack environment for some time. The operator password is worth rotating with it.
### `[x]` 2.6 — Base images are not pinned `(F10)`
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
while `PORTAINER.md` documents digest-pinned immutable bases.
### `[x]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
Vendored rather than integrity-pinned, so the Site no longer depends on a third
party being reachable and honest when a customer opens it. Both files are served
from this origin and their provenance is recorded in `local/static/vendor/README.md`,
verified against the SRI digests cdnjs publishes for 3.11.174.
`cdnjs.cloudflare.com` is gone from `script-src`, `worker-src` and `connect-src` in
both gateway templates: scripts and workers are now `'self'` plus `blob:` for the
worker the Site builds itself.
Verified in a browser against the running stack: pdf.js loads from `/vendor/`,
the blob worker starts, and a real 7-page PDF parses with no CSP violation. Both
browser suites and the full integration suite pass.
**Still open: the version.** 3.11.174 is old. GHSA-wgrm-67xf-hhpq is mitigated —
`dtf-site.html` already passes `isEvalSupported: false`, which is the documented
workaround — but staying on it indefinitely is not a posture. Upgrading is an API
change rather than a file swap and needs its own browser testing, so it is
deliberately not bundled here.
### `[x]` 2.8 — Single shared operator credential `(F12)`
Accounts now live in `dtf_local.operators`, one per person, with `movements.operator`
and `order_files.created_by` recording who actually acted. Administered from the API
container with `python3 -m app.operators` (list, add, password, disable, enable);
passwords are read from the terminal so they never reach shell history or the
process list, and disabling revokes open sessions immediately rather than leaving
them valid for the rest of the eight-hour window.
Migration was the risk, since getting it wrong locks the factory out of the Kanban.
`OPERATOR_EMAIL`/`OPERATOR_PASSWORD` seed the first account, once: a password
changed through the CLI is never reverted by a stale environment variable on the
next deploy. The first attempt did not work — `db-init` was not given those
variables in either compose file, so no account would have been created and login
would have failed closed with 503. Both files now pass them to the migration job.
Verified end to end: the unchanged credential still logs in, a second operator
authenticates separately, wrong passwords and unknown accounts are rejected, and
disabling ends access at once.
**Roles are deliberately not included.** The meeting described separation of duties
for rework authorisation (Mayana classifies, Thales or Alexandre authorise), but the
rework feature does not exist in this system, so there is nothing for a role to
gate. Building an authorisation model with no consumer would be guesswork. Add roles
with the feature that needs them.
### `[~]` 2.9 — TLS is terminated outside the repository `(F13)`
Downgraded 2026-09-21. The stack publishes plain HTTP on 18080/18081 while
`COOKIE_SECURE: "true"`, and nothing in the repo provisions certificates — but
`nginx-proxy-manager` on the host owns 80/443 and terminates TLS in front of it,
so cookies are not being dropped in practice. This is undocumented operational
knowledge rather than a live defect.
What remains: record the proxy in `PORTAINER.md` as part of the deployment
contract, so nobody moves the stack to a host without one and silently breaks
every session cookie. `TAREFAS.md` A2 still lists the certificate as pending;
confirm it is actually issued for the DTF subdomain.
### `[ ]` 2.10 — No email verification, no password recovery `(F14)`
A locked-out customer has no path back, and registration accepts any CNPJ without
proving control of the e-mail. Needs a transactional mail provider — **client input**.
### `[ ]` 2.11 — LGPD `(F15)`
CNPJ, phone and e-mail are kept indefinitely in `accounts.profile` and
`orders.snapshot`. Artwork has a 30-day policy; personal data has no defined
retention, export or deletion path. The Site links an external privacy notice;
confirm that it covers this processing and define the required records and
customer rights flow before production activation.
### `[x]` 2.13 — A foreign paid quote ID exposes an order (2026-09-22 review)
The `dev-paid` refusal fallback fetched `orders` by `quote_id` without `owner`.
A separate local customer session received the full paid order when supplied
another customer's quote ID. `app/api/orders.py` now includes the owner in the
fallback query. The local payment integration test confirms a foreign ID returns
404 while the owner can still retrieve the already-paid order.
### `[x]` 2.14 — A signed approval without a paid amount creates an order
`app/payments.py` compared amounts only when the event contained one. A local
signed `approved` event without `amount_cents` created an order. The service now
requires an actual integer amount equal to the approved total; local integration
tests cover missing, non-integer, underpaid and correct values. Currency and
provider payment identity belong to the wider contract in 3.7; this gate does
not complete 1.1.
### `[~]` 2.15 — Public intake controls need operational proof
Unfinished reservations now have a one-hour lease and owner-scoped cancellation;
anonymous admission capacity still needs a firm bound. Keep ClamAV signatures
current and alert on stale data; scope reverse-proxy IP trust
to the actual hop and verify it through Swarm ingress. These are separate
controls, but all must work before public large-file intake is considered safe.
The production topology has not been verified by the repository review.
---
## Block 3 · Architecture — needs a decision before code
### `[~]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
**Decided 2026-09-28 (the user: "we are an e-commerce"):** a cart the Site
priced is approved when the quote is created and can be paid at once
(`app/quote_review.py`, the same pricing the operator's approval uses). A
person reviews only orders above `QUOTE_AUTO_MAX_METRES` (50 m) and items
that claim a grade the Site could not have computed (unanalysed art with a
discount). The Kanban lists automatic approvals and the reason for each
manual one. **Still open:** the grade and layout are the browser's (3.2,
3.9), so a customer who edits the page can claim a better grade, up to the
top tier's discount; the server must compute the grade before this closes.
Previously:
Payment requires `quotes.approved`, set only by an authenticated operator. The
meeting's premise was that the 17h30 order waiting until 5am is what costs the
money. As built, a 2am order still waits for a person. `CONTEXT.md` frames this as a
temporary development trust boundary — the risk is that it silently becomes the
delivered model.
**Decide:** what makes a quote auto-approvable (mode, metre range, grade floor,
returning customer), and what still routes to a human.
### `[?]` 3.2 — Billable metres are computed in the customer's browser `(F17, F18)`
For loose artwork, `metros` comes from `desenhaMontagem`/`encaixar` — a canvas
alpha-mask packer running client-side. The server never recomputes it.
`passoDe()`/`CELULAS_MAX` coarsen the grid for large sheets and image decoding
differs by browser, so **the same cart can price differently on different devices**.
The code comments reference "o motor do servidor"; that engine does not exist.
Worse, the layout the customer is quoted on is never produced — operators upload
final files by hand, so billed metres ≠ printed metres and a designer redoes work
the site already did.
**Decide:** port the packer to the server as the pricing authority and the print-file
generator, with the browser as preview only. This is the single largest gap between
what was promised in the meeting and what exists.
**2026-09-30:** the grade (the resolution discount) is no longer taken on trust.
Before an automatic approval the API recomputes it from the uploaded files by the
Site's rules (`app/grade_check.py`): image headers for PNG/JPG/WebP, the placed
images of a PDF up to 150 MB. A claim more than 2 points above the file's grade,
or a discount on a file the server cannot grade, waits for review with the reason
on the Kanban. The metres (the packing) are still the browser's.
### `[~]` 3.3 — The 5 GB problem is unsolved `(F19)`
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
≤ 128 MiB. As of 2026-09-23, customer selection and API reservation reject files
above the effective scan limit before transfer, and the Site displays the current
limit. This prevents a doomed upload; it does not deliver the promised 5 GiB path.
This is exactly the risk Jorge raised in the meeting.
**Decide:** raise the scan ceiling with a resource/timeout design, or define an
explicit large-file path (staged scan, sampled scan, operator override with audit).
**Built 2026-09-29 (sheets of several GB are the normal order, not the
exception):** files up to 5 GB. ClamAV scans up to 2 GB (`StreamMaxLength
2000M`); above that a file is released only when its first bytes match the
format its name claims (option A, the user's choice). The Site grades a sheet
over 150 MB from the pixel size in the PNG/JPEG/WebP header without decoding
it, and measures large PDFs through ranged reads; the worker never opens a
source over 300 MB: a single finished sheet placed whole becomes its own print
file, anything else goes to hand preparation. Uploads start as items enter the
cart and the lease renews with each part. Verified on the local stack: 386 MB
(ClamAV 78 s), 1.8 GB (ClamAV 6 min 18 s, scanner under 430 MB of memory, the
original as print file), 2.3 GB PNG released by the format check and a
disguised 2.3 GB file refused, and the header grade of a 200 MB file in 0.5 s.
**Open:** large PDFs get no automatic grade (the DPI of images inside is not
read without rendering); the scanner takes one file at a time, so several
multi-GB uploads queue; pieces, residue and background of a large sheet are
not checked automatically.
### `[ ]` 3.4 — Upload throughput `(F20)`
8 MiB parts, strictly sequential in `local/static/upload.js:21`, one presign
round-trip per part → ~640 sequential API calls for a 5 GB file, through an nginx
`limit_req` of 20r/s. Add parallelism (4–6 in flight) and batch presigning.
### `[~]` 3.5 — Payment ordering `(F27)`
The local fake `pay` call now runs inside the order transaction, and the inbound
webhook records and applies a delivery transactionally. This does not make an
external charge atomic with PostgreSQL: a provider can succeed while the database
write fails, or deliver the approval later. Add a durable payment intent,
provider idempotency key and reconciliation as part of 1.1 and 3.7.
### `[~]` 3.6 — Preserve and bind the order the customer actually reviewed
The browser's width, copies, rotation, mirroring and repetitions are absent from
the API item, so the factory cannot reproduce the priced layout. Removing an
artwork can leave a stale cart item; editing after quote creation can leave the
old quote payable; a new correction can leave an obsolete final active. Persist
a versioned per-file production specification, tie the displayed cart to its
immutable quote, and tie final approval to the latest correction revision.
Cover the real editor-to-quote-to-final journey, not only the pricing table.
**Local progress 2026-09-23:** The Site includes per-upload width, length,
copies, rotation, mirroring, measurement source, and the exact placement of
each copy in production specification v2. The API checks coverage, dimensions,
film bounds, and quote height; commercial review cannot replace the layout.
The order snapshot and downloadable Kanban manifest retain it. Browser quote actions are disabled when
the cart differs, including same-price changes. Editor changes invalidate the
current cart item immediately; a new customer correction deactivates prior
finals. Browser and local API regressions pass. **Still open:** generate and
validate the final print file from the approved source revision, and
make quote/cart continuity work across devices through a server-authoritative
confirmation flow. Current quote binding is a browser guard.
### `[?]` 3.7 — Complete payment state and reconciliation rules
Event-ID deduplication does not establish which provider payment settled which
quote. Define intent creation, provider transaction ID, currency, paid-at time,
pending/rejected/refunded/cancelled states, late approval after quote expiry,
overpayment and provider success followed by database failure. Record refused
paid events for resolution. Decide who reconciles them and when production must
stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
### `[~]` 3.8 — Collect a deliverable destination before charging freight
The quote has a shipping service and CEP but no recipient, street, number,
city/state or delivery snapshot. Add and validate these fields with 1.2, then
bind the chosen service and final freight amount to the payment intent.
**Local progress 2026-09-24:** the Site collects recipient, street, number,
complement, district, city and UF for delivery; the API requires them for any
non-pickup quote, requires the CEP to be the one freight was quoted for, and
refuses an address on pickup. The address is part of the reviewed quote, the
order snapshot, the Kanban card and the Tiny payload. Changing it after a quote
invalidates that quote in the browser like any other cart change. Binding the
chosen freight service to the payment intent waits on 1.2.
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
Reject or route for review when PDF page count/geometry, image decoding or DPI
cannot be established. Do not infer pixels from compressed file size, grade a
mixed item from only the readable files, silently shrink oversized artwork, or
allow a displayed DPI rejection to proceed through checkout. Use representative
real artwork in acceptance checks.
**Local progress 2026-09-22:** DPI refusal and warning acknowledgement now gate
the cart and quote API records the acknowledgement. Oversized loose-art width
is rejected in the UI, API production contract, and packer. On 2026-09-23,
unreadable loose images are blocked, mixed analyzed/manual sheets receive no
automatic grade or discount, and rotated DPI uses the pixel dimension that
corresponds to printed width. PDF dimensions now come from the parsed page
model, with page count, crop, rotation, and UserUnit checks; multi-page and
malformed PDFs block quoting. Isolated browser checks cover those cases and
same-origin PDF rendering. Unsupported PDF image operators and representative
print-file evidence still need correction before this item can close.
---
## Block 4 · Scale and performance
- `[x]` 4.1 — Ten indexes added, each matched to a query the application issues,
and no more: every extra index is paid for on each write. The outbox and live
uploads use partial indexes so they stay the size of the backlog rather than of
all history. Verified against the running database — the planner chooses
`outbox_pending` and `orders_owner` for the queries they exist for.
- `[x]` 4.2 — The board returns every order still in progress, however old, plus a
window of recent finished ones (`BOARD_FINISHED_LIMIT`, default 50) and the true
finished total. An operator can never lose a card they could act on; only terminal
ones are trimmed. The Kanban column reads "Finalizado · 50 de 213" when truncated,
so the count is not mistaken for an all-time total. Pending quotes now have
a paginated view; older completed orders still need search in 4.6.
- `[ ]` 4.3 — Scan throughput `(F21)`: one `scan_loop` thread, `worker` at
`replicas: 1`, ClamAV `MaxThreads 2`, browser gives up after 150s.
- `[x]` 4.4 — Failed image decoding no longer infers pixels from compressed
file size. Unreadable loose images cannot enter the cart or receive a grade;
an isolated browser regression covers the failure path (2026-09-23).
- `[x]` 4.5 — Dead config `(F28)`: resolved by deleting `deploy/stack.yaml` in 2.12.
`CLAMD_HOST` no longer appears anywhere; `scanning.py` reaching `'scanner'`
directly is now simply how it works, not a contradiction.
- `[~]` 4.6 — Older unpaid quotes can disappear behind the board limit.
On 2026-09-23 the board began showing newest pending and approved unpaid
quotes separately, with cursor pagination and counts; a local regression
retrieved all 105 pending and 22 approved fixture quotes and cleaned them up.
**Still open:** an explicit terminal state for abandoned/expired quotes and
search/history for older completed orders.
---
## Block 5 · Hygiene and maintenance
- `[x]` 5.1 — CI coverage `(F33)`. An `integration` job now builds the localhost
stack and runs smoke, workflow, security, scanning, retention, runtime security
and both browser suites; `publish-and-deploy` depends on it. Verified by
reintroducing the 0.1 defect: `py_compile` and the unit tests still passed while
`smoke_test` failed on `/session`, blocking the release. Browser tests skip with
a warning when the runner has no Chrome — **install `google-chrome-stable` on the
runner (or set `CHROME_BIN`) to make them gate as well.**
- `[x]` 5.2 — `portal/`, `kanban/`, `agente/`, the root `schema.sql` and
`.env.exemplo` removed: 2,283 lines implementing a model this system abandoned,
referenced by nothing, with several endpoints taking the acting user from the
request body. The documents describing them are archived under `docs/historico/`
with a header saying they are background, not instructions.
- `[x]` 5.3 — Root `requirements.txt` deleted. It pinned by wildcard, listed
packages the system does not use, and sat next to the hash-locked
`infra/requirements.lock` inviting the wrong one to be installed. Only historical
documentation referred to it.
- `[x]` 5.4 — `pip` removed from `infra/requirements.txt` and from the lock. Nothing
depended on it; it was pinned only because it was listed directly, and installing
it put a package manager inside the read-only runtime image. The base image's own
pip performs the hash-enforced install. Verified: the image builds under
`--require-hashes` and reports the base pip, 25.0.1.
- `[ ]` 5.5 — Doc drift `(F34)`. `README.md`, `CONTEXT.md`, `LOCAL_SETUP.md` and
`SECURITY_REPORT.md` describe a MinIO localhost stack, an API with "no external
network route", a `operator` / `local-operator-only` login the email-validated
model rejects, and a release gate — none match the current tree.
- `[x]` 5.6 — The Site's 1,575-line inline script is now nine files under
`local/static/`, cut at the author's own section boundaries so no function was
split: config, modes, upload, sheet analysis, PDF, quality, packing, cart, flow.
`dtf-site.html` is 1,394 lines of markup and style. The extraction was verified
byte-identical before the tags were swapped in, and they load as classic scripts
in the original order, so evaluation semantics are unchanged.
A consequence worth having: with no inline script anywhere, the policy needs no
hash allowlist and is now simply `script-src 'self'`. `site-packing.js` is also
where a server-side packer (3.2) has to agree, which was the point of splitting.
- `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python)
`(F26)`. `test_pricing` guards parity; generate one from the other instead.
- `[ ]` 5.11 — Nothing tests the schema against an empty database. The 4.1 indexes
were added next to the existing one, which sits before the tables they name, so
`CREATE INDEX ... ON dtf_local.order_files` ran before that table existed. Every
local run passed because the volume already had the tables; CI caught it on its
clean volume. A migration is only really exercised from nothing, so the
integration job should run `down -v` before `up` — or a dedicated step should
apply `schema.sql` twice to a fresh database, proving both a first install and
a re-run.
- `[ ]` 5.10 — The browser suites were moved into a Chrome container on the
Compose network and made required in CI. Confirm the complete checkout journey
passes in that topology and on the actual Gitea runner before closing this item.
- `[ ]` 5.9 — `local/browser_test.mjs` failed once and passed on an immediate
re-run, with no code change in between (2026-09-21). It is a deploy gate when the
runner has Chrome, so an intermittent failure there blocks releases for no reason.
Suspect Chrome startup timing or a race against stack readiness. Watch it, and if
it recurs add an explicit readiness wait rather than a retry.
- `[x]` 5.8 — The Site claimed 90-day file storage and 12-month history in two
places, and invited customers to reorder "sem subir de novo". Files are kept 30
days. The copy now states 30 days, says a later order needs the file again, and
keeps only the true part: order history remains in the account. Policy unchanged;
the promise was corrected to match it.
- `[x]` 5.12 — Repair operational entrypoints after the `local/` split.
On 2026-09-23, staging and both API images package `ops/`; staging calls
`ops.staging_readiness`; local backup calls `ops.storage_backup` through
`compose.local.yaml`; documented security and backup commands use the real
modules. Verified a network-disabled staging pass with non-secret fixture
data, a production API image import, local security status, and a local
backup/restore of the database plus 78 clean objects. Production offsite
recovery and signature freshness remain separate open items.
- `[x]` 5.15 — MinIO stopped publishing public images: by 2026-09-24 both
Docker Hub and quay.io answered anonymous pulls with 401, so a runner or
machine without a cached image could not start the stack. The local/CI
storage and storage-init now use Chainguard's MinIO build (ships `sh` and
`mc`, non-root), pinned by digest. Verified with a fresh local build and the
full integration sequence. Production uses R2 and is unaffected.
- `[x]` 5.16 — The operator login limit (10 per account per 15 minutes) counted
successful logins too, so ordinary use could lock an operator out, and one
extra test login made CI's final browser sign-in fail with 429. Now every
attempt counts against the source address and only failures count against
the account; registration still counts every attempt. The security suite's
lockout check (ten failures, then 429) is unchanged and passes.
- `[x]` 5.17 — Site redesign (2026-09-24), Dropstar brand kept; the previous
look is tag `ui-v1`. The home, one page per product (`/arquivo-por-metro`,
`/artes-avulsas`, `/uv-arquivo-por-metro`, `/uv-artes-avulsas`) and
`/carrinho` have their own addresses but stay one document, so artwork held in
the browser survives moving between them (`web/site-pages.js`,
`web/site-steps.js`; nginx serves `index.html` for those paths). The product
page is built around a buy box (`web/site-compra.js`) that shows the item the
flow already priced (grade, price per metre, metres charged, total,
resolution acknowledgement and "Adicionar ao carrinho"); the duplicated
quality and preview panels are hidden. The cart has "Remover" per item,
"Esvaziar carrinho" and an 8-second undo. Fixed on the way, all already in
production before: the saved-cart note took a grid column and pushed the
order into a narrow strip, the panels outside `.w` ran edge to edge, and
"57 cm" wrapped on the ready-sheet option. The browser suite covers product
and cart addresses, reload on a product page, and remove/undo; the security
suite checks the new addresses carry the same CSP.
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
and object backups, a consistent snapshot boundary, Swarm data placement and
a restore rehearsal that opens every required live order file.
**2026-09-29:** database part built. The `backup` service dumps daily,
encrypts with age (the server holds only the public key) and uploads to a
bucket of its own, whose lifecycle rule expires copies and whose lock keeps
them from being deleted early. The Kanban shows the latest run, and
`tests/backup_test.py` restores a copy in CI. Setup and restore:
docs/BACKUP.md. Artwork is not copied: it is temporary and already on R2.
- `[ ]` 5.14 — Promote and verify one immutable release. **2026-09-24:** green
pushes to `main` now publish images and Portainer's pull-and-redeploy is the
release gate; the source preflight is advisory unless enforced by variable,
because enforcing it while the adapters are fake made every release fail.
Previously: normal `main` pushes ran checks only; manual dispatch required source preflight and a configured
webhook, and scans images before publishing. Still make the full preflight
validate the active stack, deploy the tested immutable image references, test
clean install and upgrade, check application readiness after Portainer
redeploys, and isolate concurrent CI stacks.
---
## Done
### Week 1 — infrastructure, uploads, service base
- `[x]` Compose stack: Site, Kanban, API, PostgreSQL, worker, ClamAV, R2/MinIO storage.
- `[x]` Gitea + Portainer publication path; web service startup and API rollout fixed.
- `[x]` Database passwords with special characters handled via discrete libpq fields.
- `[x]` Kanban e-mail/password login; missing operator config no longer breaks stack boot.
- `[x]` Direct resumable multipart browser → private object storage.
- `[x]` Quarantine + ClamAV gate: only `clean` files can be quoted, paid, downloaded or queued.
- `[x]` Retention worker: incomplete 1d, rejected 3d, originals 7d after approval, finals 30d.
- `[x]` Server-side pricing authority with parity test against the Site JavaScript (4,444 cases).
- `[x]` Loose-artwork packing flow: per-file width, copies, rotate, mirror, live 57 cm preview,
5 mm gap, ruler and watermark preserved; metres follow packed height.
- `[x]` Rotation/mirror applied to packing masks; stale renders no longer overwrite a newer preview.
- `[x]` Hash-locked Python dependencies; Trivy reports in `output/security/`.
### Block 0 — 2026-09-18
- `[x]` `import os` restored in `local/auth.py`; `COOKIE_SECURE` is now a single
module constant shared with `local/app.py`.
- `[x]` Navigation links no longer preselect a product (`data-modo-cta` removed).
- `[x]` Ready sheet vs loose artwork is an explicit, priced, reversible selector
(`#tipoEnvio`), shown in all four modes and locked once a file is attached.
The product is the declaration; `sel()` no longer switches anything silently.
- `[x]` `medirFolha` returns `dpiFolha`; an image that cannot span the film width
at `DPI_RECUSA` is refused as a sheet, with one click to send it as loose artwork.
- `[x]` `pintaCaminhos` scoped to `#caminhos .cam` — its global `.cam` selector was
clobbering the new control.
- `[x]` `compose.local.yaml` restored (MinIO, fake providers, builds from source).
- `[x]` `APP_DB_PASSWORD` separated from `POSTGRES_PASSWORD`, with a `bootstrap.py`
guard that refuses identical credentials in both configuration forms.
- `[x]` Checkout confirmation moved out of the panel the success path hides.
### Block 2 and CI — 2026-09-18
- `[x]` The web gateway overwrites `X-Forwarded-For` with the peer address instead
of appending to it, and `client_ip()` resolves the requester for rate-limit
buckets and security events. Verified: a forged `203.0.113.99` never reaches the
audit trail.
- `[x]` Guest sessions are limited per source. The first attempt used 30/IP, which
the new regression caught as too tight for shared NAT — recreating the original
fault in a narrower form — so the ceiling is 240 per 15 minutes, overridable with
`GUEST_SESSION_LIMIT`.
- `[x]` Security events now carry the source address (`operator_login_failed`,
`customer_login_failed`, `cross_origin_rejected`, `http_security_event`).
- `[x]` CI runs the integration suites against a real stack before publishing.
### 2026-09-21
- `[x]` 2.3 — `local/secrets.py` resolves every `<NAME>_FILE` into `<NAME>` from the
API, worker and bootstrap entrypoints, failing closed on an unreadable or empty
secret and on a value supplied both ways. Verified by booting the API, the worker
and bootstrap with credentials supplied only as mounted files, including a
password containing `:/?#[]&=+$ ,%`. `OPERATOR_USER` in the stack became
`OPERATOR_EMAIL`, which is what the runtime reads.
- `[x]` 2.5 — The gate now loads `local/secrets.py` and makes it resolve every
secret `deploy/stack.yaml` declares, plus asserts it fails closed. Verified
against a no-op loader (11 blockers) and one that swallows a missing file
(1 blocker); only the real implementation passes. The four marker strings that
stopped matching when R2 support landed were removed; the two describing real
blockers stay, so the gate still refuses a release while the payment and
messaging adapters are fake.
- `[x]` 2.4 — A blocking Trivy secret scan was added and verified both ways: a
planted AWS key pair, GitHub token and private key block the job; the repository
passes clean. Worth knowing: Trivy allowlists documented example credentials, so
my first probe passed with AWS's own sample keys — the gate is a backstop, not
permission to commit secrets. The source preflight now runs and always prints its
verdict, enforcing only when `ENFORCE_PRODUCTION_PREFLIGHT` is `true`; enforcing
it today would block every deploy, since it refuses a release while the adapters
are fake. Image vulnerabilities are reported after each build, not enforced —
56 HIGH and 3 CRITICAL, only 15 with an upstream fix. `PORTAINER.md` and
`SECURITY_REPORT.md` now carry a table of what gates and what does not, replacing
descriptions of checks that never ran.
- `[x]` 2.6 — Both bases pinned by digest, OS packages upgraded in the production
images, and the web image moved off the nginx 1.28 line.
| Image | Before | After |
|---|---|---|
| API | 56 HIGH, 3 CRITICAL (15 fixable) | 46 HIGH, 0 CRITICAL |
| Web | 5 HIGH, all unfixable in place | 0 HIGH, 0 CRITICAL |
The 1.28 nginx pins `nginx=1.28.3-r1` in `/etc/apk/world`, so `apk upgrade`
cannot patch it even though Alpine ships `-r7`; `nginx:alpine` (1.31.6) is clean
while `1.29-alpine` scans worse at 37 HIGH. The two remaining "fixable" API
findings are `msgpack` and `setuptools`, which I confirmed are absent from the
built image rather than trusting the earlier report. Local images now share the
pinned bases, so the integration suite exercises what ships; full suite passes on
nginx 1.31.6. With both images at zero CRITICAL, the image scan now **gates on
CRITICAL** and reports HIGH.
### 2026-09-21 — from the runner host inventory
- `[x]` Fixed a regression in 2.1: the production gateway sits behind
`nginx-proxy-manager`, so `$remote_addr` there is the proxy, not the customer.
Overwriting `X-Forwarded-For` with it would have recorded the proxy's address for
every request in production — the same bug 2.1 set out to fix. The gateway now
uses `real_ip` to recover the customer's address from the proxy's header, trusting
only private networks, so a request arriving directly at the published port
cannot spoof it. Validated with `nginx -t` against the rendered config.
- `[~]` 2.9 downgraded: TLS is terminated by that proxy, not missing.
### Reporting
- `[x]` Operator guide (`docs/guia-operador-kanban.pdf`) and Site guide
(`docs/guia-site-dtf.pdf`), 2026-09-24, with screenshots of a throwaway stack
and fictional orders. The payment step has no screenshot until Mercado Pago
is configured, and the guides name the provisional Kanban domain; regenerate
them when either changes. Early work toward Week 3's "orientação à operação".
- `[x]` Operator guide rebuilt from a committed source (2026-09-25):
`docs/guias/operador/` (HTML and the original screenshots, cropped as
before), printed by `docs/guias/imprimir.sh`. The source of the 09-24 PDF
was never committed. Corrected: stage moves do not update Tiny (only
"Aprovada" and, for pickup, "Pronto para envio", once enabled); WhatsApp
notices go through the client's Tiny -> n8n flow; the correction notice is
not automatic, the reason is in Minha conta; the Tiny card's renewal,
"Verificar" and "Testar conexão" checks. Found while writing it: the
customer's order history showed operators' internal reasons for moving an
order back; it now omits back moves and shows reasons only for corrections.
The Site guide's source is also not in the repository.
- `[x]` Week-2 client report (`Relatorio-Semana-2-DTF.docx`), written 2026-09-25,
sent 2026-09-28. Before sending, the FlexiPRINT import was taken out of
"O que falta" (it is done this week) and the print-file paragraph no longer
names PSD, AI, CDR or multi-page PDFs as hand-preparation cases. **Reversed
2026-09-29:** those formats stay as built: no automatic check, the full rate
per metre, and the operator prepares them by hand. Automatic generation is
not planned; the sent report omits them from its list of exceptions. Freight is reported as Jadlog data received,
waiting on packaging weight and dimensions and the charging policy.
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to
remove the inaccurate "Arquivo por metro permanece separado, com seleção explícita"
claim and the internal commit reference.