# Business rules — BuildingTomorrow

Evidence date: 2026-10-19.

Source: `master` at `0c7d5df`. Rules are taken from code. Each rule is labelled.

**Documented** — stated in `README.md`, a route comment, or a comment that describes intended behaviour.

**Implemented** — enforced by a branch, query, or write in PHP.

**Inferred** — a reasonable reading of the code that the code does not state as a rule.

No business-rules document existed in Silicon Ark or in the source tree.

## Identity and access

### BR-001 — Staff login matches email and password hash

**Implemented.** `Welcome::index` loads the user by `inputEmail` and accepts the session only when `password_verify` succeeds. The session stores `username`, `role`, and `isLoggedIn`.

### BR-002 — A signed-in user who opens `/` is sent to home

**Implemented.** If `isLoggedIn` is already true, `Welcome::index` redirects to `home`.

### BR-003 — Screens outside the exception list require a session

**Implemented.** Filter `isLoggedIn` redirects to `/` when the session flag is not true.

Exceptions (`app/Config/Filters.php`): `/`, `getLogin`, `forgot_password`, `reset_password`, `set_password`, `update_password`, `register`, `organisations`, `organisations/*`, `ussd`, `assessments`, `assessments_events_handler`, and cron paths `Cronjob/*`, `cronjob/*`, `runScheduledJobs`.

`blocked` is not in the login exception list. It is in the grant exception list. A person who is not logged in cannot open `blocked`; they are sent to `/`.

### BR-004 — Menu grants are by role and menu row

**Implemented.** `Authorization` takes the first URL segment, looks up `user_menu` by URL, and checks `user_access` for the session role and that menu id. No grant redirects to `blocked`. No menu row redirects to `/`.

`home`, `Welcome/*`, and `chatGPT/*` skip this filter, as do the public paths in BR-003.

**Inferred.** Auto-routing (`setAutoRoute(true)`) means a controller is still blocked unless its first segment exists as a menu URL. A report controller with no menu row cannot be opened by a normal staff session.

### BR-005 — The only seeded role is Developer, and it receives the seeded grants

**Implemented.** `UserRole` inserts id 1, name Developer. `UserAccess` inserts rows only for `role_id` 1.

Further roles can be created in the UI. Their grants are data.

### BR-006 — Self-registration creates a Developer

**Implemented.** `Welcome::registration` sets `inputRole` to 1. The email must be unique on `users.username`. The two password fields must match.

**Requires Confirmation.** Whether registration is an approved public action. The README lists "User Registration" as a starter feature, so the existence of the screen is **Documented**. Assigning role 1 is **Implemented** and is not explained in the README.

### BR-007 — CSRF and honeypot are not applied

**Implemented.** Both aliases exist and both are commented out of `$globals`.

## USSD enrolment

### BR-008 — The calling number is the household phone number

**Implemented.** `createNewHouseholdStudent` sets `household.phone_number` from the USSD `phoneNumber`. The log stores that number as `recipient_phone_number`.

### BR-009 — Menu depends on household and student presence

**Implemented** and **Documented** in comments in `ussd.php`.

- No household: setting `unregistered_household_menu`. The comment says the only option is enrol.
- Household and no students: setting `registered_household_no_students_menu`. The comment says enrol only, and household questions are excluded.
- Household and at least one student: setting `registered_household_registered_students_menu`. The comment lists enrol, start/continue assessment, and start/continue lessons. The code's action list also includes account management.

The words of the menu are not in git. They are settings.

### BR-010 — An open session is resumed

**Implemented.** On an empty `text`, if the latest log's `ussd_menu` does not contain `END`, the stored `action` selects enrolment, assessment, lesson, or account management.

### BR-011 — A dropped initiated assessment is marked redialled

**Implemented.** If the previous log has `assessment_initiated` set and not null, `updateAfricasTalkingRedialledCall` runs before a brand-new menu is shown. Column `call_redialled` was added in the 2024-05-01 migration.

### BR-012 — An existing household skips household questions

**Implemented.** When `getHouseholdDetails` returns a row, `student_only_questions` is set and the next question becomes `getDefaultStudentOnlyEnrolQuestion()`. Household fields collected later are unset before insert.

### BR-013 — A student first name must be unique inside the household

**Implemented.** When the previous question maps to `student.first_name` and `isUnique` is 1, a match from `getHouseholdStudentDetailsByName` repeats the question with the text "{name} has already been registered. Please type a different name."

**Inferred.** Uniqueness is per household phone, not global, because the lookup takes the calling number.

### BR-014 — Enrolment is saved only after a terminal confirmation of 1

**Implemented.** When the next question id is 0 and the last input is `1`, the session rows are mapped through question `dbTable` / `dbField` and `createNewHouseholdStudent` runs. The log status becomes `Processed` when the insert returns true. The response is `END` plus the last question text.

### BR-015 — New household and student rows are stored active

