Task — engineering-spec@1

"TIME billable flag cascade — 4-step resolver (entry override → project default → engagement policy → tenant default) with snapshot on row"

draftTASK-TIME-005
module time · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-TIME-001, TASK-PROJ-006 · blocks TASK-TIME-009

§1 — Description (BCP-14 normative)

The TIME service MUST ship 4-step billable-flag cascade at services/time/src/billable/ with closed source enum, snapshot at entry-write, override-at-each-level controls, and 4 memory audit kinds.

  1. MUST define closed billable_source enum: ('entry_override','project_default','engagement_policy','tenant_default') per DEC-1411. Cardinality 4.
  1. MUST add columns to TIME entries: is_billable BOOLEAN NOT NULL, billable_source billable_source NOT NULL. Snapshotted at entry creation per DEC-1410.
  1. MUST add billable_default BOOLEAN columns to: projects (NULL = inherit), engagements (NULL = inherit), tenants (NOT NULL — required).
  1. MUST resolve billable flag per cascade.rs::resolve(entry_override, project_id, engagement_id, tenant_id) per DEC-1412:
  1. MUST snapshot at write per DEC-1414. Post-write is_billable is IMMUTABLE — never re-cascaded.
  1. MUST support per-level override per DEC-1413:
  1. MUST emit 4 memory audit kinds per DEC-1415:
  1. MUST thread trace_id end-to-end.
  1. MUST NOT re-resolve post-write (per DEC-1414).
  1. MUST NOT allow tenant_default NULL (per DEC-1410 — must have base).

§2 — Why this design (rationale)

