draft

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:

  1. An owner or admin enables attendance and optionally sets one organization-level workday.
  2. 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.
  3. The record keeps the photo, coordinates, accuracy, server timestamp, actor, and capture mode.
  4. Mistakes are corrected through a reasoned, revision-safe flow with an audit trail.
  5. An authorized leader exports a bounded .xlsx workbook 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, or excused presentation.
    • 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 409 with 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.
  • 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 .xlsx workbook 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.

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_recorded or not_scheduled, when a manager selects them, then the mobile action surface offers team clock-in/status actions; given scoped members are open, 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 absent or excused with 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.

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.
  • 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 404 without 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 403 and 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_in and clock_out requires 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.correct capability.
  • Only one non-void attendance record can exist per organization member and organization-local work date.
  • A member can have at most one open record across all work dates in an organization. A new clock-in while any earlier record remains open returns 409 with that record.
  • not_recorded and expected-day totals exist only for organization working days on/after the member’s joined_at date. 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:

AreaIncluded
Roster and policyReuse active OrganizationMember, canonical OrganizationMember.team, Team.manager, organization timezone, joined date, and working days; one optional organization-level start/end/break/grace policy
Self captureCurrent-day server-timestamp clock-in and clock-out by an enabled member, each requiring one fresh in-app photo and foreground GPS fix
Team captureAuthorized 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 custodySeparate immutable check-in/out photo upload, latitude, longitude, accuracy, device capture time, server time, actor, and capture-mode fields
ReviewDay/team view, own history, honest not_recorded / not_scheduled projections, private photo/map evidence, capture source, actor, durations, and schedule comparisons
CorrectionOwn correction request; authorized direct correction/decision; reason, optimistic revision, audit trail, and void instead of delete
ExportServer-generated .xlsx detail and summary sheets for a bounded date range and authorized roster scope
Product surfacesWeb camera/geolocation, shared Android/iOS just-in-time camera/location capture, native share sheet, and assistant capture handoff
Reliability and securityTenant/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:

FieldShapeRule
organizationone-to-one FKTenant owner; cascade only when the organization is deleted
enabledbooleanDefault true; a persisted false remains authoritative; separate from billing entitlement
scheduled_start_localnullable timeOptional organization-level reference time
scheduled_end_localnullable timeMust be after start for this v1 day schedule
unpaid_break_minutesnon-negative integerDefault 0; cannot exceed scheduled minutes
grace_minutesnon-negative integerDefault 0; applies symmetrically to late/early calculations
self_clock_enabledbooleanDefault true once attendance is enabled
team_clock_enabledbooleanDefault true once attendance is enabled
correction_requests_enabledbooleanDefault true
updated_bynullable member FKLast 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:

