# ClockmateHQ — Deployment Runbook

## 2026-09-11 Head Office verification release

This release changes the attendance presence rule to `GPS OR office network`,
keeps the permanently printed Head Office QR unchanged, and adds a verification
audit report and settings UI. Apply only
`migrations/024_attendance_verification_upgrade.sql` before uploading the new
application files. Full configuration, verification, environment variables,
file manifest, and rollback steps are in
[`docs/attendance-verification-upgrade.md`](docs/attendance-verification-upgrade.md).

The older network-OTP enforcement notes below describe the previous release.
For this release, OTP remains an identity fallback and is not accepted as proof
of Head Office presence.

**Previous release date:** 2026-08-27
**Contents:** Location anti-cheat hardening · Organisation-network validation · SMS OTP (Frog by Wigal) · Staff PWA gold refresh

**This is the file to follow.** The two documents below are the detail and the reasoning; you do not need them to deploy.

- [docs/location-anticheat-hardening.md](docs/location-anticheat-hardening.md) — the nine geofence holes and how each was closed
- [docs/network-trust-and-otp.md](docs/network-trust-and-otp.md) — network trust, OTP design, and the gold refresh

---

## TL;DR

**This deploy is safe to ship as-is.** Every new control defaults to observe-only: `GEOFENCE_MODE=monitor`, `NETWORK_TRUST_MODE=monitor`, `FROG_SMS_ENABLED=false`. Nothing is refused and no SMS is sent until you deliberately switch them on in Step 6.

Two things must be done before enforcement can do anything at all:

1. **Warehouse coordinates are all `NULL`** — the geofence has nothing to measure against. → Step 4
2. **No trusted network exists** — nobody can be identified as on-network. → Step 5

---

## Step 1 — Upload files

**New files**

```
src/Services/GeofenceService.php
src/Services/NetworkTrustService.php
src/Services/PhoneNumber.php
src/Services/SmsService.php
src/Services/OtpService.php
migrations/021_punch_rejections.sql
migrations/022_network_trust_and_otp.sql
tests/test_geofence.php
tests/test_anticheat.php
tests/test_network_otp.php
docs/location-anticheat-hardening.md
docs/network-trust-and-otp.md
DEPLOY.md
```

**Modified files**

```
src/Controllers/AttendanceController.php
src/Controllers/LocationTrackingController.php
src/Services/AntiCheatService.php
assets/js/tap.js
public/sw.js
views/staff.php
config/env.php
scripts/run_migrations.php
```

`src/Services/LocationIntegrityService.php` is unchanged.

> **PWA cache:** `assets/css/app.css` and `assets/js/tap.js` changed. The current release uses `gcx-attendance-shell-v15` in `public/sw.js` so staff devices pick up unobstructed errors, biometric-or-SMS identity confirmation, QR/GPS receipts, and immediate activity refresh instead of retaining older cached assets.

---

## Step 2 — Run migrations

```bash
php scripts/run_migrations.php
```

Both new migrations are registered. They are additive — two new tables (`punch_rejections`, `trusted_networks`, `attendance_otps`) and three new columns on `attendance_taps`. No existing data is modified.

Confirm:

```sql
SHOW TABLES LIKE 'punch_rejections';
SHOW TABLES LIKE 'trusted_networks';
SHOW TABLES LIKE 'attendance_otps';
SHOW COLUMNS FROM attendance_taps LIKE 'network_trusted';
```

---

## Step 3 — Environment variables

Set these on the host. **Leave the modes at `monitor` for now.**

| Variable | Set to now | Purpose |
|---|---|---|
| `GEOFENCE_MODE` | `monitor` | `off` \| `monitor` \| `enforce` — refuses out-of-fence punches at `enforce` |
| `NETWORK_TRUST_MODE` | `monitor` | `off` \| `monitor` \| `enforce` — requires SMS OTP off-network at `enforce` |
| `FROG_SMS_ENABLED` | `false` | Master switch for sending SMS |
| `FROG_API_KEY` | *your key* | Frog by Wigal |
| `FROG_USERNAME` | *your username* | Frog by Wigal |
| `FROG_SENDER_ID` | *approved sender ID* | Must be pre-approved by Wigal |

Both mode variables default to `monitor`, and any unrecognised value also resolves to `monitor` — a typo can neither disable protection nor silently start rejecting.

### Security: rotate these

`config/env.php` currently ships **live Frappe credentials hardcoded as defaults**, directly beneath a comment saying secrets must never be committed:

