# Business rules — magusEventsXamarin

Evidence date: 2026-10-09. Inspected revision: `master` at `607005b`.

No business-rules document existed in the source repository. Every rule below is **Inferred** from the implementation unless a comment in source states the intent. Inferred does not mean approved. Where the code looks accidental, the rule says so and marks **Requires Confirmation**.

The product name VAP is not defined in source. These rules use the label the UI uses.

## Identity and session

**BR-001 — There is no operator account.**  
The only gate is a four-digit code compiled into `OTPDialog.Button_OTP_Submit_Clicked`. It is the same for every device build of this source. A match opens the requested page. A mismatch clears the fields and shows the dialog again.  
Evidence: `OTPDialog.xaml.cs`. Provenance: **Inferred** from the comparison. The digits are omitted from this document.

**BR-002 — Mode is a device property, not a server setting.**  
`"0"` is application mode. `"1"` is VAP mode. `SelectMode` writes those strings. `EventSettings` writes the picker index, which is 0 or 1 for the two items in that order.  
On the next cold start, `"0"` opens the application scanner and any other saved value opens the VAP scanner. No saved key opens the event placeholder.  
Evidence: `SelectMode.xaml.cs`, `EventSettings.xaml`, `App.xaml.cs`.

**BR-003 — One event is active on the device.**  
NEXT sets `Active = 0` on every `Events` row with `AC > 0`, then sets `Active = 1` on the chosen `AC` (update, or insert if the update changed no row).  
`GetAccreditationID` returns the `AC` of the last row from `SELECT * FROM Events Where Active = 1`. There is no `ORDER BY`. Two active rows would make the winner depend on SQLite's row order.  
Evidence: `EventSettings.Button_next_Clicked`, `Database.GetAccreditationID`.

**BR-004 — Scanning does nothing without an active event.**  
`UserProfile` and `VehicleProfile` return immediately when `AID` is not greater than 0. `AID` is copied from `AppState` when the page is constructed. A page created before NEXT still holds 0.  
Evidence: `ApplicationScan.UserProfile`, `VAPScan.VehicleProfile`.

## People and permits stored locally

**BR-005 — A profile or permit without a barcode is not stored.**  
`Person.save_to_db` and `Vap.save_to_db` skip JSON objects whose `Barcode` is `""`. Source comments date that rule to 20190718. The import-file writer uses the same barcode check.  
Evidence: `Person.cs`, `Vap.cs`.

**BR-006 — Two person populations share `Persons`.**  
API downloads delete `AAID > 0` and upsert rows with a positive `AASID`.  
The accreditation JSON import deletes `AASID < 0` and inserts new rows with `AASID` `-1`, `-2`, … and `AAID` `'0'`. Those import rows survive the API delete because their `AAID` is not greater than 0.  
A scan lookup is by `Barcode` and `AC` only. It does not care which population the row came from.  
Evidence: `ApplicationSettings.DownloadFootprintApplications`, `Person.save_import_file_to_db`, `Person.GetRow`.

**BR-007 — Permit download replaces positive permit ids.**  
`DELETE FROM Permits WHERE PID > 0`, then page `getvapandroid` and upsert by `PID`. Objects with `PID` not greater than 0 are skipped.  
Evidence: `VAPSettings.DownloadPermits`, `Vap.save_to_db`.

## Application access decision

All of the following are **Inferred** from `ApplicationScan.UserProfile`.

**BR-008 — Unknown barcode is denied.**  
If no `Persons` row has `AASID != 0`, the screen shows Invalid / N/A and takes the denied path. A scan row is still inserted.

**BR-009 — Zone ids come from `Persons.aZones`.**  
The code strips `"`, `[`, and `]` and treats the remainder as a SQL `IN` list. That string is also passed to `Zones.GetByArray` for the numbers shown on screen.

**BR-010 — Lock narrows the zones that can grant entry.**  
`GetLockedCount` counts `Zones` where `Lock = '1'` and `Active = '1'` and `AID` is the event.  
If that count is greater than 0, candidate zones are `Lock = '1'` and `Active = '1'` and `ZID IN (person zones)`.  
If the count is 0, candidate zones are `Active = '1'` and `ZID IN (person zones)`.  
`Hidden` is not part of this filter. Hidden zones still grant access. On screen, a hidden zone contributes an icon instead of a number (`getZones`).

**BR-011 — Zone number 312 and zone type 24 force the all-access path.**  
If the person's zone string contains `312`, `FULLACCESS` is set and the candidate query is the unlocked active query even when locks exist.  
If any candidate row has `ZTID == 24`, `AllAccess` is set.  
Either flag sets the result to granted and draws the type-24 icon when that row supplied a base64 `Icon`.  
The numbers are literals. What they mean in Footprint data is **Unknown / Requires Confirmation**.

