# Business rules — fim

Evidence date: 2026-10-15. Source: `git@bitbucket.org:overdrive/fim.git` at `41df15e` (`master`).

No business-rules document existed in the source tree. Every rule below is **Inferred** or **Implemented** from code. None is a signed-off policy. Where the code is ambiguous, the rule says so.

The mailbox literal is BR-025. The database literal is BR-027. Both are copied from `fim.config.php`.

## Identity

**BR-001 — Environment name selects the snapshot table.** **Implemented.** Queries use `sysAudit_` concatenated with `$siteEnviroment`. The committed value is `qa`, so the table name is `sysAudit_qa`. The comment in config says QA, LIVE, or DEV. Nothing validates the value.

**BR-002 — Project name is a label.** **Implemented.** `$siteProjectName` (`Test_Project`) is interpolated into the mail subject only. It is not stored on the audit row.

**BR-003 — Last user on a recorded row is the environment name.** **Implemented.** `Save()` sets `strLastUser` to `$siteEnviroment`, not to a person. `Update()` of a change overwrites `strLastUser` with the literal `CHANGES`. `Ignore()` of a new row copies `strLastUser` from the environment row. `ResetMaster()` copies the environment row as-is.

## What gets recorded

**BR-004 — Excludes.** **Implemented.** `$siteWildcards`, split on `;`:

```text
.
..
%.sublime-project
%.sublime-workspace
%sess_%
%.gitignore%
%.git
%.old
%.bak
%error_log%
%.pdf
%.png
%.xls
%.xlsx
%.csv
%pdfs
/cached-files%
```

The walker tests the **basename** from `scandir` via `in_array_wildcard`:

- Exact string match, so `.` and `..` are skipped.
- A pattern with `%` at both ends matches when the name contains the inner text.
- A pattern with only a leading `%` matches when the name ends with the inner text.
- A pattern with only a trailing `%` matches when the name starts with the inner text.
- A pattern with no `%` that is not an exact match does not exclude.
- A `%` that sits only in the middle is not handled by those three branches, so the walker does not exclude it.

The compare queries append `AND {alias}.strPath NOT LIKE '{pattern}'` for every pattern, against the **stored relative path**, using SQL `LIKE`. That is a different matcher and a different string than the basename check.

`/cached-files%` cannot match a `scandir` basename, because those names do not start with `/`. It can affect only the SQL side, and only when `strPath` starts with `/cached-files`.

**Inferred** consequence: a path can be recorded and still be hidden from the report, or skipped by the walker and therefore never stored. The committed patterns that use a leading, trailing, or wrapping `%` agree for basenames. `/cached-files%` does not.

**BR-005 — Directories and files are both in scope.** **Implemented.** `is_dir` chooses type `Dir` or `File`. There is no extension allow-list beyond the exclude list. `.jpg`, `.php`, `.txt`, `.pptx`, and `.rtf` are not excluded. `.pdf`, `.png`, `.xls`, `.xlsx`, and `.csv` are excluded by suffix.

**BR-006 — Path stored on the row.** **Implemented.** If the absolute walk path contains `$siteDirectory`, that prefix is removed. Otherwise `realpath()` is taken and the same prefix removal is applied. A path outside the prefix is stored as the real path unchanged, because `str_replace` finds nothing.

**BR-007 — One environment insert per visit.** **Implemented.** `Save()` always `INSERT`s. It does not update an existing row. The environment DDL has no unique key. The master DDL has `UNIQUE (strPath, dtAudit)` but that DDL is not executed by the script.

**BR-008 — Same-day environment rows are cleared only when the pause file is created.** **Implemented.** `DELETE FROM sysAudit_{env} WHERE dtAudit = LEFT(NOW(),10)` runs only when `sesFile.txt` did not exist at the start of the request. A resumed run does not delete. A compare run does not delete.

## Hashes and permissions

**BR-009 — File identity hash.** **Implemented.** `sha1( sha1_file(realpath) + owner + permission + size + mtime )`. A content change, owner change, mode change, size change, or mtime change all change the hash. The code does not hash directories' children.

**BR-010 — Directory identity hash.** **Implemented.** `sha1(relative path + owner + permission)`. Size is still stored (`filesize` of a directory) but it is not part of the directory hash. `Compare()` forces the size cell colour to black for type `Dir`, so a size difference on a directory is not highlighted. The hash comparison can still flag the row if owner, mode, or path-derived hash differs.

