# CYDM — Progress Log

> Append-only. Newest entries at the top. One entry per completed slice.
> Format: what was built · what was tested · judgement calls made.

---

## 2026-08-16 — Session 1 (autonomous)

### Slice 0.1 — Documentation plan ✅

**Built**
- `docs/DEVELOPMENT_PLAN.md` — master plan: what we're building, confirmed technical decisions with reasoning, build strategy, definition of done, five phases, cross-cutting rules, risk register.
- `docs/ROADMAP.md` — sequencing, estimates, dependencies, critical path, deliberately-deferred list.
- `docs/PROGRESS.md` — this log.

**Judgement calls**

Three decisions in the original specs did not survive contact with the actual environment. Each is documented in the plan with its reasoning:

1. **PostgreSQL 16 → MySQL 8.4.** The Postgres container is not running, and `PHP_FPM_INSTALL_PGSQL`, `PHP_WORKER_INSTALL_PGSQL` and `WORKSPACE_INSTALL_PG_CLIENT` are all `false` in the Laradock `.env` — the PHP-FPM and worker containers have no Postgres driver at all. Switching would mean rebuilding three images before writing a line of code. MySQL 8.4 is already running with drivers installed, and the user pointed explicitly at `laradock/mysql` for creating the database. What this costs us (row-level security, `pgvector`) and the trigger for revisiting it are both recorded.

2. **stancl/tenancy v4 → v3.10.1.** The spec assumed v4. **v4 has not been released — it is explicitly work-in-progress.** The latest stable is v3.10.1 (2026-08-05), which supports Laravel 10–13. Nothing in the architecture depends on v4-only features; its Postgres-RLS model is noted as a future path.

3. **Laravel 12 → Laravel 13.** Laravel 13 shipped 2026-03-17 and requires PHP 8.3–8.5. Laradock runs PHP 8.3.33, so it qualifies. Choosing 12 would start the project one version behind with support ending sooner.

**Also decided:** the Laravel AI SDK is installed but kept off the money path entirely — no AI touches credit decisions, interest computation, loan classification, provisioning, or GL postings. This will be recorded as a Boost project rule so it survives future sessions.

**Next:** slices 0.2–0.5 (git, database, nginx, hosts), then 0.6 (Laravel install).

---

### Slices 0.2–0.5 — Git, database, nginx, hosts ✅ *(hosts partially blocked)*

**Built**
- **Git** initialised, author configured, baseline commit `f250ca1` capturing the original specs plus the new plan docs.
- **Databases** `cydm` and `cydm_testing` created in Laradock MySQL 8.4, with a dedicated `cydm` user (password `cydm_secret`) granted access to only those two schemas — matching the one-user-per-project convention already in use here (`paisha`, `tanpesa`, `africraft`…). Persisted to `laradock/mysql/docker-entrypoint-initdb.d/cydm.sql` so a fresh data directory recreates them automatically.
- **Nginx** site at `laradock/nginx/sites/cydm.conf`, validated with `nginx -t` and reloaded live. Two server blocks — see the judgement call below.

**Tested**
- `mysql -u cydm -pcydm_secret -e "SHOW DATABASES"` → sees `cydm` and `cydm_testing` only. Isolation confirmed.
- `nginx -t` → syntax OK.
- `curl -H "Host: cydm.test" http://localhost/` → HTTP 404 (nginx routing correctly; no app deployed yet).
- `curl http://localhost:81/` → HTTP 404 (fallback block reached).

**Judgement calls**

1. **The hosts file could not be written.** `C:\Windows\System32\drivers\etc\hosts` requires Administrator rights and this session does not have them — the write failed with "Access to the path is denied". Rather than leave the app unreachable, I added a **second nginx server block on port 81** as `default_server`, which matches any hostname. `http://localhost:81` therefore reaches the app with no hosts entry at all. Laradock already publishes port 81 and nothing else was using it.

   The proper subdomain routing still matters for tenancy testing, so I wrote `scripts/setup-hosts.ps1` — idempotent, backs the file up, flushes DNS, and takes a `-Tenants` list. **This is the one thing needing your attention: run it once from an Administrator PowerShell.** Until then, everything is testable on port 81.

2. **MySQL user creation failed initially.** `GRANT ALL ON cydm.* TO 'default'@'%'` returned `ERROR 1410: You are not allowed to create a user with GRANT` — MySQL 8 removed implicit user creation via GRANT, and no `default` user exists here anyway. Inspecting `mysql.user` showed the established pattern is a dedicated user per project, so I followed it with an explicit `CREATE USER`.

---

### Slice 0.6 — Laravel install ⚠️ *framework version changed*

