| Product | ELMS — Electronic Locker Management System |
| Source | Everest Engineering "Smart Package Locker Management System" coding challenge |
| Version | 1.0 |
| Date | 2026-08-03 |
| Status | Approved |
| Related docs | Flowchart · ERD · Technical Design · Task Breakdown |
E-commerce deliveries increasingly go to secure self-service locker stations in malls, offices, and residential complexes instead of doorsteps. A delivery agent stores a package in a locker; the system issues a one-time pickup code; the customer later uses that code at the station to open the locker and collect the package.
ELMS is the management system behind those stations: it tracks stations and lockers, allocates lockers to packages, issues and validates pickup codes, computes storage charges for packages that linger, and stays correct when many agents store packages at the same time.
The challenge defines four levels. All four are in scope, including the optional Level 4:
- Level 1 — basic locker and package storage (allocation, availability, pickup codes)
- Level 2 — package retrieval and locker release
- Level 3 — extended storage charges (tiered pricing)
- Level 4 — correct handling of concurrent storage requests (optional — included)
- Correct locker allocation: smallest available locker that fits the package, every time.
- Secure, simple retrieval: locker ID + pickup code is all a customer needs.
- Accurate tiered storage charges, collected (mock) before the locker opens.
- Concurrency-safe allocation: no double-assignment under simultaneous load, availability always consistent.
- Operable by non-developers: admin UI for stations, lockers, pricing, and occupancy.
- Shippable MVP quality: tested (unit → E2E), linted, CI-gated, one-command deploy.
- Real notification delivery (SMS/email). The challenge states codes are delivered by an external system; ELMS records customer contact info and surfaces the code to the agent, nothing more.
- Real payment processing. Payment is a mock confirmation step; a gateway integration is a documented extension.
- Physical locker hardware integration. "Locker opens" is simulated in API responses; a hardware driver interface is a documented extension.
- Customer accounts, package tracking history for customers, refunds, multi-tenancy, i18n.
| Persona | Access | What they do |
|---|---|---|
| Customer | Kiosk UI, no login | Enters locker label + pickup code, sees storage charge, pays (mock), collects package |
| Delivery agent | Agent UI, username/password login | Picks a station, checks availability, stores packages, hands the pickup code to the system (customer receives it out-of-band) |
| Admin | Admin UI, username/password login | Manages stations and lockers, toggles locker maintenance, edits pricing, monitors occupancy |
Roles are fixed (agent, admin) and seeded; there is no self-registration. Admin can do everything an agent can.
US-A1 — As an admin, I can create a station with a name and address.
- AC: station appears in station list; agents can select it when storing.
US-A2 — As an admin, I can add lockers to a station by choosing a locker type.
- AC: the type carries the cell's real interior dimensions in millimetres; the locker gets a unique human-readable label within the station (e.g.
M-03); startsavailable.
US-A3 — As an admin, I can view all lockers with their current status (available / occupied / out_of_service).
- AC: list is filterable by station, status, locker type; reflects state changes immediately. (Challenge L1: "view the list of lockers along with their current availability status.")
US-A4 — As an admin, I can put an empty locker into maintenance (out_of_service) and bring it back.
- AC: OOS lockers are never allocated; an occupied locker cannot be set OOS (error explains why); OOS → available restores allocation eligibility.
US-A5 — As an admin, I can delete an empty locker, and delete a station that has no lockers.
- AC: deleting an occupied locker or a non-empty station fails with a clear error.
US-A6 — As an admin, I can see an occupancy dashboard.
- AC: per-station totals (available/occupied/OOS), and per stored package: locker, dimensions or template name, dwell time, accrued charge so far.
US-A7 — As an admin, I can manage the two catalogs the rest of the system measures against: locker types (a named cell size with interior width/height/depth) and package templates (a named standard box with its outer dimensions).
- AC: both are full CRUD; a type or template that lockers or packages already reference cannot be deleted, and the error says so.
- AC: a template can be deactivated instead, which removes it from the agent's form without touching history.
- AC: editing a template never changes the dimensions recorded against packages already stored.
US-B1 — As an agent, I can log in and select a station.
US-B2 — As an agent, I get the station's verdict on the package — perfect fit, oversize cell, station full, or too big — before storing, from the measurements alone. The locker wall itself is not my screen; the estate overview belongs to the admin dashboard. (L1, revised by T-98 — originally "view locker availability", satisfied by a grid)
US-B3 — As an agent, I can store a package by picking a standard box template or entering custom dimensions, plus the customer's name and contact (both required).
- AC: templates are shown as cards with their measurements; Custom reveals three millimetre inputs. (L1)
- AC: before submitting, the form shows the fit pass: the verdict, the cell it would claim (or the class that would take it, or the largest the station runs), the tightest-axis clearance, and how many cells are free. An oversize verdict informs; it never blocks the store.
- AC: system assigns the smallest available locker at that station that fits — every package side, sorted, must clear the corresponding locker side by 20 mm, in any orientation; ties broken deterministically. (L1)
- AC: the dimensions are frozen on the package at store time; a later edit to the template does not change them.
- AC: response shows the locker label and a 6-digit pickup code, displayed exactly once; the code is never retrievable again. (L1)
- AC: if no suitable locker is available, the system returns an explicit "package cannot be stored" message and stores nothing. (L1)
- AC: code delivery to the customer is out of scope (assumed external SMS/email). (L1)
US-C1 — As a customer at the kiosk, I select my station (a physical kiosk is pinned to its station; the web kiosk picks one), enter a locker label + pickup code, and see my package and its storage charge. (L2, L3)
- AC: valid station + label + code → package summary (template name or dimensions, stored-at, days held) + itemized charge quote.
- AC: invalid locker or wrong code → one generic "invalid locker or code" error (no hint which part was wrong).
- AC: 5 consecutive failed attempts against a package block further attempts for 15 minutes (clear "try again later" message).
US-C2 — As a customer, I pay the storage charge (mock) and the locker opens. (L2, L3)
- AC: payment confirmation and package release are atomic: package marked
retrieved, locker becomesavailableagain, receipt returned with charge breakdown. (L2: "locker opens, package removed, locker available again") - AC: charge is fixed at quote time; a quote/pending payment expires after 15 minutes, after which the customer must re-quote.
- AC: a code that was already used cannot open anything (package no longer
stored→ generic invalid error).
US-D1 — The system records the exact storage timestamp when a package is stored. (L3)
US-D2 — The system computes the charge from full-and-partial 24-hour days elapsed:
days = max(1, ceil(hours_in_locker / 24))— a "day" is each started 24-hour period from the storage time; day 1 is charged (no free period).- Tiered rates: days 1–5 at
X/day, days 6–10 at2X/day, day 11+ at3X/day. (L3 example, adopted literally) - Example (X = 10): 3 hours → 10; 26 hours → 20; 6 days → 70; 12 days → 210.
US-D3 — As an admin, I can edit X (the base daily rate) and the currency in a pricing config screen.
- AC: changes apply to charges quoted after the change; amounts are stored in minor units (integer cents) — no floating point.
US-E1 — When multiple agents store packages simultaneously, each locker is assigned to at most one package; two requests never receive the same locker. (L4)
US-E2 — With more concurrent requests than suitable lockers, exactly the available lockers are assigned; surplus requests get the "no suitable locker" message. (L4)
US-E3 — Locker availability remains correct and up to date under load; no request can observe a locker as both free and assigned. (L4)
- AC (proof): automated race test — N goroutines contending for M lockers results in exactly M stored packages, N−M rejections, zero double-assignments; suite passes under Go's race detector.
US-F1 — Agents and admins log in with username/password; sessions are httpOnly cookies; logout works. Customers never log in — the pickup code is the credential.
US-G1 — The system records an append-only audit event for every state-changing action: package stored (with the acting agent), package retrieved / locker opened, each failed and blocked pickup attempt, and every admin mutation (stations, lockers, locker types, package templates, pricing).
- AC: each event carries the actor (
nullfor kiosk actions — the customer has no identity; agents and admins are identified from the session, never from client input), the station/locker/package it concerns where applicable, a timestamp, and event-specific details (old → new values for edits). - AC: the event is written in the same transaction as the action it records — no event without the action, no action without the event.
- AC: pickup codes — attempted or real — never appear in any event.
US-G2 — As an admin, I can review the audit log in an Activity screen.
- AC: reverse-chronological and paginated; filterable by event type, station, and date range.
| ID | Requirement | Level | Stories |
|---|---|---|---|
| FR-1 | CRUD stations; delete only when empty | — | US-A1, US-A5 |
| FR-2 | Create lockers under a station from a locker type; unique label per station | L1 | US-A2 |
| FR-3 | List lockers with live status; filter by station/status/locker type | L1 | US-A3 |
| FR-4 | Locker maintenance mode; OOS excluded from allocation; occupied cannot go OOS | — | US-A4 |
| FR-5 | Store package: smallest fitting available locker at chosen station — per-dimension fit, sorted sides, 20 mm clearance, any orientation; deterministic tie-break | L1 | US-B3 |
| FR-6 | On store: generate unique 6-digit pickup code, return it once with locker label | L1 | US-B3 |
| FR-7 | No fitting locker → explicit failure, no side effects | L1 | US-B3 |
| FR-8 | Retrieve: validate station + locker label + code; generic error on mismatch; attempt limiting (5 fails → 15-min block) | L2 | US-C1 |
| FR-9 | Valid retrieval: quote charge → mock payment → atomically mark retrieved + free locker + receipt | L2/L3 | US-C2 |
| FR-10 | Record storage timestamp; compute tiered charge (day-1 charged; 1X/2X/3X tiers; 24-h days, started days count) | L3 | US-D1, US-D2 |
| FR-11 | Admin-editable base rate X and currency; integer minor-unit money | L3 | US-D3 |
| FR-12 | Concurrent stores: no double-assignment, correct availability, surplus requests rejected cleanly | L4 | US-E1–E3 |
| FR-13 | Session auth for agent/admin; role-gated endpoints; public kiosk endpoints | — | US-F1 |
| FR-14 | Occupancy dashboard with dwell time and accrued charges | — | US-A6 |
| FR-15 | CRUD locker types and package templates; deletion restricted once referenced; templates deactivatable; package dimensions frozen at store time | L1 | US-A7 |
| FR-16 | Fit preview: given dimensions and a station, deliver a verdict — perfect_fit / oversize_cell / station_full / too_big, judged against the in-service estate (occupied counts, out-of-service does not) — with the cells that justify it, without claiming anything |
L1 | US-B2, US-B3 |
| FR-17 | Append-only audit events for every mutation, written in the same transaction; actor from session (null at kiosk); no pickup codes in events; admin Activity screen with type/station/date filters | — | US-G1, US-G2 |
| ID | Requirement |
|---|---|
| NFR-1 | Correctness under concurrency: allocation is serializable in effect; DB-level backstop (partial unique index) makes double-assignment impossible even under application bugs |
| NFR-2 | Security: bcrypt password hashing; pickup codes stored hashed; constant-time comparison; no code/locker enumeration; httpOnly SameSite cookies; input validation everywhere |
| NFR-3 | Quality gates: unit + integration + API + E2E tests; go vet, golangci-lint, race detector in CI; CI green required |
| NFR-4 | Operability: one-command docker compose up; DB migrations run automatically; seeded demo data; structured logs (slog) with request IDs; /healthz |
| NFR-5 | Extensibility: pure domain core isolated from transport/persistence; documented extension points (real payments, notifications, tier editor, hardware driver) |
| NFR-6 | Performance: allocation and retrieval are single-transaction, index-backed operations; suitable for kiosk latency (<100 ms server-side typical) |
Real SMS/email delivery · real payment gateway · locker hardware control · customer accounts · package expiry/return-to-sender workflows · multi-tenant operators · reporting exports · audit-log retention/archival (events accumulate; rotation is an extension). Each has a noted extension path in the TDD.
- All FRs demonstrably work via the three UIs and the API.
- Race-proof test (US-E3 AC) passes under
-racein CI. - E2E happy paths + key error paths green in CI.
docker compose upfrom a clean checkout yields a working system with seeded demo data.- README quickstart ≤ 5 steps; docs in
/docsmatch implementation.
| Decision | Choice | Why |
|---|---|---|
| Interface | REST API + React SPA (kiosk / agent / admin areas) | Challenge allows any; demonstrates full-stack + API design |
| Stack | Go backend, PostgreSQL, React + TypeScript frontend | User choice; Go concurrency strengths fit L4 |
| Architecture | Hexagonal-lite (pure domain core, ports/adapters), OpenAPI-first | Extensibility is an explicit challenge criterion |
| Level 4 | Included | Differentiator; contained cost with Postgres row locking |
| Pricing | Charge from day 1, literal PDF tiers, X + currency admin-editable | User choice ("charge from day 1") |
| Payment | Mock pay step gates locker opening | User choice; demonstrates the flow without a gateway |
| Stations | Multi-station model | Matches scenario text; richer data model |
| Auth | Seeded users, session cookies; kiosk public | Code is the customer credential; registration out of scope |
| Agent store screen | Fit-pass verdict replaces the locker wall (T-98) | The agent never picks a cell, so the wall was scenery; the verdict answers the question they actually have, and "perfect" is class-relative — no slack threshold to tune |
| Timeline | None fixed — "shippable MVP, quality paramount" | Drives full test/CI scope |
| Sizing (M9) | Real millimetres on lockers and packages; small/medium/large enum dropped |
A rank comparison calls a 1200 mm ski box "large" and hands it a locker it cannot enter |
| Locker sizes (M9) | A locker_types catalog, not per-locker dimensions |
How real banks are built: a few standard cells repeated; per-row numbers invite 47 subtly different "mediums" |
| Fit rule (M9) | Per-dimension, sorted triples, 20 mm absolute clearance, any of 6 orientations | Volume alone lets a long thin package "fit"; sorting permits the rotation a human would do; absolute clearance because the room a hand needs does not scale with the cell |
| Box templates (M9) | Database table with admin CRUD | An operator can add their own mailer without a deploy; the name survives onto the receipt |
| Fit preview (M9) | Server endpoint runs the real rule | Keeps the rule to two expressions (Go, SQL); a TypeScript third copy is the one no cross-check test can reach |
| Weight limits | Out of scope | Needs a scale at the counter; recorded as an extension point |
| Customer fields | customer_name and customer_contact required at store time (was optional) |
A package with nobody attached cannot be found again, and the notification channel to come depends on the contact; DB columns stay nullable for rows stored before the rule |
| Store confirm (T-99) | Two-stage dialog: frozen docket as the confirm gate, then the one-time code reveal with a production-delivery note | Reading the pass back catches the wrong box or contact before a cell is taken; no reservation state — the pass names a class, and smallest-fit at confirm absorbs a cell taken meanwhile |
| Audit log | Append-only audit_events table, written at the service layer inside the action's transaction; every mutation logged; admin Activity screen |
Scattered per-table timestamps cannot answer "who did what, when" across entities; DB triggers hide the logic and lack the actor; same-transaction write means the log and the state cannot disagree |