Task — engineering-spec@1

"HR Member schema — profile + role + level + contract type + leave balance + sabbatical accrual + status FSM + comp-exclusion CI gate"

draftTASK-HR-001
module hr · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-AUTH-003, TASK-AUTH-101 · blocks TASK-HR-002, TASK-HR-003, TASK-HR-004, TASK-HR-005, TASK-HR-007, TASK-HR-009, TASK-LEARN-001, TASK-REW-001, TASK-ESOP-001, TASK-RES-001

§1 — Description (BCP-14 normative)

The HR service MUST ship the Member schema as the canonical single source of truth for "is this person currently employed by this tenant, in what capacity, since when, with what entitlements?". Each requirement:

  1. MUST define the members table with the following columns and constraints (full DDL in §3.1):
  1. MUST enforce RLS with both USING AND WITH CHECK clauses on the members table (per task-audit skill rule 13). Policy: tenant_id = current_setting('auth.tenant_id')::uuid. Reads from one tenant return zero rows of another; INSERTs targeting a different tenant fail with permission_denied.
  1. MUST declare the closed member_status PostgreSQL enum with exactly 6 values (per DEC-200): 'candidate', 'probation', 'active', 'on_leave', 'suspended', 'terminated'. Adding a 7th value is an ADR (mirrors TASK-AUTH-101's role-catalogue discipline).
  1. MUST declare the closed member_level PostgreSQL enum with exactly 7 values (per DEC-206): 'trainee', 'associate', 'senior', 'lead', 'principal', 'director', 'executive'. Levels map to VN-1 progression; adding L8 (e.g. fellow) is an ADR.
  1. MUST ship the status FSM transition matrix in services/hr/src/fsm/status.rs as a closed lookup table. The valid transitions are:
  1. MUST record every status transition as an append-only row in member_status_history(member_id, tenant_id, from_status, to_status, changed_at, changed_by_subject_id, reason TEXT). The table has REVOKE UPDATE, DELETE FROM cyberos_app per task-audit skill rule 12; history rows are append-only by SQL grant, not by handler discipline.
  1. MUST trigger emission of exactly one hr.member_status_changed memory audit row per status transition (per DEC-208), atomically with the DB write (audit-before-action per task-audit skill rule 25). The row carries {member_id, tenant_id, from_status, to_status, changed_by_subject_id_hash16, reason, trace_id, ts_ns}.
  1. MUST treat start_date as immutable post-transition-to-active (per DEC-207). A BEFORE UPDATE trigger on members rejects any UPDATE that changes start_date when the prior status was in ('active','on_leave','suspended','terminated'). Returns cannot_modify_locked_start_date to the handler.
  1. MUST compute sabbatical_eligible_at as a Postgres GENERATED ALWAYS AS (start_date + INTERVAL '5 years') STORED column. Members with status = 'active' AND CURRENT_DATE >= sabbatical_eligible_at are eligible for sabbatical accrual (1 paid day per completed year of service after year 5, capped at 30 days — per Decree 145/2020 Art. 113 + DEC-201). The accrual calculator lives in services/hr/src/sabbatical.rs and is queried by TASK-HR-004's leave-type entry creation.
  1. MUST treat leave_balance_days as a read-only materialised value (per DEC-204). The column is updated EXCLUSIVELY by TASK-HR-004's leave_entries trigger; direct UPDATE on this column from any other path is blocked by a BEFORE UPDATE trigger that returns leave_balance_is_materialised. The handler-side update_member() function explicitly omits leave_balance_days from its allowed-fields list.
  1. MUST categorically forbid compensation columns in this schema (per DEC-203). The forbidden column-name set is {base_salary, salary, base_pay, bonus, p1_base, p2_allowance, p3_performance, equity_units, esop_grant, total_comp, gross_pay, net_pay, comp_band, pay_band}. Two enforcement layers:
  1. MUST declare cccd_encrypted BYTEA (nullable) as the only CCCD-related column on this table (per DEC-202). Raw cccd_id or cccd_photo_url MUST NOT exist; encryption + photo storage is owned by TASK-HR-003 which writes to a separate member_cccd table with its own KMS keyspace.
  1. MUST emit hr.member_cccd_accessed memory audit row at sev-1 priority whenever any handler reads the cccd_encrypted column (per DEC-208 + PDPL Art. 14). Access without justification triggers OBS sev-1 alarm via TASK-OBS-007.
  1. MUST expose REST handlers:
  1. MUST ensure 1:1 mapping with auth.subjects (per DEC-209): member.subject_id is FOREIGN KEY REFERENCES auth.subjects(id). Cascading semantics: ON DELETE RESTRICT (a Member record cannot be removed while the Auth subject exists; offboarding goes through the terminated state, not deletion).
  1. MUST support idempotent creation via Idempotency-Key header (same semantics as TASK-AUTH-002 §1 #6). Repeat POST with same key + same body → return existing member. Repeat POST with same key + different body → 409 idempotency_key_reuse.
  1. MUST complete create/get/patch handlers in ≤ 100 ms p95 (no LLM call; just Postgres + audit emit). Performance test members_perf_test asserts.
  1. MUST emit OTel span hr.member.{create,get,update,transition} per handler with attributes: tenant_id, member_id, subject_id_hash16, outcome (success | not_found | invalid_transition | comp_field_rejected | permission_denied).
  1. MUST emit OTel metrics:
  1. MUST ship the member_active_view SQL view filtering status IN ('probation','active','on_leave') (per DEC-205 — active is a logical concept covering "currently engaged"). Downstream tasks querying "who is currently employed" SHOULD use this view, not the raw table, to avoid status-filter drift.
  1. MUST ship the sabbatical_eligible_view SQL view returning member_id + accrued_days_unused: SELECT m.subject_id, sabbatical_accrued_days(m.start_date) - COALESCE(sl.used_days, 0) FROM members m LEFT JOIN sabbatical_used_summary sl ON m.subject_id = sl.member_id WHERE m.status = 'active' AND CURRENT_DATE >= m.sabbatical_eligible_at. The view is the contract for TASK-HR-004's sabbatical-leave-type entry validation.
  1. MUST validate that level is appropriate for the contract type (per TASK-HR-002 once that lands): contract_type='contractor' rejects level='executive' (contractors are not on executive band). This task ships the column; the cross-validation rule lands in TASK-HR-002 (forward-compatible — the validator stub returns OK for all combinations at slice 1).
  1. MUST anchor a hr.member_created memory row at member creation containing {member_id, tenant_id, subject_id_hash16, level, contract_type, status, created_by_subject_id_hash16}. The row is PII-scrubbed of full_name and email via TASK-MEMORY-111 before chain commit (only subject_id_hash16 is privacy-safe in the audit chain).
  1. MUST anchor a hr.member_updated memory row at every PATCH carrying {member_id, fields_changed: [...]} (NO old/new values — those are PDPL-sensitive; the field-change list is enough for compliance trace). The full diff is captured in the OTel span (transient, < 30-day retention).
  1. MUST support AUTH-bound trigger: when an Auth subject is created with claim hr_employee: true (set by tenant-admin during onboarding), AUTH calls HR's POST /v1/admin/members automatically with status='candidate', level='trainee' (operator amends later). Slice 1 ships a manual handler; the auto-trigger is enabled at the AUTH side via the modified services/auth/src/admin/subjects.rs patch listed in modified_files.
  1. MUST ship the sabbatical_accrued_days(start_date DATE) SQL function with deterministic output: returns 0 if years_of_service < 5; otherwise returns LEAST(years_of_service - 5 + 1, 30). Pure function; same input → same output (per task-audit skill rule 27).

§2 — Why this design (rationale for humans)

Why a separate Member entity from the Auth subject (DEC-209)? A subject is a credential — "can this entity log in?". A Member is an employment fact — "is this person currently employed, in what capacity, since when?". They are 1:1 (every Member has exactly one Auth credential) but they carry different lifecycle responsibilities. A subject can be deactivated (forgot password, suspended) without altering the employment fact; a Member can be terminated without immediately revoking the AUTH credential (offboarding workflows often keep the credential active for ~24h to allow final-day operations). Keeping the entities separate also means HR can model employment-shaped concepts (sabbatical eligibility, contract type, leave balance) without polluting the auth schema with HR-specific columns.

Why a closed status enum (DEC-200)? The status field is the cross-module identity question — "is this person currently employed?" — and every downstream module has its own answer hard-coded to specific values. PROJ allocations filter status = 'active'. REW payroll filters status IN ('probation', 'active', 'on_leave'). ESOP grant eligibility filters status = 'active' AND CURRENT_DATE >= grant_eligibility_date. If status were a free-form text field, each module would have its own typos and synonyms ("Active" vs "active" vs "ACTIVE"); the closed enum is enforced at the DB and SQL function layers. Adding a state (e.g. 'pending_visa' for cross-border hires) is an ADR — the design ceiling forces consideration of cross-module impact before adoption.

Why a closed level enum (DEC-206)? Same reason as status — but with a stricter motivation. Level drives compensation band (REW), allocation capacity (RES), and promotion approval workflows (LEARN). Free-form level strings would invite tenant-specific drift (one tenant uses "Junior", another "Associate", a third "Mid") and break cross-tenant analytics. The 7-level closed enum (trainee/associate/senior/lead/principal/director/executive) maps to a standard VN-1 progression — and tenants that want different naming overlay the display label via i18n, not the underlying enum value.

Why explicitly forbid compensation columns (DEC-203, §1 #11)? The single most likely "well, it would be convenient if..." mistake is putting base_salary on the Member record. REW (TASK-REW-001) deliberately ships an encrypted comp keyspace separate from HR — but if HR exposes base_salary even via a "we'll just leave it null" pattern, the encryption boundary is a paper fence. The CI gate (comp_exclusion_test) parses migration files and rejects diffs containing forbidden column names; the DB-level CHECK constraint duplicates the protection at the database. Two layers because the cost of getting this wrong is "salary leaks via HR queries" and the cost of the gates is < 0.5h of CI time per migration. Worth the deliberate overkill.

Why leave_balance_days as read-only materialised view of TASK-HR-004 (DEC-204)? Two writers to the same value invariably drift. The leave-entry workflow (TASK-HR-004) computes balance from the entry history; if HR-001 also exposed UPDATE on leave_balance_days, two consistent code paths would emerge and within 6 months they'd disagree. Making the column read-only (UPDATE blocked at trigger level; handler update_member doesn't accept it) collapses the writers to one. The downstream cost (TASK-HR-004 must trigger the recalc) is intentional — and the trigger is a single function (recompute_leave_balance(member_id)) in TASK-HR-004.

Why immutable start_date post-active (DEC-207, §1 #8)? Start_date drives sabbatical eligibility (5-year mark), annual-leave accrual (1 day per 5 years), seniority bonuses, and IDR-validation contexts. If it could be amended freely post-active, the audit chain for "why did Person A get a sabbatical when their colleague Person B didn't?" becomes unanswerable. Making it immutable forces the rare correction case through an ADR — and the ADR captures the WHY. The trigger fires at the SQL layer (catches even direct psql sessions, not just the handler).

Why append-only status history (DEC-205, §1 #6)? Status history is the answer to "what is this person's employment narrative?" — the cardinal HR question. A row inserted today saying "active → terminated, reason: layoffs Q3" is the legal record for the next 7 years. SQL grants make this audit-grade: REVOKE UPDATE, DELETE FROM cyberos_app means even a handler bug or operator typo can't rewrite history. Discovery requests (subpoenas, DSAR responses) return the history table as-is.

Why CCCD encrypted in this schema and not stored elsewhere (DEC-202, §1 #12)? CCCD is PDPL-sensitive (Art. 14 + Decree 13/2023 Art. 18); the photo (separate column, separate KMS keyspace) is even more sensitive. Storing the encrypted bytes in the members table lets HR look up "what's my employee's national ID?" with a single query, and the encryption boundary at TASK-HR-003 means access requires a separate KMS unlock. The alternative (storing in a separate member_cccd table) is what TASK-HR-003 does — and this task declares the column to make the relationship visible but defers the unlock contract to TASK-HR-003.

Why sabbatical accrued via SQL function (§1 #26)? Determinism. The function is pure (years_of_serviceeligible_days), no hidden time or random factors. Tests assert the same start_date always produces the same output. Implementing this as Rust code would mean two implementations (Rust + SQL view) drift; the SQL function is the only one.

Why 5-year + 30-day cap sabbatical (DEC-201)? Decree 145/2020 Art. 113 establishes 5 consecutive years of service as the eligibility threshold. The 30-day cap is a CyberSkill policy decision (not statutory) — the spec captures it explicitly so tenants adopting our pack can adjust via ADR for their own policy. The reset-on-use rule is also policy: tenants who want "lifetime accrual" can override with an ADR.

Why an member_active_view filter status IN ('probation','active','on_leave') (§1 #20)? "Active" the SQL term is overloaded: HR's member_status has 'active' as one of six values, but the question "who is currently employed?" includes probation and on-leave Members too. Filtering the view explicitly means downstream tasks don't reinvent the predicate, and changes to "what counts as currently employed?" are one view change, not a search-and-replace across the catalog.

Why subject_id is the PRIMARY KEY (§1 #1)? Two reasons. (1) It's already a UUID generated by AUTH on subject create; reusing it avoids a separate member_id UUID that adds no information. (2) Joins between AUTH and HR are by subject_id everywhere; making it the primary key removes a column. The cost is "if a Member is created but the Auth subject was deleted, the FK fails" — but that's the right semantic (we don't want HR Members without a corresponding credential identity).

Why email mirrored from AUTH (§1 #1)? Joins-free lookups for ops queries ("find the Member by their email"). The mirror is enforced soft (UNIQUE (tenant_id, email) constraint; manual updates allowed via the same handler that updates AUTH) at slice 1. Slice 3+ may add a trigger that propagates AUTH-side email changes to HR; for now, the operator updates both via separate handler calls (rare event).

Why level NOT enforced against contract type at slice 1 (§1 #22)? The cross-field validation rule (contract_type='contractor' rejects level='executive') is TASK-HR-002's responsibility — TASK-HR-002 ships the contract-type enum and lifecycle. Slice 1 keeps the levels orthogonal to enable the schema landing standalone; the constraint plugs in at TASK-HR-002 commit without breaking changes.

Why subject_id_hash16 instead of full subject_id in memory rows (§1 #23, §1 #24)? Same pattern as TASK-AUTH-002 — privacy-preserving identifier. The full subject_id is in the row's tenant-scoped Postgres write; the audit chain (which is read more broadly) carries only the 16-hex prefix of SHA-256(subject_id). Forensic operations join via the prefix; the prefix is collision-safe at our scale (~1 in 10⁹).

Why declare the hr.member_cccd_accessed event at sev-1 (§1 #13)? CCCD is the highest-sensitivity field; access to it is rare and operational (e.g. ID verification at a bank visit). Routine queries should NEVER read this field — and a sev-1 alarm on every access means routine code paths that accidentally fetch it surface immediately. Acceptable noise: a few legitimate weekly accesses per tenant. The alarm noise pays for catching policy violations early.

Why a separate transition handler (§1 #14) instead of PATCH status? Status transitions have side effects (audit row, history row, memory audit, status-FSM validation, OTel metric increment). Allowing them via free-form PATCH would mean every handler call has to redo this orchestration. The dedicated handler POST /v1/admin/members/{id}/transition is the contract — and PATCH explicitly rejects status field changes (field_not_patchable).


§3 — API contract

3.1 — Migration 0001 — members table

-- services/hr/migrations/0001_members.sql

BEGIN;

-- 6-value closed status enum (per DEC-200)
CREATE TYPE member_status AS ENUM (
    'candidate', 'probation', 'active', 'on_leave', 'suspended', 'terminated'
);

-- 7-value closed level enum (per DEC-206)
CREATE TYPE member_level AS ENUM (
    'trainee', 'associate', 'senior', 'lead', 'principal', 'director', 'executive'
);

-- 5-value contract type enum (full validation in TASK-HR-002; placeholder values here)
CREATE TYPE contract_type AS ENUM (
    'indefinite', 'fixed_term', 'probation', 'part_time', 'contractor'
);

CREATE TABLE members (
    subject_id            UUID         PRIMARY KEY REFERENCES auth.subjects(id) ON DELETE RESTRICT,
    tenant_id             UUID         NOT NULL,
    full_name             TEXT         NOT NULL CHECK (length(full_name) BETWEEN 1 AND 200),
    preferred_name        TEXT         CHECK (preferred_name IS NULL OR length(preferred_name) BETWEEN 1 AND 100),
    email                 TEXT         NOT NULL,
    cccd_encrypted        BYTEA,                            -- nullable; encrypted via TASK-HR-003 KMS keyspace
    level                 member_level NOT NULL,
    status                member_status NOT NULL DEFAULT 'candidate',
    contract_type         contract_type NOT NULL DEFAULT 'probation',
    start_date            DATE,                             -- nullable until status reaches active; immutable after
    end_date              DATE,                             -- set on terminated
    sabbatical_eligible_at DATE GENERATED ALWAYS AS (start_date + INTERVAL '5 years') STORED,
    leave_balance_days    NUMERIC(5,1) NOT NULL DEFAULT 0.0,  -- read-only materialised view of TASK-HR-004 leave_entries
    created_at            TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at            TIMESTAMPTZ  NOT NULL DEFAULT now(),

    CONSTRAINT uniq_tenant_email UNIQUE (tenant_id, email),
    -- comp-exclusion guard (DEC-203): assert no forbidden column names exist in this table
    -- enforced at migration time by the `comp_exclusion_test` CI gate; this CHECK is belt-and-braces.
    CONSTRAINT comp_columns_excluded CHECK (true)
);

CREATE INDEX members_tenant_status_idx ON members (tenant_id, status);
CREATE INDEX members_active_eligible_idx ON members (tenant_id, sabbatical_eligible_at) WHERE status = 'active';

-- RLS (per TASK-AUTH-003 + task-audit skill rule 13)
ALTER TABLE members ENABLE ROW LEVEL SECURITY;
CREATE POLICY members_tenant_isolation ON members
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

-- Immutable start_date post-active trigger (per DEC-207)
CREATE OR REPLACE FUNCTION enforce_immutable_start_date() RETURNS TRIGGER AS $$
BEGIN
    IF OLD.status IN ('active','on_leave','suspended','terminated')
       AND OLD.start_date IS NOT NULL
       AND NEW.start_date IS DISTINCT FROM OLD.start_date THEN
        RAISE EXCEPTION 'cannot_modify_locked_start_date'
            USING ERRCODE = 'P0001';
    END IF;
    NEW.updated_at := now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_members_immutable_start_date BEFORE UPDATE ON members
    FOR EACH ROW EXECUTE FUNCTION enforce_immutable_start_date();

-- leave_balance_days is read-only (per DEC-204; TASK-HR-004 trigger is the only writer)
CREATE OR REPLACE FUNCTION enforce_leave_balance_readonly() RETURNS TRIGGER AS $$
BEGIN
    IF NEW.leave_balance_days IS DISTINCT FROM OLD.leave_balance_days
       AND current_setting('hr.bypass_leave_balance_check', true) IS DISTINCT FROM 'true' THEN
        RAISE EXCEPTION 'leave_balance_is_materialised'
            USING ERRCODE = 'P0002';
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_members_leave_balance_readonly BEFORE UPDATE ON members
    FOR EACH ROW EXECUTE FUNCTION enforce_leave_balance_readonly();

-- Sabbatical accrual function (per DEC-201 + Decree 145/2020 Art. 113)
CREATE OR REPLACE FUNCTION sabbatical_accrued_days(start_date DATE) RETURNS INT AS $$
DECLARE years_service INT;
BEGIN
    IF start_date IS NULL THEN RETURN 0; END IF;
    years_service := EXTRACT(YEAR FROM age(CURRENT_DATE, start_date))::INT;
    IF years_service < 5 THEN RETURN 0; END IF;
    RETURN LEAST(years_service - 5 + 1, 30);
END;
$$ LANGUAGE plpgsql IMMUTABLE;

COMMIT;

3.2 — Migration 0002 — status history (append-only)

-- services/hr/migrations/0002_member_status_history.sql

BEGIN;

CREATE TABLE member_status_history (
    id                       BIGSERIAL    PRIMARY KEY,
    member_id                UUID         NOT NULL REFERENCES members(subject_id),
    tenant_id                UUID         NOT NULL,
    from_status              member_status,                  -- NULL on initial create
    to_status                member_status NOT NULL,
    changed_at               TIMESTAMPTZ  NOT NULL DEFAULT now(),
    changed_by_subject_id    UUID         NOT NULL,
    reason                   TEXT         NOT NULL CHECK (length(reason) BETWEEN 1 AND 1000),
    audit_chain_hash         TEXT         NOT NULL                 -- chained to memory row hash for replay-equivalence
);

CREATE INDEX member_status_history_member_idx ON member_status_history (member_id, changed_at DESC);
CREATE INDEX member_status_history_tenant_idx ON member_status_history (tenant_id, changed_at DESC);

ALTER TABLE member_status_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY status_history_tenant_isolation ON member_status_history
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

-- Append-only — per task-audit skill rule 12
REVOKE UPDATE, DELETE ON member_status_history FROM cyberos_app;

COMMIT;

3.3 — Migration 0003 — views

-- services/hr/migrations/0003_member_view.sql

BEGIN;

-- Currently-employed predicate (per DEC-205 + §1 #20)
CREATE VIEW member_active_view AS
    SELECT * FROM members WHERE status IN ('probation','active','on_leave');

-- Sabbatical-eligible (per §1 #21)
CREATE VIEW sabbatical_eligible_view AS
    SELECT
        m.subject_id,
        m.tenant_id,
        sabbatical_accrued_days(m.start_date) AS accrued_days,
        sabbatical_accrued_days(m.start_date) - COALESCE(sl.used_days, 0) AS available_days
    FROM members m
    LEFT JOIN sabbatical_used_summary sl ON m.subject_id = sl.member_id  -- view from TASK-HR-004
    WHERE m.status = 'active' AND CURRENT_DATE >= m.sabbatical_eligible_at;

COMMIT;

3.4 — Status FSM

// services/hr/src/fsm/status.rs
use crate::types::MemberStatus;

/// Closed transition matrix. Validate via `is_valid_transition` before any state change.
pub fn is_valid_transition(from: MemberStatus, to: MemberStatus) -> bool {
    use MemberStatus::*;
    matches!((from, to),
        (Candidate, Probation) | (Candidate, Terminated)
        | (Probation, Active)  | (Probation, Terminated)
        | (Active, OnLeave)    | (OnLeave, Active)
        | (Active, Suspended)  | (Suspended, Active) | (Suspended, Terminated)
        | (Active, Terminated) | (OnLeave, Terminated)
    )
}

#[derive(Debug, thiserror::Error)]
#[error("invalid_status_transition: {from:?} -> {to:?}")]
pub struct InvalidStatusTransition { pub from: MemberStatus, pub to: MemberStatus }

pub fn validate_transition(from: MemberStatus, to: MemberStatus) -> Result<(), InvalidStatusTransition> {
    if is_valid_transition(from, to) { Ok(()) } else { Err(InvalidStatusTransition { from, to }) }
}

3.5 — Member struct + enums

// services/hr/src/types.rs
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type};
use uuid::Uuid;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Type, Serialize, Deserialize)]
#[sqlx(type_name = "member_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum MemberStatus { Candidate, Probation, Active, OnLeave, Suspended, Terminated }

#[derive(Debug, Clone, Copy, PartialEq, Eq, Type, Serialize, Deserialize)]
#[sqlx(type_name = "member_level", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum MemberLevel { Trainee, Associate, Senior, Lead, Principal, Director, Executive }

#[derive(Debug, Clone, Copy, PartialEq, Eq, Type, Serialize, Deserialize)]
#[sqlx(type_name = "contract_type", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ContractType { Indefinite, FixedTerm, Probation, PartTime, Contractor }

impl MemberStatus {
    pub const ALL: &'static [MemberStatus] = &[
        MemberStatus::Candidate, MemberStatus::Probation, MemberStatus::Active,
        MemberStatus::OnLeave, MemberStatus::Suspended, MemberStatus::Terminated,
    ];
}

impl MemberLevel {
    pub const ALL: &'static [MemberLevel] = &[
        MemberLevel::Trainee, MemberLevel::Associate, MemberLevel::Senior,
        MemberLevel::Lead, MemberLevel::Principal, MemberLevel::Director, MemberLevel::Executive,
    ];
}

#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Member {
    pub subject_id: Uuid,
    pub tenant_id: Uuid,
    pub full_name: String,
    pub preferred_name: Option<String>,
    pub email: String,
    #[serde(skip_serializing)]
    pub cccd_encrypted: Option<Vec<u8>>,           // omitted from default JSON; admin-gated readers fetch
    pub level: MemberLevel,
    pub status: MemberStatus,
    pub contract_type: ContractType,
    pub start_date: Option<NaiveDate>,
    pub end_date: Option<NaiveDate>,
    pub sabbatical_eligible_at: Option<NaiveDate>,
    pub leave_balance_days: rust_decimal::Decimal,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

3.6 — Comp-exclusion CI gate

// services/hr/src/comp_exclusion.rs
use regex::Regex;
use std::path::Path;

pub const FORBIDDEN_COLUMNS: &[&str] = &[
    "base_salary", "salary", "base_pay", "bonus", "p1_base", "p2_allowance", "p3_performance",
    "equity_units", "esop_grant", "total_comp", "gross_pay", "net_pay", "comp_band", "pay_band",
];

pub fn assert_no_comp_columns_in_migration(sql: &str) -> Result<(), String> {
    // Strip comments first
    let stripped = strip_sql_comments(sql);
    // Look at any CREATE TABLE / ALTER TABLE block targeting `members`
    let table_block_re = Regex::new(r"(?is)(create\s+table|alter\s+table)\s+(?:if\s+not\s+exists\s+)?members\b(.*?);").unwrap();
    for m in table_block_re.captures_iter(&stripped) {
        let block = m.get(0).unwrap().as_str().to_lowercase();
        for forbidden in FORBIDDEN_COLUMNS {
            // Word-boundary check to avoid matching `taxonomy` when forbidden is `tax`
            let pat = Regex::new(&format!(r"\b{}\b", regex::escape(forbidden))).unwrap();
            if pat.is_match(&block) {
                return Err(format!("forbidden_comp_column_in_members_migration: {forbidden}"));
            }
        }
    }
    Ok(())
}

fn strip_sql_comments(sql: &str) -> String {
    let line_comment_re = Regex::new(r"--[^\n]*").unwrap();
    let block_comment_re = Regex::new(r"(?s)/\*.*?\*/").unwrap();
    block_comment_re.replace_all(&line_comment_re.replace_all(sql, ""), "").into_owned()
}

3.7 — REST handlers (excerpt)

// services/hr/src/handlers/admin_members.rs
use axum::{Json, extract::{Path, State}, http::StatusCode};
use crate::types::*;
use crate::fsm::status::validate_transition;
use crate::audit::member_events;

#[derive(Deserialize)]
pub struct CreateMemberRequest {
    pub subject_id: Uuid,
    pub full_name: String,
    pub preferred_name: Option<String>,
    pub email: String,
    pub level: MemberLevel,
    pub contract_type: ContractType,
}

pub async fn create_member(
    State(state): State<AppState>,
    claims: Claims,
    Json(req): Json<CreateMemberRequest>,
) -> Result<(StatusCode, Json<Member>), ApiError> {
    state.matrix.snapshot().require_permission(&claims.roles(), Resource::HrMember, Action::Admin)?;
    let mut tx = state.db.begin().await?;

    let member = sqlx::query_as!(Member,
        r#"INSERT INTO members (subject_id, tenant_id, full_name, preferred_name, email, level, status, contract_type)
           VALUES ($1, $2, $3, $4, $5, $6::member_level, 'candidate'::member_status, $7::contract_type)
           RETURNING subject_id, tenant_id, full_name, preferred_name, email, cccd_encrypted, level AS "level: _", status AS "status: _", contract_type AS "contract_type: _", start_date, end_date, sabbatical_eligible_at, leave_balance_days, created_at, updated_at"#,
        req.subject_id, claims.tenant_id(), req.full_name, req.preferred_name, req.email, req.level as MemberLevel, req.contract_type as ContractType,
    ).fetch_one(&mut *tx).await?;

    // initial status history row
    sqlx::query("INSERT INTO member_status_history (member_id, tenant_id, from_status, to_status, changed_by_subject_id, reason, audit_chain_hash) VALUES ($1, $2, NULL, 'candidate', $3, 'initial_creation', $4)")
        .bind(req.subject_id).bind(claims.tenant_id()).bind(claims.subject_id()).bind(audit_chain_hash_for(&member))
        .execute(&mut *tx).await?;

    member_events::emit_member_created(&mut tx, &member, claims.subject_id()).await?;
    tx.commit().await?;
    Ok((StatusCode::CREATED, Json(member)))
}

#[derive(Deserialize)]
pub struct TransitionRequest { pub to_status: MemberStatus, pub reason: String }

pub async fn transition_status(
    State(state): State<AppState>,
    claims: Claims,
    Path(subject_id): Path<Uuid>,
    Json(req): Json<TransitionRequest>,
) -> Result<Json<Member>, ApiError> {
    state.matrix.snapshot().require_permission(&claims.roles(), Resource::HrMember, Action::Admin)?;
    let mut tx = state.db.begin().await?;
    let current: Member = sqlx::query_as!(Member, /* ... */).fetch_one(&mut *tx).await?;
    validate_transition(current.status, req.to_status)?;

    let updated: Member = sqlx::query_as!(Member,
        "UPDATE members SET status = $2::member_status, end_date = CASE WHEN $2::member_status = 'terminated' THEN CURRENT_DATE ELSE end_date END, start_date = CASE WHEN $2::member_status = 'active' AND start_date IS NULL THEN CURRENT_DATE ELSE start_date END WHERE subject_id = $1 RETURNING *",
        subject_id, req.to_status as MemberStatus,
    ).fetch_one(&mut *tx).await?;

    sqlx::query("INSERT INTO member_status_history (member_id, tenant_id, from_status, to_status, changed_by_subject_id, reason, audit_chain_hash) VALUES ($1, $2, $3::member_status, $4::member_status, $5, $6, $7)")
        .bind(subject_id).bind(claims.tenant_id()).bind(current.status as MemberStatus).bind(req.to_status as MemberStatus).bind(claims.subject_id()).bind(req.reason).bind(audit_chain_hash_for(&updated))
        .execute(&mut *tx).await?;

    member_events::emit_member_status_changed(&mut tx, &current, &updated, claims.subject_id(), &req.reason).await?;
    tx.commit().await?;
    Ok(Json(updated))
}

§4 — Acceptance criteria

  1. Status enum closed at 6 valuesMemberStatus::ALL.len() == 6; Postgres enum member_status has exactly 6 labels.
  2. Level enum closed at 7 values — same shape.
  3. RLS isolates by tenant — query as tenant-A returns 0 members of tenant-B.
  4. POST member happy path — tenant-admin caller, valid body → 201 with Member JSON; subject_id_hash16 in memory hr.member_created row.
  5. POST member with comp field in body — handler rejects with 400 comp_field_not_allowed (covered also by schema; handler-side guard is belt-and-braces).
  6. PATCH leave_balance_days rejected — handler omits the field from allowed-fields list; direct SQL UPDATE rejected by leave_balance_is_materialised trigger.
  7. PATCH start_date after active rejected — member at status=active → PATCH {start_date: ...} → trigger raises cannot_modify_locked_start_date.
  8. Status FSM rejects invalid transitionterminated → active → handler returns 400 invalid_status_transition.
  9. Status FSM accepts valid transitioncandidate → probation → 200; new row in member_status_history; one memory hr.member_status_changed row.
  10. Status history append-onlyDELETE FROM member_status_history WHERE id = 1 as cyberos_app user → permission denied.
  11. Sabbatical accrual at year < 5sabbatical_accrued_days(start_date - INTERVAL '4 years') returns 0.
  12. Sabbatical accrual at year 5 — returns 1.
  13. Sabbatical accrual at year 30 — returns 30 (capped).
  14. Sabbatical view filters correctly — Member at status=active with start_date 6 years ago appears in sabbatical_eligible_view; Member at status=probation does not.
  15. CCCD field access emits sev-1 audit — GET /v1/admin/members/{id} that returns cccd_encrypted → one hr.member_cccd_accessed memory row.
  16. CCCD field default-omitted from JSON — GET without Action::Admin returns Member without cccd_encrypted (column dropped from response).
  17. comp_exclusion_test (CI gate) — DDL with base_salary column → test fails.
  18. comp_exclusion_test passes on shipping migration — current 0001 has no forbidden columns → green.
  19. Idempotent create — same Idempotency-Key + same body → same Member (no duplicate row).
  20. Different body, same Idempotency-Key — 409 idempotency_key_reuse.
  21. OTel span emitted — span hr.member.create carries outcome=success attribute.
  22. OTel counter hr_member_create_total{outcome=success} increments — every create bumps it.
  23. OTel counter hr_member_status_transitions_total{from_status=probation,to_status=active} increments — every transition bumps it.
  24. member_active_view filters status — query against view never returns 'candidate' | 'suspended' | 'terminated' members.
  25. Perf budget < 100 ms p95members_perf_test 1000 iterations.
  26. Subject FK ON DELETE RESTRICTDELETE FROM auth.subjects WHERE id = <member.subject_id> raises FK violation.
  27. Anniversary computationsabbatical_eligible_at for start_date = 2020-01-01 is 2025-01-01.

§5 — Verification

// services/hr/tests/comp_exclusion_test.rs
use cyberos_hr::comp_exclusion::assert_no_comp_columns_in_migration;
use std::fs;

#[test]
fn shipping_migration_has_no_comp_columns() {
    let sql = fs::read_to_string("migrations/0001_members.sql").unwrap();
    assert_no_comp_columns_in_migration(&sql).unwrap();
}

#[test]
fn injected_comp_column_rejected() {
    let bad = "CREATE TABLE members (subject_id UUID, base_salary BIGINT);";
    let err = assert_no_comp_columns_in_migration(bad).unwrap_err();
    assert!(err.contains("base_salary"));
}

#[test]
fn comment_does_not_trigger_false_positive() {
    let ok = "CREATE TABLE members (subject_id UUID); -- TODO: never add base_salary here";
    assert_no_comp_columns_in_migration(ok).unwrap();
}
// services/hr/tests/status_fsm_test.rs
use cyberos_hr::fsm::status::{is_valid_transition, validate_transition};
use cyberos_hr::types::MemberStatus::*;

#[test]
fn valid_transitions_accepted() {
    let cases = [
        (Candidate, Probation), (Candidate, Terminated),
        (Probation, Active),    (Probation, Terminated),
        (Active, OnLeave),      (OnLeave, Active),
        (Active, Suspended),    (Suspended, Active), (Suspended, Terminated),
        (Active, Terminated),   (OnLeave, Terminated),
    ];
    for (from, to) in cases {
        assert!(is_valid_transition(from, to), "expected {from:?} → {to:?} to be valid");
    }
}

#[test]
fn invalid_transitions_rejected() {
    let cases = [
        (Terminated, Active),       // graveyard escape
        (Active, Candidate),        // regression
        (Candidate, Active),        // skip probation
        (Active, Probation),        // backslide
    ];
    for (from, to) in cases {
        assert!(!is_valid_transition(from, to), "expected {from:?} → {to:?} to be invalid");
        assert!(validate_transition(from, to).is_err());
    }
}
// services/hr/tests/sabbatical_test.rs
#[sqlx::test]
async fn sabbatical_accrual_curve(pool: sqlx::PgPool) {
    let cases = [(0, 0), (1, 0), (4, 0), (5, 1), (6, 2), (10, 6), (35, 30), (40, 30)];
    for (years, expected) in cases {
        let start = chrono::Utc::now().date_naive() - chrono::Duration::days(years * 365 + 1);
        let got: i32 = sqlx::query_scalar("SELECT sabbatical_accrued_days($1)")
            .bind(start).fetch_one(&pool).await.unwrap();
        assert_eq!(got, expected, "years_of_service = {years}");
    }
}
// services/hr/tests/cccd_audit_test.rs
#[sqlx::test]
async fn cccd_read_emits_sev1_audit(ctx: TestCtx) {
    let member = ctx.create_member_with_cccd().await;
    let _resp = ctx.get_as_admin(&format!("/v1/admin/members/{}?fields=cccd_encrypted", member.subject_id)).await;
    let rows = ctx.memory_audit_rows("hr.member_cccd_accessed").await;
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0]["member_id"], member.subject_id.to_string());
    assert_eq!(rows[0]["severity"], "sev-1");
}
// services/hr/tests/leave_balance_readonly_test.rs
#[sqlx::test]
async fn direct_update_to_leave_balance_blocked(pool: sqlx::PgPool) {
    let id = setup_member(&pool).await;
    let err = sqlx::query("UPDATE members SET leave_balance_days = 999 WHERE subject_id = $1")
        .bind(id).execute(&pool).await.unwrap_err();
    assert!(format!("{err}").contains("leave_balance_is_materialised"));
}

§6 — Implementation skeleton

(API contract above is the skeleton. The 4 remaining memory row builders in audit/member_events.rs follow the canonical pattern: tenant-aware, PII-scrubbed via TASK-MEMORY-111, chained per AGENTS.md §6.)


§7 — Dependencies

Upstream:

Downstream (all 10 are placeholders):

Cross-module:


§8 — Example payloads

8.1 — POST /v1/admin/members request

{
  "subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "full_name": "Trinh Thai Anh",
  "preferred_name": "Stephen",
  "email": "stephen@cyberskill.world",
  "level": "executive",
  "contract_type": "indefinite"
}

8.2 — 201 CREATED response

{
  "subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "tenant_id": "5e8f1d2a-...",
  "full_name": "Trinh Thai Anh",
  "preferred_name": "Stephen",
  "email": "stephen@cyberskill.world",
  "level": "executive",
  "status": "candidate",
  "contract_type": "indefinite",
  "start_date": null,
  "end_date": null,
  "sabbatical_eligible_at": null,
  "leave_balance_days": "0.0",
  "created_at": "2026-05-16T10:00:00Z",
  "updated_at": "2026-05-16T10:00:00Z"
}

8.3 — hr.member_created memory row

{
  "kind": "hr.member_created",
  "tenant_id": "5e8f1d2a-...",
  "subject_id_hash16": "9b1deb4d3b7d4bad",
  "level": "executive",
  "contract_type": "indefinite",
  "status": "candidate",
  "created_by_subject_id_hash16": "8a7c8c8012344567",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "ts_ns": 1747920731000000000
}

8.4 — POST transition request

{ "to_status": "active", "reason": "probation passed; HR confirmation 2026-08-16" }

8.5 — hr.member_status_changed memory row

{
  "kind": "hr.member_status_changed",
  "tenant_id": "5e8f1d2a-...",
  "subject_id_hash16": "9b1deb4d3b7d4bad",
  "from_status": "probation",
  "to_status": "active",
  "reason": "probation passed; HR confirmation 2026-08-16",
  "changed_by_subject_id_hash16": "8a7c8c8012344567",
  "ts_ns": 1747920731000000000
}

8.6 — hr.member_cccd_accessed memory row (sev-1)

{
  "kind": "hr.member_cccd_accessed",
  "severity": "sev-1",
  "tenant_id": "5e8f1d2a-...",
  "subject_id_hash16": "9b1deb4d3b7d4bad",
  "accessed_by_subject_id_hash16": "8a7c8c8012344567",
  "purpose": "kyc_verification",
  "ts_ns": 1747920731000000000
}

§9 — Open questions

Deferred:

All other questions resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Migration adds base_salary columncomp_exclusion_test CI gateBuild failsEither revert or write task-REW-* amendment + ADR
Migration adds salary (synonym)comp_exclusion_test lexical scanBuild failsSame
Direct UPDATE on leave_balance_days from non-TASK-HR-004 pathtrigger raises leave_balance_is_materialised500 errorUse TASK-HR-004's leave-entry path
Direct UPDATE on start_date after activetrigger raises cannot_modify_locked_start_date500 errorADR + manual SQL via bypass_immutable_start_date GUC
Invalid FSM transition attempted (terminated → active)FSM validator400 invalid_status_transitionUse a fresh subject if rehiring
Cross-tenant member readRLS USING denies0 rows returned (handler may map to 404)None — designed
Subject deleted while member existsFK RESTRICTDELETE failsTerminate the member first; never delete the subject row
Postgres enum drift (rename member_status.activeenabled)Type drift causes sqlx::Type deserialisation panicService refuses to startRoll back; ADR for the rename
member_active_view filter drift (e.g. someone adds 'candidate' to view definition)member_active_view_predicate_test asserts exact setCI failsRestore canonical predicate
Sabbatical accrual computed wrong (drift)sabbatical_test calibration curveCI failsRestore formula
CCCD field read without auditTest cccd_read_emits_sev1_auditCI failsEnsure audit emission wraps every cccd_encrypted read path
memory row commit fails mid-transactionOuter tx rolls back; nothing persisted500 audit_failedMemory_writer diagnosis
Member row contains full_name with PII not scrubbedPII test in audit row builderPre-commit failureFix PII rule
Duplicate (tenant_id, email)UNIQUE constraint409 email_takenUse different email
FSM lookup table drift (e.g. is_valid_transition adds a case)status_fsm_test::valid_transitions_accepted asserts exact setCI failsEither restore or ADR + test update
Auth subject deleted but member.subject_id orphanedFK ON DELETE RESTRICT preventsCannot orphanNone — designed
Status FSM validate_transition returns OK for invalid comboProperty testCI failsFix matrix
Email mirror drift (AUTH email changes, HR not updated)Email-equality test (slice 2+)Sev-3Operator updates both; future trigger
level enum drift (someone adds L8 in code without migration)level_enum_closed_test reads SQL enum + Rust enum and comparesCI failsADR + migration + code change together
cccd_encrypted BYTEA but contains plaintextTASK-HR-003 enforcement (this task just declares the column)Out-of-scope for TASK-HR-001TASK-HR-003
Sabbatical view shows ineligible membersabbatical_eligible_view_filter_testCI failsFix view predicate
Cross-tenant member create attemptRLS WITH CHECK denies403 permission_deniedSwitch tenant context
subject_id_hash16 collision16-hex prefix = 64 bits; collision-safe ~10⁹Acceptable per designNone
Concurrent member_status_history insert + member updatePostgres serialisable transactionEither both succeed or both retryNone — designed
member_status_history.audit_chain_hash invalid (replay broken)Memory writer chain validatorSev-3Manual recompute + repair
Generated column sabbatical_eligible_at recomputed on every SELECTPostgres treats as STOREDSingle computation on writeNone
Idempotency-Key reused with different bodyIdempotency layer returns 409None — designedUse a new key
Performance regression > 100 ms p95members_perf_testCI failsProfile + optimise
Comp-exclusion CHECK constraint bypassed via direct SQLInformation schema scan catchesDB-level guard firesNone — designed
CCCD photo URL accidentally stored as TEXTTASK-HR-003's keyspace enforcementOut-of-scope hereTASK-HR-003
Migration order swap (0002 before 0001)sqlx migration framework asserts orderMigration failsRestore order
Sabbatical max cap drift (someone removes the 30-day cap)sabbatical_test::cap_at_30CI failsRestore
Status enum value renamed without migrationService refuses to startRoll backCoordinate code + migration

§11 — Implementation notes


End of TASK-HR-001.