# Kualifi — locked decisions

Product: **Kualifi** (AI4Good) — multi-tenant SaaS preparing Malaysian companies
for SIRIM 55:2023 ESG certification. Built as "ESGReady", **rebranded to Kualifi
on 29 Jul 2026**. Commercial productization of the ESG module originally built
inside Dale Carnegie Malaysia's VoC portal; **the DC portal is not touched by
this project.**

Decisions below were asked and answered explicitly on 28–29 Jul 2026 (none
assumed). Change them deliberately, in this file, not by drive-by code edits.

## Platform & hosting
- Plain **PHP 8 + PDO + MySQL** on cPanel-style shared hosting. No framework, no
  Composer — same constraints and idioms as the DC portal it derives from.
- **Domain: `kualifi.com`** (decided 29 Jul 2026), hosted as an **addon domain
  on an existing cPanel account**.
- English-only UI for v1.

## Brand (29–30 Jul 2026)
- Product name **Kualifi** everywhere (app, emails, padmin). `APP_COMPANY`
  stays **AI4Good** — Kualifi is the product, not the company.
- **Brand package v1.3 adopted 30 Jul 2026** (`KualifiBrand/Corrected-Assets/`
  is the authority): navy/cobalt/lime/teal on warm canvas, Plus Jakarta Sans +
  Inter, pill actions, navy+lime focus ring. Landing page uses the approved
  website copy verbatim; About section carries the approved Purpose, Promise,
  Positioning statement, five-year direction and message pillars.
- Tagline: **"Making ESG readiness achievable."**
- **INTERIM RULE (in force, user decision: applies EVERYWHERE)** — no
  user-visible copy, email, or AI-generated document names SIRIM or
  SIRIM 55:2023; say "Malaysia's national ESG standard". Internal identifiers
  (standards.code `SIRIM55`, seed filename, SQL comments, negative prompt
  instructions) are exempt. Lift only when SIRIM's written permission lands
  (named-reference register is parked in the brand guidelines §8).
- Readiness never blurs into certification: "Certification Pack" renamed
  **"Assessment Pack"**; approved interim disclaimer on the landing page,
  public footer and Assessment Pack.
- **Four-tier readiness (user decision: full migration, not display-only)**:
  Foundation < 35 ≤ Building < 60 ≤ Progressing < 80 ≤ Assessment-ready
  (padmin-editable boundaries on `standards`). Enum values
  `foundation/building/progressing/assessment_ready`; live DB migrates via
  `sql/migrate_01_four_tier.sql`. Every outcome renders with
  "Kualifi methodology v1 · assessed <date>" (brand rule).
- British English, no Oxford comma, unspaced em dashes — policy for all copy.
- **Approved deviation from the brand nav spec**: the landing navigation omits
  "Resources" and "Pricing" until those pages exist; "Assessment preparation"
  is an on-page section. Revisit when pricing launches.
- **Reviewer-feedback additions (30 Jul 2026), approved extensions to the
  website copy**: three pain cards under the problem section; a metrics strip
  in About (true product facts only — no customers to cite yet; replace with
  testimonials when real ones exist); "About 5 minutes · instant result ·
  free" microcopy under assessment CTAs; public assessment asks the questions
  FIRST and contact details after (no-JS falls back to one screen); question
  pillar labels appear only in the result breakdown. Tagline feedback
  ("available" vs "achievable") noted and NOT adopted — the approved brand
  asset stands; revisit at the next brand review.
- Leads inbox: `mahmud.hisham@ai4good.com` (config).

## Tenancy
- **Single database**; every tenant-owned table carries `company_id`; every
  query filters on it via `Tenant::id()`. No page accepts a company_id from
  user input.
- **Template-copy pattern**: standard content lives in global `std_*` seed
  tables; activating a standard copies rows into the company's own editable
  tables (`Tenant::activateStandardContent()`).
- **Multi-standard schema** (`standard_id` on standards content) with only
  SIRIM 55 shipped in v1.

