# PROJECT_STATE

Read this first. Then inspect the specific files mentioned before touching them.

## What this is

CA Automator: a mature, working Python (stdlib WSGI, no framework) + MySQL/SQLite
practice-management app for a Nepali chartered accountant, with a vanilla-JS
offline-first PWA frontend. **Not** Laravel/PHP — that was the original build-prompt's
assumption; corrected after inspection (see git history, first commits).

Currently being generalized from a Nepal-only tool into a **country-aware ERP
platform** (Nepal first, India/UAE next), per user instruction: extend the existing
Python app in place, database strictly MySQL going forward. No rewrite.

**This installation's database of record is MySQL** (`ca_automator` on `localhost`,
cutover executed 2026-08-31, see `docs/mysql-migration.md`). `data/.env` sets
`CA_DB_ENGINE=mysql`; `start.sh` sources it, so the real app reads MySQL by default
now. `db.py` still supports `CA_DB_ENGINE=sqlite` and the test suite runs against it
(fast, no external dependency) — that's test/dev-harness plumbing, not this
installation's real data, which lives in MySQL only.

**Remote**: `https://github.com/kreesa325737/ca-automator` (private), `main` tracked.
Push normally from here on — `git push`.

**Deployment to caautomator.com — prepared, blocked, not yet executed.** See
`docs/cpanel-deployment.md` for the full procedure: cPanel/Passenger on the same
shared hosting account (`kreesa` on `kush.mysecurecloudserver.com`) already running
`billing.kreesa.com` and the `kreesa.com` demand platform. Blocked on an account-wide
httpd vhost issue only the host (Nest Nepal) can fix — nothing on that account
serves over HTTP at all right now, for any site. `run.py cron-tick` (a bounded
one-shot substitute for the old always-running `run.py worker`, needed because
Passenger has no persistent-process option) and a real MySQL-mode `cmd_backup()`
(the old one was SQLite-only and had been silently failing every cycle since the
2026-08-31 cutover — a full week with zero automated backups until this was
caught and fixed) are both done and tested. Decision already made: **migrate**
the real local data to the new host, not a fresh install — once that migration
completes, the Mac's local instance stops being the copy of record (see that
doc's own final section for why two independently-writable copies would diverge).

Also carries a Phase 1-3 ERP build (org structure, employees, task/workflow/
resource engines — see "Phase 1-3: org/people/task/workflow/resource engine"
below) done against the *original* master-prompt's module checklist, once
inspection showed the pre-existing app already covered most of the rest of it
(clients, engagements, timesheets, billing, audit, documents...).

## Architecture (unchanged facts, don't re-derive)

- `server/app.py` (11.3k lines) — all routes, WSGI app.
- `server/engines.py` (5.7k) + `server/{acct,audit_execution,business,corporate,
  forensic,ipo,tax_advisory,tp}_engines.py` — calculation engines, mirrored
  line-for-line in `web/*.js` for offline parity. Keep both sides in sync when
  editing an engine.
- `server/db.py` — data layer. Already dual-engine (`CA_DB_ENGINE=sqlite|mysql`,
  env vars `CA_MYSQL_HOST/PORT/USER/PASSWORD/DATABASE`). New columns go through
  `_NEW_COLUMNS` (dict keyed by table), applied idempotently by `init_db()` on
  both engines via `_migrate_columns()` — **never** hand-edit `schema.sql` /
  `schema_mysql.sql` for a column added after initial release; they're frozen
  historical snapshots, `_NEW_COLUMNS` is the source of truth for anything after.
- `server/reports.py` — Excel (openpyxl) + Word (python-docx) builders.
- 152-table schema. `tp_country` (already seeded with every ISO-3166 country,
  NP/IN/AE carrying real regulatory content) + `tp_regulatory_rule` (versioned,
  effective-dated, per-country rules) is a fully-built Jurisdiction Configuration
  Engine — built for Transfer Pricing, but structurally the pattern the Country
  Engine generalizes. Reuse it; don't build a parallel country table.
- No ORM, no tests-by-default coverage of `app.py` routes — 262 tests exist,
  concentrated on engines/migrations/auth-adjacent modules (`server/tests/`).

## Completed

- **Git repo initialized** (`git init`, baseline commit `30060de`). `.gitignore`
  excludes `venv/`, `data/*.db*`, `data/.env`, `data/cred_enc.key`, logs. This repo
  had no version control before — always work from commits now, the baseline
  commit is the recovery point if anything here needs undoing.
- **Country Engine, first slice**:
  - `rate_master.country_code` and `firm.country_code` columns added via
    `_NEW_COLUMNS` in `server/db.py`, default `'NP'`. Backward-compatible: every
    existing row backfills to `'NP'`, so nothing already using `db.rates()`/
    `db.rate_dict()` changes behavior.
  - `db.rates()` / `db.rate_dict()` gained an optional `country_code="NP"` kwarg
    that filters `rate_master` by country. Old call sites (36 across
    engines.py/app.py/site_chat.py/ca_assistant.py/tax_advisory_engines.py)
    untouched, still get Nepal data.
  - New `server/country_service.py` — the intended single entry point going
    forward: `firm_country_code(firm_id)`, `country(code)`, `active_countries()`
    (currently NP/IN/AE — the three with real `tp_regulatory_rule` content),
    `rates(firm_id, ...)`, `rate_dict(firm_id, ...)`. Engines/routes should call
    through this, not `db.rates()` directly, once they're made country-aware.
  - Tests: `server/tests/test_country_service.py` (7 tests, all passing) +
    existing `test_db_migrations.py` (5) still passing. Full suite: 262 → now
    269 passing after these additions.
- **MySQL cutover rehearsed** end-to-end against a *copy* of the real database
  (never the live file) — schema init, full data migration (1,724 rows / 152
  tables), row-count verification all clean. Real cutover steps documented in
  `docs/mysql-migration.md`. **Not yet executed for real** — that's a deliberate
  maintenance-window operation, not done silently.
