# Tablet PIN Session Redesign — Design Document **Date:** 2026-05-24 **Status:** Approved for implementation **Owner module:** `fusion_plating_shopfloor` (with minor changes in `fusion_plating` for ACL data) **Brainstorm:** session with @gsinghpal, 2026-05-24 **Linked plan:** TBD (writing-plans skill, next step) **Related:** `docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md` (Phase 1 permissions overhaul which surfaced this gap) --- ## Problem Statement The current tablet PIN system on entech is **theatre**. It looks like a security/audit gate but doesn't actually enforce attribution: - The shop-floor tablet PC logs in ONCE as a persistent "shopfloor service" Odoo user. That session never changes. - When a tech taps their tile and enters the PIN, the OWL frontend stores a `current_tech_id` in an in-memory service (`fp_shopfloor_tech_store`). The underlying Odoo session cookie does NOT change. - ~15 of the ~30 shop-floor write endpoints accept a `tablet_tech_id` kwarg and use `env_for_tablet_tech` to attribute the write to the tech via `env(user=tech_id)`. The OTHER ~15 endpoints write under the session user (the shopfloor service user). The audit trail is incomplete. - There is no idle timeout, no re-lock on walk-away. Once unlocked, the `tech_id` sits in OWL state until someone manually swaps it. A tech walks away, the next person clicks a job card → Odoo records the work under the prior tech. - A tech (or anyone with browser access) can type any URL — `/odoo/settings`, `/web/...` — and act under the shopfloor service user's full backend privileges. The "lock" is only an OWL overlay over the kanban; it doesn't gate URL navigation. **The whole point of adding a PIN was to enforce "log who did what."** Today it doesn't. AS9100/Nadcap audit trails are unreliable because attribution can be wrong. --- ## Locked Design Decisions | # | Question | Decision | |---|---|---| | Q1 | Session model | **Real per-tech sessions (impersonation).** After PIN unlock the backend creates a REAL Odoo session AS the tech (cookie swap, server-side session row). They literally ARE that user for the duration of the unlock. | | Q2 | Lock-back trigger | **Idle timeout + manual lock + hard ceiling.** Default 10 min idle, 8 hr hard ceiling regardless of activity. Manual "Lock" / "Hand-Off" button always available. | | Q3 | Kiosk identity | **Dedicated kiosk user.** New user `fp_tablet_kiosk@enplating.local` in a new `group_fp_tablet_kiosk` group with near-zero ACL (read `res.users` for tile grid + read `ir.config_parameter` for idle settings). | | Q4 | Manager override / impersonation | **No special path.** Manager wanting to chip in must PIN in as themselves. Simpler, strongest audit. | | Q5 | OLD endpoint lifecycle | **Remove after successful rollout.** Two-step deploy with 1-week overlap, then Step 3 cleanup commits remove the legacy `tablet_tech_id` plumbing. | --- ## Section 1 — Architecture Overview ### Three identities, two state transitions ``` ┌──────────────────────────────────────────────────────────────────────┐ │ STATE: KIOSK │ │ ──────────── │ │ Browser cookie = kiosk session_id │ │ Session uid = fp_tablet_kiosk (new user, near-zero ACL) │ │ Visible = lock screen + tile grid only │ │ Idle timer = OFF │ └──────────────────────────────────────────────────────────────────────┘ │ │ Tech taps tile + enters correct PIN │ POST /fp/tablet/unlock_session │ → server: verify hash, mint new session ▼ ┌──────────────────────────────────────────────────────────────────────┐ │ STATE: TECH │ │ ─────────── │ │ Browser cookie = tech session_id (fresh Set-Cookie from unlock) │ │ Session uid = tech.id (real Odoo session as the tech) │ │ Visible = full Plating UI per tech's normal ACLs │ │ Idle timer = ON (10 min default) │ │ Hard ceiling = 8 hr from session_started_at │ │ │ │ EVERY Odoo write naturally attributed via session.uid: │ │ create_uid = tech.id │ │ write_uid = tech.id │ │ chatter authorship = tech.partner_id │ │ NO MORE tablet_tech_id plumbing needed │ └──────────────────────────────────────────────────────────────────────┘ │ │ Any of: manual Lock button / 10 min idle / │ 8 hr ceiling │ POST /fp/tablet/lock_session │ → server: destroy session, re-auth as kiosk ▼ back to KIOSK ``` ### Why this is fundamentally different from today Today the tablet has ONE persistent session. The PIN is an OWL overlay; the underlying Odoo session never changes. New design: every PIN unlock creates a NEW Odoo session AS the tech. Lock-back DESTROYS that session and creates a fresh kiosk session. Result: Odoo's standard `create_uid` / `write_uid` / chatter authorship already attributes everything correctly. We drop the `tablet_tech_id` kwarg + `env_for_tablet_tech` helper entirely — they become dead code. **Security gain:** If the tech navigates to ANY Odoo URL (Settings, Users, anything), they're acting under their own permissions, not the kiosk's. The kiosk user has near-zero ACLs so even if someone hits `/fp/...` URLs before PIN-ing in, they see nothing. --- ## Section 2 — Components & Files ### New files | Path | Responsibility | |---|---| | `fusion_plating_shopfloor/data/fp_tablet_kiosk_user.xml` | Idempotent create of `fp_tablet_kiosk@enplating.local` + assignment to `group_fp_tablet_kiosk`. Default password = random secret stored in `ir.config_parameter['fp.tablet.kiosk_password']` (sysadmin can reset). | | `fusion_plating_shopfloor/security/fp_tablet_kiosk_security.xml` | New `res.groups` `group_fp_tablet_kiosk`. NOT under the Fusion Plating privilege block — orthogonal to the 8-role hierarchy. `privilege_id` stays empty so it doesn't pollute the role dropdown. | | `fusion_plating_shopfloor/security/ir.model.access.csv` (rows added) | 2 ACL rows for kiosk: READ `res.users` (tile grid) + READ `ir.config_parameter` (idle settings). Nothing else. No fp.job, no sale.order, no anything. | | `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | New model `fp.tablet.session.event` — append-only audit log. Owner-only read; only base.group_system can ever write/unlink (we don't grant that). | | `fusion_plating_shopfloor/views/fp_tablet_session_event_views.xml` | List + form views. Owner-only menu under Plating → Configuration → Tablet Audit Log. Smart button on `res.users` form. | | `fusion_plating_shopfloor/models/res_users.py` (existing — add auth check) | Override `_check_credentials` to handle `type='fp_tablet_pin'` (custom auth manager). Verifies PIN hash + recordset state; raises `AccessDenied` on any failure. | | `fusion_plating_shopfloor/data/fp_tablet_cron.xml` | New cron `_cron_force_lock_stale_sessions` (every 5 min). Finds any active fp.tablet.session.event past 8hr ceiling with no `session_ended_at`, force-marks it ended. | | `fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js` | OWL service. Tracks idle time via DOM event listeners. Fires lock-back at 10min idle or 8hr ceiling. Replaces `fp_shopfloor_tech_store`. | ### Modified files | Path | Change | |---|---| | `controllers/tablet_controller.py` | Replace `/fp/tablet/unlock` with two new endpoints: `/fp/tablet/unlock_session` (verifies PIN → mints new Odoo session via `request.session.authenticate(db, {type:'fp_tablet_pin', ...})`), `/fp/tablet/lock_session` (destroys tech session → re-auths as kiosk). OLD `/fp/tablet/unlock` kept alive during the 1-week overlap. | | `controllers/_tablet_audit.py` | `env_for_tablet_tech` becomes a one-line no-op pass-through. Marked deprecated; deleted in Step 3 cleanup. | | All ~15 endpoints with `tablet_tech_id` kwarg | Step 3 (post-overlap) — remove the kwarg. Endpoints run under `request.env.user` which IS the tech (because session swap). | | OWL `FpTabletLock` component | Use new `tablet_session_manager` service. On successful unlock, `window.location.reload()` so the entire app re-bootstraps with the tech's session cookie. (Cleanest — no half-state.) | | `services/fp_rpc.js` | Stop auto-injecting `tablet_tech_id`. Becomes a thin wrapper, removable once endpoints drop the kwarg in Step 3. | ### Auth path — custom auth manager **Method:** Register a new auth type `fp_tablet_pin` via `res.users._check_credentials`. 1. PIN unlock endpoint calls `request.session.authenticate(request.db, {'type': 'fp_tablet_pin', 'login': tech.login, 'pin': pin})`. 2. Odoo's standard auth flow takes over: `_check_credentials` is invoked, sees `type='fp_tablet_pin'`, calls our handler. 3. Our handler hashes the PIN and compares against `tech.x_fc_tablet_pin_hash`. Validates `tech.active` and that the tech holds any shop-branch group. 4. On success, Odoo issues the session, sets cookie, returns response — **same code path Odoo uses for password login**. We get session lifecycle hooks, validation chain, and security for free. **Alternative considered:** direct `request.session.uid = tech.id` + manual cookie. Faster to implement but bypasses Odoo's `_check_credentials` validation chain (2FA, IP gating, future security modules). Picked the slower-to-implement but correct path. ### Idle-timer mechanics (OWL service) ```javascript // tablet_session_manager.js (sketch) class TabletSessionManager { setup(env) { this.idleMs = 10 * 60 * 1000; // 10 min, configurable via ir.config_parameter this.ceilingMs = 8 * 60 * 60 * 1000; // 8 hr hard this.lastActivity = Date.now(); this.sessionStartedAt = ...; // from server on bootstrap ['click', 'touchstart', 'keydown', 'mousemove'].forEach(ev => document.addEventListener(ev, () => this.touch(), { passive: true }) ); setInterval(() => this.tick(), 5000); } touch() { this.lastActivity = Date.now(); } tick() { const now = Date.now(); if (now - this.lastActivity > this.idleMs || now - this.sessionStartedAt > this.ceilingMs) { this.lockBack(now - this.lastActivity > this.idleMs ? 'idle' : 'ceiling'); } } async lockBack(reason) { await rpc("/fp/tablet/lock_session", { reason }); window.location.reload(); // fresh page → fresh kiosk session } } ``` Server-side belt-and-suspenders: cron `_cron_force_lock_stale_sessions` runs every 5 min and force-destroys any tablet session past the 8-hr ceiling — handles browser crashes, tablet reboots with stale cookie, etc. --- ## Section 3 — Session Lifecycle in Detail ### Unlock flow ``` Tablet OWL Server ────── ─── ────── Tap tile ──────────────▶ PIN pad opens Enter 4 digits ────────────▶ collect PIN POST /fp/tablet/unlock_session { tech_id, pin } cookie: kiosk_session_id ────────────────────▶ 1. verify kiosk session active 2. browse(tech_id), exists+active 3. lockout check (failed_count, locked_until) 4. verify_tablet_pin via hash │ ├─ FAIL → fp.tablet.session.event │ (failed_unlock, │ attempted_user_id=tech_id, │ failure_reason='wrong_pin', │ ip, ua) │ + increment failed_count │ + maybe set locked_until │ + return {ok:false, error} │ └─ PASS: 5. session.authenticate(db, { type:'fp_tablet_pin', login:tech.login, pin:pin }) → Odoo issues new sid, uid=tech.id → response Set-Cookie 6. fp.tablet.session.event (unlock, user_id=tech_id, session_id_hash=sha256(sid), session_started_at=now, ip, ua) 7. reset failed_count=0 8. return {ok:true, tech_name} ◀──────────────────── window.location.reload() ``` ### Lock-back flow ``` Trigger (any of): - User taps Lock button - 10 min no activity (idleMs) - 8 hr since session_started_at (ceilingMs) POST /fp/tablet/lock_session { reason: 'manual' | 'idle' | 'ceiling' } cookie: tech_session_id ──────▶ server: 1. read session.uid (current tech) 2. fp.tablet.session.event (event_type matches reason, user_id=tech_id, session_id_hash, session_ended_at=now, duration_seconds=now - session_started_at) 3. request.session.logout() 4. response Set-Cookie: clear tech session 5. session.authenticate(db, {type:'password', login:'fp_tablet_kiosk', password:KIOSK_SECRET_from_ir_config}) → response carries new kiosk Set-Cookie 6. return {ok:true, locked_at:now} ◀────── browser receives Set-Cookie (kiosk session) window.location.reload() App re-bootstraps as kiosk → lock screen renders ``` ### Edge cases (must work) | Scenario | Behavior | |---|---| | Tech walks away, browser crashes | Cron `_cron_force_lock_stale_sessions` (every 5 min) finds the stale session, writes `event_type='force_lock'`. Next tablet boot starts as kiosk regardless. | | Two techs race-tap two tiles simultaneously | First unlock wins. Second tech's POST sent with the FIRST tech's NEW cookie (Set-Cookie applied) → backend sees them as tech 1, returns access-denied to mint another session. UI debounces with spinner. | | Wrong PIN 5 times | `failed_count` → 5, `locked_until` set to now+5min. Subsequent PIN attempts return `{ok:false, locked_until}` → UI shows countdown. Audit event each attempt. | | Network drops mid-unlock | OWL gets timeout → retry button. Session NOT created server-side if request never completed. Single DB transaction guarantees atomicity. | | Browser pre-fills cached cookie from old session | Server validates session row; if invalid, returns 401, OWL forces page reload → re-authenticates as kiosk. | | Manager force-resets a tech's PIN while tech is unlocked | Tech's current session keeps working (sessions independent of PIN hash). Next PIN entry requires the new PIN. Manager action logged via `admin_reset` event. | | Tech navigates to `/odoo/settings` or other URLs | They're a real Odoo user with their own ACLs. Standard ACLs apply. Technician sees what a Technician would see (mostly nothing — Manager+ only menus). | | Tablet PC reboots mid-shift | Boots to login page (kiosk session cookie may have expired). Stored kiosk credential auto-fills. Reaches lock screen, ready for PIN. | ### Concurrency / race protection - `unlock_session` takes a DB row lock on `res.users(id=tech_id)` for the duration of `verify_tablet_pin` + `failed_count` write. Prevents double-counting failed attempts. - `fp.tablet.session.event` writes are sudo'd and append-only. Race conditions produce two adjacent audit rows (sortable by `create_date`) — never lose data. --- ## Section 4 — Audit Log Model + UI ### Model: `fp.tablet.session.event` ```python class FpTabletSessionEvent(models.Model): _name = 'fp.tablet.session.event' _description = 'Tablet Session Event (audit log)' _order = 'create_date desc' _rec_name = 'event_type' event_type = fields.Selection([ ('unlock', 'Unlock (PIN success)'), ('failed_unlock', 'Failed PIN attempt'), ('manual_lock', 'Manual lock (Hand-Off button)'), ('idle_lock', 'Idle timeout lock'), ('ceiling_lock', '8-hour ceiling lock'), ('force_lock', 'Force lock (cron, stale session)'), ('admin_reset', 'Admin force-reset PIN'), ], required=True, readonly=True, index=True) user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict', help='The tech whose session was unlocked/locked. NULL for failed ' 'attempts where the tile was tapped but unlock never succeeded.') attempted_user_id = fields.Many2one('res.users', readonly=True, ondelete='restrict', help='For failed_unlock: which tile was tapped. user_id stays empty.') session_id_hash = fields.Char(readonly=True, help='sha256 hash of the Odoo session sid. Lets us correlate ' 'events for the same session without storing the raw token.') session_started_at = fields.Datetime(readonly=True) session_ended_at = fields.Datetime(readonly=True) duration_seconds = fields.Integer(readonly=True) ip_address = fields.Char(readonly=True) user_agent = fields.Char(readonly=True, help='Trimmed to 256 chars.') failure_reason = fields.Selection([ ('wrong_pin', 'Wrong PIN'), ('locked_out', 'Locked out (too many failures)'), ('no_pin_set', 'No PIN configured'), ('user_inactive', 'User archived or disabled'), ('no_role', 'User has no shop-branch role'), ], readonly=True) acting_uid = fields.Many2one('res.users', readonly=True, help='The user the SERVER saw at request time. Usually fp_tablet_kiosk ' 'for unlocks; the manager for admin_reset; base.user_root for cron.') notes = fields.Text(readonly=True) ``` **Design rules:** - Append-only. No `_inherit = 'mail.thread'`. No `write()` or `unlink()` ACL granted to any group except `base.group_system` (which we DON'T grant — root SQL access required to tamper). - Hashed session sid, not raw — if DB leaks, attackers can't replay sessions. - `attempted_user_id` cleanly distinguishes "Carlos's tile was tapped" from "Carlos was authenticated." ### Lifecycle: who writes what | Trigger | Endpoint | event_type | user_id | attempted_user_id | acting_uid | |---|---|---|---|---|---| | Successful PIN unlock | `/fp/tablet/unlock_session` | unlock | tech | — | kiosk | | Wrong PIN | `/fp/tablet/unlock_session` (fail) | failed_unlock | — | tech | kiosk | | 5th wrong PIN → lockout | same | failed_unlock (`locked_out`) | — | tech | kiosk | | Lock button | `/fp/tablet/lock_session` | manual_lock | tech | — | tech | | 10 min idle | `/fp/tablet/lock_session` | idle_lock | tech | — | tech | | 8 hr ceiling | `/fp/tablet/lock_session` or cron | ceiling_lock | tech | — | tech or cron | | Cron force-lock | `_cron_force_lock_stale_sessions` | force_lock | tech | — | base.user_root | | Manager resets PIN | `/fp/tablet/reset_pin_for` | admin_reset | tech | — | manager | ### UI: 3 surfaces 1. **Owner-only menu** — Plating → Configuration → Tablet Audit Log. List view with badges, filters (today/week/month, event_type, user), group-by, default 90-day window. 2. **Smart button on `res.users` form (Owner-only)** — "Tablet Events" with last-7-days count, opens audit list filtered to that user (`user_id` OR `attempted_user_id`). 3. **Chatter linkback (deferred to follow-up)** — tooltip on chatter messages linking to the unlock event. Phase 2, not blocking. ### Retention - Default: indefinite. `_cron_purge_old_session_events` exists but is DISABLED. Configurable via `ir.config_parameter['fp.tablet.audit.retention_days']` (unset = forever). - AS9100 retention typically 3-7 years. Safe default for small shops. ### What this audit log does NOT replace - Standard `create_uid` / `write_uid` on every model — that's the primary audit. - Chatter authorship — still primary on individual records. - This log catches what `create_uid` can't: failed attempts, session lengths, idle vs manual locks, gaps between sessions. --- ## Section 5 — Migration & Rollout ### Two-step deploy with overlap window To avoid downtime, OLD and NEW endpoints coexist for ~1 week. Tablets switch over individually as they reboot. **Step 1 — Day 0 deploy (NEW code, OLD still alive)** `-u` brings up: - kiosk user + group + ACL - new `/fp/tablet/unlock_session` and `/fp/tablet/lock_session` - `fp.tablet.session.event` model + audit views - new OWL `tablet_session_manager` service - custom `fp_tablet_pin` auth manager OLD endpoints stay: - `/fp/tablet/unlock` returns `current_tech_id` (no session swap) - `env_for_tablet_tech` still routes endpoints - OWL `fp_shopfloor_tech_store` still bypassed when new manager is active Frontend feature flag `ir.config_parameter['fp.shopfloor.tablet_session_mode']`: - `'legacy'` (Day 0 default) → OWL uses old flow - `'session_swap'` → OWL uses new flow **Step 2 — Days 1-7 cutover, one tablet at a time** - Flip flag to `'session_swap'` on entech - Per tablet: 1. Reboot or hard-refresh browser 2. Sysadmin enters kiosk credential ONCE (stored in 1Password) 3. Bookmark `/odoo/action-fusion_plating_shopfloor.action_fp_plant_kanban` 4. Lock screen renders under kiosk user 5. Test: tech taps tile, enters PIN, full flow works as them 6. Track in spreadsheet (2-3 tablets total) **Step 3 — Day 14 cleanup commits** - Sweep `tablet_tech_id` kwargs out of all ~15 endpoints - Delete `_tablet_audit.env_for_tablet_tech` (becomes import error → forces final cleanup) - Remove OLD `/fp/tablet/unlock` endpoint - Remove OLD `fp_shopfloor_tech_store` OWL service - Strip auto-injection from `services/fp_rpc.js` - Archive the legacy "shopfloor service" user ### Auto-login pattern for kiosk Three options, cheapest first: 1. **Browser-stored cookie + long session_lifetime (recommended for entech)** — set `session_db.session_lifetime` to 90 days for kiosk. Sysadmin logs in once, cookie lasts 3 months. Cheap, manual. 2. **Kiosk browser extension** — KioWare or Chromium kiosk-mode auto-fills credential. Auto-recovers from reboots. 3. **Odoo SSO with stored token** — overkill for 2-3 tablets. ### Rollback plan - Set `tablet_session_mode = 'legacy'` → all OWL switches back. No redeploy. - If kiosk user is broken, legacy "shopfloor service" still has its permissions and the OLD endpoints — system keeps working. ### What WON'T be touched - `res.users.x_fc_tablet_pin_hash` — same field, same hash format, same `verify_tablet_pin()`. PINs don't need to be reset. - Lockout state (`failed_count`, `locked_until`) — preserved. - All other shop-floor functionality (kanban, workspace, recipes) — unaffected. ### Timing on entech | Day | Action | |---|---| | 0 (deploy day) | `-u fusion_plating_shopfloor` + 4 related modules. Verify new endpoints. `tablet_session_mode='legacy'`. | | 0 (evening) | Flip flag on one test tablet. Manual test 5 unlock/lock cycles. | | 1-2 | Roll to second + third tablets. Owner watches audit log. | | 3-7 | Operators use new flow. Owner reviews audit log daily. | | 7 | Decision: keep `'session_swap'` permanently OR roll back. | | 14 | If kept: Step 3 cleanup commits. | --- ## Section 6 — Acceptance, Risks, Deferred ### Acceptance criteria 1. **Identity** — pick any `create_uid` on `fp.job.step` change in last 30 days. Cross-reference `fp.tablet.session.event` for active session at that timestamp under that user. Match rate target: 100% of tablet-originated writes. 2. **Failed attempts** — query "every wrong-PIN attempt for user X in last week" returns rows with `attempted_user_id=X`, `failure_reason='wrong_pin'`, IP, UA, timestamp. 3. **Session length** — longest session ≤ 8hr (within 5-min cron grace). 4. **Gap detection** — adjacent lock → next unlock delta computable; "was anyone on the floor at 2pm?" answerable. 5. **No silent attribution** — post-Step 3, action endpoint without `tablet_tech_id` runs under `request.env.user` which IS the tech. `create_uid = tech.id`. 6. **Kiosk privilege check** — log in as `fp_tablet_kiosk` in private browser. Try to navigate any plating URL. Result: access denied / blank menu. 7. **Browser navigation under tech** — as the unlocked tech, hit `/odoo` main menu. They see ONLY menus their role allows. 8. **Idle lockout fires** — PIN-unlock, wait 11 min touching nothing. Auto-lock. `event_type='idle_lock'` row appears. 9. **Hard ceiling fires** — backdate `session_started_at` past 8 hrs. Run cron. `session_ended_at` populates, `event_type='force_lock'`. 10. **Audit log append-only** — try `event.write({...})` as Owner. AccessError. Only root SQL access can tamper. ### Open risks | Risk | Likelihood | Mitigation | |---|---|---| | Custom auth manager conflicts with Odoo's session_lifetime / 2FA / IP modules | Medium | Distinctive type name (`fp_tablet_pin`, not `tablet_pin`). Tests for pwd + 2FA paths unchanged. Document in CLAUDE.md. | | Browser cookie misbehavior on rapid lock/unlock | Low | `window.location.reload()` after every transition kills half-state. | | Tablet PC reboot mid-shift with stale cookie | Medium | Long-lived kiosk cookie (90-day session_lifetime). Sysadmin re-login if expired (~30s downtime). | | Backend cron clock drift | Low | All timestamps `fields.Datetime.now()` (UTC, server). Server cron is source of truth for ceiling. | | Network drop mid-unlock | Low | One DB transaction — atomic. Either session+audit row both commit, or neither. | | Operator hits backend URLs | Expected behavior | They're real Odoo users with their own ACLs. Standard ACLs apply. Working as designed. | | Brute-force PIN attempts as DoS | Low (insider only) | 5-attempt → 5-min lockout. Cron clears `failed_count` after 1hr of no failures. 10000 possible PINs + lockout → ~3.5 years to brute-force on average. | ### Deferred to follow-up - **Chatter linkback** (Section 4, surface 3) — useful but not blocking. Phase 2. - **2FA on lock screen** (badge tap, biometric) — out of scope. - **Per-tablet identity** — currently every tablet uses same kiosk credential. If you ever want to track which physical tablet did what, add `tablet_device_id`. Deferred — small shop doesn't need it. - **SAML/SSO integration** — out of scope. - **Manager override mode** — explicitly killed in Q4. Manager wanting to chip in must PIN in as themselves. - **Time-clock integration** — separate concern. The `session_started_at` could feed time-tracking but that integration is its own design. - **Mobile (non-tablet) access** — Technician on phone uses standard Odoo login. PIN flow is tablet-only. ### Estimated effort - Phase 1: server-side (kiosk user, auth manager, endpoints, audit model, cron) — **~1.5 days** - Phase 2: OWL (session manager, lock-back UI, reload-on-transition) — **~0.5 days** - Phase 3: audit views + Owner menu + smart button — **~0.5 days** - Phase 4: entech rollout (deploy, feature-flag test, per-tablet cutover, validation week) — **~1 day spread over 7 days** - Phase 5: Step 3 cleanup (rip out tablet_tech_id) — **~0.5 days** **Total: ~4 development days + 1 calendar week observation.** --- ## Status - ✅ Brainstorm complete (5 locked decisions) - ✅ Design doc written - ⏳ Self-review (next) - ⏳ User reviews this spec - ⏳ Invoke `writing-plans` to create the implementation plan - ⏳ Execute the plan per `subagent-driven-development` - ⏳ Deploy + validate on entech --- *End of design document.*