You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/06-DECISIONS.md

705 lines
51 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 06 — Open Decisions (founder calls)
Each has a recommendation; none is blocking to *start* Phase 0, all must be locked to *exit* it.
## D1 — Product name & positioning vs "SiMS Classic"
New name, or "SiMS" with a generation marker? **Recommendation:** keep the SiMS brand
(it carries your existing base's trust), market the rebuild as *SiMS Next* or *SiMS 2*
the migration pitch is easier when it's "your SiMS, reborn" rather than a stranger.
## D2 — POS shell ✅ REVISED (2026-07-09): web app first
Founder call: **the product is a web app.** POS and back office are browser apps served
by the store-server on LAN (later the same apps served from cloud). Zero installs at
counters — the Oracle Forms deployment model, modernized. Consequences:
- **Hardware routes through the store-server**: web POS → HTTP → store-server → ESC/POS
over TCP 9100 (printers + drawer). Scanners are keyboard-wedge (browser-native).
Weighing scales: Web Serial API (Chrome/Edge) or a store-server serial bridge — spike item.
- **Electron shell is kept as an optional kiosk wrapper** (~100 lines, already built) for
counters that need true offline billing or locked-down kiosk mode; same web bundle inside.
- **Browser key conflicts** (F11 fullscreen, F12 devtools): keymaps are DB config —
the web preset leans on Numpad+ for cash-close and Chrome kiosk mode at counters.
- Offline story for browser POS (PWA + IndexedDB bill queue) is a Phase-2 hardening item;
v1 counters depend on the store-server over LAN, exactly like Classic depends on the
Oracle server today.
## D3 — Sync: buy (PowerSync / ElectricSQL) vs build (custom outbox)
**Recommendation:** spike both in Phase 0; prefer buying the transport if it handles
per-tenant partial sync cleanly. The domain rules (immutable documents, per-counter
series, derived stock) are ours regardless.
## D4 — GSP vendor for e-Invoice / e-Way
ClearTax vs Masters India vs others. Decide on: API reliability, per-document pricing at
your volume, sandbox quality. **Recommendation:** shortlist two, integrate behind our own
interface so switching is a config change.
## D5 — Pricing tiers ✅ RESOLVED (direction)
Founder call (2026-07-08): prices aren't fixed yet and must be **addable/changeable in the
DB** so they reflect in the app without a release. Design adopted: `plan`, `plan_feature`,
and `price_book` tables (see [07-DB-AND-CONFIG.md](07-DB-AND-CONFIG.md) §2) — new plans and
₹ amounts are rows, the license token is a signed snapshot of them. Actual ₹ amounts get
decided whenever you're ready; nothing blocks on it.
## D6 — Accounting depth at launch
Full books (P&L/BS) in-app from Phase 2, or ledgers + **Tally export** first?
**Recommendation:** ledgers + Tally export in Phase 2, full statements later. Most shops'
CAs live in Tally; export buys goodwill for a fraction of the effort. Classic parity data
should confirm how many customers actually used full accounting.
## D7 — Store Hub (LAN sync box) as requirement or option
Multi-counter stores need counters to see shared stock/bills when internet is down.
**Recommendation:** ship Store Hub as an optional role of the main billing PC
(a checkbox, not a separate device) from Phase 2.
## D8 — Android POS terminals (Sunmi-style) in scope? ✅ RESOLVED
Settled by the market-architecture brainstorm (08-MARKET-ARCHITECTURES.md, in the Store repo):
every player winning high-throughput counters is desktop + local-first; Android wins
micro-merchants and mobility. **Decision:** v1 = Windows desktop POS; Phase 34 = Android
*companion* app (line-busting, stock-take, approvals) sharing the billing-engine package;
full Android POS only if we later go down-market deliberately. D2 (Electron) stands.
## D9 — Purchase Inbox channels at launch
Email-in + manual upload first; WhatsApp-forward intake later (needs WhatsApp Business
API setup). **Recommendation:** email + upload in Phase 3, WhatsApp intake fast-follow.
## D10 — Cloud region & provider
Any major provider with a Mumbai region works. Deciding inputs: managed-Postgres quality,
egress pricing, your team's familiarity. **Recommendation:** pick boring; revisit never.
## D13 — Support ticketing: build vs integrate (raised by 11-ADMIN-SUPPORT-CONSOLE.md)
The diagnostic bundle, error-code playbooks, and Store 360° are ours; the ticket queue
itself is commodity. **Recommendation:** integrate a boring helpdesk in Phase 2; build
only if the integration fights the error-code workflow.
## D14 — Cloud staging ✅ RESOLVED: local-only first, cloud layered on later
Founder call (2026-07-09): v1 runs fully local (counters + in-store Oracle 12c per D12),
then the cloud tier is added and switched on per store. Accepted consequences: owner app,
Purchase Inbox, WhatsApp, central backup, silent auto-update, and HQ fleet features all
wait for the cloud workstream. Guardrail (non-negotiable): client UUIDs + dormant outbox
rows are written from the first local build, so cloud enablement is switch-on, not
migration. See the staging note atop 04-ROADMAP.md (in the Store repo).
## D11 — Existing team & hiring plan (partially answered)
Founder input (2026-07-08): a support team exists for Classic and continues; the product
principle is a **stable frontend** with changes flowing from backend/DB, handled by the
team — adopted as the config-driven design in [07-DB-AND-CONFIG.md](07-DB-AND-CONFIG.md).
Still needed to calibrate the roadmap: builder headcount and skill mix for the new stack.
## D12 — Local DB engine ✅ RESOLVED (for now): Oracle 12c stays at the store tier
Founder call (2026-07-09): the in-store DB remains **Oracle 12c** — it is already deployed
shared infrastructure with multiple other systems running on it, so for the existing base
the license and operations cost is sunk. Analysis and the adapted design are in
[07-DB-AND-CONFIG.md](07-DB-AND-CONFIG.md) §3.
**Consequences accepted with this call:**
- **D3 resolves to "build"**: off-the-shelf sync engines (PowerSync/ElectricSQL) speak
Postgres/SQLite only — sync is a custom outbox, with outbox tables living in Oracle and
a store-side sync agent draining them to the cloud.
- **Counters still carry a local SQLite buffer** (offline bill queue + item-search cache)
so a counter survives store-server or LAN death — the "counter never stops" goal is
independent of the store engine.
- Backend connects via node-oracledb **thin mode** (no Oracle client install needed;
supports 12.1+).
- **New code stays engine-portable**: repository/DAL layer, portable SQL by default,
Oracle-only features only with a written justification — "for now" must stay cheap to
revisit.
**Revisit triggers (any one reopens this decision):** onboarding NEW customers who don't
already have Oracle in-store (license cost is no longer sunk); the other systems sharing
the DB migrate off 12c or upgrade (19c/23ai); a security/compliance audit flags the
unpatched engine; or sync friction in Phase 01 proves materially costlier than an
engine port. Risk register entries in 07 §3 remain on record.
## D15 — Internal HQ ops console: build now ✅ RESOLVED (2026-07-09; split to its own repo 2026-07-16)
Founder call (2026-07-09): an **internal tool for running our own ~300-client business**
quotations/proforma/invoices in minutes, recurring billing with auto/manual email
reminders, per-module payment + AMC tracking, per-client AWS usage & cost, and the full
client interaction memory (installation, training, visits, calls, meetings, sales
outcomes) in a system instead of heads. **Decision:** start the
[doc-11 HQ Console](11-ADMIN-SUPPORT-CONSOLE.md) **early**, as `apps/hq` (built in the shared
monorepo at the time; **split into its own repo `C:/SiMS/hq` on 2026-07-16**) —
spec in [14-SPEC-HQ-CONSOLE.md](14-SPEC-HQ-CONSOLE.md). Cloud-hosted **vendor-side**
(D14 untouched — the store product stays local); SQLite behind portable repositories with
S3 backup; **Gmail API** sending from the company mailbox; automated AWS cost attribution;
**full replacement of the internal Oracle APEX app** — one-time verified migration, then
APEX goes read-only, no double entry. Reuses `@sims/billing-engine` (our invoices are GST
documents), `@sims/domain` (paise money, FY, doc series), `@sims/ui`, and the `@sims/auth`
patterns.
**Alternatives rejected:** fully separate codebase (rebuilds the engine/UI/patterns and
builds doc-11 twice); off-the-shelf CRM (no module-level tracking against our catalog, no
AWS attribution, no path to the fleet console).
**Consequences accepted:**
- This shipped as **two deployables with different release rhythms**. On **2026-07-16 they
were split into two independent repos** (`C:/SiMS/hq` and `C:/SiMS/store`) — separate
histories, deploy scripts, and versioning; the shared packages were forked into each.
- Doc-11's phase mapping **splits**: the ops slice ships early (HQ-1…3 in
[14-SPEC-HQ-CONSOLE.md](14-SPEC-HQ-CONSOLE.md) §6); the fleet half is unchanged and
waits for the cloud tier per D14.
- **One repo, two runtime-independent applications** (founder re-confirmed 2026-07-10
after weighing a full split): separate servers, DBs, builds and deploys; only source
packages are shared. Guardrails: a bundle-isolation check in the test suite (store
bundle contains no HQ code and vice versa), per-app version tags, and the rule that
**HQ's DB is the cloud tier's nucleus** — the tenant registry is born from HQ's client
table (same UUIDs, promoted once), never built beside it. **Postgres locked as the
production engine (founder, 2026-07-10)** — swap runs as its own task after HQ-2,
before the real-data import, so live data never migrates engines; SQLite stays the
dev/test engine behind the same repositories. Physical repo split stays a cheap,
reversible option if practice ever demands it.
## D16 — Quote-to-close funnel slice (2026-07-17)
Founder-approved design in
`superpowers/specs/2026-07-17-quote-to-close-funnel-design.md`; decisions locked
with it:
- **Employee foundation rides `staff_user`.** The Employee entity is **backed by the
existing `staff_user` table** — renaming it would churn `session.staff_id`,
`interaction.staff_id` and audit entity names for zero gain; "Employee" lives in the
repo/API/UI vocabulary only. Roles widened to `owner | manager | staff` via a guarded,
transactional table rebuild (SQLite cannot ALTER a CHECK); the enum is validated in
the repo. Session hardening rode along: `verifySession` requires `active=1`, and
deactivation / password reset purge sessions in the same transaction.
- **Chase-list over kanban (option B).** The pipeline is **one cross-client ranked
list** — most-overdue on top, colour bands, an explicit next action per row,
`All | Mine | Overdue | Lost` filters — not a kanban board.
- **Derived pipeline stages, zero new pipeline storage.** Stage (Enquiry / New
Project / Quoted-Waiting / Won / Lost) is **never stored** — computed from
`client.status` + the client's latest live quotation; age anchors on the first
`sent` document event. The only new column anywhere in the slice is
`client.owner_id` (a bare lead has no creator to derive an owner from — A4).
- **Escalating owner+client reminders on the dated `reminder_schedule` table.** New
sendable `quote_followup` rule kind: the queued reminder row **is** the owning
employee's nudge (owner derived `doc_id → document.created_by`), and its send **is**
the client email with a public share link. Cadence **and** message text are dated
config rows (rule 3) resolved by `resolveSchedule` — defaults 3/7/14 (quote) and
7/15/30 (invoice) ship as seeded rows plus code-constant fallbacks, so a cadence
change is a new dated row, never a release. Catch-up fires only the highest crossed
milestone; accept / lose / convert / cancel dismisses open nudges per-row (audited)
in the same transaction; previews resolve shares read-only — minting happens only in
the send path (60-day expiry, live links reused).
- **Convert recompute + duplicate-invoice guard (F3/F4).** PROFORMA → INVOICE rebuilds
the carried lines through `computeBill` on the **invoice's own `doc_date`** (a tax
invoice uses the rate that is law on its issue date), and `convertDocument` rejects
any source with a live (non-cancelled) forward child — one sale, one invoice number.
- **Proforma supersede matrix.** Corrections stay firewalled by document type: a draft
PI is simply re-drafted; an issued, unallocated PI may cancel (number stays consumed)
and be recreated as a `ref_doc_id`-linked supersede; an issued INVOICE is **never**
recreated — cancel-if-unpaid or credit note only.
- **Assumptions A1A7 (defaulted, flagged in the spec):** A1 module roster balances
support and revenue; A2 employees are a plain table (no org chart); A3 document
owner = `created_by` for v1 (no `document.owner_id` until a handoff feature is
real); A4 `client.owner_id` is the one genuinely new owner column; A5 notify-all
text is owner-typed ad-hoc, not dated config; A6 roster visible to all staff,
notify-all owner/manager only; A7 invoice-overdue anchors on `doc_date` (no
due-date column exists).
**Recorded deviation from the funnel spec's blanket pagination rule (§5 / rule 8):**
`GET /employees` returns the full set with `total` and **no `page`/`pageSize`**
console users are a small bounded set (single-digit headcount), the count is displayed,
and nothing is silently truncated, which is the intent of rule 8. If the team ever
grows past a screenful, paginate it like every other list.
**Delivery state (2026-07-17, final):** all ten phases landed. Phases 16 (employee
foundation + management surface, owner plumbing, dated schedule, pipeline chase-list,
escalating quote follow-up) plus the F3/F4 convert hardening, then 710 in the same
day's second pass: one-click `POST /documents/:id/convert-and-send` (guards → one txn
convert+issue → post-commit email), proforma `POST /documents/:id/supersede`, invoice
`dN` milestone escalation (d7/d15/d30 from the dated schedule, F7 highest-only
catch-up), and the module → client roster (`GET /modules/:id/clients` + CSV export +
owner/manager Notify-all with per-recipient audit). Suite at 293 tests / 57 files.
## D17 — HQ console visual redesign: warm ops-console (2026-07-17)
Founder-approved spec in `superpowers/specs/2026-07-17-hq-console-redesign-design.md`
(delivered same day; adversarial multi-agent review + fixes recorded there). Decisions
locked with it:
- **Direction: "Direction A v2 discipline wearing the H-04 warm palette."** Synthesised
from the two most recent design artifacts (the chosen Claude-Design "Direction A v2"
system and the H-04 Client 360 mockup): warm paper neutrals (`#FAFAF7`/`#E7E5DE`/
`#1A1A17`), flat border-defined surfaces, shadows only on overlays.
- **Teal is the default accent; primary buttons are ink, not accent.** Accent colour is
reserved for *state* (active nav, chips, links, focus). The 6 previous accents stay
switchable; dark mode is warm-tinted with explicit per-accent dark values.
- **Mono/sans split.** JetBrains Mono for machine data (codes, doc numbers, GSTINs, ids);
Inter with tabular numerals for money. Uppercase micro-labels never below 11px (WCAG).
- **Ctrl-K command palette over more nav.** Client search + pages + actions in one
keyboard-first overlay (generic `CommandPalette` in `@sims/ui`; AND-term matcher).
- **Client 360 = record header + KPI row + relationship-pulse ribbon + deep-linkable
tabs** — the ribbon plots documents / payments / interactions / AMC on one 12-month
axis from data the page already loads (no chart lib, no new endpoints).
- **UI-only slice.** No backend/schema changes; `@sims/ui` stays generic (no router or
icon deps — `lucide-react` lives in `apps/hq-web` only). Known deferred: server-side
"outstanding ₹" figure for the Client 360 KPI row (client-side paise math is banned).
## D18 — Go-live cluster: due dates, ops pages, shell & profile, client support data ✅ DELIVERED (2026-07-17)
Founder-approved slice (spec `superpowers/specs/2026-07-17-golive-cluster-design.md`), built
in ten phases the same day. The calls that matter going forward:
- **Invoice due dates = terms default + per-document override.** `billing.payment_terms_days`
(seeded 15) is the operational default; the *dated* fact is the `due_date` stamped on each
invoice at issue (covers manual issue, convert-and-send and recurring generation in one
place). Overdue reminders anchor `COALESCE(due_date, doc_date)`; the chase mail says
"was due on X and is now N day(s) overdue"; legacy invoices keep the old anchor.
- **Client support data (APEX parity):** `anydesk`/`os`/`district`/`sector` on the client;
district+sector filter the book. The DB password is AES-256-GCM at rest (`HQ_SECRET_KEY`,
loud refusal without it), NEVER in payloads (only `hasDbPassword`), write and reveal are
owner/manager-gated with one audit row per reveal.
- **Roster single-write-path:** the module→clients roster edits (kind/edition/renewal/
status, assign, unassign) reuse `updateClientModule`/`assignModule` — the same audited
functions Client 360 uses, so the two views cannot disagree. `ClientModulePatch` gained
kind/edition with allowedKinds validation.
- **Quotation sectioning:** QUOTATION prints one titled section per module (name +
what's-included + price; print page-break per module); INVOICE/PI/CN/RECEIPT keep the
billing grid. Rendered in the ONE `documentHtml`, preserving preview↔PDF fidelity.
- **Self-service profile:** `/me` routes are strictly self-scoped (role/email untouchable);
changing your own password requires the current one and kills every OTHER session.
- **My Day:** staff dashboards are always scoped to their own book server-side;
owner/manager toggle Mine/Everyone (`?mine=1`). Sidebar collapses to an icon rail.
- **APEX import UI:** owner-only `/import` wraps the existing stage/verify/commit —
commit stays locked while any staged row carries a problem.
Delivery was followed the same day by an independent review of the full slice diff:
8 findings (4 major — convert-and-send click bubbling on the register, importer not
mapping the WS-F support fields, Pipeline still on the old plain convert, missing
client-book filter UI; 4 minor — typed contact roles absent, dashboard/aging still
anchored on doc_date, non-atomic dbPassword PATCH, audit-outside-txn on three new
write paths) — all confirmed and fixed in one pass (`fix(review)` commit).
Suite at 345 tests / 66 files after the review pass.
## D19 — Postgres switch executed (D15 delivered) ✅ DELIVERED (2026-07-17)
The engine slice queued after D18, done BEFORE the real APEX cutover so production data
is born in Postgres and nothing is ever migrated. Spec:
`superpowers/specs/2026-07-17-postgres-switch-design.md`. The calls that matter:
- **One async DB interface, two engines.** Every repo/route/scheduler/test awaits
`db.all/get/run/exec/transaction`. better-sqlite3 stays for dev + the fast suite
(`SqliteDb`, statement-cached, manual BEGIN/SAVEPOINT); Postgres (`PgDb`, node-pg Pool)
is selected by `DATABASE_URL`. Repos keep `?` placeholders and portable SQL; the pg
adapter rewrites `?`→`$n`, pins transactions to one pooled client via AsyncLocalStorage
(nested = savepoints), and parses int8/numeric to JS numbers (paise stay integer math).
- **Postgres schema choices:** bigint for every paise column (int4 overflows at ₹2.15
crore), 0/1 flags stay INTEGER (repos say `active=1` on both engines), JSON stays TEXT
(node-pg auto-parses jsonb — would break JSON.parse at the repo edge), dates stay TEXT
ISO. Numbered migrations + schema_migrations runner (migrations-pg.ts), applied
atomically; the reminder rule_kind CHECK is born in its final widened form.
- **Proof:** default suite 346/346 on SQLite (zero behavior change); 5-test pg
integration suite (opt-in `HQ_PG_TEST_URL`, wipes its target) green on local
PostgreSQL 17 — migrations-from-zero, series numbering + due-date stamp, paisa-exact
payment→paid, ON CONFLICT exactly-once reminders, savepoint rollback, HTTP boot smoke.
- **Known nuance before go-live:** LIKE is case-insensitive on SQLite, case-sensitive on
Postgres — client search will match case-sensitively in prod until repos switch to
`LOWER(...) LIKE LOWER(?)`.
- Local dev Postgres: PostgreSQL 17 Windows service, superuser `postgres`, role `hq`,
databases `hq` (runtime) + `hq_test` (integration suite). Prod = RDS at go-live, same
`DATABASE_URL` mechanism.
## D20 — HQ hosting target: hq.simssoftware.com on the company AWS box (2026-07-17)
Founder call. The console goes live at **`https://hq.simssoftware.com`**:
- **DNS stays at GoDaddy** (where simssoftware.com lives) — one `A` record `hq` → the
EC2 Elastic IP. No nameserver moves, no Route 53 migration; the rest of the domain
(www, mail) is untouched.
- **The existing company EC2 hosts it** as its own section (`/opt/sims-hq`), beside
whatever else the box serves — no new instance for v1. TLS via Caddy (or a server
block in the box's existing nginx), pm2 from the repo root, only 80/443 public.
- **Sequencing: database first, then the server section.** Provision RDS Postgres, set
`DATABASE_URL` (D19's switch), boot + migrate, run the APEX cutover into Postgres —
and only then wire DNS/TLS and hand the URL to staff. Production data is born in
Postgres; the SQLite dev DB is never migrated.
- Runbook updated with the exact configs (GoDaddy record, Caddyfile/nginx block, RDS
sizing + security-group shape, pm2 invocation): `DEPLOY-HQ.md` §0 and §7. The founder
has hosted this way before — the runbook now carries the fiddly configuration detail
so setup on the box is a checklist, not archaeology.
## D20 — APEX parity: ticket desk, per-service data, branches, full importer ✅ DELIVERED (2026-07-17)
Driven by the real APEX export (`C:\SiMS\hq\Apex`, 25 tables). A 46-agent profiling+audit
swarm analysed the actual data before the importer was trusted. Spec:
`superpowers/specs/2026-07-17-apex-parity-design.md`. The calls that matter:
- **Ticket desk is in scope** (reversing the original non-goal): 4,567 APEX tickets are the
team's daily workbench. New `ticket` table (team-visible; "mine" is a filter not a gate),
status open/in_progress/waiting/closed/dropped, honest pagination, kind suggestions from
history. Client 360 gains a Tickets tab.
- **client_module carries per-service operational data** (provider/username/password_enc/
details JSON/remark) — the portal password encrypted + reveal audited, exactly like the
client DB password. **NOTE: D21 restructures this into a module-defined field schema.**
- **client_branch** table (minimal; APEX ho_branch_list parity).
- **Five modules** (SMS, RTGS, WhatsApp, Mobile App, ATM) seeded from client YES/NO flags;
ticket + roster module pickers read the live catalog, so a sixth module needs no code.
- **Importer v2** (`import-apex-full.ts`, 9 files) survived the swarm's real-data findings:
bills are LINE ITEMS grouped by INVOICE_NO across three GST eras; 46 raw ticket statuses
pattern-mapped; OLD_ID re-key chains collapsed; APEX test client (SID 329) + orphan refs
skipped with counted notes; plaintext credentials encrypted at commit (refuses without
HQ_SECRET_KEY). Trial import into local Postgres verified — see the spec banner for the
reconciled counts (273 clients / 150 docs = ₹33.17L / 1,826 tickets / INVOICE seed 10252).
Two real-data facts to carry forward: the export has NO state/GSTIN column (all docs split
CGST/SGST at the company state) and one client's OS field holds a plaintext Linux root
password (move it into an encrypted field + rotate at cutover).
## D21 — Module-defined field schema (config on the module) ✅ DELIVERED (2026-07-17)
Spec `superpowers/specs/2026-07-17-module-field-schema-design.md`. Each module declares its
own operational fields (`module.field_spec` JSON: FieldDef key/label/type/options/required);
per-client values live on `client_module` as `field_values` (non-secret, type-validated
against the owning module's spec) + `secrets_enc` (one AES-GCM blob over a {key:plaintext}
map, N secrets, reveal audited). Owner defines a module's fields on the Modules page; the
Client 360 service card renders the form automatically; a new module or field needs NO code
and NO migration — the founder's flexibility mandate, delivered. Built **additively** over
D20 (login trio keeps its columns; retiring them into field_spec is a later cleanup after the
direction is confirmed). Verified on the real re-import: RTGS 13 fields / SMS 6 / MobileApp
1, 53/53 RTGS + 102/105 SMS links carrying structured values. Suite 363 green.
## D23 — SMS volume-band billing (dated rate card) ✅ DELIVERED (2026-07-17)
SMS packs are the majority of billing, so they get first-class quantity-tiered pricing.
`usage_rate_band` is a dated per-module rate card (generic — any usage-billed module can
have one). `resolveUsageRate(module, count, date)` picks the band with the highest
`min_qty <= count`; a count below the lowest band is under the minimum order. `buildLines`
resolves a line's unit price as: explicit per-line override > tiered rate card > dated price
book — so an SMS line prices by count automatically, a save below the minimum is refused,
and the live preview warns instead of throwing. Founder card seeded (effective 2026-04-01):
≥50k → ₹0.40, ≥100k → ₹0.37, ≥200k → ₹0.34, ≥300k → ₹0.30 per SMS, minimum 50,000 — money
locked to the paisa in tests (50k→₹23,600, 100k→₹43,660, 200k→₹80,240, 300k→₹1,06,200 incl
18% GST). Owner routes GET/POST /modules/:id/rate-card. Rates are integer paise; a new card
is a new dated set of rows, so issued invoices keep the rate they were billed at.
## Catalogue additions (2026-07-17) — CloudBackup + RecoverX configured as data
Two recently-developed modules set up entirely as configuration (no code), proving the D20/
D21 flexible model: **CloudBackup & Disaster Management** (8 fields incl. AWS creds as a
secret; subscription ₹36k/yr + setup ₹25k + integration ₹10k) and **RecoverX / Recovery
Module** (7 fields incl. CBS server IP, APEX URL, field-officer count, GPS consent, admin
secret; app ₹35k + implementation ₹10k + integration ₹5k + AMC ₹15k/yr). Quote content
seeded from the real quotations for the section-per-module PDF.
Suite at 372 tests / 70 files after D21 + D23.
## D22 — Onboarding milestone tracker (reportable) ✅ DELIVERED (2026-07-17)
Founder call: track the common project status (advance paid / setup / installed / go-live /
balance paid / AMC) that applies to EVERY module sale, and make it reportable across the
book. `project_milestone` — each client_module (a "project") carries a checklist seeded from
the dated `project.milestone_template` setting (owner-editable; a project can add its own
steps). Ticking stamps the date, audited; `ensureProjectMilestones` is idempotent and
back-fills projects created before D22 (the 167 imported ones seeded). Cross-project board
(`GET /projects/board` — per-milestone done/total/pending) + drill-down
(`GET /projects/pending/:key` — who's pending that step). UI: a per-project checklist on the
Client 360 Modules tab + a new **Onboarding** work page (board + click-through to pending
projects). Also fixed a latent bug found here: `uuidv7()` is now monotonic within a process,
so `ORDER BY id` is genuine creation order (pagination + the sync cursor rely on it) — a
sub-millisecond tie no longer falls back to the random tail.
Suite at 377 tests / 71 files after D22.
## D24 — Red-team audit remediation ✅ (2026-07-18)
A 14-surface multi-agent red-team + quality audit (report artifact + `redteam-findings`)
returned 96 verified findings — **0 critical, 16 high, 43 medium, 37 low**. All 16 highs and
the load-bearing mediums were fixed in seven batches, each committed green with tests:
- **Auth (A):** `staff_user.failed_count`/`locked_until` — 8-fail/15-min account lockout;
`login()` constant-time (dummy scrypt on missing email — no enumeration oracle) returning a
typed `{ok,reason}`; per-IP (20/min) + per-email (8/15min) limiters on `/auth/login`;
per-user reveal limiter (20/min) on the three credential-reveal routes; `trust proxy` so
`req.ip` is the real client behind nginx. `makeRateLimiter``rate-limit.ts` (no cycle).
- **Authorization (B):** `requireManagerial` gate on `cancel` / `credit-note` (destructive)
and `POST /documents/:id/share` (the unauthenticated-public-link IDOR); `PATCH
/interactions/:id` restricted to author-or-managerial. **Deliberate:** general client
read/edit stays open to staff — a shared support-team book is the intended model; the
sensitive credential reveals are managerial + rate-limited.
- **Money (C):** credit-note capped to the invoice's remaining creditable value (read+insert
one txn); `nextDocNo` atomic `UPDATE … RETURNING` (row lock, no duplicate/burned numbers);
`markStatus` rejects accepted/lost on non-QUOTATION docs; `computeBill` bounds qty
magnitude (fractional kept for weighed Store goods) + HQ `buildLines` requires whole-unit
integer qty; `recordPayment` rejects TDS > cash received.
- **Concurrency (D):** `SqliteDb.transaction` serialized by an async mutex + AsyncLocalStorage
re-entrancy (nested → savepoints) — the shared `txnDepth` counter could interleave
concurrent BEGIN/COMMIT and lose writes; scheduler isolates each recurring plan (one bad
plan no longer aborts the whole scan + auto-send); notify-broadcast IIFE gets a trailing catch.
- **Rendering (E):** strict CSP meta in both letterhead templates + puppeteer aborts every
non-`data:` request — an `esc()` miss degrades to a rendering glitch, not SSRF/RCE. Preview
↔ PDF byte-fidelity preserved.
- **Backend perf (D24):** 30 indexes on the hot FK/status/date columns, both engines
(`INDEX_DDL` + pg migration 007); client search + district/sector filters `LOWER(…) LIKE
LOWER(?)` — bare LIKE was case-SENSITIVE on Postgres and silently broke prod search.
- **Frontend (F):** a11y (keyboard rows, `<main>`+skip link, aria-labels, form Login, alert
toasts), UX safety (confirm on mass-email + reminder send, in-flight disable to stop
double-submit, debounced search, visible inline errors, ErrorBoundary), and shared
component extraction.
**Explicitly accepted / deferred (documented, not fixed):** async scrypt for `verifyPin`
(large shared-package refactor; the event-loop-DoS vector is mitigated by the login rate
limiter now); rendered-PDF caching on `/share`; api.ts file split; broad naming-consistency
renames (`staff`/`user`/`employee`, `at_wall`, schema.sql drift) — too risky pre-go-live.
## SQL-injection re-audit ✅ CLEAN (2026-07-18)
The SQL-injection surface (transiently blocked by a safety classifier during the D24 run)
was re-run as a focused 4-angle workflow (string-interpolation, dynamic identifiers, the
`?`→`$n` rewriter, the CSV importer) with adversarial verification. **Result: 0 findings.**
The auditors confirmed clean, angle by angle: every UPDATE `SET` clause is built from a fixed
source-literal column allowlist with `?`-bound values; every WHERE fragment is a constant
with bound args; client/ticket search uses `LOWER(col) LIKE LOWER(?)` with the pattern bound;
all `LIMIT/OFFSET` are integer-clamped bound params and every `ORDER BY` is a static literal;
D21 field-spec keys are validated by `KEY_RE` and used only as JSON map keys inside a bound
`field_values`/`secrets_enc` blob (never as SQL identifiers); the CSV importer binds every
cell value and no CSV header becomes a column name; and `db-pg.ts` `toDollarParams` fails
closed (a mis-split breaks the query, never injects) since bound values never pass through it.
## D25 — Installable PWA + phone compatibility ✅ (2026-07-18)
Founder ask: the console should be a PWA and phone-usable. Delivered without a heavy build
plugin: a hand-written `manifest.webmanifest` (standalone, teal theme, scope `/`) + an icon
set generated from the HQ mark (`pwa-192/512`, full-bleed `maskable-512`, `apple-touch-icon`,
`icon.svg`; `sharp` is a build-only devDep); a conservative service worker (`public/sw.js`)
that makes the app installable and fast on repeat loads but **never** intercepts `/api` or
`/share` (always live), does network-first navigations with an app-shell fallback, and
cache-first for hashed assets — registered from `main.tsx` in production builds only. Phone
polish on top of the existing `<900px` drawer + scrolling tables: safe-area insets
(`viewport-fit=cover`), 16px inputs (kills iOS focus-zoom), 40px tap targets, bottom-sheet
dialogs. Also hardened the deploy: `nginx` now sends `X-Forwarded-For $remote_addr` (replace,
not append) so the D24 per-IP rate limiters can't be bypassed by a spoofed header.
## D25 addenda (2026-07-18)
- **Module display order**: `module.sort_order` (both engines) — the app lists modules
SMS > RTGS > WhatsApp > MobileApp > RecoverX > CloudBackup > ATM > AMC; owner-editable
config (default 100 trails). Set on the live data.
- **Login by username**: `staff_user.username` (unique, both engines) is the login
identifier; `email` is demoted to a plain contact field on the user (kept required at the
DB for now — making it optional is a later table rebuild). Existing accounts backfill
username=email so logins are unbroken; `login()` matches username OR email. Employees can
be created with an explicit username.
- **Nginx XFF** hardened to `$remote_addr` (replace) so the D24 per-IP limiters can't be
spoofed. **Note (open, user's deliberate edit):** `server.ts` carries a placeholder
`HARDCODED_DATABASE_URL` gated to production — must NOT be filled with the real password
in source (it would push to Gitea); use a Docker secret / gitignored file instead.
- In progress: brighter document type/status colours + sectioned Client-360 details +
Employees username field (frontend).
## D26 — Functional gaps: GST report, doc search, client statement, bulk ops, AWS UI (2026-07-18)
Founder call ("start all of it", functional first). Built the five gaps the daily work misses:
- **GST filing summary** (`gstSummary`, GET /reports/gst-summary): per-month CGST/SGST/IGST +
taxable + total over issued invoices (add) and credit notes (subtract, s.34), from the
stored paise so it ties to the invoices exactly; money locked in tests.
- **Document search**: `listDocuments` free-text `q` on doc number OR client name
(case-insensitive, both engines); `GET /documents?q=`.
- **Bulk operations** (owner-only): `bulkUpdateClients` (fill GSTIN/state/district across a
set, each audited) + `bulkSetMilestone` (mark the already-live services on the onboarding
board) — POST /clients/bulk, POST /projects/bulk-milestone.
- **Client statement** + **AWS cost UI**: frontend over existing ledger / AWS endpoints.
Backend committed green (350 tests); UIs follow. Ops (Gmail connect, server secrets, DB
password out of source, backups) are the next slice after the features.
## D27 — Review-screen program: Renewals, Cockpit, Audit viewer (2026-07-18)
Founder steer mid-program: "it's a small business — the main thing we need is screens we
can review to and fro." So we built read-and-navigate screens and dropped the heavy
automation (bank reconciliation, WhatsApp adapter, GST e-invoice/GSP — too much machinery
for ~300 clients). Delivered: **Renewals** command centre (`renewalsDue`, one-click renewal
proforma), owner **Cockpit** (`misCockpit`, this-month-vs-last composed from the same repo
aggregates the Reports/Dashboard use), **Audit viewer** (`listAuditPage`/`auditFacets`,
owner-only, filter + honest pagination, reading writes no audit).
## D28 — SMS credit review + live gateway balance sync (2026-07-19)
The SMS module already declares an `sms_balance` service field (D26); this makes it
actionable. **SMS balances** screen lists every SMS client lowest-first, "Low" (< the dated
`sms.low_balance_threshold`) is the default upsell view, one click raises a top-up proforma,
and the owner Cockpit + Dashboard carry the low-count alert. **Ticket SLA**: an Age column,
`ticket.sla_days` (dated) drives an overdue mark + filter, and a "Bill" action opens New
Document with the client pre-selected (light ticketinvoice; `/documents/new?client=`).
## D29 — Console visual refresh: cockpit dashboard + palettes + Geist (2026-07-19)
The Dashboard is reworked into a **cockpit**: a flat "Today" hero (date, greeting, the headline
overdue figure, EveryoneMine scope, quick actions), the four KPI stat cards, the reminder queue,
then a **bento grid** of the day's sections (overdue, due-this-week, renewals, follow-ups, recent
payments). Low-colour on purpose ink + neutrals + one accent, red only for real failures
(`.dash-*` in `apps/hq-web/src/app.css`, wired to the existing `/dashboard` payload; honest data
only, no invented figures).
**Theme gains a third axis — palette (the neutral ground).** Alongside mode (light/dark) and the
seven accents, `data-palette` selects the greys: **Warm** (the default, unchanged), **Slate**
(cool), **Graphite** (dark-first), **Zinc** (near-mono). Backward-compatible and config-driven
(`packages/ui` `theme.ts` + `tokens.css`; `warm` needs no attribute), picked in Settings
Appearance beside accent + mode. The UI font is swapped **Inter → Geist Variable + Geist Mono**
(bundled offline via `@fontsource-variable/geist`). Class names stay stable restyling stays in
one place.
## D30 — Home hub landing + sidebar reorg + section counts (2026-07-19)
A **Home hub** card launcher becomes the landing (`/`); the Dashboard moves to `/dashboard`. The
hub surfaces only the day-to-day sections (Dashboard, Clients, Documents, Reminders, Renewals,
Pipeline, Tickets, SMS credits, Modules) each a card with a one-line purpose and a live count
everything else stays in the sidebar. The **sidebar is reorganized** from one overloaded "Work"
group into six intent groups (Overview · Work · Clients & billing · Catalog · Insight · Admin)
with clearer names (MIS cockpit, SMS credits, APEX import); "New document" is an action, not a nav
row. **Section count badges** neutral by default, amber for SMS-low, red for failed sends sit
on Reminders / Pipeline / Tickets / Renewals / SMS / Clients / Documents-drafts / Modules, fetched
best-effort (a slow/failed one never blocks the shell). The Modules page gains a per-module
**client count** column.
## D31 — Credentials stored in the clear (owner decision, 2026-07-19)
Founder call: "no encryption for anything those credentials will be used regularly." The
held-out `HQ_SECRET_KEY` was a daily friction point (the SMS-gateway balance pull needs to
decrypt 78 logins every day), so credential **encryption at rest is removed**. `crypto.ts`
`encrypt()` now stores tagged plaintext (`plain:…`); `decrypt()` untags it with no key, and
still reads legacy AES-256-GCM payloads *with* the key purely to migrate them. The six
"refuses/cannot without HQ_SECRET_KEY" guards (module password, module secret, client DB
password + their reveals, the SMS refresh) are gone set/reveal/use all work keyless.
**Scope:** this is the reversible credential vault only module portal/gateway passwords,
module secret fields, client DB passwords, the Gmail refresh token. **Login passwords stay
one-way scrypt-hashed** (`@sims/auth`) those never need to be read back, so nothing there
changes. Secrets are still kept out of list payloads and audit rows (only `{set: true}` /
the key name is audited, never the value), and reveal is still managerial + audited.
**Migration:** `migrateLegacyCiphertext(db, keyHex)` (POST /admin/migrate-plaintext, owner)
rewrites every existing AES row as plaintext using the key ONE final time idempotent, one
audit summary. After a clean run the key is never needed again. Existing encrypted rows
cannot be read without the key (or a fresh plaintext source), so the key is required exactly
once. **SECURITY:** a DB dump/backup now exposes these logins in the clear protect the DB
file and its backups (this raises the priority of the pending DB-secret + backup ops slice).
## D32 — Remove the key concept entirely (2026-07-19)
The key was never in hand, so the D31 "unlock with the key once" path was impossible. Founder:
"remove the key concept maybe in future we'll add it." `crypto.encrypt/decrypt` are now fully
keyless (the `keyHex` params remain, ignored, so a key can be wired back later); `decrypt()`
returns '' for any leftover legacy AES value instead of throwing (unreadable treated as unset,
never fed back as ciphertext). The key-based `/admin/migrate-plaintext` is replaced by keyless
**`/admin/clear-legacy-credentials`** (`clearLegacyCiphertext`) which NULLs every unreadable
pre-D31 AES value (module passwords/secrets, client DB passwords) and drops the dead Gmail
account row, so each affected login reads as "not set" and is ready for fresh plaintext.
**Consequence:** the pre-D31 encrypted logins (incl. the 76 imported SMS passwords) are gone
they must be re-populated in plaintext (APEX re-import, now keyless, or re-entry). HQ_SECRET_KEY
is no longer used anywhere.
**Live balance fetch** (founder: "configure the BalAlert API so we see the current count,
daily auto + low alert"): the provider panel (`sms.balance_api_url`, default
`http://164.52.195.161/API/BalAlert.aspx`) returns pipe-delimited `code|STATUS|payload`;
`parseBalanceResponse` reads the trailing number, flags ERROR lines, never guesses. Each SMS
subscription's already-imported `username`/`password_enc` are used the automated path
decrypts directly (no reveal-audit spam) and audits `sms_balance` **only when it changes**;
per-poll telemetry (ok/error + time) lives in the unaudited `sms_balance_check` table
(operational log, not domain data). A managerial "Refresh from gateway" button polls all;
the scheduler runs one pass per calendar day (`maybeRefreshSmsDaily`, claims the day via a
setting). **Requires `HQ_SECRET_KEY`** set to the import-time key to decrypt without it the
refresh no-ops with a clear message, it never stores anything unencrypted.
(Superseded by D31/D32: the key was removed; the pull now runs keyless.)
## D33 — Database backups (ops, 2026-07-19)
`apps/hq/scripts/backup.ts` `apps/hq/src/backup.ts` (`npm run backup`) `pg_dump`s the
database to a timestamped, compressed,
`pg_restore`-able file under `./backups` (gitignored), keeping the newest `HQ_BACKUP_KEEP`
(default 14). It finds `pg_dump` on PATH or in the Windows `C:\Program Files\PostgreSQL\*\bin`
install. Restore: `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" <dump>`.
Priority rose with D32 dumps now contain **plaintext credentials**, so the `backups/` dir
must stay access-controlled and off shared drives. Still pending in the ops slice: schedule
it (Task Scheduler / cron), off-box copy, and getting the DB password out of `server.ts`.
It resolves its target through the SAME shared resolver as the server and the maintenance
scripts (`resolveDbUrl`, D19) and logs the resolved host/db before dumping the original
`scripts/backup.mjs` read `DATABASE_URL` straight off the environment and so could never
reach production, where that variable is not exported.
**Working invocation (final review FIX 3):** `resolveDbUrl` only returns the hardcoded
production connection when `NODE_ENV=production` and an operator's own interactive shell on
the prod box does NOT have that set (only the deployed service's env does), so a bare
`npm run backup` typed by hand still resolves nothing and refuses. Run the backup with ONE of:
- `NODE_ENV=production npm run backup` matches how the deployed service resolves it, or
- `DATABASE_URL=postgres://… npm run backup` explicit target, overrides resolution.
The refusal error itself now names both of these exact commands, so an operator who hits it
can act without reading source. This does not change `resolveDbUrl`'s production semantics and
does not embed the connection string anywhere new.
## D34 — Employee master import + invited/first-login flag (2026-07-19)
Imported the 11-row employee master (`empmaster.xlsx`) into `staff_user` via
`scripts/import-employees.ts`: username = first-name slug, default password `<username>@123`
(founder's explicit choice), role `staff`, active per `ACTIVE_STAT`. New `must_change_password`
column drives an "Invited · must change pw" badge on the Employees page and sends the user to
Profile to change it on first sign-in; the flag clears on that first self-change. A background
security review flagged the predictable default password kept per the founder's explicit
instruction, with the invited flag + change-nudge as the mitigation (full first-login
enforcement offered, not yet enabled).
## D35 — Client type (PACS/Store) + directory-sourced LTD numbers (2026-07-19)
`client.client_type` = **PACS** (co-op society) vs **Store** (retail), backfilled by name
(ends in STORE Store) and filterable on the Clients screen. **Data-correction session**
(founder-approved, review-sheet-first): 187 client names cleaned mechanically (co-op style
`CO-OPERATIVE`, run-on splits, `ARP00KARA`→`ARPOOKARA`, spacing), applied after a backup and
audited. **LTD/registration numbers** looked up from the **Kerala govt co-op directory** (the
source of truth) via a multi-agent web-lookup workflow 259 PACS across two runs (the
first-wave batches needed a smaller-batch re-run to clear a transient safety-classifier block);
**141 now carry their LTD number in the name** (` LTD NO. <n>`, guarded against duplicates).
The ~26 real banks not found online + non-bank societies were left untouched (no guessing).
## D36 — Login history (IP + device) + first-login nudge (2026-07-19)
Every `/auth/login` attempt is recorded in a `login_event` table (staff_id or null, username
tried, outcome success/failed/locked, IP, user-agent) — security telemetry, separate from the
domain audit, best-effort so it never breaks login. Owner-only **Login history** page (Admin):
who signed in / failed, when, from which IP + parsed device, outcome filter, paginated.
`login()` now returns `mustChangePassword` (D34) so an invited user lands on Profile to change
their default password.
## D37 — Society category (Kerala co-op directory taxonomy) (2026-07-20)
`client.category` classifies each client the way the official Kerala co-op directory does:
**Service Bank · Urban Bank · Vanitha · Housing · Employees · Agricultural · Marketing ·
Bank (other) · Society (other) · Store · Other**. Derived from the name (most-specific-first;
a "VANITHA … SOCIETY" is Vanitha, not the generic Society), backfilled by a portable CASE and
applied to new/edited clients via `categoryFromName`. Clients screen gains a Category filter +
column. Live backfill: 135 Service Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural,
11 Urban, …
## D38 — Client Detail redesign: composed onboarding pipeline, status line & payments context (2026-07-20)
Reworks the Client 360 Modules + Payments tabs around how the business actually runs — a module is
the *end* of a sales process, not a one-click assignment.
**Composed onboarding templates (config over code).** The step list is resolved as a **shared lead
front + a per-module tail**: `project.milestone_template.front` (Enquiry received · Visit / meeting
scheduled · Quotation sent · Quotation approved) applies to **every** module, and
`project.milestone_template:<CODE>` supplies that module's delivery steps (SMS: Document collection ·
DLT registration · Account creation · Installation · Testing · Training · Go-live · Payment received;
RTGS and Mobile App have their own). Edit the front once and every module follows. Both are
owner-editable in a new **Modules → Onboarding steps** editor. A one-time, idempotent migration
(`scripts/apply-onboarding-pipeline.ts`) moves existing projects onto the composed list, carrying
mapped ticks.
**The status line drives the record.** The checklist became a **horizontal status line** whose ticks
**auto-advance `client_module.status`** (quotation approved→ordered, installation→installed,
training→trained, go-live→live; never downgrades), so the status dropdown stops being hand-set. The
**Payment received** step links to a real payment (`project_milestone.payment_id`, step date mirrors
`received_on`). The **Account creation** step prompts for the module credentials — and credentials
are **no longer required when adding a module** (they don't exist yet at that point); they stay
editable anytime via the module's Access panel.
**Module + payments surfaces.** Details render as a read-only **50/50 sheet** (Access | Details) with
one Edit per group — no always-on input boxes. Each module block lists **its own documents**
(line-item match) labelled Quote / Renewal / **Bulk top-up** / Invoice; SMS can raise a
**bulk-credit purchase** proforma (`source='bulk_sms_topup'`) distinct from a subscription renewal
(`source='module_renewal'`). The Payments tab gains an **Outstanding** panel — issued invoices with a
balance plus **sent/accepted** proformas (un-issued drafts excluded, owner decision) — and a
*Settled → document* column per payment. The money engine is untouched (read-only ledger extension).
Also: a **stalled-onboarding** query for the dashboard/MIS, and a mobile fix so the record header
stacks instead of overlapping the client name.
Spec + plans under `docs/superpowers/`.
## D39 — Go-live: hq.simssoftware.in ✅ DELIVERED (2026-07-20/21)
HQ is live in production at **`https://hq.simssoftware.in`**, on the existing company EC2 box,
superseding the RDS/GoDaddy/`.com` plan in `docs/DEPLOY-HQ.md` (that doc predates the actual
setup — `deploy/PATHS-AND-NAMES.md` is the accurate reference; DEPLOY-HQ.md is marked superseded).
**Deployed as built, three deviations from the original runbook:**
- **Docker Compose V2 isn't installed** on the box (only the old standalone `docker-compose`
1.21.0, too old for this file's `extra_hosts: host-gateway`). Deployed via plain
`docker build` + `docker run --add-host host.docker.internal:host-gateway ...` instead —
the exact equivalent of `docker compose up -d --build`. `deploy/PATHS-AND-NAMES.md`
documents both forms; installing the Compose v2 plugin is a later nice-to-have, not a blocker.
- **`Dockerfile` drops `package-lock.json` before `npm install`** (new step). A Windows-generated
lockfile hit npm/cli#4828 — `@rollup/rollup-linux-x64-gnu` failed to resolve during the
container build even on a clean install. Removing the lockfile forces same-platform resolution.
- **Real data was carried over, not "born empty + APEX import."** The working dataset turned out
to live in a local Postgres instance (273 clients, 14 staff, 156 documents, 1826 tickets, etc.),
not the local SQLite file assumed by D19/D20 — SQLite only had 2 leftover test accounts. A
Postgres→Postgres copy (`apps/hq/scripts/migrate-pg-to-pg.ts`) wiped the target `hq` database's
schema, rebuilt it via the app's own migrations, then copied every table across in one
transaction (FK checks disabled for the load via `session_replication_role=replica`). The
APEX CSV importer is no longer needed for the initial 300 clients — they're already in.
(`apps/hq/scripts/migrate-sqlite-to-pg.ts` was the first attempt, against the wrong source;
left in the repo, harmless — it refuses to run against a non-empty target.)
**Known accepted debt, deferred on purpose (owner decision):**
- `HARDCODED_DATABASE_URL` (`db-pg.ts`) carries the real Postgres password in git history —
Gitea is internal/single-user, so this is accepted for now; rotating it is the top item
whenever the hardening pass happens.
- Postgres port 5432 is open to the public internet (`0.0.0.0`), not restricted to the EC2
security group — same deferred bucket.
- `SIMS_PIN_PEPPER` is still unset (login hashes un-peppered) — same bucket.
- Shared `postgres` superuser role, not a dedicated least-privilege `hq` role — same bucket.
**Still open, not yet done:** `share.base_url` setting, and a backup cron for the shared
Postgres (`pg_dump` — no RDS automated snapshots here).