- **Six core engines (and every other rate_master-driven engine) are now
  country-aware**, via a request-scoped context rather than a new parameter on
  every engine function:
  - `db.py` gained a `contextvars`-based "current country" (`_current_country`,
    default `'NP'`), read by `rates()`/`rate_dict()` only when `country_code`
    isn't passed explicitly (their default changed from the literal `"NP"` to
    `None` → falls through to the contextvar). This is what actually makes
    `engines.py`'s ~15 `rate_dict(domain, fy, applies_to)` call sites — which
    take no country parameter of their own and were not touched — resolve
    against a non-Nepal country when asked. Covers *every* rate_master-driven
    engine (depreciation/salary_tds/business_tax/penalty/loan/lease, but also
    `dd_workbench`, `tds_deduction`, `vat_return` etc.), not just the six core
    ones — same mechanism, no extra work.
  - `country_service.use_country(code)` / `use_firm_country(firm_id)` — context
    managers that set/reset that contextvar. `app.py`'s four `engines.compute(...)`
    call sites (`/api/compute`, `/api/reports`, `client_directory`,
    `linked_workfiles`) now wrap the call in `with country_service.use_firm_country(u["firm_id"]):`.
  - The job-queue "report" path in `run.py`'s `run_jobs()` (queued/background
    report generation) does **not** yet wrap its `engines.compute(...)` call —
    its payload has no `firm_id` today. Still defaults correctly to `'NP'`, no
    regression, just not yet country-routed. Left for later.
  - Tests: `server/tests/test_country_service.py`, `UseCountryContextTests` (6
    tests) — proves the context changes unparameterized lookups, restores on
    both normal exit and exception, and that an explicit `country_code` still
    overrides it. Full suite: 275 passing.