```php
'FRAPPE_HR_API_KEY'    => getenv('FRAPPE_HR_API_KEY')    ?: '36897da2f807734',
'FRAPPE_HR_API_SECRET' => getenv('FRAPPE_HR_API_SECRET') ?: '12540e370ea86de',
```

These are in your git history. Rotate them in Frappe, move the values to environment variables, and replace the defaults with `''`. The Frog credentials are already env-only for this reason.

---

## Step 4 — Backfill warehouse coordinates

Nothing in the geofence can work until this is done. Currently **all 16 warehouses have `NULL` latitude/longitude**, Head Office included.

```sql
SELECT id, name, attendance_mode, latitude, longitude, geofence_radius_meters
FROM warehouses WHERE is_active = 1
ORDER BY (latitude IS NULL) DESC, id;
```

**Get accurate coordinates the right way:** stand at the site entrance, take a normal GPS punch in the staff PWA, then read the recorded `lat`/`lng` back out of `attendance_taps`. That is the position as this device stack actually measures it, which is what the fence compares against. A Google Maps pin can sit tens of metres off from where phones resolve.

Head Office first — it is your only `attendance_mode = 'qr'` site, 50 active staff:

```sql
UPDATE warehouses
SET latitude = 5.6037,            -- replace with real entrance coordinates
    longitude = -0.1870,
    geofence_radius_meters = 150
WHERE id = 14;
```

**Radius guidance:** cover the whole compound — car park, gate, every entrance — not just the building. 150 m is a sensible start. The effective radius is this value plus up to 75 m of GPS-accuracy allowance, so 150 behaves like up to 225 in practice. Start generous; tighten later using observed distances.

After setting coordinates for a warehouse, clear its stale learned model (it may be skewed by past force-accepted clock-ins):

```sql
DELETE FROM geo_models WHERE warehouse_id = 14;
```

---

## Step 5 — Add the office network

> **There is no Wi-Fi SSID check.** Browsers cannot read the network name — no web API exposes it. The office network is identified by its **public egress IP**, which is what a device on `T1-5th-Agencies` actually reveals. Full reasoning in the network doc.

Find the address. Easiest, from a device on the office Wi-Fi:

```bash
curl -s https://api.ipify.org
```

Better — after a day of monitor mode, use what the server actually saw:

```sql
SELECT ip, COUNT(*) AS taps, COUNT(DISTINCT user_id) AS staff
FROM attendance_taps
WHERE warehouse_id = 14
  AND server_time_utc >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 3 DAY)
GROUP BY ip ORDER BY taps DESC;
```

The address most Head Office staff share during working hours is your egress IP. Add it:

```sql
INSERT INTO trusted_networks (label, cidr, warehouse_id, is_active, note, created_at_utc, updated_at_utc)
VALUES ('T1-5th-Agencies', '102.176.94.0/24', 14, 1,
        'Head Office Wi-Fi egress', UTC_TIMESTAMP(), UTC_TIMESTAMP());
```

- Replace the CIDR with yours. A single address is fine: `'102.176.94.10/32'` or just `'102.176.94.10'`.
- `warehouse_id = 14` scopes it to Head Office; `NULL` applies everywhere.
- Add a second row for any failover link.
- **IPv6 matters** — many Ghanaian carriers hand out v6. If staff arrive over v6 and you only list a v4 block, every one of them gets challenged.

### Collect missing phone numbers

12 of 50 Head Office staff have no `mobile_number` and cannot receive an OTP. They fall to the review queue rather than being locked out, but they get no second factor.

```sql
SELECT u.id, u.username, u.full_name, w.name AS warehouse
FROM users u LEFT JOIN warehouses w ON w.id = u.primary_warehouse_id
WHERE u.is_active = 1 AND (u.mobile_number IS NULL OR u.mobile_number = '')
ORDER BY w.name, u.full_name;
```

Formats need no cleaning — `0594164436`, `+233550742064`, `233550742064` and bare `243011252` are all accepted.

---

## Step 6 — Observe, then enforce

**Do not skip the observation window.** This is the entire point of monitor mode.

### After one full working week, review

```sql
-- Geofence: what would have been refused, and to whom?
SELECT reason, COUNT(*) AS attempts, COUNT(DISTINCT user_id) AS staff,
       ROUND(AVG(distance_m)) AS avg_m, ROUND(MAX(distance_m)) AS max_m
FROM punch_rejections
WHERE blocked = 0 AND created_at_utc >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY reason ORDER BY attempts DESC;
```