## Accounts & access
- **Self-registration** with email verification (single-use hashed tokens,
  48h TTL). First registrant creates the company and becomes **Owner**;
  Owners invite **Members** by email. Two roles only in v1.
- **Platform admins are a separate auth surface** (`platform_admins`, /padmin)
  that never mixes with tenant users. Support access is by **impersonation**,
  entered/exited with `audit_log` rows and a visible banner.
- Suspending a company takes effect on the next request, not next login.

## Holidays & working week (30 Jul 2026, superseding same-day national-only note)
- **Per-state model**: `companies.state` (chosen at registration, Owner-editable
  in Team; 3-letter codes, `''` = unspecified) selects which holidays apply —
  national `''` rows plus that state's rows in `holidays.state` — AND the
  weekend pattern: Kedah/Kelantan/Terengganu run Friday–Saturday
  (`EsgSchedule::FRI_SAT_STATES`), everyone else Saturday–Sunday.
- Seed (`sql/seed_holidays_my.sql`) carries the national set; state-specific
  holidays are added via padmin → Holidays as customers need them.
- Date changes never retro-shift already-generated periods (same rule as
  holiday edits); state changes affect future generations only.

## AI
- Platform-owned Anthropic key (key file outside webroot); document generation
  uses **`claude-opus-5`** (decided 29 Jul 2026 — same price as the previously
  chosen `claude-opus-4-8`, current-generation Opus; `max_tokens` 8000 with
  effort `medium` because Opus 5 thinks by default within the token cap).
- **Per-tenant monthly AI quota** (`companies.ai_quota_month`, default 20,
  padmin-adjustable), consumed and audited via `ai_usage` before each call.

## Email
- All sending through **`Mailer::send()`** — PHP `mail()` today (confirmed for
  launch, 29 Jul 2026); the wrapper is the single swap point for SMTP/provider
  later. **SPF + DKIM must be configured on kualifi.com** — see
  `docs/DEPLOY.md` step 7.

## Funnel
- Public **Readiness Assessment**: weighted No/Partially/Yes questions per
  pillar (E/S/G/MS), outcome Poor/Intermediate/Ready with padmin-editable
  thresholds on the standard; guest attempts claimable after signup;
  retakeable in-portal with trend. Support-interest → `leads` queue in padmin,
  notified to `LEADS_EMAIL`.

## Billing
- **Dormant skeleton only**: `plans` / `subscriptions` / `payments` tables
  exist, every company sits on a seeded `free` plan, **no gateway chosen** —
  deliberately deferred until payments actually launch.

## Legal / IP
- SIRIM 55:2023 is a licensed document. Reusing its clause summaries and
  guidance content in a commercial product is an IP question the user has
  **explicitly deferred** — revisit before public launch.

