"TIME VN Labour Code Art. 107 OT cap — hard-block at entry write when monthly OT > 40h or yearly OT > 200h (300h with regulator approval)"
§1 — Description (BCP-14 normative)
The TIME service MUST ship VN Labour Code Art. 107 OT cap enforcement at services/time/src/vn_labour/ with 4-tier hard-blocks (monthly + yearly + daily + weekly), per-Member approval tier flag, 80% soft warnings, vn-1-residency scoping, and 3 memory audit kinds.
- MUST define closed
ot_breach_kindenum:('monthly_40h_breach','yearly_200h_breach','yearly_300h_breach_no_approval','daily_4h_breach','weekly_12h_breach')per DEC-1392. Cardinality 5.
- MUST apply caps ONLY when
Member's tenant.residency = 'vn-1'per DEC-1394. Non-VN Members skip cap check entirely (other labour-law tasks handle).
- MUST compute OT =
MAX(0, daily_total_hours - 8)per Art. 107. Standard workday baseline 8h; everything beyond is OT.
- MUST aggregate OT at 4 granularities via
vn_labour/aggregator.rs:
- Daily: SUM(OT hours) WHERE entry_date = $today.
- Weekly: SUM(OT hours) WHERE entry_date >= $week_start (Monday-Sunday).
- Monthly: SUM(OT hours) WHERE entry_month = $current_month.
- Yearly: SUM(OT hours) WHERE entry_year = $current_year.
- MUST check 4 hard caps pre-entry-write per DEC-1391 + DEC-1395:
- Daily: new_daily_ot > 4h →
daily_4h_breach. - Weekly: new_weekly_ot > 12h →
weekly_12h_breach. - Monthly: new_monthly_ot > 40h →
monthly_40h_breach. - Yearly (standard tier): new_yearly_ot > 200h AND approval='standard' →
yearly_200h_breach. - Yearly (extended tier): new_yearly_ot > 300h →
yearly_300h_breach_no_approval(regardless of approval; 300h is absolute cap). - First-matching breach returned; entry write blocked with 412 + breach kind.
- MUST emit 80% soft warning per DEC-1391. If new_total > 0.80 × cap AND new_total ≤ cap: write proceeds + emits
time.ot_warning_issuedsev-3 + UI banner.
- MUST consume per-Member
vn_ot_approvalflag per DEC-1393 fromhr_members.vn_ot_approval. TASK-HR-001 owns this field; CLO sets after MOLISA approval received.
- MUST define
vn_ot_trackingtable at migration0003for materialised aggregates:(member_subject_id UUID, year INT, month INT, day DATE, week_start DATE, daily_ot_seconds INT, weekly_ot_seconds INT, monthly_ot_seconds INT, yearly_ot_seconds INT, updated_at TIMESTAMPTZ, PRIMARY KEY (member_subject_id, day)). Updated transactionally on each TIME entry write. RLS scoped.
- MUST expose approval-change endpoint
POST /v1/admin/members/{member_id}/vn-ot-approvalbody{ tier, molisa_doc_ref, expires_at }. Caller hasclorole. Updateshr_members.vn_ot_approval+ emitstime.ot_approval_changedsev-1.
- MUST emit 3 memory audit kinds per DEC-1396:
time.ot_warning_issued(sev-3)time.ot_breach_blocked(sev-2 — material entry rejection)time.ot_approval_changed(sev-1 — compliance event)
- MUST thread trace_id end-to-end.
- MUST NOT allow entry write that breaches per DEC-1391.
- MUST NOT apply VN caps to non-vn-1 Members per DEC-1394.
§2 — Why this design (rationale)
Why hard-block (§1 #5, DEC-1391)? Soft warnings get ignored. Hard-block forces conversation: either Member legitimately stops, or admin investigates rate-card/staffing issue, or extended approval requested.
Why 4 cap granularities (§1 #4-5)? Art. 107 specifies all 4 (daily, weekly, monthly, yearly). Single yearly aggregate would let Member burn 300h in January and crash; granular limits enforce smooth distribution.
Why approval tier flag (§1 #7, DEC-1393)? MOLISA-approved 300h tier is per-Member (not org-wide). Per-Member flag lets CLO grant on case-by-case basis without org-wide policy change.
Why residency scoping (§1 #2, DEC-1394)? Non-VN Members have their own labour laws (EU 48h/week, US no federal cap, etc.). Applying VN caps to non-VN Members is wrong; future tasks cover other jurisdictions.
§3 — API contract
-- 0003_vn_ot_tracking.sql
CREATE TYPE ot_breach_kind AS ENUM ('monthly_40h_breach','yearly_200h_breach','yearly_300h_breach_no_approval','daily_4h_breach','weekly_12h_breach');
CREATE TABLE vn_ot_tracking (
member_subject_id UUID NOT NULL,
tenant_id UUID NOT NULL,
day DATE NOT NULL,
week_start DATE NOT NULL,
year INT NOT NULL,
month INT NOT NULL,
daily_ot_seconds INT NOT NULL DEFAULT 0,
weekly_ot_seconds INT NOT NULL DEFAULT 0,
monthly_ot_seconds INT NOT NULL DEFAULT 0,
yearly_ot_seconds INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (member_subject_id, day)
);
ALTER TABLE vn_ot_tracking ENABLE ROW LEVEL SECURITY;
CREATE POLICY vn_ot_tracking_rls ON vn_ot_tracking
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE DELETE ON vn_ot_tracking FROM cyberos_app;
GRANT UPDATE (daily_ot_seconds, weekly_ot_seconds, monthly_ot_seconds, yearly_ot_seconds, updated_at)
ON vn_ot_tracking TO cyberos_app;
Endpoints:
POST /v1/admin/members/{id}/vn-ot-approval (clo)
GET /v1/time/vn-ot/status?member_id=... (member own; or hr_admin)
§4 — Acceptance criteria
- ot_breach_kind cardinality 5.
- Monthly 40h hard-block — 40h+1min entry → 412 + monthly_40h_breach.
- Yearly 200h block (standard) — Member at 200h0min, +1min entry → 412 + yearly_200h_breach.
- 300h with approval — Member with tier='extended', at 250h, +30min entry → succeeds.
- 300h absolute cap — Member with tier='extended', at 300h0min, +1min → 412 + yearly_300h_breach_no_approval.
- Daily 4h breach — single day 4h+1min OT → 412.
- Weekly 12h breach — sum across week 12h+1min → 412.
- 80% warning emitted — 32h monthly (80% of 40h) → entry succeeds + audit warning.
- Non-VN Member skipped — sg-1 Member 50h monthly OT → succeeds without check.
- Approval change requires CLO — non-CLO call → 403.
- Approval change emits sev-1 audit — CLO updates tier →
time.ot_approval_changed. - OT calculation correct — 9h day = 1h OT.
- Trace_id end-to-end.
- PII scrub — audit row carries Member hash only.
- Aggregator transactional — entry insert + tracking update atomic.
- 3 memory audit kinds emitted in full lifecycle.
- RLS isolation — cross-tenant tracking invisible.
- Concurrent entries respect cap — race-safe via SELECT FOR UPDATE on tracking row.
- Week boundary correctly Monday — entry on Sunday counts to that week.
- Year rollover — Jan 1 entry counts to new year; Dec 31 to old.
§5 — Verification
#[tokio::test]
async fn monthly_40h_hard_blocked() {
let ctx = TestContext::with_vn_member().await;
ctx.seed_ot_for_month(ctx.member_id, 40 * 3600).await; // exactly 40h
let r = ctx.create_entry_with_ot(ctx.member_id, 60).await; // +1min
assert_eq!(r.status(), 412);
let body: serde_json::Value = r.json().await.unwrap();
assert_eq!(body["breach_kind"], "monthly_40h_breach");
}
#[tokio::test]
async fn extended_approval_300h_works() {
let ctx = TestContext::with_vn_member_with_extended_approval().await;
ctx.seed_ot_for_year(ctx.member_id, 250 * 3600).await;
let r = ctx.create_entry_with_ot(ctx.member_id, 30 * 60).await;
assert_eq!(r.status(), 201);
}
#[tokio::test]
async fn non_vn_member_skipped() {
let ctx = TestContext::with_sg_member().await;
ctx.seed_ot_for_month(ctx.member_id, 50 * 3600).await;
let r = ctx.create_entry_with_ot(ctx.member_id, 8 * 3600).await;
assert_eq!(r.status(), 201);
}
#[tokio::test]
async fn 80pct_warning_emitted() {
let ctx = TestContext::with_vn_member().await;
ctx.seed_ot_for_month(ctx.member_id, 30 * 3600).await; // 75% of 40h
let r = ctx.create_entry_with_ot(ctx.member_id, 3 * 3600).await; // pushes to 33h (82.5%)
assert_eq!(r.status(), 201);
let audit = ctx.memory_rows().await;
assert!(audit.iter().any(|r| r.kind == "time.ot_warning_issued"));
}
// 5.5..5.10: daily/weekly breaches, approval gate, aggregator transactional, race-safety
§7 — Dependencies
Upstream: TASK-TIME-001 (entry write path). Cross-module: TASK-HR-001 (vn_ot_approval flag), TASK-TIME-002 (timer integration), TASK-AUTH-101 (clo role), TASK-AI-003, TASK-MEMORY-111.
§8 — Example payload
time.ot_breach_blocked:
{
"kind": "time.ot_breach_blocked",
"severity": 2,
"tenant_id": "8a2f...",
"actor_id": "user.member.456",
"trace_id": "...",
"payload": {
"member_subject_id_hash16": "f8a1...",
"breach_kind": "monthly_40h_breach",
"attempted_ot_seconds": 60,
"current_monthly_ot_seconds": 144000,
"cap_seconds": 144000
}
}
§9 — Open questions
Deferred:
- Deferred: Non-VN labour codes (EU 48h, US OSHA) — slice 2 / per-jurisdiction tasks.
- Deferred: Tet/National holiday triple-pay flagging — slice 2.
- Deferred: Per-engagement OT cap (some clients want stricter) — slice 2.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Aggregator out-of-date | SELECT FOR UPDATE locks tracking row | Inherent consistency | None needed |
| Approval expired mid-year | check expires_at | Treated as 'standard' tier | CLO renews |
| Concurrent entry race | row lock | First wins | Inherent |
| Bulk import bypasses check | import handler must invoke same check | Sev-2 if missing | Code review |
| Cap reached exactly | inclusive vs exclusive | 412 at +1s past cap | Inherent |
| Member transferred to vn-1 mid-year | check at write-time only | Subsequent entries enforced | History grandfathered |
| Daily cap reached but week not | order of checks | Daily wins (more granular) | Inherent |
| Year-end rollover entry | timestamp check | Counts to entry's date year | Inherent |
| Negative OT (correction reduces total) | aggregator handles signed | Subtracts correctly | Inherent |
| Tracking table desync vs entries | nightly reconciliation job | Sev-2 if drift detected | Rebuild from entries |
| Cross-tenant attempt | RLS | 0 rows | Inherent |
| Approval ref doc lost | TEXT field; no FK | Soft loss; CLO re-attaches | Inherent |
| Member changes residency from vn-1 to sg-1 | residency immutable per TASK-TEN-103 | Cannot happen | Inherent |
| Bulk entry retroactively breaches | check at write per entry | Each entry checked individually | Inherent |
| Approval tier changed mid-write | tx isolation | Last-write semantics | Race rare |
§11 — Implementation notes
§11.1 Aggregator uses materialized table for O(1) cap lookup; transactional update on every TIME entry write.
§11.2 Week boundary: Monday 00:00 tenant timezone (default Asia/Ho_Chi_Minh).
§11.3 Year boundary: Jan 1 00:00 tenant timezone.
§11.4 Cap order checked: daily → weekly → monthly → yearly (most granular first).
§11.5 OT computed per entry: OT = MAX(0, entry_seconds_in_day - (8*3600 - prior_today_seconds)).
§11.6 Concurrent insert race: SELECT ... FROM vn_ot_tracking WHERE member_id=$1 AND day=$2 FOR UPDATE before write.
§11.7 Approval expires_at check: if expired, fallback to 'standard' tier silently + emit sev-3 warning.
§11.8 Bulk import handler MUST invoke same cap_check per entry; CI lint enforces.
§11.9 Nightly reconciliation: SUM(entries.duration) vs tracking.daily_ot_seconds; sev-2 alert on drift > 60s.
§11.10 PII: Member identifier hashed to 16-hex in chain; raw retained in DB.
End of TASK-TIME-007 spec.