Evidence-led inspection recording for packaged commodities in India.
An officer photographs a package. Maanak reads the label, keeps the image beside what was read from it, applies a versioned rule interpretation to the values the officer has reviewed, and produces a report anyone can verify later without being shown the case.
Maanak is not a government service. It is not affiliated with, endorsed by or certified by any authority, and it issues no enforcement action of its own. Every legal conclusion is recorded against the named officer who reached it.
Most of the design follows from these. They are not aspirations in a document; they are enforced in the check implementations and covered by tests.
Absent evidence is never a violation. Three situations look alike on screen and mean entirely different things: the package genuinely lacks a declaration, the photograph does not show the panel, or the panel is visible but unreadable. Only the first is a compliance failure. The second returns additional evidence required, the third unable to determine, and no configuration makes either report as non-compliant.
You can watch the difference. Running the checks on an inspection before officer review produces 7 additional evidence required, 3 unable to determine and zero non-compliant. After review of the same photograph, 10 rules apply and one non-compliant finding appears. The evidence did not change. The review did.
Machine observation and officer decision are separate records. OCR writes a candidate with a machine state and a confidence. An officer separately writes a review state. A correction never overwrites what the machine read: the previous state is written to a revision first, and both appear in the report. Only reviewed values reach the rule engine, and that is enforced in the engine rather than left to the interface.
- The officer either photographs the package in the browser or attaches an image already on the device. On a phone the camera opens in the page, so the panel can be checked against the frame before anything is kept.
- Each image is validated from its own bytes and measured on eleven quality signals: sharpness, glare, highlight and shadow clipping, brightness, contrast, resolution, skew, framing, text size and capture source. The officer is then told what to fix in plain words: "strong glare is covering part of the panel".
- The original bytes are stored unchanged, hashed with SHA-256, and the chain of custody is recorded. Location is never read from photograph metadata.
- A background worker reads the panel with Tesseract in English and Hindi, trying several language and orientation configurations and keeping whichever measurably reads best.
- Declarations are located and normalised with exact
Decimalarithmetic and unit-aware conversion: MRP, net quantity, unit sale price, manufacturer, consumer care, country of origin, date marking, batch. - Each reading is shown beside the region of the image it came from, to confirm, correct or reject with a reason.
- Approved rule versions are applied to the reviewed values, and every finding records its citation, why that version was selected, the inputs and the arithmetic.
- A report snapshot is frozen, hashed, and rendered to PDF and to an editable document from that snapshot.
- A public page confirms a report exists and is unchanged, without disclosing the case.
Consumers need no account. A complaint can be triaged into an inspection, carrying the consumer's photograph across as evidence. A reported inspection can become a case with a served, frozen notice.
Four services, exposed on the homepage and gathered under web/services.html:
| Service | Needs | Returns |
|---|---|---|
| Read what must be printed on a package | nothing | The eleven declarations in plain language, each with the provision it is attributed to and whether that attribution is confirmed |
| Report a packaged product | a photograph and a description | A reference to keep. Contact details are optional |
| Track that report | the reference and the contact detail given | Progress only, never the inspection that followed |
| Verify a report reference | the reference and its printed code | Whether the report was issued and is unchanged, disclosing nothing about the case |
The services page also states the boundary, which matters more than appearing complete. Nothing here assesses nutrition, ingredients, allergens or food safety. No rule in this system derives from the Food Safety and Standards regulations, and it gives no dietary advice. Net quantity cannot be confirmed from a photograph, and a readable barcode is not evidence that a product is genuine. For each of those the page names the authority that does hold the power: FSSAI, the state Controller of Legal Metrology, the National Consumer Helpline on 1915, and electronic filing before a consumer commission.
A minimum character height in millimetres cannot be derived from a photograph. The same letter fills more pixels from closer and fewer from further away, and without a known scale in frame there is no conversion.
So the check refuses. Without an officer's measurement it returns additional evidence required and says what is needed. With a measurement it compares against the threshold together with the stated uncertainty, and a measurement whose band crosses the threshold returns unable to determine rather than a decision that would not survive being challenged.
This is the clearest case of a general rule: the system would rather say it does not know.
Two benchmarks, both small, and the weaker one is the honest one.
On labels this project generates, scripts/measure_ocr_accuracy.py scores ten
declarations with known correct values under eight kinds of degradation, and gets 72 of 80
fields right. Every one of the eight failures is the same failure: the reader returns
lodised for Iodised, because capital I and lowercase l are the same glyph in a
sans-serif face. No profile or dictionary setting fixed it, so the reading is kept and the
ambiguity is reported to the officer instead of being silently corrected. A rule that
turned a leading lowercase l into a capital I would also turn Lemon into Iemon.
On photographs of real packaging the picture is much worse, and it is worth stating
plainly. scripts/measure_ocr_real.py runs 14 consumer photographs of Indian packs
contributed to Open Food Facts, using each product's declared quantity as the ground
truth. Six of the 14 yielded any declaration at all, ten declarations were located in
total, and net quantity was read from none of them. One of the failures is a crop of a
salt pouch that shows "NET QUANTITY: 1 kg" in large bold type; all seven reader profiles
return either nothing or noise on the whole image. Crop to those words and plain greyscale
reads "NET QUANTITY:" exactly, which moves the diagnosis: the engine can read this print,
and asking it to segment a whole curved pack in one pass is what defeats it. The value is
harder still, because "1 kg" misreads even when isolated.
There is no 100 per cent here and there is not going to be. What the system does instead is refuse to turn a failed reading into a finding. Across all 14 photographs, including the five the quality gate refused outright, the number of non-compliant findings produced before officer review was zero. That is the property worth having: the reader is unreliable on real packaging, and the design already assumes it.
docs/KNOWN_LIMITS.md records both figures and the diagnosis.
Docker with Compose v2 is the only requirement. No Python, Node or database on the host.
cp .env.example .envReplace every CHANGE-ME value in .env. Generate them with:
python -c "import secrets; print(secrets.token_urlsafe(48))"
python -c "import base64,os; print(base64.b64encode(os.urandom(32)).decode())" # MINIO_KMS_SECRET_KEYThen:
docker compose up --build -d| What | Where |
|---|---|
| Browser application | http://localhost:8080 |
| API readiness | http://localhost:8000/health/ready |
| API documentation | http://localhost:8000/docs |
| Object storage console | http://localhost:9001 |
GET /health/ready reports each dependency separately and returns 503 while any of the
four is unavailable.
Two commands. The first creates an account per role, loads the starter rules, and takes each one through simulation and approval using the second rule administrator, because an author cannot approve their own version. The second walks one case through the entire path so the workspace is not empty.
docker compose run --rm --no-deps -e API_URL=http://api:8000 \
--entrypoint python api scripts/seed_demo.py
docker compose run --rm --no-deps -e API_URL=http://api:8000 \
--entrypoint python api scripts/seed_example.pyThe second prints its progress: complaint CMP-2026-000001 → inspection
INSP-2026-000001 → 11 readings confirmed → checks → decision → report
RPT-2026-000001 → case CASE-2026-000001 → notice NOT-2026-000001 served.
Sign in at http://localhost:8080/login.html. Every account uses the password the seeder prints.
| Account | Role | Sees |
|---|---|---|
inspector@example.org |
inspector | Gurugram district; captures and reviews evidence |
reviewer@example.org |
reviewer | Records the decision, issues reports |
controller@example.org |
controller | Haryana and below; cases and the audit trail |
ruleauthor@example.org |
rule_admin | Authors rule versions |
ruleapprover@example.org |
rule_admin | Approves them, which the author cannot |
admin@example.org |
admin | Accounts, roles, jurisdictions |
otherstate@example.org |
inspector | Punjab; exists to show jurisdiction isolation |
Those are throwaway local credentials. A real deployment creates the first administrator
through POST /api/v1/auth/bootstrap, which works once and only while no account exists.
There is no public registration.
Eleven interpretations ship with the software. None has been checked against a gazette notification. Each carries a legal-authority flag set to false, and that flag appears on the rule, on every finding that uses it, and printed on every report.
Some values are explicit placeholders. The minimum character height is a single figure where the real minimum varies with the area of the principal display panel. The permitted unit lists come from ordinary retail practice rather than the Second Schedule.
docs/LEGAL_SOURCES.md and the site's own legal sources page name every one of them and the procedure to close each gap. Read them before drawing any conclusion about what has been proven.
The engine and the citations are separate claims. What the engine does is tested and holds regardless of whether a citation string is correct.
Every suite prints each check with the value it measured, so a passing run is a readable statement of what was verified rather than a count.
# Formatting, lint and types
docker compose run --rm --no-deps --entrypoint sh api scripts/quality.sh
# Unit tests, the prose gate and the live-stack suites. Mount the repository root
# rather than api/ alone so the prose gate can read web/ and docs/ as well.
docker compose run --rm --no-deps -v "$PWD:/repo" -w /repo/api \
-e PYTHONPATH=/repo/api --entrypoint sh api scripts/run_tests.sh
# Browser, accessibility, tints, links
docker build -f api/Dockerfile.browser -t maanak-browser:dev api
docker run --rm --network maanak_default -v "$PWD/api:/w" -w /w \
-e BASE_URL=http://web:8080 maanak-browser:dev
docker run --rm --network maanak_default -v "$PWD/api:/w" -w /w \
-e BASE_URL=http://web:8080 maanak-browser:dev python scripts/audit_app.py
docker run --rm --network maanak_default -v "$PWD/api:/w" -w /w \
-e BASE_URL=http://web:8080 maanak-browser:dev python scripts/measure_a11y.py
docker run --rm --network maanak_default -v "$PWD/api:/w" -w /w \
-e BASE_URL=http://web:8080 maanak-browser:dev python scripts/scan_tints.py --app
# The whole officer workflow through the interface, from an empty inspection to an
# opened case. Needs the seeded accounts and a label image to upload.
docker compose run --rm --no-deps --user root -v "$PWD/api:/app" \
--entrypoint python api scripts/make_sample_label.py
docker run --rm --network maanak_default -v "$PWD:/repo" -v "$PWD/api:/w" -w /w \
-e BASE_URL=http://web:8080 maanak-browser:dev python scripts/verify_officer_flow.pyEach of the eight verify_* suites bootstraps its own workspace, so run
scripts/reset_data.py between them. That also flushes the Redis rate-limit counters,
without which repeated sign-ins start returning 401.
Measured on the current tree:
| Suite | Result |
|---|---|
verify_schema.py |
14 checks: 30 tables, 184 indexes, 29 CHECK constraints, audit table rejects UPDATE and DELETE |
verify_security.py |
51 checks |
verify_extraction.py |
62 checks |
verify_rules.py |
71 checks |
verify_auth_flow.py |
70 checks |
verify_pipeline.py |
56 checks |
verify_workflow.py |
117 checks |
verify_matters.py |
86 checks |
verify_browser.py |
60 checks, 0 axe violations on 8 pages |
verify_officer_flow.py |
44 checks: the whole officer workflow driven through the interface, from opening an inspection to opening a case |
audit_app.py |
140 checks: every page, all 14 workspace screens scanned with axe, 0 violations; every register shows real rows; the evidence viewer pans without a drag for 2.5.7, proved by reading the scroll position either side of a click; no sideways scroll at 320, 360, 390 or 820 pixels |
check_api_reach.py |
98 API operations: 74 reached from a screen, 24 recorded with a reason, 0 unexplained |
check_permission_names.py |
33 permission names used by the interface, 0 that do not exist |
check_js_bindings.py |
27 modules, 0 using a helper they never imported |
check_error_messages.py |
177 error messages, 0 naming a JSON key or a column |
measure_a11y.py |
0 axe violations across 14 public pages at WCAG 2.0/2.1/2.2 A and AA; 0 targets under 24×24; 0 sticky or fixed elements |
scan_tints.py |
0 warm-tinted surfaces across 24 pages at 3 breakpoints |
check_links.py |
0 broken links, 76 in-page anchors resolve |
check_prose.py |
0 machine-writing tells across 28 pages and 16 documents, 44,218 words |
| pytest | 321 unit items, 5 live-stack items |
quality.sh |
139 files formatted, lint clean, mypy clean on 88 files |
verify_package.sh |
39 checks on the release archive |
verify_restore.py |
11 checks on a restored deployment: the audit chain recomputes and its head matches what the backup recorded, the append-only trigger survived, and every stored object still matches its hash |
measure_load.py |
Reads peak at 156 requests per second at 2 threads, p50 14 ms and p95 19 ms; beyond that throughput is flat and latency climbs to p50 129 ms at 16 threads. The worker drains 17 OCR jobs a minute at 3.6 seconds each, bounded by max_jobs = 2. One machine, not a benchmark, and the figure moves with whatever else that machine is doing |
Two of those exist because the standard tooling does not cover them. measure_a11y.py
measures WCAG 2.2 success criterion 2.5.8, for which axe-core has no rule, and enumerates
sticky positioning for 2.4.11. check_prose.py enforces the writing rules this project
holds itself to, so "the copy is not machine-generated filler" is a check and not a claim.
It treats the em dash as a hard failure rather than rationing it: every place the code
reached for one turned out to be a sentence that read better rebuilt around a colon, a
full stop or a different clause order. A unit test extends the same rule to the Python,
JavaScript and CSS sources, and check_report_text.py extracts the text from the
generated PDF and DOCX to confirm it holds in the documents a reader receives.
Both found real defects. Target-size measurement caught utility links at 19 pixels, checkboxes at 13×13 and file inputs at 21. Extending axe to the inspection screen caught a critical and a serious violation that had gone unnoticed on the most important screen in the application.
A modular monolith. FastAPI serves the API; a separate arq worker runs OCR so no request ever waits on it; Redis carries the queue; MinIO provides S3-compatible storage with encryption at rest; PostgreSQL 16 is the only database; nginx serves the browser application and proxies the API.
The frontend is plain HTML, CSS and ES modules. No build step, no framework, no
bundler, no third-party script. The content security policy is default-src 'self' with
no unsafe-inline, which is checkable rather than aspirational: an inline style
attribute in the officer workspace was found and removed precisely because the policy
refuses it.
Layers run in one direction. app/api/v1/ holds routes and no logic, app/schemas/
validates, app/services/ holds all behaviour, app/models/ holds tables. app/domain/
holds the enums and state machines, and is the single source of truth: it generates the
database CHECK constraints and the vocabulary the frontend renders, so the two cannot
drift apart.
Some invariants worth knowing:
- Jurisdiction is applied inside the SQL query, never filtered from results afterwards. A request for a record outside your jurisdiction returns 404, not 403, because a 403 would confirm the record exists.
audit_eventsrejectsUPDATEandDELETEthrough a database trigger, so an administrator with full application access still cannot edit history. Entries are hash-chained. Forging one has been tested: verification named the offending sequence number and reported that its previous hash did not match.- Money and quantities are
Decimalthroughout, rounded half-up to the paise. Never float. - Report snapshots, served notices and evidence identity are immutable by trigger.
- Authentication is cookie-only. There are no bearer tokens anywhere in the design.
- Every request schema sets
extra="forbid", so a request cannot set a field the endpoint did not intend to expose.
docs/ARCHITECTURE.md explains the reasoning behind each choice.
| Document | Contents |
|---|---|
| PROJECT_REPORT.md | The whole project in one document: problem, design, what was measured, what is missing |
| RUNNING.md | Running it on localhost from a clean machine, with every command |
| ARCHITECTURE.md | Components, data flow, why each technology |
| DEPLOYMENT.md | Clean install, environment, operations, troubleshooting |
| LEGAL_SOURCES.md | What is verified, what is not, and how to close the gap |
| PERMISSIONS.md | Role and permission matrix, jurisdiction model |
| STATE_MACHINES.md | Inspection, complaint and case states |
| EVIDENCE.md | Chain of custody and report verification |
| THREAT_MODEL.md | Threats, controls, residual risk |
| DATA_INVENTORY.md | Personal data, purpose, retention |
| TESTING.md | How to run every check and what each proves |
| BACKUP_RESTORE.md | Backup, restore, integrity re-verification |
| DEMO.md | The connected walkthrough, in order |
| DEMO_SETUP.md | Building the demonstration workspace, including the before and after review comparison |
| KNOWN_LIMITS.md | What this does not do, and what is unproven |
This is a working local deployment, not an accredited production system. The gaps are named individually rather than gestured at, here and on the site's own security and accessibility pages.
Absent: TLS, malware scanning on upload, managed secrets, monitoring, alerting, an incident process, and any independent security or accessibility assessment. No screen reader has been run against the interface, and nobody has tested it with disabled users.
Backup and restore are implemented and proven rather than absent: scripts/backup.sh,
scripts/restore.sh and api/scripts/verify_restore.py, the last of which re-verifies a
restored deployment against the audit chain head recorded at backup time. What is still
missing there is scheduling and an off-host target, both of which are deployment decisions.
Before field use, an authority must have every legal interpretation confirmed against the
gazette and recorded against the rule version; terminate TLS and set COOKIE_SECURE=true,
DOCS_ENABLED=false and explicit TRUSTED_HOSTS; move secrets into a managed store;
require the second factor rather than only offering it; replace the development report
signer with a real signing service or leave
signing off rather than implying a signature exists; and commission independent security,
accessibility, legal and user-acceptance testing.
Setting MAANAK_ENV=production refuses to start while several of those remain unsafe. It
checks what is technically checkable and cannot check the rest.
devpilotX and catburglarX. The repository is mirrored at devpilotX/maanak and catburglarX/maanak, with both accounts holding write access to each. The two remotes carry the same history and the same commit hashes.
Apache License 2.0. See LICENSE and NOTICE.
axe-core is vendored under api/scripts/ for the accessibility suite, unmodified, under
the Mozilla Public License 2.0. It is not served to users and is not part of the deployed
application.



