Industrial PPE (Personal Protective Equipment) management system. NR-6 and eSocial S-2240 compliant. Single Python process, no web framework. In production at a Brazilian torque-converter remanufacturing plant since September 2025.
Brazilian labor regulation NR-6 requires every employer to (a) issue role-appropriate PPE to every worker, (b) record each delivery with the employee's signature, and (c) preserve that record for 20 years (TST jurisprudence). Without that trail under a Ministry of Labor audit, the company is exposed to uncapped labor-court damages and operational shutdown.
Off-the-shelf options on the Brazilian market are heavyweight ERP add-ons priced for companies the plant does not have. epi.manager is one Python process and one SQLite file on a LAN, sized for the team that uses it.
- PPE catalog with Certificate of Approval (CA), validity windows, manufacturer, lot and per-product service life — CA tracking is required by NR-6.
- Employees with role, registration number and CPF.
- PPE delivery, single item or multi-item batch, with a signed PDF per delivery. The whole batch rolls back if any item is short.
- EPI lifecycle, proactively: from each product's service life and the last delivery, the system computes the replacement-due date per worker and surfaces overdue/upcoming swaps on its own.
- Per-worker EPI card: everything a worker currently holds, with replacement status and one-click re-issue.
- Function PPE matrix: the mandatory PPE of each role; the system flags by itself which active workers are missing a required item or holding an expired one.
- Coverage-based stock health: "low" / "buy now" is measured in days of coverage (stock ÷ average consumption), not a fixed quantity — so it adapts to how much each item is actually used (5 helmets ≠ 5 ear plugs). Optional per-product manual minimum as a fallback when there is no consumption history yet. Auto-reorder also suggests how much to buy.
- Pre-configured kits per role (welder, mechanic, electrician).
- Compliance dashboard with a "today's pending actions" hub: replacement swaps, function gaps, reorder needs, CA expiration alerts, NR-6 90-day inspection cycle, stock thresholds, daily and monthly aggregates.
- Audit trail preserved for the 20 years required by NR-6 (soft-delete + JSON snapshot of every deletion).
- Self-maintenance: a background daemon purges expired sessions and snapshots the database (online backup, rotated) — no operator action.
- Keyboard-first UI: ⌘K command palette,
g+ letter navigation, theme toggle.
- No web framework.
http.server.ThreadingHTTPServerfrom stdlib. Routing is a flatif/elifladder inapp.py:do_GET/do_POST, ~700 lines readable in one sitting. Trade-off discussed below. - Custom multipart parser. Python 3.13 removed
cgi.FieldStorage;MultipartForminapp.py(~60 lines) reimplements it on top ofemail.parserfrom stdlib. No new runtime dependency. - Thread-safe SQLite via
threading.local()connection-per-thread + WAL. Replaces an earlier singleton withcheck_same_thread=False, a documented anti-pattern underThreadingHTTPServer(write-up inCRITICA.md). - Atomic stock decrement under concurrent withdrawal:
UPDATE stock SET qty = qty - ? WHERE qty >= ?plus arowcount == 0check inside the transaction. TOCTOU coverage intests/test_assignments.pyuses a monkey-patched saboteur to force a depletion between check and update. - CSP with per-request nonce on inline
<script>.script-src-attr 'unsafe-inline'(CSP3) is a known compromise for legacyonclick=attributes and is scheduled for removal once the inline JS is extracted. - CSRF double-submit cookie, ~30 lines,
secrets.compare_digest. - Password hashing: PBKDF2-SHA256, 200k iterations, 16-byte per-user salt. Sessions persisted in SQLite (survive restart, not bound to a single worker).
- CPF / LGPD: normalized to 11 digits at the service boundary,
CHECK (cpf GLOB '[0-9]...')at the schema,CpfMaskFilterrewriting11144477735→111.***.***-35in every log record before it leaves the process. - Soft-delete with snapshot.
products.deleted_at+ JSON snapshot indeletion_audit. Hard-delete would violate the 20-year retention requirement. - Defense-in-depth schema.
CHECKconstraints on every table (enums, qty > 0, cost ≥ 0, ISO date format, CPF format),ON DELETE RESTRICT/SET NULL/CASCADEper relationship intent. Direct writes via the SQLite CLI still respect the invariants. - Unified delivery view.
v_deliveriesfolds individual withdrawals (assignments) and kit/batch items (withdrawal_items) into one source, so the per-worker EPI card and the replacement-due math see every delivery without having to unify the underlying tables first. Replacement date islast delivery + lifespan_days. - Background self-maintenance. A daemon thread purges expired sessions and takes a consistent online backup of the database (
sqlite3backup API, WAL-safe), keeping the last N rotated copies. Runs off the request hot path; fully env-configurable.
flowchart TB
Browser[Browser]
Server[ThreadingHTTPServer<br/>:8140]
Handler[EpiRequestHandler<br/>do_GET / do_POST<br/>~1.1k lines]
Auth[core/auth.py<br/>session + CSRF + PBKDF2]
Multipart[MultipartForm<br/>email.parser<br/>custom]
Services{{services/}}
DB[(SQLite + WAL<br/>12 tables)]
PDF[pdf/receipts.py<br/>ReportLab]
Audit[(deletion_audit<br/>20-yr trail)]
Logs[CpfMaskFilter<br/>LGPD-safe logs]
Browser -->|HTTP on LAN; TLS planned| Server
Server --> Handler
Handler --> Auth
Handler --> Multipart
Handler --> Services
Services --> DB
Services --> Audit
Services -->|out of tx| PDF
Auth --> DB
Services -.-> Logs
subgraph "services/"
S1[products]
S2[employees]
S3[stock]
S4[assignments]
S5[kits]
S6[compliance]
S7[audit]
S8[dashboard]
end
A request flow for the PPE delivery hot path:
POST /assign
→ require_auth() — session lookup in SQLite
→ validate_csrf() — double-submit cookie check
→ svc_assignments.assign_epi(conn, product, employee, qty)
→ normalize_cpf(employee_cpf) # 111.444.777-35 → 11144477735
→ BEGIN TRANSACTION
→ check employee exists
→ UPDATE stock SET qty = qty - ? WHERE qty >= ?
if rowcount == 0: raise (rollback)
→ INSERT INTO assignments
→ UPDATE pdf_filename
→ COMMIT
→ generate_pdf(...) outside transaction
→ 303 redirect → /assignments?msg=Delivery+recorded
Flask or FastAPI would have saved ~300 lines and added one dependency. Stdlib won because (a) the deploy target is one industrial PC with cautious IT, (b) the request handler is a one-afternoon read for the next engineer, and (c) framework abstractions cost more than they save at this size. The trade-off is real: no worker pool, no graceful reload, no battle-tested middleware. Migration to Flask + Jinja2 is documented in CRITICA.md, gated on >5 concurrent users or non-LAN exposure.
Python 3.13 removed cgi.FieldStorage. Most projects pin <3.13 or pull in python-multipart; this one reimplemented it on top of email.parser from stdlib in ~60 lines. No new dependency, and the upload path stayed working through the Python upgrade.
No build step, no JS framework, no SPA hydration. The plant has 50 Mbps shared internet and a Windows PC running the server. Server-rendered pages stay under 50 KB and avoid a class of bugs (client/server state desync). Cost: every UI change is a Python deploy.
authlib, flask-login and friends would have been faster to write, but each is a dependency to track for CVEs over the product's 20-year lifetime. PBKDF2 is in stdlib, sessions are ~30 lines of SQLite and CSRF double-submit is another ~30. The whole auth surface lives in one file and reviews end-to-end.
| Layer | Choice | Notes |
|---|---|---|
| Language | Python 3.12+ | stdlib-first |
| HTTP | http.server.ThreadingHTTPServer |
no Flask / FastAPI |
| Persistence | sqlite3 + WAL + threading.local |
thread-safe, single file |
| Templates | f-string in Python | server-rendered, no build step |
| ReportLab | only third-party runtime dep | |
| Auth | PBKDF2-SHA256, sessions in SQLite, CSRF double-submit | stdlib only |
| Tests | pytest + requests | 105 tests, in-process server fixture |
| Lint | ruff | strict config in pyproject.toml |
| CSS | hand-rolled tokens, ~1.2k lines | no build step, dark/light themes |
epi_system/
├── app.py # HTTP routing, multipart parser, server bootstrap (~1.1k LOC)
├── run.py # entrypoint
├── pyproject.toml # deps + ruff + pytest config
│
├── core/
│ ├── auth.py # login, sessions in SQLite, CSRF, PBKDF2 hashing
│ ├── db.py # threading.local conn, schema, migrations v1-v6, v_deliveries view
│ ├── log_filters.py # CpfMaskFilter (LGPD)
│ ├── maintenance.py # background daemon: session purge + DB online backup
│ └── responses.py # html_page, html_page_bare, security headers, CSP nonce
│
├── services/ # domain logic (pure-ish, take a conn)
│ ├── products.py # CRUD + soft-delete + service life
│ ├── employees.py # CRUD + normalize_cpf + validate_cpf_format
│ ├── stock.py # add_stock, update_stock, coverage-based stock health
│ ├── assignments.py # assign_epi, assign_multiple_epis (atomic)
│ ├── kits.py # role-based PPE bundles
│ ├── lifecycle.py # replacement-due math, per-worker EPI card
│ ├── matrix.py # function PPE matrix + automatic gap detection
│ ├── compliance.py # NR-6 90-day cycle
│ ├── audit.py # deletion_audit reader
│ └── dashboard.py # aggregates, shell_status, reorder suggestions
│
├── views/render.py # server-rendered templates (~1.6k LOC)
├── pdf/receipts.py # ReportLab PDF generation
├── static/app.css # design system tokens + components (~1.2k LOC)
│
├── tests/ # 36 tests, isolated_db + in-process server fixtures
├── screenshots/ # documentation assets
├── CRITICA.md # self-imposed technical audit (severity-ranked)
└── README.md # this file
Requirements: Python 3.12+ on Linux, macOS, or Windows. ~50 MB disk.
git clone https://github.com/leobaray/epi_system.git
cd epi_system
# Runtime deps (only ReportLab)
pip install -r requirements.txt
# Dev deps (ruff, pytest, requests)
pip install -e ".[dev]"
# Bootstrap an admin and run
EPI_ADMIN_USER=admin EPI_ADMIN_PASS=changeme python run.py
# → http://localhost:8140/Environment:
| Variable | Default | Purpose |
|---|---|---|
EPI_AUTH_ENABLED |
1 |
0 to disable auth (LAN-only setups) |
EPI_SESSION_TTL |
28800 |
Session lifetime in seconds (8h) |
EPI_COOKIE_SECURE |
0 |
Force Secure flag on session cookie |
EPI_ADMIN_USER / EPI_ADMIN_PASS |
— | First-run admin bootstrap |
EPI_BACKUP_ENABLED |
1 |
0 to disable the automatic DB backup |
EPI_BACKUP_INTERVAL |
86400 |
Minimum seconds between backups (24h) |
EPI_BACKUP_KEEP |
7 |
How many rotated backups to retain |
EPI_MAINTENANCE_INTERVAL |
3600 |
Session-purge / maintenance loop period |
Backups at rest. Each snapshot is a full copy of the database and therefore
contains PII (CPFs, names), password hashes and sessions. The backups/ directory
and its files are created with restrictive permissions (0o700 dir / 0o600
files on POSIX; on Windows, ACLs follow the host/process account). backups/ is
git-ignored. Retention is the last EPI_BACKUP_KEEP rotated copies (default 7).
For stronger postures, store the directory on an encrypted volume or add
at-rest encryption at the host level — kept out of the app to avoid bundling a
crypto dependency.
Run tests:
pytest # 105 tests
pytest --cov=services --cov=core --cov-report=term-missingIn production at a single industrial site since 2025-09:
- 23 active employees, 21 PPE products in catalog, 854 units in stock.
- Every individual withdrawal — including high-turnover consumables like ear plugs and gloves — generates a signed PDF.
- Replaces a paper-and-spreadsheet workflow that left the site with no per-delivery PPE trail and out of compliance with NR-6.
In the first month in production, an auditor asked for the full PPE delivery history of one specific employee. The system produced the report in seconds; before, that record did not exist anywhere.
After several months in production, four items stand out. The full list with severity is in CRITICA.md.
- Extract a thin routing layer. The flat
if/elifladder inapp.pyworked while the URL space was small, but it has crossed the point where adding a route requires reading 50 lines around it for context. A ~30-line decorator-based router (no Flask dependency) is on the roadmap. - Async PDF generation. PDF generation runs synchronously outside the request transaction. Under bursty multi-user delivery (a full-shift kit assignment), it can block a request for 200–500 ms. An outbox table + worker would smooth that out.
- Replace f-string templates with Jinja2. Manual
safe_escape()is XSS-prone by construction in any non-trivial template. CSP buys time;Jinja2.Environment(autoescape=True)is the right long-term fix. - Move from raw
sqlite3to SQLAlchemy + Alembic — not for the ORM (queries are simple), but for versioned migrations. The current ad-hoc block incore/db.pydoes not handle column renames.
Solo developer, 21, Curitiba, Brazil. Built epi.manager in personal time as the only software engineer at the company that uses it.
Other projects in the same period:
- cnn — visual identification of automotive torque-converter models from a phone photo. Jetpack Compose Android client + PyTorch backend over FastAPI.
- magnum-site — the company's corporate site (Portuguese SEO #1 for several torque-converter terms).
- A RAG system over ~3,500 internal industrial documents (~45 pages each) with a "tutor mode" that adapts vocabulary to the user's technical level. Internal, no public repo.
Stack across projects: Python, Dart, Kotlin, TypeScript.
Looking for remote engineering roles with international teams.
- GitHub: @leobaray
- Workana: Leonardo Baray
- Email: leonardobaray@outlook.com
Licensed under the MIT License — see LICENSE.