**Built**
- `laravel/react-starter-kit` v1.0.1 installed (Inertia + React 19 + TypeScript + Tailwind v4, `components.json` present for shadcn/ui), then copied into the project directory alongside the existing docs and git history.

**Judgement call — Laravel 12, not 13**

The plan targeted Laravel 13. Laravel's own release-notes table claims Laravel 13 supports "PHP 8.3 – 8.5". **That table is wrong.** The real Composer constraint in `laravel/framework` v13 is `"php": "^8.4"`, and the upgrade attempt failed:

```
laravel/framework[dev-master, 13.0.x-dev] require php ^8.4
  -> your php version (8.3.33) does not satisfy that requirement
```

Reaching PHP 8.4 means either bumping `PHP_VERSION` in Laradock's **shared** `.env` and rebuilding `workspace`, `php-fpm` and `php-worker` — which affects every other project on this machine — or standing up parallel 8.4 services. I was not willing to do either unattended, on a shared environment, while you were asleep.

So I checked what staying on 12 actually costs. The answer is close to nothing: `laravel/ai` requires only `php ^8.3` and `illuminate/* ^12.0|^13.0`; Boost supports Laravel 10–13; `stancl/tenancy` supports 10–13; `spatie/laravel-permission` supports 10+. Every package you asked for works on Laravel 12.

The one real cost: Laravel 12's bug-fix window closed on 13 August 2026 (three days ago). Security fixes continue to February 2027. The exact upgrade procedure is written up in [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) §2.0 for when you want to make the PHP 8.4 call — and nothing in this build will depend on Laravel 13-only APIs, so the door stays open.

**Next:** `.env` configuration, Boost + AI SDK, core packages.

---

### Slices 0.7–0.10 — Configuration, tooling, first render ✅

**Built**
- `.env` wired to Laradock service hostnames, `Africa/Dar_es_Salaam`, Redis for cache/session/queue, and placeholder blocks for the Tanzanian integrations.
- Vite bound to `0.0.0.0:5173` with `usePolling` — Windows bind mounts do not propagate inotify events, so HMR is silent without it.
- Laravel Boost 2.5.3 with guidelines, skills and MCP, wired to Claude Code.
- Laravel AI SDK 0.10.3, stancl/tenancy 3.10.1, spatie/laravel-permission 8.3, sanctum 4.3, Pest 3.8.
- Composer constraints pinned — the installs had left four packages at `*`.

**Tested**
- `php artisan about` → MySQL and Redis connected, timezone correct.
- `curl` on both entry points → HTTP 200.
- Welcome page renders through React in a real browser.

**Two failures worth recording**

1. **`NOAUTH Authentication required` on every request.** This Laradock instance sets `REDIS_PASSWORD=secret_redis`, and Laravel's default `.env` has `REDIS_PASSWORD=null`. Every page 500'd until the password was set. Noted in `ENVIRONMENT.md` because it will catch the next project on this machine too.

2. **`vendor:publish` reported "no publishable resources" for packages that were plainly installed.** The cause was a stale `bootstrap/cache/packages.php` — the composer run that added them had not triggered discovery. `composer dump-autoload -o` followed by `package:discover` fixed it. Worth knowing because the error message points nowhere near the actual problem.

**Judgement call — Pest 3, not Pest 4.** Pest 4 requires PHP `^8.4`, the same blocker as Laravel 13. Pest 4's built-in browser testing was the reason for choosing it, so browser tests will use Laravel Dusk instead. Recorded in `TESTING.md`.

---

### Slice 1.1 — Design system ✅

**Built**
- `resources/css/app.css` rewritten from the starter kit's neutral greys to the CYDM palette, authored in **OKLCH** rather than HSL.
- Blue primary (hue 258), orange accent (hue 45). Light and dark derived independently rather than by inversion — the light-mode blue reads muddy on a dark ground, so it is lifted.
- Loan classification status colours per GN 679 Reg 45.
- Chart series kept inside the brand palette and ordered so adjacent series stay distinguishable in greyscale.
- Tabular figures on all table cells; `prefers-reduced-motion` honoured.

**Tested in browser** — read computed styles directly rather than trusting the build:

| | Light | Dark |
|---|---|---|
| `--primary` | `oklch(.45 .22 258)` | `oklch(.65 .22 258)` |
| `--accent` | `oklch(.7 .18 45)` | `oklch(.75 .18 45)` |
| `--background` | `oklch(1 0 0)` | `oklch(.145 0 0)` |

Login page renders; submit button paints `oklch(0.65 0.22 258)`. No console errors.

**Why OKLCH.** It is perceptually uniform — equal lightness values look equally light across hues. That is what will let a tenant shift their brand colour without silently destroying contrast, and it means the branding editor can validate a colour by arithmetic rather than by eye. In HSL the same operation is unpredictable.