```sql
-- Network: is the office actually recognised?
SELECT network_trusted, COUNT(*) AS taps
FROM attendance_taps
WHERE warehouse_id = 14
  AND server_time_utc >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY network_trusted;
```

**How to read these.** A handful of `outside_geofence` hits at 5–10 km is what cheating looks like — enforce. Dozens of staff at 150–250 m means your radius is too tight or the coordinates are slightly off — fix that first. A wave of `invalid_coordinates` on QR means devices are still running cached `tap.js` — wait for it to settle.

For network trust, `network_trusted = 1` must dominate at Head Office. If it does not, your CIDR is wrong or incomplete. **Enforcing in that state would SMS the entire office on Monday morning and burn your credit balance within the hour.**

### Test SMS to one person before enabling it broadly

```php
$sms = new App\Services\SmsService();
var_dump($sms->send('233XXXXXXXXX', 'Clockmate test message'));
// expect: ok => true, status => 'ACCEPTD'
```

`NOT_CONFIGURED` = credentials missing. `TRANSPORT` = the server cannot reach the gateway. Anything else is Frog's own text (`Insufficient balance`, `Invalid phone number format`).

### Enable, one at a time

Turn on the geofence first, watch for a few days, then network trust:

```
GEOFENCE_MODE=enforce
```

```
NETWORK_TRUST_MODE=enforce
FROG_SMS_ENABLED=true
```

---

## Rollback

Set the offending variable back to `monitor` (or `off`) and restart. Modes are read per request — no migration rollback, no code revert, no data loss.

| Symptom | Action |
|---|---|
| Staff refused at the gate | `GEOFENCE_MODE=monitor` |
| Everyone getting SMS codes | `NETWORK_TRUST_MODE=monitor` |
| SMS credit draining | `FROG_SMS_ENABLED=false` |

---

## Verification

```bash
php tests/test_geofence.php      # OK: 47 passed
php tests/test_anticheat.php     # OK: 14 passed
php tests/test_network_otp.php   # OK: 56 passed
```

All three are pure — no database, no HTTP, no SMS credit — and safe on any environment.

Then, on the live site: **take one real GPS punch and one real QR scan.** This was not verified end to end here (see below).

> Never run `tests/run_tests.php` against a live database — it executes `migrations/001_init.sql` and inserts warehouses named `T`. Warehouses 12, 15, 16 and 17 came from previous runs.

---

## What was and was not verified

**Verified:** 117 assertions across the three suites; both migrations applied cleanly; the OTP lifecycle end to end against a real database with a stubbed gateway (issue → normalise → hashed storage → wrong code rejected → correct accepted → replay refused → cooldown); DB-backed network matching including warehouse scoping; and the gold refresh screenshotted in light and dark on a 390×844 viewport (`output/gold-v2/`).

**Not verified:**

- **No live SMS was sent** through Frog — it spends credit and needs your account. Step 6 covers it.
- **No authenticated HTTP punch was driven through Apache.** This dev machine's Apache PHP is missing `pdo_mysql` (`could not find driver`), so login returns 500 there; the screenshots were taken against PHP's built-in server instead. Your production server is unaffected, but do the two manual punches above.

---

## Known limitations

1. **GPS spoofing is not fully solvable server-side.** A rooted Android with a mock-location app can feed the browser plausible coordinates. This work closes every hole where cheating took *zero* effort and forces the rest to leave evidence — impossible-travel sequences, off-network flags, rejection records.
2. **A VPN into the office presents the office IP.** Network trust is only as strong as your VPN access control.
3. **Dynamic egress IPs need maintenance.** When the ISP rotates the address, the network check starts failing for everyone. The Step 6 query surfaces this.
4. **The OTP proves phone possession, not presence.** A colleague holding your phone can read the code out. It raises effort and creates an audit trail; the geofence is what checks presence.
5. **`device_guid` is client-chosen**, so device binding and collusion detection are advisory, not proof. Fixing it properly means binding to the WebAuthn credential already in `webauthn_credentials`.
6. **QR tokens are static.** Requiring GPS on QR means a photographed code is only usable from inside the geofence, which removes most of its value — but the token itself still does not rotate. Deliberately out of scope this round.
7. **Local tables are MyISAM**, which silently ignores transactions and foreign keys. Worth confirming what production uses — `attendance_taps` on MyISAM means attendance writes have no transactional integrity.
