Task — engineering-spec@1

"TIME manual entry form — retroactive time logging with date validation + per-day total cap + TASK-TIME-007 VN Labour Code cap integration"

draftTASK-TIME-003
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 manual entry form at services/time/src/manual_entry/ with date-window validation tiers (30d/90d/1y), 24h per-day hard cap + 16h soft-block, TASK-TIME-007 VN OT chain integration, 5 closed-enum reasons, and 5 memory audit kinds.

  1. MUST define closed manual_entry_reason enum: ('forgot_to_start_timer','off_network','mobile_bulk_add','correction','retroactive_invoiced') per DEC-1403. Cardinality 5.
  1. MUST expose POST /v1/time/entries/manual body { engagement_id, project_id?, task_id?, entry_date, duration_seconds, description, reason, approval_override? }. Handler:
  1. MUST enforce date-window tiers per DEC-1400 + DEC-1404:
  1. MUST enforce 24h-per-day hard cap per DEC-1401. SUM(duration_seconds) for (member, entry_date) + new_duration > 86_400 → 412 + daily_24h_cap_exceeded. Emit time.manual_entry_blocked_24h_cap sev-2.
  1. MUST emit 16h soft-block warning per DEC-1401. SUM + new > 57_600 (16h) AND ≤ 86_400 → require approval_override.engagement_admin_subject_id; without override → 412 + daily_16h_softblock. With override → audit time.manual_entry_overrode_16h_softblock sev-2.
  1. MUST chain into TASK-TIME-007 OT cap check per DEC-1402. After per-day cap check, invoke vn_labour::cap_check for vn-1 Members. Breach → 412 + matching breach kind.
  1. MUST validate entry_date not in future. entry_date > today → 400 + future_date_invalid.
  1. MUST emit 5 memory audit kinds per DEC-1405. PII-scrub description via TASK-MEMORY-111.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT allow > 1 year past entries (per DEC-1404).
  1. MUST NOT bypass TASK-TIME-007 OT cap (per DEC-1402).

§2 — Why this design (rationale)

