# Kualifi — deployment runbook (cPanel, no SSH, no Terminal)

Ordered checklist for putting kualifi.com live. Every step is doable from the
cPanel UI; CLI scripts run via one-shot cron jobs (this host has no Terminal).

**Actual deployed layout** (differs from the original template): the project
root is `/home/dalecarn/public_html/addons/kualifi/` and the kualifi.com
addon-domain docroot is its `public/` folder. That works — `config/`, `sql/`,
`src/`, `bin/`, `uploads/` are above the docroot so kualifi.com cannot serve
them, and the parent domain returns 404 for those paths (verified 29 Jul 2026).
The deny-all `.htaccess` files in each of those folders are the backstop.

## 1. Domain + files

1. Register **kualifi.com** and point its nameservers/DNS at the hosting
   account.
2. cPanel → **Addon Domains** → add `kualifi.com`. Set the document root to a
   fresh directory, e.g. `/home/dalecarn/public_html/addons/kualifi/public`.
3. Upload the repo so the layout on the server mirrors the repo, with ONLY
   `public/` inside the docroot:

   ```
   /home/dalecarn/public_html/addons/kualifi/
     config/        <- outside webroot (has credentials)
     src/
     sql/
     bin/
     uploads/esg/   <- evidence files, outside webroot, must be writable
     public/        <- the addon domain's document root
   ```

4. Confirm `public/.htaccess` uploaded (dotfiles are easy to miss in File
   Manager — enable "Show Hidden Files").

## 2. Database

1. cPanel → **MySQL Databases**: create database `dalecarn_kualifi` and user
   `dalecarn_kualifi_app` with a strong generated password; grant ALL on the DB.
2. phpMyAdmin → select the database → **Import tab** (never paste into the SQL
   box — large pastes truncate silently) → import `sql/schema.sql`, then
   `sql/seed_standard_sirim55.sql`. If the file is too big, gzip it first —
   the Import tab accepts `.sql.gz`.
3. Sanity check: `standards` has the SIRIM55 row, `std_requirements` /
   `assessment_questions` are populated, `plans` has the `free` plan.

## 3. Config

Edit `config/config.php` on the server:
- `DB_NAME` / `DB_USER` / `DB_PASS` — the real values from step 2 (cPanel
  prefixes the names with the account name).
- `APP_BASE_URL` — confirm `https://kualifi.com` (no trailing slash).
- Leave everything else as committed.

Enable **AutoSSL / Let's Encrypt** for the addon domain (cPanel → SSL/TLS
Status) before testing — the session cookie is marked secure on HTTPS.

## 4. Anthropic key

1. In the Anthropic Console, create a dedicated API key for Kualifi under a
   funded account. Decide a **monthly spend cap** while you're there — every
   tenant's document generation bills this key (quota default: 20 docs/tenant/
   month, padmin-adjustable).
2. Create the file at the exact path configured in `ANTHROPIC_KEY_FILE`
   (currently `.../addons/kualifi/config/kualifi-anthropic.key`), containing
   the key on one line; set permissions to 600 in File Manager. The folder is
   above the kualifi.com docroot and carries a deny-all `.htaccess`, and
   `*.key` is gitignored — keep it out of OneDrive/local copies regardless.

## 5. First platform admin (via one-shot cron)

No Terminal on this host — use a temporary cron job:

1. cPanel → **Cron Jobs** → new job, schedule **Once Per Minute**.
2. Command (password ≥ 12 chars; avoid `%` and `!` — cron/shell mangle them):

   ```bash
   php /home/dalecarn/public_html/addons/kualifi/bin/create_padmin.php "Your Name" you@example.com "a-long-password" >> /home/dalecarn/padmin-setup.log 2>&1
   ```

3. After a minute, check `/home/dalecarn/padmin-setup.log` in File Manager —
   it should say `Created platform admin #1 (...)`.
4. **Delete the cron job** (the command line contains the password) and the
   log file, then sign in at `https://kualifi.com/padmin/`.

Re-running the same command later with a new password resets that admin's
password — that is also the recovery path if it's ever forgotten.

## 6. Reminder cron

cPanel → **Cron Jobs** → daily, early MYT (e.g. `0 7 * * *`):

```bash
php /home/dalecarn/public_html/addons/kualifi/bin/reminders.php >> /home/dalecarn/kualifi-cron-debug.log 2>&1
```

