"TIME weekly approval flow — Member submit → AM (engagement_admin) review → CFO visibility with auto-lock + bulk-approve + diff view"
§1 — Description (BCP-14 normative)
The TIME service MUST ship weekly approval flow at services/time/src/timesheet/ with 5-state status FSM, AM review + reject, bulk approval, 14-day auto-lock, diff view on resubmission, email notifications, and 5 memory audit kinds.
- MUST define closed
timesheet_statusenum:('open','submitted','approved','rejected','locked')per DEC-1421. Cardinality 5.
- MUST define
timesheetstable at migration0005:(timesheet_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_subject_id UUID NOT NULL, engagement_id UUID NOT NULL, week_start_date DATE NOT NULL, week_end_date DATE NOT NULL, status timesheet_status NOT NULL DEFAULT 'open', total_seconds INT NOT NULL DEFAULT 0, billable_seconds INT NOT NULL DEFAULT 0, submitted_at TIMESTAMPTZ, reviewed_at TIMESTAMPTZ, reviewed_by_subject_id UUID, locked_at TIMESTAMPTZ, lock_reason TEXT, trace_id CHAR(32)). Partial unique(member_subject_id, engagement_id, week_start_date). Append-only at status field via REVOKE per task-audit skill rule 12.
- MUST define
timesheet_reviewstable at migration0006for review history:(id BIGSERIAL PRIMARY KEY, timesheet_id UUID NOT NULL REFERENCES timesheets(timesheet_id), reviewer_subject_id UUID NOT NULL, action TEXT NOT NULL CHECK (action IN ('approved','rejected')), reason TEXT, reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(), trace_id CHAR(32)). Append-only.
- MUST enforce RLS scoped to tenant_id; Members see own timesheets, AMs see engagement-scoped, CFO sees all.
- MUST expose Member submit
POST /v1/time/timesheets/{id}/submit. Handler:
- Validates Member owns timesheet.
- Validates status='open' or 'rejected'.
- Aggregates entries for week → total_seconds, billable_seconds.
- Transitions status='submitted' + submitted_at.
- Triggers email to AM via TASK-EMAIL-001.
- Emits
time.timesheet_submittedsev-2.
- MUST expose AM approve
POST /v1/time/timesheets/{id}/approve. Caller hasengagement_adminrole. Handler:
- Validates status='submitted'.
- Transitions status='approved' + reviewed_at + reviewed_by.
- INSERTs review row.
- Auto-transitions to 'locked' immediately per DEC-1422.
- Emits
time.timesheet_approvedsev-1.
- MUST expose AM reject
POST /v1/time/timesheets/{id}/rejectbody{ reason }. Handler:
- Transitions status='rejected'.
- INSERTs review row with reason.
- Triggers email to Member with reason.
- Emits
time.timesheet_rejectedsev-1.
- MUST expose bulk approve
POST /v1/time/timesheets/bulk-approvebody{ engagement_id, week_start_date }per DEC-1423. AM scope. Approves all submitted timesheets matching filter; one memory row + per-timesheet audit. Emitstime.timesheet_bulk_approvedsev-2 + N individualtime.timesheet_approved.
- MUST expose diff view
GET /v1/time/timesheets/{id}/diff?since=<submission_n>per DEC-1424. Returns added/removed/modified entries since prior submission for resubmission review.
- MUST auto-lock 14d post-week-end per DEC-1425 via
auto_lock_job.rs:
- Daily job: SELECT timesheets WHERE status='open' AND week_end_date < now() - 14d.
- Transition status='locked' + lock_reason='auto_lock_14d_no_submission'.
- Triggers email to Member + AM.
- Emits
time.timesheet_auto_lockedsev-2.
- MUST block entry writes for locked weeks per DEC-1422. TASK-TIME-001 entry/create.rs modified to check
timesheets.statusfor the week; locked → 412 +week_locked.
- MUST trigger emails per DEC-1426 via TASK-EMAIL-001:
- Monday 09:00: reminder to Members with unsubmitted previous week.
- Wednesday 09:00: reminder to AMs with pending reviews.
- On rejection: Member notified with reason.
- On auto-lock: Member + AM notified.
- MUST emit 5 memory audit kinds per DEC-1427. PII-scrub reason via TASK-MEMORY-111.
- MUST thread trace_id end-to-end.
- MUST NOT allow mutation of approved/locked timesheets per DEC-1422.
- MUST NOT allow non-AM approval per DEC-1420 (cfo has visibility only; no approval power).
§2 — Why this design (rationale)
Why weekly cadence (§1 #1, DEC-1420)? Industry standard; matches client billing cadence; recent enough that Members remember what they did; not so frequent it's overhead.
Why immediate auto-lock on approve (§1 #6, DEC-1422)? Approved = ready-for-invoice. Lock prevents drift. Corrections via correction_to pattern (TASK-TIME-001 derivative).
Why 14d auto-lock (§1 #10, DEC-1425)? Forgotten timesheets pollute reporting. 14 days = enough for Member to catch up; not so long that quarter-close blows up.
Why bulk approve (§1 #8, DEC-1423)? AM reviewing 20 Members weekly = 20 individual clicks. Bulk per-engagement reduces to 1 click + spot-check view.
Why diff view (§1 #9, DEC-1424)? Resubmission after rejection — AM needs to see what changed, not re-review from scratch. Standard pattern (Git PR diff).
§3 — API contract
-- 0005_timesheets.sql
CREATE TYPE timesheet_status AS ENUM ('open','submitted','approved','rejected','locked');
CREATE TABLE timesheets (
timesheet_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
member_subject_id UUID NOT NULL,
engagement_id UUID NOT NULL,
week_start_date DATE NOT NULL,
week_end_date DATE NOT NULL,
status timesheet_status NOT NULL DEFAULT 'open',
total_seconds INT NOT NULL DEFAULT 0,
billable_seconds INT NOT NULL DEFAULT 0,
submitted_at TIMESTAMPTZ,
reviewed_at TIMESTAMPTZ,
reviewed_by_subject_id UUID,
locked_at TIMESTAMPTZ,
lock_reason TEXT,
trace_id CHAR(32)
);
CREATE UNIQUE INDEX uniq_timesheet_member_week
ON timesheets(member_subject_id, engagement_id, week_start_date);
ALTER TABLE timesheets ENABLE ROW LEVEL SECURITY;
CREATE POLICY timesheets_rls ON timesheets
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE DELETE ON timesheets FROM cyberos_app;
GRANT UPDATE (status, total_seconds, billable_seconds, submitted_at, reviewed_at,
reviewed_by_subject_id, locked_at, lock_reason) ON timesheets TO cyberos_app;
-- 0006_timesheet_reviews.sql
CREATE TABLE timesheet_reviews (
id BIGSERIAL PRIMARY KEY,
timesheet_id UUID NOT NULL REFERENCES timesheets(timesheet_id),
reviewer_subject_id UUID NOT NULL,
action TEXT NOT NULL CHECK (action IN ('approved','rejected')),
reason TEXT,
reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
trace_id CHAR(32)
);
ALTER TABLE timesheet_reviews ENABLE ROW LEVEL SECURITY;
CREATE POLICY timesheet_reviews_rls ON timesheet_reviews
USING (timesheet_id IN (SELECT timesheet_id FROM timesheets WHERE tenant_id = current_setting('auth.tenant_id')::uuid))
WITH CHECK (timesheet_id IN (SELECT timesheet_id FROM timesheets WHERE tenant_id = current_setting('auth.tenant_id')::uuid));
REVOKE UPDATE, DELETE ON timesheet_reviews FROM cyberos_app;
Endpoints:
POST /v1/time/timesheets/{id}/submit
POST /v1/time/timesheets/{id}/approve (engagement_admin)
POST /v1/time/timesheets/{id}/reject (engagement_admin)
POST /v1/time/timesheets/bulk-approve (engagement_admin)
GET /v1/time/timesheets/{id}/diff?since=N
GET /v1/time/timesheets/pending (engagement_admin)
GET /v1/time/timesheets/mine (member)
§4 — Acceptance criteria
- timesheet_status cardinality 5.
- Submit transitions — open → submitted; email sent.
- Approve locks — submitted → approved → locked immediate.
- Reject + reason — submitted → rejected; Member emailed.
- Resubmit — rejected → submitted; diff view shows changes.
- Bulk approve — 5 submitted in engagement → all → approved.
- Auto-lock 14d — week_end+14d + status=open → locked.
- Locked entries blocked — entry write for locked week → 412.
- AM-only approval — Member tries to approve own → 403.
- 5 memory audit kinds emitted.
- Email cadence Monday/Wednesday — scheduled jobs verified.
- CFO read-only — CFO can view but POST approve → 403.
- Trace_id end-to-end.
- RLS cross-tenant denied.
- PII scrub reason.
- Concurrent submit race — first wins; second 409.
- Approved correction via correction_to — works (TASK-TIME-001 path).
- Diff includes added + removed + modified — entry diffs accurate.
- Pending list ordered by week_end — oldest first.
- Bulk approve idempotent on already-approved — skips, no error.
§5 — Verification
#[tokio::test]
async fn submit_then_approve_locks() {
let ctx = TestContext::with_timesheet().await;
ctx.submit_timesheet(ctx.ts_id).await;
ctx.as_am().approve_timesheet(ctx.ts_id).await;
let status: String = sqlx::query_scalar("SELECT status::text FROM timesheets WHERE timesheet_id=$1")
.bind(ctx.ts_id).fetch_one(&ctx.pool).await.unwrap();
assert_eq!(status, "locked");
}
#[tokio::test]
async fn reject_with_reason_emails_member() {
let ctx = TestContext::with_submitted_timesheet().await;
ctx.as_am().reject_timesheet(ctx.ts_id, "missing project codes").await;
let emails = ctx.sent_emails_to(ctx.member_id).await;
assert!(emails.iter().any(|e| e.body.contains("missing project codes")));
}
#[tokio::test]
async fn auto_lock_after_14d() {
let ctx = TestContext::with_open_timesheet_week_ago(15).await;
ctx.run_auto_lock_job().await;
let status: String = sqlx::query_scalar("SELECT status::text FROM timesheets WHERE timesheet_id=$1")
.bind(ctx.ts_id).fetch_one(&ctx.pool).await.unwrap();
assert_eq!(status, "locked");
}
#[tokio::test]
async fn locked_week_blocks_entry_write() {
let ctx = TestContext::with_locked_timesheet().await;
let r = ctx.create_entry_for_week(ctx.member_id, ctx.locked_week_date).await;
assert_eq!(r.status(), 412);
}
// 5.5..5.10: bulk, diff, AM-only, audit, race, CFO read-only
§7 — Dependencies
Upstream: TASK-TIME-001 (entries to aggregate). Cross-module: TASK-AUTH-101 (engagement_admin role), TASK-EMAIL-001 (notifications), TASK-AI-003, TASK-MEMORY-111.
§8 — Example payload
time.timesheet_approved:
{
"kind": "time.timesheet_approved",
"severity": 1,
"tenant_id": "8a2f...",
"actor_id": "user.engagement_admin.789",
"trace_id": "...",
"payload": {
"timesheet_id": "0190...",
"member_subject_id_hash16": "f8a1...",
"engagement_id": "0190...",
"week_start_date": "2026-05-10",
"total_seconds": 144000,
"billable_seconds": 130000
}
}
§9 — Open questions
Deferred:
- Deferred: Multi-week submission (catch-up) — slice 2.
- Deferred: Auto-approve for trusted Members (zero-rejection track record) — slice 3.
- Deferred: Variance flags (week deviates > 20% from history) — slice 3.
- Deferred: Mobile push notifications — slice 3.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Submit before week-end | date check | 400 + early_submission | Wait for week end |
| Approve without AM role | role check | 403 | Inherent |
| Reject without reason | validation | 400 | Inherent |
| Email send fails | TASK-EMAIL-001 retry | Audit logged; user notified | Inherent retry |
| Concurrent submit + auto-lock race | tx isolation; partial unique catches | Submit wins if before lock | Inherent |
| Bulk approve includes non-submitted | filter on status='submitted' | Skipped silently | Inherent |
| Bulk approve cross-engagement | scope check | 403 if non-AM | Inherent |
| Auto-lock job crashes | watchdog | Sev-2; manual lock CLI | Operator runs job manually |
| Locked week with pending corrections | correction_to path | Allowed; preserves audit | TASK-TIME-001 derivative |
| Member resubmits after lock | status check | 412 + week_locked | New entries via correction |
| AM tries to approve own timesheet | role + self check | 403 | Inherent |
| Submitted timesheet with 0 entries | allowed (zero-hours week) | Approved if AM ok | Inherent |
| Diff view across multiple resubmissions | versioned reviews | Last-submission diff | Inherent |
| Tenant timezone affects week boundary | per-tenant config | Monday 00:00 tenant TZ | Inherent |
| Email rate-limit hit | TASK-EMAIL-001 queue | Delivery delayed | Inherent retry |
| Reviewer subject_id deleted post-approval | FK soft | Review row retained | Inherent forensic |
| Bulk approve > 100 timesheets | size limit | 413 | Caller filters narrower |
| Auto-lock during Member typing | tx isolation | Member's submit may race with lock | First-wins |
§11 — Implementation notes
§11.1 Week boundary: Monday 00:00 tenant timezone.
§11.2 Aggregation at submit reads entries for week × engagement × member.
§11.3 Bulk approve uses single tx + N audit emits.
§11.4 Auto-lock job runs daily 03:00; sweeps past-due.
§11.5 Diff view compares two submission snapshots; each submission persists entry IDs.
§11.6 Email templates use TASK-PORTAL-002 brand pack overrides for tenant theming.
§11.7 Locked-week entry block at TASK-TIME-001 create.rs entry-point.
§11.8 Concurrent submit race resolved by partial unique on (member, engagement, week).
§11.9 Audit row carries member + AM subject IDs as hashes.
§11.10 CFO visibility via separate read endpoint with cfo role; no write.
End of TASK-TIME-006 spec.