- **`nepal_calendar` display output is now gated to Nepal firms**:
  - `engines._bs_label(ad_date)` — returns the Bikram Sambat label only when
    `country_service.current_country_code() == "NP"`, else `None`. Replaces
    the three unconditional `nepal_calendar.bs_approx(d)["label"]` call sites
    in `engines.py` (`_attach_dates`, `loan_schedule`, `lease_schedule`) — a
    non-Nepal firm's loan/lease schedule rows no longer carry a BS date that
    has nothing to do with its own calendar.
  - `db.get_current_country()` / `country_service.current_country_code()` —
    read-only peek at the same contextvar `use_country`/`use_firm_country`
    set, added alongside `_bs_label` since gating needed a getter, not just
    the existing set/reset.
  - **Deliberately NOT touched**: the `fiscal_year` table itself is
    structurally Nepal-BS-native (`label_bs`/`start_bs`/`end_bs` are all
    `NOT NULL`, no `country_code`), and `nepal_calendar.fy_label_for_ad()`'s
    own un-configured-date fallback derives a Bikram Sambat label. A firm
    with no `fiscal_year` row covering a given date — which today means
    every non-Nepal firm, since nothing populates country-appropriate rows
    yet — still gets a fabricated BS-style fiscal-year label internally
    (`engines.py`'s `_group_journals_by_fy`, used by `grant_accounting`).
    Fixing this properly means a real country-neutral Financial Period
    engine (the original master prompt's item #7), not a small gating
    change — left as its own future task, not silently folded into this one.
  - The two explicit `/api/calendar/bs-from-ad` / `/api/calendar/ad-from-bs`
    conversion endpoints in `app.py` are untouched — they're opt-in
    utilities a date-picker widget calls deliberately, not auto-injected
    into engine output, so there's nothing to gate.
  - Tests: `NepalCalendarGatingTests` (3 tests) — NP gets a label, IN gets
    `None`, and the no-context-at-all case (every pre-existing caller)
    behaves exactly as before. Full suite: 278 passing.
- **India (`IN`) rate_master content seeded**, in `seed.sql`, right after the
  Nepal `tds` block. 22 rows, all `country_code='IN'`, `verification_status=
  'UNVERIFIED'` (same convention as every Nepal row), `fiscal_year_label=NULL`
  (a standing rule — sidesteps the Financial Period gap above since it
  matches any `fy` value passed in). Verified end-to-end, not just present in
  the table: `personal_tax('individual', 1200000)` under `use_country('IN')`
  correctly computes ₹80,000 tax through the real new-regime slab structure
  (0%/5%/10%/15% bands), and `salary_tds` correctly resolves EPF through the
  *existing* `pf_employer`/`pf_employee` fallback path (Nepal's own non-SSF
  branch) — no new engine code needed for that part.
  - **Seeded**: `income_tax` individual slabs (new/default regime, Sec
    115BAC(1A)), `income_tax` company rates (`company_rate_domestic_standard/
    _concessional/_new_manufacturing/_foreign`, discovered dynamically by the
    existing `company_rate_` prefix mechanism — no engine change), `loss_
    carry_forward_years` (8y, Sec 72), `health_insurance_cap` (Sec 80D,
    non-senior only), `payroll.pf_employee/pf_employer` (EPF 12%/12%), `tds`
    category rates (`tds_rate_contract/consultancy/commission/interest/
    dividend/rent/royalty/nonresident` — India's TDS system is structurally
    the same category→rate shape as this domain already, unlike depreciation/
    VAT/penalty below). `tds_rate_nonresident.value_num` is deliberately
    `NULL` (`'SOURCE REQUIRED'`-style, matching the existing convention
    already used for the same non-resident-payment uncertainty in the
    Transfer Pricing module's `tp_regulatory_rule` rows) — no single rate
    exists without knowing the payment type and any DTAA position.
  - **Deliberately NOT seeded** (full reasoning is a comment block at the top
    of the India section in `seed.sql`) — these need real *engine-shape*
    work, not just more rows, because India's actual mechanics don't fit the
    domain's current shape:
    - `depreciation` — Nepal's Schedule 2 pools are absorption-tranche-dated
      by BS month; India's Sec 32 block-of-assets system is a different
      mechanic, not just different rates.
    - `vat` — India's GST is multi-slab (0/5/12/18/28% + CGST/SGST/IGST
      split); this domain only has one `standard_rate` field.
    - `penalty` — Nepal's codes are literally section-numbered
      (`s117_.../s118_.../s119_.../s120_...`) to Income Tax Act 2058
      provisions whose Indian analogues (234A/B/C/F, 270A) don't share the
      same amount/percentage/trigger shape.
    - Nepal-specific reliefs with no Indian equivalent at all
      (`remote_area_cap*`, `foreign_allowance_exempt_pct`,
      `ssf_rebate_slab1`, `female_rebate`, `senior_citizen_slab1_extra_pct`,
      `retirement_exempt_*`, `medical_credit_*`).
    - `donation_cap_pct`/`donation_cap_amount` (India's 80G is tiered
      50%/100%-with/without-qualifying-limit by donee category — a single
      cap number would misrepresent how the relief works) and
      `retirement_deduction_cap` for 80C (a shared multi-instrument basket
      cap, not a retirement-contribution-specific one).
    - No `applies_to='couple'` rows at all for India — India has no joint/
      couple filing status, unlike Nepal; this is a structural fact, not a
      gap. (`IndiaSeedDataTests.test_no_couple_status_seeded_for_india`
      guards this.)
  - Tests: `IndiaSeedDataTests` (7 tests) in `test_country_service.py` —
    correct slab tax computed via the real engine, no couple rows, company
    categories discovered dynamically, EPF resolves via the PF fallback,
    TDS categories present, the non-resident rate is deliberately unset
    (not guessed), every IN row is UNVERIFIED, and depreciation/vat/penalty
    have zero IN rows (so that gap is caught by CI, not rediscovered by
    surprise later). Full suite: 286 passing.

- **`firm.country_code` is now settable from the admin UI**:
  - New `GET /api/admin/active-countries` (gated behind `site_settings`,
    same as the rest of firm-profile) — returns `country_service.
    active_countries()` (NP/IN/AE), not the full ~195-row `tp_country`
    reference list (that one's for a related party's *location*, not a
    firm's own jurisdiction — see `tp_country`'s seed comment).
  - `PUT /api/admin/firm-profile` now accepts `country_code`, validated
    against that same active-countries set (400 on anything else). Since
    the column is `NOT NULL`, an omitted/blank value keeps the firm's
    current country rather than failing the UPDATE — matters for an older
    cached frontend or a direct API call made before this field existed.
  - `web/app.js`'s `drawFirmProfile()` — a new "Country / jurisdiction"
    `<select>` next to the existing firm-profile fields, populated from
    `/api/admin/active-countries`. Reuses the existing generic `field(...,
    'select', {options})` helper and the existing generic save handler (it
    already sweeps every `[data-key]` element) — no new frontend plumbing.
  - Verified live end-to-end (real dev server, real HTTP calls, not just
    read): logged in, fetched `/api/admin/active-countries`, set the firm
    to `IN` and confirmed it stuck via `/api/bootstrap`, confirmed an
    invalid code 400s with the exact valid-set message, confirmed omitting
    `country_code` entirely preserves the existing value rather than
    nulling it out, reset back to `NP`.
  - No new automated test — matches this codebase's existing convention of
    zero unit tests directly against `app.py` routes (see "Known
    constraints" below); manual/live verification was the bar every other
    route change here already meets.

## Phase 1-3: org/people/task/workflow/resource engine (2026-08-31)

Built against a point-by-point gap check of the original master prompt's
Phase 1 (foundation) / Phase 2 (client+work engine) / Phase 3 (resource
management) module lists, checked directly against the schema/code rather
than assumed. What the pre-existing app already had (clients, engagements,
timesheets, invoicing, audit log, module-level permissions) was left alone;
this section is only what was genuinely missing.

**Schema** (`schema.sql` + `schema_mysql.sql`, both hand-kept in sync — see
"Known constraints"; applied to the real live MySQL database, not just
SQLite): `org_branch`/`org_department`/`org_team`/`org_team_member` (named
`org_*`, not `branch`/`team`, specifically to avoid colliding with the
*existing* `team_member` table, which is the public site's staff-bio roster
— a different concept entirely), `job_position` (per-firm configurable,
`hierarchy_level` not `rank` — `RANK()` is a reserved MySQL keyword — seeded
with a default Managing Partner→Intern hierarchy via `_seed_default_job_positions`,
seeded only into a firm with zero rows so a renamed/deleted builtin is never
silently re-created), `employee_profile` (1:1 with `app_user` via a surrogate
`id` + `UNIQUE user_id`, not `user_id` as the bare primary key — that would
have picked up `AUTO_INCREMENT` under the schema's own SQLite→MySQL
translation rule, a real bug caught before it reached real data), `client_contact`,
`task_dependency`, `task_checklist_item`, `engagement_member` (additive
alongside the existing `engagement.partner_id`/`manager_id`, not a
replacement), `calendar_event` (internal, distinct from the existing public
`appointment_slot`/`appointment_booking`), `workflow_rule`, `skill`,
`employee_skill`. `task` gained `parent_task_id`/`reviewer_id`/
`estimated_hours`/`overdue_notified_at`; `time_entry` gained a nullable
`task_id` (actual hours per task = `SUM(time_entry.hours)`, computed, never
stored — same convention as the client-directory roll-up).

Three real MySQL-specific bugs were caught by testing against an actual
MySQL database *before* touching real data, not assumed away: `rank` and
`position` are reserved words (worked around by naming, not quoting — see
above and `job_position` vs. the generic term "position"), a `TEXT` column
used in any index needs an explicit key length (fixed by using `VARCHAR`
for the handful of columns that are genuinely short — position/skill names,
`trigger_event`, `start_at`), and MySQL cannot `DEFAULT` a `TEXT` column at
all (worked around by leaving `action_json` nullable and defaulting it in
Python instead of SQL).

**`server/workflow_engine.py`** — EVENT → CONDITION → ACTION, deliberately
closed rather than a speculative generic engine: `TRIGGER_EVENTS` = 
`engagement.created`, `task.completed`, `task.overdue`, `deadline.approaching`
— every one of these has a real `fire_event(...)` call site (`create_engagement`,
`update_task`, `notify.sweep_overdue_tasks()`); `ACTION_TYPES` = `create_task`,
`notify_user`, `notify_role`. `condition_json` is plain `{field: value}`
equality against the event's context dict, nothing fancier on purpose.
Verified live: a rule wired to `task.completed` → `notify_role: partner`
actually delivered a notification when a real task was marked done through
the real API.

**`server/resource_engine.py`** — `workload_report(firm_id)` (capacity vs.
`engagement_member.allocated_hours` vs. actual `time_entry` hours logged in
the last 7 days, all computed on the fly) and `suggest_candidates(firm_id,
job_position_id, skill_id)` (a legible, additive score: skill match +
position match + availability headroom — never writes an assignment, matches
the original design note "the manager must retain control... never make
critical professional assignments invisibly"). The 7-day window boundary is
computed in Python and bound as a parameter, not SQLite's
`date('now', '-7 days')` inline — that modifier-argument form isn't one of
the two literal strings `db._translate_sql()` rewrites for MySQL, so it would
have reached MySQL unmodified and failed; caught by testing against real
MySQL, not assumed portable.

**~35 new routes in `app.py`**: org structure + position CRUD, employee
profile upsert (`PUT /api/admin/employees/<user_id>`, one route for both
create and update since the relationship is 1:1), client contacts, full task
CRUD — **the `task` table had zero API routes at all before this**, not
partial coverage — with checklist/dependencies/subtasks, engagement team
roster, internal calendar, workflow rule CRUD (validated against the
engine's own `TRIGGER_EVENTS`/`ACTION_TYPES`), skills, resource
workload/suggest. New `org_admin` module_key (added to `ALL_GRANTABLE_MODULE_KEYS`
in `db.py` *and* `GRANTABLE_MODULES` in `app.js` — both must stay in sync,
see the comment at each) gates the admin-configuration surface (org
structure/positions/employees/skills/workflow rules); tasks/contacts/
engagement-members/calendar are deliberately ungated, matching the existing
`client_followup`/`client_credential` precedent (ordinary work-management
actions any staff member with client access already has). Since `org_admin`
is a module introduced *after* the one-time `_grandfather_firm_module_grants`
transition, a new `_grant_new_module_to_every_firm(c, "org_admin")` migration
explicitly grants it to every existing firm — without this it would 403 for
everyone but a platform admin.

**Frontend** (`web/app.js`): three new pages — `renderOrgAdmin()` (branches/
departments/teams/positions/employees/skills/workflow-rules/workload, one
page with independently-loading card sections, same shape as the existing
Site Settings page), `renderTasks()`, `renderCalendarPage()` — wired into the
router and the nav rail (`org-admin` gated by `isModuleGranted('org_admin')`;
`tasks`/`calendar` ungated, same reasoning as their API routes). Reuses the
existing `field()`/`toast()`/`api()` helpers and card/`<details>` UI
conventions throughout — no new frontend machinery introduced.

**Verification**: 301 tests passing (15 new — `test_workflow_engine.py`,
`test_resource_engine.py`; no route-level tests, matching this codebase's
existing convention). Every new backend route exercised live against a real
dev server (org structure, positions, employees including the
can't-report-to-yourself guard, contacts, the full task engine including
subtasks/checklist/dependencies/actual-hours roll-up, engagement members,
calendar, workflow rules including an actual live fire, skills, resource
workload/suggest, the overdue-task sweep including same-day idempotency) —
transcripts of these live checks are in this session's history, not
re-derived here. **The frontend UI was NOT visually verified in a browser**
— no browser-automation tool was available this session. What *was* done
instead: `node --check` for syntax validity, the server confirmed serving
the updated file correctly, and a manual review pass that caught and fixed
one real bug (an unscoped `document.querySelector` in the workflow-rule form
that happened to work today only because no other field on that page shares
its key name — fixed to be properly scoped like every other form on the
page). Treat the new pages as backend-verified and syntax-valid, not as
"confirmed working end-to-end in a browser" — that step is still owed.

## Follow-up: actual browser verification (same day)

The "not visually verified" gap above got closed: installed Playwright +
Chromium into the session scratchpad (not added to this repo — no
`package.json`/JS test framework exists here, and this was a one-off
verification tool, not new project infrastructure), started a real dev
server against scratch data, and drove every new page through an actual
browser — login, then each Organization & People section (add a branch,
department, team, skill, workflow rule; verify the 9 seeded positions and
both employees list), Tasks (create, add a checklist item, mark done and
confirm the strike-through persists), Calendar (create an event), and the
nav rail itself.

This caught two real bugs that no amount of code review or API-level
testing had surfaced, both now fixed:

1. **A task created with no `client_id`/`engagement_id` was permanently
   invisible.** The Tasks page's own "New task" form doesn't collect a
   client at all, so *every* task created through it hit this — the API
   call succeeded (toast said "Task added"), but `GET /api/tasks`'s `WHERE`
   clause had no fallback for a task tied to neither, so it silently never
   appeared in any list. Fixed two ways together: `create_task` now
   defaults `assignee_id` to the creator when not supplied (same convention
   `create_engagement` already uses for `partner_id`), and `list_tasks`'s
   `WHERE` gained a third `OR` branch matching on the assignee's firm. A
   task now always has at least one column tying it to a firm.
2. **Stale dropdowns**: adding a branch didn't refresh the department
   form's branch picker (same for department→team and position→employee,
   though the position case was already handled). Fixed by having each
   add/delete handler redraw every section whose dropdown depends on that
   cache (`drawBranches()` now also redraws `drawDepartments()` and
   `drawEmployees()`; `drawDepartments()` also redraws `drawTeams()` and
   `drawEmployees()`).

Re-ran the full click-through after both fixes: **19/19 checks passed, zero
JS console errors**. Screenshots confirm the visual output is clean and
consistent with the rest of the app (same header, same card style). No
regression in the existing 301 automated tests. This is the strongest
verification bar anything in this session met — actual browser interaction,
not just API calls or static review — but it was still only the specific
flows listed above, not exhaustive; things like task dependencies, subtasks,
the resource-suggest endpoint's UI (there isn't one yet — only the API and
`workload_report`'s table are wired to a page), and editing an existing
employee's profile through the form were not clicked through.

## Financial Period Engine (2026-08-31)

Closed the gap flagged above. **Deliberately did not touch `fiscal_year`
itself** — it's genuinely Nepal-native (Bikram Sambat is the real calendar a
Nepali fiscal year is defined against) and already carries live production
data; relaxing its `NOT NULL` BS columns would have meant either a risky
constraint-relaxation migration on a table already in use, or fabricating
meaningless BS dates for a non-Nepal row. Instead:

- New table `financial_period` (AD-only: `country_code`, `label`, `start_ad`,
  `end_ad`, `is_current`) — a country-neutral sibling, not a migration.
- New `server/fiscal_year_engine.py`: `generate_periods(country_code, ...)`
  (India: Apr 1→Mar 31, label `'2025-26'`; UAE: calendar year — a country
  with no known convention 400s rather than guessing one),
  `seed_default_periods()` (idempotent, `INSERT OR IGNORE`),
  `period_label_for_ad()` (falls back to a plain AD calendar year — **never**
  Bikram Sambat math, which was the actual bug), `current_period_label(firm_id)`
  (resolves the firm's own country; a Nepal firm gets `db.current_fy_label()`
  completely unchanged — verified identical, not just "should be the same").
- **Correction to the earlier note above**: the real gap wasn't in
  `grant_accounting` (that module uses its own separate `_fy_year`/`_fy_label`
  machinery, unrelated) — it was in `loan_schedule` and `lease_schedule`,
  both of which call `_group_journals_by_fy` with `fiscal_years` always
  queried from the Nepal-only `fiscal_year` table regardless of the
  requesting firm's country. Fixed via a new `_fiscal_years_for_grouping(dates)`
  helper in `engines.py` that branches on `country_service.current_country_code()`.
  Verified directly: the same `loan_schedule` call under `use_country('IN')`
  now groups journals as `['2025-26', '2026-27', '2027-28']`; under
  `use_country('NP')` it's unchanged (`['2082-83', '2083-84', '2084-85']`).
- 4 new admin routes (`GET/POST /api/admin/financial-periods`, `POST
  .../generate`, `DELETE .../<id>`) plus a new admin UI card
  (`drawFinancialPeriods()` in `web/app.js`, shown only for a non-NP firm) —
  verified live in a real browser (3/3 checks, zero console errors): the
  card renders for an India-scoped firm, "Generate" populates the table,
  and the current period is correctly flagged.
- Applied to the real live MySQL database; India and UAE periods
  (2024–2030) seeded into it. **Auto-seeding is deliberately not wired into
  `init_db()`** (unlike e.g. `_seed_default_job_positions`) — financial
  periods are only relevant to a firm actually operating in that country,
  unlike positions which every firm needs; seeding happens via the admin
  "Generate" button or a one-off script, not automatically for every
  install regardless of whether anyone uses India/UAE.
- 11 new tests (`test_fiscal_year_engine.py`), including two that exercise
  the real `loan_schedule` engine under both country contexts, not just the
  standalone module. 312 tests passing.

## India engine-shape work: depreciation, GST, interest/penalty (2026-08-31)

New `server/india_engines.py` — the three domains flagged since the earlier
India-seed pass as needing real engine mechanics, not just rate_master data:

- **`india_depreciation()`** — Sec 32 block-of-assets WDV. Genuinely
  simpler than Nepal's Schedule 2 (no absorption tranches, no repair-
  ceiling capitalisation): just the 180-day put-to-use test (full rate at
  180+ days, half rate below). A block reduced to nil/negative WDV by
  disposal proceeds becomes a short-term capital gain (Sec 50), not
  negative depreciation.
- **`india_gst_return()`** — multi-slab GST (0/5/12/18/28%, discovered
  dynamically via a `gst_rate_` prefix, same mechanism as
  `company_rate_`/`tds_rate_`), CGST+SGST for intra-state vs IGST for
  inter-state, input tax credit netting with the one cross-utilisation rule
  actually modelled (leftover IGST credit offsets CGST then SGST). Got its
  **own `gst` domain** rather than extending Nepal's `vat` (one
  `standard_rate` field — genuinely can't represent GST's shape).
- **`india_interest_penalty()`** — Sec 234A (late filing), 234B (advance
  tax shortfall), 234C (quarterly instalment shortfall, 4 checkpoints),
  234F (flat late-filing fee, tiered by income).

Wired identically to every other engine: `ENGINES` dict in `engines.py`
(`india_depreciation`/`india_gst`/`india_interest_penalty` module keys,
callable via the existing `POST /api/compute`), added to
`ALL_GRANTABLE_MODULE_KEYS` + granted to every existing firm (same
`_grant_new_module_to_every_firm` pattern as `org_admin`), added to
`web/app.js`'s `GRANTABLE_MODULES` (via a new `INDIA_ENGINE_MODULES` dict)
so an admin can turn on API access — but **no dedicated module-page UI
yet** (no `route`, so no nav link) — see "Not yet done" below.

**A real bug caught by the tests, not shipped**: the first draft of
`india_interest_penalty()`'s Sec 234C formula divided by 12 an extra time —
copied from `engines.penalty()`'s Sec 118 pattern without noticing that
Nepal's `s118_interest_rate` is an *annual* rate (hence that function's own
`/12`) while India's `s234_interest_rate_pct` was seeded as a *monthly*
rate. A hand-computed test (`15000 shortfall × 1% × 3 months = 450`, not
`37.5`) caught it immediately. Fixed; 19 tests now passing for this module,
several with hand-verified reference numbers (e.g. block depreciation with
mixed full/half-rate additions, GST net payable after ITC).