FieldShapeRule
organizationFKRequired tenant owner
membernullable FK to OrganizationMemberSET_NULL preserves history after roster removal
member_name_snapshotstringRequired at first persistence
member_email_snapshotstringRequired at first persistence
teamnullable FK to TeamCanonical OrganizationMember.team at first persistence
team_name_snapshotstringHistorical export label; blank for unassigned
work_datedateOrganization-local date determined at clock-in or explicit correction
check_in_atnullable UTC datetimeServer time for normal capture
check_out_atnullable UTC datetimeServer time for normal capture
statusenumopen, complete, absent, excused, voided
check_in_sourcenullable enumself, team, assistant, manual
check_out_sourcenullable enumSame vocabulary
check_in_actornullable member FKActor custody, preserved with display-name snapshot if removed
check_out_actornullable member FKActor custody, preserved with display-name snapshot if removed
check_in_actor_name_snapshotstringRequired when check-in actor exists
check_out_actor_name_snapshotstringRequired when check-out actor exists
check_in_photonullable FK to private UploadRequired for normal check-in; PROTECT while record exists
check_out_photonullable FK to private UploadRequired for normal check-out; distinct from check-in photo
check_in_latitudenullable decimalDevice-reported range [-90, 90]; required for normal check-in
check_in_longitudenullable decimalDevice-reported range [-180, 180]; required for normal check-in
check_in_accuracy_mnullable positive decimalOS-reported horizontal accuracy in meters
check_in_device_captured_atnullable UTC datetimeAdvisory device time; server time remains authoritative
check_out_latitudenullable decimalDevice-reported range [-90, 90]; required for normal check-out
check_out_longitudenullable decimalDevice-reported range [-180, 180]; required for normal check-out
check_out_accuracy_mnullable positive decimalOS-reported horizontal accuracy in meters
check_out_device_captured_atnullable UTC datetimeAdvisory device time; server time remains authoritative
check_in_evidence_modenullable enumindividual or shared_team; required with check-in evidence
check_out_evidence_modenullable enumindividual or shared_team; required with check-out evidence
timezone_snapshotIANA timezoneRequired
scheduled_start_snapshotnullable local datetimeNull when policy is unset
scheduled_end_snapshotnullable local datetimeNull when policy is unset
unpaid_break_minutes_snapshotintegerRequired, default 0
grace_minutes_snapshotintegerRequired, default 0
revisionpositive integerStarts at 1, increments on each correction/void
last_adjustment_reasonnullable stringRequired 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) while status="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_at when both exist.
  • open requires check-in and no check-out; complete requires both; absent and excused require neither; voided is excluded from normal totals.
  • A normal self, team, or assistant check-in requires all check-in photo/GPS fields; a normal checkout requires all check-out photo/GPS fields. absent, excused, and an authorized manual repair 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_url in 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 generic EvidenceSource, because that organization-wide surface would disclose the capturing member and clock-event timing;
  • purpose="attendance_photo", visibility="private", nullable consumed_at, and nullable consumption_key fields 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_at must 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_minutes is null when the schedule is unset. When it is set it is scheduled_end_snapshot - scheduled_start_snapshot - unpaid_break_minutes_snapshot — net of the break, the same basis as worked_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, and extra_minutes are 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_minutes is reference data only. It is not approved or payable overtime.
  • not_recorded is a transport/UI projection for an active member-day with no active row. It is available only on/after that member’s joined_at date. It is not a persisted status and is never silently converted to absent.
  • not_scheduled is 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 to absent, or emitted as an export row.
  • Expected employee-days and not_recorded projections include only dates present in the organization’s working_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:

FieldShapeRule
organizationFKRequired tenant owner
recordnullable FKNull permits a request for an entirely missed day
memberFKThe employee whose attendance would change
work_datedateOrganization-local date
requested_bymember FKMust be self or an authorized leader
proposed_check_in_at / proposed_check_out_atnullable UTC datetimesValidated together
proposed_statusenumopen, complete, absent, or excused
reasonnon-empty stringRequired
record_revisionnullable integerNull only when no record existed
statusenumpending, approved, rejected, cancelled
decided_by, decided_at, decision_notenullableRequired 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.