Why 4-step cascade vs simpler N-step (§1 #4, DEC-1412)? Industry convention from consulting tools — billability rules naturally hierarchical (tenant baseline → engagement contract → project type → entry exception).

Why snapshot at write (§1 #5, DEC-1414)? Without snapshot, retroactive policy changes silently rewrite history. With snapshot, invoice integrity preserved across policy evolution.

Why per-level override (§1 #6, DEC-1413)? Different roles legitimately need different override scopes. Tenant admin sets defaults; engagement_admin tunes per-engagement; cfo overrides specific engagements; Member flags exceptions.


§3 — API contract

-- 0004_billable_defaults.sql
CREATE TYPE billable_source AS ENUM ('entry_override','project_default','engagement_policy','tenant_default');

ALTER TABLE time_entries
  ADD COLUMN is_billable BOOLEAN NOT NULL,
  ADD COLUMN billable_source billable_source NOT NULL,
  ADD COLUMN billable_override BOOLEAN;

ALTER TABLE projects ADD COLUMN billable_default BOOLEAN;
ALTER TABLE engagements ADD COLUMN billable_default BOOLEAN;
ALTER TABLE tenants ADD COLUMN billable_default BOOLEAN NOT NULL DEFAULT true;

Endpoints:

PATCH  /v1/projects/{id}        { billable_default }     (engagement_admin)
PATCH  /v1/engagements/{id}     { billable_default }     (cfo)
PATCH  /v1/admin/tenants/{id}   { billable_default }     (cfo)

§4 — Acceptance criteria

  1. billable_source cardinality 4.
  2. Tenant default applied — entry with no project/engagement override → source=tenant_default.
  3. Engagement override — engagement.billable_default=false → entry billable=false; source=engagement_policy.
  4. Project override — project.billable_default=false (engagement=true) → entry billable=false; source=project_default.
  5. Entry override — Member sets billable_override=true (project=false) → entry billable=true; source=entry_override.
  6. Snapshot immutable — entry created billable=true; subsequent engagement policy change to false → entry remains billable=true.
  7. 4 memory audit kinds emitted.
  8. Tenant default required — INSERT tenants without billable_default fails NOT NULL.
  9. Engagement_admin updates project — PATCH succeeds + audit.
  10. CFO updates engagement — PATCH succeeds + audit.
  11. Non-admin cannot update project default — 403.
  12. Member entry override at form — UI checkbox flips per-entry.
  13. Cascade order verified — fixture with all 4 levels populated → entry_override wins.
  14. Cascade order skip NULL — project.billable_default=NULL → falls through to engagement.
  15. PII scrub — audit row carries no description text.
  16. Trace_id end-to-end.
  17. Cross-tenant RLS — engagement default change scoped.
  18. Audit sampled correctlybillable_resolved at 1% per TASK-OBS-006 tail.
  19. Member override doesn't change project default — entry-level only.
  20. Concurrent updates race-safe — last-write-wins on policy fields.

§5 — Verification

#[tokio::test]
async fn cascade_4_step() {
    let ctx = TestContext::with_tenant_default(true).await;
    ctx.set_engagement_default(ctx.eng_id, Some(false)).await;
    ctx.set_project_default(ctx.proj_id, Some(true)).await;

    let entry_id = ctx.create_entry(ctx.member_id, ctx.proj_id, None).await;
    let row: (bool, String) = sqlx::query_as("SELECT is_billable, billable_source::text FROM time_entries WHERE entry_id=$1")
        .bind(entry_id).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(row.0, true);
    assert_eq!(row.1, "project_default");
}

#[tokio::test]
async fn entry_override_wins() {
    let ctx = TestContext::with_engagement_default(false).await;
    let entry_id = ctx.create_entry_with_override(ctx.member_id, ctx.eng_id, true).await;
    let row: (bool, String) = sqlx::query_as("SELECT is_billable, billable_source::text FROM time_entries WHERE entry_id=$1")
        .bind(entry_id).fetch_one(&ctx.pool).await.unwrap();
    assert!(row.0);
    assert_eq!(row.1, "entry_override");
}

#[tokio::test]
async fn snapshot_immutable() {
    let ctx = TestContext::with_engagement_default(true).await;
    let entry_id = ctx.create_entry(ctx.member_id, ctx.proj_id, None).await;
    ctx.set_engagement_default(ctx.eng_id, Some(false)).await;
    let row: (bool,) = sqlx::query_as("SELECT is_billable FROM time_entries WHERE entry_id=$1")
        .bind(entry_id).fetch_one(&ctx.pool).await.unwrap();
    assert!(row.0);  // unchanged
}

// 5.4..5.6: enum cardinality, role checks, audit emission

§7 — Dependencies

Upstream: TASK-TIME-001 (entry write), TASK-PROJ-006 (project billable_default column). Cross-module: TASK-AUTH-101 (engagement_admin + cfo roles), TASK-AI-003, TASK-MEMORY-111.


§8 — Example payload

time.engagement_policy_changed:

{
  "kind": "time.engagement_policy_changed",
  "severity": 2,
  "tenant_id": "8a2f...",
  "actor_id": "user.cfo.789",
  "trace_id": "...",
  "payload": {
    "engagement_id": "0190...",
    "from_default": true,
    "to_default": false
  }
}

§9 — Open questions

Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Tenant default NULLNOT NULL constraintINSERT failsTenant config must set
Project/engagement deleted mid-entry-writeFK softCascade falls through to next levelInherent
Policy change racetx isolationLater writes use new policy; older entries snapshottedInherent
Override checkbox UI bug sends NULLhandler treats as no-overrideFalls through cascadeUI fix
Cross-tenant policy updateRLS403Inherent
Project from different engagementengagement check at entry400Inherent
Member without engagement membershipRLS at write403Inherent
Cascade query slow at high volumeOBS latencysev-3; index ensures O(1)Index check
Source enum extended without migrationCI cardinality testCI failsMigration first
Snapshot field accidentally mutatedREVOKE UPDATEDB rejectsInherent
Audit row sampling drops critical eventTASK-OBS-006 tailEntry-override always emitted (not sampled) per AC #18Inherent
Engagement_admin updates project in other engagementrole scope check403Inherent
Tenant default change retroactively wantedsnapshot principlePast entries unchangedManual re-issue if needed
Concurrent project default updatestx isolationLast writer winsInherent
Member overrides for non-billable engagementallowed but flaggedAudit notes deviationEngagement_admin review

§11 — Implementation notes

§11.1 Cascade resolver does 1 SELECT per entry write; ~1ms with proper indexes.

§11.2 Snapshot fields: REVOKE UPDATE on (is_billable, billable_source) per task-audit skill rule 12.

§11.3 Override boolean explicitly nullable — distinguishes "no override" (NULL) from "false override" (FALSE).

§11.4 Audit sampling at 1% via TASK-OBS-006 except entry_override always emitted (Member intent).

§11.5 UI form pre-fetches cascade preview ("This will be billable based on engagement policy").

§11.6 Policy change endpoints emit memory before commit (atomic).

§11.7 Tenant default defaults to TRUE on tenant create (most consultancies bill by default).

§11.8 Project/engagement defaults default to NULL (inherit).

§11.9 PII: no description in audit; only IDs.

§11.10 Cross-tenant via RLS.


End of TASK-TIME-005 spec.