# 360LM Architecture Decision Wiki

_Karpathy-style thematic synthesis of all ADRs. Each cluster is self-contained.
Agent: read the relevant cluster before making decisions in that area.
Human: browse by theme rather than scanning the flat ADR list._

**Last updated:** 2026-07-01 · **Total ADRs:** 96 · **Index:** [README.md](README.md)

---

## Clusters

1. [Auth & Identity](#1-auth--identity)
2. [Hub — Navigation & Registry](#2-hub--navigation--registry)
3. [PWA Architecture](#3-pwa-architecture)
4. [UX Interaction Standards](#4-ux-interaction-standards)
5. [DB Schema & Ownership](#5-db-schema--ownership)
6. [PostgREST & API Layer](#6-postgrest--api-layer)
7. [PostgreSQL Patterns](#7-postgresql-patterns)
8. [Routing & Infrastructure](#8-routing--infrastructure)
9. [Finance Domain](#9-finance-domain)
10. [Recce & Client Portal](#10-recce--client-portal)
11. [AI & Integrations](#11-ai--integrations)
12. [Learning Hub](#12-learning-hub)
13. [Testing](#13-testing)
14. [Maps](#14-maps)
15. [Dev Operations & Safety](#15-dev-operations--safety)
16. [Platform Standards](#16-platform-standards)
17. [Infrastructure & External Integrations](#17-infrastructure--external-integrations)
18. [UX & Data Patterns](#18-ux--data-patterns)

---

## 1. Auth & Identity

**Core idea:** Employees use PIN via Hub. External clients use magic-link tokens. Sessions bridge PWAs via shared localStorage keys on the same origin. No passwords, no OAuth for internal users.

| ADR | Decision |
|-----|----------|
| [ADR-011](ADR-011-pin-based-auth-no-passwords.md) | 4–6 digit numeric PIN, stored hashed, assigned by admin |
| [ADR-012](ADR-012-hub-as-sso-gateway.md) | Hub is the single SSO gateway for all employee PWAs |
| [ADR-018](ADR-018-client-auth-company-code-not-pin.md) | External clients use company code + access code (separate from hub) |
| [ADR-026](ADR-026-cross-pwa-session-handoff-localstorage.md) | Session handoff via shared localStorage keys — no server round-trip |
| [ADR-048](ADR-048-client-portal-magic-link-auth.md) | Recce client portal uses email magic-link / invite-token |
| [ADR-050](ADR-050-client-auth-tables-rpc-only-no-anon-grants.md) | client_user / client_session / client_invite — RPC-only, no web_anon grants |

**Session key contract (shared across PWAs):**
- `lm360-session` — employee hub session
- `lm360-activity-session` — activity PWA session
- `lm360-client-session` — external client session
- `recce-client-session` — recce client portal session

**Files:** `hub/index.html` (SSO + PIN login) · `client/index.html` (client auth) · `admin/index.html` (PIN assignment)

---

## 2. Hub — Navigation & Registry

**Core idea:** Hub owns navigation, not business logic. Every PWA registers in DB before going live. Static JSON kept as offline emergency fallback only.

| ADR | Decision |
|-----|----------|
| [ADR-001](ADR-001-hub-next-redirect-on-login.md) | All PWA→hub redirects MUST append `?next=<encoded-path>` |
| [ADR-016](ADR-016-new-pwa-must-register-in-hub-registry.md) | Every new PWA registers in `hub.pwa_registry` before going live |
| [ADR-028](ADR-028-hub-navigation-only-feature-ownership.md) | Hub is navigation-only — no business forms, no domain data |
| [ADR-029](ADR-029-employee-access-dual-write-db-and-json.md) | Access dual-written to `hub.employee_access` + `hub-access.json` fallback |

**Key tables:** `hub.pwa_registry` · `hub.employee_pwa_access`
**Key file:** `hub/index.html` · `hub-access.json` (fallback)

---

## 3. PWA Architecture

**Core idea:** Single HTML files, no build pipeline, no framework. Offline-first via IndexedDB. Cache busted by incrementing CACHE_VER constant in sw.js. External libs self-hosted, never CDN.

| ADR | Decision |
|-----|----------|
| [ADR-005](ADR-005-sw-cache-ver-string-bump.md) | SW cache busting via `CACHE_VER` constant bump — not `skipWaiting()` |
| [ADR-013](ADR-013-single-html-file-no-framework.md) | Each PWA = single `index.html`, inline CSS+JS, no framework, no build |
| [ADR-020](ADR-020-offline-first-indexeddb-primary.md) | Field PWAs are offline-first: IndexedDB primary, PostgREST sync secondary |
| [ADR-021](ADR-021-sw-cache-first-network-fallback.md) | Cache-first for assets; network-only for `/db/` and proxy API calls |
| [ADR-035](ADR-035-screenshot-css-js-deterrence-only.md) | Screenshot prevention is CSS/JS deterrence only — OS enforcement unavailable |
| [ADR-088](ADR-088-indexeddb-schema-versioning.md) | IndexedDB versioning: `<PWAName>DB` naming, `if (oldV < N)` additive migrations, mandatory `onblocked` handler, CACHE_VER co-bump |

**Pattern:** bump `CACHE_VER` in every `*/sw.js` on deployment. Bump IndexedDB version + `CACHE_VER` together when schema changes.
**Reference impl for offline-first:** `recce/index.html` (RecceDB v4 — IndexedDB + sync queue)

---

## 4. UX Interaction Standards

**Core idea:** Mobile-first. Root document scroll only. Safe-area insets always. No system pickers for >5 options. Complex forms in full-screen overlays.

| ADR | Decision |
|-----|----------|
| [ADR-002](ADR-002-safe-bottom-css-mandatory.md) | Every PWA MUST link `shared/safe-bottom.css` — safe-area inset bottom |
| [ADR-003](ADR-003-mobile-scroll-root-document.md) | Long lists scroll ROOT document only — no `overflow:auto` next to map/canvas |
| [ADR-027](ADR-027-leave-one-out-faceted-filtering.md) | Multi-filter: leave-one-out availability — grey unavailable, red conflicting |
| [ADR-030](ADR-030-no-select-for-more-than-5-options.md) | `<select>` only for ≤5 options; >5 requires searchable type-ahead or chip-grid |
| [ADR-031](ADR-031-full-screen-overlays-for-complex-forms.md) | Forms >3 fields → full-screen overlay with `visualViewport` resize listener |
| [ADR-061](ADR-061-dual-mode-lazy-search-eager-dashboard.md) | Bounded datasets: lazy server Search tab + eager client Dashboard tab |
| [ADR-091](ADR-091-inapp-tour-engine-contract.md) | Multi-step onboarding: `initTour(steps, {storageKey, autoShow})` via `shared/tour.js`; storage key `<pwa>-tour-v<N>`; z-index 9990 |

**File:** `shared/safe-bottom.css`
**Reference impls:** `activity/index.html` (faceted filters) · `sales/index.html` (type-ahead) · `finance/custodian/index.html` (full-screen overlays)

---

## 5. DB Schema & Ownership

**Core idea:** One schema per PWA. `expense.employees` is the single canonical identity table — deliberate cross-schema exception. `sales.catalog` is the shared product master. `custodian.payees` is the shared payee master.

| ADR | Decision |
|-----|----------|
| [ADR-009](ADR-009-each-pwa-owns-its-db-schema.md) | Each PWA owns a dedicated PostgreSQL schema named after the PWA |
| [ADR-032](ADR-032-expense-employees-canonical-identity.md) | `expense.employees` is platform-wide canonical identity — cross-schema FK anchor |
| [ADR-036](ADR-036-custodian-payees-shared-payee-master.md) | `custodian.payees` + `custodian.payee_methods` shared across all payment PWAs |
| [ADR-043](ADR-043-sales-catalog-shared-product-master.md) | `sales.catalog` is platform-wide product master — Vendor Management links to it |
| [ADR-044](ADR-044-finance-mini-pwa-family.md) | Finance = family of mini-PWAs under `/finance/` sharing `custodian.*` schema |
| [ADR-053](ADR-053-per-brand-data-on-recce-submissions.md) | Per-brand counter data lives on `recce.submissions`, not `counters.counter` |
| [ADR-058](ADR-058-cc-transactions-cross-schema-view-only.md) | Credit Card reads `vehicle.cc_transactions` via view only — zero writes to vehicle schema |

**Canonical cross-schema anchors:**
```
expense.employees  ← all schemas FK here for person identity
sales.catalog      ← all schemas FK here for product identity
custodian.payees   ← all payment schemas FK here for payee identity
```

---

## 6. PostgREST & API Layer

**Core idea:** PostgREST handles all CRUD. Business logic in PostgreSQL RPCs. Cross-schema or privileged access via dedicated proxy services. `web_anon` role kept minimal.

| ADR | Decision |
|-----|----------|
| [ADR-014](ADR-014-postgrest-as-api-layer.md) | All CRUD via PostgREST at `/db/`; business logic in PostgreSQL RPCs; no custom API server |
| [ADR-010](ADR-010-cross-schema-via-proxy-not-postgrest.md) | Cross-schema queries go through proxy with direct DB pool — not PostgREST reconfiguration |
| [ADR-006](ADR-006-verify-pin-returns-table-array.md) | `verify_pin` returns TABLE array `[{id,name,role}]` — check `Array.isArray(res)` |
| [ADR-050](ADR-050-client-auth-tables-rpc-only-no-anon-grants.md) | Sensitive auth tables: RPC-only access, no direct `web_anon` grants |
| [ADR-054](ADR-054-counter-sync-rpc-with-stores-json-fallback.md) | RPC primary, `stores.json` fallback — show warning banner when fallback fires |

**PostgREST headers for RPCs (POST):**
```javascript
{ 'Content-Type': 'application/json',
  'Accept-Profile': 'schema_name',
  'Content-Profile': 'schema_name' }  // ← both required; Content-Profile routes the RPC
```

---

## 7. PostgreSQL Patterns

**Core idea:** Trigger functions writing privileged tables use SECURITY DEFINER. RETURNS TABLE functions need `#variable_conflict use_column`. Atomic cross-schema writes in one RPC transaction. Soft polymorphism for multi-parent FKs.

| ADR | Decision |
|-----|----------|
| [ADR-007](ADR-007-pg-trigger-security-definer.md) | Trigger functions writing privileged tables MUST be `SECURITY DEFINER` |
| [ADR-008](ADR-008-plpgsql-variable-conflict-use-column.md) | `RETURNS TABLE` functions MUST declare `#variable_conflict use_column` after `AS $$` |
| [ADR-033](ADR-033-expense-approval-atomic-rpc.md) | `approve_sheet` atomically deducts impress in one transaction — no split writes |
| [ADR-034](ADR-034-hr-salary-cross-schema-atomic-rpc.md) | `pay_salary_slip` atomically writes `hr.salary_slips` + `finance.transactions` |
| [ADR-037](ADR-037-transaction-lines-soft-polymorphism.md) | `transaction_lines.source_id` has no FK constraint — soft polymorphism via `source_type` |
| [ADR-038](ADR-038-vendor-catalog-trigger-auto-populate.md) | `fn_line_enrich_catalog` trigger auto-populates vendor catalog on transaction line insert |
| [ADR-040](ADR-040-cross-schema-active-sync-trigger.md) | `trg_sync_custodian_active` SECURITY DEFINER trigger syncs deactivation across schemas |
| [ADR-045](ADR-045-transaction-edit-delete-approval-queue.md) | Non-admin edits/deletes go via `edit_requests` queue; admins use direct RPCs |
| [ADR-046](ADR-046-installation-campaigns-canonical-job-record.md) | `installation.campaigns` writes first; `sales.jobs` references it — app-level rollback |

**Copy-paste guard for new trigger functions:**
```sql
CREATE OR REPLACE FUNCTION schema.fn_name()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
  -- ...
END;
$$;
```

**Copy-paste guard for RETURNS TABLE functions:**
```sql
CREATE OR REPLACE FUNCTION schema.fn_name(...)
RETURNS TABLE (col1 TYPE, col2 TYPE) LANGUAGE plpgsql AS $$
#variable_conflict use_column
BEGIN
  -- ...
END;
$$;
```

---

## 8. Routing & Infrastructure

**Core idea:** Traefik auto-discovers containers via Docker labels. Two full stacks on same VPS (dev + prod). Prod promoted via rsync allowlist, not full checkout. Every new service = add labels to compose + `docker compose up -d`.

| ADR | Decision |
|-----|----------|
| [ADR-017](ADR-017-traefik-docker-labels-routing.md) | All routing via Traefik + Docker labels — no Nginx config files |
| [ADR-015](ADR-015-dev-prod-two-stacks-same-vps.md) | Dev (`dev.srv1111289`) + Prod (`srv1111289`) — separate DB, containers, web root |
| [ADR-060](ADR-060-prod-deploy-rsync-allowlist.md) | `deploy-prod.sh` promotes via named rsync allowlist — not full git checkout |
| [ADR-023](ADR-023-push-notifications-self-hosted-vapid.md) | Push notifications: self-hosted VAPID server (port 8770, pywebpush) |
| [ADR-087](ADR-087-push-notification-implementation-contract.md) | Push implementation contract: SW handler shape, subscription payload, `hub.push_subscriptions` storage, notification options |
| [ADR-049](ADR-049-recce-views-hmac-signed-proxy.md) | Recce files served via HMAC-signed `/recce-view-proxy/` — no direct serve |
| [ADR-055](ADR-055-mail-outbox-external-poller.md) | Email via `mail_outbox` table + 30s poller — not inline SMTP |

**Key infra files:**
- `/root/360lm-web/docker-compose.yml` — all service definitions
- `/usr/local/bin/deploy-prod.sh` — prod promotion script
- `/root/360lm-web/.env` — `WEB_DOMAIN`, `DEV_DOMAIN`, secrets

---

## 9. Finance Domain

**Core idea:** Finance is a mini-PWA family sharing `custodian.*` schema. Shared payee master. Two reactivation modes (limited/full). Vendor catalog auto-built from transactions. Rate card comparison uses latest, not lowest.

| ADR | Decision |
|-----|----------|
| [ADR-044](ADR-044-finance-mini-pwa-family.md) | Finance = `/finance/custodian/`, `/finance/upi/`, `/finance/vendors/` — not monolith |
| [ADR-036](ADR-036-custodian-payees-shared-payee-master.md) | `custodian.payees` shared across Custodian, Vendors, UPI Pay |
| [ADR-039](ADR-039-custodian-reactivation-limited-full-mode.md) | Reactivation: Limited (settle only) or Full — explicit admin choice |
| [ADR-042](ADR-042-vendor-rate-card-baseline-latest-not-lowest.md) | Rate card comparison baseline = latest active, not lowest historical |
| [ADR-033](ADR-033-expense-approval-atomic-rpc.md) | Expense approval atomically deducts impress — one RPC, one transaction |
| [ADR-057](ADR-057-statement-close-gated-on-zero-unmatched.md) | Statement `close_statement` blocked until all lines matched |

---

## 10. Recce & Client Portal

**Core idea:** Recce submissions store per-brand counter data. Supersede flow (not update-in-place) when engaged official replaced. Files served via HMAC-signed proxy. Client visibility filtered by brand. Client approval forms use one-time token Excel + VBA direct submit with supervisor upload fallback.

| ADR | Decision |
|-----|----------|
| [ADR-041](ADR-041-recce-supersede-flow-not-update.md) | Replacing engaged official Recce → supersede flow (both visible), not silent swap |
| [ADR-047](ADR-047-recce-client-visibility-per-brand.md) | Client sees Recces filtered by brand, not by counter |
| [ADR-049](ADR-049-recce-views-hmac-signed-proxy.md) | View files/PDFs via HMAC-signed proxy — no direct Traefik serve |
| [ADR-053](ADR-053-per-brand-data-on-recce-submissions.md) | Per-brand commercial data on `recce.submissions`, not `counters.counter` |
| [ADR-054](ADR-054-counter-sync-rpc-with-stores-json-fallback.md) | Counter sync: `my_visible_counters` RPC → `stores.json` fallback with warning |
| [ADR-096](ADR-096-excel-approval-form-one-time-tokens.md) | Client approval forms: Excel (.xlsm) with VBA macro direct POST, one-time 30-day tokens, supervisor .xlsm upload modal fallback (Phase 6 planned) |

**Key columns:** `recce.submissions.superseded_at` · `superseded_by` · `is_official_for_client` · `client_status`
**Key tables:** `recce.excel_submission_tokens` (one-time auth tokens, 30-day TTL, single-use)

---

## 11. AI & Integrations

**Core idea:** Every live AI feature follows a standard contract (auth chain → human-review gate → loading UX → graceful degradation). Occasional-use features use copy-paste hand-off. Each domain has its own AI sidecar proxy.

| ADR | Decision |
|-----|----------|
| [ADR-062](ADR-062-live-ai-pipeline-contract.md) | All AI features: auth chain → human-review gate → loading UX → 503 handling |
| [ADR-063](ADR-063-ai-dev-toolchain.md) | AI dev toolchain: Ruflo MCP swarm, markitdown MCP (PDF/DOCX/XLSX→MD), graphify, ponytail, 3-tier routing (Haiku/Sonnet/Opus) |
| [ADR-025](ADR-025-ollama-alongside-cloud-ai.md) | Ollama (llava:7B + gemma3:4b) self-hosted; Claude primary for agentic; Ollama fallback for OCR |
| [ADR-019](ADR-019-ocr-client-side-identity-docs-server-side-photos.md) | OCR: Tesseract.js client-side (HR identity docs); Gemini+Ollama server proxy (bill photos) |
| [ADR-024](ADR-024-google-slides-via-gas-proxy.md) | Google Slides automation via Apps Script + self-hosted `/slides-proxy/` bridge |
| [ADR-056](ADR-056-ai-handoff-via-prompt-not-live-api.md) | Low-volume AI (credit card reconciliation): structured prompt copy-paste, not live API |
| [ADR-059](ADR-059-tour-ai-dedicated-sidecar-proxy.md) | Tour AI has dedicated sidecar (port 8774) — separate from shared AI finance proxy |

**AI proxy map:**
```
counter-ai   (Claude/OpenRouter)  → counter intelligence
tour-ai      (port 8774)          → route planning / itinerary
ocr-proxy    (port 8766)          → bill photo OCR (Gemini + Ollama fallback)
dispatch-ai  (port 8767)          → dispatch operations (Ollama primary)
ai-finance   (port 8772)          → finance AI
slides-proxy (port 8768)          → Google Slides generation
```

---

## 12. Learning Hub

**Core idea:** Hub-auth-gated catalog. Two content types: Scene Guide (screenshot + callout panels) and Screencast (video). Completion is threshold-based and scrub-proof. Two-RPC beacon captures partial views. Each content type has its own authoring pipeline.

| ADR | Decision |
|-----|----------|
| [ADR-051](ADR-051-learning-completion-threshold-scrub-proof.md) | Scene Guide: all scenes visited; Screencast: 80% watch time (play events only, not `currentTime`) |
| [ADR-052](ADR-052-learning-two-rpc-beacon-start-end.md) | `start_view` creates row + returns `view_id`; `end_view` via `sendBeacon` on unload |
| [ADR-064](ADR-064-video-tutorial-production-pipeline.md) | Screencast pipeline: `scripts.json → edge-tts → Playwright recording → ffmpeg H.264 CRF 18` |
| [ADR-065](ADR-065-scene-guide-format.md) | Scene Guide: JSON data contract (`learn/data/{id}.json`), percentage callout coords, mandatory case_study intro, browser TTS |

**Screencast pipeline stages:** scripts.json authoring → edge-tts TTS (hi-IN-MadhurNeural / en-IN-PrabhatNeural) → Playwright WebM recording (slowMo:800 inside launchOptions) → ffmpeg 2.5× slowdown + subtitle burn-in + concat → H.264 CRF 18 MP4 → auto-deploy to `[pwa]/tutorial/`

**Scene Guide data contract:** `learn/data/{id}.json` — bilingual `{ en, hi }` fields, `case_study` intro (persona/situation/goal/skills), `scenes[]` each with `img`, `title`, `text`, `callout` (percentage coords), optional `tip`. Screenshots at 800×450px in `learn/screenshots/{id}/`.

**Key tables:** `learn.view_event` · `learn.tutorial`
**Key RPCs:** `start_view(tutorial_id, employee_id, content_type)` → `BIGINT view_id` · `end_view(view_id, ...)`
**Player:** `learn/player.html?id={id}` (Scene Guide) · `[pwa]/tutorial/tutorial.html` (Screencast)
**Reference impls:** `finance/custodian/tutorial/` (Screencast) · `learn/data/field-agent-01.json` (Scene Guide)

---

## 13. Testing

**Core idea:** Playwright E2E, one spec per PWA. Minimum coverage: DOM render, JS state, SW version, hub registry, key interactions. Must pass before merge to master.

| ADR | Decision |
|-----|----------|
| [ADR-022](ADR-022-playwright-e2e-one-spec-per-pwa.md) | Playwright E2E; `tests/_template.spec.js` as base; tests run against dev domain |

**Key files:** `playwright.config.js` · `tests/_template.spec.js` · `tests/<pwa>.spec.js`
**Run:** `npx playwright test tests/<pwa>.spec.js --reporter=json > test_reports/...`

---

## 14. Maps

**Core idea:** Google primary (geocoding, display). TomTom for traffic, large matrices (>100 elements), truck profiles. Auto-failover via shared helper.

| ADR | Decision |
|-----|----------|
| [ADR-004](ADR-004-maps-google-primary-tomtom-fallback.md) | Google Maps primary; TomTom for traffic/large-matrix/truck + auto-failover |

**File:** `/var/www/360lm/shared/maps-client.js`

---

## Cross-cutting rules (apply everywhere)

These apply to every PWA and every new feature — no exceptions without a superseding ADR:

| Rule | ADR |
|------|-----|
| Redirect to hub with `?next=` | ADR-001 |
| Link `shared/safe-bottom.css` | ADR-002 |
| Scroll root document only | ADR-003 |
| Bump `CACHE_VER` on every deploy | ADR-005 |
| Check `verify_pin` as array | ADR-006 |
| SECURITY DEFINER on privileged triggers | ADR-007 |
| `#variable_conflict use_column` in RETURNS TABLE | ADR-008 |
| Register in `hub.pwa_registry` | ADR-016 |
| Use maps-client.js for all map calls | ADR-004 |
| Follow AI pipeline contract | ADR-062 |
| Run VCC checklist before first Edit/Write | ADR-068 |
| Confirm before any cross-PWA infra change | ADR-067 |
| Hotfix: bugfix-DB → dev branch → deploy-prod.sh | ADR-069 |
| Register session + acquire zone locks before shared work | ADR-066 |
| All timestamps displayed in IST | ADR-070 |
| All monetary inputs use Indian format | ADR-071 |
| All proof images: capture → canvas editor → compress | ADR-072 |
| Every fetch: `Accept-Profile` + `Content-Profile` headers | ADR-074 |
| RPC errors: `RAISE EXCEPTION USING MESSAGE=...` | ADR-075 |
| Every PWA `<head>`: `viewport-fit=cover` + `theme-color` | ADR-076 |
| `/shared/` helper needs 3+ PWAs + ADR before adding | ADR-079 |
| Hub is authoritative; deactivation cascades via trigger | ADR-080 |
| Safe-area: `max(Xpx, env(safe-area-inset-*))` on sticky elements | ADR-081 |
| Traefik: Docker labels only; `traefik-net`; dual cert resolvers | ADR-073 |
| OpenClaw: `openclaw_ro` SELECT-only; default-deny new schemas | ADR-077 |
| Paperclip: zero lm360 access; API-only for future data needs | ADR-078 |
| ERP PWAs: `color-scheme: light only`; no dark mode toggle | ADR-082 |
| Forms: client checks presence; server enforces business rules | ADR-083 |
| Lists >500 rows: PostgREST `Range` header, page size 50 | ADR-084 |
| CSV/PDF/file uploads: sidecar — never multipart to PostgREST | ADR-085 |
| Isolated PWA: own DB + domain + auth (all 4 criteria required) | ADR-086 |
| Push SW: mandatory `push` + `notificationclick` handlers; subscriptions in `hub.push_subscriptions` | ADR-087 |
| IndexedDB: additive-only `onupgradeneeded` (`if oldV < N`); mandatory `onblocked` toast; bump CACHE_VER together | ADR-088 |
| Field-facing PWAs: bilingual EN/HI with `t()` helper + DB-backed `employee.lang` + EN/हिं toggle | ADR-089 |
| Field-facing PWAs: `applyFontSize()` at init; 5-level `--font-scale` CSS custom property | ADR-090 |
| Multi-step tours: `shared/tour.js`; storage key `<pwa>-tour-v<N>`; increment N on step change | ADR-091 |
| Employee prefs: DB → localStorage → hardcoded default (silent on RPC fail); device prefs: localStorage only | ADR-092 |

---

## 15. Dev Operations & Safety

**Core idea:** Four protocols govern how changes reach production safely: a mandatory pre-build bug-prevention gate (VCC), an explicit stop-and-confirm for cross-PWA infrastructure changes, a structured hotfix workflow that never touches prod directly, and a conflict zone locking system for parallel Claude Code sessions.

| ADR | Decision |
|-----|----------|
| [ADR-066](ADR-066-parallel-cli-session-coordination.md) | Parallel CLI sessions register on a shared board and acquire conflict zone locks in alphabetical order — prevents concurrent writes to Traefik, grants, Docker, hub registry, SW cache |
| [ADR-067](ADR-067-cross-pwa-change-safety-gate.md) | Any change to PostgREST grants, Docker compose, Traefik routing, hub session format, shared DB schemas, or proxy services requires explicit user confirmation before proceeding |
| [ADR-068](ADR-068-vcc-pre-build-safety-checklist.md) | Before the first Edit/Write of any session, run the VCC checklist (sections A–O) — 13 bug classes, each backed by a production incident |
| [ADR-069](ADR-069-production-hotfix-protocol.md) | Hotfixes: clone `lm360_prod` → `lm360_bugfix`, author on dev branch, test against bugfix DB, merge→master, run `deploy-prod.sh`, then drop bugfix DB and container |

**Conflict zones (ADR-066):** `CRON_JOBS` · `DBT` · `DB_EXPENSE_SHARED` · `DB_GRANTS` · `DB_MIGRATIONS` · `DOCKER_SHARED` · `MEMORY_MD` · `NGINX_SHARED` · `SYSTEMD_ENV` · `TRAEFIK` — acquired in alphabetical order to prevent deadlock.

**Cross-PWA triggers (ADR-067):** PostgREST grants · Docker shared networks · Traefik labels · hub session localStorage keys · SW `CACHE_VER` patterns · `expense.employees` / `hub.*` / any schema read by 3+ PWAs · all sidecar proxies.

**VCC key checks (ADR-068):** verify_pin returns array not bool · DB migrations applied before RPC calls · SW `CACHE_VER` bumped · DOM IDs verified after HTML changes · all fetches guard `resp.ok` · IST timezone conversion · cross-PWA approval gate.

**Hotfix flow (ADR-069):** `createdb lm360_bugfix -T lm360_prod` → `postgrest-bugfix` container → `git checkout -b hotfix/x master` → fix + test → merge → `deploy-prod.sh` → `dropdb lm360_bugfix` + `docker rm postgrest-bugfix`.

---

## 16. Platform Standards

**Core idea:** Cross-cutting UX and API conventions applied uniformly across all ~20 PWAs. Each standard is copy-verbatim (not a shared import) to preserve PWA self-containment (ADR-013). Violations cause inconsistent UX for field teams.

| ADR | Decision |
|-----|----------|
| [ADR-070](ADR-070-ist-timezone-enforcement.md) | All user-facing timestamps converted to IST (UTC+5:30) at the presentation layer; storage always UTC |
| [ADR-071](ADR-071-indian-number-formatting.md) | All monetary inputs use Indian format `??,??,???.??` via three canonical copy-verbatim functions (`fmtAmountInput`, `_indFmt`, `_parseAmt`) |
| [ADR-072](ADR-072-proof-image-capture-annotation.md) | Proof images: two-phase flow (capture → canvas editor → compress); CTH composite for cash; base64 in `receipt_image TEXT`; lazy-fetch on tap |
| [ADR-074](ADR-074-postgrest-accept-profile-header.md) | Single PostgREST instance; every fetch sends `Accept-Profile: <schema>` + `Content-Profile: <schema>`; cross-schema reads via views, not header-switching |
| [ADR-075](ADR-075-rpc-error-response-format.md) | Business-logic RPC errors: `RAISE EXCEPTION USING MESSAGE=...` → HTTP 400; TABLE RPCs: empty array = failure; client always guards `resp.ok` |
| [ADR-076](ADR-076-mobile-first-viewport-standard.md) | Every PWA `<head>`: `viewport-fit=cover`, `mobile-web-app-capable`, `apple-mobile-web-app-capable`, `theme-color`; no `user-scalable=no` |
| [ADR-079](ADR-079-shared-helper-governance.md) | Helper goes in `/shared/` only if used by 3+ PWAs + no PWA state + ADR-documented; breaking changes update ALL consumers in one PR |
| [ADR-080](ADR-080-hub-session-registry-cross-schema-sync.md) | Hub is authoritative: PIN → localStorage → PWA access; deactivation cascades via SECURITY DEFINER trigger; offline via `hub-access.json` dual-write |
| [ADR-081](ADR-081-safe-area-inset-rendering.md) | `max(Xpx, env(safe-area-inset-*))` on sticky bars, FABs, modals, canvas editor; pairs with ADR-076 (`viewport-fit=cover`) and ADR-002 (`safe-bottom.css`) |
| [ADR-089](ADR-089-bilingual-platform-architecture.md) | Bilingual EN/HI: DB-backed `employee.lang` preference, copy-verbatim `t({en,hi})` helper, EN/हिं toggle, mandatory for field-facing PWAs |
| [ADR-090](ADR-090-font-size-accessibility-preference.md) | Font size: 5-level scale (0–4, 0.85×–1.25×), `<pwa>-font-size` localStorage key, `applyFontSize()` at init before first render, CSS `--font-scale` custom property |
| [ADR-092](ADR-092-user-preference-cascading-fallback.md) | User preferences: employee-tier (DB → localStorage → default); device-tier (localStorage → default); RPC failure always falls through silently |

**IST pattern (ADR-070):** `toLocaleString('en-IN', {timeZone:'Asia/Kolkata'})` or `new Date(utc_ms + 5.5*3600*1000)` — never store IST, only display it.

**Amount input pattern (ADR-071):** `type="text" inputmode="decimal"` + `oninput="fmtAmountInput(this)"` + `_parseAmt(field.value)` before DB write.

**RPC error pattern (ADR-075):** SQL: `RAISE EXCEPTION USING MESSAGE='User-readable', DETAIL='technical'` → JS: `if (!resp.ok) { const e = await resp.json(); showToast(e.message); return; }`

**Safe-area pattern (ADR-081):** `padding-bottom: max(16px, env(safe-area-inset-bottom))` — the `max()` ensures devices without notch still get the base padding.

**Bilingual pattern (ADR-089):** `const t = o => (typeof o === 'string') ? o : (o[lang] || o.en || '')` — copy-verbatim, never import. All user-visible strings in field PWAs are `{en:'...', hi:'...'}` objects. Language preference stored in `employee.lang`; fallback to `localStorage.getItem('lang') || 'en'`. DevGuide: `docs/bilingual_devguide.md`.

**Font scale pattern (ADR-090):** Call `applyFontSize()` as first line of `init()`. Storage key: `'<pwa>-font-size'`. Default level 2 (1.0×). CSS: `font-size: calc(1rem * var(--font-scale, 1))` on all text elements.

**Preference fallback pattern (ADR-092):** Employee-tier (language, notifications) → fetch DB RPC → localStorage cache → hardcoded default. Device-tier (font-size, zoom) → localStorage only, never DB. RPC failure always silent: fall through without error toast.

---

## 17. Infrastructure & External Integrations

**Core idea:** VPS-level infrastructure decisions and external service integration contracts. These govern how new services are added to the platform and how AI/external tools access 360lm data.

| ADR | Decision |
|-----|----------|
| [ADR-073](ADR-073-traefik-label-conventions.md) | Docker labels only (no static config); `mytlschallenge` for platform services, `zerossl` for external/AI; `traefik-net` shared network; `stripprefix` when backend expects root path |
| [ADR-077](ADR-077-openclaw-integration-schema-grants.md) | `openclaw_ro` role: SELECT on expense/finance/installation/stores/production/vrs/recce/sales; NOT activity/dispatch/client/vehicle; two-layer auth (Traefik basic + app token) |
| [ADR-078](ADR-078-paperclip-isolation.md) | Paperclip: separate PG role, ZERO lm360 access, independent backup (60 min + 3 AM daily); future data access via API only, never direct DB |

**Traefik label template (ADR-073):**
```yaml
traefik.enable: "true"
traefik.http.routers.<name>.rule: "Host(`domain`) && PathPrefix(`/path/`)"
traefik.http.routers.<name>.entrypoints: websecure
traefik.http.routers.<name>.tls.certresolver: mytlschallenge  # or zerossl for AI services
traefik.http.services.<name>.loadbalancer.server.port: "<PORT>"
```

**openclaw_ro grants (ADR-077):** Default-deny for new schemas — explicit approval required before adding a new schema to OpenClaw's read access.

**Paperclip isolation rule (ADR-078):** If Paperclip ever needs 360lm data, the correct path is a dedicated API endpoint — never a direct DB grant.

---

## 18. UX & Data Patterns

**Core idea:** Cross-cutting UX and data-handling conventions for forms, lists, and file operations. These apply to all PWAs that collect input, display large datasets, or handle files beyond simple proof images.

| ADR | Decision |
|-----|----------|
| [ADR-082](ADR-082-dark-mode-theming-architecture.md) | ERP PWAs: forced light mode (`color-scheme: light only`); standalone/isolated PWAs may choose their own theme |
| [ADR-083](ADR-083-form-validation-strategy.md) | Client validates presence + basic format only; server enforces all business rules; field highlight on submit failure; no `required` attribute |
| [ADR-084](ADR-084-list-pagination-infinite-scroll.md) | ≤500 rows: client-side (ADR-061); >500: PostgREST `Range: items=0-49` + `Prefer: count=exact`; infinite scroll for feeds, numbered pages for grids |
| [ADR-085](ADR-085-file-upload-patterns.md) | CSV/XLSX: `FileReader` + JSON RPC (max 5 MB / 1000 rows); PDF: server-side sidecar; general files: multipart sidecar (max 10 MB); no multipart to PostgREST directly |
| [ADR-086](ADR-086-isolated-pwa-architecture.md) | Isolated PWA (all 4 criteria: different users, data isolation, different auth, standalone domain) gets own DB/domain/role; ERP PWA if any criterion fails |

**Validation split (ADR-083):**
- Client: required presence · amount > 0 · non-empty string · `_parseAmt()` before DB write
- Server only: balance limits · status transitions · uniqueness · FK checks · date-range rules

**Pagination pattern (ADR-084):**
```js
// Infinite scroll trigger
const sentinel = document.querySelector('.loading-sentinel');
new IntersectionObserver(entries => {
  if (entries[0].isIntersecting && !loading) loadNextPage();
}).observe(sentinel);
// PostgREST request
fetch('/api/rpc/...', { headers: { 'Range': `items=${offset}-${offset+49}`, 'Prefer': 'count=exact' } })
```

**Isolated vs ERP decision (ADR-086):** All 4 must be true to isolate — different users, data isolation needed, different auth, no shared schemas. If any fails → ERP PWA registered in hub.pwa_registry.