MethodPathAuth/RoleRequestResponseErrors
GET/api/attendance/policy/authenticatednoneeffective policy, enabled state, org timezone, and working days403 only if entitlement denies F106
PATCH/api/attendance/policy/attendance.settingspolicy fieldsupdated policy400 invalid schedule; 403
POST/api/uploads/presign/self/team clock capabilityimage MIME/size plus purpose="attendance_photo"private upload ID and POST fields; no public URL400; 403; 429; 503 private mode unavailable
POST/api/uploads/{id}/complete/upload actorattendance purposecompleted private upload; evidence_source_id=null because generic evidence rows are forbidden for this purpose400; 403; 404; 409
GET/api/attendance/day/?date=&team_id=&cursor=attendance.read_teamISO date, optional scoped teamcursor page of member-day rows400 date; 404 team; 403
GET/api/attendance/me/?before=&limit=own read, self clock, or correct capabilityoptional cursorown attendance record history400 cursor; 403
GET/api/attendance/records/{id}/own/team read or correct capability plus row scopenonereloadable record detail with actor snapshots plus server-derived can_request_correction / can_correct / can_void403; 404
GET/api/attendance/records/{id}/evidence/{event}/photo/own or scoped team readevent is check_in or check_outredirect/response with short-lived private signed GET403; 404; 410 evidence unavailable
POST/api/attendance/clock/attendance.clock_selfaction enum, private photo ID, and GPS payloadcurrent own record + idempotent outcome400; 403; 409; 422 evidence invalid
POST/api/attendance/team-clock/attendance.clock_teamaction, member IDs, reason if needed, photo ID, GPS payloaditemized bounded outcomes400; 403; 404; 422; scope-wide rejection
PATCH/api/attendance/records/{id}/attendance.correctexpected revision, timestamps/status, reasonrevised record; original evidence unchanged400; 404; 409 stale
POST/api/attendance/records/{id}/void/attendance.correctexpected revision, reasonvoided record; evidence retained400; 404; 409 stale
POST/api/attendance/correction-requests/own or managed scopework date, proposal, reasonpending request400; 403; 409 pending exists
GET/api/attendance/correction-requests/own-flow capability or correct scopeoptional status, work date, member, record, cursorbounded queue with server-derived per-row can_approve / can_reject / can_cancel400; 403
POST/api/attendance/correction-requests/{id}/decide/attendance.correctdecision, noterequest + resulting record when approved400; 404; 409 stale
POST/api/attendance/correction-requests/{id}/cancel/requester onlynonecancelled request403; 404; 409 not pending
GET/api/attendance/export.xlsx?from=&to=&team_id=&status=attendance.exportbounded filters.xlsx stream400; 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.

RoleOwn readSelf clockTeam readTeam clockCorrect/decideExportSettings
Owner/adminyesyesall teamsall teamsall organizationall organizationyes
Manageryesyesmanaged teamsmanaged teamsmanaged teamsmanaged teamsno
Memberyeswhen enablednonoown request onlynono
Vieweryesnononononono

Proposed capabilities:

  • attendance.read_own
  • attendance.clock_self
  • attendance.read_team
  • attendance.clock_team
  • attendance.correct
  • attendance.export
  • attendance.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_in and clock_out first 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 409 with 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 409 and requires a reasoned correction.
  • Normal clocking is allowed on any date. absent and excused team 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_recorded on an expected working day and not_scheduled otherwise; 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. absent and excused require 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 422 with guidance to narrow the range.
  • Workbook locale: requester’s resolved en or vi locale.
  • 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:

  1. 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.
  2. 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 with attendance.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 Attendance action for every authenticated role. The action opens the existing shared Attendance route immediately. Homepage copy must not claim Clock in or Clock out unless that homepage has loaded authoritative attendance state.
  • Put the compact self clock card near the top of the existing Pulse/Today lane.
  • Add Attendance to 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_recorded
  • not_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, Tabs
  • DatePicker or the repository’s current date primitive
  • Select, Checkbox, Dialog, AlertDialog
  • Form with 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, export
  • clockIn, clockOut, recordForTeam, markAbsent, markExcused
  • notRecorded, open, complete, absent, excused, voided
  • notScheduled
  • recordedBy, selfRecorded, pendingCorrection
  • scheduleNotConfigured, selfClockDisabled, offlineNoChange
  • invalidClockState, staleRecord, memberOutOfScope, exportTooLarge
  • correctionReason, requestCorrection, approve, reject, voidRecord
  • workedMinutes, scheduledMinutes, lateMinutes, earlyLeaveMinutes, extraMinutesReference
  • exportGenerating, exportReady, exportFailed
  • takePhoto, retakePhoto, capturePreview, sharedTeamEvidence
  • cameraPermissionDenied, cameraUnavailable, openDeviceSettings
  • acquiringLocation, locationPermissionDenied, locationUnavailable, locationAccuracy
  • lowAccuracyWarning, 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_ROUTES in apps/assistant/action_routes.py), so there is no per-client wiring — but capture_required has 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 declared handoff family with a destination, which would need an ACTION_MANIFEST schema 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.

