Task — engineering-spec@1

"Billable cascade — Member-override → task-class → role-default → fallback; resolution snapshot at time-entry write"

doneTASK-PROJ-006
module proj · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-PROJ-005 · blocks TASK-PROJ-007, TASK-TIME-005

§1 — Description (BCP-14 normative)

The billable-cascade resolver MUST compute billable: bool for each time-entry write by consulting 4 sources in strict precedence order:

  1. Tier 1 — Member-override: explicit per-member rule on this engagement (e.g. "Alice is non-billable on this engagement"). Schema: member_billable_overrides(member_id, engagement_id, billable, reason, created_at, tenant_id).
  2. Tier 2 — Task-class override: explicit per-engagement per-task-class rule (e.g. "research is non-billable on engagement X"). Schema: task_class_billable(engagement_id, task_class, billable, tenant_id).
  3. Tier 3 — Role-default: the matching rate card's billable_default (TASK-PROJ-005).
  4. Tier 4 — Fallback: false. Never silently bill.

The resolver:

  1. MUST return BillableResolution { value: bool, source: Tier, tier_consulted: u8, snapshot_at: i64 } — every resolution carries provenance.
  2. MUST stop at the FIRST applicable tier (no further consultation once a rule matches).
  3. MUST snapshot the resolved value AT WRITE TIME of the time-entry. Later changes to any tier do NOT retroactively modify existing entries.
  4. MUST emit proj.billable_resolved memory audit row per call with payload {time_entry_id, member_id, engagement_id, task_class, role, billable_value, source_tier, snapshot_at_ns, trace_id}.
  5. MUST validate member_id belongs to the engagement (cross-engagement override invalid).
  6. MUST be deterministic per (member_id, task_class, role, engagement_id, at_date) snapshot at AT-DATE; cascade reads rate card via TASK-PROJ-005::lookup_at(at_date) so the snapshot is reproducible.
  7. MUST emit OTel metrics:
  1. MUST expose REST: POST /api/proj/engagements/:eng/billable-cascade/resolve with body {member_id, task_class, role, at_date} → 200 BillableResolution. Used by clients (Kanban, timeline) for preview before writing.
  2. MUST expose admin CRUD for Tier-1 + Tier-2 overrides:
  1. MUST emit memory audit on override CRUD: proj.member_billable_override_set / proj.task_class_billable_set.
  2. MUST RLS-enforce per tenant (TASK-AUTH-003).
  3. MUST validate the resolver inputs at handler boundary: member_id exists in tenant; engagement_id exists in tenant; at_date is not more than 5 years in the past; currency is in the rate-card's enum. Invalid inputs → 400 with structured error.
  4. MUST support a "bulk-resolve" endpoint: POST /api/proj/engagements/:eng/billable-cascade/bulk with [{member_id, task_class, role, at_date}] array (max 1000 items) → returns array of resolutions in input order. Used by timesheet-import flows.
  5. MUST mark resolved billable values as IMMUTABLE on the time entry row: any PATCH attempting to change billable_snapshot is rejected with 405 (force re-resolution via separate time-entry-recompute admin endpoint, which itself is audit-trail-heavy).
  6. MUST support per-engagement DEFAULT TIER-2 task-class billable: cyberos_proj_engagement_settings.default_task_class_billable = {feature_work: true, research: false, ...} applied to engagement creation if no per-class override exists. Tenant-admin sets the default.
  7. MUST track proj_billable_resolution_latency_ms histogram per call; p95 budget < 20ms (cascade is in the time-entry hot path).
  8. MUST support querying historical resolutions: GET /api/proj/engagements/:eng/billable-cascade/history?member=:id&from=&to= returns memory audit rows. Useful for auditing "why was this hour billed."
  9. MUST include effective_overrides_applied field in BillableResolution: even if Tier 3 matched, the response carries metadata about which other tiers existed but didn't match (e.g. "rate card was used; member-override exists but matches the same value"). Operator transparency.
  10. MUST support a tenant-level "billable by default" policy override of the §1 Tier-4 fallback: cyberos_proj_tenant_settings.cascade_fallback_billable = false (default false). Tenants who explicitly want default-billable can set true (e.g. internal-billing tenants). The default remains conservative.
  11. MUST validate engagement_id is not archived: resolutions on archived engagements are forbidden (would create new time entries on closed engagements). 409 engagement_archived.
  12. MUST support an "explain" mode on the resolve endpoint: ?explain=true returns the full decision tree showing what each tier returned (matched/not-matched), not just the winner. Used by operator UI for debugging.