## Soft delete — KF-260825-0001 (21 Aug 2026)
- **"Delete" is a flag, not a destruction.** The eight tables behind the ×
  buttons (laws, aspects, stakeholders, roles, actions, logbook, objectives,
  documents) carry `deleted_at` + `deleted_by`; every handler sets them instead
  of DELETE, every list/count/report query excludes flagged rows, and
  `Esg::purgeFiles()` has no live callers — **evidence bytes and `esg_files`
  rows survive the deletion of their parent**. Rationale: a customer could
  destroy their own evidence irrecoverably in one click, in a product whose
  claim is provability; also v2-migration forward-compatibility ("they removed
  this" ≠ "this was never there").
- `deleted_by` is v1's only edit attribution — never drop it.
- Scope note: the brief listed seven handlers; implementation found and
  converted an eighth (`esg_roles.php` — missed by the original audit because
  roles carry no evidence files) plus the wizard's three internal hard-delete
  paths (`esg_setup.php` step5 roles, step6 superseded drafts, step7
  objectives). Reviews already had no delete path and are unchanged.
- Documented exceptions that deliberately DO see soft-deleted rows:
  `MAX(version)` on documents (version numbers are never reused), approve/
  archive statements (approved rows cannot be deleted), the objectives
  aspect-ownership check (an existing link to a removed risk is preserved),
  the profile significance recompute, activation's copy-once guards, and
  `bin/purge_demo_companies.php` (true purge, demo data only).
- Registers also gained `created_at`; rows from before migrate_04 stay NULL —
  **deliberately not backfilled** (NULL = "before this change"; a
  migration-date backfill would be fiction).
- Demo companies were truly purged from production BEFORE this landed
  (13→21 Aug: `bin/purge_demo_companies.php`, clone-rehearsed then executed).

## Stable codes on seeded reference data — KF-260825-0002 (21 Aug 2026)
- **Scheme (user-approved)**: laws = the official Malaysian act number
  (`MY-ACT-127` … `MY-ACT-502`; prefix leaves room for `STD-*` voluntary
  standards or state enactments later); aspects = pillar–category–topic slugs
  (`E-CONS-WATER`, `G-MKT-DATA`, …); stakeholders = `SH-*` slugs. Rejected:
  opaque sequential codes (equally stable, useless in mapping conversations).
- **A code is an identity, never a description.** Labels/categories/guidance
  may be amended freely; codes never change and are NEVER reused. A topic that
  splits gives its children new codes and retires its own.
- **Enforcement, not convention**: `sql/std_labels.lock.json` locks code→label
  per table; `bin/check_std_integrity.php` (read-only CLI) diffs the LIVE DB
  against it and exits non-zero on drift — catching manual SQL edits too.
  Amending a label legitimately = seed + manifest in the same commit, then a
  clean check run. This must stay green through the planned 18-acts content
  amendment.
- Mechanics: `code` on the three `std_*` tables (unique per standard),
  nullable `std_code` on the tenant copies, backfilled by exact label match
  (deterministic — no rename path exists), carried forward at activation.
  User-added custom rows stay NULL by design; no fuzzy matching, ever.
- Why now: the v1→v2 migration verdict ("a script, not per-customer manual
  work") rests on labels being immutable — codes make that hold once labels
  start changing. See docs/V1-MIGRATION-ANSWERS.md question 10.
- Extended 21 Aug (batch follow-up, user-requested): the manifest also locks
  **std_requirements** by clause_no (title + requirement + documented_info).
  The requirement set is seeded-only with no edit path in the product; the
  checker now enforces that absence rather than assuming it. No new columns —
  clause_no was already the stable key and tenant rows FK by id.

## Question-bank versioning — KF-260825-0003 (21 Aug 2026)
- **Weights are immutable.** The padmin editor's UPDATE no longer touches
  weight (read-only in the UI, absent from the SQL); a new question's weight
  is set once at creation. `bin/check_std_integrity.php` flags any live
  weight differing from the current snapshot — manual SQL edits included.
- **Every real bank change appends a version**: text, active, sort, an added
  question, or the outcome-band thresholds → a full JSON snapshot row in
  append-only `question_bank_versions` (with padmin attribution and an
  auto-composed change note) + `standards.question_bank_version` bump, in
  one transaction. No-op saves bump nothing.
- **Attempts record the version in force** — `assessments.bank_version`,
  stamped inside `Assessment::saveAttempt()` (funnel, retake and claimed
  guest attempts alike). Attempts from before migrate_06 stay NULL —
  deliberately not backfilled (same honesty rule as register created_at).
- The user-facing methodology stamp string ("Kualifi methodology v1 · date")
  and the four readiness bands are UNCHANGED — shared with v2; any visible
  change goes to the product owner first. The version number shows only in
  the padmin editor.
- Bands are captured inside each snapshot, so a version identifies the whole
  scoring context: wording, weights, active set, thresholds.

## Store the Assessment Pack — KF-20260908-11 (9 Sep 2026)
- Every render of esg_report.php stores a **self-contained** copy in
  `pack_snapshots` (CSS already inline; external font links stripped from
  the stored variant — system-font fallback keeps it styled indefinitely;
  the live stored-copies strip removed). **Hash-deduplicated** with the
  generated timestamp normalised: a refresh mints nothing; only content
  changes create a new stored version — so the table holds every DISTINCT
  Pack a customer could have handed over.
- Stamp set (settled): methodology version, question-bank version, standard
  edition, risk threshold in force, generated-at, generated-by. No
  requirement-set version — the catalogue is manifest-locked, not versioned.
- **Retention (approved)**: indefinite while the company exists; the Owner
  may soft-delete a stored copy ("the record that it existed is kept");
  company FK CASCADE — a full ESG position is a retention liability, so it
  dies with the company (deliberately unlike sent_email).
- Viewer: esg_pack_view.php serves the stored HTML read-only, tenant-gated.
  The "Stored copies" strip lives in the print-hidden controls area.
  migrate_13; purge tool covers the table.

## File checksums — KF-20260908-10 (9 Sep 2026)
- `esg_files.sha256` computed at upload (after the move, so the digest
  describes the bytes at rest); one-off `bin/backfill_checksums.php` fills
  pre-existing rows (idempotent; missing bytes stay NULL — honest absence).
  A hash added later can never cover files uploaded before it, which is why
  this shipped now rather than a fourth deferral.
- **Deliberately no deduplication and no UI** — the column and the backfill
  only, per the brief. The index on sha256 exists for future
  integrity/migration lookups, not uniqueness.

## Targeted tenant audit log — KF-20260908-09 (9 Sep 2026)
- **Not a full write log.** `tenant_audit` records only changes that alter
  what the Assessment Pack says: risk scores (likelihood/severity/
  significance — including bulk significance flips from a threshold change),
  objective baseline and target, document approval and archival (the
  supersession chain), requirement status (ALL six writers now flow through
  the audited `Esg::markRequirement()`), and readiness-band moves between
  attempts. Who, what, when, old value, new value; edits made under support
  impersonation carry `via_padmin_id`.
- The shape is generic (event + entity + field + old/new) so the audited
  list grows without reshaping — expected once the pilot shows what
  customers actually edit. Append-only; a failed audit write can never
  break the edit itself. Company FK CASCADE (tenant-owned record);
  purge tool covers it. migrate_11. No viewer UI yet — data first.

## Owner as a user reference — KF-20260908-08 (9 Sep 2026)
- esg_actions and esg_objectives gain nullable `owner_user_id` ALONGSIDE the
  owner string, never replacing it: the picker sets both (link + display
  name); typing a non-teammate name sets only the string, exactly as before
  — owners are frequently not users. ON DELETE SET NULL, matching
  uploaded_by/approved_by. company_requirements.owner stays text. migrate_10.
- The select wins when both controls are filled; teammate links are
  tenant-checked server-side (the FK alone is global).
- **Admin reassignment (the point of the item)**: the Owner moves ALL of one
  user's linked actions and objectives to another active user in one
  transactional operation (Team page). Only LINKED rows move — free-text
  owners name nobody reassignable; deleted rows keep their history.
- Wizard step 7's owner field stays free text (linkable later via the edit
  form). The reminder digest is deliberately untouched — targeting reminders
  at owning users is a separate decision.

## Company identity — KF-20260908-07 (9 Sep 2026)
- One company was identified by nothing but a name string. Now: a coded
  **business type** (BT-* list — codes are identity, labels display — locked
  in std_labels.lock.json and verified against CompanyIdentity::TYPES by the
  integrity checker) plus two registration numbers: the new twelve-digit SSM
  format (PRIMARY matching identifier) and the old style (secondary), each
  unique where present. **At least one required, neither individually** —
  post-2019 incorporations have only the new format. Validation deliberately
  loose (new: digits-only, twelve of them; old: uppercase, contains a digit).
- **Asked in wizard step 1** (the company-identity step), required before
  activation (belt-check in the activate handler). Registration stays
  frictionless — nothing asked at signup.
- **Activated companies missing it get a BLOCKING prompt at sign-in** —
  the gate lives in Auth::require() (review-caught: the login landing never
  calls requireActive), so every signed-in page enforces it; exempt are the
  prompt itself, the wizard and logout. Skippable at most once
  (companies.identity_skipped burns the skip; a fresh login re-arms).
  **Impersonating padmins are fully exempt** — they must never burn a
  tenant's one skip nor be trapped behind data only the tenant knows.
  Outstanding count + per-tenant status in padmin.
- migrate_09; no backfill needed — no real tenants predate this.

## Per-document AI questions — KF-20260908-13 (9 Sep 2026)
- Every draft's editor now carries a "(Re)Generate with AI" panel showing
  that kind's questions (src/DocQuestions.php: the shared base five plus 2–3
  sharpening questions per kind — approvals/gifts for anti-corruption,
  conduct/dealings for ethics, notify-who for change, channels/frequency for
  comms; policy and 'other' use the base alone). This closes the old gap
  where manually created drafts had no Generate button at all: the button
  only appeared when generated_inputs existed, and a blank draft had nothing
  to generate FROM.