#CaseTypeWhere
1Policy validation and historical snapshot stabilitybackendbackend/tests/attendance/test_policy.py
2Normal self clock-in/out requires separate complete private photo/GPS sets and uses server timebackendbackend/tests/attendance/test_clocking.py
3Missing photo, missing GPS, client timestamp, and partial evidence each reject with zero attendance writesbackendbackend/tests/attendance/test_clocking.py
4Idempotent 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 uniquenessbackendbackend/tests/attendance/test_corrections.py, test_concurrency.py, test_concurrency_pg.py
5Cross-tenant record, member, team, correction, evidence, signed-photo read, and export isolationbackendbackend/tests/attendance/test_multitenancy.py
6F73 allow/deny, own evidence access, manager current-team plus record-team scope, transfer, and removal boundariesbackendbackend/tests/attendance/test_permissions.py
7F34 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-storebackendbackend/tests/attendance/test_evidence.py
8Public, foreign-owned, cross-tenant, stale, reused, incomplete, oversized, or non-image uploads are rejectedbackendbackend/tests/attendance/test_evidence.py
9GPS preserves values, accepts positive low accuracy, and rejects invalid range, age, future skew, or replaybackendbackend/tests/attendance/test_evidence.py
10Team preflight rejects one out-of-scope member or missing group evidence with zero writesbackendbackend/tests/attendance/test_team_clock.py
11One actor-owned group photo/GPS set is shared only by successful records in one idempotent team batchbackendbackend/tests/attendance/test_team_clock.py
12Team state conflicts return itemized bounded outcomes after scope/evidence preflightbackendbackend/tests/attendance/test_team_clock.py
13Correction lifecycle preserves original photo/GPS, labels and attributes evidence-missing manual repair, requires decision notes, and rejects stale revisionbackendbackend/tests/attendance/test_corrections.py
14Cross-midnight checkout, 24-hour bound, DST/timezone snapshotsbackendbackend/tests/attendance/test_time_boundaries.py
15Working-day/joined-date projections, off-day real records, and composite-cursor history without same-date skipsbackendbackend/tests/attendance/test_expected_days.py
16F16 events cover mutations/export while logs and audit exclude coordinates, photo URLs, and image bytesbackendbackend/tests/attendance/test_audit.py
17XLSX detail includes coordinates/accuracy/evidence mode/reference and contains no image or photo URLbackendbackend/tests/attendance/test_export.py
18XLSX summary, locale, value-only cells, injection escaping, 366-day/100,000-row bounds, and no persisted filebackendbackend/tests/attendance/test_export.py
19Web camera/geolocation permission, capture/upload/preview, low-accuracy, private-read, and retry statesfrontendfrontend/src/test/attendance/AttendancePage.test.tsx
20Web self and authorized team-batch journeys cannot confirm or show success without complete evidencee2efrontend/tests/e2e/f106-attendance.spec.ts
21Assistant returns capture handoff, enforces scope/confirmation, and keeps raw photo/URL/coordinates out of contextbackend evalbackend/tests/evals/assistant/test_attendance.py
22Shared KMP capture-state contracts model self, batch, retry, and failure statesKMPandroid/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
23Android uses in-app camera plus foreground-only location and declares no background location permissionsource/runtime.maestro/critical/114_attendance_self_capture.yaml; android/app/src/test/kotlin/com/locuno/lumi/android/ui/AttendanceAndroidActualsTest.kt
24iOS source uses in-app camera plus when-in-use location and does not request always/background locationsource onlyscripts/tests/test_f106_ios_actuals_source.py; iOS M12/M13 runtime remains open, with named-device proof also open
25Android team shared evidence, private photo review, export download, and native share work end to end; iOS proves only private evidence and native shareplatform E2EAndroid 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
26Stale/incomplete/unconsumed attendance uploads are unusable and object/metadata deletion finishes within one hourbackendbackend/tests/attendance/test_evidence_cleanup.py
27Correction and decision dialogs keep action rows reachable under short Android and IME-constrained viewportssource/contractandroid/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:

RouteMethodsPurpose
policy/GET, PATCHRead the effective policy; edit it with attendance.settings
clock/POSTOne self clock-in or clock-out
team-clock/POSTOne roll call of 1–200 members in one request
day/GETOne organization-local day across the caller’s row scope
me/GETThe caller’s own history, keyset-paged
export.xlsxGETThe streamed timesheet workbook
records/<pk>/PATCHOne revision-checked correction
records/<pk>/void/POSTRetire an employee-day, never delete it
records/<pk>/evidence/<event>/photo/GETMint a short-lived private read URL
correction-requests/POSTPropose a repair without applying it
correction-requests/<pk>/decide/POSTApprove or reject a proposal
correction-requests/<pk>/cancel/POSTWithdraw 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

ConditionAPI behaviorUser-visible behaviorRetry/logging
Attendance policy disabledowner/admin may read/setup; other attendance operations return 403Setup prompt for owner/admin; unavailable state for othersNo retry until enabled
Self clock disabled403 stable codeattendance.selfClockDisabledNo retry; security outcome metric only
Viewer attempts mutation403Existing no-permission copyNo retry
Cross-tenant or out-of-scope object404Generic not foundSecurity counter; no object detail
Team payload contains out-of-scope member403, zero writesattendance.memberOutOfScopeCorrect selection, then retry
Camera permission denied or camera unavailableNo clock requestExplain requirement; offer OS settings or cancelNo attendance write; no permission-loop retry
Location permission denied or no foreground fixNo clock requestExplain requirement; offer OS settings or retry acquisitionNo attendance write; never request background access
Private attendance upload mode unavailable503, no attendance writeattendance.evidenceUnavailableRetry later; operational alert
Photo compression/upload incomplete or failedNo clock request or 422, no attendance writeKeep preview when safe and offer explicit retryRetry upload, then reuse the same action idempotency
Photo is public, stale, reused, foreign, or invalid404 for hidden ownership; otherwise stable 422attendance.photoUploadFailedRecapture; never disclose object details
GPS invalid, over five minutes old, over one minute future, or replayed400 or stable 422, no attendance writeattendance.locationUnavailableAcquire a new fix
GPS accuracy exceeds 100 mSuccess with low_accuracyShow measured accuracy warningNot an error; user may voluntarily recapture
Team batch is empty or exceeds 200 members400, zero writes, before scope or evidence workNarrow the selectionAdjust selection, then retry
Team clock lacks complete shared group evidence422, zero writes across the batchReturn to camera/location stepRecapture once for the batch
Clock-in while already open409 + current recordattendance.invalidClockStateRefresh state; no blind retry
New-day clock-in while an older record is open409 + older open recordattendance.invalidClockStateComplete or request correction for the older record
Clock-out without open record409attendance.invalidClockStateOffer correction request
Duplicate idempotency keyOriginal successful responseCurrent stateSafe automatic network retry
Client supplies normal clock timestamp400Generic invalid requestNo retry
Checkout before check-in400Exact time validationCorrect values
Normal checkout exceeds 24 hours409Offer correction pathNo blind retry
Stale correction revision409 + current recordattendance.staleRecordRefresh and reconfirm
Pending correction already exists409 + pending requestShow pending stateNo duplicate
Authorized photo URL expires410 or fresh authorization challenge, no bytesattendance.evidenceUnavailable with retryReauthorize against current row scope
Schedule unsetSuccess with null comparisonsattendance.scheduleNotConfiguredNot an error
Offline/network failureNo server write acknowledgedattendance.offlineNoChangeRetry only with same idempotency key
Export range exceeds 366 days400Date-range guidanceNarrow range
Export exceeds 100,000 rows422attendance.exportTooLargeNarrow date/team filters
Workbook generation fails500, no empty fileattendance.exportFailedError ID; same filters may retry
Suspended/inactive membership403Session/access messageNo 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 manual and evidence_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.