---

### Slices 1.3–1.4, 1.6 — Auth, RBAC, dashboards ✅

**Built**
- **95 permissions across 17 modules, 8 roles.** The separations encode the maker-checker controls the regulations assume rather than reflecting an org chart: a loan officer can appraise but not approve or disburse; a teller handles cash but touches no loan decision or journal; the internal auditor is read-only everywhere outside the audit module, because write access would compromise the independence they report to the Board with.
- The seeder validates its own role definitions and throws on an unknown permission — a typo in a role definition would otherwise silently grant nothing, which looks like a bug somewhere else entirely.
- Users table gains `phone`, login trail, `password_changed_at`, and lockout fields, so the 90-day expiry and lockout policies have somewhere to live.
- Roles and permissions shared with Inertia for UI gating — presentation only; policies re-check server-side.
- **Role-aware dashboard**: seven roles, seven distinct metric sets, with `KpiTile` and the money formatting layer underneath.
- `lib/money.ts` — integer cents in, formatted TZS out. Negatives in parentheses per accounting convention, em dash for zero, compact form for tiles.

**Tested**
- **45 tests, 124 assertions, all passing**, against **MySQL** rather than SQLite in-memory. SQLite is faster but diverges precisely where this application cares — decimal handling, strict mode, constraint enforcement. Suite takes ~2m 15s on this bind mount, which is acceptable for the confidence.
- `npx tsc --noEmit` clean, after fixing four pre-existing starter-kit type errors that would have blocked CI on day one.
- **Browser**: logged in as `officer@cydm.test`, confirmed the Loan Officer dashboard renders — "Karibu, Amina", role-correct tiles, `TZS 42.3M` formatting, no console errors.

**Judgement call — placeholder metrics, loudly labelled.** The dashboard cannot show real figures until members, savings and loans exist. Rather than block the layout on phase 2, `DemoMetrics` supplies plausible Tanzanian SACCOS numbers and the UI carries a visible "Sample data" banner. A dashboard of invented numbers that doesn't say so is how a demo becomes a decision. The class is marked for deletion in slice 2.9.

---

### Slice 0.1 (continued) — Documentation ✅

**Built**
- Restructured into `docs/`: the five original specs folded in alongside new `README`, `DEVELOPMENT_PLAN`, `ROADMAP`, `PROGRESS`, `ENVIRONMENT`, `DESIGN`, `TESTING`.
- Feature specs written for loans, members/KYC, savings/shares, accounting, and payments — each with regulatory basis, data model, workflows, edge cases, test checklist and build order.
- `.ai/rules/` recorded so future sessions and agents inherit the money, tenancy, accounting and regulatory-sourcing constraints.

**The finding that justified the research**

The loan classification rules in the original specs were **wrong**, and wrong in the direction that understates provisions. I downloaded the actual Microfinance (Non-Deposit Taking Microfinance Service Providers) Regulations 2019 — GN No. 679 — from the Bank of Tanzania and read Reg 44 and 45.

| | Original spec | GN 679 Reg 45(1) |
|---|---|---|
| Bucket 1 | Performing — 0 days | **Current — 0 to 5 days** |
| Bucket 2 | Watch — ≤30 days | **Especially Mentioned — 6 to 30 days** |
| Bucket 3 | Substandard — ≤60 days | Substandard — 31 to 60 days |
| Bucket 4 | Doubtful — ≤180 days | **Doubtful — 61 to 90 days** |
| Bucket 5 | Loss — 180+ days | **Loss — more than 90 days** |

A loan 120 days overdue requires a **100% provision** under the real rule. The spec would have provisioned it at 50%. On any real portfolio that is a material misstatement of loan-loss provision — exactly what a BoT inspection looks for.

Two further rules were missing from the specs entirely:

- **Reg 45(3)–(4)**: housing microfinance has its own, more lenient schedule (91–180 Substandard, 180–360 Doubtful, 361+ Loss). `loan_products` therefore needs an `is_housing_microfinance` flag.
- **Reg 44(2)**: *"Loans which are payable in installments shall be considered past due in their entirety if any of the installments has become due and unpaid for one day or more."* One day late puts the **whole outstanding balance** past due, not the arrears amount. PAR must use the full balance. A naïve implementation ages only the missed instalment, reports a PAR several times smaller than the truth, and passes its own tests doing so.

Corrected in `docs/COMPLIANCE.md`, `docs/features/00-overview.md`, and specified in full in `docs/features/03-loans.md`. The regulation text is kept at `storage/app/regs/` so the next person does not have to re-source it.

---

### Slice 1.2 — Tenancy core ✅

Done on a branch (`feat/tenancy-core`) and merged only once green, because this
was the highest-risk change of the session — it alters the users table that
authentication depends on.

