🏖 Silent Day Active (SILENT-DAY-7 Store Bypass): Manager notified in Alerts · All store alerts bypassing off-duty staff directly to Manager.
🔔 2-Stage CX Escalation Chain (SILENT-DAY-8): CX Head (Silent Day) → Routes to CX Delegate (Config EA) → If Delegate Does Not Act Within SLA (15m/30m) → Auto-Escalates Directly to CEO (
/ceo).← Back to Oven Command
CONTRACT-T1-11-CAFE-PEOPLE-PRESENCE-MODULE.md
# G2 3-Legged Design Contract: Multi-Outlet Café People, Presence, Roster & Mobility (`T1-11` / `EPIC-CAFE-PEOPLE-PRESENCE-MOBILITY`)
- **Epic ID:** `T1-11` / `EPIC-CAFE-PEOPLE-PRESENCE-MOBILITY`
- **G1 Intake:** [`INTAKE-T1-11-CAFE-PEOPLE-PRESENCE-MODULE.md`](file:///Users/srinivas/workspaces/harleys/runtime/agy-eval/intake-master/OUT/INTAKE-T1-11-CAFE-PEOPLE-PRESENCE-MODULE.md) (read §0 for gaps in the brief and §9 for owner decisions `D1–D10`)
- **Feature Flags (seeded `off`):** `people_presence_radar_live`, `people_inter_outlet_loan_live` (both `authority = "admin"`)
- **Proposed write paths (new):** `app/people_presence.py`, `app/people_roster.py`, `app/people_loans.py`, `app/people_passport.py`, `app/people_presence_views.py`, `db/014_people_presence_mobility.sql`, `tests/unit/test_t1_11_people_presence.py`
- **Shared-file touch points (NOT disjoint; merge-conflict risk with Wave 1 in `round7-wt`):** `app/main.py` (route registration + the `/tasks/checkin` handler), `app/people_admin.py` (new permission actions, flag catalog entries, `list_people` enrichment), `app/admin_views.py` (`/admin/people` columns + `Login As` button), `app/views.py` (kiosk roster source). Keep each to a single registration/hook call (`register_people_presence_routes(app)`), no logic inline.
- **Migration number:** `014` per the owner's brief. `009–013` are claimed by `T2-6/T2-8`, `T1-4`, `T1-8`, `T1-9`, `T1-10`. **`010` (`T1-4`) overlaps this contract; see decision `D1`.**
- **Status:** **G2 DESIGN READY FOR OWNER READOUT. Not build-signed.** Same standard as `T1-10`: the three legs are a single-author draft. The sign-off block (§Sign-off) shows each leg's checklist as author-complete and independent review as **PENDING**. Nothing here has been independently signed.
- **Verification status of artefacts:** the SQL has **not been executed** (no PostgreSQL available in the authoring environment). It must be applied to a scratch DB twice (idempotency) at G3 before anything else.
---
## Leg 1: PRODUCT
### 1.1 Vocabulary & Invariants
| Term | Definition |
|---|---|
| **Home outlet** | `hop.people.primary_outlet_code`. Never changed by a loan. |
| **Active outlet (date)** | `staff_roster_assignments.active_outlet_code`: where the person works on `business_date`. Equals home unless an approved loan exists. |
| **Assignment** | The plan for one person on one date: `SHIFT` (template or custom window), `WEEK_OFF`, or `LEAVE`. At most one non-cancelled per person per date. |
| **Presence event** | An immutable fact (check-in, break, check-out, manager mark). The radar state is *derived* from events + assignment + clock; it is never stored. |
| **Loan** | A request to move one person's whole assignment for one business date to another outlet, inside an explicit time window. |
| **Passport** | One row per person: station certs, FoSTaC, medical fitness, ID/EPFO/ESIC status, maintenance domain/verifier authorisation. |
| **Area Manager** | = role `cluster_manager` (no `area_manager` role exists in `SEEDED_ROLES`). |
**Hard invariants (enforced in the DB where possible, in the service otherwise):**
1. A person has ≤ 1 live assignment per date (partial unique index).
2. `active_outlet_code ≠ home_outlet_code` only if `loan_id` is set (CHECK).
3. A person has ≤ 1 `REQUESTED/APPROVED` loan per date (partial unique index).
4. Presence events are append-only; corrections are new events that name the event they correct, with a reason and the corrector.
5. Nobody approves their own loan; nobody authorises themselves as verifier; nobody approves their own unscheduled check-in (CHECKs).
6. The kiosk never shows or accepts someone not on its outlet's current published roster (own + loaned-in − loaned-out), except via manager approval.
7. Crew-facing UI gains one tag and one error message; nothing else.
### 1.2 Roles & Access (new `check_access_permission` actions)
| Action | `shift_lead` | `outlet_manager` | `cluster_manager` | `city_head` / `ops_head` | `hr` | `admin` | `super_admin` | Others |
|---|---|---|---|---|---|---|---|---|
| `view_presence` | own outlet | own outlet | cluster | city / all | all | all | all | `403` |
| `edit_roster` (draft/publish, past dates need reason) | no | own outlet (future dates) | cluster (future dates) | no | all (incl. past) | all (incl. past) | all | `403` |
| `mark_attendance` (Late / Absent / clear) | own outlet, today | own outlet | cluster | no | no | all | all | `403` |
| `request_loan` | no | yes (either side) | yes | no | no | yes | yes | `403` |
| `approve_loan` | no | **home** or **receiving** outlet only, in `BOTH_OUTLET_MANAGERS` mode | yes (alone suffices, `CLUSTER_MANAGER` mode, must cover both outlets) | no | no | yes | yes | `403` |
| `view_passport_status` (chips only) | own outlet | own outlet | cluster | city / all | all | all | all | `403` |
| `view_passport_detail` / `edit_passport` | no | no | no | no | **yes** | **yes** | **yes** | `403` |
| `authorise_verifier` | no | no | no | no | yes | yes | yes | `403` |
| `login_as` (button) | no | no | no | no | no | no | **yes** (existing `T1-0`) | `403` |
Scope is resolved with `person_has_place` + `scope_type/scope_value` from `user_assignments` exactly as elsewhere. **A person with no place sees nothing.** `/admin/*` stays gated by `view_admin` (`super_admin/admin/hr`); outlet and cluster managers use the **`/manager/*` mirrors** (§2.1), a proposed addition to the owner's route list because they cannot open `/admin/*` today.
### 1.3 Presence State Machine (derived; pure function `derive_presence`)
Inputs: assignment `A` (type, resolved window start `s`, end `e`, grace `g`), *effective* events for `(person, business_date)` at `A.active_outlet_code`, `now`, `viewing_outlet`. *Effective events* = all events minus any event that a correction row points at, minus corrections with `voids = true`.
First match wins:
| # | Condition | State | Notes |
|---|---|---|---|
| 1 | no assignment and no events | not listed | |
| 2 | no assignment, has `CHECK_IN` | `PRESENT` + `unscheduled` | only reachable via manager approval |
| 3 | `A.type = WEEK_OFF` and no `CHECK_IN` | `WEEK_OFF` | |
| 4 | `A.type = LEAVE` and no `CHECK_IN` | `LEAVE` | |
| 5 | `viewing_outlet = home` and `active ≠ home` | `LOANED_OUT` | shows target outlet + window; **not** counted absent |
| 6 | last `CHECK_OUT` is after last `CHECK_IN` | `RELIEVED` | `left_early_min = max(0, e − check_out)` |
| 7 | `CHECK_IN` exists and last `BREAK_START` has no later `BREAK_END` | `ON_BREAK` | does **not** count toward station coverage |
| 8 | `CHECK_IN` exists | `PRESENT` if `check_in ≤ s+g`, else `LATE` (`late_min = check_in − s`) | `late_min` is always recorded for the ledger |
| 9 | no `CHECK_IN`; latest manager mark is `MARK_ABSENT` | `ABSENT` | |
| 10 | no `CHECK_IN`; latest manager mark is `MARK_LATE` | `LATE` (expected at `expected_at`) | stops the nudge ladder |
| 11 | no `CHECK_IN`, no mark, `now < s` | `SCHEDULED` | |
| 12 | no `CHECK_IN`, no mark, `s ≤ now ≤ s+g` | `DUE` | amber |
| 13 | no `CHECK_IN`, no mark, `s+g < now ≤ e` | `LATE` (`NOT_CHECKED_IN`) | red; nudge ladder active |
| 14 | no `CHECK_IN`, no mark, `now > e` | `ABSENT` (`NO_SHOW`, auto) | counted absent in ledger |
Overlay `LOANED_IN` is added when `A.loan_id` is set and `viewing_outlet = active`. A `CHECK_IN` always beats a plan or mark (rows 3, 4, 9, 10 yield to row 8): *presence is a fact, the roster is a plan.* A punch at an outlet that isn't `A.active_outlet_code` is **rejected at write time** and never reaches this function.
### 1.4 Loan State Machine
```
REQUESTED ──approve (per mode)──▶ APPROVED ──window ends / check-out──▶ COMPLETED
│ │ │
│ └─reject──▶ REJECTED └─cancel (before first presence event)──▶ CANCELLED (assignment reverts)
├─cancel (requester)──▶ CANCELLED
└─no decision by starts_at──▶ EXPIRED
```
- `approval_mode = CLUSTER_MANAGER` → one `cluster_manager` whose scope covers **both** outlets.
- `approval_mode = BOTH_OUTLET_MANAGERS` → one `outlet_manager` of the home outlet **and** a different one at the receiving outlet. A shift lead cannot approve.
- **Approval side effects (single transaction):** (1) re-validate guardrails and passport for the receiving outlet's station; (2) set the day's assignment `active_outlet_code = to_outlet`, `loan_id`, `home_shift_key = previous shift_key`, `shift_key = NULL`, `window_start/end` = loan window; if no assignment existed, create a `PUBLISHED` one; (3) audit; (4) telemetry `people.loan_approved`.
- **Cancel / expire before any presence event:** revert the assignment to `home` + `home_shift_key`. After a presence event exists: the loan can only complete; reverting is blocked (`409`).
- **Rejections at request time (`409` with plain-English reason):** person is `WEEK_OFF`/`LEAVE` that date; person has an active loan that date; window is outside `[date 00:00, date+1 06:00)` or > 16 h; combined-day guardrail breach; medical `EXPIRED` for a food station at the receiving outlet; `home = to`; person not active; `home_outlet_code ≠` person's `primary_outlet_code`.
- **Kiosk effect:** loaned-in person is listed on the receiving kiosk from `starts_at − early_margin_minutes` (default 30, `D10`) until `ends_at`.
### 1.5 Station Coverage Heat-Check
- Stations (controlled vocabulary, `STATIONS` constant): `ESPRESSO`, `FOH_COUNTER`, `PASTRY_FINISH`, `BOH_CONTINENTAL`, `DISPATCH`, `HOUSEKEEPING`. `FOOD_STATIONS` = all except `HOUSEKEEPING` (`D3`, `CONFIRM-LEGAL`).
- A person **covers** station `X` now iff state is `PRESENT` or `LATE`-checked-in (not `ON_BREAK`, not `RELIEVED`) **and** their passport has `X` at level `CERTIFIED`.
- `min(X)` comes from `outlet_shifts.station_min` of each shift whose window is open now. Seeds (`ESPRESSO:1`, `FOH_COUNTER:1`) are **placeholders** for Owner/Ops to set per outlet.
- **RED:** `min(X) ≥ 1` and `covering = 0` **and** (`now > earliest s+g of an assigned certified person` **or** no certified person is assigned). **AMBER:** `0 < covering < min(X)`; *or* `covering = min(X)` and one covering person's break starts within 15 min; *or* a certified assigned person is `DUE`/`SCHEDULED` and the window has opened.
- Alerts: RED persisting **10 min** raises `coverage_red` once per station per shift (`BO-ACCT-ALERT-CAP`); AMBER never nudges (board only).
### 1.6 Guardrails (placeholders; `D9`, `CONFIRM-LEGAL`)
Stored in `hop.settings` (text values; admin-editable on `/config`): `people.max_daily_minutes=540`, `people.max_weekly_minutes=2880`, `people.min_rest_gap_minutes=600`, `people.max_consecutive_days=6`, `people.week_starts_on=MON`, `people.medical_expiring_days=14`, `people.medical_missing_blocks=off`.
- **Warn on edit; block on publish** unless the publisher records an `override_reason` (stored on the assignment, audited).
- `scheduled_minutes = (e − s) − (break_end − break_start)`; split shifts sum their segments.
- **Medical rule:** `EXPIRED` ⇒ **hard block** on rostering/looping into `FOOD_STATIONS` (no manager override; only renewing the passport clears it). `MISSING` (no date on file) ⇒ **amber warning only** while `people.medical_missing_blocks = off` (default), so a fresh rollout with an empty passport table doesn't block every shift; HR flips it to `on` after back-fill. `EXPIRING` (≤ 14 d) ⇒ amber.
- **FoSTaC:** `EXPIRED/MISSING` ⇒ amber; **block** only when a shift is designated "supervisor-required" and no rostered person holds a valid `SUPERVISOR`-level cert (`D4`).
### 1.7 Dignity & Privacy
No per-person leaderboard, rank or "worst latecomers" list anywhere. A person's own history is visible to them and to managers up their `reports_to`/scope chain. Telemetry is per-outlet counts only. Passport numbers and documents: HR/Admin/Super Admin only; every detail read is audited. Selfie retention reuses the 30-day rule (key nulled, event kept). The module stores **no Aadhaar/PAN/ID numbers**: statuses and document references only.
---
## Leg 2: UI / UX
### 2.1 Routes (summary; full table in §3.2)
| Surface | Admin / HR / Super Admin | Outlet / Cluster Manager (phone, English-only) | Kiosk (crew) |
|---|---|---|---|
| People list | `/admin/people` (extended) | n/a (existing My Team) | n/a |
| Presence radar | `/admin/people/presence` | `/manager/presence` | n/a |
| Roster planner | `/admin/people/roster` | `/manager/roster` | n/a |
| Loans | `/admin/people/loan*` | card on `/manager/needs-you` + `/manager/presence` | `Loaned from NAN` tag only |
| Passport | `/admin/people/{ref}/passport` | status chips in radar/roster drawer | n/a |
### 2.2 UX Rules
1. **English-only** on `/admin*` and `/manager*` (`O-034`: 0 Devanagari, no language switcher). Only the kiosk roster may show `EN | HI`.
2. **Controls ≥ 48 × 48 px**; every radar row is one line on a 390 px phone (name · state chip · times). No raw enum codes (`LOANED_OUT`, `FOH_ASSOCIATE`) ever render: chips use plain words (*Loaned to JUB*, *Not checked in*, *On break*, *Left 15:35*).
3. **Colour is never the only signal:** each state has a text label and an icon; RED/AMBER carry the words *No espresso cover*, *Cover thin*.
4. **Truthful freshness:** `as of HH:MM:SS` on every board; > 2 min stale ⇒ banner *"Not live: showing data from 11:42"*, never silent green. Switch `off` ⇒ routes `404`, nav item hidden, kiosk unchanged.
5. **No unpersisted controls** (`A555`): every toggle writes an event/row and reads back; failure shows *Not saved: Retry*.
6. **Ground crew simplicity:** no new kiosk buttons. Wrong-outlet punch error: *"You are working at JUB today."* Loaned-in tag: *Loaned from NAN*.
7. Harley's Teal `#1e6158` for primary buttons; Pistachio/Red env banners from `T1-1` remain.
### 2.3 Screens
**A. Chain Board (`/admin/people/presence`, default tab "All outlets")**
```
┌ Presence · Fri 23 Oct · as of 07:42:10 · [Date ◂ ▸] [Shift: All ▾] ─────────────┐
│ NAN Present 6/7 · Late 1 · Off 2 Espresso ● Foh ● Pastry ● │
│ JUB Present 4/4 · Loaned in 1 Espresso ● Foh ● Pastry ▲ Cover thin│
│ BAN Scheduled 0/5 (opens 08:00) — │
│ HITECH Present 3/5 · NOT CHECKED IN 2 Espresso ■ No espresso cover │
│ GACH No roster published for today ⚠ │
│ CK-HYD Present 8/8 │
└──────────────────────────────────────────────────────────────────────────────────┘
```
Tile → outlet board. Tiles for outlets with no published roster say so (never a green "0/0").
**B. Outlet Board (rows)**
| Name · station | Shift | Check-in | Break | Check-out | State chip |
|---|---|---|---|---|---|
| Priya · Espresso `A Opening 07:00–15:30` | 07:00–15:30 | 07:04 | 11:00–11:32 | n/a | **Present** · `Pistachio Kunafa ✓` |
| Karan · FOH | 07:00–15:30 | n/a | n/a | n/a | **Not checked in** (15 min) · [Mark Late] [Mark Absent] [Find cover] |
| Meera · FOH *(Loaned from NAN)* | 14:00–22:00 | 13:52 | n/a | n/a | **Present** · Loaned in |
| Ravi · BOH | 07:00–15:30 | n/a | n/a | n/a | **Loaned to JUB** 14:00–22:00 |
Row drawer: today's timeline, week hours so far, passport chips (`Medical: Valid till 14 Mar ✓` · `FoSTaC: Expiring 9 Nov ▲` · station certs · launch badges), `Open Passport →` (HR/Admin/Super Admin), `Login As` (Super Admin only).
**C. Loan sheet** (bottom sheet on phone): Person · From → To outlet · Date · Start–End (pre-filled from the receiving shift gap) · Reason (`Rush / Sick cover / Event / Training / Other`) · Note · live guardrail line (*"Meera: 7h 55m today, 41h this week: OK"* or *"Blocked: medical expired 21 Oct"*) · **[Request loan]**. Approver sees one card: *"JUB asks for Meera (NAN) 14:00–22:00, rush"* · **[Approve] [Reject]**, with the mode shown (*"Needs Area Manager"* or *"Needs both outlet managers: NAN ✓ · JUB waiting"*).
**D. Roster grid (`…/roster?week=2026-10-26&outlet=NAN`)**: people × 7 days; cell cycles `A · B · C · Split · Off · Leave`; per-row weekly hours; per-column coverage strip (station min vs rostered certified); red cell outline on guardrail breach / medical block with the reason in the cell drawer; **[Save draft]** · **[Publish week]** · **[Export Attendance Summary (not payroll)]**. Published weeks show a lock icon; edits ask for a reason.
**E. Passport (`…/{ref}/passport`)** (`HR/Admin/Super Admin`): sections Stations · FoSTaC · Medical Fitness · ID / EPFO / ESIC status · Maintenance (domains + verifier toggle with authoriser recorded) · Launch badges (read-only, from `T1-10`). Statuses show as `Valid / Expiring / Expired / Not on file`.
**F. `/admin/people` list additions:** columns *Today* (outlet · shift · state chip), *Passport* (3 chips), action `Login As` (Super Admin only, `POST /admin/view-as`, existing `T1-0` contract).
**G. Kiosk:** "On This Shift" roster source swaps to the published-assignment projection; loaned-in people carry the tag; an unlisted person who taps their name sees the existing first-sign-in/approval flow (`/manager/approve`).
---
## Leg 3: ENGINEERING
### 3.1 Data Model: `db/014_people_presence_mobility.sql` (schema `hop`)
Conventions copied from `db/002`: `integer GENERATED BY DEFAULT AS IDENTITY` ids, `text` outlet FK to `hop.outlets(branch_code)`, `hop.people(id)` FKs, `timestamptz`, idempotent (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`).
```sql
-- db/014_people_presence_mobility.sql
-- Depends on: 002 (hop.people, hop.outlets, hop.switches, hop.settings from init.sql).
-- Idempotent and additive; safe to re-run. Does NOT create outlets.
BEGIN;
-- ─────────────────────────────────────────────────────────────────────────────
-- 1. Shift templates per outlet (Morning / Mid / Closing / Split …)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hop.outlet_shifts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
outlet_code text NOT NULL REFERENCES hop.outlets(branch_code),
shift_key text NOT NULL CHECK (shift_key ~ '^[A-Z0-9_]{2,24}$'),
display_label text NOT NULL,
start_time time NOT NULL, -- local outlet time (Asia/Kolkata in v1)
end_time time NOT NULL,
crosses_midnight boolean GENERATED ALWAYS AS (end_time <= start_time) STORED,
segments jsonb, -- split shifts: [{"start":"07:00","end":"11:00"},{"start":"16:00","end":"20:00"}]
break_start time,
break_end time,
grace_minutes integer NOT NULL DEFAULT 15 CHECK (grace_minutes BETWEEN 0 AND 60),
station_min jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"ESPRESSO":1,"FOH_COUNTER":1}
active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 100,
updated_by text,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (outlet_code, shift_key),
CHECK (start_time <> end_time),
CHECK ((break_start IS NULL) = (break_end IS NULL)),
CHECK (segments IS NULL OR jsonb_typeof(segments) = 'array'),
CHECK (jsonb_typeof(station_min) = 'object')
);
-- Seed café outlets that exist TODAY. Window times come from the owner brief;
-- break windows and station minimums are PLACEHOLDERS for Ops to set per outlet.
-- Non-café outlets (CPU / cloud kitchen) are configured via /config, not seeded.
INSERT INTO hop.outlet_shifts
(outlet_code, shift_key, display_label, start_time, end_time, break_start, break_end, station_min, active, sort_order, updated_by)
SELECT o.branch_code, t.shift_key, t.display_label, t.st::time, t.et::time,
t.bs::time, t.be::time, t.sm::jsonb, t.active, t.ord, 'seed:014'
FROM hop.outlets o
CROSS JOIN (VALUES
('MORNING', 'Shift A · Opening', '07:00', '15:30', '11:00', '11:30', '{"ESPRESSO":1,"FOH_COUNTER":1}', true, 10),
('MID', 'Shift B · Mid / Peak', '11:00', '19:30', '15:00', '15:30', '{"ESPRESSO":1,"FOH_COUNTER":1}', true, 20),
('CLOSING', 'Shift C · Closing', '14:30', '23:00', '18:30', '19:00', '{"ESPRESSO":1,"FOH_COUNTER":1}', true, 30),
('SPLIT', 'Split (configure per outlet)','07:00', '20:00', NULL, NULL, '{}', false, 40)
) AS t(shift_key, display_label, st, et, bs, be, sm, active, ord)
WHERE o.outlet_type = 'cafe'
ON CONFLICT (outlet_code, shift_key) DO NOTHING;
-- ─────────────────────────────────────────────────────────────────────────────
-- 2. Loans (created before assignments, which reference them)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hop.staff_inter_outlet_loans (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
person_id integer NOT NULL REFERENCES hop.people(id),
home_outlet_code text NOT NULL REFERENCES hop.outlets(branch_code),
to_outlet_code text NOT NULL REFERENCES hop.outlets(branch_code),
loan_date date NOT NULL, -- business date of the window start
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
station text,
reason text NOT NULL CHECK (reason IN ('RUSH','SICK_COVER','EVENT','TRAINING','OTHER')),
reason_note text,
status text NOT NULL DEFAULT 'REQUESTED'
CHECK (status IN ('REQUESTED','APPROVED','REJECTED','CANCELLED','EXPIRED','COMPLETED')),
approval_mode text CHECK (approval_mode IN ('CLUSTER_MANAGER','BOTH_OUTLET_MANAGERS')),
requested_by integer NOT NULL REFERENCES hop.people(id),
requested_at timestamptz NOT NULL DEFAULT now(),
cluster_approver_id integer REFERENCES hop.people(id),
home_mgr_approver_id integer REFERENCES hop.people(id),
to_mgr_approver_id integer REFERENCES hop.people(id),
decided_at timestamptz,
decision_note text,
cancelled_by integer REFERENCES hop.people(id),
early_margin_minutes integer NOT NULL DEFAULT 30 CHECK (early_margin_minutes BETWEEN 0 AND 120),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (home_outlet_code <> to_outlet_code),
CHECK (ends_at > starts_at AND ends_at - starts_at <= interval '16 hours'),
-- nobody approves a loan of themselves
CHECK (cluster_approver_id IS DISTINCT FROM person_id),
CHECK (home_mgr_approver_id IS DISTINCT FROM person_id),
CHECK (to_mgr_approver_id IS DISTINCT FROM person_id),
-- APPROVED needs a complete approval for its mode
CHECK (
status <> 'APPROVED'
OR (approval_mode = 'CLUSTER_MANAGER' AND cluster_approver_id IS NOT NULL)
OR (approval_mode = 'BOTH_OUTLET_MANAGERS'
AND home_mgr_approver_id IS NOT NULL
AND to_mgr_approver_id IS NOT NULL
AND home_mgr_approver_id <> to_mgr_approver_id)
)
);
-- one live loan per person per date
CREATE UNIQUE INDEX IF NOT EXISTS uq_loans_one_live_per_day
ON hop.staff_inter_outlet_loans (person_id, loan_date)
WHERE status IN ('REQUESTED','APPROVED');
CREATE INDEX IF NOT EXISTS idx_loans_to_outlet_date ON hop.staff_inter_outlet_loans (to_outlet_code, loan_date);
CREATE INDEX IF NOT EXISTS idx_loans_home_outlet_date ON hop.staff_inter_outlet_loans (home_outlet_code, loan_date);
CREATE INDEX IF NOT EXISTS idx_loans_pending ON hop.staff_inter_outlet_loans (status, starts_at) WHERE status = 'REQUESTED';
-- ─────────────────────────────────────────────────────────────────────────────
-- 3. Roster assignments (the PLAN)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hop.staff_roster_assignments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_date date NOT NULL,
person_id integer NOT NULL REFERENCES hop.people(id),
home_outlet_code text NOT NULL REFERENCES hop.outlets(branch_code),
active_outlet_code text NOT NULL REFERENCES hop.outlets(branch_code),
assignment_type text NOT NULL CHECK (assignment_type IN ('SHIFT','WEEK_OFF','LEAVE')),
leave_kind text CHECK (leave_kind IN ('SICK','CASUAL','PAID','UNPAID','OTHER')),
shift_key text, -- template at active outlet; NULL for custom window / loan
home_shift_key text, -- remembered so a cancelled loan can revert
window_start time, -- override / custom window (loans)
window_end time,
station text CHECK (station IS NULL OR station IN
('ESPRESSO','FOH_COUNTER','PASTRY_FINISH','BOH_CONTINENTAL','DISPATCH','HOUSEKEEPING')),
loan_id integer REFERENCES hop.staff_inter_outlet_loans(id),
status text NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT','PUBLISHED','CANCELLED')),
published_at timestamptz,
published_by integer REFERENCES hop.people(id),
scheduled_minutes integer NOT NULL DEFAULT 0 CHECK (scheduled_minutes >= 0),
override_reason text, -- guardrail override recorded at publish
created_by text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (active_outlet_code, shift_key)
REFERENCES hop.outlet_shifts (outlet_code, shift_key), -- MATCH SIMPLE: skipped when shift_key IS NULL
CHECK (active_outlet_code = home_outlet_code OR loan_id IS NOT NULL),
CHECK (assignment_type <> 'SHIFT'
OR shift_key IS NOT NULL
OR (window_start IS NOT NULL AND window_end IS NOT NULL)),
CHECK ((window_start IS NULL) = (window_end IS NULL)),
CHECK ((assignment_type = 'LEAVE') = (leave_kind IS NOT NULL)),
CHECK (assignment_type = 'SHIFT' OR (shift_key IS NULL AND window_start IS NULL AND station IS NULL AND loan_id IS NULL)),
CHECK (status <> 'PUBLISHED' OR (published_at IS NOT NULL AND published_by IS NOT NULL))
);
-- one live assignment per person per date
CREATE UNIQUE INDEX IF NOT EXISTS uq_roster_one_live_per_day
ON hop.staff_roster_assignments (person_id, business_date)
WHERE status <> 'CANCELLED';
CREATE INDEX IF NOT EXISTS idx_roster_outlet_date ON hop.staff_roster_assignments (active_outlet_code, business_date) WHERE status = 'PUBLISHED';
CREATE INDEX IF NOT EXISTS idx_roster_home_date ON hop.staff_roster_assignments (home_outlet_code, business_date);
-- ─────────────────────────────────────────────────────────────────────────────
-- 4. Presence events (the FACT): append-only
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hop.staff_presence_events (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_date date NOT NULL,
person_id integer NOT NULL REFERENCES hop.people(id),
outlet_code text NOT NULL REFERENCES hop.outlets(branch_code), -- where the punch happened
assignment_id integer REFERENCES hop.staff_roster_assignments(id),
loan_id integer REFERENCES hop.staff_inter_outlet_loans(id),
event_type text NOT NULL CHECK (event_type IN
('CHECK_IN','BREAK_START','BREAK_END','CHECK_OUT','MID_SHIFT_OK','STEPPED_OUT',
'MARK_LATE','MARK_ABSENT','CLEAR_MARK')),
occurred_at timestamptz NOT NULL DEFAULT now(), -- SERVER time, authoritative
client_ts timestamptz, -- kiosk clock, advisory
clock_suspect boolean NOT NULL DEFAULT false, -- |client_ts − occurred_at| > 5 min
source text NOT NULL CHECK (source IN ('KIOSK','MANAGER','SYSTEM','IMPORT')),
unscheduled boolean NOT NULL DEFAULT false,
approved_by integer REFERENCES hop.people(id), -- manager approval for unscheduled check-in
recorded_by integer REFERENCES hop.people(id), -- who typed it (manager marks / corrections)
expected_at timestamptz, -- MARK_LATE: when they are expected
is_correction boolean NOT NULL DEFAULT false,
corrects_event_id bigint REFERENCES hop.staff_presence_events(id),
voids boolean NOT NULL DEFAULT false, -- correction that cancels the target event
note text,
selfie_s3_key text, -- nulled after 30 days; row kept
idempotency_key text UNIQUE, -- kiosk retry safety
CHECK (NOT is_correction OR (corrects_event_id IS NOT NULL AND note IS NOT NULL AND recorded_by IS NOT NULL)),
CHECK (is_correction OR (corrects_event_id IS NULL AND NOT voids)),
CHECK (source <> 'MANAGER' OR recorded_by IS NOT NULL),
CHECK (event_type NOT IN ('MARK_LATE','MARK_ABSENT','CLEAR_MARK') OR source = 'MANAGER'),
CHECK (NOT unscheduled OR approved_by IS NOT NULL),
CHECK (approved_by IS DISTINCT FROM person_id)
);
CREATE INDEX IF NOT EXISTS idx_presence_outlet_day ON hop.staff_presence_events (outlet_code, business_date, occurred_at);
CREATE INDEX IF NOT EXISTS idx_presence_person_day ON hop.staff_presence_events (person_id, business_date, occurred_at);
CREATE INDEX IF NOT EXISTS idx_presence_selfie_age ON hop.staff_presence_events (occurred_at) WHERE selfie_s3_key IS NOT NULL;
-- Append-only guard: no DELETE; UPDATE allowed ONLY to null out selfie_s3_key (retention purge).
CREATE OR REPLACE FUNCTION hop.presence_events_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'hop.staff_presence_events is append-only (DELETE blocked)';
END IF;
IF NEW.selfie_s3_key IS NOT NULL
OR (to_jsonb(NEW) - 'selfie_s3_key') IS DISTINCT FROM (to_jsonb(OLD) - 'selfie_s3_key') THEN
RAISE EXCEPTION 'hop.staff_presence_events is append-only (only selfie_s3_key may be nulled)';
END IF;
RETURN NEW;
END $$;
DROP TRIGGER IF EXISTS trg_presence_events_guard ON hop.staff_presence_events;
CREATE TRIGGER trg_presence_events_guard
BEFORE UPDATE OR DELETE ON hop.staff_presence_events
FOR EACH ROW EXECUTE FUNCTION hop.presence_events_guard();
-- ─────────────────────────────────────────────────────────────────────────────
-- 5. Skill passport (one row per person). NO ID NUMBERS stored: status + doc refs only.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS hop.staff_skill_passports (
person_id integer PRIMARY KEY REFERENCES hop.people(id),
station_certs jsonb NOT NULL DEFAULT '[]'::jsonb,
-- [{"station":"ESPRESSO","level":"CERTIFIED","certified_on":"2026-09-01","certified_by":112}]
fostac_level text CHECK (fostac_level IN ('HANDLER','SUPERVISOR')),
fostac_cert_ref text,
fostac_valid_until date,
medical_valid_until date,
medical_doc_s3_key text,
govt_id_status text NOT NULL DEFAULT 'PENDING' CHECK (govt_id_status IN ('PENDING','VERIFIED')),
epfo_status text NOT NULL DEFAULT 'PENDING' CHECK (epfo_status IN ('NOT_APPLICABLE','PENDING','ENROLLED')),
esic_status text NOT NULL DEFAULT 'PENDING' CHECK (esic_status IN ('NOT_APPLICABLE','PENDING','ENROLLED')),
maintenance_domains text[] NOT NULL DEFAULT '{}'
CHECK (maintenance_domains <@ ARRAY['IT','CIVIL','MACHINERY']::text[]),
is_maint_verifier boolean NOT NULL DEFAULT false,
verifier_authorised_by integer REFERENCES hop.people(id),
verifier_authorised_at timestamptz,
updated_by text,
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (jsonb_typeof(station_certs) = 'array'),
CHECK (NOT is_maint_verifier OR (verifier_authorised_by IS NOT NULL AND verifier_authorised_at IS NOT NULL)),
CHECK (verifier_authorised_by IS DISTINCT FROM person_id) -- no self-authorisation
);
-- Medical status as text so MISSING (unknown) is never confused with EXPIRED (known bad).
CREATE OR REPLACE FUNCTION hop.fn_medical_status(p_person_id integer, p_on date, p_warn_days integer DEFAULT 14)
RETURNS text LANGUAGE sql STABLE AS $$
SELECT CASE
WHEN sp.medical_valid_until IS NULL THEN 'MISSING'
WHEN sp.medical_valid_until < p_on THEN 'EXPIRED'
WHEN sp.medical_valid_until <= p_on + p_warn_days THEN 'EXPIRING'
ELSE 'VALID'
END
FROM (SELECT 1) one
LEFT JOIN hop.staff_skill_passports sp ON sp.person_id = p_person_id
$$;
-- ─────────────────────────────────────────────────────────────────────────────
-- 6. Kiosk roster projection: "On This Shift" (own + loaned-in; loaned-out already moved away)
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW hop.v_outlet_shift_roster AS
SELECT a.id AS assignment_id,
a.business_date,
a.active_outlet_code AS outlet_code,
a.person_id,
a.home_outlet_code,
(a.loan_id IS NOT NULL) AS is_loaned_in,
COALESCE(a.shift_key, 'CUSTOM') AS shift_key,
COALESCE(a.window_start, s.start_time) AS window_start,
COALESCE(a.window_end, s.end_time) AS window_end,
COALESCE(s.crosses_midnight,
COALESCE(a.window_end, s.end_time) <= COALESCE(a.window_start, s.start_time)) AS crosses_midnight,
COALESCE(s.grace_minutes, 15) AS grace_minutes,
COALESCE(l.early_margin_minutes, 0) AS early_margin_minutes,
a.station
FROM hop.staff_roster_assignments a
LEFT JOIN hop.outlet_shifts s
ON s.outlet_code = a.active_outlet_code AND s.shift_key = a.shift_key
LEFT JOIN hop.staff_inter_outlet_loans l ON l.id = a.loan_id
WHERE a.status = 'PUBLISHED'
AND a.assignment_type = 'SHIFT'
AND (a.loan_id IS NULL OR l.status IN ('APPROVED','COMPLETED'));
-- ─────────────────────────────────────────────────────────────────────────────
-- 7. Feature flags (default off) + guardrail settings (placeholders)
-- ─────────────────────────────────────────────────────────────────────────────
INSERT INTO hop.switches (key, value, updated_by) VALUES
('people_presence_radar_live', 'off', 'seed:014'),
('people_inter_outlet_loan_live','off', 'seed:014')
ON CONFLICT (key) DO NOTHING;
INSERT INTO hop.settings (key, value) VALUES
('people.max_daily_minutes', '540'),
('people.max_weekly_minutes', '2880'),
('people.min_rest_gap_minutes', '600'),
('people.max_consecutive_days', '6'),
('people.week_starts_on', 'MON'),
('people.medical_expiring_days', '14'),
('people.medical_missing_blocks', 'off')
ON CONFLICT (key) DO NOTHING;
COMMIT;
```
**Notes for the Eng reviewer:**
- `hop.staff_roster_assignments.person_id` → `hop.people(id)` (not `hop.staff`): the legacy `hop.staff` table has no outlet column and is a pilot artefact; `hop.shift_allocations` (`staff_id`, `DAY`/`EVENING`) is kept as a **derived projection** while the flag is on (`D2`). Mapping `staff_code` ↔ `people.id` goes through `person_to_staff_dict`/`find_person`.
- Statement order matters: loans → assignments (FK to loans) → events (FK to both). Run migration in one transaction.
- No `btree_gist`/extension dependency: the one-loan-per-day rule uses a partial unique index.
- `hop.audit_log.category` already allows `'people'`; **no change** to that CHECK is needed.
- **Backfill (only if `010` shipped first):** `INSERT INTO staff_presence_events (…MARK_LATE/MARK_ABSENT…) SELECT … FROM hop.shift_attendance_flags` (`on_time/week_off` map to nothing / `WEEK_OFF` assignments); then mark `010`'s table read-only. Deferred to the `D1` decision.
### 3.2 Routes
Conventions: HTML routes follow the existing form-POST → `303` pattern; each list/board has a `?format=json` variant (same service call) used by the 30 s poll. Every route: flag check first (`off` ⇒ `404`), then session/actor, then permission action (`403` with reason), then service. `{ref}` = anything `find_person` resolves (`person_code` `H-000123`, `staff_code`, `hr_emp_no`, numeric id); **never assume `staff_code` is set** (the CSV importer leaves it `None` for most people).
| # | Method · Path | Flag | Permission | Request | Success | Errors |
|---|---|---|---|---|---|---|
| 1 | `GET /admin/people` *(existing; extended)* | none (columns degrade to `-` if flags off) | `view_admin` | existing filters + `?on=YYYY-MM-DD` | adds *Today* + *Passport* columns, `Login As` for Super Admin | existing |
| 2 | `GET /admin/people/presence` | `people_presence_radar_live` | `view_presence` | `?outlet=ALL\|NAN…&date=&shift=&format=` | chain board / outlet board + `as_of` | `404` flag off · `403` · `400` bad date |
| 3 | `GET /manager/presence` | same | `view_presence` (own outlet/cluster) | same | phone mirror of #2 scoped to actor | same |
| 4 | `POST /admin/people/loan` | `people_inter_outlet_loan_live` **and** radar flag | `request_loan` | `person_ref, to_outlet, loan_date, start, end, reason, note?, station?` (`approval_mode` derived from requester role unless Admin forces one) | `303` to presence + creates `REQUESTED`; `201` JSON `{loan_id, status, needs: [...]}` | `409` per §1.4 rejection reasons · `403` · `404` person/outlet |
| 5 | `POST /admin/people/loan/{loan_id}/decision` | loan flag | `approve_loan` | `decision=approve\|reject`, `note?` | `APPROVED` (side effects §1.4) or `REJECTED`; partial approval in `BOTH` mode stays `REQUESTED` with `needs` | `409` already decided / self-approval / guardrail re-check failed · `403` · `404` |
| 6 | `POST /admin/people/loan/{loan_id}/cancel` | loan flag | requester or approver or Admin | `note?` | `CANCELLED`, assignment reverts | `409` once a presence event exists |
| 7 | `GET /admin/people/roster` · `GET /manager/roster` | radar flag | `view_presence` | `?week=YYYY-MM-DD(Mon)&outlet=` | roster grid + guardrail + coverage strips | `404` · `403` |
| 8 | `POST /admin/people/roster-assign` | radar flag | `edit_roster` | JSON/form: `[{person_ref, date, type: SHIFT\|WEEK_OFF\|LEAVE, shift_key?, station?, leave_kind?}]` (1..N cells); `publish=true` optional | `200` `{saved: n, warnings: [...], blocked: [...]}` (draft); publishing returns `409` listing blockers unless `override_reason` given | `403` · `409` medical `EXPIRED` for food station · `409` published past date w/o Admin/HR · `400` bad shift_key |
| 9 | `POST /admin/people/roster/publish` | radar flag | `edit_roster` | `outlet, week, override_reason?` | assignments → `PUBLISHED`, audit, telemetry | `409` blockers · `403` |
| 10 | `GET /admin/people/roster/export.csv` | radar flag | `edit_roster` | `outlet, from, to` | CSV *Attendance Summary (NOT payroll)*: person_code, name, date, outlet, shift, first_in, last_out, break_min, worked_min, late_min, state | `403` |
| 11 | `GET /admin/people/{ref}/passport` | radar flag | `view_passport_detail` (HR/Admin/Super Admin); managers get chips from #2/#7 only | n/a | passport page (§2.3E) + audit read | `404` · `403` |
| 12 | `POST /admin/people/{ref}/passport` | radar flag | `edit_passport` (+ `authorise_verifier` for verifier toggle) | fields of §3.1 table 5 | `303`; audit before/after | `400` · `403` · `409` self-authorise |
| 13 | `POST /manager/presence/mark` | radar flag | `mark_attendance` | `person_ref, mark=late\|absent\|clear, expected_at?` | event row `MARK_*` | `403` · `409` already checked in (mark ignored) |
| 14 | `POST /tasks/checkin` *(existing handler; extended)* | radar flag (else legacy path unchanged) | kiosk session | existing selfie + 4-digit PIN + `idempotency_key` | writes `CHECK_IN`/`BREAK_*`/`CHECK_OUT` event; updates legacy `hop.sign_ins` for compatibility | `409` *"You are working at JUB today."* (wrong outlet, no event) · unlisted ⇒ existing approval flow (`/manager/approve`) |
| 15 | `POST /admin/view-as` *(existing `T1-0`)* | `view_as_live` | Super Admin | `person_id` | unchanged | unchanged. **Login As is a button only; no new auth path** |
**Kiosk roster source:** `GET /login` / `GET /tasks` "On This Shift" reads `hop.v_outlet_shift_roster` filtered to the kiosk's outlet, the current business date, and windows that are open (`window_start − early_margin ≤ now ≤ window_end`) when `people_presence_radar_live` is on; otherwise the legacy path is untouched.
### 3.3 Feature Flags (`T2-6b` plane)
| Flag | Authority | Default | Effect when `off` |
|---|---|---|---|
| `people_presence_radar_live` | `admin` | `off` | `/admin/people/presence`, `/manager/presence`, roster, passport routes `404`; kiosk uses legacy roster; no presence events written; no sweeps scheduled; `/admin/people` columns show `-` |
| `people_inter_outlet_loan_live` | `admin` | `off` | loan routes `404`, loan buttons hidden, kiosk shows no loaned-in people; existing approved loans are frozen read-only (not reverted) |
Enable order: radar → loans. Loans **require** radar on (checked at read time, `409`-style `404` otherwise). Both honour the plane's **Safe Mode Kill Switch**. Rollout scope (`Pilot Outlet Only (NAN)` → global) is applied through the plane's outlet override API (**dependency to confirm: same gap noted in `T1-10`**). Registering these two flags raises the catalog from `18` to **`20`**; update the catalog count and the "all flags default off" test in `T2-6b`. They must also be added to `SEEDED_SWITCH_KEYS` for backward compatibility. The older `shift_flags_live` and `shift_timings_live` switches become **no-ops once the radar flag is on** (their screens read the new store); document this on `/admin/switches`.
### 3.4 Integration Hooks (exact)
| System | Hook | Detail |
|---|---|---|
| **HOP Shift Spine** (`/login`, `/tasks`) | `people_roster.kiosk_roster(outlet, now)` | Returns rows of `v_outlet_shift_roster` open now. `/tasks/checkin` validates the punch against `active_outlet_code` for the date. Unlisted person ⇒ existing `hop.approvals` flow; on approval writes `CHECK_IN` with `unscheduled=true, approved_by`. Compatibility: after each publish/loan change, upsert `hop.shift_allocations` (`MORNING`/`MID` → `DAY`, `CLOSING` → `EVENING`; custom windows by start time `< 14:00` → `DAY`) so unchanged code keeps working (`D2`). |
| **Accountability & Nudge** (`T1-0b/T1-7`) | Rules registered as **config rows** | `presence_no_checkin`: trigger `shift_start + grace` with no `CHECK_IN` and no `MARK_*`; ladder `T+0 → on-duty Shift Lead`, `+5 → Outlet Manager`, `+15 → Cluster Manager`, `+30 → City Head`; stops on `CHECK_IN`, `MARK_LATE`, `MARK_ABSENT`, or `LOANED_OUT`. `coverage_red`: §1.5, one per station per shift, to Outlet Manager (→ Cluster Manager after 15 min). `loan_pending`: reminds approver at `starts_at − 60 min`. `roster_unpublished`: Outlet Manager `D−3 18:00` if next week unpublished. **Task re-route:** `nudge.reroute_unowned_tasks(outlet, station, to_person)` for tasks whose station has no `PRESENT` person; **requires a `T1-7` API that is not confirmed to exist; if absent, ship nudge-only and log the gap.** If the shift lead is also absent: fall back to Outlet Manager, then Cluster Manager. Quiet hours (`DF-3`) honoured. |
| **Compliance** (`T1-8`) | `people_passport.medical_status(person, on_date)` ⇄ `compliance.report_people_cert_rollup(outlet)` | `T1-8` stays outlet-level (`fostac_med` licence = supervisor cert on premises); **per-person** cert data is owned here. `T1-8` reads a roll-up `{valid, expiring, expired, missing}` per outlet. Medical `EXPIRED` ⇒ hard block (§1.6). Confirm ownership split with the `T1-8` lane before build (`CONFIRM-LEGAL` on "Form 4", FoSTaC supervisor-ratio rules). |
| **New Product Launch** (`T1-10`) | `npl.certified_launches_for(person_id)` (read) | Radar/roster chips show certified launches; outlet card "launch-ready on this shift: n of m" feeds `T1-10`'s enablement coverage rule (default 100% of rostered crew for next 3 shifts) via `people_roster.rostered_people(outlet, next_n_shifts)`. **No write path to `npl_crew_certifications`.** Soft dependency: chips hide if `T1-10` absent. Note `T1-10` stores `outlet_id BIGINT` vs `hop.outlets.branch_code TEXT`; join on `person_id` only. |
| **Projects & Maintenance** (`T1-9`) | `people_passport.can_verify(person_id, outlet_code, at, work_done_by)` | `True` iff person is an authorised verifier **or** `outlet_manager`/`shift_lead`, **and** derived state at `outlet_code` at `at` is `PRESENT`/`LATE`-checked-in (not `ON_BREAK`/`RELIEVED`), **and** `person_id ≠ work_done_by`. `T1-9`'s existing `verified_by ≠ work_done_by` rule stays; this adds the physical-presence rule. Technician lists come from `maintenance_domains` (`IT/CIVIL/MACHINERY`). Soft dependency: if `T1-9` absent, nothing calls it. |
| **Feature Flags** (`T2-6b`) | `is_feature_enabled(flag, outlet)` | Per §3.3. People code never writes flag tables. |
| **Super Admin Login As** (`T1-0`) | existing `POST /admin/view-as` | Button only. While `hop_view_as` is active, `POST /tasks/checkin` and every `/manager/presence/mark`, `/admin/people/loan*`, `/admin/people/roster*` write is `403` *"View-As is for troubleshooting"* (extends the existing evidence block) so the audit trail isn't polluted. |
| **Success Telemetry** (`T2-6`) | counts-only events | `people.checkin_on_time`, `people.checkin_late`, `people.checkin_unscheduled`, `people.coverage_red_minutes`, `people.loan_requested/approved/rejected`, `people.roster_published`, `people.roster_published_lead_days`. Per-outlet aggregates; **no `person_id`, names or free text in telemetry** (`EN-01`). Contributes a "People" card to `/admin/success`. |
| **Audit** | `add_audit_log(category='people', …)` | `target`: `roster:<outlet>:<week>`, `loan:<id>`, `passport:<person_id>`, `presence_correction:<event_id>`; before/after in `detail`. Passport **detail reads** also audited. |
### 3.5 Scheduled Jobs
| Job | Cadence | Does |
|---|---|---|
| `presence_grace_sweep` | every 60 s, only for outlets with an open shift window | finds published `SHIFT` assignments past `s+g` with no `CHECK_IN`/`MARK_*`/loan-out; opens/advances the `presence_no_checkin` ladder idempotently (`UNIQUE(assignment, rung)`) |
| `coverage_sweep` | every 60 s | computes §1.5; raises `coverage_red` after 10 min persistence; writes board cache |
| `loan_expiry_sweep` | every 5 min | `REQUESTED` with `starts_at ≤ now` → `EXPIRED`; `APPROVED` with `ends_at ≤ now` → `COMPLETED` |
| `selfie_retention_purge` | daily 03:00 IST | `UPDATE … SET selfie_s3_key = NULL WHERE occurred_at < now() − interval '30 days'` (trigger permits only this) and delete the S3 objects; verify delete before nulling |
| `passport_expiry_digest` | daily 08:00 IST | list of medical/FoSTaC `EXPIRING ≤ 14 d` and `EXPIRED` with future assignments → HR + Outlet Manager (flag future assignments red; **never auto-delete them**) |
| `roster_projection_sync` | on publish/loan change + hourly safety | upserts legacy `hop.shift_allocations` projection |
All jobs: no-op when the radar flag is `off`; idempotent; time in `Asia/Kolkata`.
### 3.6 Presence Write Path (checks in order)
1. Kiosk session valid and bound to `outlet_code`; person PIN (4-digit) verified (existing).
2. Resolve today's assignment for the person (`business_date` = date of the shift *start*; a post-midnight punch belongs to the previous date when an open `crosses_midnight` window exists).
3. **Outlet check:** `assignment.active_outlet_code == kiosk.outlet_code`, else `409` *"You are working at X today."* and **no event**.
4. **Window check:** now within `[window_start − early_margin, window_end + 60 min]` for `CHECK_IN`; outside ⇒ treated as unscheduled (manager approval).
5. **Sequence check:** `CHECK_IN` once (idempotent via `idempotency_key`); `BREAK_START` needs an open check-in and no open break; `CHECK_OUT` closes an open break first.
6. Persist selfie to S3 and **verify the write** before inserting the event (`Not saved — Retry` on failure; `G01` standard); then insert event; then update legacy `hop.sign_ins` if first check-in.
7. Clock: store `client_ts`; set `clock_suspect` when `|client_ts − now| > 5 min`.
8. Emit telemetry; evaluate nudge ladder state.
### 3.7 Acceptance Tests (`tests/unit/test_t1_11_people_presence.py`)
*Schema / DB*
1. Migration `014` applies cleanly on a DB with `001–008`, and applies **twice** without error or duplicate seed rows.
2. Two live assignments for one person/date ⇒ unique violation; a `CANCELLED` + a new live row succeeds.
3. `active_outlet_code ≠ home_outlet_code` without `loan_id` ⇒ CHECK violation.
4. A second `REQUESTED/APPROVED` loan for one person/date ⇒ unique violation; after `CANCELLED`, a new one succeeds.
5. `UPDATE`/`DELETE` on `staff_presence_events` fails, except `UPDATE … SET selfie_s3_key = NULL`.
6. `APPROVED` loan without a complete approval for its mode ⇒ CHECK violation; `approver = person` ⇒ CHECK violation; `BOTH` mode with same manager twice ⇒ CHECK violation.
7. Passport `is_maint_verifier = true` without authoriser ⇒ CHECK violation; authoriser = self ⇒ CHECK violation; domain outside `IT/CIVIL/MACHINERY` ⇒ CHECK violation.
8. `fn_medical_status` returns `MISSING / EXPIRED / EXPIRING / VALID` at the boundaries (`valid_until = on_date − 1`, `= on_date`, `= on_date + 14`, `+ 15`).
*Derivation (`derive_presence` truth table, one test per row 1–14 of §1.3, plus):*
9. `CHECK_IN` beats `WEEK_OFF`, `MARK_ABSENT` and `MARK_LATE`; correction event replaces its target; `voids` removes it.
10. Check-in at `s+g` ⇒ `PRESENT`; at `s+g+1 min` ⇒ `LATE` with `late_min` recorded; `ON_BREAK` excluded from coverage.
11. Midnight-crossing window: a `00:30` punch resolves to the previous `business_date`.
*Services / routes*
12. Radar routes `404` with flag off; `403` for crew, shift lead of another outlet, and for outlet manager on `/admin/*`; `/manager/presence` scoped to actor's outlet/cluster only.
13. A person with no place sees nothing.
14. Wrong-outlet kiosk punch ⇒ `409` with *"You are working at JUB today."* and **zero** rows written.
15. Unlisted person ⇒ existing approval flow; approved ⇒ event with `unscheduled=true, approved_by`; manager cannot approve self.
16. Loan: `WEEK_OFF`/`LEAVE` person ⇒ `409`; expired medical + food station ⇒ `409`; `CLUSTER_MANAGER` mode approved by a cluster manager not covering both outlets ⇒ `403`; `BOTH` mode needs two distinct managers, one per outlet; approval re-validates guardrails.
17. Approved loan: person disappears from home kiosk roster, appears on receiving roster from `starts_at − 30 min`, tagged loaned-in; cancel before first event reverts the assignment; cancel after ⇒ `409`.
18. Roster: publish blocks on guardrail breach without `override_reason`; medical `EXPIRED` blocks food-station assignment with no manager override; `MISSING` only warns while `people.medical_missing_blocks = off`, blocks when `on`; past-date edit requires Admin/HR + reason.
19. Passport: managers see chips only (no numbers/doc keys in HTML or JSON); HR/Admin see detail and the read is audited; `{ref}` resolves for a person with `staff_code = NULL` via `person_code`.
20. Verifier: `can_verify` is false when not `PRESENT` at the outlet, when `person = work_done_by`, or when not authorised.
21. Nudge: `presence_no_checkin` fires once per rung on `T+0/+5/+15/+30`, is idempotent across sweeps, stops on `MARK_LATE`, `CHECK_IN`, loan-out; `coverage_red` fires once per station per shift after 10 min.
22. View-As: all write routes `403` while `hop_view_as` is active; Login As button renders only for Super Admin.
23. Telemetry payloads contain no `person_id`/name/free text.
24. Retention job nulls `selfie_s3_key` older than 30 days and leaves the event row intact.
25. **`O-034`:** `0` Devanagari in `/admin/people*`, `/manager/presence`, `/manager/roster`; every control `≥ 48 px`; no raw enum strings in rendered HTML; kiosk roster EN/HI toggle works.
26. **Backward compatibility:** with both flags `off`, the full existing suite is unchanged (`559/559` baseline at the time of writing) and kiosk behaves byte-for-byte as before.
27. **Cross-module unison:** an integration test walks one day, rostering a person → medical block → check-in → coverage → loan → verifier check → telemetry, asserting each module reads the same person state.
### 3.8 Delivery Slices
Per Intake §8: **S1** schema + templates + roster + passport · **S2** presence events + radar (`NAN`) · **S3** nudges + unscheduled approval + `010` reconciliation · **S4** loans + chain board · **S5** compliance/launch/maintenance/Login-As unison · **S6** Cover Finder + telemetry + export. Each slice is independently flag-gated and shippable.
---
## Cross-Module Unison Contract (summary)
`T1-11` is the **single source of "who is where, when, and fit-for-station"**. Every other module consumes it via the hooks in §3.4 and writes nothing back to it. It writes nothing into any other module's tables.
| Question | Owner of the answer | Consumers |
|---|---|---|
| Who is rostered at outlet O in window W? | `T1-11` roster | Spine/Kiosk, Nudge, NPL, Telemetry |
| Is she actually here now? | `T1-11` presence events | Nudge, Maintenance verifier, Spine |
| Is he legally fit for this station? | `T1-11` passport (+ `T1-8` roll-up) | Roster, Loans |
| Is she launch-certified? | `T1-10` (`npl_crew_certifications`) | `T1-11` chips, NPL coverage |
| Who may verify this repair? | `T1-11` (`can_verify`) | `T1-9` |
| Who gets nudged and when? | `T1-7` (config rows) | `T1-11` sweeps register rules |
| Is the feature on? | `T2-6b` | all |
---
## Sign-off (3-legged)
| Leg | Author checklist (self, complete) | Independent reviewer | Status |
|---|---|---|---|
| **Product** | vocabulary, invariants, access matrix, state machines, dignity/privacy, decisions `D1–D10` raised | *not assigned* | **PENDING independent review** |
| **UI / UX** | English-only, 48 px, plain-word chips, freshness honesty, crew-simplicity, screens A–G | *not assigned* | **PENDING independent review** |
| **Engineering** | schema, routes, flags, hooks, jobs, write path, 27 acceptance tests | *not assigned* | **PENDING independent review; SQL not executed** |
| **Owner Gate** | n/a | Owner | **PENDING: decisions `D1–D10`** |
No build may start until all four rows are signed (`O-038`).
## Open Items Carried to Owner Readout
`D1` fold `T1-4`/`010` into `T1-11` · `D2` keep `shift_allocations` as projection · `D3` medical scope · `D4` FoSTaC block vs warn · `D5` export is not payroll · `D6` no split-day loans in v1 · `D7` no geofence · `D8` confirm Pillars 5–6 · `D9` guardrail defaults (`CONFIRM-LEGAL`) · `D10` 30-min early margin. **Engineering unknowns:** `T1-7` task re-route API; `T2-6b` outlet-scoped override API; exact kiosk roster code path for `hop.staff` (no outlet column); other outlets (`JUB/BAN/HITECH/GACH/CK-HYD`) not yet in `hop.outlets`.

OneOps