#ClaimStatusEvidence
1Policy read/write with schedule validation, snapshot immunity, and a settings capability gate✅ backendapps/attendance/services.py::effective_policy, update_policy, validate_policy_fields; tests/attendance/test_policy.py (TestPolicyDefaultsAndToggles, TestPolicyScheduleValidation, TestPolicyDoesNotRewriteHistory, TestPolicySettingsCapability)
2Self clock-in/out on authoritative server time, with mandatory photo + GPS and a refused client timestamp✅ backendservices.py::clock_self, _do_clock_in, _do_clock_out; serializers.py::ClockRequestSerializer; tests/attendance/test_clocking.py (TestClockIn, TestClockOut, TestEvidenceIsMandatory, TestClientTimestampIsRefused)
3One-request team batch (1–200) with all-or-nothing scope and evidence preflight, then itemized per-member outcomes✅ backendservices.py::clock_team, _preflight_members, _claim_group_evidence; tests/attendance/test_team_clock.py (TestOneRequestOneEvidenceSet, TestScopePreflightIsAllOrNothing, TestEvidencePreflightIsAllOrNothing, TestItemizedOutcomes, TestBatchBounds)
4Revision-checked correction, void, and propose/decide/cancel correction requests, all audited✅ backendservices.py::correct_record, void_record, create_correction_request, decide_correction_request, cancel_correction_request; tests/attendance/test_corrections.py (TestDirectCorrection, TestStaleRevision, TestVoid, TestDecision, TestCorrectionAudit)
5Day, 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✅ backendscope.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)
6Manager access needs both a current managed-member edge and a matching record team snapshot; transfer/removal revokes it immediately✅ backendscope.py::_has_manager_scope, managed_member_ids; tests/attendance/test_permissions.py (TestManagerScope, TestTransferAndRemoval, TestLegacyTeamMemberGrantsNothing)
7Tenant isolation on every attendance query, endpoint, and idempotency key✅ backendIsOrgMember + explicit organization_id filters throughout apps/attendance/; tests/attendance/test_multitenancy.py (all four classes), test_corrections.py::TestCrossTenantIsolation
8Seven attendance capabilities registered and correctly distributed across roles✅ backendapps/permissions/catalog.py; tests/attendance/test_permissions.py::TestCapabilityCatalog, TestSettingsCapabilityGate, TestMemberAndViewerScope
9Every 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✅ backendcore/idempotency.py::idempotent_operation; tests/core/test_idempotency.py, tests/attendance/test_corrections.py::TestCorrectionIdempotency, TestIdempotentReplayAuthorization, test_team_clock.py::TestIdempotencyAndReplay
10Database-level concurrency invariants: one open record per member, one active row per employee-day✅ backendmodels.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
11Attendance photos are forced private by purpose, consumed exactly once, and read only through a short-lived authorized URL✅ backendADR-048; apps/uploads/services.py::visibility_for_purpose, consume_upload, generate_private_signed_get; tests/attendance/test_evidence.py::TestPrivateEvidenceRead, test_permissions.py::TestEvidenceReadAuthorization
12An unconsumed attendance upload is unusable and is swept with its object within one hour✅ backendapps/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_*
13GPS is stored verbatim, range-checked, freshness-checked, and low accuracy is labeled rather than refused✅ backendserializers.py::GpsSerializer, services.py::validate_gps_freshness, is_low_accuracy; tests/attendance/test_evidence.py (TestGpsStoredVerbatim, TestAccuracy, TestGpsFreshness)
14Derived values come from record snapshots, report null (never 0) when unknowable, and survive policy/timezone change✅ backendapps/attendance/derived.py::derived_values; tests/attendance/test_expected_days.py (TestWorkedMinutes, TestGraceAndSchedule), test_time_boundaries.py::TestSnapshotsSurvivePolicyAndTimezoneChanges
15Bounded, formula-safe XLSX export streamed with private, no-store and no image bytes, object URL, or storage key✅ backendapps/attendance/export.py (MAX_RANGE_DAYS, MAX_DETAIL_ROWS, escape_text, stream_workbook); tests/attendance/test_export.py (TestWorkbookShape, TestWorkbookSafety, TestBounds, TestStreaming, TestScopeAndHonesty)
16Audit records custody and shape only — never coordinates, names, emails, URLs, or correction reasons✅ backendservices.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
17Attendance is never inferred from Pulse, execution events, messages, sessions, or an upload alone✅ backendtests/attendance/test_clocking.py::TestNonInference, tests/evals/assistant/test_attendance.py::TestB106NonInference
18Assistant read tool is deterministic, row-scoped, and redacts evidence to presence/mode/accuracy status✅ backendapps/ai/toolbelt_registry_attendance.py::_redact_evidence, _redact_row, read-attendance-day; tests/evals/assistant/test_attendance.py (TestB100RowScope, TestB101EvidenceRedaction)
19Every assistant attendance action is confirm-only at autonomy ceiling zero and returns a capture handoff rather than manufacturing evidence✅ backendapps/assistant/action_definitions_attendance.py (five actions, all autonomy=0); tests/evals/assistant/test_attendance.py (TestB102CaptureHandoff, TestB103NoManufacturedEvidence, TestB104ConfirmOnly)
20All 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
21Web attendance surface — /work/attendance, /settings/attendance, Today clock card, team review, correction flow, export UI, camera + geolocation permission states✅ web implementation + Chromium mechanicsBoth 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
22Native attendance surface — shared KMP contract, Android and iOS screens, in-app camera, foreground-only location, authenticated workbook share🚧 native implementation built; virtual mechanics partially provenShared 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
23Browser and named-device runtime proof for the capture flow🚧 browser and virtual Android proven; named devices and iOS capture openE01–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
24Live S3 proof for private attendance object custody✅ dev + production live proofCommit 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
25Live assistant runtime proof (real model, real thread)🔲 not proventests/evals/assistant/test_attendance.py exercises the tool and action contracts deterministically. No grounded run against a live provider has been recorded
26Production-scale export timing🔲 not measuredMAX_DETAIL_ROWS = 100_000 is enforced, but no timing or memory measurement exists for a workbook near that ceiling on production-shaped data
27F53/F74 export entitlement (assumption 10)🔲 openThere 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):

  1. Active OrganizationMember rows are the roster; OrganizationMember.team and Team.manager are 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 add Employee or authorize through the legacy TeamMember display roster.
  2. Tenant scope precedes capability and row scope on every query.
  3. F73 owns capability authorization; frontend role labels are presentation only.
  4. F16 owns audit evidence.
  5. F91 action contracts require confirmation, service-layer authorization, exact target binding, and an explicit autonomy ceiling.
  6. F30/F79 own native route integration; no sixth tab.
  7. Backend serializers that affect shared clients require KMP contract fixture regeneration under ADR-025.
  8. Excel generation reuses openpyxl; the attendance export service is apps/attendance/export.py (row 15). No second workbook path may be added.
  9. Pulse and F90 are explicit non-sources. No existing signal is evidence of presence.
  10. F53/F74 own canonical server-side plan and export entitlement. Still open — see row 27. F106 gates its export on the attendance.export capability 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.
  11. F34 keeps its public CDN URL for existing consumers, and ADR-048 added the forced-private attendance_photo purpose, 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.
  12. 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_missing labeling 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:

  1. 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.
  2. For manager roll call, is all active members preselected faster or too risky? Test both with three managers using 20-person teams before fixing the UI default.
  3. 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.
  4. 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 Attendance title while the app was in Vietnamese.
  • The native route title now uses the existing attendance.capture.title localization key, so Vietnamese renders Chấm công and English remains Attendance.