**Built**
- `tenants` and `domains` tables; `Tenant` and `Domain` models.
- `BelongsToTenant` trait: global scope on every query, `tenant_id` stamped on create.
- `DatabaseTenancyBootstrapper` **disabled** — it swaps database connections per tenant, which is the multi-database model. Cache, filesystem and queue bootstrappers stay, so cache keys and storage paths are tenant-prefixed and queued jobs carry their tenant.
- Demo tenant seeded — *Akiba Yetu SACCOS* at `demo.cydm.test`, with six staff.
- `scripts/check-tenant-scoping.sh` + CI pipeline.

**The decision worth reading**

What should the scope do when **no tenant is active**?

The obvious implementation returns every row. That is what turns a forgotten `tenancy()->initialize()` in a console command, a scheduled report or a queued job into a silent cross-tenant leak — it fails **open**, and it fails **quietly**. Nothing errors; the numbers are just wrong, everywhere.

So instead the scope constrains to a sentinel that matches nothing, and creating a record with no tenant context throws. A missing tenant context now shows up as "no results" — an obvious bug — rather than "everyone's results". The cost is that central code must say `withoutGlobalScope(TenantScope::class)` explicitly, which CI flags for review. That is the right trade: an inconvenience that is visible beats a breach that is not.

**Users are the deliberate exception.** `tenant_id` is nullable (null = CYDM platform staff) and `User` carries **no** global scope. Authentication runs on the central domain before any tenant is resolved, so a global scope there would make platform staff unable to log in at all. User listings scope explicitly via `User::forTenant()`. Email uniqueness moved to `(tenant_id, email)` — one person may legitimately belong to two SACCOS, and a global unique index would let whichever institution registered first block every other one.

**Tested** — 12 isolation tests covering row reads, direct lookup by known id, aggregates (where a missing scope hides longest, because a count is plausible whatever it returns), mass update and delete, the no-tenant default, and FK cascade on tenant deletion. Full suite 75 tests / 187 assertions. Verified in browser that login still works after the users-table change, and that the teller sidebar correctly differs from the loan officer's.

**Two failures worth recording**
1. `loadMigrationsFrom()` does not exist on Laravel's core `TestCase` — it is an Orchestra Testbench method. The fixture migration path is registered in `AppServiceProvider` under `runningUnitTests()` instead.
2. The fixture migration was dated `2026_01_01` and ran *before* the `tenants` table existed, failing on its foreign key. Migrations from every registered path are ordered together by filename, so it is now dated `2099`.

---

## ⚠️ One thing needs you

**Run this once, from an Administrator PowerShell:**

```powershell
cd "C:\Users\dismas\Documents\DEVELOPMENT ENV\cydm"
.\scripts\setup-hosts.ps1
```

Editing `C:\Windows\System32\drivers\etc\hosts` needs elevation, which this session did not have. The script is idempotent, backs the file up first, and flushes DNS.

Until you run it, **everything works at http://localhost:81** — I added a second nginx server block on port 81 as `default_server` precisely so the missing hosts entry would not block anything. After you run it, prefer `http://cydm.test`, since that is the path that exercises real tenant subdomain routing.

---

## Where things stand

**Working, verified in a browser:** authentication, 8-role RBAC with 95 permissions, seven role-aware dashboards, permission-filtered navigation, single-database tenancy with proven row-level isolation, the blue/orange design system in light and dark. **75 tests / 187 assertions passing** against MySQL, typecheck clean, Pint clean, tenant-scoping guard clean.

**Next up**, in order: subdomain resolution middleware wired to routes (the tenancy *data* layer is done; request-level resolution is not), the audit log (1.7), institution settings (1.8), then the **posting engine (2.1)** — which gates every money feature and should be done slowly.

**Decisions I made without you** — all reversible, all written up with their reasoning:

| Decision | Where |
|---|---|
| Laravel 12 not 13 (PHP 8.4 requirement, shared environment) | DEVELOPMENT_PLAN §2.0 |
| MySQL not PostgreSQL (no pgsql driver in php-fpm here) | DEVELOPMENT_PLAN §2.1 |
| tenancy v3.10 not v4 (v4 is unreleased) | DEVELOPMENT_PLAN §2 |
| Pest 3 + Dusk, not Pest 4 (PHP 8.4) | TESTING.md |
| Tests against MySQL not SQLite | phpunit.xml |
| Placeholder dashboard metrics, visibly labelled | DemoMetrics.php |

The two worth a second look are Laravel 12 and MySQL. Both are environment constraints rather than preferences, and both have documented upgrade triggers — but if you would rather take the PHP 8.4 upgrade across the whole Laradock instance, that is your call to make, not mine to make while you sleep.

---
