Task — engineering-spec@1

"HR performance signal aggregator — read-only consumer of PROJ + TIME + LEARN signals for periodic performance snapshots"

draftTASK-HR-008
module hr · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-PROJ-013, TASK-TIME-001 · blocks none

§1 — Description (BCP-14 normative)

The HR service MUST ship performance signal aggregator at services/hr/src/perf/ reading PROJ + TIME + LEARN, monthly snapshots, immutable, 2 memory audit kinds.

  1. MUST be read-only per DEC-1860 — only SELECT against source modules; allowed_tools disallows file_write to non-HR services.
  1. MUST validate perf_signal_kind against closed enum per DEC-1861.
  1. MUST aggregate at signal_aggregator.rs::aggregate(member, period):
  1. MUST run monthly cron at EOM per DEC-1862 via TASK-MCP-007.
  1. MUST define table at migration 0007: ``sql CREATE TABLE hr_perf_snapshots ( snapshot_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, period_end DATE NOT NULL, signals JSONB NOT NULL, prior_period_deltas JSONB, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, member_id, period_end) ); CREATE INDEX perf_snap_member_time_idx ON hr_perf_snapshots(tenant_id, member_id, period_end DESC); ALTER TABLE hr_perf_snapshots ENABLE ROW LEVEL SECURITY; CREATE POLICY perf_snap_rls ON hr_perf_snapshots USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_perf_snapshots FROM cyberos_app; -- Immutable per DEC-1863 ``
  1. MUST expose endpoints (read-only): ``text POST /v1/hr/perf/snapshot (CHRO manual trigger) GET /v1/hr/members/{id}/perf-history (snapshot list desc time) ``
  1. MUST emit 2 memory audit kinds per DEC-1864. PII per TASK-MEMORY-111: signal values hashed; member_id (uuid) ok.
  1. MUST thread trace_id from cron → aggregator → audit.
  1. MUST NOT write to PROJ/TIME/LEARN per DEC-1860 (read-only).
  1. MUST NOT mutate prior snapshot per DEC-1863.

§2 — Why this design

Why read-only (DEC-1860)? Boundary discipline — HR observes performance, doesn't influence source data integrity.

Why monthly EOM (DEC-1862)? Aligns with performance review cadence; weekly = noise, quarterly = too late.

Why immutable snapshots (DEC-1863)? Performance history must replay deterministically; corrections via new row.

Why 5 signal kinds (DEC-1861)? Covers velocity (PROJ), utilization (TIME), learning (LEARN), satisfaction (cultural), burnout (early-warning). Bounded prevents signal sprawl.


§3 — API contract

Sample snapshot:

{
  "snapshot_id": "uuid",
  "member_id": "uuid",
  "period_end": "2026-05-31",
  "signals": {
    "proj_issue_velocity": 12,
    "time_utilization_pct": 0.82,
    "learn_completion_rate": 1.0,
    "project_satisfaction_avg": 4.5,
    "ot_burnout_flag": false
  },
  "prior_period_deltas": {
    "proj_issue_velocity": +2,
    "time_utilization_pct": -0.03,
    "learn_completion_rate": 0.0,
    "project_satisfaction_avg": +0.2,
    "ot_burnout_flag": false
  }
}

§4 — Acceptance criteria

  1. 5-signal enum + cardinality test. 2. Monthly EOM cron. 3. Read-only against PROJ/TIME/LEARN. 4. Snapshots immutable (no UPDATE/DELETE). 5. UNIQUE on (member, period_end). 6. Prior-period deltas computed. 7. 2 memory audit kinds emitted. 8. PII scrubbed (signal values SHA256). 9. RLS denies cross-tenant. 10. CHRO-only manual trigger. 11. Trace_id preserved. 12. Append-only via REVOKE. 13. Missing source data → null signal + sev-2 audit. 14. Inactive member skipped. 15. ot_burnout_flag computed from 3-month rolling. 16. History query desc time. 17. CHRO-only GET. 18. Performance review UI consumes this. 19. Cron skip if 0 active members. 20. JSONB schema validated per signal kind.

§5 — Verification

#[tokio::test]
async fn aggregator_pulls_5_signals() {
    let ctx = TestContext::with_member_data_complete().await;
    let snap = ctx.aggregate(ctx.member_id, "2026-05-31").await;
    assert_eq!(snap.signals.len(), 5);
    assert!(snap.signals.contains_key("proj_issue_velocity"));
}

#[tokio::test]
async fn snapshots_immutable() {
    let ctx = TestContext::with_perf_snapshot().await;
    let r = ctx.try_mutate_snapshot(ctx.snapshot_id).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn read_only_against_sources() {
    let ctx = TestContext::with_member_data().await;
    ctx.run_perf_aggregation(ctx.member_id).await;
    let proj_writes = ctx.proj_write_count_since_test_start().await;
    let time_writes = ctx.time_write_count_since_test_start().await;
    assert_eq!(proj_writes, 0);
    assert_eq!(time_writes, 0);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-PROJ-013, TASK-TIME-001. Cross-module: TASK-LEARN-001 (completion data), TASK-TIME-007 (OT for burnout flag), TASK-MCP-007 (cron), TASK-AUTH-101 (CHRO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Source module unavailableretrynull signal + sev-2next snapshot
Snapshot mutation attemptRLS + REVOKEDB errorinherent
Duplicate snapshot per periodUNIQUEskipinherent
Inactive memberfilterskipinherent
Cron skippedcatch-up next runinherentinherent
Member just hired (no history)empty signals + sev-3inherentinherent
TASK-TIME-007 burnout calc failflag=false defaultsev-2data fix
Cross-tenant aggregate attemptRLS0 rowsinherent
JSONB schema mismatchvalidatorrejectbug fix
Decimal precision driftrust_decimalinherentinherent

§11 — Implementation notes


End of TASK-HR-008 spec.