**BR-011 — Modes are rewritten toward 0755 and 0644.** **Implemented.** After the insert, if the path exists and the last four permission digits are not already the target, `chmod` is called. Failure of `chmod` is not checked. The function returns `"success"` or `"failed"` and the caller ignores the return. The insert has already stored the old mode, so the next snapshot is what shows the new mode.

**Requires Confirmation:** whether changing permissions is intended monitoring behaviour.

## Baseline and findings

**BR-012 — Master is the baseline.** **Implemented.** Table `sysAudit_master`. Findings are joins to `sysAudit_{environment}`.

**BR-013 — Latest snapshot wins for changes and new rows.** **Implemented.** `Compare()` takes `MAX(dtAudit)` from the environment table and filters changes and new rows with `dtAudit = '{that value}'`. The value is whatever MySQL returns for `MAX` on that column. The insert writes `Y-m-d H:i:s`. The commented DDL declares `dtAudit` as `date`. If the live column is a date, the time portion is not part of the key. **Unknown / Requires Confirmation** which type production uses.

**BR-014 — A change is the same path and a different hash.** **Implemented.** Join condition is hash inequality and path equality. Master rows with `strHash = 'IGNORE'` are excluded. Compared display fields are owner, permission, size, mtime, and hash. Type, path, extension, and audit time are not in that highlight list. Directory size highlight is suppressed (BR-010).

**BR-015 — Missing means the master path is absent from the whole environment table.** **Implemented.** `LEFT JOIN` on path with environment `ID` null. It does not require the absence to be on the latest audit date. A path recorded on an older environment day is not missing. Master `IGNORE` rows are excluded. Wildcard SQL excludes apply.

**BR-016 — New means the latest environment path is absent from master.** **Implemented.** `RIGHT JOIN` on path with master `ID` null, environment `dtAudit` equal to the max audit value. Wildcard SQL excludes apply on the environment alias. `IGNORE` is not consulted on the environment row.

**BR-017 — `IGNORE` suppresses later change and missing hits for that master row.** **Implemented.** Both queries require `sysAudit_master.strHash <> 'IGNORE'`. Ignoring a change or a missing row sets that hash and leaves the path in master, so it also stops being "new". Ignoring a new row inserts a master row whose hash is `IGNORE` and whose other columns come from the environment row. Nothing in this repository clears `IGNORE` except a later update (which copies the live hash) or `ResetMaster` (which deletes the table).

**BR-018 — Accepting a change copies live metadata onto the master path.** **Implemented.** Sets master `dtAudit` to `Now()`, and copies owner, permission, size, hash, and mtime. Sets `strLastUser` to `CHANGES`. Does not copy extension or type. `ID=*` applies to every master path whose hash differs and whose environment `dtAudit` is the latest. The SQL does not add the wildcard `NOT LIKE` fragment in the `CHANGES` branch. The `NEW` all-rows branch does.

**BR-019 — Accepting a new path inserts it.** **Implemented.** Inserts audit time, type, path, extension, owner, permission, size, hash, mtime, last user, and `dtLastEdit` from the environment row. `ID=*` uses the same filter as the new-row report, including wildcard excludes. A single id ignores that filter and uses `sysAudit_{env}.ID = '{ID}'`.

**BR-020 — There is no accept action for missing paths.** **Implemented.** `Update` case `MISSING` breaks without SQL. The email offers Ignore, not Update, for missing rows.

**BR-021 — Ignore-all exists only for missing rows.** **Implemented.** `ID=*` under `IGNORE` + `MISSING` sets `IGNORE` on every master row with no environment path and a hash that is not already `IGNORE`. The same `*` under `IGNORE` + `CHANGES` updates `ID = '*'` and therefore matches no integer id. Under `IGNORE` + `NEW` the insert selects `ID = '*'`.

**BR-022 — Reset replaces the entire baseline with today's environment rows.** **Implemented.** `TRUNCATE TABLE sysAudit_master`, then `INSERT INTO sysAudit_master SELECT * FROM sysAudit_{env} WHERE LEFT(dtAudit,10) = CURDATE()`. Ignore flags, older master rows, and any column mismatch between the two tables are not handled. If `SELECT *` column order differs, the insert is a MySQL error. The script does not check that error.