The `>>` capture means a wrong path or fatal error lands in
`kualifi-cron-debug.log` instead of vanishing. On a healthy run it records
`START: lead time 14 day(s).` and the script's own log appears at
`/home/dalecarn/public_html/addons/kualifi/kualifi-reminders.log`. The job
also prunes expired email tokens.

## 6b. Applying updates (30 Jul 2026 — brand redesign)

For the Kualifi brand redesign + four-tier readiness release.
**Run the database migrations BEFORE uploading files** — the new code queries
columns the migrations add and every page 500s until they exist, while the
currently-deployed code is entirely unaffected by the new columns. Migration
first = zero downtime; upload first = minutes of outage.
1. phpMyAdmin → the kualifi database → Import → run
   `sql/migrate_01_four_tier.sql` ONCE (renames outcome bands, recomputes
   stored scores; safe on existing data), then
   `sql/migrate_02_state_holidays.sql` ONCE (adds per-state columns; existing
   companies stay national/Mon–Fri until an Owner picks a state in Team), then
   `sql/migrate_03_ai_cost_tracking.sql` ONCE (adds token + USD cost columns
   to ai_usage; padmin → Tenants then shows per-client AI spend. Rows from
   before the migration show US$0.00 — cost unknown), then
   `sql/migrate_04_soft_delete.sql` ONCE (KF-260825-0001: deleted_at/deleted_by
   on the eight register/module tables + created_at on four registers.
   PREREQUISITE: run `bin/purge_demo_companies.php --execute` FIRST — once the
   matching code deploys, × buttons flag rows instead of deleting, and the demo
   companies could no longer be truly removed from the UI. Register rows created
   before this migration show created_at NULL — that is deliberate, not a bug:
   NULL means "before this change" and must never be backfilled), then
   `sql/migrate_05_stable_codes.sql` ONCE (KF-260825-0002: stable codes on
   std_laws/aspects/stakeholders + std_code backfilled onto tenant copies by
   exact label match. READ THE TWO RESULT GRIDS the import prints: "seeded
   template rows still uncoded" and "UNMATCHED_seeded" must both be 0.
   User-added custom rows stay NULL — correct, not a failure. Afterwards run
   `php bin/check_std_integrity.php` — it must print INTACT; run it again
   after ANY future change to std_* content), then
   `sql/migrate_06_question_bank_versions.sql` ONCE (KF-260825-0003:
   append-only question-bank snapshots + version pointer + bank_version on
   attempts. The result grid must show version 1 with questions_in_snapshot
   equal to questions_live. Attempts from before the migration keep
   bank_version NULL — deliberate, never backfill. Upload the matching code
   together: src/bootstrap.php, src/QuestionBank.php, src/Assessment.php,
   public/padmin/questions.php, bin/check_std_integrity.php — the checker
   reports DRIFT until this migration is imported), then
   `sql/migrate_07_sent_email.sql` ONCE (KF-20260908-02: the sent-mail
   evidence table. Upload with it: src/Mailer.php, public/register.php,
   public/index.php, public/forgot.php, public/app/team.php,
   bin/reminders.php. Deploy order is forgiving here — code uploaded before
   the migration still sends mail and merely logs a "sent_email record
   failed" line until the table exists).

2. Upload every changed file (`public/`, `src/`, `sql/`) **including the new
   `public/assets/img/` logo folder** — mirror the repo layout.
3. Mirror the one changed config line on the server:
   `const APP_TAGLINE = 'Making ESG readiness achievable.';`
4. Hard-refresh the site — the stylesheet name is unchanged, so browsers may
   cache the old theme.
5. **Pre-rebrand AI documents**: documents generated before the redesign may
   name SIRIM in their body. Check with
   `SELECT id, company_id, title FROM esg_documents WHERE content LIKE '%SIRIM%';`
   and regenerate (or hand-edit) any hits — the interim rule covers generated
   documents too.

### 6c-0. Brand screen on AI output (KF-20260908-04)

Import `sql/migrate_08_ai_rejected.sql` ONCE (two columns on ai_usage), then
upload together: `src/CopyGuard.php`, `src/bootstrap.php`, `src/Tenant.php`,
`public/app/esg_documents.php`, `public/app/esg_setup.php`,
`public/padmin/tenants.php`. Smoke: Regenerate any AI draft — normal output
saves as usual; a rejection (if one occurs) flashes the caught terms, leaves
the draft untouched, and the ai_usage row shows rejected=1 with the terms in
rejected_reason while the month's quota count ignores it.

### 6c-8. Store the Assessment Pack (KF-20260908-11)

Import `sql/migrate_13_pack_snapshots.sql` ONCE, then upload together:
`public/app/esg_report.php`, `public/app/esg_pack_view.php` (new),
`bin/purge_demo_companies.php`. Smoke: open the Assessment Pack (the first
open stores silently — the strip lists copies that existed at render time);
refresh — "Stored copies (1)" appears; refresh again — still 1 (dedup);
change any register row and reopen — 2; a stored copy opens in a new tab
fully styled with no Google Fonts access; Owner "Remove" hides a copy.

### 6c-7. File checksums (KF-20260908-10)

Import `sql/migrate_12_file_checksums.sql` ONCE, then upload `src/Esg.php`
and `bin/backfill_checksums.php`, then run the backfill once (one-shot cron
as usual):

```bash
php /home/dalecarn/public_html/addons/kualifi/bin/backfill_checksums.php >> /home/dalecarn/backfill.log 2>&1
```

Smoke: the log ends "Done: N hashed, 0 missing bytes, 0 unreadable"; upload
a new evidence file and its esg_files row carries a 64-char sha256.

### 6c-6. Targeted tenant audit log (KF-20260908-09)

Import `sql/migrate_11_tenant_audit.sql` ONCE, then upload together:
`src/TenantAudit.php`, `src/bootstrap.php`, `src/Esg.php`,
`src/Assessment.php`, `bin/purge_demo_companies.php`,
`public/app/esg_dashboard.php`, `public/app/esg_risks.php`,
`public/app/esg_objectives.php`, `public/app/esg_documents.php`,
`public/app/esg_reviews.php`, `public/app/esg_logbook.php`,
`public/app/esg_profile.php`, `public/app/esg_setup.php`. Smoke: change one
risk's likelihood and save, then in phpMyAdmin
`SELECT * FROM tenant_audit ORDER BY id DESC LIMIT 5` — rows show the field,
old and new values, and your user id.

### 6c-5. Owner as a user reference (KF-20260908-08)

Import `sql/migrate_10_owner_user.sql` ONCE (owner_user_id + FK on
esg_actions and esg_objectives), then upload together: `src/helpers.php`,
`public/app/esg_actions.php`, `public/app/esg_objectives.php`,
`public/app/team.php`. Smoke: raise an action assigning a teammate from the
new select — the row shows their name; edit it and the select is
preselected; type a free-text name instead and it saves unlinked as before.
Team page (Owner): "Reassign ESG work" moves the teammate's assigned items
and reports the counts.

### 6c-4. Company identity (KF-20260908-07)

Import `sql/migrate_09_company_identity.sql` ONCE (identity columns + unique
keys on companies), then upload together: `src/CompanyIdentity.php`,
`src/bootstrap.php`, `src/Auth.php`, `src/Tenant.php`, `src/StdIntegrity.php`,
`bin/check_std_integrity.php`, `bin/reminders.php`, `sql/std_labels.lock.json`,
`public/app/company_identity.php` (new), `public/app/esg_setup.php`,
`public/padmin/tenants.php`. Then run the integrity checker — INTACT now also
covers the business-type codes. Smoke: sign in as an ACTIVATED company → the
identity prompt blocks → Skip works once → sign out/in → prompt returns
without Skip → fill it → padmin shows the type + number and the outstanding
count clears.

### 6c-3. Per-document AI questions (KF-20260908-13)

No DB migration. Upload together: `src/DocQuestions.php`, `src/bootstrap.php`,
`public/app/esg_documents.php`, `public/app/esg_setup.php`. Smoke: open any
DRAFT document — the "(Re)Generate with AI" panel shows its questions,
prefilled where answers exist; a manually created blank draft now has the
panel too (that was the gap). Generate one and confirm the answers persist
on reload and quota decremented by one.

### 6c-2. Demo seed disarmed (KF-20260908-06)

No upload — the opposite: **delete `sql/seed_demo_companies.sql` from the
server** (File Manager → addons/kualifi/sql/). The fixture now lives in the
repo at `dev-fixtures/demo-companies.NEVER-IMPORT.sql`, ships disarmed
(`@armed := 0`), refuses databases with real tenants even when armed, and
must never be uploaded (cloak `dev-fixtures/` in Dreamweaver alongside
`.git` and `_notes`). To seed a demo/staging clone: edit `@armed := 1` in a
copy, import THAT, then discard the copy.

### 6c-1. Watchdog + scheduled integrity check (KF-20260908-05)

No DB change, no cron change — the watchdog rides the existing daily
reminders cron. Upload together: `src/StdIntegrity.php`,
`bin/check_std_integrity.php`, `bin/reminders.php`. Smoke: run the checker
once by hand (must still print INTACT — same core, new plumbing); after the
next cron run, `kualifi-reminders.log` gains WATCHDOG lines, and the first
Monday brings the heartbeat email to LEADS_EMAIL. If the Monday heartbeat
ever stops arriving, the cron is dead — check cPanel Cron Jobs.

### 6c. SMTP2GO transport (KF-20260908-01) (no DB migration). Order matters only
for DNS; the code is safe first (no key = mail() exactly as before):
1. Upload `src/Mailer.php` and mirror the new SMTP2GO block of
   `config/config.php` onto the server copy (the block between the
   "SMTP2GO transactional transport" comment and the AI section).
2. Create the SMTP2GO account → Settings → Sender Domains → add
   `kualifi.com` → add the THREE CNAME records it shows into cPanel Zone
   Editor (names are account-specific: a return-path `em….kualifi.com`, a
   DKIM `s…._domainkey.kualifi.com`, a `link.kualifi.com`) → wait for green
   verification ticks. Leave open/click tracking OFF in settings.
3. Edit the existing SPF TXT on kualifi.com: ADD `include:spf.smtp2go.com`
   before `-all` and KEEP every existing mechanism (the host IP must stay —
   the mail() fallback still sends from it). Existing DKIM record and DMARC
   are unchanged.
4. Settings → API Keys → create a key with sending permission only → put it
   as ONE line in `/home/dalecarn/kualifi-smtp2go.key`, chmod 600.
5. Smoke: trigger a password-reset email; the new sent_email row must show
   transport='smtp2go' with a provider_message_id, and the mail must arrive
   with `Received: from …smtp2go…` headers. Delete the key file to test the
   fallback (row shows transport='mail'), then restore it.

### 6d. HTTPS hardening (KF-20260908-03)

No DB change. Upload `public/.htaccess` and `src/Auth.php` together, then
IMMEDIATELY test — a bad .htaccess takes the whole site down, so verify
within the same minute and be ready to re-upload the previous version:
1. `http://kualifi.com/` must 301 to `https://kualifi.com/` (check with the
   browser's network tab or `curl -I http://kualifi.com/`).
2. The HTTPS response must carry `Strict-Transport-Security: max-age=31536000`.
3. Sign in — the `kualifi_sess` cookie must show the Secure flag (browser
   dev tools → Application → Cookies).
Existing sessions survive; nobody is logged out by this change.

## 7. Email deliverability (do not skip)

Verification emails are the front door — if they land in spam, nobody can
register.

1. cPanel → **Email Deliverability** → kualifi.com → install the suggested
   **SPF** and **DKIM** records (if DNS is at the registrar, copy the records
   there).
2. Create the mailbox or forwarder for `noreply@kualifi.com` (some hosts
   refuse to send from an address that doesn't exist).
3. Send a test registration to a Gmail address and check "Show original" —
   SPF and DKIM should both say PASS.

## 8. Smoke test (end-to-end)

1. Register a test company → verification email arrives (not spam) → verify →
   sign in.
2. Take the public Readiness Assessment as a guest, then register with the
   claim link — the score should appear on the dashboard.
3. Run the 8-step setup wizard; generate at least one AI document (proves the
   key file + model work); approve it as Owner.
4. Dashboard: duties appear with dates; complete one and revert it.
5. Attach an evidence file to a requirement; download it back.
6. Print-preview the Certification Pack.
7. `/padmin/`: sign in, impersonate the test company (banner appears), stop
   impersonating, check the audit log recorded both.
8. Password reset round-trip.

## 9. Before PUBLIC launch (not needed for a private pilot)

- **SIRIM 55:2023 content licensing** — explicitly deferred; resolve before
  charging customers or marketing publicly (see `DECISIONS.md`).
- Payment gateway + pricing when billing switches on.
- A backup plan: cPanel backup schedule must cover the DB **and**
  `/home/dalecarn/public_html/addons/kualifi/uploads/` (tenant evidence lives there, not in git).