### A significant correction: India seed data was never actually live

While applying this round's new rate_master rows to the real production
database, discovered that **the earlier India rate_master seed content
(income_tax slabs, TDS rates, payroll) was never actually in the live
database at all**, despite being reported as applied earlier this session.
What actually happened: that content was added to `seed.sql` *before* the
real MySQL cutover, and was only ever verified against scratch/rehearsal
databases at the time. When the real cutover ran, `migrate_sqlite_to_mysql.py`
copied the real SQLite source's data — which never had India content in it
either, since India seeding was never applied to the real SQLite file — and
the subsequent `run.py init` against the now-populated (no longer "fresh")
MySQL database silently skipped `seed.sql` entirely (`init_db()` only runs
it `if fresh or force_seed`). Nobody's data was at risk at any point — the
gap was that new content silently never arrived, not that anything existing
was touched — but the earlier claim of "applied to the real live database"
for that content was simply wrong. Corrected now: see below.

### A real, separately-significant bug found while fixing the above

Getting the missing India content into the real database required
`db.init_db(force_seed=True)` (re-running `seed.sql` against a non-fresh
database). Rehearsing this first (as with every schema change this
session) surfaced a genuine bug: **`rate_master`'s original
`UNIQUE (domain, code, fiscal_year_label, applies_to)` never deduped a
standing rule (`fiscal_year_label IS NULL` — the convention every India row
uses) against itself**, because SQL treats every `NULL` as distinct from
every other `NULL` for uniqueness purposes — the exact same class of bug
already fixed once in this codebase for `tp_regulatory_rule` (see that
table's `ux_tp_rule_dedup`), just never applied to `rate_master`. Confirmed
live in a rehearsal database: `force_seed=True` doubled every India
row. The original `UNIQUE` constraint also never included `country_code` at
all — flagged as a real gap in this file even before this discovery, now
actually triggered.

**Fixed** with the same pattern already established for exactly this
problem: `db._fix_rate_master_dedup()` (mirroring `_dedupe_audit_criteria`,
right above it in `db.py`) deletes any pre-existing duplicates (keeping the
lowest id — rate_master rows are looked up by domain/code/country/fy/
applies_to, never cached by row id anywhere durable) and creates a
corrected expression index, `ux_rate_master_dedup`, using `COALESCE` on the
nullable columns plus `country_code` — same technique as
`ux_tp_rule_dedup`, engine-specific SQL for the MySQL functional-index
syntax (extra parens around the `COALESCE(...)` terms). Runs on every boot,
placed in `schema.py`/`schema_mysql.sql`'s comments (the actual index can't
live in the `CREATE TABLE` script itself — `country_code` doesn't exist yet
at that point in a fresh install, since it's a `_NEW_COLUMNS` addition
applied by `_migrate_columns()` afterward).

