"TIME manual entry form — retroactive time logging with date validation + per-day total cap + TASK-TIME-007 VN Labour Code cap integration"
§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.
- MUST define closed
manual_entry_reasonenum:('forgot_to_start_timer','off_network','mobile_bulk_add','correction','retroactive_invoiced')per DEC-1403. Cardinality 5.
- MUST expose
POST /v1/time/entries/manualbody{ engagement_id, project_id?, task_id?, entry_date, duration_seconds, description, reason, approval_override? }. Handler:
- Validates engagement membership.
- Date-window check per §1 #3.
- Per-day cap check per §1 #4.
- TASK-TIME-007 OT cap check per §1 #5.
- Creates TIME entry via TASK-TIME-001.
- Emits
time.manual_entry_createdsev-2.
- MUST enforce date-window tiers per DEC-1400 + DEC-1404:
entry_date ≥ now() - 30d→ no approval required.30d < age ≤ 90d→ requiresengagement_admin(caller has role ORapproval_override.subject_idreferences one).90d < age ≤ 1y→ requirescfoapproval.> 1y→ rejected with 412 +entry_too_old.- Approval-override path emits
time.manual_entry_past_90d_approvedortime.manual_entry_past_1y_approvedsev-2.
- 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. Emittime.manual_entry_blocked_24h_capsev-2.
- 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 → audittime.manual_entry_overrode_16h_softblocksev-2.
- MUST chain into TASK-TIME-007 OT cap check per DEC-1402. After per-day cap check, invoke
vn_labour::cap_checkfor vn-1 Members. Breach → 412 + matching breach kind.
- MUST validate
entry_datenot in future.entry_date > today→ 400 +future_date_invalid.
- MUST emit 5 memory audit kinds per DEC-1405. PII-scrub description via TASK-MEMORY-111.
- MUST thread trace_id end-to-end.
- MUST NOT allow > 1 year past entries (per DEC-1404).
- 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
- manual_entry_reason cardinality 5.
- 30d default window — entry 31d ago without override → 412.
- 90d engagement_admin override — entry 60d ago with override succeeds + sev-2 audit.
- 1y cfo override — entry 200d ago with cfo override succeeds.
- >1y rejected — entry 400d ago → 412 + entry_too_old.
- 24h cap — Member with 23h59m already, +2min entry → 412.
- 16h softblock — Member with 14h, +3h entry without override → 412 + daily_16h_softblock.
- 16h with override — same scenario with engagement_admin override → succeeds.
- VN OT cap chained — Member at 39h monthly OT, +90min entry (1.5h OT) → 412 monthly_40h_breach.
- Future date rejected — entry_date = tomorrow → 400.
- 5 memory audit kinds emitted.
- Trace_id end-to-end.
- PII scrub — description hash in audit.
- Non-VN Member skips OT check — sg-1 Member entries unaffected by OT chain.
- Same-day OT count post-entry — entry creates row; subsequent timer-stop sees updated total.
- Engagement_admin not member — admin from different engagement → 403.
- Approval override subject_id validated — invalid override → 400.
- Audit kind per scenario — happy →
time.manual_entry_created; 24h →_blocked_24h_cap; etc. - Description optional empty — empty description allowed but warned.
- 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:
- Deferred: Bulk manual entry CSV import — slice 2.
- Deferred: Calendar UI for retroactive bulk edit — slice 2.
- Deferred: Per-engagement custom date-window override — slice 2.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Date > 1 year | tier check | 412 | Caller rejects or escalates legally |
| 24h cap on bulk-add path | per-day check | 412 | Member spreads across days |
| Approval override missing | check | 412 with hint | Caller adds override |
| Invalid approval subject_id | role check | 400 | Caller fixes |
| TASK-TIME-007 OT breach | chained check | 412 with breach kind | Member splits to overtime-tier or new day |
| Future date | date check | 400 | Caller fixes |
| Cross-tenant engagement | RLS | 403 | Inherent |
| Description PII not scrubbed | TASK-MEMORY-111 | Audit dropped + sev-3 | Inherent |
| Same-day timer + manual race | concurrent inserts | Both checked individually; second may hit 24h cap | Inherent |
| Engagement membership lost mid-write | RLS | 403 at write | Inherent |
| Reason missing | validation | 400 | Inherent |
| Bulk import bypass | handler enforces same checks | Tested via TASK-TIME-007 §11.8 lint pattern | CI catches |
| Approval override expired | check expires_at | 412 | Re-request approval |
| Manual entry creates 0-duration | 0 allowed (placeholder) | Inherent | Member edits later |
| Duration > 24h single entry | per-day cap | 412 | Split 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.