§2 — Why this design (rationale for humans)

Why 4 tiers (DEC-270, §1)? Real-world billable rules need granularity: org-level role defaults are too coarse (engineer is "usually" billable but specific engineers on specific eng aren't); per-member always-overridable is too granular (no defaults; every member needs explicit). Four tiers cover the cases without combinatorial explosion.

Why first-match wins (§1 #2)? Layered overrides need clear precedence. The pattern matches CSS specificity / IAM policy precedence — specific overrides general.

Why snapshot at write-time (DEC-271, §1 #3)? Retroactive rule changes break invoices already sent. A client paid for 100 hours billed at one rate; later flipping a member to non-billable would retroactively zero out a paid invoice. Snapshot = bill = invariant.

Why fallback false not true (DEC-272, §1)? Asymmetric risk: defaulting to billable means a tracking mistake bills a client (loss of trust + refund + admin overhead). Defaulting to non-billable means a missed bill (caught at invoice review). The latter is recoverable.

Why audit per resolution (§1 #4)? Auditors investigating "why was this hour billed" need the full chain: which tier matched, which rule, when. The audit row encodes the decision tree at the time of decision.

Why preview endpoint (§1 #8)? UX: when an engineer logs time on a task, the UI shows "Billable: ✓ (via task_class override)" so they know what they're committing to. Without preview, the UX shows nothing → surprise at invoice time.

Why input validation at handler (§1 #12)? Defence in depth: resolver assumes valid inputs (per the contract); handler validates so downstream resolver code is small + fast. Bad inputs returning 400 with structured errors is far better than silent fallthrough.

Why bulk-resolve (§1 #13)? Timesheet imports (CSV from external tools) have hundreds of entries; per-entry HTTP call = thousands of network round-trips. Bulk amortises the cost.

Why immutability on time entry (§1 #14)? Snapshot is the bill-truth; mutating it post-write retroactively re-bills. Force-re-resolution path is admin-only and audit-heavy because it's an exceptional operator workflow.

Why engagement-level default task-class billable (§1 #15)? Common case: every engagement has the same "feature_work=billable, research=non-billable" pattern. Without engagement defaults, operators set 8 task_class rows per engagement. Defaults = one set per tenant.

Why p95 < 20ms (§1 #16)? Time-entry write is in the operator's UI critical path; >20ms feels laggy. Cascade is 4 SQL queries worst-case; budget is generous.

Why historical resolution query (§1 #17)? Auditors investigating an invoice line item ask "why was this billable?" — answer requires the resolution that was made at write time. memory audit query gives the trace.

Why effective_overrides_applied metadata (§1 #18)? Operator transparency: "Tier 3 matched (rate card default = true); a member override exists but also returns true, so no change." Without this, operators can't tell if the result is from the priority tier or a deeper one happening to agree.

Why per-tenant fallback override (§1 #19)? Internal-billing tenants (cost-center accounting) want default-billable; SaaS-revenue tenants want default-not-billable. One global default doesn't fit; per-tenant policy does. Default remains conservative.

Why archived-engagement rejection (§1 #20)? Closed engagements shouldn't accept new time entries (would re-open billing). Rejecting at the cascade layer catches operators who don't see the archived status in their UI.

Why explain mode (§1 #21)? Debugging cascade decisions in production: operator wants to know "why did this resolve to false?" — explain mode shows each tier's match/no-match status. Default mode is summary; explain is for diagnostics.


§3 — API contract

Migrations

-- 0006_member_billable_overrides.sql
CREATE TABLE member_billable_overrides (
    member_id      UUID NOT NULL,
    engagement_id  UUID NOT NULL REFERENCES engagements(id),
    billable       BOOLEAN NOT NULL,
    reason         TEXT,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by     UUID NOT NULL,
    tenant_id      UUID NOT NULL,
    PRIMARY KEY (member_id, engagement_id)
);
CREATE POLICY mbo_tenant_isolation ON member_billable_overrides
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- 0006_task_class_billable.sql
CREATE TABLE task_class_billable (
    engagement_id  UUID NOT NULL REFERENCES engagements(id),
    task_class     TEXT NOT NULL CHECK (task_class IN
                   ('feature_work','bug_fix','meeting','review','research','sales_call','admin','training')),
    billable       BOOLEAN NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by     UUID NOT NULL,
    tenant_id      UUID NOT NULL,
    PRIMARY KEY (engagement_id, task_class)
);
CREATE POLICY tcb_tenant_isolation ON task_class_billable
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

Cascade resolver

// services/proj-sync/src/billable/mod.rs
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, Debug, Serialize, Deserialize, sqlx::Type, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "TEXT", rename_all = "snake_case")]
pub enum TaskClass {
    FeatureWork, BugFix, Meeting, Review, Research, SalesCall, Admin, Training,
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Tier { MemberOverride, TaskClassOverride, RoleDefault, Fallback }

#[derive(Clone, Debug, Serialize)]
pub struct BillableResolution {
    pub value:           bool,
    pub source:          Tier,
    pub tier_consulted:  u8,             // 1..=4 (matches §1 #2 first-match-wins; this is the matching tier)
    pub snapshot_at_ns:  i64,
}

pub async fn resolve(
    pool: &sqlx::PgPool,
    member_id: uuid::Uuid,
    task_class: TaskClass,
    role: crate::rate_card::Role,
    engagement_id: uuid::Uuid,
    at_date: chrono::NaiveDate,
    currency: crate::rate_card::Currency,
) -> Result<BillableResolution, BillableError> {
    let now_ns = chrono::Utc::now().timestamp_nanos_opt().unwrap();

    // Tier 1: member-override
    if let Some(row) = sqlx::query!(
        "SELECT billable FROM member_billable_overrides
         WHERE member_id = $1 AND engagement_id = $2",
        member_id, engagement_id
    ).fetch_optional(pool).await.map_err(map_db)? {
        return Ok(BillableResolution {
            value: row.billable, source: Tier::MemberOverride,
            tier_consulted: 1, snapshot_at_ns: now_ns,
        });
    }

    // Tier 2: task-class override
    if let Some(row) = sqlx::query!(
        "SELECT billable FROM task_class_billable
         WHERE engagement_id = $1 AND task_class = $2",
        engagement_id, task_class as TaskClass
    ).fetch_optional(pool).await.map_err(map_db)? {
        return Ok(BillableResolution {
            value: row.billable, source: Tier::TaskClassOverride,
            tier_consulted: 2, snapshot_at_ns: now_ns,
        });
    }

    // Tier 3: role-default (rate card)
    if let Ok(card) = crate::rate_card::lookup_at(pool, engagement_id, role, currency, at_date).await {
        return Ok(BillableResolution {
            value: card.billable_default, source: Tier::RoleDefault,
            tier_consulted: 3, snapshot_at_ns: now_ns,
        });
    }

    // Tier 4: fallback
    Ok(BillableResolution {
        value: false, source: Tier::Fallback,
        tier_consulted: 4, snapshot_at_ns: now_ns,
    })
}

§4 — Acceptance criteria

  1. Tier 1 hits first — member-override = true, all else default → resolution.source = MemberOverride.
  2. Tier 1 false stops cascade — member-override = false → resolution.value = false, source = MemberOverride (does not fall through).
  3. Tier 2 hits when no Tier 1 — no override + task_class_billable = true → source = TaskClassOverride.
  4. Tier 3 hits when no T1/T2 — rate card default = true → source = RoleDefault.
  5. Tier 4 fallback — none of T1/T2/T3 match (e.g. no rate card for that role/date) → source = Fallback, value = false.
  6. Snapshot preserved on later override change — write entry at T1 with member-override = true; at T2 flip override to false; original entry's billable stays true (snapshotted).
  7. Cross-engagement override invalid — override row for (member, engagement_A) does NOT affect engagement_B.
  8. Preview endpointPOST /resolve returns same value as actual write would.
  9. Audit row emitted — every resolve → proj.billable_resolved row with source_tier.
  10. Audit on override CRUDproj.member_billable_override_set on POST.
  11. Counter increments — 100 resolves → counter proj_billable_resolutions_total sums to 100 across labels.
  12. RLS enforces — tenant A's override invisible to tenant B.
  13. Cascade depth metric — Tier 4 fallback → depth = 4; Tier 1 hit → depth = 1.
  14. Idempotent override POST — same Idempotency-Key → returns prior; same key + diff body → 409.
  15. Handler input validation — POST with at_date=2010-01-01 (>5y past) → 400 at_date_too_old; invalid member/engagement → 400 (AC for §1 #12).
  16. Bulk resolve handles 1000 items — POST /bulk with 1000-element array → 200 with 1000-element response in input order; > 1000 → 413 (AC for §1 #13).
  17. Bulk preserves per-item independence — bulk with mix of valid + invalid → each item's status independent (AC for §1 #13).
  18. billable_snapshot PATCH rejected — direct PATCH of time entry's billable_snapshot → 405 (AC for §1 #14).
  19. Engagement-default task-class billable applied — tenant default feature_work: true; new engagement → has tier-2 row matching default; resolve returns true via Tier 2 (AC for §1 #15).
  20. Latency p95 < 20ms — load test 1000 resolves → histogram p95 < 20ms (AC for §1 #16).
  21. Historical query returns memory rows — GET /history → audit rows for member+time range (AC for §1 #17).
  22. effective_overrides_applied in response — resolve where Tier 1 + Tier 3 both exist and agree → metadata shows both (AC for §1 #18).
  23. Tenant fallback override honoured — set cascade_fallback_billable=true; Tier 4 fallback → value=true (AC for §1 #19).
  24. Archived engagement rejected — resolve on archived eng → 409 engagement_archived (AC for §1 #20).
  25. Explain mode returns full tree — POST /resolve?explain=true → response includes per-tier match/value; default mode returns summary only (AC for §1 #21).

§5 — Verification

#[tokio::test]
async fn tier_1_member_override_first_match() {
    let env = TestEnv::new().await;
    let (eng, alice) = env.bootstrap().await;
    env.set_member_override(alice, eng, true).await;
    env.set_task_class(eng, TaskClass::Research, false).await;
    let res = resolve(&env.pool, alice, TaskClass::Research, Role::Engineer, eng,
                      "2026-05-16".parse().unwrap(), Currency::VND).await.unwrap();
    assert_eq!(res.value, true);
    assert_eq!(res.source, Tier::MemberOverride);
    assert_eq!(res.tier_consulted, 1);
}

#[tokio::test]
async fn tier_2_task_class_when_no_member() {
    let env = TestEnv::new().await;
    let (eng, alice) = env.bootstrap().await;
    env.set_task_class(eng, TaskClass::Meeting, false).await;
    env.create_rate_card_default(eng, Role::Engineer, true).await;
    let res = resolve(&env.pool, alice, TaskClass::Meeting, Role::Engineer, eng,
                      "2026-05-16".parse().unwrap(), Currency::VND).await.unwrap();
    assert_eq!(res.source, Tier::TaskClassOverride);
    assert_eq!(res.value, false);
}

#[tokio::test]
async fn tier_4_fallback_is_false() {
    let env = TestEnv::new().await;
    let (eng, alice) = env.bootstrap().await;
    // No overrides; no rate card
    let res = resolve(&env.pool, alice, TaskClass::Research, Role::Engineer, eng,
                      "2026-05-16".parse().unwrap(), Currency::VND).await.unwrap();
    assert_eq!(res.source, Tier::Fallback);
    assert_eq!(res.value, false);
    assert_eq!(res.tier_consulted, 4);
}

#[tokio::test]
async fn snapshot_immutable_after_override_change() {
    let env = TestEnv::new().await;
    let (eng, alice) = env.bootstrap().await;
    env.set_member_override(alice, eng, true).await;
    let entry = env.create_time_entry(alice, eng, TaskClass::FeatureWork).await;
    assert_eq!(entry.billable_snapshot, true);

    env.set_member_override(alice, eng, false).await;
    let refetched = env.read_time_entry(entry.id).await;
    assert_eq!(refetched.billable_snapshot, true);   // snapshot preserved
}

§6 — Implementation skeleton

(API + DB schema above.)


§7 — Dependencies


§8 — Example payloads

{
  "kind": "proj.billable_resolved",
  "payload": {
    "time_entry_id":    "te-...",
    "member_id":        "mb-...",
    "engagement_id":    "eng-...",
    "task_class":       "feature_work",
    "role":             "engineer",
    "billable_value":   true,
    "source_tier":      "role_default",
    "tier_consulted":   3,
    "snapshot_at_ns":   1747407137483000000,
    "trace_id":         "0af..."
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
No rate card for role+dateTier 3 lookup_at ErrFalls through to Tier 4 (false)Operator adds card OR accepts non-billable
Duplicate override on PKunique constraint409 on POSTCaller checks first
Cross-engagement overriderow scoped to (member, engagement)Other engagements unaffectedNone
Race: resolve mid-override-updateeach resolve uses current snapshotSnapshot at resolve-time is consistentNone
Tier 1 override deletednext resolve falls to Tier 2New entries get new valueBy design (snapshot protects historical)
Audit emit failsResolved value still returned; audit lostsev-2Operator restores memory
Currency mismatch in rate-card lookuplookup returns None → fallback to Tier 4FalseOperator adds rate card
Tenant isolation broken (RLS bug)property test catchesCI blockedAuthor fixes
Idempotency-Key reuse with different bodyhandler check409Caller uses fresh key
Member not in engagementshould reject upstreamresolver doesn't validate; Tier 1 returns no row → falls throughUpstream caller validates
Storage Err mid-cascadesqlx Err500Operator restores DB
Two tier-2 rules same engagement+classPK prevents409None
Resolution at date in pastlookup_at uses historical cardCorrectNone
TaskClass enum driftCHECK constraint422Caller uses valid enum
Bulk-resolve > 1000 itemsbounded413Caller batches
Bulk-resolve item validation failureper-item statuspartial resultsCaller
Engagement archived mid-resolvecheck at top of resolver409Operator
Tenant fallback override = trueTier 4 returns trueby configNone
Explain mode response > 100KBbounded by tier count (4)NoneNone
Currency mismatch in rate-card lookup_atTier 3 returns None → Tier 4falls throughOperator adds card
at_date in past after engagement archiveresolver returns archived error409None
Bulk-resolve includes deleted memberper-item 400proceeds with othersNone
Same task_class enum value different storage (case drift)strict enum matching422Caller normalises
Resolution latency > 20msOBS histogramSEV-3 if sustainedOperator investigates
Resolver called > tenant rate-limitupstream limiter429Caller backs off
Tier 1 override row with billable=nullNOT NULL constraintrejected at insertNone
Audit row dedup (same time_entry_id twice)none enforcedduplicates possible; downstream dedupsNone
Concurrent override edit + resolveresolver reads snapshot at timeboth consistentNone

§11 — Implementation notes


End of TASK-PROJ-006.