# Business rules — CCID

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/ccid.git`, `master` at `1d3e6bf`.

Rules are taken from the code. Each rule is labelled **Inferred** when the code behaves that way and no comment or document states it as policy. Live lookup data (group names, precinct names, category lists) is not in Git.

## Access

**BR-001.** A person can sign in only when `sysUser.strEmail` matches, `sysUser.blnActive` is `1`, and the linked `sysSecurityGroup.blnActive` is `1`. Provenance: **Inferred** from `login.php`. The email match is exact as passed to `NemoDatabase::qs` (SQL equality, not a trimmed case-insensitive compare in PHP).

**BR-002.** The password check uses the latest `sysPassword` row for type `User` and that user id. The stored hash must equal SHA-256 of the stored salt plus the typed password. Provenance: **Inferred** (`NemoPassword::checkPassword`).

**BR-003.** After login the application opens the 2024 contact list, for every security group. Provenance: **Inferred**. A comment dated 2019-01-31 said group `4` should open contacts and everyone else should open search. That branch is commented out.

**BR-004.** A signed-in user may open a script only when their group has `blnView = 1` on a `sysMenuLevel2` row whose `strUrl` equals that script name. Otherwise the app shows an access-denied message. Provenance: **Inferred** (`Nemo::iniSecurity`).

**BR-005.** Menu entries shown to the user are level-2 rows with `blnMenuItem = 1` that the user's group is linked to. Provenance: **Inferred** (`_nemo.menu.cls.php`). The actual labels are data.

**BR-006.** Security group id `4` does not get a Make Inactive button on the company list. The same restriction on the 2024 contact list is commented out, so it is not applied. The older `contact.php` list still applies it. Provenance: **Inferred**. The business name of group `4` is **Unknown / Requires Confirmation**.

**BR-007.** Marking a user inactive (`blnActive = 0`) is what the user list calls delete. The row stays. Provenance: **Inferred** (`User::Delete`).

**BR-008.** Deleting a security group removes the `sysSecurityGroup` row. Provenance: **Inferred** (`SecurityGroup::Delete`). Cascades from the foreign key sketched in a comment on `user.cls.php` (`ON DELETE CASCADE` from `sysUser.refSecurityGroupID`) are **Unknown / Requires Confirmation**. That `ALTER` is a comment, not a migration that this repository runs.

**BR-009.** Forgotten-password reveals whether an email is in `sysUser`. An unknown address returns `0`. A known address returns `1` and sends mail. Provenance: **Inferred** (`login.php` script and `SendResetEmail`).

**BR-010.** A reset link is valid only while the latest password row's `dtLastEdit` is inside `PASSWORD_Reset_Interval`, and the `ResetKey` equals that row's hash. Provenance: **Inferred**. Seeded interval: `60 MINUTE`. Live value may differ.

**BR-011.** A new password is rejected in the browser when it is shorter than `PASSWORD_Strength_Check_MinLength`, lacks a lower-case letter, an upper-case letter, or a digit, or contains a restricted word. Seeded minimum is 8. Seeded restricted words are `password` and `123`. Provenance: **Inferred** (`validatePasswordJS`). The server-side reset path checks history, not this regular expression. A client that skips the script can post a weak password. Provenance: **Inferred** (`resetPassword.php` calls `checkLastPasswords` and `createPassword` only).

**BR-012.** A password that matches one of the last `PASSWORD_MaxHistory` hashes is rejected with "Your password has already been used." Seeded history length is 8. Provenance: **Inferred**.

**BR-013.** Password expiry does not run when `PASSWORD_MaxAge` is the string `false`. That is the seeded value. Provenance: **Inferred** (`checkExpiry`). Whether login calls `checkExpiry` was not found in `login.php`. Expiry is implemented on the password class and is not part of the login script inspected here.

**BR-014.** Five consecutive incorrect-password results are supposed to suspend the account and email the user. The save of `blnLogin = 0` is commented out, and the counter joins `sysLogin.strUsername` to `sysUser.strEmail` while this login page does not set `$strUsername`. Provenance: **Inferred**. Treat the on-screen suspension text as not reliable until the username column and the save are confirmed.

**BR-015.** Session lifetime is two hours (`session.gc_maxlifetime` `7200`). Provenance: **Inferred** (`system.php`). The server's own GC settings can collect sooner or later. **Unknown / Requires Confirmation** for the host.

**BR-016.** There is no captcha on login or on the reset form. Provenance: **Inferred**.

## Contacts and companies

**BR-017.** The working contact record is `tblContact2024`. The older `tblContact` screen is a separate feature. Provenance: **Inferred**.

**BR-018.** A new contact cannot be saved when another row already has the same `strName` and `strSurname`. Editing an existing contact does not repeat that check. Email is not part of the duplicate rule. Provenance: **Inferred** (`Contact::Save`).

**BR-019.** The contact list defaults to active people. Status "Inactive" and "All" are available. Provenance: **Inferred** (filter default `1`).

**BR-020.** Contact search is AND across words and OR across fields (id, title, name, surname, email, telephone, cell, company address, company name). Provenance: **Inferred**.

**BR-021.** The contact list collapses to one row per contact. Company, address, and precinct on that row are whichever joined company the query returns. Provenance: **Inferred**.

**BR-022.** Saving a contact with a company name deletes every company link for that contact and writes one link. Provenance: **Inferred**. This is stricter than the company screen, which can store many links.

**BR-023.** A company is matched by exact `strCompany`. The same name updates the existing company instead of inserting another. Provenance: **Inferred**.

**BR-024.** A company created from the contact form is stored active (`blnActive = 1`). Provenance: **Inferred**.

**BR-025.** If the posted precinct is `0`, the contact save writes precinct id `5`. Provenance: **Inferred**. The name of precinct `5` is **Unknown / Requires Confirmation**.

**BR-026.** Empty company telephone, email, emergency number, website, or address on the contact save does not overwrite those columns. A posted precinct is written even when the earlier defaulting turned `0` into `5`. Provenance: **Inferred** (the `!== ''` guards). A company name is always written when that branch runs.

**BR-027.** Saving a contact with a category deletes all categories on that company and inserts the one selected category. Provenance: **Inferred**.

**BR-028.** Saving a company from `company.php` replaces categories with every checked category, which can be more than one. Group links are not written from the company save. Provenance: **Inferred**.

**BR-029.** The same contact may be linked to the same company more than once only when the position text differs. The duplicate check is company + contact + position. Provenance: **Inferred** (`Company::AddContact`, `Contact::AddToCompany`). The contact form's own save does not use this rule; it deletes all links first.

**BR-030.** Add Contact and the contact popup both require a selected person or company and a non-empty position. Provenance: **Inferred** (browser validation and the PHP empty checks).

**BR-031.** Position on a link is free text. The positions lookup (`tblPosition`) is not consulted. Provenance: **Inferred**.

**BR-032.** Removing a person from a company deletes the link whose position text matches. It does not delete the contact or the company. Provenance: **Inferred**.

**BR-033.** Make Inactive on contacts sets `tblContact2024.blnActive = 0`. The `Delete` action on that page does the same thing. It does not call `Contact::Delete`. Provenance: **Inferred**.

**BR-034.** `Contact::Delete`, if called, hard-deletes the contact and rows in `tblContactCategory` and `tblContactGroup`. It does not delete `tblCompanyContact`. Provenance: **Inferred**. No current page action calls it.

**BR-035.** Make Inactive on companies sets `tblCompany.blnActive = 0`. The message text says "Contact(s) set to INACTIVE." Provenance: **Inferred**. Linked contacts are not changed by this statement.

**BR-036.** Contact groups are whatever boxes are checked at save time. Previous `tblContactGroup` rows for that contact are removed first. Provenance: **Inferred**.

**BR-037.** Category and group aliases used as report column names must be unique across `tblCategory.strAlias`, `tblGroup.strAlias`, and `tblField.strAlias`. The category failure message says "Group not saved" even when a category was not saved. Provenance: **Inferred**.

**BR-038.** An inactive category type is omitted from the contact form's category-type dropdown (`blnActive = 1`). The precinct dropdown on that form lists every precinct, with no active filter. The contact list precinct filter also lists every precinct. Provenance: **Inferred**.

**BR-039.** Company Add Contact only offers contacts with `blnActive = 1`. Provenance: **Inferred**.

**BR-040.** Precinct names must be unique. The check compares the posted name with every precinct, including the one being edited, so saving an unchanged precinct name is rejected. Provenance: **Inferred**.

**BR-041.** Position names in `tblPosition` must be unique. Delete sets `blnActive = 0` and the message says "Records Deleted." The same pattern is used for categories, groups, and precincts. Provenance: **Inferred**.

## Search

**BR-042.** A saved search stores `refUserID` as the user who created it. Later edits do not change that owner. Provenance: **Inferred** (`Search::Save`).

**BR-042a.** The previous-searches list defaults `frUser` to the signed-in user id, so it shows that user's searches. The same dropdown includes "All" (`-1`), which removes the user filter and lists every search. Provenance: **Inferred** (`Search::__construct`, `Search::getList`).

**BR-042b.** The search-result query dropdown lists every `tblSearch` row with `blnActive = 1`, joined to `sysUser`, with no owner filter. Provenance: **Inferred** (`SearchResult::__construct`). `Search::Delete` removes the row rather than clearing `blnActive`. No page action was found that sets `blnActive` to `0` on a search.

**BR-043.** Running from the list requires exactly one checked search. Provenance: **Inferred**.

**BR-044.** Contact-field criteria on a saved search are AND. "Not contain" excludes a non-empty criterion. "Is empty" keeps null and blank. Provenance: **Inferred**.

**BR-045.** Two or more category filters on a saved search match a company that has any one of those categories (OR). One category filter matches only that category. Provenance: **Inferred**. Commit message: "change category search filters to be ORs".

**BR-046.** A category row with sub-categories enabled and a blank sub-category matches any company category of that parent name. Provenance: **Inferred**.

**BR-047.** Group filters on a saved search are AND. A filtered group must be present on the contact. An earlier OR version is commented out. Provenance: **Inferred**.

**BR-048.** Deleting a saved search removes the search and its field rows. Provenance: **Inferred**.

## Users and settings

**BR-049.** Creating a user stores a random password with date `2010-01-01` and emails a reset link to the posted email. The user does not choose the first password on the form. Provenance: **Inferred**.

**BR-050.** Changing your own password requires the current password and a value that is not in history. The profile form does not let the user edit their security group, and the username and email controls are read-only. If the password boxes are left empty, `SaveMyProfile` returns without saving other fields. Provenance: **Inferred**.

**BR-051.** User list search matches user id, username, or email. Default status filter is All (`-1`), unlike contacts, which default to Active. Provenance: **Inferred**.

**BR-052.** Settings values can be edited. The setting key is not edited from the detail control (it is rendered as a label). Create Views executes stored SQL. Provenance: **Inferred**. That action is as powerful as the SQL stored in the database.

**BR-053.** Security-group save replaces the whole permission set for that group. It can also change the entity label, menu-item flag, and divider flag on `sysMenuLevel2`, which is shared by every group. Provenance: **Inferred**.

## Deployment and environment

**BR-054.** The database is chosen by HTTP host name. `ccid-database.co.za` is treated as `www.ccid-database.co.za`. That case is commented "LIVE PROD" and uses database `ccidddmshv_db1`. `dev.overdrive.co.za`, `ccid.php8`, and `ccid.local` use `ccid_qa`. `localhost` uses `ccidddmshv_db1` on the local server. `lamp` and `debian` use a database named `ccid`. Provenance: **Inferred** from the switch. Which of these hosts still exist is **Unknown / Requires Confirmation**.

**BR-055.** HTTP is redirected to HTTPS by `.htaccess`. Some host cases also force `SERVER_PROTOCOL` to HTTPS or HTTP so the login URL scheme matches the vhost. Provenance: **Inferred**. A mismatch was noted in comments as breaking login.

## Data handling rules visible in code

**BR-056.** `tblLoginInsert` writes the global `$strPassword` into `sysLogin.strPassword`. On the login page that global is the typed password. Provenance: **Inferred**. This stores the submitted password on a login attempt when the global is in scope. **Requires confirmation** against a real `sysLogin` row before treating historical rows as containing passwords. The column is selected by `CheckSecurityLock` and masked with asterisks only for a "Login successful" row inside that function's email table.

**BR-057.** Last-user and last-edit are stamped from the session username on contact, company, user, precinct, and security-group saves. Provenance: **Inferred**.

**BR-058.** List export is the HTML table with an Excel content type. It is not a filtered "selected rows only" export. `isPageable` is turned off first. Provenance: **Inferred**.

## Not a rule

- The constants returned by `getNewEmails` and `getNewTickets` are not a product quota. Nothing in the contact flow reads them.
- Seeded password-policy numbers are defaults written if the settings rows are inserted by the password class. They are not evidence of the values on the live database.
- Comments that name a MariaDB upgrade, PHP 8, or Hetzner describe past work. They do not by themselves prove the current host's engine version.