**BR-012 — Grant and deny presentation.**  
Granted: status GRANTED, background `#77B13D`, white text, `PlayGrantedAudio`.  
Denied: status DENIED, background `#BF360C`, white text, `PlayDeniedAudio`.  
Role chip: `RoleColor`, or `#00981E` when empty.  
Audio failure is ignored.

**BR-013 — The application screen rebuilds on a short timer.**  
`BUSY` starts at 5. Every two seconds it decrements. When it is 0 the page navigates to a new `ApplicationScan`, which starts the timer again. A completed scan sets `BUSY` back to 5.  
Effect: the screen reloads about every ten seconds, including while idle.  
Evidence: `ApplicationScan` constructor and `OnCompleted`. Whether the idle reload was intended is **Requires Confirmation**.

## Application scan log

**BR-014 — Every application decision appends one `ScanLog` row. VAP decisions do not.**  
Insert columns, in order: `EventID`, `ScanDate`, `ScanTime`, `ProfileID`, `ProfileApprovedZones`, `ProfileStartDate`, `ProfileEndDate`, `ProfileFullName`, `ScanCheck`, `ScanCheckValue`, `ScanFormCheck`, `ScanTimeOverrideCheck`, `ScanType`, `ScanStatus`, `ScanPrimaryZone`, `ScanLimit`, `ScanSentTag`.

Values actually bound, in order:

| Position | Column name | Value the code sends |
|---|---|---|
| 1 | EventID | active event id |
| 2 | ScanDate | `yyyy-MM-dd` |
| 3 | ScanTime | `HH:mm:ss` |
| 4 | ProfileID | `Persons.AAID` (0 when no row) |
| 5 | ProfileApprovedZones | zone numbers from the person's zone list, space-separated |
| 6 | ProfileStartDate | zone numbers of the matching active zones (or of all active zones when the match was empty) |
| 7 | ProfileEndDate | NULL |
| 8 | ProfileFullName | first name, space, surname |
| 9–12 | ScanCheck through ScanTimeOverrideCheck | NULL |
| 13 | ScanType | literal `3` |
| 14 | ScanStatus | `1` granted, `0` denied |
| 15 | ScanPrimaryZone | literal `1` |
| 16 | ScanLimit | NULL |
| 17 | ScanSentTag | `'0'` |

`ProfileStartDate` is `VARCHAR(10)` in the bundled schema. A long zone list can be truncated by SQLite column affinity rules only if the application later enforces a length. SQLite does not enforce `VARCHAR` length. The mismatch between column names and values is **Implemented**. Whether it is a defect is **Requires Confirmation**.

`ScanType` `3` is not named in source. The bundled database also contains a row with `ScanType` `1`. What the codes mean is **Unknown / Requires Confirmation**.

**BR-015 — Unsent means `ScanSentTag = 0` for the active event, three rows at a time.**  
`GetScanLog` is `SELECT * FROM ScanLog WHERE EventID = '{id}' AND ScanSentTag = '0' LIMIT 3`.  
SYNC SCANS marks those rows `ScanSentTag = 1` immediately after starting the HTTP GET, including when the later response is ignored. A failed request is not retried, because the rows no longer match the query.  
The success alert does not wait for the response.  
Evidence: `Database.GetScanLog`, `Webservices.SyncScanLog`, `ScanLog.updateScanSentTag`, `EventSettings.Button_upload_scans_Clicked`.

**BR-016 — The legacy file log is not part of the current decision.**  
The semicolon-delimited `scan_{event}.txt` write is commented out. A 30-minute timer still attempts an FTP upload of that path. Missing file and FTP errors are caught and ignored.  
Evidence: `ApplicationScan.Upload_ScanLog`, `Webservices.UploadScanLog`.

## VAP access decision

**Inferred** from `VAPScan.VehicleProfile` and `DriverBarcode_TextChanged`.

**BR-017 — A vehicle is granted only when the permit exists and the gate number matches.**  
Permit lookup is `Permits.Barcode` plus `Permits.AC`.  
The device gate is the first row of `SELECT` in `Gates.GetRow()` (no id): `Gates.Number` must equal `Permits.Gate`.  
No gate row, or a different number: `blnGateAccess` stays false, the result is denied, and the text is "VEHICLE & GATE ACCESS DENIED".  
Permit missing (`PID` not `> 0`): "VEHICLE DENIED" when the gate flag happens to be true, otherwise the gate message. A missing permit usually fails the gate comparison as well, so the gate message is the common case.  
Loading-zone filtering uses the same lock / active / `312` / `ZTID == 24` pattern as application zones, against `Loading_Zones` and `Permits.pZones`.  
`DriverScanRequired == "1"` asks for a driver scan and does not auto-reset. Any other value shows `DriverName` and `DriverSurname` when the name is non-empty and resets after five seconds. Denied resets after five seconds.

