Task — engineering-spec@1

"TIME timer start/stop — single-active-timer per Member + auto-stop on logout + ≤15-min resolution snap + idle-detection at 10min"

draftTASK-TIME-002
module time · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-TIME-001 · blocks none

§1 — Description (BCP-14 normative)

The TIME service MUST ship timer start/stop primitive at services/time/src/timer/ with single-active enforcement, 10-min idle detection, 15-min commit snap, logout auto-commit, 5-state enum, and 5 memory audit kinds.

  1. MUST define closed timer_state enum: ('running','paused_idle','paused_manual','committed','abandoned') per DEC-1383. Cardinality 5.
  1. MUST define timers table at migration 0002: (timer_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_subject_id UUID NOT NULL, engagement_id UUID NOT NULL, project_id UUID, task_id UUID, description TEXT, state timer_state NOT NULL DEFAULT 'running', started_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(), idle_paused_at TIMESTAMPTZ, manual_paused_at TIMESTAMPTZ, committed_at TIMESTAMPTZ, committed_entry_id UUID, abandoned_at TIMESTAMPTZ, trace_id CHAR(32)). Partial unique (member_subject_id) WHERE state IN ('running','paused_idle','paused_manual') enforces single-active per DEC-1380.
  1. MUST enforce RLS scoped to tenant_id AND member_subject_id = current_setting('auth.subject_id')::uuid (Members see own timers only).
  1. MUST expose POST /v1/time/timer/start body { engagement_id, project_id?, task_id?, description? }. Handler:
  1. MUST auto-stop prior active timer per DEC-1380 on new start. Sequence:
  1. MUST expose POST /v1/time/timer/heartbeat for client keep-alive. Body { timer_id }. Handler:
  1. MUST expose POST /v1/time/timer/stop body { timer_id, final_description? }. Handler:
  1. MUST detect idle per DEC-1381 via idle_detector.rs::run_watchdog(). Scheduled job runs every 60s:
  1. MUST expose POST /v1/time/timer/resume body { timer_id, idle_decision } where idle_decision ∈ {include, exclude, partial}. Handler:
  1. MUST expose POST /v1/time/timer/pause body { timer_id, reason? }. Handler:
  1. MUST auto-commit on logout per DEC-1384. TASK-AUTH-004 logout handler invokes time::timer::commit_all_for_subject(subject_id):
  1. MUST snap duration UP to nearest 15-min per DEC-1382. Implementation: let snapped_seconds = ((raw_seconds + 899) / 900) * 900.
  1. MUST support timer abandon at POST /v1/time/timer/abandon body { timer_id, reason }. Transitions state='abandoned' WITHOUT creating TIME entry. Use case: accidental timer start. Emits time.timer_abandoned sev-3.
  1. MUST emit 5 memory audit kinds per DEC-1385:
  1. MUST PII-scrub description via TASK-MEMORY-111 — SHA256 in chain; raw in DB.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT allow multi-active timer per DEC-1380 (partial unique enforces).
  1. MUST NOT snap duration DOWN (always round UP per DEC-1382 — Member-favourable).

§2 — Why this design (rationale)