Boundary:

  • This changes only the native navigation title key. Attendance routing, capture state, authorization, API payloads, and persistence are unchanged.

Evidence:

  • WorkspaceDirectoryTests.test_attendanceNativeTitleUsesLocalizedAttendanceKey protects the route key and the Vietnamese catalog value.
  • Sentry lumi-ios / development had 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 in or Clock out from a homepage boolean that every platform host hard-coded to false, so the label could not represent server truth.

Decision:

  • Both leader and member variants show one prominent, localized Attendance action 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.attendanceEntryIsAlwaysAvailableWithoutInventingClockState protects 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:

  • AttendanceCorrectionDialogSourceTest first 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_id as 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 member fixture; 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.GAP004 and F106.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/attendance on 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 AttendancePolicy row exists and that the read/clock path does not create settings state.
  • Web component tests prove the settings link is present for attendance.settings and 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=false rows.
  • 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.
  • LumiInlineAction now derives its visual colors from both semantic tone and enabled state. Disabled actions consistently use ArchiveGray and SoftSlate; 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_API35 passed M12–M15 in 3m46s. Built and installed APK SHA-256 values matched; server state was 1:2:1 for self/team/shared-photo; successful self/team HTTP POST counts were exactly 2:1; and successful evidence authorization requests were exactly 2.
  • 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.md owns 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.GAP004 and the native portion of F106.GAP007 remain blocking.