**Implemented.** `createNewHouseholdStudent` sets `active` to `1` when the field is not already in the mapped data, and sets `created_at`. After insert it writes `household.student_id` and `student.household_id`.

### BR-015a — USSD gender digits map to Male and Female

**Implemented.** When the mapped field is `student.gender`, input equal to setting `ussd_male_gender_option_key` is stored as `Male`, and input equal to setting `ussd_female_gender_option_key` is stored as `Female`. Any other gender input is stored as the raw digit.

### BR-016 — Opt-out flags exist on household and student

**Implemented** as columns `opt_out` on both tables (migrations of 2023-04-03 and 2023-04-18). Helpers `opt_out_household_from_program`, `opt_out_student_from_program`, `opt_in_household_into_program`, and `opt_in_student_into_program` perform the changes. The account-management comment says a household opt-out also opts out students linked to that household.

**Requires Confirmation.** The exact column values (0 versus 1) were not re-read in every helper. `AdminStudent` seed sets `opt_out` to `'1'` on its sample rows, which would mean the sample students start opted out if 1 means opted out. **Unknown** which numeric value means opted out until those seed rows are confirmed against `opt_out_student_from_program`.

## Assessments and lessons

### BR-017 — Lessons are blocked until the initial assessment is finished

**Implemented.** `USSDModel::lesson` returns an END message that the caller must dial again and choose option 2.

### BR-018 — A finished initial assessment cannot be started again from option 2

**Implemented.** `USSDModel::assessment` returns an END message that tells the caller to choose option 3.

### BR-019 — Lesson type controls which voice branch runs

**Implemented** strings compared in PHP: `initialassessment`, `numberrecognition`, `lesson`, `assessment`.

**Implemented** strings offered on the lesson form: `numberrecognition`, `assessment`, `enrolment`, `initial assessment`, `lesson`.

**Discrepancy.** `initial assessment` (form) is not the same string as `initialassessment` (voice handler and USSD action). **Requires Confirmation** before treating them as one type.

### BR-020 — An assessment lesson fails when the score is below the pass score

**Implemented.** In the events handler, if `total_score < pass_score` and `lessons.type` is `assessment`, the call is treated as an end-of-level failure: play `level_failed_audio` and SMS the learner with the USSD code setting.

`pass_score` is a lesson column and a form field. Levels also expose `get_level_pass_score`. Which record wins when both are set was not fully traced. **Requires Confirmation** if both are populated in production.

### BR-021 — Repeat and skip-ahead digits come from settings

**Implemented.** Digit equal to `question_repeat_key` repeats. Digit equal to `question_no_answer_proceed_key` moves on. Other digits follow the question-flow correct/incorrect links (`get_assessment_next_question_flow_details`).

### BR-022 — Question audio is chosen for the student language

**Implemented.** `get_question_audio_url($question_id, $language_id)` supplies the Play URL. Questions have a `time_limit` used as the GetDigits timeout.

### BR-023 — A completed lesson and a completed programme play different audio

**Implemented.** Settings `lesson_completed_congratulatory_audio` and `lesson_program_completed_congratulatory_audio`.

### BR-024 — Level completion compares answered lessons with non-ending questions

**Implemented.** `check_level_completed` counts `lesson_flow` questions where `ending_question != 1` and compares that count with the sum of `answer_log.is_lesson_completed` for the student. Equality means the level is complete.

**Inferred.** The comparison uses a sum of a flag against a count of questions. If `is_lesson_completed` is stored once per lesson rather than once per question, the counts will not mean the same thing. **Requires Confirmation** against real `answer_log` rows.

### BR-025 — Number-recognition failure can clear progress

**Implemented** as a function `reset_user_progress_on_number_recognition_fail($student_id)`. The events handler has a `numberrecognition` branch that repeats that assessment. The exact score that calls the reset was not isolated from the surrounding view. **Inferred** from the function name and the branch.

### BR-026 — Placement after an initial assessment stores a level Salesforce id on the student

**Implemented.** `update_student_initial_assessment_placement_level_salesforce_id`. The migration that would add several of these columns is commented out (`StudentExtraFields2`). **Requires Confirmation:** the live database has the columns even though this migration no longer creates them.

### BR-027 — One hardcoded caller number is rewritten

**Implemented.** In `assessments.php`, caller `+27214485759` is replaced with `+27721158502`. The comment says "testing".

**Requires Confirmation.** This is a production behaviour if this file is deployed as-is. It is not described as a business rule anywhere else.

## Account management

### BR-028 — Account management is a separate USSD action with its own menus

**Implemented.** Actions include `account_management`, `household_opt_out`, `student_opt_out`, `account_transfer_question1`, `account_transfer_question2`, `opt_in`, `student_opt_in`, and `update_student_*` for first name, last name, age, gender, and language.

Menus: `getAccountManagementMenu("default_account_management_menu")` and `household_opt_out_menu`, plus further setting names used deeper in the method.

### BR-029 — Transfer requires the old number and the old language

