Task — engineering-spec@1

"RES allocation Gantt UI — drag-rebalance interface over capacity matrix with optimistic concurrency + commit-on-save"

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

§1 — Description (BCP-14 normative)

The RES service + portal-web frontend MUST ship allocation Gantt at services/res/src/allocation/ with drag-rebalance + optimistic concurrency + pre-commit validation, 4 memory audit kinds.

  1. MUST validate allocation_change_status against closed enum per DEC-2041.
  1. MUST propose changes at proposer.rs::propose(member_id, week, changes) per DEC-2040 — changes array of {project_id, delta_hours}.
  1. MUST validate at validator.rs::validate(proposal) per DEC-2043:
  1. MUST commit at commit_handler.rs::commit(proposal, version) per DEC-2044:
  1. MUST define table at migration 0002: ```sql ALTER TABLE res_capacity_matrix ADD COLUMN version INT NOT NULL DEFAULT 1; GRANT UPDATE (allocated_hours, version) ON res_capacity_matrix TO cyberos_app;

CREATE TABLE res_allocation_changes ( change_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, iso_week CHAR(8) NOT NULL, project_id UUID NOT NULL, old_allocated_hours NUMERIC(5,2) NOT NULL, new_allocated_hours NUMERIC(5,2) NOT NULL, status TEXT NOT NULL CHECK (status IN ('proposed','validated','committed','rejected','conflicted')), rejection_reason TEXT, proposed_by UUID NOT NULL, committed_at TIMESTAMPTZ, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX changes_member_week_idx ON res_allocation_changes(tenant_id, member_id, iso_week, created_at DESC); ALTER TABLE res_allocation_changes ENABLE ROW LEVEL SECURITY; CREATE POLICY changes_rls ON res_allocation_changes USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON res_allocation_changes FROM cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/res/allocations/propose body: {member_id, iso_week, changes: [...], expected_version} POST /v1/res/allocations/{id}/commit GET /v1/res/allocations/changes?member_id=...&iso_week=... ``
  1. MUST emit 4 memory audit kinds per DEC-2045. PII per TASK-MEMORY-111: hours SHA-256 hashed.
  1. MUST thread trace_id from UI action → propose → validate → commit → audit.
  1. MUST NOT bypass validation per DEC-2043.
  1. MUST NOT mutate prior change row per DEC-2044 (append-only).

§2 — Why this design

Why drag UI (DEC-2040)? Spreadsheet-style allocation is tedious; visual drag matches mental model.

Why optimistic concurrency (DEC-2042)? Multiple CHROs editing concurrently; version check prevents last-write-wins overwrite.

Why pre-commit validation (DEC-2043)? Better UX to surface OT cap violation at propose-time than after save.

Why append-only changes (DEC-2044)? Audit trail — "who changed what when" must be preserved.


§3 — API contract

Sample propose:

POST /v1/res/allocations/propose
{
  "member_id": "uuid",
  "iso_week": "2026-W20",
  "expected_version": 3,
  "changes": [
    {"project_id": "uuid-a", "delta_hours": -5},
    {"project_id": "uuid-b", "delta_hours": +5}
  ]
}

Response (validated):

{
  "proposal_id": "uuid",
  "status": "validated",
  "warnings": [{"kind": "over_threshold", "current_pct": 105}]
}

Response (rejected):

{
  "status": "rejected",
  "rejection_reason": "OT cap exceeded: weekly OT would be 14h, max 12h per Decree 145"
}

§4 — Acceptance criteria

  1. allocation_change_status enum cardinality 5. 2. Drag UI updates state. 3. Pre-commit validation enforced. 4. OT cap rejection (TASK-RES-005). 5. Over-threshold warning (TASK-RES-003 110%). 6. Optimistic concurrency via version check. 7. Version mismatch → status=conflicted. 8. Transactional commit (all-or-nothing). 9. 4 memory audit kinds emitted. 10. PII scrubbed (hours SHA256). 11. RLS denies cross-tenant. 12. CHRO/PM role only. 13. Trace_id preserved. 14. Append-only via REVOKE. 15. rust_decimal precision. 16. History query desc time. 17. Drag UI disables locked rows. 18. Commit dialog confirms changes. 19. WebSocket update broadcasts to other CHRO clients. 20. Undo via new compensating change.

§5 — Verification

#[tokio::test]
async fn optimistic_concurrency_rejects_stale() {
    let ctx = TestContext::with_matrix_v1().await;
    let p1 = ctx.propose(ctx.member_id, this_week(), changes(), 1).await;
    ctx.commit(p1.id).await;  // version → 2
    let p2 = ctx.propose(ctx.member_id, this_week(), changes(), 1).await;
    let r = ctx.try_commit(p2.id).await;
    assert!(r.status == "conflicted");
}

#[tokio::test]
async fn ot_cap_rejection() {
    let ctx = TestContext::member_at_60h_already().await;
    let r = ctx.propose(ctx.member_id, this_week(), add_5h_more(), 1).await;
    assert!(r.status == "rejected");
    assert!(r.rejection_reason.contains("OT cap"));
}

#[tokio::test]
async fn commit_atomic() {
    let ctx = TestContext::with_matrix().await;
    let p = ctx.propose(...).await;
    ctx.simulate_db_failure_mid_commit().await;
    let row = ctx.fetch_matrix_row(ctx.member_id, week).await;
    assert_eq!(row.version, 1);  // unchanged
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-RES-001. Cross-module: TASK-RES-003 (threshold flags), TASK-RES-005 (OT cap), TASK-PROJ-001 (project context), TASK-AUTH-101 (CHRO/PM role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Version mismatchcheckconflicted; UI refreshretry
OT cap violationvalidatorrejectedadjust
Negative allocationvalidate400fix
DB transaction failrollbacksev-2; status=rejectedretry
Cross-tenant writeRLS403inherent
Decimal precisionrust_decimalinherentinherent
WebSocket disconnectreconnectstale view + sev-3refresh
Drag past capacity boundsUI clampinherentinherent
Mass-allocation request (>50 changes)batch limit400split
Concurrent same-cell proposeUNIQUE on commitfirst winsretry

§11 — Implementation notes


End of TASK-RES-002 spec.