Why single-active timer (§1 #5, DEC-1380)? Multi-timer = dual-counting same wall-clock period. Industry standard (Toggl, Harvest, Clockify) all enforce single-active.

Why 15-min snap UP (§1 #12, DEC-1382)? Industry billing convention; rounding down would systematically undercount Member time. Snap-up = Member-favourable + reduces line-item count.

Why 10-min idle detection (§1 #8, DEC-1381)? Member walks away from desk. 10 min = enough for short bathroom break + not so long that forgotten timer accrues hours.

Why logout auto-commit (§1 #11, DEC-1384)? Orphan timers across sessions = data quality nightmare. End-of-session commit = clean state.


§3 — API contract

-- 0002_timers.sql
CREATE TYPE timer_state AS ENUM ('running','paused_idle','paused_manual','committed','abandoned');

CREATE TABLE timers (
  timer_id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  member_subject_id UUID NOT NULL,
  engagement_id UUID NOT NULL,
  project_id UUID,
  task_id UUID,
  description TEXT,
  state timer_state NOT NULL DEFAULT 'running',
  started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  idle_paused_at TIMESTAMPTZ,
  manual_paused_at TIMESTAMPTZ,
  total_pause_seconds INT NOT NULL DEFAULT 0,
  committed_at TIMESTAMPTZ,
  committed_entry_id UUID,
  abandoned_at TIMESTAMPTZ,
  abandon_reason TEXT,
  trace_id CHAR(32)
);
CREATE UNIQUE INDEX uniq_active_timer_per_member
  ON timers(member_subject_id)
  WHERE state IN ('running','paused_idle','paused_manual');
CREATE INDEX idx_timers_heartbeat ON timers(last_heartbeat_at) WHERE state = 'running';
ALTER TABLE timers ENABLE ROW LEVEL SECURITY;
CREATE POLICY timers_rls ON timers
  USING (tenant_id = current_setting('auth.tenant_id')::uuid
         AND member_subject_id = current_setting('auth.subject_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid
              AND member_subject_id = current_setting('auth.subject_id')::uuid);
REVOKE DELETE ON timers FROM cyberos_app;
GRANT UPDATE (state, last_heartbeat_at, idle_paused_at, manual_paused_at,
              total_pause_seconds, committed_at, committed_entry_id, abandoned_at, abandon_reason)
  ON timers TO cyberos_app;

Endpoints:

POST   /v1/time/timer/start
POST   /v1/time/timer/heartbeat
POST   /v1/time/timer/pause
POST   /v1/time/timer/resume
POST   /v1/time/timer/stop
POST   /v1/time/timer/abandon
GET    /v1/time/timer/current

§4 — Acceptance criteria

  1. timer_state cardinality 5.
  2. Single active per Member — starting timer with existing active auto-commits prior.
  3. 15-min snap UP — 23-min timer commits as 30-min entry.
  4. Logout auto-commits — session end transitions running timers to 'committed'.
  5. 10-min idle pause — heartbeat absent 10 min → state='paused_idle'.
  6. Resume include vs excludeidle_decision='exclude' subtracts idle from duration.
  7. Abandon no entry — abandoned timer produces no TIME entry.
  8. 5 memory audit kinds emitted.
  9. RLS Member-scoped — caller sees own timers only.
  10. Heartbeat idempotent — repeated heartbeats update last_heartbeat_at without errors.
  11. Engagement membership validated — timer for non-member engagement → 403.
  12. PII scrub — description_sha256 in chain only.
  13. Trace_id end-to-end.
  14. Cross-tenant denied — RLS rejects.
  15. Snap respects pause time — running 60m + paused 15m + running 30m = 90m work; snaps to 90m exactly (multiple of 15).
  16. Project_id optional — timer without project_id allowed (general engagement work).
  17. Audit emission on each transition — every state change emits an audit.
  18. Concurrent start race — two simultaneous starts → partial unique constraint fires; one wins, other gets 409.
  19. Timer over 24h — single timer running 26h still commits (snapped to 24h)?... actually no, snap to 1560 minutes (26h * 60 = 1560).
  20. Description optional — empty description allowed at start; can be set at stop.

§5 — Verification

#[tokio::test]
async fn single_active_enforced() {
    let ctx = TestContext::with_member().await;
    let t1 = ctx.start_timer().await;
    let t2 = ctx.start_timer().await;
    let state1: String = sqlx::query_scalar("SELECT state::text FROM timers WHERE timer_id=$1")
        .bind(t1).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(state1, "committed");
}

#[tokio::test]
async fn fifteen_min_snap_up() {
    let ctx = TestContext::with_member().await;
    let t = ctx.start_timer().await;
    ctx.travel(Duration::from_secs(23 * 60)).await;
    let r = ctx.stop_timer(t).await;
    let entry_id: Uuid = r.json::<serde_json::Value>().await.unwrap()["committed_entry_id"].as_str().unwrap().parse().unwrap();
    let duration: i32 = sqlx::query_scalar("SELECT duration_seconds FROM time_entries WHERE entry_id=$1")
        .bind(entry_id).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(duration, 30 * 60);
}

#[tokio::test]
async fn idle_pause_after_10min() {
    let ctx = TestContext::with_member().await;
    let t = ctx.start_timer().await;
    ctx.travel(Duration::from_secs(11 * 60)).await;
    ctx.run_idle_watchdog().await;
    let state: String = sqlx::query_scalar("SELECT state::text FROM timers WHERE timer_id=$1")
        .bind(t).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(state, "paused_idle");
}

#[tokio::test]
async fn logout_commits_running_timers() {
    let ctx = TestContext::with_member().await;
    let t = ctx.start_timer().await;
    ctx.logout().await;
    let state: String = sqlx::query_scalar("SELECT state::text FROM timers WHERE timer_id=$1")
        .bind(t).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(state, "committed");
}

// 5.5..5.10: enum cardinality, RLS, heartbeat, audit, resume decision, abandon

§7 — Dependencies

Upstream: TASK-TIME-001 (TimeEntry schema). Cross-module: TASK-AUTH-004 (logout integration), TASK-PROJ-001 (project_id), TASK-AI-003, TASK-MEMORY-111.


§8 — Example payload

time.timer_committed:

{
  "kind": "time.timer_committed",
  "severity": 2,
  "tenant_id": "8a2f...",
  "actor_id": "user.member.456",
  "trace_id": "...",
  "payload": {
    "timer_id": "0190...",
    "engagement_id": "0190...",
    "duration_snapped_seconds": 1800,
    "description_sha256": "..."
  }
}

§9 — Open questions

Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Concurrent start racepartial unique409; one winsInherent
Heartbeat missed during network blipwatchdogIdle pause triggersResume on reconnect
Logout race with manual stoptx isolationLast writer wins; idempotent commitInherent
Server clock skewtimestamp diffDuration approximate; ≤ 1s skew acceptableNTP
Browser tab background suspends heartbeatclient-side detectionIdle pause; user resumes on tab focusInherent
Long-running timer (24h+)absolute capHard-cap at 24h via VN Labour Code (TASK-TIME-007 derivative)Member splits across days
Engagement permission lost mid-timerRLS at commitCommit may fail; sev-2 alertManual entry via TASK-TIME-003
Abandoned timer with significant accrued timecounterSev-3 audit; reviewableMember confirms
Idle decision not specified at resumerequired field400Inherent
Project_id deleted mid-timerFK softTimer continues; FK enforced at commitManual entry alternative
Description PII-scrub failsscrub errorAudit row dropped; raw retained in DBSev-3 alert
Multiple devices race startpartial uniqueFirst wins; second gets 409Inherent
Pause-resume-pause-resume cycletotal_pause_seconds accumulatorTracked correctlyInherent
Long pause (8h+ overnight)watchdogState remains paused; commit excludes by defaultUser decision at resume
Auto-commit creates 0-min entryrareSkipped (no entry created) + abandoned stateInherent
Timer for archived engagementengagement check at start403 + engagement_archivedUse active engagement

§11 — Implementation notes

§11.1 Watchdog runs as scheduled task; queries timers WHERE state='running' AND last_heartbeat_at < now() - 10min.

§11.2 15-min snap formula: let snapped = ((seconds + 899) / 900) * 900; integer arithmetic.

§11.3 Logout handler at TASK-AUTH-004 invokes services/time/src/timer/commit.rs::commit_all_for_subject synchronously before returning.

§11.4 SPA widget polls /timer/current on load; sends heartbeat every 60s while page is active.

§11.5 Heartbeat uses Page Visibility API to suspend when tab backgrounded (battery savings).

§11.6 Single-active partial unique uses Postgres expression index; very fast lookup.

§11.7 Pause durations tracked cumulatively in total_pause_seconds for stop-time computation.

§11.8 TIME entry created at commit time via TASK-TIME-001 standard insert path.

§11.9 Description PII-scrub via TASK-MEMORY-111 standard ruleset.

§11.10 Cross-tenant isolation via RLS + explicit member_subject_id check.


End of TASK-TIME-002 spec.