Tests:

  • AttendanceAndroidActualsTest plus 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_enabled and correction_requests_enabled into 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.GAP004 and native GAP007 remain blocking because source-level platform actuals and lifecycle-owned runtime proof do not yet exist.

Tests:

  • AttendanceDtoContractTest, AttendanceContractTest, AttendanceScreenModelTest, AttendanceApiClientTest, AttendancePhotoUploaderTest, Android CompanyBreadthTests, and iOS WorkspaceDirectoryTests / 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" plus visibility="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 exact general + public or attendance_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 .xlsx download. Backend pytest separately proves the endpoint’s current row-scope authorization.
  • Changed facingMode from 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.GAP003 is closed. The web portion of GAP007 is proven with deterministic Chromium media.
  • GAP007 remains blocking for native capture. Local Playwright routes still mock private S3, but the separate commit-bound dev and production lifecycle at cf49e7689 closes GAP008.

Tests:

  • frontend/tests/e2e/f106-attendance.spec.ts E01–E06.
  • frontend/src/test/attendance/useAttendanceCapture.test.tsx W01 ideal-facing constraint.

2026-07-27 — Keep non-working-day members clockable without inventing absence

Type: Bug Fix

Summary:

  • Added the transport-only not_scheduled day state for active, joined members with no record on a non-working date.
  • Kept not_recorded exclusive 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_scheduled never 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, and AttendanceCorrectionRequest (migration 0001_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, migration 0004): purpose="attendance_photo" derives private visibility server-side, consume_upload claims a photo exactly once, generate_private_signed_get mints a short-lived authorized read, and cleanup_stale_private_uploads deletes an unconsumed upload and its object within one hour.
  • Added seven attendance.* capabilities to apps/permissions/catalog.py and routed every mutation through core/idempotency.py::idempotent_operation, so a refusal writes nothing and leaves the Idempotency-Key unused.
  • 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, streamed private, no-store).
  • Added the F91 assistant surface: the deterministic read-attendance-day tool with evidence redacted to presence/mode/accuracy status, and five confirm-only actions at autonomy=0 that return a capture-required handoff instead of manufacturing evidence.
  • Annotated all ten attendance views with @extend_schema/@extend_schema_view and added the response serializers they reference, so all thirteen operations are typed in openapi/schema.yaml instead 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/uploads gained a private path alongside its existing public CDN path; existing public consumers are unchanged.
  • openapi/schema.yaml and 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 — including test_concurrency_pg.py, which arbitrates the simultaneous-clock-in race on Postgres rather than SQLite.
  • Added backend/tests/evals/assistant/test_attendance.py for the read tool and action contracts, and backend/tests/core/test_idempotency.py for 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.export capability plus the module switch; that is authorization, not billing, and it must not be read as an entitlement decision.
  • The backend’s optional team_id export 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 bypass 404 concealment.
  • F106.GAP003 and live-storage GAP008 are closed. F106.GAP004 (mobile), native capture under GAP007, and live assistant GAP010 remain blocking. F106 follows the repository’s hand-written frontend/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_ids bound at 1–200 per request, with 400 and zero writes outside that range, rejected before scope resolution or evidence validation. §5, §9, and §12.1 updated.
  • Replaced the request.tenant wording in §2.5 and §5 with the mechanism this repository actually implements: IsOrgMember reads X-Organization-ID and sets request.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, and TenantQuerySetMixin do not exist in backend/; 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_missing exception.
  • 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ông lookup language, including imperative phrasing such as xem 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-day handler 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_tool
  • backend/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.