**BR-018 — A required driver must be a person whose `AAID` occurs inside `AccreditedStaff`.**  
Person lookup is the same barcode-and-event query as an application scan.  
Grant when `Vap.AccreditedStaff.Contains(Profile.AAID.ToString())`. This is a substring test. An id of `12` matches a staff list that contains `123`. Whether that is acceptable is **Requires Confirmation**.  
Failure copy: "DRIVER NOT LINKED TO PERMIT" or "DRIVER NOT FOUND". Both are denied. The page resets after seven seconds.  
The driver handler runs on any text change of `DriverBarcode`, not only when the vehicle path asked for a driver.

**BR-019 — VAP settings keep a single gate.**  
SUBMIT deletes every `Gates` row, then inserts the selected gate. Loading zones are updated or inserted. They are not deleted when missing from the new download.  
Evidence: `VAPSettings.button_submit_Clicked`.

**BR-020 — VAP settings skip the grid when the active event is not found in the download.**  
The match index starts at 1. After a match it is decremented. If there is no match it stays 0, and `PopulateGatesAndZones` does nothing because it requires `UID > 0`.  
Evidence: `VAPSettings.wb_DownloadStringCompleted`. Application settings use a 0-based index and do not have this skip.  
Whether the VAP index was meant to skip a header row is **Requires Confirmation**. The event JSON itself is a list of events, and the code then subtracts one.

## Downloads and time

**BR-021 — "Online" means the tournament URL opens.**  
`OpenRead("https://www.footprintapp.net/api/tourn/")` must succeed. The host has a `www` prefix. `AppState.Server` does not.  
Evidence: `Webservices.CheckForInternetConnection`.

**BR-022 — Accreditation import file is newer when the server timestamp is later than the local file's creation time.**  
Compared as `DateTime`. The file URL is always `https://footprintapp.net/local_data/accreditations/{AC}.json`.  
If the server field is null or empty, the client keeps the sentinel `2019-01-02` and may still treat the file as newer than a local file created at the other sentinel.  
Evidence: `ApplicationSettings.DownloadAccreStringCompleted`. The same comparison in `Webservices.CheckAccreditations` does not guard a null timestamp.

**BR-023 — Delta updates are not running.**  
`LastUpdate.txt` and `LastVAPUpdate.txt` default to the literal `2018:09:29 08:00` (colon between year and month). New stamps are `yyyy-MM-dd HH:mm`. The methods that would send those stamps are not on a timer.  
Evidence: `Webservices.CheckUpdates`, `CheckVapUpdates`. Comment in `ApplicationScan`: sync happens only from DOWNLOAD APPLICATIONS, and profile updates are off.

**BR-024 — Meal fields are stored and not enforced.**  
`TotalDays` from the event JSON is written to `MealDays`. `MealsPerDay` is copied. No scan screen reads `Food`, `IsMealZone`, `DayNumber`, or `SessionNumer`.  
Evidence: `EventSettings` insert, `Meals.cs`, commented readers in `Database.cs`.

## Device behaviour that affects the operation

**BR-025 — EXIT ends the process.**  
`Process.GetCurrentProcess().Kill()` on event settings. Unsaved picker changes are not written.  
Evidence: `EventSettings.Button_exit_Clicked_1`.

**BR-026 — SQL is built by concatenating strings.**  
Lookups and updates quote barcodes, names, and zone lists into SQL. A barcode containing a quote can change the statement. This is **Implemented**. It is not documented as a desired rule.

**BR-027 — Failures are silent unless a download label or the sync alert says otherwise.**  
Most `catch` blocks are empty. Download labels can show "Unable to download data", "Unable to download Permits", or "Unable to download Applications".

## Seed data shipped inside the Android asset

`Assets/database.sqlite3` has empty `Events`, `Persons`, `Permits`, `Zones`, `Loading_Zones`, `Gates`, and `Food`. `ScanLog` has two rows (autoincrement 2).

| LogID | EventID | ScanDate | ScanType | ScanStatus | ScanSentTag | Notes |
|---|---|---|---|---|---|---|
| 1 | 0 | 2020-08-05 | 1 | 1 | 0 | `ProfileApprovedZones` is `1,2,3,4`. `ProfileFullName` is a person's name. The name is not copied here. |
| 2 | 86 | 2020-08-05 | 3 | 0 | 1 | Empty name. Shape matches the application insert (`ScanType` 3, `ScanPrimaryZone` 1). |

`Assets/database - Copy.sqlite3` is the same length and a different hash. Nothing in `FileAccessHelper` opens it.

A first Android launch copies `database.sqlite3` into the personal folder only when that destination is missing. Later asset changes do not replace an existing device file.