Verified thoroughly before touching production: fresh-install idempotency
(SQLite and MySQL), and — the case that actually matters — recovery from an
already-duplicated database (manually forced a duplicate, dropped the
index, confirmed `init_db()` cleans it up to exactly one row and the index
then actively blocks a repeat `IntegrityError`, not just silently ignores
it). 4 new regression tests (`DedupeRateMasterTests` in
`test_db_migrations.py`).

**Applied to the real production database**, in order: took a fresh
`mysqldump` backup, ran the dedup-fix migration alone first (confirmed
zero change — the real database had no existing duplicates, verified with
a *correct* query — domain+code+country_code+**fiscal_year_label**+
applies_to; an earlier check that omitted `fiscal_year_label` produced a
false alarm, since Nepal legitimately has two fiscal years' worth of data
for the same code, which is not a duplicate), then `force_seed=True` to
actually insert the missing content. Real client/user/engagement counts
confirmed unchanged throughout (4 clients, 3 users, 3 engagements).
`rate_master` went from 194 rows (Nepal only) to 232 (194 NP + 38 IN — the
original 22 plus this round's 16 depreciation/GST/penalty rows), zero true
duplicates. A live `india_depreciation` compute against the real production
database confirmed correct.

## Offline PWA investigation — a real bug found, not just "gaps" (2026-08-31)

Investigated what "offline PWA parity" for the country-awareness work
actually requires, rather than assuming the scope. Two findings:

**The premise was narrower than it sounded.** This app's "offline" story
has never meant general offline CRUD — clients, engagements, tasks, admin,
the new Organization/Calendar/Workflow-rule screens, all of it is
online-only throughout the entire app (`if (!state.online || state.offlineMode)`
guards on every write, confirmed by grep, not assumed). "Offline" means
specifically: the six calculation engines (`web/engines.js`, mirroring
`server/engines.py` "line for line" per the README) can run against a
locally cached rate snapshot with no connection. So "offline parity" was
never about making the new Phase 1-3 features work offline — nothing in
this app does that — it was specifically about whether the *cached rate
snapshot* those six engines depend on was country-correct.

**It wasn't — and the bug wasn't offline-specific, it hit the online Rate
Master screen too.** `GET /api/bootstrap` (the payload both the live app
*and* the offline cache are built from — `idbPut('kv', ..., 'rates')` on
this exact response) called `db.rates()` and `db.current_fy_label()` with
no country argument at all, and `db.unverified_count()` counted across
every country. None of the four `engines.compute(...)` call sites fixed
earlier in this session touch this endpoint — it's a separate code path.
Concretely: an India-scoped firm's Rate Master screen, *online*, showed
Nepal's rates — and whatever got cached for offline use was the same wrong
data. Fixed: `bootstrap()` and `GET /api/rates` now resolve
`country_service.firm_country_code(u["firm_id"])` explicitly and pass it to
`db.rates()`/`db.unverified_count()`; `current_fy` now uses
`fiscal_year_engine.current_period_label(u["firm_id"])` instead of the
Nepal-only `db.current_fy_label()`. `db.unverified_count()` gained an
optional `country_code` param — deliberately NOT defaulting to the ambient
`use_country()` context the way `rates()`/`rate_dict()` do, because two of
its four call sites (`run.py`'s CLI output, the public `/api/health` check)
have no firm to scope by at all and should keep reporting the grand total;
only the two firm-scoped callers pass a country explicitly. Also gave
`POST /api/rates` (platform-admin-only rate creation) a `country_code`
field — it previously always defaulted to `'NP'`, so a platform admin
literally could not create an India rate through the UI/API at all, only by
editing `seed.sql` directly (which is how every India row this session
exists at all).

**The six existing offline engines already thread rates as data, not code**
— confirmed, not assumed: `CAEngines.compute(moduleKey, state.rates, state.fy, data)`
in `web/app.js` passes the cached rate array in as a parameter;
`web/engines.js` never references `state.rates` (or any global) internally.
Loaded `engines.js` directly in Node (with a minimal `window`/`localStorage`
shim) and fed it a rates array containing only India codes — it ran clean
and reported exactly those India codes as unverified, no Nepal fallback
leaking through. This means the `bootstrap()` fix alone makes offline
computation correct for these six engines, for any domain whose *shape*
already fits India (income_tax/payroll/tds — the ones actually seeded) —
no `engines.js` changes were needed.

**What's still a real, un-closed gap**: the Nepal-specific *mechanics*
baked into both `engines.py` and its `engines.js` mirror (e.g.
depreciation's Bikram-Sambat-month absorption tranches) are unchanged by
design — India's differently-shaped `india_depreciation()`/`india_gst_return()`/
`india_interest_penalty()` (see above) are server-only, with **no offline
JS mirror at all**. An India firm's depreciation/GST/interest-penalty work
requires a connection; everything else India has (income tax, TDS, payroll)
now works offline correctly once the fix ships. Porting the three new
India engines to JS is real, scoped, doable follow-up work — not done here.

## Country switcher + module-country-applicability + UAE modules (2026-09-01)

User's explicit 5-point requirement this pass: country must be a real
runtime switch affecting every module's computation; module VISIBILITY
must be filtered by country (an Indian client only sees India-applicable
modules, UAE only UAE-applicable); switching country refreshes the module
list; a prominent country switcher belongs at the top of Admin; a module
whose rules are genuinely universal shouldn't be duplicated per country.
Explicit prioritization: **UAE before India** ("UAE is easy with tax rules
and other rules... complete all module for UAE we will think about India
Later") — India-specific work beyond what already existed is deliberately
deferred, not forgotten.

**Module-country-applicability**: `country_service.MODULE_COUNTRY_APPLICABILITY`
— a dict of `module_key -> {country codes it's limited to}`. A key absent
from the dict applies everywhere, deliberately the common case: grepping
every engine file confirmed the large majority of modules (Transfer
Pricing, Practice Management, Forensic Audit, Loan/EMI, Lease, Valuation,
FDI, Inventory, Fin Statements, Grant, Projection, DD, Capital Advisory,
Fixed Assets, Audit Execution, Corporate/Business/IPO/Tax Advisory,
Accounting Automation, Statutory/Internal Audit, Organization & People...)
have zero `rate_master` dependency and are already country-neutral in
computation — confirmed, not assumed. Only `personal_tax`/`salary_tds`/`tds`
(no UAE equivalent at all — UAE has no personal income tax or general
withholding regime), `depreciation`/`biz_tax`/`penalty` (Nepal-shaped
mechanics), `india_*` and `uae_*` are actually listed. `is_module_applicable()`
is the one function to call; `bootstrap()` serves the map as
`module_country_applicability` (JSON-safe: sets → sorted lists) so
`web/app.js`'s `isModuleCountryApplicable()` has one source of truth
instead of a second hard-coded copy. Wired into `buildRail()` (both
`MODULES` and `EXTRA_MODULES` loops) and the admin per-user module-grant
checkbox list (`renderAdmin()`'s `moduleKeys`).

**Country switcher**: prominent `<select>` at the top of the Admin page,
next to the existing FY switcher (`countrySwitcherHtml()`/`wireCountrySwitcher()`
in `web/app.js`). Saves via a **new, dedicated** `PUT /api/admin/firm-country`
endpoint, gated on `is_super_admin` (not the `site_settings` module grant,
so it works regardless of a firm's module entitlements) — deliberately
NOT reusing `PUT /api/admin/firm-profile`: that endpoint always rewrites
all 14 of its fields from the request body (`b.get(f)`, missing fields →
NULL), so posting just `{country_code}` through it blanked the firm's
name/address/phone/socials and 500'd on `firm.name`'s `NOT NULL`
constraint — caught live via Playwright against a scratch instance before
it shipped, not assumed safe. On success: full `bootstrap()` refresh,
`state.fy` set to the refreshed `current_fy` (see Financial Period fix
below), `buildRail()` re-run.

**UAE modules — all complete**, per explicit priority:
- **VAT**: zero new code. `engines.vat_return()` only ever reads
  `vat.standard_rate` — exactly UAE's single 5%-rate shape. Just a
  `rate_master` seed.
- **Corporate Tax** (0% to AED 375,000, 9% above): zero new engine either
  — it's `engines.personal_tax()`'s existing marginal-slab mechanic, called
  with `status='company'` against a `'company'`-scoped `income_tax` seed.
  Registered as the `uae_corporate_tax` lambda in `engines.ENGINES`.
- **Penalties** (Cabinet Decision 49/2021, late-payment mechanics as
  revised by 108/2021): genuinely needed its own engine — fixed-AED /
  capped-% structure, nothing like Nepal's Sec 117-120 or India's
  234A/B/C/F. New `server/uae_engines.py`, mirrored in `web/engines.js` for
  offline parity.
- Both UAE-specific modules got **real module pages** (`web/app.js`
  `MODULES` dict, codes `CT`/`UP`) — unlike the India engines, which
  remain API-only (see "Not yet done" below). Live Playwright verification:
  nav visibility follows the country switch both ways, forms render,
  offline computation is correct (500,000 taxable income → 11,250 tax;
  a late-registration flag → 10,000 penalty; the 300% late-payment cap
  actually caps).
- Applied to real production MySQL (backup taken first at
  `data/backups/pre_uae_seed_20260901_074238.sql`; verified client/user/
  engagement/firm counts unchanged, `rate_master` grew by exactly the 14
  new AE rows, both existing firms auto-granted the two new modules via
  `_grant_new_module_to_every_firm`).

**Financial year now follows the country switch**: separate user report,
mid-session — the FY pickers (global switcher, every module workfile's own
picker, Transfer Pricing's picker) kept showing Nepal's Bikram Sambat
`fiscal_year` table regardless of country, and the working FY never moved
off whatever Nepali label was last set. Root cause: `bootstrap()` only ever
exposed the Nepal-only table; nothing read the country-aware
`financial_period` table the existing Financial Period Engine already
builds periods for. Fixed with `fiscal_year_engine.financial_periods_for_firm(firm_id)`
— returns the right table's rows (Nepal's own for a Nepal firm,
`financial_period` for any other), normalised to the SAME
`{label_bs, start_ad, end_ad, is_current}` shape `fiscal_year` already
uses, so every existing FY picker and pro-ration lookup (`web/engines.js`'s
`faFyWindow`/`fyLabelForAd`, used by Fixed Assets' day-based pro-ration)
works unchanged — just fed correctly-scoped data via `bootstrap()`'s new
`financial_periods` field, instead of always Nepal's. `ensure_periods_exist()`
auto-seeds `financial_period` the first time a country with a known
generation rule is actually needed, so the picker is never empty right
after a firm's first switch to India/UAE. Live-verified: switching to UAE
moved the FY from `2083-84` to `2026` (a 2024-2029 calendar-year spread,
auto-seeded) everywhere at once; switching to India showed `2026-27`.

**Accounting policy generator made country-aware too**: found while
auditing for remaining Nepal-only text — `acct_engines.suggest_policies()`
(the Accounting Automation module's policy-paragraph generator, output that
ends up in an actual client deliverable) unconditionally cited "Nepal
Financial Reporting Standards (NFRS)", "NAS 23/12/21" and "the Income Tax
Act, 2058" in every generated paragraph, for every firm regardless of
country. Now takes a `country_code` and cites the right GAAP (NFRS for
Nepal, Ind AS for India, IFRS for UAE) and tax statute per country, via a
`_STANDARDS` lookup — mirrored in `web/acct_engines.js`. The `AUDX_CHECKLIST_TEMPLATES`
"NFRS Compliance" audit checklist in `app.py` (internal working-paper
checklist, not a client-facing deliverable) still cites NFRS/NAS numbers
unconditionally — lower-stakes than the policy generator, not fixed this
pass, worth a follow-up (see below).

**Organization & People, confirmed (not just assumed) UAE-ready**: zero
`rate_master`/country coupling found in `org_engines.py`/`workflow_engine.py`/
`resource_engine.py` — branches, departments, teams, positions, employee
profiles, skills, workflow rules all work identically regardless of firm
country. `org_admin` is correctly absent from `MODULE_COUNTRY_APPLICABILITY`
(applies everywhere).

**Client-level country + logo**: `client.country_code`/`client.logo_path`
(via `_NEW_COLUMNS`), `country_service.client_country_code()`/
`use_country_for_client()` resolve a client's own jurisdiction when set,
falling back to the firm's — so a UAE-based firm with an India-incorporated
subsidiary client computes that client's own workfiles under India's rules.
`clientFormHtml()` in `web/app.js` has the country select + logo upload
wired in. This was built earlier in the session (see git log around the
same date) and re-confirmed still working this pass, not re-built.

Tests: 5 (`is_module_applicable`) + 14 (`uae_engines`) + 6
(`financial_periods_for_firm`/`ensure_periods_exist`) = 25 new tests this
pass. Full suite: 342 → 368 passing.

## Not yet done / next steps

1. ~~Actual MySQL cutover~~ — **done, 2026-08-31** (see above and
   `docs/mysql-migration.md`). Executed on explicit instruction. `data/.env`
   now sets `CA_DB_ENGINE=mysql`; the SQLite file remains as a pre-cutover
   backup only.
2. ~~Give `run_jobs()`'s "report" job payload a `firm_id`~~ — turned out to be
   moot: nothing in the codebase actually `INSERT`s into `job_queue` with
   `job_type='report'` anywhere (checked directly, no call site exists). That
   branch in `run.py`'s `run_jobs()` is unreachable scaffolding for a
   queued-report feature that was never wired to a UI trigger — not a live
   country-routing gap. Revisit only if/when something actually starts
   enqueueing that job type.
3. ~~The offline PWA has Nepal assumptions baked in~~ — **investigated and
   the real bug fixed, see "Offline PWA investigation" above.** Turned out
   to be a `bootstrap()`/`GET /api/rates` bug (always resolved Nepal
   regardless of firm), not a `web/engines.js` problem — that file already
   threads rates as a parameter, confirmed by loading it in Node directly.
   Remaining gap, real but scoped: `india_depreciation`/`india_gst_return`/
   `india_interest_penalty` have no offline JS mirror at all yet (server-only).
4. ~~A real country-neutral Financial Period/fiscal-year engine~~ — **done,
   see "Financial Period Engine" above.**
5. ~~India: depreciation/VAT(GST)/penalty engine-shape work~~ — **done, see
   "India engine-shape work" above.** `india_engines.py`, callable via the
   existing `/api/compute`, 19 tests. Remaining gap: **no dedicated
   module-page UI** — these three have no `route`/nav link, only API
   access (granted, not yet wired to a form). Building that UI (dynamic
   block/line-item entry forms, matching the sophistication of e.g. the
   existing Depreciation module's pool editor) is its own substantial
   frontend task, not done here.
6. Every India rate_master value is an unverified placeholder, same as every
   Nepal one — needs review by someone qualified in Indian tax law before
   any client work, exactly like the Nepal banner already requires. Not
   something an AI session can complete on its own.
7. ~~The new Organization/Tasks/Calendar UI needs an actual browser
   click-through~~ — **done, same day** (see "Follow-up: actual browser
   verification" above). Installed Playwright into the scratchpad, drove the
   real UI, found and fixed two real bugs (a standalone task was permanently
   invisible; stale branch/department dropdowns). 19/19 checks passing, zero
   console errors. Not exhaustive though — task dependencies, subtasks, and
   editing an existing employee's profile were never clicked through; there
   is also no UI at all yet for `resource_engine.suggest_candidates()` (only
   `workload_report()`'s table is wired to a page).
8. `job_position.hierarchy_level` exists and is populated (default Managing
   Partner=0 → Intern=8) but **nothing reads it yet** — no approval-authority
   check, no "a reviewer must outrank the assignee" rule, no escalation
   logic. It's structurally ready for that (see the original master prompt's
   §6-7) but wiring it in is separate future work, not done here.
9. `schema.sql`/`schema_mysql.sql` are hand-kept in sync (`convert_schema.py`,
   referenced in a code comment, doesn't actually exist in this repo) —
   every new table this pass added was translated by a one-off script
   applying the same rules `db._mysql_column_decl()` already uses, then
   hand-reviewed and tested against a real MySQL database. Keep doing that
   for future tables; don't assume the two files can drift and be
   reconciled later.
10. Resource allocation (`resource_engine.suggest_candidates`) and workload
    are not offline-capable and have no UI test coverage beyond the API —
    same caveat as #7.
11. `workflow_rule.condition_json` only supports flat equality — deliberately,
    per its own docstring — but if a firm asks for "OR" or "greater than"
    conditions, that's a real, known ceiling, not an oversight to silently
    work around.
12. **India, deferred by explicit user instruction** ("we will think about
    India Later" — UAE was prioritized first, see "Country switcher +
    module-country-applicability + UAE modules" above). Still true from
    item 5: `india_depreciation`/`india_gst_return`/`india_interest_penalty`
    have API access only, no module-page UI and no offline JS mirror.
    Resume here when the user asks for India work specifically — don't
    self-initiate it.
13. `AUDX_CHECKLIST_TEMPLATES`'s "NFRS Compliance" checklist area in `app.py`
    still cites NFRS/NAS standard numbers unconditionally for every
    country — same class of bug as the accounting-policy generator fixed
    this pass, just lower-stakes (an internal working-paper checklist, not
    text that ends up in a client deliverable the way the policy generator's
    output does). Same fix shape would apply: key the checklist item text
    off `country_service.firm_country_code()` via a `_STANDARDS`-style
    lookup, same as `acct_engines._STANDARDS`.
14. The ~20 modules asserted "already country-neutral" (no `rate_master`
    dependency, confirmed by grep) were confirmed for computation shape,
    not spot-checked one-by-one through an actual UAE-context browser
    session the way VAT/Corporate Tax/Penalties were. Reasonable confidence,
    not the same standard of proof as the three modules that got live
    Playwright verification this pass.
15. UAE Corporate Tax's Small Business Relief election and free-zone
    Qualifying Income treatment (both of which can make the effective rate
    0% regardless of the two-slab structure seeded) are not modelled —
    `uae_corporate_tax` always applies the standard mainland slabs. Called
    out in the seed.sql row's own `notes` column; a firm relying on either
    election needs to compute outside this module for now.

## Known constraints / house rules for this codebase

- Every regulatory figure in `rate_master`/`tp_regulatory_rule` ships
  `UNVERIFIED` until a human checks it against the actual law — never mark
  something verified without being told to.
- `schema.sql` is the single source of truth; `schema_mysql.sql` is *generated*
  from it (`convert_schema.py`, not inspected yet) — never hand-edit
  `schema_mysql.sql` independently for the original tables, only through
  `_NEW_COLUMNS` for anything added later (see above).
- No test coverage assumption for `app.py` routes — manually verify route
  changes; the 262+ automated tests are concentrated in engines/db-migrations.
- ~~**`rate_master`'s `UNIQUE (domain, code, fiscal_year_label, applies_to)`
  constraint does not include `country_code`.**~~ — **fixed, see "Country
  switcher + module-country-applicability + UAE modules" below.**
  `_fix_rate_master_dedup()` in `db.py` dedupes any existing collision then
  creates `ux_rate_master_dedup`, a COALESCE-based expression UNIQUE index
  that DOES include `country_code` (schema.sql's own constraint is
  untouched — frozen historical snapshot, per house rule above; this is a
  `_NEW_COLUMNS`-era migration layered on top, same as everything after
  initial release). Proven, not just asserted: UAE's seed rows share the
  same standing (`fiscal_year_label IS NULL`) `domain`+`code`+`applies_to`
  shape India already used (e.g. `income_tax`/`slab_1_ceiling`/`company`) —
  doesn't collide, precisely because `country_code` differs — and applied
  cleanly against real production MySQL with zero duplicate-key errors.
