Tracking lives in this file: flip [ ] → [x] as tasks land; update the milestone table. Sized for a solo developer; order respects dependencies (each milestone builds on the previous). Requirement IDs refer to the PRD; design references to the TDD.
| # | Milestone | Proves | Status |
|---|---|---|---|
| M0 | Repo scaffold & tooling | make dev runs, CI skeleton green |
✅ done |
| M1 | Database & data layer | migrations + sqlc + seed against real PG | ✅ done |
| M2 | Domain core | allocation + pricing + codes fully unit-tested | ✅ done |
| M3 | Services & Postgres adapter | store/retrieve use cases pass integration tests | ✅ done |
| M4 | HTTP API & auth | full contract serves; API tests green | ✅ done |
| M5 | Level 4 concurrency proof | race test: N≫M, zero double-assign, -race clean |
✅ done |
| M6 | Web SPA (kiosk/agent/admin) | all three journeys clickable end-to-end | ✅ done |
| M7 | E2E & full CI pipeline | Playwright suite green in CI | ⬜ not started |
| M8 | Ship polish | docker compose up demo-ready; docs match reality |
🟨 in progress |
| M9 | Physical dimensions | lockers and packages measured in mm; 3-D fit with clearance; box templates | ✅ done |
| M10 | Audit log | every mutation leaves an event in the same tx; admin Activity screen | ✅ done |
Legend: ⬜ not started · 🟨 in progress · ✅ done
- T-01
git init; root layout per TDD §2 (server/,web/,docs/);.gitignore,.editorconfig - T-02 Go module; pin toolchain;
golangci-lintconfig - T-03
Makefiletargets:dev,gen(sqlc + oapi + TS client),test,test-int,e2e,lint,seed - T-04
docker-compose.yml: postgres 17 (healthcheck) + api + web stubs - T-05 CI skeleton (
.github/workflows/ci.yml): lint + build jobs on push/PR
- T-10 Migration 0001: enums,
users,sessions,stations,lockers,packages,payments,pricing_configper ERD — incl. partial unique indexpackages(locker_id) WHERE status='stored',payments(package_id) WHERE status='pending',CHECK (pricing_config.id = 1)(FR-12 backstop) - T-11 Migrate-on-boot in
cmd/api; fail fast on dirty version - T-12 sqlc setup; first queries (stations, lockers CRUD); generated code committed
- T-13 Idempotent seed: admin + agent users (bcrypt), 1 station, S/M/L locker grid, pricing X=10.00 USD
- T-14 testcontainers-go harness: migrated PG per test run, tx-rollback helper
- T-20
Size/rank +FitsIn; typed sentinel errors — unit tests (FR-5) - T-21
ChargeFor: day countingmax(1, ceil(h/24)), tier math in minor units, breakdown struct — table-driven boundary tests: 3 h, exactly 24 h, 24 h+1 s, day 5/6, day 10/11, 12 d (FR-10, US-D2 examples) - T-22 Pickup code:
crypto/rand6-digit no-bias generation; bcrypt hash/verify helpers — tests incl. uniqueness-retry path (FR-6) - T-23
Clockport + frozen-clock test helper (US-D1 testability)
- T-30 Ports (
TxRunner,Repo) + pgx tx runner implementation - T-31 Allocation query with
FOR UPDATE SKIP LOCKED+ size-rank ordering (TDD §7); sqlc integration - T-32
StorePackageservice: tx = claim locker → insert package → occupy locker → return label + one-time code; no-locker →ErrNoLockerAvailable(FR-5..7) — integration tests incl. atomicity on induced failure - T-33
QuotePickup: lookup by station+label+code (generic failure), attempt counter + 15-min block, charge viaChargeFor, pending payment (TTL 15 min, partial-unique; re-quote returns existing pending) (FR-8, FR-10) — integration tests - T-34
ConfirmPickup: tx = lock package row → payment paid → package retrieved (+final charge) → locker available; expired quote →ErrQuoteExpired(FR-9) — integration tests incl. reuse-attempt after retrieval - T-35 Admin services: stations/lockers CRUD rules (delete-empty-only, occupied-cannot-OOS), pricing get/put, dashboard aggregation (FR-1..4, FR-11, FR-14) — integration tests
- T-36 Auth service: login (bcrypt verify) → session create; validate; logout; expiry sweep (FR-13)
- T-40
openapi.yaml: full contract per TDD §8.1 incl.problem+jsonerror schemas - T-41 oapi-codegen strict-server wiring; chi router; middleware: request ID, slog access logs, panic recovery
- T-42 Error mapper: typed errors → status table (TDD §8.3); generic 404 for pickup; 429 +
Retry-After - T-43 Session middleware + role guards; login/logout/me endpoints; cookie flags per TDD §8.2
- T-44 Implement all handlers over services
- T-45 API test suite (
httptest): auth gates, each endpoint happy + error path, no-enumeration check - T-46
/healthz(DB ping) + graceful shutdown
- T-50 Race integration test: 50 goroutines × 10 suitable lockers → exactly 10 stored, distinct lockers, 40 clean rejections; final state audit (US-E1..E3)
- T-51 Store⇄retrieve interleaving test: freed locker becomes allocatable, never double-visible
- T-52 Whole server test suite under
-racein CI; document proof in README
- T-60 Vite + React + TS scaffold; Tailwind; router with 3 areas; TanStack Query; generated TS client from
openapi.yaml(make gen) - T-61 Auth plumbing: login page,
useMeguard, role-based routing,problem+json→ toast/inline errors - T-62 Agent area: station picker → availability grid → store form → code-shown-once modal (US-B1..B3)
- T-63 Kiosk area: station select → label+code form → quote with breakdown → mock-pay → "locker open" receipt; invalid/blocked/expired states (US-C1, US-C2)
- T-64 Admin area: stations & lockers CRUD + maintenance toggle; pricing editor; occupancy dashboard (US-A1..A6, US-D3)
- T-65 Frontend unit tests (Vitest): quote rendering, code modal, guards
- T-70 Playwright setup running against compose stack
- T-71 Scenarios: full store→pickup happy path; wrong code ×5 → blocked; station full → 409 UX; maintenance locker excluded; pricing change reflected in next quote; from T-97 — store by box template end to end, and a custom package nothing on the estate fits (the fit pass stamps too big and names the largest cell the station runs, the store answers 409); from T-103 — the Activity screen lists the store and pickup the run just performed, newest first
- T-72 CI pipeline complete: lint → server tests (-race) → web build/test → e2e → docker build; branch protection on green
- T-80 README: quickstart (≤5 steps), demo credentials, architecture summary linking
/docs, L4 proof pointer - T-81 Compose hardening: healthchecks, restart policies, first-run seed flag — all three services
restart: unless-stopped; the distroless api probes itself (/api -healthcheck→/api/v1/healthz, so DB loss flips the container unhealthy); web waits on api healthy, not started, because nginx proxies/apithere;SEED=true(default) was already wired end to end and stays the first-run switch - T-82 Docs sync pass: PRD/TDD/ERD/flowcharts vs implementation; update deltas
- T-83 Demo walkthrough script (agent stores → customer retrieves → admin dashboard)
- T-84 Public demo deploy: single-container build (
go:embedSPA into the API binary, rootDockerfile) + Cloud Run/Neon guide (demo.md) — verified end-to-end against Neon - T-85 Tests for
internal/webui: asset caching, missing-asset 404, SPA fallback, non-GET rejection (T-84 shipped without them)
Replaces the small | medium | large enum with real millimetres on both sides of
the fit test, and gives the agent standard box templates. Full design:
spec.
-
T-90 Migration 0002 (expand):
locker_types+package_templateswith 4 types and 7 templates as reference data (sorted-dimension and volume generated columns); nullablelockers.locker_type_idandpackages.length_mm/width_mm/height_mm/template_id, both backfilled from the old enum;make gento pick the new columns up in the sqlc models. Queries wait for T-92, which has the first consumer. Additive on purpose —sizeis read by six queries, the seeder, two services, the handlers, the spec and the SPA, so dropping it here would leave the tree uncompilable until T-94 (FR-2, FR-15) -
T-91 Domain:
Dimensions,ClearanceMM = 20,FitsInas a sorted-triple comparison (any of 6 orientations),SelectLockerordering by interior volume then label — table-driven tests incl. the exact clearance boundary and entry-order invariance. The size-basedFitsIn/SelectLockergo here, since nothing outside the domain called them;Size/Rank/ParseSize/ErrInvalidSizesurvive insize.gountil T-94, because fifteen call sites and twoNOT NULLcolumns still speak the enum — the same reason 000002 was additive (FR-5) -
T-92 sqlc: catalog CRUD queries,
ClaimSmallestFittingLockerwithFOR UPDATE OF l SKIP LOCKED, fit-preview reads; locker reads joinlocker_typessoapp.Lockercarries an interior andLocker.ForAllocationis back; the locker writes derivelocker_type_idfrom the size class with 000002's own mapping, so the enum path keeps working while every new row gets a type (T-93 turns that around); Go↔SQL cross-check rewritten against dimensions, retiring T-91's canonical-interior bridge; both halves of the L4 proof moved onto a single-type grid, plusTestClaimDoesNotLockTheLockerTypeRow— the race test only catches a plainFOR UPDATEabout three runs in four, so the deterministic proof holds three transactions open instead (FR-12) -
T-93 Services:
StorePackagetakestemplate_idordimensions— never both, never neither — resolves the template inside the transaction that claims on it, and freezes the three numbers onto the package, so correcting a template next month cannot rewrite what a package stored last month was measured at;FitPreview(station, dims)answers{fits, available_count, smallest_type, largest_free_type}holding no locks, since the binding decision stays with the claim query; locker-type and template CRUD onAdminService, deletion restricted by the foreign key (→ErrNotEmpty) with retiring as the way out for a template that has history. Editing stays unrestricted on purpose — dimensions are copied at store time, so a correction cannot reach history (US-A7). Two transitional pieces earn their keep until T-94, both insize_bridge.goso removing them is a deletion:DimensionsForSize, which the handler calls because the wire contract still carries a size class, andsizeForDimensions, which fills the still-NOT NULLpackages.sizefrom the package rather than from the cell it landed in — a small parcel in a Large cell is still small. The locker writes keep deriving the type from the size (T-92's direction): turning that around needs a size→type lookup at the HTTP boundary that T-94 would delete the same day, so it moves there, and the seed's Tower XL pair with it (FR-5, FR-7, FR-15, FR-16) -
T-94 Migration 0003 (contract) sets the four columns
NOT NULL, drops bothsizecolumns, dropslockers_allocation_idxandDROP TYPE locker_size; its down half re-derives each enum value from millimetres, which cannot be lossless and says so.openapi.yamldropsSizeand gainsDimensions,LockerType,PackageTemplate,FitPreview, the eight catalog endpoints andGET /stations/{id}/fit;Locker.size→locker_type, the locker filter →?locker_type_id=, and stored/quote/dashboard payloads carrydimensions+ nullabletemplate_name.StorePackageRequestkeepstemplate_idanddimensionsas plain optional fields rather than aoneOf: aoneOfgenerates a union type in both clients for a rule the server has to enforce anyway.domain/size.go,ErrInvalidSizeandapp/size_bridge.goare deleted whole;ErrInvalidDimensionstakes the 400 row. Locker writes take alocker_type_id, so the seed grid gainsT-01/T-02and reaches eighteen — which is what makes smallest-fit visibly about litres: a medium parcel spilling out of the Mediums now lands in a 240 L tower, not a 324 L Large. The SPA moves with the contract to keep the tree green —formatDimensions/freeByTypereplaceSIZE_TEXT/SIZE_ORDER, the grid groups by cell type, the locker form picks a type and the store form offers templates or three numbers; T-95..T-97 build the real screens on top (FR-3, FR-15, FR-16) -
T-95 A Catalogs tab holding both CRUD screens: cell types (create, delete, ordered by interior volume with litres shown) and box templates (create, retire/offer, delete). One tab rather than two, because both are short reference lists an admin reaches for together. Deletion is offered plainly and allowed to fail — the server refuses a referenced row and the screen shows the reason (US-A7).
proposeLabelinlib/lockers.tssuggests<type initial>-<next seq>when a cell type is chosen, one past the highest in use so a gap left by a deletion is never silently reused; still overridable, because the label is what a customer reads off the door (US-A2, US-A7). The sharednot-emptydetail was reworded — it is now raised by four restricted deletes, and "empty it before deleting it" was only true of the first -
T-96 The store form becomes a grid of template cards — each naming its box and its three numbers, because an agent reads a box off a shelf and a name alone does not say which one they are holding — plus a Custom card that reveals three millimetre inputs. Under it, a live fit preview debounced 250 ms (
useDebounced), so typing400asks once rather than three times and cannot settle on the answer to40. It reads "2 lockers fit — smallest is Tower XL, 400 × 400 × 1500 mm", or names the biggest free cell when nothing fits, so the answer to a parcel that does not go in is "bring something this size" rather than "no". Arole="status"live region: it changes under an agent who is looking at the measurements. The rule is answered by the server, never recomputed here (US-B3, FR-16) -
T-97 The kiosk quote names the box the agent picked, or its three numbers when it was measured by hand — something a customer can check against what they are handed; the dashboard column does the same and is headed Package rather than Size. Docs sync: the flowchart's store path now shows the fit preview and the
FOR UPDATE OF lclaim, its admin path gains the catalogs; the README's smallest-fit table is rewritten in millimetres — including the curtain pole a three-word enum would have called "large" and handed a 900 mm locker — and its L4 section carries the real query. PRD, TDD §8.1 and the ERD were already written for M9 and needed no change. The two E2E scenarios move to T-71: Playwright is not set up until T-70 (M7, not started), so there is no harness to write them against (US-C1, FR-14) -
T-98 The agent's locker wall comes down and a verdict takes its place.
FitPreviewnow answers with one of four words, judged class-relative against the in-service estate — occupied cells count, out-of-service cells do not exist:perfect_fitwhen the cell it would claim is the tightest class the station has for the parcel,oversize_cellwhen every tighter cell is taken (storing stays allowed — the agent is told, never blocked),station_fullwhen a class takes it but no such cell is free, andtoo_bigwhen nothing here ever will — so a full station is no longer mistakable for a small one. Class-relative on purpose: no slack threshold to tune, and the verdict falls straight out of allocation semantics. Two new reads back it (TightestFittingType,LargestPresentType);LargestAvailableTypeand the wire'slargest_free_typeretire in favour oftightest_type+largest_type, and the verdict is computed server-side byfitVerdict— the one place the rule lives (FR-16). The form renders it as a boarding-pass docket (FitPass): mono body carrying package, cell, tightest-axis clearance and free count, a perforated stub stamping the verdict in the status triad, and a decorative barcode that encodes nothing. Clearance on the pass is display arithmetic on two figures the server sent; the judgement never left the server.LockerGridis deleted with its wall test — the agent never picks a cell, so the wall was scenery, and the estate overview belongs to the admin dashboard (US-B2 rewritten, PRD decisions log + TDD §8.1 + flowchart synced). The app fixture'stakeAllLockersnow occupies instead of OOSing: the verdict made that distinction load-bearing, and a full station's estate still exists (FR-16, US-B2, US-B3) -
T-99 Storing gains its confirmation: submit freezes the docket into a two-stage dialog — read back to the customer, Cancel free, the claim fired only by Confirm & store — so the one-time reveal stops arriving unannounced. Nothing is reserved: the pass names a class, never a cell, and smallest-fit re-runs under
FOR UPDATE OF l SKIP LOCKEDat confirm, so a cell taken while the pass was being read lands the package one size up (the docket's ownoversize_cellword) and a station filled meanwhile is the 409 shown inside the dialog with the form intact behind it. The reveal keeps every code-shown-once rule and gains the production note — the code would go to the customer directly (SMS/email); the agent sees it only because the demo has no delivery channel. Focus lands on each stage's heading, never a button: Enter held from submit cannot click through to a claim, nor from confirm through the only showing of the code.Docketmoves out ofFitPassso the dialog prints the same slip the form does;CodeHandoverbecomes the reveal stage ofStoreDialog. Full design: spec (US-B3, FR-6, FR-7)
- T-100 Migration 0004:
audit_eventsper ERD — bigserial PK (insertion order),event_type, nullableactor_user_id/station_id/locker_id/package_idFKs allON DELETE SET NULL,details jsonb,occurred_at; indexes(occurred_at),(event_type, occurred_at),(station_id, occurred_at); sqlc insert + filtered/paginated list queries (US-G1) —event_typeis text, not an enum: a new recorded action must not need a migration, and nothing branches on it in SQL. The list ordersoccurred_at DESC, id DESC— the bigserial breaks same-tick ties so the feed never flaps between page loads — with every filter optional (sqlc.narg) and both time bounds inclusive, since they arrive as day boundaries;CountAuditEventsanswers "how many pages" under the same filters. Integration tests pin newest-first, each filter, the tie-break, and that an event outlives all four entities it references (delete package → locker → station → user; the row survives with nulled refs anddetailsstill naming the label). T-101 owns the recorder; the queries deliberately expose no update or delete - T-101 Recorder:
InsertAuditEventonRepo; every mutating service method writes its event inside its own transaction — store, failed/blocked pickup attempt, confirm/retrieve, station/locker/catalog CRUD, pricing change — actor from the session, never client input;detailssnapshots labels/names and old → new values. Integration tests: induced failure after the mutation → no event; committed mutation → exactly one; no pickup code ever lands indetails(US-G1) — the actor rides the context:app.WithActoris stamped in one place, the session guard, so a request body can never nominate its own author and kiosk events carry a null actor by construction.recordEventmarshals the details and fails the use case if the log write fails — an action that cannot be recorded does not happen. Edits record changed-fields-only as{"old","new"}pairs (changes.add); deletions reference nothing and carry the name indetails, since the row is gone. An attempt against an unknown label is still recorded (a probe sweep is what the trail is for) but an attempt naming a nonexistent station is not — the event's station FK has nowhere to point, which the uniformity test caught as a leakedaudit_events_station_id_fkey. Quote success stays uneventful on purpose: US-G1 enumerates actions an operator answers for, and a priced offer is not yet one - T-102
GET /admin/audit-eventsinopenapi.yaml: admin-only, newest first, paginated, filtersevent_type/station_id/from/to(TDD §8.1); handler over the list query; API tests: role gate, each filter, page bounds (US-G2) — the response is a page object{events, total, limit, offset}: total under the same filters in the same transaction as the page, so the two cannot disagree about a feed being appended to. Limit defaults to 50, caps at 200, negatives and oversizes answer 400 from the service (ErrInvalidInput) rather than trusting spec validation alone; past-the-end is an empty page, not an error. Landing the endpoint tripped both drift-detectors by design — the §8.1 contract table test and the guard-map test each gained theirlistAuditEvents: adminrow - T-103 Admin Activity screen: reverse-chronological table, filter bar (event type, station, date range), pagination; Vitest: row rendering per event shape, filter wiring (US-G2) — a fifth admin tab. Every row renders from the event's
detailssnapshot alone (lib/audit.ts:eventLabel+summarize), never a lookup, because the snapshot is the one part that still means something after its entities are deleted; edits read asfield old → new, money throughformatMoney, and an unrecorded event type still renders humanized rather than blank — the log may be ahead of the screen. The event stamp is toned: failed/blocked attempts in signal, stores/retrievals strong, admin housekeeping quiet. The By column saysoperator/kiosk(uuid in the tooltip) — the wire carries no username and a uuid is not a name. Changing any filter resets to page one; paging keeps the previous page on screen (placeholderData) so the feed advances instead of blinking. Date bounds go up asT00:00:00Z/T23:59:59Zagainst the server's inclusive bounds. Verified in the running stack via Playwright: store → wrong code → retrieve → all five event shapes on screen, filter narrows to one
| Challenge level | Covered by |
|---|---|
| L1 storage & allocation | T-20, T-31, T-32, T-44, T-62 · dimensions: T-90..T-93, T-96 · fit verdict: T-98 · confirm gate: T-99 |
| L2 retrieval | T-33, T-34, T-44, T-63 |
| L3 charges | T-21, T-23, T-33, T-34, T-64 (pricing editor) |
| L4 concurrency (optional — included) | T-10 (backstop), T-31, T-50..T-52 · preserved across M9 by T-92 |
| Quality bar (PRD §8) | T-45, T-52, T-65, T-70..T-72, T-80..T-85 |