Why tiered approval (§1 #3, DEC-1404)? Newer entries = legitimate forgetfulness; older entries = either bookkeeping cleanup or fraud. Tier escalation matches the suspicion gradient.

Why 24h hard cap (§1 #4, DEC-1401)? Physically impossible to work > 24h in 24h. Catches obvious typos before they pollute invoices.

Why 16h soft-block (§1 #5)? Possible but unusual; requires conscious approval. Catches plausible-but-suspicious entries.

Why chain into TASK-TIME-007 (§1 #6, DEC-1402)? Manual entry must respect all the same labour-law constraints as timer entries; otherwise it's the bypass path.


§3 — API contract

POST   /v1/time/entries/manual                       (member; with approval override)
GET    /v1/time/entries/manual/pending-approvals     (engagement_admin or cfo)

Body:

{
  "engagement_id": "0190...",
  "project_id": "0190...",
  "entry_date": "2026-05-15",
  "duration_seconds": 7200,
  "description": "Sprint planning meeting",
  "reason": "forgot_to_start_timer",
  "approval_override": null
}

For past-90d:

{
  ...
  "reason": "correction",
  "approval_override": { "engagement_admin_subject_id": "..." }
}

§4 — Acceptance criteria

  1. manual_entry_reason cardinality 5.
  2. 30d default window — entry 31d ago without override → 412.
  3. 90d engagement_admin override — entry 60d ago with override succeeds + sev-2 audit.
  4. 1y cfo override — entry 200d ago with cfo override succeeds.
  5. >1y rejected — entry 400d ago → 412 + entry_too_old.
  6. 24h cap — Member with 23h59m already, +2min entry → 412.
  7. 16h softblock — Member with 14h, +3h entry without override → 412 + daily_16h_softblock.
  8. 16h with override — same scenario with engagement_admin override → succeeds.
  9. VN OT cap chained — Member at 39h monthly OT, +90min entry (1.5h OT) → 412 monthly_40h_breach.
  10. Future date rejected — entry_date = tomorrow → 400.
  11. 5 memory audit kinds emitted.
  12. Trace_id end-to-end.
  13. PII scrub — description hash in audit.
  14. Non-VN Member skips OT check — sg-1 Member entries unaffected by OT chain.
  15. Same-day OT count post-entry — entry creates row; subsequent timer-stop sees updated total.
  16. Engagement_admin not member — admin from different engagement → 403.
  17. Approval override subject_id validated — invalid override → 400.
  18. Audit kind per scenario — happy → time.manual_entry_created; 24h → _blocked_24h_cap; etc.
  19. Description optional empty — empty description allowed but warned.
  20. Reason required — missing reason → 400.

§5 — Verification

#[tokio::test]
async fn manual_entry_within_30d_succeeds() {
    let ctx = TestContext::with_member().await;
    let r = ctx.post_manual_entry(json!({
        "engagement_id": ctx.eng_id, "entry_date": yesterday(),
        "duration_seconds": 3600, "description": "test", "reason": "forgot_to_start_timer"
    })).await;
    assert_eq!(r.status(), 201);
}

#[tokio::test]
async fn 31d_requires_admin_override() {
    let ctx = TestContext::with_member().await;
    let r = ctx.post_manual_entry_at(31, None).await;
    assert_eq!(r.status(), 412);
    let r2 = ctx.post_manual_entry_at(31, Some(ctx.engagement_admin_id)).await;
    assert_eq!(r2.status(), 201);
}

#[tokio::test]
async fn 24h_per_day_hard_blocked() {
    let ctx = TestContext::with_member().await;
    ctx.seed_entries(ctx.member_id, today(), 23 * 3600 + 3540).await;  // 23h59m
    let r = ctx.post_manual_entry(json!({
        "engagement_id": ctx.eng_id, "entry_date": today(),
        "duration_seconds": 120, "description": "x", "reason": "forgot_to_start_timer"
    })).await;
    assert_eq!(r.status(), 412);
}

#[tokio::test]
async fn vn_ot_cap_chained() {
    let ctx = TestContext::with_vn_member().await;
    ctx.seed_ot_for_month(ctx.member_id, 39 * 3600).await;
    let r = ctx.post_manual_entry_with_duration(8.5 * 3600.0).await;  // 0.5h OT pushes monthly to 40.5
    assert_eq!(r.status(), 412);
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["breach_kind"], "monthly_40h_breach");
}

// 5.5..5.10: future date, 5 cardinality, 16h softblock, override paths, audit emissions

§7 — Dependencies

Upstream: TASK-TIME-001 (entry write). Cross-module: TASK-TIME-007 (OT chain), TASK-AUTH-101 (engagement_admin + cfo roles), TASK-AI-003, TASK-MEMORY-111.


§8 — Example payload

time.manual_entry_overrode_16h_softblock:

{
  "kind": "time.manual_entry_overrode_16h_softblock",
  "severity": 2,
  "tenant_id": "8a2f...",
  "actor_id": "user.member.456",
  "trace_id": "...",
  "payload": {
    "member_subject_id_hash16": "f8a1...",
    "entry_date": "2026-05-17",
    "override_admin_subject_id_hash16": "9c4e...",
    "total_day_seconds_after": 75600
  }
}

§9 — Open questions

Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Date > 1 yeartier check412Caller rejects or escalates legally
24h cap on bulk-add pathper-day check412Member spreads across days
Approval override missingcheck412 with hintCaller adds override
Invalid approval subject_idrole check400Caller fixes
TASK-TIME-007 OT breachchained check412 with breach kindMember splits to overtime-tier or new day
Future datedate check400Caller fixes
Cross-tenant engagementRLS403Inherent
Description PII not scrubbedTASK-MEMORY-111Audit dropped + sev-3Inherent
Same-day timer + manual raceconcurrent insertsBoth checked individually; second may hit 24h capInherent
Engagement membership lost mid-writeRLS403 at writeInherent
Reason missingvalidation400Inherent
Bulk import bypasshandler enforces same checksTested via TASK-TIME-007 §11.8 lint patternCI catches
Approval override expiredcheck expires_at412Re-request approval
Manual entry creates 0-duration0 allowed (placeholder)InherentMember edits later
Duration > 24h single entryper-day cap412Split into multiple entries

§11 — Implementation notes

§11.1 Date window: entry_date < today() - INTERVAL '30 days' triggers approval check.

§11.2 Approval override consumed at write time; approval_override.engagement_admin_subject_id validated against role table.

§11.3 Per-day cap check uses TASK-TIME-007's aggregator (shared infrastructure).

§11.4 TASK-TIME-007 chain invoked AFTER per-day cap (most-granular first).

§11.5 UI form pre-fetches Member's today total to surface 16h warning client-side.

§11.6 Bulk import (CSV) deferred to slice 2 but architecture supports it (same handler chain).

§11.7 Audit row carries Member + admin override subject IDs as hashes.

§11.8 Trace_id propagated from request through cap_check + write + audit.

§11.9 Engagement_admin override validated by role table at write time (race-safe).

§11.10 Future-date check uses tenant timezone (default Asia/Ho_Chi_Minh for VN).


End of TASK-TIME-003 spec.