- Prefill order: the row's own stored answers → the base answers from the
  company's most recent generated document (the wizard's words carry over)
  → empty. Generation stores the answers on the row (generated_inputs JSON —
  no migration), consumes quota as before, and passes the KF-20260908-04
  brand screen before storage. The inputs-less 'regenerate' handler is
  superseded and removed.
- Wizard step 6 unchanged in required fields; the per-kind extras appear as
  one optional collapsed block ("skip freely — you can answer them later on
  each document's page"). Roti Harum examples throughout.

## Demo seed disarmed — KF-20260908-06 (9 Sep 2026)
- The demo fixture's only protection was a sentence in a document. Now it is
  `dev-fixtures/demo-companies.NEVER-IMPORT.sql` — out of the `sql/` folder
  the runbook points at, named for what it is, behind a deny-all .htaccess —
  and **disarmed by construction**: `@armed := 0` ships in the file (arming
  is a deliberate edit), and even armed, one gate variable `@go` closes if
  ANY real tenant (non-.example user) exists. Companies/users inserts and
  every LAST_INSERT_ID capture are gated, so a closed gate means zero rows
  and id variables forced to 0 — no stale session id can ever attach demo
  rows to a real company, even under phpMyAdmin's ignore-errors import.
- A disarmed import ABORTS loudly at the first dependent statement (FK
  error on company_id 0) with nothing inserted — the error IS the guard
  working, and the file's header says so. Chosen over a silent no-op:
  louder is safer, and it needed no rewrite of forty statements.
- Server-side: the old `sql/seed_demo_companies.sql` must be DELETED from
  the deployed tree; `dev-fixtures/` is never uploaded.

## Watchfulness — KF-20260908-05 (8 Sep 2026)
- **The smallest thing that raises a hand**, piggybacked on the existing
  daily reminders cron (this host's only scheduler; no new cPanel config).
  Every run checks: SMTP2GO fallbacks since last check, refused sends
  (accepted=0 — mail that never left), reminder silence (>10 days with
  chaseable duties — impossible when healthy, the chase cool-off is 7),
  and reference-data drift. Findings alert LEADS_EMAIL immediately.
- **Monday heartbeat regardless** — status, integrity result, duties due,
  week's sent counts by type/transport. Its ABSENCE is the cron-death
  detector: a watchdog inside the cron cannot report the cron dying; a
  missing Monday email in a monitored inbox can.
- The integrity check now runs on a schedule: its core moved to
  src/StdIntegrity.php, shared byte-for-byte by bin/check_std_integrity.php
  (CLI, post-deploy runs) and the watchdog (daily) — the two can never
  disagree. Watchdog emails land in sent_email as msg_type='watchdog'.
- State: kualifi-watchdog.state file beside the cron logs (no migration).
  A watchdog failure can never break the digest run (guarded), and the
  watchdog runs on every cron completion including the empty-tenant path.

## Brand screen on AI output — KF-20260908-04 (8 Sep 2026)
- The interim naming rule was a prompt instruction for AI documents — a
  request, not a constraint. `CopyGuard::violations()` now screens every
  generated document body BETWEEN generation and storage (both the wizard
  and Regenerate). A hit REJECTS the draft: nothing stored, terms logged
  (error_log + ai_usage.rejected_reason), user told which terms — no silent
  rewriting, so a systematic prompt problem stays visible.
- Register taken verbatim from the Messaging Guide's never-say list:
  "SIRIM" (substring), certified/certification/certify/certifies/
  accredited/endorsed (word-bounded), "compliant with", "meets the
  requirements of". Deliberately NOT screened (owner-approved reading):
  approved, authorised, official, recognised, partner — routine
  internal-governance words in policy text, and "recognised" appears in an
  approved alternate phrase. Clause numbers and internal identifiers exempt
  (only the generated body is screened).
- **A rejected generation does not consume quota**: the ai_usage row is
  kept and flagged `rejected` (migrate_08) — cost sums stay honest, quota
  counts exclude it. Wizard rejection leaves the existing draft untouched
  (supersession now happens only after a clean screen).

## Session cookie + transport hardening — KF-20260908-03 (8 Sep 2026)
- Audit found NO HTTP→HTTPS redirect existed: http://kualifi.com served the
  site (login form included) over cleartext and issued the session cookie
  without Secure. Fixed three ways, layered:
  1. `public/.htaccess` 301-redirects every plain-HTTP request to HTTPS
     BEFORE PHP runs — no session can ever start on HTTP.
  2. `Auth::start()` sets the cookie's `secure` flag unconditionally (was:
     only when the request itself was HTTPS) — belt to the redirect's braces.
  3. HSTS `max-age=31536000` — deliberately WITHOUT includeSubDomains (the
     SMTP2GO CNAME subdomains are external services) and without preload;
     conservative and reversible. Browsers ignore HSTS on HTTP responses,
     so the header is set unconditionally.
- httponly and SameSite=Lax unchanged.

## Email transport — KF-20260908-01 (8 Sep 2026)
- **SMTP2GO** (user-chosen over Postmark: 1,000 free/month vs 100, strong
  APAC presence; deliverability good; the Mailer swap point makes revising
  this later ~30 lines + DNS). Gmail/Microsoft app-password SMTP was
  considered and rejected: wrong from-address, deprecated auth mechanism,
  account-lock failure mode, no message ids for the evidence table.
- **Transport only**: message bodies, subjects and from-address are
  byte-identical on both transports; no calling site changed.
- **API key in a one-line file ABOVE public_html** (`/home/dalecarn/
  kualifi-smtp2go.key`, chmod 600, `*.key` gitignored) — the
  ANTHROPIC_KEY_FILE pattern in a location Dreamweaver never syncs. An
  empty/missing key disables the provider entirely, so code deploys safely
  before the account exists.
- **Fallback = mail()** through the host's Exim on any provider failure
  (timeout, non-200, refusal), with the delivering transport recorded on the
  sent_email row — a fallback send is visible, never silent. No queue:
  shared hosting has no reliable async worker; the digest self-retries daily.
  Consequence: the host IP STAYS in SPF.
- **Open/click tracking off** — no pixels or rewritten links in
  security-sensitive mail; simplest DMARC alignment.

## Sent-mail record — KF-20260908-02 (8 Sep 2026)
- **Every message Mailer::send() handles leaves a row in `sent_email`**:
  company (nullable, FK SET NULL so evidence outlives the company row),
  recipient, message type (verification/invite/reset/reminder/lead), subject,
  transport ('mail' now; 'smtp2go' + provider_message_id once KF-20260908-01
  lands), accepted flag, sent_at. For a compliance product "we reminded you
  on this date" is evidence — this table is that evidence.
- **No bodies stored** — retention liability; type + subject suffice.
- A refused send is recorded too (accepted=0) — failure is visible, not
  silent. Recording is guarded: a DB hiccup costs the record, never the email.
- `company_periods.reminded_at` and the 7-day chase logic are untouched —
  this table is purely additive.

## Deferred / open (do not resolve silently)
- Payment gateway choice + pricing (billing skeleton stays dormant).
- **SIRIM content licensing for commercial use — gates PUBLIC launch.** A
  private pilot is fine; resolve before charging customers or marketing
  publicly.