**BR-023 — Report detail is capped at 50 rows per category.** **Implemented.** Summary counts are not capped. The mail is sent when the sum of the three counts is greater than zero, even if some of those rows are absent from the HTML because of the limit.

**BR-024 — The live row is labelled QA.** **Implemented.** The HTML hardcodes the heading `QA` for the environment row. The caption uses the real `$siteEnviroment` value. Those two labels can disagree.

## Mail and access

**BR-025 — One recipient.** **Implemented.** `fim.config.php` sets recipients and `From` to `jacques@overdrive.co.za`. Subject is `FIM: {project} {environment} Report {Y-m-d H:i:s}`. Headers include MIME-Version, a date, `Content-type: text/html; charset=iso-8859-1`, and a multipart boundary fragment. The body is the HTML string only. The extra parameter to `mail()` is an empty string.

**Requires Confirmation:** whether that mailbox is still the intended recipient. Commit history does not show another address.

**BR-026 — No authentication on actions.** **Implemented.** `Action`, `ID`, and `state` come from `$_REQUEST`. Any client who can request `fim.cron.php` can reset the master table, mark rows ignored, or insert master rows. Links open in a new tab (`target='_blank'`).

**BR-027 — Database credentials in the committed config.** **Implemented.** Host `localhost`, user `root`, password empty, database `fim`. The script does not read environment variables.

## Run control

**BR-028 — Timer and counter are alternatives.** **Implemented.** The counter branch is `else if`. With the committed flags, only the 5-second timer applies. PHP's own limit is 1200 seconds and is not the application stop condition.

**BR-029 — A finished walk is recognised on the next request.** **Implemented.** `Record()` writes `1` and a newline to `sesFile.txt` before it walks. If it finishes without a limit, that `1` remains. The next default request sees first line `1`, deletes the file, compares, and stops. It does not walk on that request.

**BR-030 — A paused walk stores flag `0` and the counter.** **Implemented.** The timer path writes `0`, a newline, then `$intGlobalCounter` with no trailing newline. The counter path writes the same flag via `$intProcessComplete` (set to `0` immediately above) and then the counter. Resume treats `$arrSession[0] == 0` as paused and uses line 2 as the last id to skip. `fgets` keeps the newline on line 1. The numeric compare is PHP's loose comparison.

**BR-031 — Resume still descends into directories while skipping saves.** **Implemented.** For a directory at or below the pause id, `Record()` recurses and does not insert. For a file, it `continue`s. The global counter increments before the skip test, including for the skipped nodes, so a stable `scandir` order is required for the id to mean the same path. `scandir` order is not sorted by this code. **Inferred** risk: a changed directory listing between requests mis-assigns the pause id.

**BR-032 — Timer stop redirects to a fixed placeholder.** **Implemented.** `http://www.redirect.to.url.com/`. It is not taken from config. Added in `41df15e`.

## Schema rules present only as comments

**BR-033 — Intended master shape.** **Implemented** as commented SQL, not as a migration runner. Columns: `ID`, `dtAudit` date, `strType` enum `File`/`Dir`, `strPath` varchar 255 utf8 binary, `strExtension` varchar 20 null, `strOwner` varchar 100, `strPermission` varchar 20, `dblSize` double, `strHash` varchar 255, `dtModify` datetime, `strLastUser` varchar 100 default `System`, `dtLastEdit` timestamp default `current_timestamp()` on update. Primary key `ID`. Unique (`strPath`, `dtAudit`). Key (`strPath`, `strHash`). Engine InnoDB, charset utf8.

The DDL name is `` `sysaudit_master` ``. Queries use `sysAudit_master`. On a case-sensitive server those are different tables.

**BR-034 — Intended environment shape.** **Implemented** as commented SQL. Same columns, hash varchar 512, no unique key, no secondary key. DDL name `` `sysaudit_{environment}` ``.

**BR-035 — The DDL would drop existing tables.** **Implemented** in the comment text: `DROP TABLE IF EXISTS` before `CREATE`. Because the query calls are commented, loading the config does not drop them.

## Rules that are not enforced

- No check that a hash is 40 hex characters. `IGNORE` is a longer-than-hash sentinel stored in the same column.
- No check that `Action` is sent by the mail recipient.
- No retry around `mail()` or `mysqli_query`.
- `PullData` is not a business rule of the running cron. The action that shares its name does nothing.