**Documented** in the comment above `account_management`: question 1 is the old cell number including country code, question 2 is the language on the old account. The comment says one wrong answer stops the transfer with a failure message. **Implemented** support is `transfer_previous_account_to_current($prev_phone_number, $current_phone_number)`. The "one wrong answer" string was not re-read in the method body. Treat the failure copy as **Documented** in the comment until confirmed in the branch.

### BR-030 — The enrolment handler still tells option 2 that account management is under maintenance

**Implemented** in `ussd_enrol_user` when the session is still in the enrolment first-menu switch. **Conflict** with BR-028, which the view calls when the log action is already `account_management`.

**Requires Confirmation.** Which path a handset hits for option 2 on a registered household.

## Salesforce

### BR-031 — Households are upserted with a fixed parent first name and a district setting

**Implemented.** External id is the local household id in `Ewaka_External_Id__c`. Mobile is `phone_number`. District is setting `SALESFORCE_EWAKA_DISTRICT`. Parent first name is the literal `Ewaka Household`. Parent last name is `household_name`.

### BR-032 — Students, levels, lessons, and progress use the same external-id pattern

**Implemented.** Objects: `Students_Updated__c`, `Level__c`, `Lesson__c`, `Level_Lesson_Relationship__c`, `Student_Level_Relationship__c`, `Student_Level_Lesson_Relationship__c`. External id field `Ewaka_External_Id__c`.

### BR-033 — A sync job runs only if it is named on an active cron row

**Implemented.** `runScheduledJobs` loads active cron jobs and calls `$salesforce->$functionName()`. The function name is data. A typo in that column will call a missing method.

The same call is available without a session at `GET cronjob/runScheduledJobs`.

**Requires Confirmation.** Network-level protection. None was found in the application.

### BR-034 — Salesforce tokens last one hour in the helper's save path

**Implemented.** `saveAccessTokenToDatabase` sets expiry to `time() + 3600`. A comment says the default is one hour. The read path uses setting `SALESFORCE_ACCESS_TOKEN_EXPIRY_TIME`.

### BR-035 — TLS certificate checks are off for Salesforce HTTP

**Implemented.** `CURLOPT_SSL_VERIFYHOST` and `CURLOPT_SSL_VERIFYPEER` are 0, with a comment that this is for testing and should be removed before live. If this revision is what production runs, the bypass is live. **Requires Confirmation.**

## Voice provider

### BR-036 — Outbound calls use a fixed Africa's Talking username

**Implemented.** `new AfricasTalking('siliAdmin', <setting africas_talking_api_key>)`. The from-number is setting `africas_talking_ugandan_number`.

**Inferred** from the setting name: the programme places calls from a Ugandan number. The country is not stated in the README.

### BR-037 — Call attempts are queued as sending-log type 2

**Implemented.** `log_call_request` inserts `sending_queue_log` with `type` 2 and subject "Africas Talking Call Request". `update_call_request` sets `processed`.

## Reports

### BR-038 — Engagement defaults to the current month and rejects an inverted range

**Implemented.** Start defaults to `Y-m-01`, end to `Y-m-t`. If start is after end, or either value is empty, the controller flashes "Please select the correct date range" and returns an empty set.

### BR-039 — Redialled and dropped calls are separate reports

**Implemented.** `getRedialledCalls` and `getDroppedCalls` are different model methods and different screens.

## Organisations API

### BR-040 — An organisation token is issued only when name and API key match

**Implemented.** `Organisations::login` looks up `name` and `api_key`. A miss returns "No user found". A hit returns a JWT with claim `name` and `exp` of now plus 6000 seconds.

### BR-041 — Later organisation calls require a bearer token signed with the library default secret

**Implemented.** `is_jwt_valid` rejects an empty token, an expired `exp`, or a bad signature. The default secret argument is the literal `secret`.

### BR-042 — Delete is refused when the id does not exist

**Implemented.** `delete` returns 404 "No Data Found with id …" when the row is missing.

## Content

### BR-043 — Lesson order is per level

**Implemented.** `reorderLessonsPerLevel` and `Lessons::updateLessonOrdering`.

### BR-044 — Audio files are deleted from S3 when the media record is deleted

**Implemented.** `MediaModel` calls `deleteObject` using `MEDIA_UPLOAD_BUCKET`.

## Conflicts to confirm

| Topic | Code says | Also says |
|---|---|---|
| Account management availability | View resumes it as a real flow | Enrolment switch says option 2 is under maintenance |
| Initial-assessment type spelling | Handler uses `initialassessment` | Form saves `initial assessment` |
| Product name | UI title Building Tomorrow | Salesforce parent name Ewaka Household |
| Student extra Salesforce columns | PHP updates them | Migration that adds them is commented out |
| Cron URL | Runs every active Salesforce job | No session check |
| Registration | README lists it as a feature | It grants Developer |
| Salesforce transport | Comment says remove TLS bypass before live | The bypass is in the file that production would deploy |
