main had moved on with retrieval work validated against real queries — minTokenHits (the word match needs two thirds of the label, not all of it), separator folding so "Parle G"/"Parle-G"/"ParleG" all reach Parle-G, the floor at 0.50 after "Paracetamol" came back as "Paneer Makhni 500ml" at 0.304, and ties broken on cosine distance instead of name. All of that is kept exactly as it was. The conflict was in textScore: this branch replaced the substring rule with a coverage formula to stop a bare brand name resolving to one arbitrary product. That is the wrong half to change. The substring rule scores every product of a brand 0.95 IDENTICALLY, and that tie is not the bug — it is the signal. isAmbiguous reads it, so the branch's coverage rewrite is dropped and the ambiguity layer alone does the work: "britannia" → all 258 rows tie at 0.95 → ambiguous: true + candidates "Parle G" → folding and the single-character token still land it a real name → runner-up far behind → match, unchanged Dropped with it: scanSpecificEnough, the per-hit text score, and the proportional confirmation bonus — the flat +0.10 is back. Simpler, and it leaves main's tuning untouched. TestTextScoreRewardsSpecificityNotJustOverlap tested the removed formula and is replaced by TestABrandNameScoresItsProductsIdentically, which guards the tie itself: a formula that broke it on name length or word count would bring the bug back. Docs carry both rationales, and now say plainly that confidence stays high on the ambiguous path — gate on `ambiguous`, never on `confidence`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fiesta backend (nearle)
The Go/Fiber API behind the Nearle Daily merchant console, the customer app,
the rider app and the in-store POS terminals. Postgres (nearledb) for
tenants, stores, products, stock and orders; a separate pgvector database for
the global product catalogue; Redis for POS presence; MQTT for the tills.
This page is the map. Each section says what a thing is, how to use it, and where the detail lives.
Run it
export PATH="$PATH:$HOME/go/bin" # Go 1.24 lives there on the dev Macs
docker compose -f docker-compose.local.yml up -d # postgres :5433, pgvector :5434, redis :6379
go run . # APP_ENV unset → .env.local, listens on :1122
go test ./...
An empty database is not enough — startup runs migrations that assume the
live schema. init/README.md explains loading a schema dump first.
Startup prints what it loaded and where it is pointed:
config: loaded .env.local
config: APP_ENV=local, listening on :1122, database nearle@localhost:5433/nearledb
scan: product search uses openai/all-minilm # or: EMBEDDING_PROVIDER not set, text-only
Configuration
Everything comes from environment variables, read once by config.Load().
| You want to… | Do this |
|---|---|
| Run locally | Nothing — .env.local is loaded by default |
| Run against production settings | APP_ENV=production go run . (⚠ every write is real) |
| See every variable and what it does | .env.example |
| Add a new setting | Add it to config.Config + Load() + .env.example, and to the cluster (nearle-config ConfigMap or app-secrets Secret in namespace nearle) — a variable in the file and not in the cluster is unset in production |
| Find out why it won't boot | Read the message — it lists every missing variable at once |
Precedence is real environment > .env.<APP_ENV> > .env. The container gets
no .env file at all (.dockerignore); the Dockerfile sets
APP_ENV=production and the values come from Kubernetes.
Full detail, including the committed-credentials situation:
docs/ENVIRONMENT.md.
Layout
main.go boot: config → databases → migrations → routes → MQTT → listen
config/ env-file loading, typed Config, validation
db/ Postgres (nearledb + catalogue), Redis, S3 image store
facade/ wires repositories → services → controllers (add new modules here)
routes/ one file per module; /live/api/v1/web/... (console) and /v1/mob/... (apps)
controllers/ HTTP in, HTTP out — parse, call the service, shape the envelope
services/ the rules; no SQL, no HTTP
repositories/ the SQL; nothing else
models/ request/response and table shapes
messaging/ MQTT ingest from tills, console live stream
utils/ small shared helpers (tokens, geo, embeddings, geocoding)
docs/ integration specs for the frontends and handoff notes
scratch/ one-off read/verify tools run with `go run ./scratch/<name>`
init/ schema/seed for the local database
Every response uses the same envelope:
{ "code": 200, "status": true, "message": "…", "details": … }.
Business outcomes ("out of stock", "not registered") are 200s with a reason
in the body; HTTP errors mean the request could not be served at all.
Adding an endpoint
- Model the request/response in
models/. - Repository method(s) in
repositories/— SQL only, take acontext.Context, returnerror. - Service in
services/— the rules, with sentinel errors (ErrXxxBadRequest,ErrXxxNotFound) the controller can map to statuses. - Controller in
controllers/—BodyParser/Query, call the service, map sentinel errors to 400/404/503, everything else to 500. - Routes file in
routes/, registered inroutes/routes.go. - Wire it in
facade/container.go. - Test the service with a fake repository (see
services/scan_test.gofor the pattern) — no database needed.
services/scanService.go + controllers/scanController.go are a complete,
current example of all seven.
Features with their own docs
| Feature | For | Doc |
|---|---|---|
| Environment & deployment | everyone | docs/ENVIRONMENT.md |
| Scan-to-order (camera → product → nearest store with stock) | mobile app | docs/SCAN_TO_ORDER.md |
| Catalogue import into a store | console | docs/CATALOGUE_IMPORT_INTEGRATION.md |
| POS terminal ingest, login, API | POS / tills | docs/POS_*.md |
| Access-control audit and what is still open | everyone | docs/SECURITY_HANDOFF.md |
Things to know before you get surprised
- There is no auth layer.
customerid/tenantidin a request are trusted.docs/SECURITY_HANDOFF.md§1 is the standing issue. - Login errors mean what they say.
409 Invalid Email= the query ran and matched nobody.500 Login is temporarily unavailable= the database could not answer (it used to be reported as Invalid Email; seeservices/userService.golookupLogin). - Stock is a ledger. Live stock is always
SUM(in) − SUM(out)ofproductstocksat an outlet, never a stored number. Filter on the same expression you display (services/productVisibility.goexplains why). - The catalogue is a different database and must never be reached
through the
nearledbhandle. Its per-brand tables are discovered frominformation_schema; brands appear and columns vary. - Catalogue ids are not stable across re-scrapes;
imageidis the durable key (models.Products.Imageid). - Migrations run on boot and are guarded by
IF NOT EXISTS/ schema checks, not a version table. Read the comments inmain.gobefore adding one — several have bitten before. - One MQTT client id per replica. A second connection with the same id
evicts the first. Never run a local process with the production
MQTT_URL. scratch/tools read production when run with.env.production, and most are read-only. Two are not —termbackfillandcataloguefactsbackfillrepair rows that no endpoint can reach. Both default to a dry run that prints every change and write only when passedapply, and both print the SQL to undo themselves afterwards. A new tool that writes follows that shape or it does not write.
Operations cheat-sheet (Kubernetes, namespace nearle)
kubectl get pods -n nearle # fiesta-0/1/2 (StatefulSet)
kubectl logs -n nearle fiesta-0 | grep -E 'config:|scan:|pos:'
kubectl exec -n nearle fiesta-0 -- env | grep EMBEDDING_
kubectl set env statefulset/fiesta -n nearle KEY=value # adds a var and rolls the pods
kubectl rollout status statefulset/fiesta -n nearle
The embedding model behind scan-to-order is served by the cluster's Ollama
(ollama.krow.svc.cluster.local:11434); docs/SCAN_TO_ORDER.md has the
exact settings and why that model.