"TIME billable flag cascade — 4-step resolver (entry override → project default → engagement policy → tenant default) with snapshot on row"
§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.
- MUST define closed
billable_sourceenum:('entry_override','project_default','engagement_policy','tenant_default')per DEC-1411. Cardinality 4.
- MUST add columns to TIME entries:
is_billable BOOLEAN NOT NULL,billable_source billable_source NOT NULL. Snapshotted at entry creation per DEC-1410.
- MUST add
billable_default BOOLEANcolumns to:projects(NULL = inherit),engagements(NULL = inherit),tenants(NOT NULL — required).
- MUST resolve billable flag per
cascade.rs::resolve(entry_override, project_id, engagement_id, tenant_id)per DEC-1412:
- If
entry_override IS NOT NULL→ return (override, 'entry_override'). - Else SELECT projects.billable_default; if NOT NULL → return (project_default, 'project_default').
- Else SELECT engagements.billable_default; if NOT NULL → return (engagement_policy, 'engagement_policy').
- Else SELECT tenants.billable_default → return (tenant_default, 'tenant_default').
- Sets
entries.is_billable+entries.billable_source.
- MUST snapshot at write per DEC-1414. Post-write
is_billableis IMMUTABLE — never re-cascaded.
- MUST support per-level override per DEC-1413:
- Member:
entry.billable_override BOOLEANat create-time. - engagement_admin:
PATCH /v1/projects/{id}body{ billable_default }(project-level). - cfo:
PATCH /v1/engagements/{id}body{ billable_default }(engagement-level). - cfo:
PATCH /v1/admin/tenants/{id}body{ billable_default }(tenant-level). - Each level change emits respective memory audit row.
- MUST emit 4 memory audit kinds per DEC-1415:
time.billable_resolved(sev-3 — informational; sampled 1%)time.billable_overridden_at_entry(sev-3 — when Member uses checkbox)time.project_default_changed(sev-2)time.engagement_policy_changed(sev-2)
- MUST thread trace_id end-to-end.
- MUST NOT re-resolve post-write (per DEC-1414).
- 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
- billable_source cardinality 4.
- Tenant default applied — entry with no project/engagement override → source=tenant_default.
- Engagement override — engagement.billable_default=false → entry billable=false; source=engagement_policy.
- Project override — project.billable_default=false (engagement=true) → entry billable=false; source=project_default.
- Entry override — Member sets billable_override=true (project=false) → entry billable=true; source=entry_override.
- Snapshot immutable — entry created billable=true; subsequent engagement policy change to false → entry remains billable=true.
- 4 memory audit kinds emitted.
- Tenant default required — INSERT tenants without billable_default fails NOT NULL.
- Engagement_admin updates project — PATCH succeeds + audit.
- CFO updates engagement — PATCH succeeds + audit.
- Non-admin cannot update project default — 403.
- Member entry override at form — UI checkbox flips per-entry.
- Cascade order verified — fixture with all 4 levels populated → entry_override wins.
- Cascade order skip NULL — project.billable_default=NULL → falls through to engagement.
- PII scrub — audit row carries no description text.
- Trace_id end-to-end.
- Cross-tenant RLS — engagement default change scoped.
- Audit sampled correctly —
billable_resolvedat 1% per TASK-OBS-006 tail. - Member override doesn't change project default — entry-level only.
- 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:
- Deferred: Per-task billable default (5th cascade level) — slice 2.
- Deferred: Per-role billable rules (e.g. senior consultants always billable) — slice 2.
- Deferred: Time-of-day billable rules (weekend/night premium) — slice 3.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Tenant default NULL | NOT NULL constraint | INSERT fails | Tenant config must set |
| Project/engagement deleted mid-entry-write | FK soft | Cascade falls through to next level | Inherent |
| Policy change race | tx isolation | Later writes use new policy; older entries snapshotted | Inherent |
| Override checkbox UI bug sends NULL | handler treats as no-override | Falls through cascade | UI fix |
| Cross-tenant policy update | RLS | 403 | Inherent |
| Project from different engagement | engagement check at entry | 400 | Inherent |
| Member without engagement membership | RLS at write | 403 | Inherent |
| Cascade query slow at high volume | OBS latency | sev-3; index ensures O(1) | Index check |
| Source enum extended without migration | CI cardinality test | CI fails | Migration first |
| Snapshot field accidentally mutated | REVOKE UPDATE | DB rejects | Inherent |
| Audit row sampling drops critical event | TASK-OBS-006 tail | Entry-override always emitted (not sampled) per AC #18 | Inherent |
| Engagement_admin updates project in other engagement | role scope check | 403 | Inherent |
| Tenant default change retroactively wanted | snapshot principle | Past entries unchanged | Manual re-issue if needed |
| Concurrent project default updates | tx isolation | Last writer wins | Inherent |
| Member overrides for non-billable engagement | allowed but flagged | Audit notes deviation | Engagement_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.