Spec: F106 - Attendance and Timesheet Export
Status: draft
1. Problem & CEO Job
Small teams need one dependable record of who worked, when they started and finished, and what needs correction. Lumi has roster, teams, permissions, and mobile shells but no attendance record, so managers use chat and spreadsheets and employees cannot clock themselves in or out. F106 serves see clearly and trust the signal with a sellable attendance module: explicit actor/source custody, a fresh photo and device-reported GPS point for every normal clock action, controlled corrections, and an immediately usable Excel export. It excludes biometric analysis, continuous location tracking, payroll, and workforce optimization.
Attendance photos and precise location are sensitive employee data. F106 therefore captures them only during an explicit foreground clock action, stores no continuous route, performs no face recognition, and cannot use F34’s current public-read CDN path. Photos require private objects and an authorized, short-lived read path. Leave, payroll, wage calculation, and shift rostering remain outside the feature.
1.1 Product outcome
An organization can complete the whole attendance loop without leaving Lumi:
- An owner or admin enables attendance and optionally sets one organization-level workday.
- An employee takes a fresh check-in/out photo and shares one foreground GPS fix, or an authorized leader takes one fresh group photo and GPS fix for a managed-team batch.
- The record keeps the photo, coordinates, accuracy, server timestamp, actor, and capture mode.
- Mistakes are corrected through a reasoned, revision-safe flow with an audit trail.
- An authorized leader exports a bounded
.xlsxworkbook for a selected date range.
1.2 Success measures
These are pilot acceptance thresholds, not claims about current behavior:
- Median organization setup to first successful clock-in: under 10 minutes.
- Median self clock-in or clock-out interaction, including photo and GPS: under 15 seconds after the surface is open.
- A manager can record a 20-person team with one group photo and GPS fix in under 45 seconds.
- At least 90% of expected employee-days are resolved as complete, absent, or excused after the first full pilot week.
- The current-month workbook is reachable in at most three user actions and generates in under 30 seconds for a pilot-sized organization.
- Fewer than 10% of records require a manual correction after the first pilot week.
- Zero duplicate employee-day records, zero cross-tenant reads or writes, zero publicly readable attendance photos, and 100% of normal persisted clock events contain photo, coordinates, accuracy, server timestamp, capture source, and actor.
- At least two of five 14-day pilot organizations say they would pay for the attendance module.
2. User Stories & Acceptance Criteria
2.1 Owner and admin
-
As an owner or admin, I want attendance to work immediately and an optional simple organization workday, so that the team can start without configuring an HR system.
- Given an organization has never saved an attendance policy, when an authorized member opens attendance, then attendance, self clocking, team clocking, and correction requests are enabled by default while the reference schedule remains unset.
- Given an owner/admin explicitly disables attendance or a clocking flow, when a member opens attendance, then the saved switch remains authoritative and the UI offers no dead action.
- Given scheduled start/end times are omitted, when attendance is recorded, then raw clock
times and worked duration remain valid while late, early-leave, and extra-minute fields are
null, never fabricated as zero. - Given a scheduled workday is configured, when a record is created, then its timezone, schedule, break, and grace values are snapshotted onto that record.
- Given an owner/admin changes policy, when old records are read or exported, then their snapshotted calculations do not change retroactively.
-
As an owner or admin, I want to see and correct organization attendance, so that the exported record is operationally usable.
- Given a valid organization date, when the attendance day is opened, then all active
members in scope appear once with
not_recorded,not_scheduled,open,complete,absent, orexcusedpresentation. - Given a missing or incorrect record, when the user supplies a reason and valid timestamps/status, then the corrected record is saved with a new revision and an audit event.
- Given a stale record revision, when a correction is submitted, then no write occurs and
the API returns
409with the current record. - Given an existing record should no longer count, when it is voided with a reason, then it remains auditable and is never hard-deleted.
- Given an attendance event with capture evidence, when an authorized user opens it, then Lumi shows the photo, map point, GPS accuracy, server timestamp, capture mode, and actor.
- Given an unauthorized user or expired evidence URL, when the photo is requested, then no image bytes or stable public URL are disclosed.
- Given a valid organization date, when the attendance day is opened, then all active
members in scope appear once with
-
As an owner or admin, I want to export attendance to Excel, so that I can hand the record to finance or continue working in a familiar tool.
- Given a permitted date range, when export is requested, then Lumi returns a valid
.xlsxworkbook containing detail and per-employee summary sheets. - Given no attendance exists in part of the selected range, when export is requested, then
active-roster employee-days appear honestly as
not_recorded; Lumi does not label them absent. - Given a cell value begins with
=,+,-, or@, when the workbook is generated, then the value is escaped as text and cannot execute as a spreadsheet formula. - Given captured attendance, when it is exported, then the detail sheet includes check-in and check-out latitude, longitude, accuracy, evidence status, and an authenticated Lumi record reference without embedding image bytes or public photo URLs.
- Given a permitted date range, when export is requested, then Lumi returns a valid
2.2 Manager
-
As a manager, I want to record attendance for my team, so that a supervisor can handle a shared morning roll call.
- Given members belong to teams the manager leads, when the manager selects people and takes one fresh group photo, obtains one foreground GPS fix, and clocks them in or out, then the same batch evidence is linked to every selected successful member and each member receives one itemized outcome.
- Given scoped members are
not_recordedornot_scheduled, when a manager selects them, then the mobile action surface offers team clock-in/status actions; given scoped members areopen, it instead offers team clock-out. Members requiring opposite clock directions cannot be mixed into one capture. - Given a team clock response succeeds, when the roster refreshes, then the manager sees each
employee’s local check-in/check-out times and a direction-specific itemized outcome rather
than the ambiguous label
Recorded. - Given a selected member is outside the manager’s scope, when a team operation is submitted, then the entire request is rejected with no writes.
- Given some valid members are already open or complete, when a team operation is submitted,
then valid non-conflicting members are recorded and every member receives an explicit
created,already_open,already_complete, or other bounded outcome. - Given a leader records attendance for another person, when the record is shown, then the
UI says
Recorded by <name>, labels the photo/location as shared team evidence, and never represents the action as employee-confirmed or cryptographic proof of physical presence. - Given the camera, location permission, completed photo upload, or GPS fix is missing, when the manager submits team clocking, then the entire batch is rejected with no attendance writes.
-
As a manager, I want to resolve a missed workday, so that the team sheet does not stay ambiguous.
- Given a managed member did not work, when the manager marks
absentorexcusedwith a reason, then a dated record is created or revised and audited. - Given a member asks for a correction, when the manager approves or rejects it, then the decision is limited to managed teams and records decision actor, time, and note.
- Given the manager requests an export, when the workbook is generated, then it contains only records for members currently active in teams the manager leads whose record team snapshot matches that current team.
- Given a managed member did not work, when the manager marks
2.3 Member
-
As a member, I want to clock myself in and out, so that I do not need a manager to record each day.
- Given self clocking is enabled and the member has no open record today, when the member
takes a fresh in-app photo, grants a foreground GPS fix, and confirms
Clock in, then the server records its own current timestamp and persists both evidence items. - Given the member has an open record, when the member takes a new photo, grants a new
foreground GPS fix, and confirms
Clock out, then the server records a separate checkout evidence set and shows the completed duration. - Given camera or location permission is denied, the photo upload is incomplete, or no GPS fix is available, when the member attempts to confirm, then no attendance row changes and Lumi provides a direct retry/settings path.
- Given the same idempotency key is retried, when the request reaches the server again, then the original outcome is returned and no duplicate record is created.
- Given the device is offline or the request fails, when the user presses a clock action, then Lumi shows failure and never displays a false successful clock state.
- Given self clocking is disabled or the account is suspended, when the member attempts a clock action, then the server rejects it and no record changes.
- Given self clocking is enabled and the member has no open record today, when the member
takes a fresh in-app photo, grants a foreground GPS fix, and confirms
-
As a member, I want to request a correction to my own record, so that mistakes can be fixed without silently rewriting history.
- Given an own record is wrong or missing, when the member submits proposed values and a reason, then a pending correction request is created without changing attendance.
- Given a pending request exists, when the member views the date, then the pending state and proposed change are visible.
- Given another member’s record, when the member attempts to read or correct it, then the
server returns
404without disclosing whether it exists.
2.4 Viewer
- As a viewer, I want to see my own attendance without changing it, so that read-only
organization access stays read-only.
- Given an own record, when the viewer opens attendance, then only their own history is returned.
- Given any clock, correction, settings, team, or export action, when a viewer attempts it,
then the server returns
403and performs no write.
2.5 Cross-role invariants
- Every read and write is scoped first to
request.organization_id, then to capability and team/member scope. - Every correction mutation reloads and locks the current actor inside its transaction before target scope, record/request resolution, or any write. A suspended actor or one whose capability was removed after request authentication performs no mutation.
- A manager can read, correct, or export another member’s record only when the member is currently active in a team the manager leads and the record’s team snapshot matches that current team. Pre-transfer and removed-member history is owner/admin-only.
- A normal clock action uses server time. A client cannot submit a forged current timestamp.
- Every normal self or team
clock_inandclock_outrequires a completed private photo owned by the authenticated actor plus a foreground device GPS fix containing latitude, longitude, accuracy meters, and device capture time. In a team batch, the selected employees’ records reference the group evidence owned by the authenticated team-clock actor, who may be an owner, admin, or manager. Missing either rejects the whole action. - Self capture uses one fresh in-app photo per event. Team capture uses one fresh group photo and GPS fix per batch, visibly shared across the selected successful records.
- Check-in evidence and check-out evidence are immutable and distinct. A correction may replace a mistaken timestamp/status but cannot rewrite an original photo or GPS point.
- Manual timestamps are accepted only through a correction path with a reason and the
attendance.correctcapability. - Only one non-void attendance record can exist per organization member and organization-local work date.
- A member can have at most one
openrecord across all work dates in an organization. A new clock-in while any earlier record remains open returns409with that record. -
not_recordedand expected-day totals exist only for organization working days on/after the member’sjoined_atdate. A real off-day record remains visible but does not create an expected day or schedule comparison. - A Pulse, F90 execution event, chat message, app session, passive/background GPS observation, or background job can never create or imply an attendance record.
- Attendance actions initiated through the assistant require explicit confirmation and remain within the authenticated actor’s normal capability and row scope.
3. Scope Boundaries
3.1 In scope
The scope is partitioned so each responsibility has one owner:
| Area | Included |
|---|---|
| Roster and policy | Reuse active OrganizationMember, canonical OrganizationMember.team, Team.manager, organization timezone, joined date, and working days; one optional organization-level start/end/break/grace policy |
| Self capture | Current-day server-timestamp clock-in and clock-out by an enabled member, each requiring one fresh in-app photo and foreground GPS fix |
| Team capture | Authorized owner/admin or managed-team manager records current-day clock-in/out for one or many members using one shared fresh group photo and GPS fix; absent/excused requires a reason but no capture |
| Evidence custody | Separate immutable check-in/out photo upload, latitude, longitude, accuracy, device capture time, server time, actor, and capture-mode fields |
| Review | Day/team view, own history, honest not_recorded / not_scheduled projections, private photo/map evidence, capture source, actor, durations, and schedule comparisons |
| Correction | Own correction request; authorized direct correction/decision; reason, optimistic revision, audit trail, and void instead of delete |
| Export | Server-generated .xlsx detail and summary sheets for a bounded date range and authorized roster scope |
| Product surfaces | Web camera/geolocation, shared Android/iOS just-in-time camera/location capture, native share sheet, and assistant capture handoff |
| Reliability and security | Tenant/capability/row scope, private photo access, no background location, idempotency, row locking, spreadsheet-injection protection, bounded export, and F16 audit events |
Packaging, price, and plan entitlement remain F53/F74 decisions. F106 acceptance still covers the complete capture-to-workbook loop. Implementation must reconcile that promise with canonical server-side plan truth, including the existing generic export entitlement, instead of inventing a client-side exception.
3.2 Out of scope
- Payroll, wages, paid overtime, tax, social insurance, payslips, accounting entries, or bank payment files.
- Leave requests, leave balances, holidays, accruals, or automatic absence from leave data.
- Shift rostering, rotating schedules, team-specific schedules, split shifts, multiple clock-in/out sessions per day, or shift swaps.
- Face recognition, biometric templates, liveness scoring, identity matching, or automated conclusions about who appears in a photo.
- Continuous/background location, route history, geofence enforcement, GPS spoof detection, Bluetooth/Wi-Fi proof, or location collection outside an explicit foreground clock action.
- Gallery/file-picker substitution for a normal clock photo, QR/kiosk mode, or hardware time clocks.
- Public-read attendance photos or stable public CDN links. The current F34 public upload mode cannot store F106 evidence.
- Offline write queues or background clocking. F106 requires a successful server response.
- Automatic clocking from Pulse, F90 field events, Tasks, chat, calendar, device activity, or location.
- Project/site/job cost allocation, billable time, F90 task labor approval, or customer timesheets.
- Performance scores, punctuality rankings, employee surveillance maps, or AI-generated disciplinary conclusions.
- Public attendance links, contractor portals, biometric identity, or external HRIS/payroll sync.
- Historical headcount reconstruction for people who left before F106 stored a record. A removed member appears historically only through persisted attendance snapshots.
- A parallel employee/contractor roster without a Lumi account. A person becomes attendance-eligible
only after accepting an F02 invitation and becoming an active
OrganizationMember.
4. Data Model
F106 reuses OrganizationMember as the employee roster and Team as the reporting boundary. It
must not create a parallel Employee model.
4.1 AttendancePolicy
One row per organization, extending UUIDModel:
| Field | Shape | Rule |
|---|---|---|
organization | one-to-one FK | Tenant owner; cascade only when the organization is deleted |
enabled | boolean | Default true; a persisted false remains authoritative; separate from billing entitlement |
scheduled_start_local | nullable time | Optional organization-level reference time |
scheduled_end_local | nullable time | Must be after start for this v1 day schedule |
unpaid_break_minutes | non-negative integer | Default 0; cannot exceed scheduled minutes |
grace_minutes | non-negative integer | Default 0; applies symmetrically to late/early calculations |
self_clock_enabled | boolean | Default true once attendance is enabled |
team_clock_enabled | boolean | Default true once attendance is enabled |
correction_requests_enabled | boolean | Default true |
updated_by | nullable member FK | Last actor; audit log remains authoritative |
Organization timezone and working_days remain owned by the existing organization settings. Policy
updates do not rewrite historical records.
4.2 AttendanceRecord
Extends UUIDModel:
| Field | Shape | Rule |
|---|---|---|
organization | FK | Required tenant owner |
member | nullable FK to OrganizationMember | SET_NULL preserves history after roster removal |
member_name_snapshot | string | Required at first persistence |
member_email_snapshot | string | Required at first persistence |
team | nullable FK to Team | Canonical OrganizationMember.team at first persistence |
team_name_snapshot | string | Historical export label; blank for unassigned |
work_date | date | Organization-local date determined at clock-in or explicit correction |
check_in_at | nullable UTC datetime | Server time for normal capture |
check_out_at | nullable UTC datetime | Server time for normal capture |
status | enum | open, complete, absent, excused, voided |
check_in_source | nullable enum | self, team, assistant, manual |
check_out_source | nullable enum | Same vocabulary |
check_in_actor | nullable member FK | Actor custody, preserved with display-name snapshot if removed |
check_out_actor | nullable member FK | Actor custody, preserved with display-name snapshot if removed |
check_in_actor_name_snapshot | string | Required when check-in actor exists |
check_out_actor_name_snapshot | string | Required when check-out actor exists |
check_in_photo | nullable FK to private Upload | Required for normal check-in; PROTECT while record exists |
check_out_photo | nullable FK to private Upload | Required for normal check-out; distinct from check-in photo |
check_in_latitude | nullable decimal | Device-reported range [-90, 90]; required for normal check-in |
check_in_longitude | nullable decimal | Device-reported range [-180, 180]; required for normal check-in |
check_in_accuracy_m | nullable positive decimal | OS-reported horizontal accuracy in meters |
check_in_device_captured_at | nullable UTC datetime | Advisory device time; server time remains authoritative |
check_out_latitude | nullable decimal | Device-reported range [-90, 90]; required for normal check-out |
check_out_longitude | nullable decimal | Device-reported range [-180, 180]; required for normal check-out |
check_out_accuracy_m | nullable positive decimal | OS-reported horizontal accuracy in meters |
check_out_device_captured_at | nullable UTC datetime | Advisory device time; server time remains authoritative |
check_in_evidence_mode | nullable enum | individual or shared_team; required with check-in evidence |
check_out_evidence_mode | nullable enum | individual or shared_team; required with check-out evidence |
timezone_snapshot | IANA timezone | Required |
scheduled_start_snapshot | nullable local datetime | Null when policy is unset |
scheduled_end_snapshot | nullable local datetime | Null when policy is unset |
unpaid_break_minutes_snapshot | integer | Required, default 0 |
grace_minutes_snapshot | integer | Required, default 0 |
revision | positive integer | Starts at 1, increments on each correction/void |
last_adjustment_reason | nullable string | Required for manual correction, absence/excuse, or void |
Database constraints and indexes:
- Unique active record on
(organization, member, work_date). A voided record remains history; a replacement, if needed, is created only by an audited service that prevents two active rows. - Partial unique constraint on
(organization, member)whilestatus="open", so a stale record from an earlier date blocks another clock-in until it is completed, corrected, or voided. check_out_at >= check_in_atwhen both exist.openrequires check-in and no check-out;completerequires both;absentandexcusedrequire neither;voidedis excluded from normal totals.- A normal
self,team, orassistantcheck-in requires all check-in photo/GPS fields; a normal checkout requires all check-out photo/GPS fields.absent,excused, and an authorizedmanualrepair may have no capture evidence, but must remain visibly labeled and reasoned. - Check-in and check-out photo IDs must differ. A completed attendance photo may be reused only by the selected records in one idempotent team batch, never by another event or later batch.
- Composite indexes match
(organization, work_date, member)and(organization, team, work_date, id). - Every mutation goes through an attendance service that locks the employee-day target before deciding and synchronizes idempotency inside the same transaction.
4.3 Photo and GPS evidence custody
F106 reuses the F34 upload lifecycle but cannot use F34’s current public-read object mode.
Attendance implementation is blocked until F34 supports a forced private
purpose="attendance_photo" path with:
- no
public_urlin the presign or upload response; - private S3 object ACL/policy and encryption at rest;
- a completed, same-tenant, actor-owned
Upload; private attendance uploads deliberately create no genericEvidenceSource, because that organization-wide surface would disclose the capturing member and clock-event timing; purpose="attendance_photo",visibility="private", nullableconsumed_at, and nullableconsumption_keyfields so one clock transaction can atomically claim the upload;- image-only MIME validation, at most 5 MB after client compression, and server-verified object existence;
- authorized short-lived signed GET generated only after F106 tenant, capability, current managed-member, and matching record-team scope;
- no stable URL in API payloads, logs, audit metadata, notifications, or exports.
The client must open an in-app live camera, not a gallery/file picker, and re-encode the capture to JPEG before upload so EXIF metadata is discarded. GPS comes from the platform’s foreground location API, never photo EXIF. The server treats coordinates as device-reported evidence, not tamper-proof identity or presence proof.
Freshness and reuse:
- The completed photo upload must have been created for the same actor and attendance action no more than five minutes before the clock request.
- The GPS payload is acquired in the foreground for the same action and submitted in that request.
Its
device_captured_atmust be no more than five minutes before server receipt and no more than one minute in the future. It is retained for diagnostics but cannot override server time. - The clock transaction atomically consumes the photo and its paired GPS payload. A non-idempotent later action cannot reuse either. The original idempotent retry returns the prior outcome, and members of the same team batch may share only that batch’s consumption key.
- Any positive OS-reported accuracy is accepted. Above 100 meters the record is stored and visibly
labeled
low_accuracy; Lumi does not fabricate a precise location or silently reject indoor/ rural workers after a fix exists. - One authorized team-clock batch may reference the same fresh group photo/GPS point from every
successful selected record. The batch must be one transaction/idempotency scope, the evidence
belongs to the authenticated owner/admin/manager actor, and each record is labeled
shared_team. - Photo and GPS fields are immutable after creation. A correction appends audit/revision history and may repair time/status, but cannot rewrite original capture evidence.
- Voiding a record does not delete its evidence. Photo retention follows the attendance record and organization deletion lifecycle; evidence cannot be independently publicized or detached.
- A private attendance upload becomes ineligible when its five-minute freshness window closes. If it remains incomplete or unconsumed, the attendance upload cleanup deletes its object and metadata no later than one hour after creation. Cleanup failures retain private access denial, alert operations, and retry until deletion succeeds.
4.4 Derived values
Derived values are computed from record snapshots, not current policy:
worked_minutes = max(0, check_out_at - check_in_at - unpaid_break_minutes_snapshot).scheduled_minutesis null when the schedule is unset. When it is set it isscheduled_end_snapshot - scheduled_start_snapshot - unpaid_break_minutes_snapshot— net of the break, the same basis asworked_minutes, so the two are directly comparable. Reporting one gross and the other net would make every ordinary full day look like overtime.late_minutes,early_leave_minutes, andextra_minutesare null when the schedule is unset.- Grace is applied before positive late or early minutes are returned.
- An open record has no worked total until checkout; partial elapsed time may be displayed separately but is never exported as a completed duration.
extra_minutesis reference data only. It is not approved or payable overtime.not_recordedis a transport/UI projection for an active member-day with no active row. It is available only on/after that member’sjoined_atdate. It is not a persisted status and is never silently converted toabsent.not_scheduledis a transport/UI projection for an active member-day with no active row on a non-working date. It keeps the member available for normal team clocking without claiming their silence means anything. It is never persisted, counted as an expected employee-day, converted toabsent, or emitted as an export row.- Expected employee-days and
not_recordedprojections include only dates present in the organization’sworking_days. A real clock record on another date is retained and exported, but its schedule, late, early-leave, and extra-minute comparisons are null.
4.5 AttendanceCorrectionRequest
Extends UUIDModel:
| Field | Shape | Rule |
|---|---|---|
organization | FK | Required tenant owner |
record | nullable FK | Null permits a request for an entirely missed day |
member | FK | The employee whose attendance would change |
work_date | date | Organization-local date |
requested_by | member FK | Must be self or an authorized leader |
proposed_check_in_at / proposed_check_out_at | nullable UTC datetimes | Validated together |
proposed_status | enum | open, complete, absent, or excused |
reason | non-empty string | Required |
record_revision | nullable integer | Null only when no record existed |
status | enum | pending, approved, rejected, cancelled |
decided_by, decided_at, decision_note | nullable | Required together for approved/rejected |
Only one pending request per member/work date is allowed. Approval locks and re-reads the target. A
revision mismatch returns 409 rather than applying a stale proposal.
4.6 State machines
stateDiagram-v2
[*] --> NotRecorded
NotRecorded --> Open: clock in
Open --> Complete: clock out
NotRecorded --> Absent: authorized mark
NotRecorded --> Excused: authorized mark
Open --> Voided: correction with reason
Complete --> Voided: correction with reason
Absent --> Voided: correction with reason
Excused --> Voided: correction with reason
Open --> Open: audited correction
Open --> Complete: audited correction
Complete --> Complete: audited correction
Absent --> Complete: audited correction
Excused --> Complete: audited correction
Open --> Complete: audited correction is the exit §9 promises when a normal checkout arrives more
than 24 hours after check-in: that request returns 409 and “offer correction path”, which is only
a path if a correction can close an open day. Complete --> Absent has no edge and is refused —
a day with captured evidence is not turned into an absence.
stateDiagram-v2
[*] --> Pending
Pending --> Approved: authorized decision
Pending --> Rejected: authorized decision
Pending --> Cancelled: requester cancels
Approved --> [*]
Rejected --> [*]
Cancelled --> [*]
5. API Contract
Every request that creates or changes attendance state requires Idempotency-Key: clock,
team clock, record correction, void, correction request, correction decision, and cancellation. A receipt binds
the authenticated member, HTTP method, target path (including the record/request ID), and body. A
missing or blank key returns 400; reusing it for another actor, target, method, or body returns
409. A replay re-checks current row scope before returning stored private record data. The
organization policy singleton is exempt — PATCH /api/attendance/policy/ is last-write-wins on one
row, so a key would add a client contract without adding safety. Every queryset starts with the
current tenant —
in this repository that is organization_id=request.organization_id, set by the IsOrgMember
permission class from the X-Organization-ID header — and capability plus member/team scope are
applied only after tenant scope. Cross-tenant object identifiers return 404.
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
GET | /api/attendance/policy/ | authenticated | none | effective policy, enabled state, org timezone, and working days | 403 only if entitlement denies F106 |
PATCH | /api/attendance/policy/ | attendance.settings | policy fields | updated policy | 400 invalid schedule; 403 |
POST | /api/uploads/presign/ | self/team clock capability | image MIME/size plus purpose="attendance_photo" | private upload ID and POST fields; no public URL | 400; 403; 429; 503 private mode unavailable |
POST | /api/uploads/{id}/complete/ | upload actor | attendance purpose | completed private upload; evidence_source_id=null because generic evidence rows are forbidden for this purpose | 400; 403; 404; 409 |
GET | /api/attendance/day/?date=&team_id=&cursor= | attendance.read_team | ISO date, optional scoped team | cursor page of member-day rows | 400 date; 404 team; 403 |
GET | /api/attendance/me/?before=&limit= | own read, self clock, or correct capability | optional cursor | own attendance record history | 400 cursor; 403 |
GET | /api/attendance/records/{id}/ | own/team read or correct capability plus row scope | none | reloadable record detail with actor snapshots plus server-derived can_request_correction / can_correct / can_void | 403; 404 |
GET | /api/attendance/records/{id}/evidence/{event}/photo/ | own or scoped team read | event is check_in or check_out | redirect/response with short-lived private signed GET | 403; 404; 410 evidence unavailable |
POST | /api/attendance/clock/ | attendance.clock_self | action enum, private photo ID, and GPS payload | current own record + idempotent outcome | 400; 403; 409; 422 evidence invalid |
POST | /api/attendance/team-clock/ | attendance.clock_team | action, member IDs, reason if needed, photo ID, GPS payload | itemized bounded outcomes | 400; 403; 404; 422; scope-wide rejection |
PATCH | /api/attendance/records/{id}/ | attendance.correct | expected revision, timestamps/status, reason | revised record; original evidence unchanged | 400; 404; 409 stale |
POST | /api/attendance/records/{id}/void/ | attendance.correct | expected revision, reason | voided record; evidence retained | 400; 404; 409 stale |
POST | /api/attendance/correction-requests/ | own or managed scope | work date, proposal, reason | pending request | 400; 403; 409 pending exists |
GET | /api/attendance/correction-requests/ | own-flow capability or correct scope | optional status, work date, member, record, cursor | bounded queue with server-derived per-row can_approve / can_reject / can_cancel | 400; 403 |
POST | /api/attendance/correction-requests/{id}/decide/ | attendance.correct | decision, note | request + resulting record when approved | 400; 404; 409 stale |
POST | /api/attendance/correction-requests/{id}/cancel/ | requester only | none | cancelled request | 403; 404; 409 not pending |
GET | /api/attendance/export.xlsx?from=&to=&team_id=&status= | attendance.export | bounded filters | .xlsx stream | 400; 403; 404; 422 too large |
The clock payload contains action, photo_id, and
gps={latitude, longitude, accuracy_m, device_captured_at}. Team clock also contains bounded
member_ids and a stable client batch_id; absent and excused omit capture fields and require
reason. The API never accepts photo bytes, a client-provided object URL, or coordinates encoded
only in photo metadata.
member_ids holds at least 1 and at most 200 entries. One batch is one transaction over one
locked employee-day set, so the bound keeps that transaction and its itemized response finite; 200
is far above the largest realistic single roll call while still permitting a whole site crew in one
capture. An empty list or more than 200 entries returns 400 with zero writes, rejected before any
scope resolution or evidence validation.
Record detail is a direct server read, not a reconstruction from a warm day/history cache. It
includes the immutable member/team and clock-actor name snapshots required to explain who recorded
shared team evidence. The correction queue is UUIDv7 cursor-bounded with a capped count. A member sees requests they
filed; a caller with attendance.correct additionally sees only requests they could decide under
the same current-roster plus record-team-snapshot rule as the decision endpoint. For a missed-day
request with no record snapshot, owner/admin scope or current managed membership is required.
Out-of-scope list rows are omitted rather than disclosed.
Capability checks must not strand an authorized write flow. clock_self can read only the
caller’s own history required by the Today surface, while attendance.correct can reload only an
own target and the caller’s own filed requests unless the ordinary managed-team predicate also
passes. These reachability reads do not create a role-derived or tenant-wide read grant.
Correction writes re-read the actor’s active membership and required capability under
select_for_update before resolving or locking the target row. Authentication-time membership is
not authoritative for a transaction that may begin after suspension or capability removal.
Conflict responses that support recovery carry an optional typed record or
correction_request; those fields are declared in the generated OpenAPI schema rather than hidden
inside an untyped error body.
Clients must not reconstruct row authorization from role labels, names, or a global capability
alone. Record detail returns can_request_correction, can_correct, and can_void; each correction row returns
can_approve, can_reject, and can_cancel. Approval is false when the proposal is already known
to be stale, voided, transition-invalid, or superseded by a real record, while rejection may remain
available. These booleans are derived from the same server predicates as the corresponding
mutation and are presentation affordances only: every mutation re-authorizes the row.
5.1 Capability and row-scope matrix
Capabilities are registered in F73 and enforced in the service layer. Raw frontend role checks are
not authorization. OrganizationMember.team is the attendance roster edge and Team.manager is
the leadership edge. The legacy TeamMember display roster does not grant attendance access.
Manager authorization always follows the member’s current active team scope. Record/team
snapshots also narrow sensitive historical access: a manager may act only when the member is
currently active in a team the manager leads and the record’s team snapshot matches that current
team. After a transfer, the current manager sees records snapshotted to the new team, the former
manager loses access with the roster edge, and pre-transfer history becomes owner/admin-only. After
member removal, all historical snapshots are owner/admin-only.
| Role | Own read | Self clock | Team read | Team clock | Correct/decide | Export | Settings |
|---|---|---|---|---|---|---|---|
| Owner/admin | yes | yes | all teams | all teams | all organization | all organization | yes |
| Manager | yes | yes | managed teams | managed teams | managed teams | managed teams | no |
| Member | yes | when enabled | no | no | own request only | no | no |
| Viewer | yes | no | no | no | no | no | no |
Proposed capabilities:
attendance.read_ownattendance.clock_selfattendance.read_teamattendance.clock_teamattendance.correctattendance.exportattendance.settings
5.2 Clocking rules
- Normal self/team clocking accepts no client timestamp. The backend uses current server time and
resolves the organization-local
work_date. clock_inandclock_outfirst validate a completed private attendance photo and foreground GPS payload. Missing, public, stale, cross-tenant, foreign-owned, reused, non-image, or incomplete evidence rejects the entire action before any attendance row is locked or changed.- GPS requires numeric latitude/longitude in range, positive accuracy meters, and device capture time within the five-minute age and one-minute future-skew bounds. The server stores the values verbatim after validation and never replaces a low-accuracy fix with a guessed point.
- A record has one clock-in and one clock-out in v1. Split sessions are rejected.
- Before creating a clock-in, the service locks and checks all open records for that organization
member. Any open record, including one from an earlier date, returns
409with that record. - A checkout may cross local midnight and remains attached to the check-in date. Normal checkout
more than 24 hours after check-in returns
409and requires a reasoned correction. - Normal clocking is allowed on any date.
absentandexcusedteam actions are accepted only on organization working days. - The day roster returns every active member on or after their joined date. A member with no record
is
not_recordedon an expected working day andnot_scheduledotherwise; a real record replaces either projection. - Team operations first resolve every member within the actor’s tenant and row scope. Any foreign/out-of-scope member rejects the whole request with no writes.
- Team clock-in/out uses one private group photo and GPS payload for one idempotent batch. The
evidence may be attached only to successful records from that batch and is labeled
shared_team.absentandexcusedrequire a reason but no photo/GPS because they are not clock events. - After scope validation, state conflicts are itemized per member. They do not roll back successful non-conflicting members.
- Concurrent self/team writes lock the same employee-day target and rely on the unique constraint as a final invariant, never as ordinary control flow.
5.3 Excel contract
The backend generates the workbook with the existing openpyxl dependency in write-only mode.
Files are streamed, not persisted, and responses use Cache-Control: private, no-store.
- Default range: current organization-local calendar month.
- Maximum range: 366 days.
- Maximum output: 100,000 employee-day detail rows. Larger requests return
422with guidance to narrow the range. - Workbook locale: requester’s resolved
enorvilocale. - Workbook metadata includes organization name, timezone, selected range, and UTC
generated_at. - Cells contain values only, never formulas.
- String values are escaped as text when the first non-whitespace/control character is
=,+,-, or@, or when a value starts with a tab or carriage return.
Sheets:
Attendance/Bảng công: one employee-day per row with date, employee, email, team, status, check-in, check-out, worked minutes/hours, scheduled minutes, late minutes, early-leave minutes, extra minutes (reference only), check-in/out latitude, longitude, accuracy meters, evidence status, capture mode, recorded-by actor, correction status, authenticated record reference, and note. It contains no image bytes, public photo URL, or expiring signed URL.Summary/Tổng hợp: one employee per row with resolved/expected day counts, complete, absent, excused, not-recorded, worked minutes/hours, scheduled minutes, late minutes, early-leave minutes, and extra minutes (reference only). It contains no wage or pay formula.
Current active roster membership creates not_recorded rows only on/after each member’s
joined_at date, only on organization working days, and only up to and including the current
organization-local date. A future working day inside the selected range is not projected: the
default range is the current calendar month, and stamping not_recorded on days that have not
happened yet would report an absence of evidence as evidence of absence. A real record dated in the
future is still exported. A voided record leaves its day with no active row, so that day projects
as not_recorded exactly as the day API reports it. Pending F02 invitations do not create
employee-days. Historical removed members appear only through existing record snapshots, because
F106 cannot reconstruct a roster that was never stored.
5.4 Audit and observability
F16 audit events are required for policy updates, self/team clock actions, absence/excuse, direct corrections, correction decisions, voids, and exports. Events contain tenant, actor, target IDs, source, evidence object IDs, idempotency key hash, and changed field names, but never photo URLs, image bytes, precise coordinates, or workbook contents.
Logs and metrics may count outcomes and latency. They must not contain employee names, emails, notes, raw correction reasons, coordinates, photo URLs, image contents, or workbook cell values.
6. UI / UX
6.1 Web routes
/work/attendance: own status plus authorized day/team review, filters, correction queue, and export./settings/attendance: capability-gated policy settings, linked directly from Attendance for actors withattendance.settings./work/today: compact self clock card with current state and one primary action.
The full page uses existing calm table/card patterns. It must not look like a surveillance dashboard. A record detail may show its two static capture points and private photos, but there is no aggregate employee map, route history, ranking, red shame score, or animated counter.
6.2 Mobile integration
- Do not add a sixth top-level tab.
- On the four-tab V2 Lumi homepage, expose one labeled
Attendanceaction for every authenticated role. The action opens the existing shared Attendance route immediately. Homepage copy must not claimClock inorClock outunless that homepage has loaded authoritative attendance state. - Put the compact self clock card near the top of the existing Pulse/Today lane.
- Add
Attendanceto the Workspace directory and push the shared KMP attendance screen. - Managers with capability receive the same scoped day/team actions.
- Mobile roster rows expose the next valid batch direction from server status: unrecorded or unscheduled rows can be selected for clock-in, open rows can be selected for clock-out, and a selection cannot mix directions. Personal history and manager roster rows show concise local check-in/check-out times; merged accessibility labels include employee, state, and times.
- Correction request, direct-correction, void, and decision dialogs use a bounded visible viewport, consume the platform keyboard inset, scroll only their body content, and keep Cancel/submit actions pinned and reachable on short Android screens.
- Self clocking launches the front camera by default; team clocking launches a camera suitable for a group photo. Both request foreground location just in time, show photo/GPS accuracy before confirm, upload privately, and submit only after upload completion.
- Camera and location permissions are requested from the capture flow, never at app launch. Android/iOS request foreground location only; background permission is forbidden.
- Export opens the platform share sheet after a successful authenticated download.
- Android and iOS use shared DTOs and screen state; platform shells own allowed download/share behavior.
6.3 Required states
- Loading
- Network/error with explicit retry
- Attendance not configured
- Schedule not configured
not_recordednot_scheduled- Open with clock-in time
- Complete with clock-in, clock-out, and duration
- Absent
- Excused
- Voided, visible only in audit/history contexts
- Pending correction
- Empty team/date
- No permission
- Camera permission denied
- Location permission denied
- Camera unavailable
- Taking/compressing/uploading photo
- Acquiring GPS
- Low-accuracy GPS warning with the measured accuracy
- Capture preview before confirm
- Private photo unavailable/expired with explicit retry
- Offline, with no optimistic success
- Export generating, ready, too large, and failed
Self flow is Clock action -> camera -> GPS -> preview -> confirm -> private upload -> server commit. Team flow adds member selection and states that one group photo/GPS fix will be shared by
the selected records. Neither flow can skip capture or show success before the server commit.
6.4 Components and design system
Before implementation, reuse primitives from frontend/src/components/ui/:
Card,Button,Table,Badge,TabsDatePickeror the repository’s current date primitiveSelect,Checkbox,Dialog,AlertDialogFormwith React Hook Form and Zod- Existing toast, skeleton, empty-state, and error-state patterns
Use Paper/White roots, Civic Navy text, restrained semantic status color, 44px minimum touch
targets, visible focus, screen-reader labels, and motion-reduction support. DESIGN.md and
TASTE.md pre-flight remain binding.
6.5 i18n
Namespace: attendance. English and Vietnamese are authored together; every other locale must
also receive the namespace, seeded with node frontend/scripts/propagate-new-i18n-keys.mjs, or
check-locale-parity.mjs fails — and because the runtime fallback is vi, a missing key renders
Vietnamese to every non-vi user.
The list below names concepts, not literal key strings. Actual keys are flat top-level
snake_case (clock_in, not_recorded, camera_permission_denied), matching the ~3,978
snake_case keys already in frontend/src/i18n/locales/en/*.json — there are zero camelCase keys in
the repo. (Corrected 2026-07-27: this section originally spelled the concepts in camelCase, which
no locale file in this codebase uses.)
At minimum:
title,today,myHistory,team,settings,exportclockIn,clockOut,recordForTeam,markAbsent,markExcusednotRecorded,open,complete,absent,excused,voidednotScheduledrecordedBy,selfRecorded,pendingCorrectionscheduleNotConfigured,selfClockDisabled,offlineNoChangeinvalidClockState,staleRecord,memberOutOfScope,exportTooLargecorrectionReason,requestCorrection,approve,reject,voidRecordworkedMinutes,scheduledMinutes,lateMinutes,earlyLeaveMinutes,extraMinutesReferenceexportGenerating,exportReady,exportFailedtakePhoto,retakePhoto,capturePreview,sharedTeamEvidencecameraPermissionDenied,cameraUnavailable,openDeviceSettingsacquiringLocation,locationPermissionDenied,locationUnavailable,locationAccuracylowAccuracyWarning,photoUploading,photoUploadFailed,evidenceUnavailable
6.6 Assistant path
F91 conversational parity is required, but the assistant does not infer attendance.
Read tool:
read_attendance_day(date?, team?)
Assistant contracts:
prepare_attendance_self_capture(action)prepare_attendance_team_capture(action, members)request_attendance_correction(date, proposed values, reason)decide_attendance_correction(request, decision, note)export_attendance(from, to, team?, status?)
The assistant cannot manufacture or receive capture evidence. A self/team clock intent returns a
manifest-owned capture_required ActionCard that opens the allowlisted attendance capture route.
As built (2026-07-27): the route comes from one server-side table (
ATTENDANCE_CAPTURE_ROUTESinapps/assistant/action_routes.py), so there is no per-client wiring — butcapture_requiredhas no first-class slot in the Action Manifest schema, so the card carries it in the executor result (result.capture_required,result.route) and a renderer must read the result rather than a declared card kind. That works and is what reaches the client; it is not as clean as a declaredhandofffamily with a destination, which would need anACTION_MANIFESTschema change (P47/ADR-045) and is deliberately out of F106’s scope. Recorded so the next manifest revision can absorb it rather than rediscover it. The client obtains photo/GPS directly, shows the preview, uploads the private photo, then submits the canonical attendance mutation after user confirmation. Raw image bytes, object URLs, and precise coordinates never enter the model prompt or assistant transcript.
All attendance writes are confirm-only with autonomy_ceiling=0. Exact in-tenant member resolution
is required; ambiguous names produce structured choices, and unknown names produce no action.
Reading/exporting follows the same row scope as direct UI. read_attendance_day returns evidence
presence/mode and accuracy status, not image bytes, URLs, or raw coordinates. The assistant may
return the generated workbook, but it may never announce that someone was present merely because
Lumi saw a Pulse, task event, message, login, or passive location signal.
7. Test Plan
This section defines planned proof. No listed test exists until implementation adds it. The case-by-case TDD catalog, including the team-batch cases, is docs/test-plan/F106-attendance-clock-in.md; the execution order is P59.
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Policy validation and historical snapshot stability | backend | backend/tests/attendance/test_policy.py |
| 2 | Normal self clock-in/out requires separate complete private photo/GPS sets and uses server time | backend | backend/tests/attendance/test_clocking.py |
| 3 | Missing photo, missing GPS, client timestamp, and partial evidence each reject with zero attendance writes | backend | backend/tests/attendance/test_clocking.py |
| 4 | Idempotent retries, concurrent self/team writes, correction authorization plus active-day resolution after the stable member lock shared with clock writers, concurrent correction-request creation, and cross-date open-record uniqueness | backend | backend/tests/attendance/test_corrections.py, test_concurrency.py, test_concurrency_pg.py |
| 5 | Cross-tenant record, member, team, correction, evidence, signed-photo read, and export isolation | backend | backend/tests/attendance/test_multitenancy.py |
| 6 | F73 allow/deny, own evidence access, manager current-team plus record-team scope, transfer, and removal boundaries | backend | backend/tests/attendance/test_permissions.py |
| 7 | F34 attendance purpose creates only private actor-owned objects, returns no public URL, enforces signed access, and marks authorization JSON plus signed image bytes private, no-store | backend | backend/tests/attendance/test_evidence.py |
| 8 | Public, foreign-owned, cross-tenant, stale, reused, incomplete, oversized, or non-image uploads are rejected | backend | backend/tests/attendance/test_evidence.py |
| 9 | GPS preserves values, accepts positive low accuracy, and rejects invalid range, age, future skew, or replay | backend | backend/tests/attendance/test_evidence.py |
| 10 | Team preflight rejects one out-of-scope member or missing group evidence with zero writes | backend | backend/tests/attendance/test_team_clock.py |
| 11 | One actor-owned group photo/GPS set is shared only by successful records in one idempotent team batch | backend | backend/tests/attendance/test_team_clock.py |
| 12 | Team state conflicts return itemized bounded outcomes after scope/evidence preflight | backend | backend/tests/attendance/test_team_clock.py |
| 13 | Correction lifecycle preserves original photo/GPS, labels and attributes evidence-missing manual repair, requires decision notes, and rejects stale revision | backend | backend/tests/attendance/test_corrections.py |
| 14 | Cross-midnight checkout, 24-hour bound, DST/timezone snapshots | backend | backend/tests/attendance/test_time_boundaries.py |
| 15 | Working-day/joined-date projections, off-day real records, and composite-cursor history without same-date skips | backend | backend/tests/attendance/test_expected_days.py |
| 16 | F16 events cover mutations/export while logs and audit exclude coordinates, photo URLs, and image bytes | backend | backend/tests/attendance/test_audit.py |
| 17 | XLSX detail includes coordinates/accuracy/evidence mode/reference and contains no image or photo URL | backend | backend/tests/attendance/test_export.py |
| 18 | XLSX summary, locale, value-only cells, injection escaping, 366-day/100,000-row bounds, and no persisted file | backend | backend/tests/attendance/test_export.py |
| 19 | Web camera/geolocation permission, capture/upload/preview, low-accuracy, private-read, and retry states | frontend | frontend/src/test/attendance/AttendancePage.test.tsx |
| 20 | Web self and authorized team-batch journeys cannot confirm or show success without complete evidence | e2e | frontend/tests/e2e/f106-attendance.spec.ts |
| 21 | Assistant returns capture handoff, enforces scope/confirmation, and keeps raw photo/URL/coordinates out of context | backend eval | backend/tests/evals/assistant/test_attendance.py |
| 22 | Shared KMP capture-state contracts model self, batch, retry, and failure states | KMP | android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/attendance/AttendanceContractTest.kt; android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/attendance/AttendanceBatchContractTest.kt; android/shared/src/commonTest/kotlin/com/locuno/lumi/shared/presentation/attendance/AttendanceCaptureLifecycleContractTest.kt |
| 23 | Android uses in-app camera plus foreground-only location and declares no background location permission | source/runtime | .maestro/critical/114_attendance_self_capture.yaml; android/app/src/test/kotlin/com/locuno/lumi/android/ui/AttendanceAndroidActualsTest.kt |
| 24 | iOS source uses in-app camera plus when-in-use location and does not request always/background location | source only | scripts/tests/test_f106_ios_actuals_source.py; iOS M12/M13 runtime remains open, with named-device proof also open |
| 25 | Android team shared evidence, private photo review, export download, and native share work end to end; iOS proves only private evidence and native share | platform E2E | Android M13–M15 and iOS M14–M15 via .maestro/critical/115_attendance_team_capture.yaml through 117_attendance_export_share.yaml and scripts/run-f106-attendance-proof.sh; iOS M12/M13 runtime remains open |
| 26 | Stale/incomplete/unconsumed attendance uploads are unusable and object/metadata deletion finishes within one hour | backend | backend/tests/attendance/test_evidence_cleanup.py |
| 27 | Correction and decision dialogs keep action rows reachable under short Android and IME-constrained viewports | source/contract | android/app/src/test/kotlin/com/locuno/lumi/android/ui/AttendanceCorrectionDialogSourceTest.kt |
Backend coverage must stay at the repository gate of at least 97%. A new browser E2E describe
block is required. Mobile proof must use the single approved runtime lane and lifecycle-owning
runner; emulator/simulator proof does not close named-device gaps.
8. Current Behavior
Backend, web, shared KMP, and both native actuals exist. As of 2026-07-28 the attendance domain,
API, evidence custody, export, and assistant surface are covered by the backend suite. The web has
both routes, self and one-request team capture, honest day/history/detail review, fresh-authorized
private evidence, corrections, policy settings, and bounded workbook download. E01–E06 pass in
real Chromium with deterministic fake-media frames and Playwright-controlled foreground geolocation.
An organization with no saved policy is immediately clock-ready with self/team clocking and
correction requests enabled, an unset schedule, zero break, and zero grace. A persisted disabled
switch still wins. Actors with attendance.settings receive a direct Settings link from
the web Attendance surfaces.
One guarded Android emulator lifecycle passed M12–M15; guarded iOS simulator lifecycles passed
M14 private evidence and M15 native workbook share. The browser and mobile runtime fixtures use
local private-storage substitutes, so they prove mechanics rather than live bucket-policy denial.
Correction and decision dialogs now use a bounded keyboard-safe card with a scrollable body and
pinned actions; the source/contract proof does not claim named-device interaction.
No named-device attendance evidence exists, and iOS capture remains runtime-unproven because the
simulator has no usable camera.
Endpoints (backend/apps/attendance/urls.py, mounted at /api/attendance/) — twelve routes,
thirteen operations:
| Route | Methods | Purpose |
|---|---|---|
policy/ | GET, PATCH | Read the effective policy; edit it with attendance.settings |
clock/ | POST | One self clock-in or clock-out |
team-clock/ | POST | One roll call of 1–200 members in one request |
day/ | GET | One organization-local day across the caller’s row scope |
me/ | GET | The caller’s own history, keyset-paged |
export.xlsx | GET | The streamed timesheet workbook |
records/<pk>/ | PATCH | One revision-checked correction |
records/<pk>/void/ | POST | Retire an employee-day, never delete it |
records/<pk>/evidence/<event>/photo/ | GET | Mint a short-lived private read URL |
correction-requests/ | POST | Propose a repair without applying it |
correction-requests/<pk>/decide/ | POST | Approve or reject a proposal |
correction-requests/<pk>/cancel/ | POST | Withdraw your own pending proposal |
All thirteen operations carry declared request and response schemas in openapi/schema.yaml
(@extend_schema on every view in backend/apps/attendance/views.py), so a generated client has
typed bodies rather than untyped paths.
Models (backend/apps/attendance/models.py, migration 0001_initial): AttendancePolicy,
AttendanceRecord (the employee-day, with schedule/timezone/roster snapshots, separate check-in and
check-out evidence columns, revision, and the status state machine), and
AttendanceCorrectionRequest. Derived values live in derived.py and are computed from the
record’s own snapshots, never from current policy.
Authorization. Seven capabilities are registered in backend/apps/permissions/catalog.py
(attendance.read_own, clock_self, read_team, clock_team, correct, export, settings).
Row scope is backend/apps/attendance/scope.py: manager access requires both a current managed
member edge and a matching record team snapshot; out-of-scope identifiers answer 404, not 403.
Tenant scope from IsOrgMember precedes both.
Write safety. Every mutation runs inside core/idempotency.py::idempotent_operation, so a
refusal rolls the attempt back and leaves the Idempotency-Key unused. The receipt is actor- and
target-bound; replay re-checks current row scope before returning the stored body with
replayed: true. Database-level uniqueness enforces one open record per member and
one active row per employee-day; backend/tests/attendance/test_concurrency_pg.py arbitrates the
simultaneous-clock-in race on Postgres.
Evidence custody (ADR-048, backend/apps/uploads/, migration 0004): Upload.purpose is the
only visibility input and attendance_photo derives private storage server-side. consume_upload
claims a photo exactly once, generate_private_signed_get mints a short-lived read only for an
authorized caller, and tasks.cleanup_stale_private_uploads deletes an unconsumed attendance
upload and its object after ATTENDANCE_PHOTO_ORPHAN_TTL_SECONDS (one hour). The pre-existing
public F34 CDN path is unchanged for its existing consumers.
Export (backend/apps/attendance/export.py): a two-sheet openpyxl workbook, bounded at
MAX_RANGE_DAYS = 366 and MAX_DETAIL_ROWS = 100_000 — both refused before a workbook is built.
escape_text renders formula-like strings as text. The response is streamed with
Cache-Control: private, no-store and carries no image bytes, object URL, or storage key.
Assistant surface (F91): attendance lookup language in Ask Lumi is routed to one deterministic read tool read-attendance-day
(backend/apps/ai/toolbelt_registry_attendance.py, which redacts evidence to presence/mode/accuracy
status) and five manifest-first actions in
backend/apps/assistant/action_definitions_attendance.py, every one of them autonomy=0 and
confirm-only. The clock actions return a capture-required handoff to a client camera/GPS flow that
does not exist yet; the assistant never receives image bytes, URLs, or precise coordinates.
Unchanged constraints. Active organization members and teams (F02/F03), organization timezone and working days, capability-based authorization (F73), audit logging (F16), and the web/mobile shells (F30/F79) are reused, not reimplemented.
Daily Pulse/F103 reporting is a narrative work update, not attendance. F90 execution events are a
task/job ledger, not employee-day timekeeping. Neither may be relabeled, queried as a proxy, or
migrated into attendance records; backend/tests/attendance/test_clocking.py::TestNonInference
holds that line.
9. Error States
| Condition | API behavior | User-visible behavior | Retry/logging |
|---|---|---|---|
| Attendance policy disabled | owner/admin may read/setup; other attendance operations return 403 | Setup prompt for owner/admin; unavailable state for others | No retry until enabled |
| Self clock disabled | 403 stable code | attendance.selfClockDisabled | No retry; security outcome metric only |
| Viewer attempts mutation | 403 | Existing no-permission copy | No retry |
| Cross-tenant or out-of-scope object | 404 | Generic not found | Security counter; no object detail |
| Team payload contains out-of-scope member | 403, zero writes | attendance.memberOutOfScope | Correct selection, then retry |
| Camera permission denied or camera unavailable | No clock request | Explain requirement; offer OS settings or cancel | No attendance write; no permission-loop retry |
| Location permission denied or no foreground fix | No clock request | Explain requirement; offer OS settings or retry acquisition | No attendance write; never request background access |
| Private attendance upload mode unavailable | 503, no attendance write | attendance.evidenceUnavailable | Retry later; operational alert |
| Photo compression/upload incomplete or failed | No clock request or 422, no attendance write | Keep preview when safe and offer explicit retry | Retry upload, then reuse the same action idempotency |
| Photo is public, stale, reused, foreign, or invalid | 404 for hidden ownership; otherwise stable 422 | attendance.photoUploadFailed | Recapture; never disclose object details |
| GPS invalid, over five minutes old, over one minute future, or replayed | 400 or stable 422, no attendance write | attendance.locationUnavailable | Acquire a new fix |
| GPS accuracy exceeds 100 m | Success with low_accuracy | Show measured accuracy warning | Not an error; user may voluntarily recapture |
| Team batch is empty or exceeds 200 members | 400, zero writes, before scope or evidence work | Narrow the selection | Adjust selection, then retry |
| Team clock lacks complete shared group evidence | 422, zero writes across the batch | Return to camera/location step | Recapture once for the batch |
| Clock-in while already open | 409 + current record | attendance.invalidClockState | Refresh state; no blind retry |
| New-day clock-in while an older record is open | 409 + older open record | attendance.invalidClockState | Complete or request correction for the older record |
| Clock-out without open record | 409 | attendance.invalidClockState | Offer correction request |
| Duplicate idempotency key | Original successful response | Current state | Safe automatic network retry |
| Client supplies normal clock timestamp | 400 | Generic invalid request | No retry |
| Checkout before check-in | 400 | Exact time validation | Correct values |
| Normal checkout exceeds 24 hours | 409 | Offer correction path | No blind retry |
| Stale correction revision | 409 + current record | attendance.staleRecord | Refresh and reconfirm |
| Pending correction already exists | 409 + pending request | Show pending state | No duplicate |
| Authorized photo URL expires | 410 or fresh authorization challenge, no bytes | attendance.evidenceUnavailable with retry | Reauthorize against current row scope |
| Schedule unset | Success with null comparisons | attendance.scheduleNotConfigured | Not an error |
| Offline/network failure | No server write acknowledged | attendance.offlineNoChange | Retry only with same idempotency key |
| Export range exceeds 366 days | 400 | Date-range guidance | Narrow range |
| Export exceeds 100,000 rows | 422 | attendance.exportTooLarge | Narrow date/team filters |
| Workbook generation fails | 500, no empty file | attendance.exportFailed | Error ID; same filters may retry |
| Suspended/inactive membership | 403 | Session/access message | No attendance retry |
Internal errors are reported to Sentry with tenant/user identifiers hashed or referenced by internal ID. Logs exclude names, emails, raw notes, correction reasons, coordinates, object keys, photo URLs, image bytes, and workbook content.
9.1 Edge cases
- Time: organization-local date is resolved on the server; UTC timestamps are stored; schedule snapshots preserve daylight-saving behavior; cross-midnight checkout stays on the check-in day.
- Concurrency: self and manager actions for the same member-day serialize on one locked target; retries reuse an idempotency key.
- Roster change: record name/email/team snapshots preserve existing history after deactivation, deletion, or reassignment; no-record projections use the current active roster only.
- State repair: an entirely missed day can enter through a correction request or authorized
manual record; it is labeled
manualandevidence_missing. No hidden backfill job creates absences or synthetic evidence. - Evidence ownership: a self photo belongs to that employee actor. A team group photo belongs to the authenticated owner, admin, or manager who performs the team clock and may be referenced only by successful selected records in that one batch.
- Evidence lifecycle: check-in and checkout use separate captures. Correction and void retain the originals; retention follows the attendance record and organization deletion lifecycle.
- Capture retry: an unfinished upload creates no attendance record. A completed fresh upload may be retried with the same idempotency key but cannot be consumed by a different action.
- Orphan cleanup: a stale, incomplete, or unconsumed attendance upload cannot be used and is deleted with its object/metadata no later than one hour after creation. A cleanup failure leaves it private, raises an operational alert, and retries.
- Location: any positive accuracy is retained as reported. A fix above 100 meters is visibly low-accuracy evidence, not grounds to invent a point or deny an otherwise valid clock action.
- Assistant: conversational intent opens the capture surface; the model never receives the image, private object reference, signed URL, or precise coordinates.
- Private read: an expired signed photo URL is reauthorized against current tenant/capability/ team scope. A previously authorized URL never grants durable access.
- Export: empty ranges still produce valid headers and metadata; large ranges fail before a workbook is streamed; formula-like strings remain text; images and photo URLs are never embedded.
- Network: no optimistic attendance success and no offline queue.
10. Diagrams & Docs Touched
At implementation time:
backend/DIAGRAMS.md: attendance models, indexes, tenant relationships, record state machine, correction state machine, private F34 upload/access sequence, clock/correction/export sequence, evidence retention, and redacted data flow.frontend/DIAGRAMS.md:/work/attendance,/settings/attendance, Today card, query/mutation flow, camera/geolocation permission state machine, private evidence viewer, and route ownership.- Native platform diagrams/docs: in-app camera capture, foreground-only location permission, capture preview, private upload, signed evidence read, and lifecycle-owned E2E proof.
DIAGRAMS.md: only if attendance becomes a separately deployed service, which this spec does not propose.GLOSSARY.md: planned attendance definition is added with this draft; update to as-built symbols when implemented.docs/MVP_SCOPE.md: F106 registry row and HRIS scope carve-out.docs/specs/done/F28-company-hub.md: clarify that F28 still stores policy documents only.docs/specs/done/F90-field-execution-layer.md: clarify that F90 labor/task time remains distinct.contracts/features/F106.cue: canonical requirements and explicit unimplemented gaps.
The F34 public/private object model ADR required before implementation is
ADR-048 (accepted 2026-07-27). It preserves existing
public F34 consumers while making purpose="attendance_photo" fail closed to private storage,
exactly-once consumption, short-lived re-authorized reads, and one-hour orphan deletion. A separate ADR is
required only if implementation also proposes multiple clock sessions per day, historical roster
snapshots beyond attendance rows, offline mutation, biometric analysis, geofence enforcement, or a
payroll boundary.
11. Implementation Audit (2026-07-28 — backend and web Phase 2 built)
✅ Fixed defect — the team roster was empty on non-working days
Found and fixed 2026-07-27 while building the web team lane.
day_rows() (backend/apps/attendance/services.py) emits a member row only when the member has a
record or is_expected_day() is true, and is_expected_day() requires
is_working_day(organization, work_date). §5.2 says plainly that “normal clocking is allowed on
any date” — only absent/excused are restricted to working days. The two do not agree.
User-visible consequence: on a Saturday, a manager whose organization has the default
mon,tue,wed,thu,fri working days opens the team lane and sees nobody. A weekend site crew,
a retail shift, a six-day factory week — none can be recorded at all. Six-day weeks are ordinary in
the markets this feature exists to win, so this is a core-promise failure, not an edge case.
Why it is not simply “include everyone”: the working-day gate is load-bearing for honesty.
not_recorded must mean “expected and absent from the record”, never “it was Sunday”. The fix has
to separate two different questions the code currently conflates:
- Who may I record today? — every active member, on any date.
- Whose silence today means something? — only expected days, per §4.4.
Decision: the day view returns the full active roster on or after each member’s joined date and
distinguishes not_scheduled from not_recorded; only the latter feeds expected-day counts,
adherence, and the export’s not_recorded rows.
Resolution: the day endpoint now returns joined, in-scope members as not_scheduled on
non-working days. Backend owner/manager and assistant regressions plus the web picker test pin the
state without changing expected-day counts or exports.
Verdict: the backend is built and covered; the web UI and its deterministic Chromium mechanics
are implemented through settings/export, but the product is not shippable. The API/assistant
surfaces below pass in backend/tests/; E01–E06 exercise the web flows in Chromium while stubbing
presign/S3/complete/attendance/workbook responses at the route boundary. All native surfaces,
named-device proof, live S3, live assistant, and production-scale export measurement remain absent.
A passing browser fixture is browser-mechanics proof, not live-service verification.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Policy read/write with schedule validation, snapshot immunity, and a settings capability gate | ✅ backend | apps/attendance/services.py::effective_policy, update_policy, validate_policy_fields; tests/attendance/test_policy.py (TestPolicyDefaultsAndToggles, TestPolicyScheduleValidation, TestPolicyDoesNotRewriteHistory, TestPolicySettingsCapability) |
| 2 | Self clock-in/out on authoritative server time, with mandatory photo + GPS and a refused client timestamp | ✅ backend | services.py::clock_self, _do_clock_in, _do_clock_out; serializers.py::ClockRequestSerializer; tests/attendance/test_clocking.py (TestClockIn, TestClockOut, TestEvidenceIsMandatory, TestClientTimestampIsRefused) |
| 3 | One-request team batch (1–200) with all-or-nothing scope and evidence preflight, then itemized per-member outcomes | ✅ backend | services.py::clock_team, _preflight_members, _claim_group_evidence; tests/attendance/test_team_clock.py (TestOneRequestOneEvidenceSet, TestScopePreflightIsAllOrNothing, TestEvidencePreflightIsAllOrNothing, TestItemizedOutcomes, TestBatchBounds) |
| 4 | Revision-checked correction, void, and propose/decide/cancel correction requests, all audited | ✅ backend | services.py::correct_record, void_record, create_correction_request, decide_correction_request, cancel_correction_request; tests/attendance/test_corrections.py (TestDirectCorrection, TestStaleRevision, TestVoid, TestDecision, TestCorrectionAudit) |
| 5 | Day, own-history, reloadable record-detail, and correction-queue reads honour tenant + capability + row scope, keep clock-self-only and correct-only own flows reachable, expose immutable actor snapshots, and project not_recorded rather than inventing an absence | ✅ backend | scope.py::visible_records, visible_correction_requests; services.py::day_rows, own_history, is_expected_day; tests/attendance/test_expected_days.py (TestNotRecordedProjection, TestDayEndpointContract, TestOwnHistoryEndpoint); test_corrections.py (TestRecordDetailRead, TestCorrectionRequestQueue) |
| 6 | Manager access needs both a current managed-member edge and a matching record team snapshot; transfer/removal revokes it immediately | ✅ backend | scope.py::_has_manager_scope, managed_member_ids; tests/attendance/test_permissions.py (TestManagerScope, TestTransferAndRemoval, TestLegacyTeamMemberGrantsNothing) |
| 7 | Tenant isolation on every attendance query, endpoint, and idempotency key | ✅ backend | IsOrgMember + explicit organization_id filters throughout apps/attendance/; tests/attendance/test_multitenancy.py (all four classes), test_corrections.py::TestCrossTenantIsolation |
| 8 | Seven attendance capabilities registered and correctly distributed across roles | ✅ backend | apps/permissions/catalog.py; tests/attendance/test_permissions.py::TestCapabilityCatalog, TestSettingsCapabilityGate, TestMemberAndViewerScope |
| 9 | Every mutation is idempotent: a refusal writes nothing and burns no key; receipts bind actor + HTTP target + body; replay re-authorizes current row scope before returning the stored body | ✅ backend | core/idempotency.py::idempotent_operation; tests/core/test_idempotency.py, tests/attendance/test_corrections.py::TestCorrectionIdempotency, TestIdempotentReplayAuthorization, test_team_clock.py::TestIdempotencyAndReplay |
| 10 | Database-level concurrency invariants: one open record per member, one active row per employee-day | ✅ backend | models.py::AttendanceRecord.Meta constraints; tests/attendance/test_concurrency.py, and the real race arbitrated on Postgres in test_concurrency_pg.py::test_two_simultaneous_clock_ins_create_exactly_one_record |
| 11 | Attendance photos are forced private by purpose, consumed exactly once, and read only through a short-lived authorized URL | ✅ backend | ADR-048; apps/uploads/services.py::visibility_for_purpose, consume_upload, generate_private_signed_get; tests/attendance/test_evidence.py::TestPrivateEvidenceRead, test_permissions.py::TestEvidenceReadAuthorization |
| 12 | An unconsumed attendance upload is unusable and is swept with its object within one hour | ✅ backend | apps/uploads/services.py::ATTENDANCE_PHOTO_ORPHAN_TTL_SECONDS, tasks.py::cleanup_stale_private_uploads; tests/attendance/test_evidence_cleanup.py::test_b38_*, test_b39_*, test_b40_* |
| 13 | GPS is stored verbatim, range-checked, freshness-checked, and low accuracy is labeled rather than refused | ✅ backend | serializers.py::GpsSerializer, services.py::validate_gps_freshness, is_low_accuracy; tests/attendance/test_evidence.py (TestGpsStoredVerbatim, TestAccuracy, TestGpsFreshness) |
| 14 | Derived values come from record snapshots, report null (never 0) when unknowable, and survive policy/timezone change | ✅ backend | apps/attendance/derived.py::derived_values; tests/attendance/test_expected_days.py (TestWorkedMinutes, TestGraceAndSchedule), test_time_boundaries.py::TestSnapshotsSurvivePolicyAndTimezoneChanges |
| 15 | Bounded, formula-safe XLSX export streamed with private, no-store and no image bytes, object URL, or storage key | ✅ backend | apps/attendance/export.py (MAX_RANGE_DAYS, MAX_DETAIL_ROWS, escape_text, stream_workbook); tests/attendance/test_export.py (TestWorkbookShape, TestWorkbookSafety, TestBounds, TestStreaming, TestScopeAndHonesty) |
| 16 | Audit records custody and shape only — never coordinates, names, emails, URLs, or correction reasons | ✅ backend | services.py::_audit_clock, _audit_correction, _audit_correction_request, views.py export audit; tests/attendance/test_audit.py::TestAuditPrivacy, test_corrections.py::TestCorrectionAudit, test_export.py::TestExportAudit |
| 17 | Attendance is never inferred from Pulse, execution events, messages, sessions, or an upload alone | ✅ backend | tests/attendance/test_clocking.py::TestNonInference, tests/evals/assistant/test_attendance.py::TestB106NonInference |
| 18 | Assistant read tool is deterministic, row-scoped, and redacts evidence to presence/mode/accuracy status | ✅ backend | apps/ai/toolbelt_registry_attendance.py::_redact_evidence, _redact_row, read-attendance-day; tests/evals/assistant/test_attendance.py (TestB100RowScope, TestB101EvidenceRedaction) |
| 19 | Every assistant attendance action is confirm-only at autonomy ceiling zero and returns a capture handoff rather than manufacturing evidence | ✅ backend | apps/assistant/action_definitions_attendance.py (five actions, all autonomy=0); tests/evals/assistant/test_attendance.py (TestB102CaptureHandoff, TestB103NoManufacturedEvidence, TestB104ConfirmOnly) |
| 20 | All fifteen attendance operations carry typed request/response schemas, including optional stale-record and correction-request recovery payloads, in the generated OpenAPI document | ✅ backend | @extend_schema / @extend_schema_view on every view in apps/attendance/views.py; AttendanceErrorSerializer and other response serializers in serializers.py; tests/attendance/test_corrections.py::TestAttendanceErrorSchema; regenerated openapi/schema.yaml |
| 21 | Web attendance surface — /work/attendance, /settings/attendance, Today clock card, team review, correction flow, export UI, camera + geolocation permission states | ✅ web implementation + Chromium mechanics | Both routes and all declared web flows have unit coverage. frontend/tests/e2e/f106-attendance.spec.ts executes E01–E06 in Chromium: two fresh self captures, camera/location denial with zero writes, delayed itemized team results, exactly one batch request, a fresh evidence-endpoint request and uncached URL on every open, and .xlsx download. Backend pytest separately proves current row-scope reauthorization. F106.GAP003 is closed |
| 22 | Native attendance surface — shared KMP contract, Android and iOS screens, in-app camera, foreground-only location, authenticated workbook share | 🚧 native implementation built; virtual mechanics partially proven | Shared KMP owns the API/fixture contract, capability-gated repositories, Today card, attendance screen, correction/evidence/export state, self/team state machines, itemized outcomes, and purpose-bound upload client. Attendance must be reachable from the Assistant Home +, the capability-gated Pulse card, and Workspace without adding a sixth tab. Android and iOS source actuals cover in-app camera, one foreground fix, JPEG re-encode, permission recovery, fresh private evidence, validated map handoff, and authenticated workbook sharing inside Workspace. A guarded Android emulator lifecycle passed M12–M15; guarded iOS simulator lifecycles passed M14/M15. iOS capture runtime and named-device proof remain open under F106.GAP004 |
| 23 | Browser and named-device runtime proof for the capture flow | 🚧 browser and virtual Android proven; named devices and iOS capture open | E01–E06 pass in real Chromium with deterministic media. Android M12–M15 passed in one lifecycle with matching APK hashes, exactly one successful team HTTP POST, itemized results, and exactly two successful evidence authorizations. iOS simulator M14/M15 passed, and M14 independently asserted two successful authorization requests, but the simulator’s lack of a usable camera prevents capture proof. No named Android/iPhone evidence exists, so F106.GAP004 and the remaining native portion of GAP007 stay blocking |
| 24 | Live S3 proof for private attendance object custody | ✅ dev + production live proof | Commit cf49e7689 ran backend/scripts/f106_private_s3_live_proof.py against both deployed media buckets with disposable synthetic objects: purpose-bound presign, AES256 upload, atomic consume, 60-second private, no-store signed read, direct-S3 and CloudFront 403, stale-orphan object/metadata sweep, and final cleanup all passed. deploy/s3-bucket-policy/{dev,prod}.json is versioned and the production deploy smoke repeats the storage-policy boundary. F106.GAP008 is closed |
| 25 | Live assistant runtime proof (real model, real thread) | 🔲 not proven | tests/evals/assistant/test_attendance.py exercises the tool and action contracts deterministically. No grounded run against a live provider has been recorded |
| 26 | Production-scale export timing | 🔲 not measured | MAX_DETAIL_ROWS = 100_000 is enforced, but no timing or memory measurement exists for a workbook near that ceiling on production-shaped data |
| 27 | F53/F74 export entitlement (assumption 10) | 🔲 open | There is no generic server-side export entitlement gate in this repository, and F106 did not invent one. The export is gated on the attendance.export capability plus the module switch (views.py::AttendanceExportView.get), which is authorization, not billing. The capture-to-workbook packaging decision named in assumption 10 still belongs to F53/F74 and has not been made |
Constraints already present in the codebase (unchanged, and all honoured by the implementation above except where a row marks otherwise):
- Active
OrganizationMemberrows are the roster;OrganizationMember.teamandTeam.managerare the attendance/leadership edges. Manager access requires both a current managed-member edge and a matching record team snapshot; transferred/removed history falls back to owner/admin. Do not addEmployeeor authorize through the legacyTeamMemberdisplay roster. - Tenant scope precedes capability and row scope on every query.
- F73 owns capability authorization; frontend role labels are presentation only.
- F16 owns audit evidence.
- F91 action contracts require confirmation, service-layer authorization, exact target binding, and an explicit autonomy ceiling.
- F30/F79 own native route integration; no sixth tab.
- Backend serializers that affect shared clients require KMP contract fixture regeneration under ADR-025.
- Excel generation reuses
openpyxl; the attendance export service isapps/attendance/export.py(row 15). No second workbook path may be added. - Pulse and F90 are explicit non-sources. No existing signal is evidence of presence.
- F53/F74 own canonical server-side plan and export entitlement. Still open — see row 27.
F106 gates its export on the
attendance.exportcapability plus the module switch and did not invent an entitlement mechanism; the capture-to-workbook packaging decision remains F53/F74’s to make, and must not be inferred from F106’s capability gate. - F34 keeps its public CDN URL for existing consumers, and ADR-048 added the forced-private
attendance_photopurpose, atomic consumption, one-hour orphan cleanup, and attendance-scoped signed read alongside it (rows 11–12). Attendance evidence must never be routed back onto the public path. - Web camera/GPS capture passes E01–E06 in real Chromium with deterministic camera media and Playwright-controlled foreground geolocation. Shared KMP and both native actuals exist. Android emulator M12–M15 proves its camera/location, one-batch, evidence-viewer, and workbook-share mechanics; iOS simulator M14/M15 proves evidence and workbook share only. The runtime uses a local private-object shim, so that mobile collection does not prove deployed storage custody or named-device behavior. A separate commit-bound dev and production lifecycle closes the storage boundary in row 24. The server-side half — upload freshness, GPS freshness, and single-consumption replay prevention — is implemented and covered (rows 11–13).
Contract state: F106.GAP003 is closed. F106.GAP004 now records the remaining named-device and
iOS-capture runtime work; GAP007 retains the same native-capture boundary. The live/runtime gaps
in rows 23 and 25–27 remain open; row 24 is closed. F106 is 🚧 In progress, not ✅: backend,
web, shared/native source, Android virtual-device M12–M15, iOS virtual-device M14/M15, and deployed
private-storage custody are proven, but named devices, iOS camera capture, live assistant behavior,
production-scale export timing, and canonical export entitlement are not.
12. Open Questions / Decisions
12.1 Decisions made
- One organization-local employee-day record with one clock-in/out pair is sufficient for v1.
- Server time is authoritative for normal capture; explicit timestamps require correction capability and a reason.
- The feature uses the existing roster and team model.
- Manager access to another member’s history requires both current managed-team membership and a matching record team snapshot. Pre-transfer and removed-member records are owner/admin-only.
- Every normal self or team check-in and check-out requires a fresh private in-app photo and one foreground device GPS fix. Missing either means no attendance write.
- Team capture uses one group photo/GPS set owned by the authenticated owner, admin, or manager actor per batch, visibly shared by its successful selected records.
- Check-in and checkout evidence sets are separate and immutable. Manual repair may lack evidence
only when capability, reason, audit, and
evidence_missinglabeling are present. - Any positive location accuracy is accepted and stored verbatim; above 100 meters is visibly
low_accuracy, not silently rejected. - GPS device time must be no more than five minutes old or one minute in the future at server receipt. Evidence is single-consumption except for the original idempotent result or records in one authorized team batch.
- Photos are private, have no stable public URL, and are read only through current-scope authorization plus a short-lived signed response.
- Stale, incomplete, or unconsumed attendance photos cannot be used and are deleted with their metadata no later than one hour after creation.
- No face recognition, biometric/liveness analysis, background tracking, route history, geofence enforcement, or spoof-detection claim is part of F106.
- Team capture is allowed, but actor/source attribution is always visible.
- No-record and absent are distinct.
- Export is integral to F106 wherever F106 is sold.
- Attendance never implies payroll-ready or physical-presence proof.
- Web, Android, and iOS are required product surfaces; macOS is not required for v1.
- Attendance writes through the assistant are confirm-only and never autonomous.
- One team-clock request carries 1–200
member_ids. The bound is a contract value, not a UI hint: it keeps one batch to one bounded transaction and one bounded itemized response.
12.2 Validation questions
These questions change prioritization or a later version, not the v1 data contract:
- Do pilot teams actually need more than one clock-in/out pair per day? The cheapest disconfirming test is to run five 14-day pilots and count requested split-shift corrections.
- For manager roll call, is
all active members preselectedfaster or too risky? Test both with three managers using 20-person teams before fixing the UI default. - Is organization-level schedule comparison useful enough to show by default, or should raw times lead? Measure how often pilots configure a schedule and use late/early columns.
- Which packaging converts best is an F53/F74 commercial decision. It must not fragment the core capture-to-export loop defined here.
13. Bug / Change Log
2026-08-24 — Localize the iOS Attendance navigation title
Type: iOS Localization Fix
Summary:
- Issue lumi-fb#102 reported that the iOS
Attendance route kept the English
Attendancetitle while the app was in Vietnamese. - The native route title now uses the existing
attendance.capture.titlelocalization key, so Vietnamese rendersChấm côngand English remainsAttendance.
Boundary:
- This changes only the native navigation title key. Attendance routing, capture state, authorization, API payloads, and persistence are unchanged.
Evidence:
WorkspaceDirectoryTests.test_attendanceNativeTitleUsesLocalizedAttendanceKeyprotects the route key and the Vietnamese catalog value.- Sentry
lumi-ios/developmenthad no matching Attendance-title event; managed iOS runtime, named-device, and store-release proof remain separate.
2026-08-11 — Restore Attendance on the V2 Lumi homepage
Type: Mobile Functional / Discoverability Fix
Summary:
- Issue lumi-fb#79 reported that the redesigned homepage no longer provided a route into the existing Clock-in flow.
- The V2 homepage had kept the Attendance navigation callback, but rendered it only inside the member-only chip branch. Android leaders and every iOS user (whose bridge currently mounts the leader branch) therefore had no visible homepage entry.
- The old chip also selected
Clock inorClock outfrom a homepage boolean that every platform host hard-coded tofalse, so the label could not represent server truth.
Decision:
- Both leader and member variants show one prominent, localized
Attendanceaction with a stable accessibility selector. It routes to the existing shared Attendance screen, where authoritative policy and clock state already load. - The homepage no longer accepts or displays the fabricated clock-state boolean.
Evidence:
LumiTabScreenSourceTest.attendanceEntryIsAlwaysAvailableWithoutInventingClockStateprotects both role branches, the localized label, the stable selector, and removal of the false state.- F106 M21 extends the lifecycle-owned discoverability flow from the current Lumi homepage to the shared Attendance screen. Emulator/simulator, named-device, and deployed evidence remain distinct.
Boundary:
- This fix changes only shared homepage presentation and uses the existing Android/iOS typed route. It does not change attendance authorization, capture, API, persistence, or the four-tab shell.
2026-08-03 — Keep attendance correction actions reachable on short Android screens
Type: Mobile Functional / Keyboard-safe Layout Fix
Summary:
- Issue lumi-fb#74 reported that the action buttons in the attendance correction popup were clipped or covered by the Android navigation area.
- The shared KMP correction form and decision dialogs now bound their cards to the visible viewport, consume the platform IME inset, scroll only the form body, and keep the action row outside that scroll container.
Evidence:
AttendanceCorrectionDialogSourceTestfirst failed against the unbounded dialog, then passed after the layout change, including both correction-form and decision-dialog variants.- This source/contract proof establishes the intended layout ownership; named Android interaction and production deployment remain separate evidence gates.
Boundary:
- The fix changes only shared dialog layout and does not change correction authorization, payloads, server validation, or the F106 evidence/correction state machine.
- A postmortem is recorded in
docs/postmortems/2026-08-03-android-attendance-correction-dialog-actions-clipped.md.
2026-07-29 — Make team clock-out reachable and time review explicit
Type: Mobile Functional / Accessibility Fix
Summary:
- Open team rows were not selectable because mobile treated every existing
record_idas detail-only, making the visible team clock-out action unreachable. - Team selection now derives one allowed direction per row and rejects mixed clock-in/clock-out batches before capture. The action panel exposes only actions valid for that selection.
- Worker history, Pulse, and manager roster presentation convert authoritative timestamps into the record timezone and show concise local times. Attendance row accessibility labels include the person/date and the same state/time summary.
- The explanatory subtitle now leads with the worker task: clock in when work starts, clock out when work finishes. Capture and record detail retain the private photo/GPS custody explanation.
Evidence and boundary:
- M12 runs under a real
memberfixture; M13 followed by new M19 proves one team check-in request, one team clock-out request, direction-specific results, and manager-visible in/out times. - Android emulator proof validates virtual-device mechanics only. Named Android/iPhone execution
and iOS camera/location runtime remain open under
F106.GAP004andF106.GAP007.
2026-07-29 — Make first clock-in configuration-free
Type: Behavior Change / Usability Fix
Summary:
- Changed the effective and database default policy from disabled to enabled for organizations that have never saved Attendance settings.
- Kept the optional reference schedule unset, with zero break and grace, so raw clock times work
immediately while late, early-leave, and extra-minute values remain
null. - Preserved every persisted disabled switch.
- Added a capability-gated direct link to
/settings/attendanceon the Attendance page and on the Today card’s explicitly-disabled state.
Evidence:
- The default-policy integration test first failed against
enabled=false, then passed after the model and migration change. - A clock endpoint integration test proves a member can clock in with photo and GPS before any
AttendancePolicyrow exists and that the read/clock path does not create settings state. - Web component tests prove the settings link is present for
attendance.settingsand absent as a dead clock action when the module was explicitly disabled.
Legacy-data boundary:
- The schema migration changes the default for new rows; it does not rewrite existing
enabled=falserows. - The old settings form submitted the complete policy, so repository audit metadata cannot distinguish an intentional disable from a legacy settings save that retained the old default. Automatically flipping those rows would silently override some real operator choices.
- Deployment must count legacy disabled rows and route their administrators to
/settings/attendance; the product keeps each persisted value authoritative until an authorized operator changes it.
Boundary:
- Lumi does not guess a universal 09:00–17:00 workday. Such a default would silently manufacture lateness and overtime claims for organizations with different schedules.
2026-07-29 — Make disabled team actions visually unambiguous
Type: Mobile Accessibility Fix
Summary:
- A human review of the Android M12 Maestro screenshot found that the four team actions looked enabled when zero members were selected, even though their click behavior was correctly disabled.
LumiInlineActionnow derives its visual colors from both semantic tone and enabled state. Disabled actions consistently useArchiveGrayandSoftSlate; enabled actions retain their existing semantic status colors.- A pure shared JVM regression covers all five tones in both states. The disabled token pair calculates to 4.783:1 contrast.
Evidence:
- The complete shared JVM suite passed.
- A fresh lifecycle-owned Android run built and installed one matching APK and passed M12–M15 in 4m7s. It again proved exactly one team request, itemized results, and two evidence authorizations; the new M12 screenshot visibly separates disabled from enabled actions.
- The evidence command, APK hash, screenshot hashes, and claim boundaries are recorded in
docs/tests/evidence/F106/2026-07-28-mobile-runtime/README.md.
Boundary:
- Screenshot review is not a TalkBack or native accessibility-service audit. Emulator mechanics remain distinct from named-device proof, and iOS capture remains unproven.
2026-07-28 — Collect lifecycle-owned mobile runtime proof
Type: Mobile Verification / Bug Fix
Summary:
- Added one resource-guarded runner that owns runtime preflight, boot/reuse, current-source build, exact-artifact installation, M12–M15 execution, server verification, cleanup, and shutdown.
- Added a disposable isolated backend plus purpose-scoped private-object shim and four executable Maestro flows for self capture, one-request team capture, fresh private evidence, and native workbook sharing.
- Fixed runtime-revealed defects in Android evidence decoding and popup accessibility, shared attendance refresh/navigation state, and iOS one-fix/capture teardown handling. The flows wait for scrolled Compose rows to settle before tapping and forward fixture IDs through the iOS wrapper.
Evidence:
- Android
Lumi_Pixel7_API35passed M12–M15 in 3m46s. Built and installed APK SHA-256 values matched; server state was1:2:1for self/team/shared-photo; successful self/team HTTP POST counts were exactly2:1; and successful evidence authorization requests were exactly2. - The exact iOS simulator passed M14 private evidence and M15 native export share with parallel testing disabled. The final M14 run asserted exactly two successful authorization requests. M12/M13 were not run because the simulator has no usable capture camera.
docs/tests/evidence/F106/2026-07-28-mobile-runtime/README.mdowns the commands, hashes, screenshots, and proof boundaries.
Boundary:
- Emulator/simulator mechanics are not named-device evidence. Live private-S3 custody was proved
separately at commit
cf49e7689; production-scale export and live assistant gaps still remain.
2026-07-28 — Bind iOS capture, evidence, map, and workbook actuals
Type: Mobile Implementation
Summary:
- Bound the shared self/team capture state machines to an in-app AVCaptureSession, using the front camera for self capture and the back camera for team capture without a photo-library picker.
- Added one attempt-guarded When-In-Use location fix, pixel-only JPEG re-encode, measured low-accuracy continuation, full-screen capture custody, camera teardown, and Settings + Cancel permission recovery.
- Bound each evidence open/retry to fresh authorization and an ephemeral no-store viewer, validated Maps coordinates, and shared authenticated workbook bytes from a protected temporary file.
Reason:
- iOS must provide platform mechanics without forking shared rules or weakening the photo-plus-GPS, one-batch, private-custody, and server-confirmed-success contracts.
Impact:
- iOS M08–M11 and M16 have Python source-contract ownership, the shared bridge compiles into the release XCFramework, and an arm64 simulator-target Xcode build succeeds. Lifecycle runtime and named-device proof remain open.
Tests:
scripts/tests/test_f106_ios_actuals_source.py, the complete shared JVM suite, release XCFramework assembly, locale parity, and the arm64 simulator-target Xcode build.
Notes:
- Source/compile proof is not simulator interaction or named-device evidence. PR-16 owns the
lifecycle run; live private-bucket evidence was later closed independently at commit
cf49e7689.
2026-07-28 — Bind Android capture, evidence, map, and workbook actuals
Type: Mobile Implementation
Summary:
- Bound the shared self/team capture state machines to an in-app CameraX preview, using the front camera for self capture and the back camera for team capture without a gallery or external-camera path.
- Added source wiring for one just-in-time foreground location fix per capture, camera teardown after capture and on every terminal exit, pixel-to-JPEG re-encode, measured low-accuracy continuation, and Settings + Cancel permission recovery.
- Bound every fresh evidence authorization to a non-caching private viewer, added a safe geo map
handoff, and shared authenticated workbook bytes through a cache-scoped
FileProvider.
Reason:
- Android must supply platform mechanics without forking shared business rules or weakening the photo-plus-GPS, one-batch, private-custody, and server-confirmed-success contracts.
Impact:
- Android M08, M09, M11, and M16 have source-contract ownership; M10 and URL/URI safety helpers
execute under Robolectric. Android platform mechanics and lifecycle-owned runtime proof remain
open, so
F106.GAP004and the native portion ofF106.GAP007remain blocking.
Tests:
AttendanceAndroidActualsTestplus the existing shared attendance contract suite. Source checks are not platform runtime proof.
Notes:
- Source/unit proof is not emulator or named-device evidence. PR-16 owns the lifecycle run; live
private-bucket evidence was later closed independently at commit
cf49e7689.
2026-07-28 — Add the shared native attendance contract and five-tab shell routes
Type: Mobile Implementation
Summary:
- Added regenerated backend fixtures plus shared attendance DTOs, API calls, capability-gated repositories, Today and Workspace models, and the shared KMP attendance surface.
- Added self/team capture state machines that require photo plus GPS, freeze one bounded team batch, retain retry identity, expose itemized outcomes, and never render success before the server responds.
- Added purpose-bound private JPEG upload, fresh evidence authorization state, correction/export
state, a date-based missing-record correction path, explicit export-too-large handling, and
honest
shared_team,not_recorded, and null derived-value presentation. Correction and team status dialogs remain mounted and locked until the server confirms success; failures stay visible in the dialog. - Carried
team_clock_enabledandcorrection_requests_enabledinto shared presentation state, so native controls follow server policy as well as server capabilities. Existing day records open detail and cannot be selected into a new batch. - Added typed Android and iOS Workspace routes without adding a sixth tab. Platform capture, evidence viewing, and workbook sharing remain deliberately unavailable until their actuals land.
Reason:
- Phase 3 must keep business rules and outcome vocabulary in shared code before either platform owns camera, foreground location, JPEG re-encode, or native share behavior.
Impact:
- M01–M07 and both shell route contracts are executable.
F106.GAP004and nativeGAP007remain blocking because source-level platform actuals and lifecycle-owned runtime proof do not yet exist.
Tests:
AttendanceDtoContractTest,AttendanceContractTest,AttendanceScreenModelTest,AttendanceApiClientTest,AttendancePhotoUploaderTest, AndroidCompanyBreadthTests, and iOSWorkspaceDirectoryTests/CompanyBreadthTests.
Notes:
- The shared screen hides only platform-owned capture, evidence-viewer, map, and workbook-share actions until the matching Android/iOS actual exists. Server-only correction and status actions remain reachable and capability/policy gated during the sequential mobile rollout.
2026-07-28 — Close private-upload reachability and capture-race gaps
Type: Security / Honesty Fix
Summary:
- Hid private attendance upload rows from generic upload list, detail, update, process, and delete actions. Only the capturing member may complete the just-presigned private object, with the attendance clock capability rechecked at completion.
- Invalidated pending upload and attendance continuations on cancel or unmount, so a late storage response cannot create a clock event after the user left the flow.
- Froze the selected member IDs and names when team capture starts. Background roster refetches can no longer change the photographed batch.
- Removed Cancel while the final attendance mutation is in flight. The UI still waits for the itemized server response and never renders optimistic success.
Reason:
- An adversarial cross-app review found that organization-wide F34 CRUD and generic chat, mail, attachment, processing, and relationship consumers could expose private attendance bytes or metadata, even though the attendance evidence-read endpoint itself was correctly scoped. The same review found two client races that could violate one-capture/one-confirm intent.
Impact:
- A central generic-upload resolver admits only
purpose="general"plusvisibility="public". Generic byte sinks fail closed, new relationships reject private IDs, and legacy relationships serialize no private attendance metadata. Generic evidence-source list, detail, update, and delete routes exclude reverse-linked private uploads; completed-private replay atomically deletes a stale generic source, and a one-way migration scrubs pre-existing valid private rows. Either malformed pair aborts migration before metadata changes so an operator must remediate the object bytes first. A database constraint then admits only exactgeneral+publicorattendance_photo+private, with a private key prefix, no public URL, and no generic evidence link for attendance; an expired tombstone prefix is valid only after successful object deletion. Private attendance metadata is reachable only through the purpose-bound create/complete lifecycle and the attendance-owned, re-authorized evidence viewer. - Cancellation is honest: it abandons the attendance write even if an orphaned private upload finishes later; the existing orphan sweep owns cleanup.
Tests:
backend/tests/attendance/test_evidence.py::TestPrivateEvidenceLeavesNoOrgWideTrace.backend/tests/attendance/test_private_upload_consumers.py.frontend/src/test/attendance/useAttendanceCapture.test.tsx.frontend/src/test/attendance/AttendancePage.test.tsx.
2026-07-28 — Prove the complete web attendance loop in Chromium
Type: Feature / Bug Fix
Summary:
- Added the canonical E01–E06 Playwright suite for two fresh self captures, camera/location denial,
itemized and exactly-one-request team recording, a fresh evidence-endpoint request with an
uncached URL on every open, and bounded
.xlsxdownload. Backend pytest separately proves the endpoint’s current row-scope authorization. - Changed
facingModefrom an exact media constraint to an ideal preference. Desktop webcams and deterministic browser cameras that do not advertise a facing label now remain usable. - Made the shared E2E session wait tolerate a route-level semantic
<main>nested inside the authenticated shell<main>.
Reason:
- jsdom proved state transitions but could not prove browser media, canvas, permissions, download, or request-count behavior. The first Chromium red run also showed that an exact facing constraint falsely reported an available desktop camera as unavailable.
Impact:
F106.GAP003is closed. The web portion ofGAP007is proven with deterministic Chromium media.GAP007remains blocking for native capture. Local Playwright routes still mock private S3, but the separate commit-bound dev and production lifecycle atcf49e7689closesGAP008.
Tests:
frontend/tests/e2e/f106-attendance.spec.tsE01–E06.frontend/src/test/attendance/useAttendanceCapture.test.tsxW01 ideal-facing constraint.
2026-07-27 — Keep non-working-day members clockable without inventing absence
Type: Bug Fix
Summary:
- Added the transport-only
not_scheduledday state for active, joined members with no record on a non-working date. - Kept
not_recordedexclusive to expected working days and kept both projections non-persisted.
Reason:
- The day endpoint omitted every member without a record on non-working dates, leaving the merged team clock lane empty even though §5.2 permits normal clocking on any date.
Impact:
- Owners, admins, and managers can select an in-scope weekend crew without Lumi presenting an off-day as missing attendance.
Tests:
- Backend day-projection regression plus frontend team-lane rendering coverage.
Notes:
not_schedulednever contributes to expected-day counts or XLSX export projections.
2026-07-27 — Implement the attendance backend, evidence custody, export, and assistant surface
Type: Feature
Summary:
- Added
apps/attendance—AttendancePolicy,AttendanceRecord, andAttendanceCorrectionRequest(migration0001_initial), row scope, derived values, and twelve routes / thirteen operations under/api/attendance/: policy read+write, self clock, team batch clock (1–200, itemized outcomes), day and own-history reads, private evidence photo read, correction, void, and correction-request create/decide/cancel. - Added purpose-bound private object custody to
apps/uploads(ADR-048, migration0004):purpose="attendance_photo"derives private visibility server-side,consume_uploadclaims a photo exactly once,generate_private_signed_getmints a short-lived authorized read, andcleanup_stale_private_uploadsdeletes an unconsumed upload and its object within one hour. - Added seven
attendance.*capabilities toapps/permissions/catalog.pyand routed every mutation throughcore/idempotency.py::idempotent_operation, so a refusal writes nothing and leaves theIdempotency-Keyunused. - Added the bounded, formula-safe XLSX export (
apps/attendance/export.py: 366-day and 100,000-row ceilings refused before the workbook is built,escape_text, streamedprivate, no-store). - Added the F91 assistant surface: the deterministic
read-attendance-daytool with evidence redacted to presence/mode/accuracy status, and five confirm-only actions atautonomy=0that return a capture-required handoff instead of manufacturing evidence. - Annotated all ten attendance views with
@extend_schema/@extend_schema_viewand added the response serializers they reference, so all thirteen operations are typed inopenapi/schema.yamlinstead of appearing as untyped paths.
Reason:
- §1–§5 of this spec were fully specified and entirely unbuilt. The backend is the half that has to be right first: tenant scope, row scope, evidence custody, and the one-employee-day invariants cannot be retrofitted under a UI. Typing the OpenAPI operations is a precondition for the web and native work, which must consume generated types rather than hand-written ones.
Impact:
- Backend and assistant consumers only. No user-facing change — there is still no web route and no native screen, so nothing in this feature is reachable by a person.
apps/uploadsgained a private path alongside its existing public CDN path; existing public consumers are unchanged.openapi/schema.yamland the generated frontend types were regenerated.
Tests:
- Added
backend/tests/attendance/(16 files) covering policy, clocking, team batch, corrections, evidence, evidence cleanup, expected days, export, multitenancy, permissions, audit, time boundaries, and concurrency — includingtest_concurrency_pg.py, which arbitrates the simultaneous-clock-in race on Postgres rather than SQLite. - Added
backend/tests/evals/assistant/test_attendance.pyfor the read tool and action contracts, andbackend/tests/core/test_idempotency.pyfor the shared receipt spine. - Full suite: 8483 passed, 97.16% coverage.
Notes:
- Backend requirements have collected pytest proof, and E01–E06 have executed Chromium proof. Nothing in this feature has named-device, live-S3, or live-assistant evidence — see §11 rows 21–27 for the exact open list.
- F53/F74 export entitlement (assumption 10) is still open. F106 gates the export on the
attendance.exportcapability plus the module switch; that is authorization, not billing, and it must not be read as an entitlement decision. - The backend’s optional
team_idexport filter remains available and row-scoped. The web UI does not enumerate it because the existing generic team directory is organization-wide and no attendance-scoped team-options endpoint exists; disclosing that directory to an export-only custom role would bypass404concealment. F106.GAP003and live-storageGAP008are closed.F106.GAP004(mobile), native capture underGAP007, and live assistantGAP010remain blocking. F106 follows the repository’s hand-writtenfrontend/src/types/convention; the tracked generated OpenAPI schema remains unconsumed by source code pending a separate repository-wide type-strategy decision.
2026-07-27 - Bound the team batch and name the real tenancy mechanism
Type: Documentation Change
Summary:
- Fixed the team-clock
member_idsbound at 1–200 per request, with400and zero writes outside that range, rejected before scope resolution or evidence validation. §5, §9, and §12.1 updated. - Replaced the
request.tenantwording in §2.5 and §5 with the mechanism this repository actually implements:IsOrgMemberreadsX-Organization-IDand setsrequest.organization_id, which every queryset filters on before capability and row scope.
Reason:
- The execution plan may not invent a batch size, and the spec is the only place allowed to decide
one.
request.tenant,TenantMiddleware,TenantManager, andTenantQuerySetMixindo not exist inbackend/; leaving the prose unfixed would have sent the implementation at a symbol that is not there. Rationale and rejected alternatives are recorded in P59 §0.1.
Impact:
- The API contract now has a testable batch boundary (test-plan case B54) and an error-state row.
- No runtime behavior changes; F106 remains unbuilt.
Tests:
- No product tests added. The bound is registered as a planned case in the F106 test plan.
Notes:
- Supporting rails landed with this change: ADR-048 for the F34 public/private object model, the F106 test plan, and P59 as the execution plan.
2026-07-27 - Require photo and GPS at every normal clock event
Type: Behavior Change
Summary:
- Made a fresh in-app photo and foreground device GPS fix mandatory for normal self/team check-in and check-out.
- Defined private F34 evidence custody, separate immutable check-in/out evidence, actor-owned shared team evidence, freshness/replay limits, orphan cleanup, permission/failure states, authorized review, and safe XLSX references.
- Kept manual repair possible only as an explicit reasoned, audited
evidence_missingexception. - Excluded public photos, gallery substitution, biometrics, continuous tracking, geofencing, and raw evidence exposure to the assistant.
Reason:
- The user identified photo and saved GPS evidence as a minimum viable attendance requirement, not an optional later enhancement.
Impact:
- F106 cannot ship on F34’s current public-read upload path. A private attendance upload purpose and scoped signed read are blocking dependencies.
- Backend, web, Android, iOS, export, assistant, privacy, security, and proof requirements now carry the same mandatory evidence rule.
Tests:
- Expanded the planned test matrix to cover evidence completeness, ownership, privacy, GPS validation, platform permissions, team sharing, assistant redaction, and export safety.
Notes:
- GPS is stored as device-reported point evidence. It is not continuous tracking or cryptographic proof of physical presence.
2026-08-15 - Route Ask Lumi attendance lookups to the attendance read tool
Type: Behavior Fix
Summary:
- Added an explicit Ask Lumi routing rule for attendance, timesheet, clock-in/out, and Vietnamese
chấm cônglookup language, including imperative phrasing such asxem kết quả chấm công. - Kept generic company-data retrieval and signal capture out of the attendance lookup path; the
existing tenant- and capability-scoped
read-attendance-dayhandler remains the only read source. - Added deterministic provider routing and prompt-order regression coverage.
Reason:
- The reported Ask Lumi flow was being routed to the generic company-data retriever, which has no attendance rows, so the attendance result could not be returned.
Tests:
backend/tests/ai/test_ai_provider_mock_capture_trigger.py::TestMockCaptureTriggerWidened.test_attendance_lookup_routes_to_the_attendance_read_toolbackend/tests/ai/test_ai_language_prompts.py::test_chat_prompt_routes_attendance_lookups_before_generic_retrieval
2026-07-27 - Define complete attendance v1
Type: Feature
Summary:
- Added the F106 product contract for self attendance, team attendance, controlled correction, in-app web/Android/iOS surfaces, assistant parity, and Excel export.
- Kept payroll, leave, shift planning, biometrics, continuous/background GPS tracking, offline queues, and task labor outside the feature.
- Recorded actor/source custody, tenant/capability scope, idempotency, audit, export safety, and explicit non-inference from Pulse/F90.
Reason:
- An explicit user scope decision moved basic attendance from the future HRIS exclusion into the registry as a standard, sellable wedge that can lead organizations into the rest of Lumi.
Impact:
- Product, backend, web, KMP, Android, iOS, assistant, security, and QA now have one MECE contract to plan against.
- No runtime behavior changes in this documentation-only draft.
Tests:
- No product tests added because the feature is not built.
- Documentation and Contract Graph validation are required for this draft.
Notes:
- The previous registry excluded attendance together with leave and payroll. F106 carves in only attendance timekeeping; leave and payroll remain out of scope.