"TIME TimeEntry append-only schema — correction_to link semantics + tenant-scoped RLS + invoice-grade integrity"
§1 — Description (BCP-14 normative)
The TIME service MUST ship the TimeEntry schema as the canonical append-only record of "Member X spent N minutes on issue Y at time Z". Each requirement:
- MUST define the
time_entriestable with the following columns (full DDL in §3.1):
id UUID PRIMARY KEY— row identity.tenant_id UUID NOT NULL— RLS partitioning key.member_subject_id UUID NOT NULL REFERENCES auth.subjects(id)— who performed the work.engagement_id UUID NOT NULL— the engagement billable target (per TASK-PROJ-005; placeholder FK at slice 1).issue_id UUID NOT NULL— the specific issue (per TASK-PROJ-001).ts_start TIMESTAMPTZ NOT NULL— entry start in UTC.duration_minutes INT NOT NULL CHECK (duration_minutes BETWEEN 1 AND 1440)— minimum 1 minute, maximum 24 hours (per DEC-227 + DEC-228).entry_kind entry_kind NOT NULL— closed enum:regular | overtime | weekend | holiday.entry_status entry_status NOT NULL DEFAULT 'draft'— closed enum:draft | submitted | approved | reverted(TASK-TIME-006 transitions).billable BOOLEAN NOT NULL DEFAULT false— set by TASK-TIME-005's cascade; this task declares the column only (per DEC-223).rate_card_snapshot JSONB— populated at entry creation by TASK-TIME-005; snapshot of the engagement rate-card at the row's instant (per DEC-224); empty{}at slice 1.entry_currency CHAR(3) NOT NULL— ISO-4217; defaulted from engagement.invoice_currency at row creation (per DEC-229).description TEXT— nullable; 0–1000 chars; PII-scrubbable.correction_to UUID REFERENCES time_entries(id) DEFERRABLE INITIALLY IMMEDIATE— nullable; non-null on correction rows (per DEC-220 + DEC-225).created_at TIMESTAMPTZ NOT NULL DEFAULT now().created_by_subject_id UUID NOT NULL REFERENCES auth.subjects(id).
- MUST enforce RLS with both
USINGandWITH CHECK(task-audit skill rule 13). Policy:tenant_id = current_setting('auth.tenant_id')::uuid. Cross-tenant reads return 0 rows; cross-tenant writes failpermission_denied.
- MUST declare the closed
entry_kindPostgres enum with exactly 4 values (per VN-1 working-time classification):'regular','overtime','weekend','holiday'. Adding a 5th is an ADR. Holiday-list driving the kind defaulting is TASK-TIME-007's responsibility; this task just stores the column.
- MUST declare the closed
entry_statusPostgres enum with exactly 4 values:'draft','submitted','approved','reverted'. State transitions are TASK-TIME-006's responsibility; this task ships the column at default'draft'.
- MUST be append-only at the SQL grant layer (per DEC-230 + task-audit skill rule 12). Migration applies
REVOKE UPDATE, DELETE ON time_entries FROM cyberos_app;. Mutations write a fresh row withcorrection_topointing at the prior row (per §1 #6 below).
- MUST support correction via new row (per DEC-220). The handler
POST /v1/time/entries/{id}/correctcreates a new row with:
correction_to = <prior_id>.- All other fields from the body OR copied from prior if unspecified.
tenant_id,engagement_id,issue_id,member_subject_idMUST be identical to the prior row (per §1 #11). Cross-engagement correction is forbidden.- The same audit emission contract as
createbut with kindtime.entry_corrected. Corrections are themselves correctable (chains, not trees — per DEC-226).
- MUST enforce acyclic correction chains (per DEC-225). A
BEFORE INSERTtrigger walkscorrection_toupward; if the walk visits the new row'sid(via a future re-correction back), the insert is rejected withcorrection_cycle_detected. A CI test (correction_acyclic_test) seeds a 5-row chain + an attempted cycle and asserts the reject.
- MUST enforce chain (not tree) topology (per DEC-226). At most one row may have
correction_to = <prior_id>for any givenprior_id. ABEFORE INSERTtrigger checksEXISTS (SELECT 1 FROM time_entries WHERE correction_to = $new.correction_to); if so → reject withprior_row_already_corrected. Reasoning: "what is the current value of entry X?" must be unambiguous; trees create N parallel current values.
- MUST ship the
current_time_entries_viewSQL view filtering to "effective rows only" — rows that no other row supersedes viacorrection_to. Definition:SELECT * FROM time_entries WHERE id NOT IN (SELECT correction_to FROM time_entries WHERE correction_to IS NOT NULL). Downstream tasks (TASK-TIME-005 billable cascade, TASK-TIME-009 rollup) MUST read from the view, not the raw table.
- MUST validate at API layer:
duration_minutesbetween 1 and 1440 (per DEC-227 + DEC-228; the DB CHECK constraint duplicates).ts_startnot in the future (clock-skew tolerance: +5 minutes; entries beyond that → 400ts_start_in_future).entry_kindparses to closed enum (unknown_entry_kindotherwise).- On correction:
correction_torow exists, belongs to the same tenant + engagement + issue + member. descriptionlength 0–1000 chars.
- MUST reject corrections where any of
tenant_id,engagement_id,issue_id,member_subject_iddiffer from the prior row. A triggerenforce_correction_inheritanceraisescorrection_cross_scopeon violation. Reasoning: a correction is "I logged this entry wrong" — never "I logged this entry under the wrong engagement"; the latter is a new entry + atime.entry_recordedrow.
- MUST emit memory audit row
time.entry_recordedon every create (non-correction row) andtime.entry_correctedon every correction. Both rows carry{entry_id, tenant_id, member_subject_id_hash16, engagement_id, issue_id, duration_minutes, entry_kind, entry_status, billable, ts_start, ts_ns_recorded}. The correction row additionally carriescorrection_to.
- MUST PII-scrub the
descriptionfield via TASK-MEMORY-111 BEFORE chain commit. The PostgreSQL row retains the raw text (tenant-scoped + RLS-protected); the memory audit chain holds only the scrubbed form (task-audit skill rule 18).
- MUST complete create/correct/get/list handlers in ≤ 50 ms p95.
entries_perf_testasserts on 1000 iterations.
- MUST expose REST handlers:
POST /v1/time/entries— create new entry; callerResource::TimeEntry + Action::Write.POST /v1/time/entries/{id}/correct— create correction row; same permission.GET /v1/time/entries/{id}— fetch (effective viacurrent_time_entries_viewby default;?include=historywalks the chain).GET /v1/time/entries?member_subject_id=<>&engagement_id=<>&from=<>&to=<>— list with cursor pagination; defaults tocurrent_time_entries_view.
- MUST support idempotent creation via
Idempotency-Keyheader (same semantics as TASK-AUTH-002 §1 #6).
- MUST emit OTel span
time.entry.{create,correct,get,list}per handler with attributes:tenant_id,member_subject_id_hash16,engagement_id,entry_id,outcome(success | invalid_duration | invalid_kind | cross_scope_correction | cycle_detected | prior_already_corrected | permission_denied).
- MUST emit OTel metrics:
time_entry_create_total{outcome, entry_kind}(counter).time_entry_correct_total{outcome}(counter).time_entry_correction_chain_depth(histogram; alarm at p99 > 5 — a deep chain suggests a workflow issue).time_entry_duration_minutes(histogram perentry_kind).time_entry_count{tenant_id, entry_status}(gauge).
- MUST ship
entry_chain_walker(entry_id UUID) RETURNS SETOF UUIDSQL function that returns the chain from oldest (original) to newest (effective). Used byGET ?include=historyand by the cycle-detection trigger. Maximum walk depth 100 (anti-infinite-loop safety floor).
- MUST ensure
correction_toFK isDEFERRABLE INITIALLY IMMEDIATEso the trigger sees the new row exists when validating self-references during the same transaction (Postgres-specific; needed for the cycle walker to see its own new row).
- MUST maintain the rate_card_snapshot pattern (per DEC-224). When TASK-TIME-005 ships, it populates
rate_card_snapshotat row creation with the engagement's then-current rate card. Mutations to the engagement's rate card NEVER alter pastrate_card_snapshotvalues — the snapshot is frozen at the row's instant. Slice 1 ships the column with{}default; TASK-TIME-005 fills.
- MUST support list filters:
?member_subject_id,?engagement_id,?issue_id,?from=<ts>,?to=<ts>,?entry_status,?billable. Default page size 50, max 500. Cursor pagination on(ts_start DESC, id).
- MUST support
GET /v1/time/entries/{id}?include=historyreturning the full chain[<original>, <correction_1>, <correction_2>, ...]ordered oldest to newest. CallerAction::ReadonTimeEntry.
- MUST ensure the
current_time_entries_viewperformance is acceptable (< 100 ms for 10K-entry tenant). Index:CREATE INDEX time_entries_correction_to_idx ON time_entries (correction_to) WHERE correction_to IS NOT NULL;— bounded by correction count (typically < 5% of total).
- MUST treat
created_by_subject_idas the actor (who created the row), which MAY differ frommember_subject_id(whose work is being recorded). For self-entry both are equal; for AM-on-behalf-of entry (TASK-TIME-003 manual form) they differ. Both fields are immutable per row.
§2 — Why this design (rationale for humans)
Why append-only with correction_to and not UPDATE in place (DEC-220, DEC-230)? Time entries are invoice-grade financial records. An UPDATE in place loses the prior value; the audit trail of "what was originally claimed?" disappears. Append-only via correction_to means every prior value is preserved; the chain is the legal record. SOC 2 + ISO 27001 audit-logging requirements (A.12.4) are satisfied by construction. The cost is the slight complexity of "the current row is the one not pointed to by any other row's correction_to" — but the current_time_entries_view collapses that to a single query.
Why a chain (not tree) topology for corrections (DEC-226, §1 #8)? If two rows could both correct the same prior, "what is the current value?" becomes ambiguous (which correction is the latest?). Allowing only one row per correction_to enforces a deterministic linearisation — the chain head is unambiguously "the current value." The trigger rejects the second attempt with prior_row_already_corrected. Operators who need to correct an already-corrected row simply correct the current head (the chain extends).
Why acyclic enforcement via trigger (DEC-225, §1 #7)? A correction cycle (A → B → A) is a logic error that breaks every consumer (the chain walker would loop forever; the "current value" predicate has no answer). The trigger walks the chain at INSERT time and rejects the cycle. The CI test (correction_acyclic_test) seeds a deliberate cycle attempt to assert protection.
Why correction_to cross-scope rejection (§1 #11)? A correction is "I logged the same thing wrong" — same engagement, same issue, same member. Allowing the operator to "correct" entry-1 (engagement-A, issue-X) to a new value in (engagement-B, issue-Y) would let them effectively rewrite the engagement bill silently. Cross-scope corrections are blocked at trigger; the right action is time.entry_reverted (slice 2) plus a new time.entry_recorded under the new scope.
Why durations bounded 1 ≤ minutes ≤ 1440 (DEC-227, DEC-228)? The lower bound (1 minute) is operational — entries below 1 minute are typically test data or accidental clicks. The upper bound (1440 = 24 hours) prevents a single buggy entry from claiming a year of work; daily-cap enforcement (TASK-TIME-007) operates across rows, but per-row cap catches the most extreme typos before they reach the daily aggregator.
Why rate_card_snapshot JSONB on the row (DEC-224)? The billable amount of an entry is rate × hours. If rate is fetched at invoice generation by joining to the engagement's current rate card, a CFO bumping the rate card retroactively shifts every historic invoice line — silently. Snapshotting the rate card AT the row's instant freezes the billable basis. JSONB (not FK) is the right shape because rate cards have nested structure (per-role rates, member overrides, time-of-day adjustments), and we want the snapshot to be a pure value copy that's immune to FK cascades.
Why entry_kind closed at 4 values (§1 #3)? Working-time classification under VN Labour Code is regular | overtime | weekend | holiday. Each kind has different statutory rate multipliers (TASK-REW-004 ships). Allowing a 5th (e.g. night_shift) is an ADR — the rate-multiplier table would need to extend. Closed enum prevents drift.
Why entry_status separate from billable (§1 #1)? entry_status is workflow ("has this been approved?"); billable is financial classification ("is this hour invoiced?"). An entry can be status=approved, billable=false (approved internal work, not billed to client). They are orthogonal axes.
Why slice-1 ships billable=false default (DEC-223)? The billable cascade (TASK-TIME-005) is a non-trivial 4-step decision involving the engagement's non-billable categories, the rate card's role default, and member overrides. Splitting it to its own task keeps this schema task focused on integrity. Default false is conservative — if the cascade fails to set the flag, the entry is treated as non-billable (no invoice line) rather than mis-billed. TASK-TIME-005's tests assert the default is replaced on every entry.
Why entry_currency on the row (DEC-229)? Multi-currency tenants run engagements in VND, USD, SGD, etc. The entry's currency is the engagement's invoice currency at the row's instant. Snapshotting prevents the engagement's currency switch (rare but possible during contract renegotiation) from retroactively converting past entries. Invoice math is TASK-INV-001's concern; this task just preserves the source-of-truth currency.
Why created_by_subject_id distinct from member_subject_id (§1 #25)? Most entries are self-logged (member = creator). Some entries are AM-on-behalf-of (manual entry on someone else's behalf with their confirmation — TASK-TIME-003 ships that flow). Both fields capture different facts: "whose work this records" vs "who pressed the button." Audit trails need both.
Why per-row immutability of all fields (implicit in DEC-230)? Combined with append-only at SQL grant, this means a row's content is its forever-record. Workflows that need "edit" semantics use the correction handler (which creates a new row pointing at the prior); UI affordances may present this as "edit" but the underlying mechanism is always insert-new.
Why current_time_entries_view instead of always-current column on the row (§1 #9)? Adding is_current BOOLEAN to the row creates a writer dependency — every correction would have to update the prior row's is_current = false, breaking append-only. The view filters at read time; the index on correction_to WHERE correction_to IS NOT NULL is small (~5% of rows).
Why DEFERRABLE INITIALLY IMMEDIATE on the self-FK (§1 #20)? The cycle-detection trigger walks correction_to to check whether the chain loops back to the new row's id. Standard (non-deferrable) FK constraints would reject the row before the trigger sees it. DEFERRABLE INITIALLY IMMEDIATE means the FK is checked at the end of the statement, after the trigger; the trigger can use the new row's id during validation.
Why list defaults to current view (§1 #22)? The 99% query pattern is "what hours did this Member log this week?" — and the answer is current-effective, not raw history. The 1% query "show me the correction history of entry-1" uses ?include=history. Defaulting to history-aware listing would surface every superseded row, confusing operators.
Why chain max-depth 100 (§1 #19)? Practical chains are 1–3 rows (entry + one correction is typical; pathological cases reach 5–10). 100 is a safety floor — any chain that deep is either an integration bug or a stress test; the walker bails to prevent infinite-loop-like CPU consumption. Production chains hit 100 are alarmable.
Why description 0–1000 chars (§1 #10)? Short enough to discourage prose-narrative entries (which belong in PROJ issue comments, not TIME); long enough to let "fixed merge conflict in chat/auth bridge — see PR-451" fit. PII-scrubbed via TASK-MEMORY-111 before chain commit; operators warned that descriptions are visible to AM + CFO via TASK-TIME-006.
Why two memory audit row kinds (recorded + corrected) and not one (DEC-231)? Different operator queries: "show me the original-creation activity for this engagement" filters on time.entry_recorded; "show me the correction activity" filters on time.entry_corrected. A single kind would require an extra is_correction flag and degrade query selectivity.
§3 — API contract
3.1 — Migration 0001 — time_entries
-- services/time/migrations/0001_time_entries.sql
BEGIN;
CREATE TYPE entry_kind AS ENUM ('regular', 'overtime', 'weekend', 'holiday');
CREATE TYPE entry_status AS ENUM ('draft', 'submitted', 'approved', 'reverted');
CREATE TABLE time_entries (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
member_subject_id UUID NOT NULL REFERENCES auth.subjects(id) ON DELETE RESTRICT,
engagement_id UUID NOT NULL,
issue_id UUID NOT NULL,
ts_start TIMESTAMPTZ NOT NULL,
duration_minutes INT NOT NULL CHECK (duration_minutes BETWEEN 1 AND 1440),
entry_kind entry_kind NOT NULL,
entry_status entry_status NOT NULL DEFAULT 'draft',
billable BOOLEAN NOT NULL DEFAULT false,
rate_card_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
entry_currency CHAR(3) NOT NULL CHECK (entry_currency ~ '^[A-Z]{3}$'),
description TEXT CHECK (description IS NULL OR length(description) BETWEEN 0 AND 1000),
correction_to UUID REFERENCES time_entries(id) DEFERRABLE INITIALLY IMMEDIATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by_subject_id UUID NOT NULL REFERENCES auth.subjects(id) ON DELETE RESTRICT
);
CREATE INDEX time_entries_tenant_member_ts_idx ON time_entries (tenant_id, member_subject_id, ts_start DESC);
CREATE INDEX time_entries_tenant_engagement_ts_idx ON time_entries (tenant_id, engagement_id, ts_start DESC);
CREATE INDEX time_entries_correction_to_idx ON time_entries (correction_to) WHERE correction_to IS NOT NULL;
ALTER TABLE time_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY time_entries_tenant_isolation ON time_entries
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
-- Append-only enforcement (DEC-230)
REVOKE UPDATE, DELETE ON time_entries FROM cyberos_app;
-- Cycle detection on correction_to (DEC-225)
CREATE OR REPLACE FUNCTION detect_correction_cycle() RETURNS TRIGGER AS $$
DECLARE
walker UUID;
depth INT := 0;
BEGIN
IF NEW.correction_to IS NULL THEN RETURN NEW; END IF;
walker := NEW.correction_to;
WHILE walker IS NOT NULL AND depth < 100 LOOP
IF walker = NEW.id THEN
RAISE EXCEPTION 'correction_cycle_detected' USING ERRCODE = 'P0010';
END IF;
SELECT correction_to INTO walker FROM time_entries WHERE id = walker;
depth := depth + 1;
END LOOP;
IF depth >= 100 THEN
RAISE EXCEPTION 'correction_chain_too_deep' USING ERRCODE = 'P0011';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_time_entries_no_cycle BEFORE INSERT ON time_entries
FOR EACH ROW EXECUTE FUNCTION detect_correction_cycle();
-- Chain (not tree) enforcement (DEC-226)
CREATE OR REPLACE FUNCTION enforce_chain_topology() RETURNS TRIGGER AS $$
BEGIN
IF NEW.correction_to IS NULL THEN RETURN NEW; END IF;
IF EXISTS (SELECT 1 FROM time_entries WHERE correction_to = NEW.correction_to AND id != NEW.id) THEN
RAISE EXCEPTION 'prior_row_already_corrected' USING ERRCODE = 'P0012';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_time_entries_chain_topology BEFORE INSERT ON time_entries
FOR EACH ROW EXECUTE FUNCTION enforce_chain_topology();
-- Cross-scope correction rejection (§1 #11)
CREATE OR REPLACE FUNCTION enforce_correction_inheritance() RETURNS TRIGGER AS $$
DECLARE prior RECORD;
BEGIN
IF NEW.correction_to IS NULL THEN RETURN NEW; END IF;
SELECT tenant_id, engagement_id, issue_id, member_subject_id
INTO prior FROM time_entries WHERE id = NEW.correction_to;
IF NOT FOUND THEN
RAISE EXCEPTION 'correction_target_missing' USING ERRCODE = 'P0013';
END IF;
IF prior.tenant_id != NEW.tenant_id
OR prior.engagement_id != NEW.engagement_id
OR prior.issue_id != NEW.issue_id
OR prior.member_subject_id != NEW.member_subject_id THEN
RAISE EXCEPTION 'correction_cross_scope' USING ERRCODE = 'P0014';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_time_entries_correction_inheritance BEFORE INSERT ON time_entries
FOR EACH ROW EXECUTE FUNCTION enforce_correction_inheritance();
COMMIT;
3.2 — Migration 0002 — current view + chain walker
-- services/time/migrations/0002_time_entries_view.sql
BEGIN;
CREATE VIEW current_time_entries_view AS
SELECT * FROM time_entries
WHERE id NOT IN (
SELECT correction_to FROM time_entries WHERE correction_to IS NOT NULL
);
-- Walk a chain from any node to its head (effective row).
CREATE OR REPLACE FUNCTION entry_chain_walker(p_entry_id UUID) RETURNS SETOF UUID AS $$
DECLARE
head UUID;
walker UUID;
depth INT := 0;
BEGIN
-- Walk to the original.
walker := p_entry_id;
WHILE EXISTS (SELECT 1 FROM time_entries WHERE id = walker AND correction_to IS NOT NULL) AND depth < 100 LOOP
SELECT correction_to INTO walker FROM time_entries WHERE id = walker;
depth := depth + 1;
END LOOP;
head := walker;
-- Walk down to the effective row, returning each id.
walker := head;
depth := 0;
LOOP
RETURN NEXT walker;
SELECT id INTO walker FROM time_entries WHERE correction_to = walker;
EXIT WHEN NOT FOUND OR depth >= 100;
depth := depth + 1;
END LOOP;
END;
$$ LANGUAGE plpgsql STABLE;
COMMIT;
3.3 — Rust types
// services/time/src/types.rs
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Type};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Type, Serialize, Deserialize)]
#[sqlx(type_name = "entry_kind", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum EntryKind { Regular, Overtime, Weekend, Holiday }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Type, Serialize, Deserialize)]
#[sqlx(type_name = "entry_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum EntryStatus { Draft, Submitted, Approved, Reverted }
impl EntryKind {
pub const ALL: &'static [EntryKind] = &[EntryKind::Regular, EntryKind::Overtime, EntryKind::Weekend, EntryKind::Holiday];
}
impl EntryStatus {
pub const ALL: &'static [EntryStatus] = &[EntryStatus::Draft, EntryStatus::Submitted, EntryStatus::Approved, EntryStatus::Reverted];
}
#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct TimeEntry {
pub id: Uuid,
pub tenant_id: Uuid,
pub member_subject_id: Uuid,
pub engagement_id: Uuid,
pub issue_id: Uuid,
pub ts_start: DateTime<Utc>,
pub duration_minutes: i32,
pub entry_kind: EntryKind,
pub entry_status: EntryStatus,
pub billable: bool,
pub rate_card_snapshot: serde_json::Value,
pub entry_currency: String,
pub description: Option<String>,
pub correction_to: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub created_by_subject_id: Uuid,
}
3.4 — Validation
// services/time/src/validation.rs
use chrono::{DateTime, Duration, Utc};
pub const MIN_DURATION_MINUTES: i32 = 1;
pub const MAX_DURATION_MINUTES: i32 = 1440;
pub const FUTURE_TOLERANCE: Duration = Duration::minutes(5);
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("duration_out_of_range: {0}")]
DurationOutOfRange(i32),
#[error("ts_start_in_future: {0}")]
TsStartInFuture(DateTime<Utc>),
#[error("description_too_long: {0}")]
DescriptionTooLong(usize),
}
pub fn validate_duration(minutes: i32) -> Result<(), ValidationError> {
if minutes < MIN_DURATION_MINUTES || minutes > MAX_DURATION_MINUTES {
return Err(ValidationError::DurationOutOfRange(minutes));
}
Ok(())
}
pub fn validate_ts_start(ts: DateTime<Utc>, now: DateTime<Utc>) -> Result<(), ValidationError> {
if ts > now + FUTURE_TOLERANCE { return Err(ValidationError::TsStartInFuture(ts)); }
Ok(())
}
pub fn validate_description(desc: &Option<String>) -> Result<(), ValidationError> {
if let Some(d) = desc {
if d.len() > 1000 { return Err(ValidationError::DescriptionTooLong(d.len())); }
}
Ok(())
}
3.5 — REST handlers (excerpt)
// services/time/src/handlers/entries.rs
use axum::{Json, extract::{Path, State, Query}, http::StatusCode};
use crate::types::*;
use crate::validation::*;
use crate::audit::entry_events;
#[derive(Deserialize)]
pub struct CreateEntryRequest {
pub member_subject_id: Uuid,
pub engagement_id: Uuid,
pub issue_id: Uuid,
pub ts_start: DateTime<Utc>,
pub duration_minutes: i32,
pub entry_kind: EntryKind,
pub entry_currency: String,
pub description: Option<String>,
}
pub async fn create_entry(
State(state): State<AppState>,
claims: Claims,
Json(req): Json<CreateEntryRequest>,
) -> Result<(StatusCode, Json<TimeEntry>), ApiError> {
state.matrix.snapshot().require_permission(&claims.roles(), Resource::TimeEntry, Action::Write)?;
validate_duration(req.duration_minutes)?;
validate_ts_start(req.ts_start, Utc::now())?;
validate_description(&req.description)?;
let id = Uuid::new_v4();
let mut tx = state.db.begin().await?;
let entry = sqlx::query_as!(TimeEntry, r#"
INSERT INTO time_entries (id, tenant_id, member_subject_id, engagement_id, issue_id,
ts_start, duration_minutes, entry_kind, entry_status, billable,
rate_card_snapshot, entry_currency, description, correction_to, created_by_subject_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::entry_kind, 'draft'::entry_status, false,
'{}'::jsonb, $9, $10, NULL, $11)
RETURNING *
"#,
id, claims.tenant_id(), req.member_subject_id, req.engagement_id, req.issue_id,
req.ts_start, req.duration_minutes, req.entry_kind as EntryKind,
req.entry_currency, req.description, claims.subject_id(),
).fetch_one(&mut *tx).await?;
entry_events::emit_entry_recorded(&mut tx, &entry).await?;
tx.commit().await?;
Ok((StatusCode::CREATED, Json(entry)))
}
#[derive(Deserialize)]
pub struct CorrectEntryRequest {
pub duration_minutes: Option<i32>,
pub entry_kind: Option<EntryKind>,
pub description: Option<String>,
pub ts_start: Option<DateTime<Utc>>,
}
pub async fn correct_entry(
State(state): State<AppState>,
claims: Claims,
Path(prior_id): Path<Uuid>,
Json(req): Json<CorrectEntryRequest>,
) -> Result<(StatusCode, Json<TimeEntry>), ApiError> {
state.matrix.snapshot().require_permission(&claims.roles(), Resource::TimeEntry, Action::Write)?;
let mut tx = state.db.begin().await?;
let prior: TimeEntry = sqlx::query_as!(TimeEntry, "SELECT * FROM time_entries WHERE id = $1", prior_id)
.fetch_one(&mut *tx).await?;
let new_id = Uuid::new_v4();
let duration = req.duration_minutes.unwrap_or(prior.duration_minutes);
let kind = req.entry_kind.unwrap_or(prior.entry_kind);
let desc = req.description.or(prior.description.clone());
let ts_start = req.ts_start.unwrap_or(prior.ts_start);
validate_duration(duration)?;
validate_ts_start(ts_start, Utc::now())?;
validate_description(&desc)?;
let entry = sqlx::query_as!(TimeEntry, r#"
INSERT INTO time_entries (id, tenant_id, member_subject_id, engagement_id, issue_id,
ts_start, duration_minutes, entry_kind, entry_status, billable,
rate_card_snapshot, entry_currency, description, correction_to, created_by_subject_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::entry_kind, $9::entry_status, $10,
$11, $12, $13, $14, $15)
RETURNING *
"#,
new_id, prior.tenant_id, prior.member_subject_id, prior.engagement_id, prior.issue_id,
ts_start, duration, kind as EntryKind, prior.entry_status as EntryStatus, prior.billable,
prior.rate_card_snapshot, prior.entry_currency, desc, prior.id, claims.subject_id(),
).fetch_one(&mut *tx).await?;
entry_events::emit_entry_corrected(&mut tx, &entry, &prior).await?;
tx.commit().await?;
Ok((StatusCode::CREATED, Json(entry)))
}
§4 — Acceptance criteria
- EntryKind closed at 4 values —
EntryKind::ALL.len() == 4; Postgres enum has exactly 4 labels. - EntryStatus closed at 4 values — same shape.
- RLS isolates by tenant — query as tenant-A returns 0 entries of tenant-B.
- Create entry happy path — valid body → 201 with
TimeEntryJSON; onetime.entry_recordedmemory row. - Create entry < 1 minute — 400
duration_out_of_range. - Create entry > 1440 minutes — 400
duration_out_of_range. - Create entry ts_start in future — 400
ts_start_in_future. - UPDATE on time_entries blocked —
UPDATE time_entries SET duration_minutes = 100 WHERE id = $1ascyberos_appuser → permission denied. - DELETE on time_entries blocked — same as #8 for DELETE.
- Correct entry happy — valid prior id + new duration → 201 with new row; original kept; new row's
correction_to = prior_id. - Correct entry cross-tenant rejected — correction targeting a prior in a different tenant → 400
correction_cross_scope. - Correct entry cross-engagement rejected — modifying engagement_id in correction body → 400
correction_cross_scope. - Tree topology rejected — two correctors of same prior → second insert raises
prior_row_already_corrected. - Cycle topology rejected — A → B → A attempted →
correction_cycle_detected. - Chain depth > 100 rejected — synthetic 101-row chain → 102nd insert raises
correction_chain_too_deep. - current_time_entries_view filters correctly — query on view never returns rows that are pointed to by
correction_to. - GET ?include=history returns full chain — chain of 3 → 3 rows returned in oldest-first order.
- Idempotent create — same Idempotency-Key + same body → same entry.
- OTel span
time.entry.createemitted — withoutcome=success. - OTel counter
time_entry_create_total{outcome=success, entry_kind=regular}increments — per create. - OTel counter
time_entry_correct_total{outcome=success}increments — per correction. - OTel histogram
time_entry_correction_chain_depthobserves — chain of 4 → observation of 4. - Perf budget < 50 ms p95 —
entries_perf_test1000 iterations. - Subject FK ON DELETE RESTRICT —
DELETE FROM auth.subjects WHERE id = <member_subject_id>raises FK violation if entries exist. - rate_card_snapshot defaults to
{}— slice 1 default until TASK-TIME-005 fills. created_bydistinct frommember_subject_id— AM-on-behalf-of entry creates row withcreated_by = AM_id, member_subject_id = staff_id.
§5 — Verification
// services/time/tests/append_only_test.rs
#[sqlx::test]
async fn update_blocked_at_grant(pool: sqlx::PgPool) {
set_role_app(&pool).await;
let id = seed_entry(&pool).await;
let err = sqlx::query("UPDATE time_entries SET duration_minutes = 999 WHERE id = $1")
.bind(id).execute(&pool).await.unwrap_err();
assert!(format!("{err}").contains("permission denied"));
}
#[sqlx::test]
async fn delete_blocked_at_grant(pool: sqlx::PgPool) {
set_role_app(&pool).await;
let id = seed_entry(&pool).await;
let err = sqlx::query("DELETE FROM time_entries WHERE id = $1").bind(id).execute(&pool).await.unwrap_err();
assert!(format!("{err}").contains("permission denied"));
}
// services/time/tests/correction_acyclic_test.rs
#[sqlx::test]
async fn direct_cycle_rejected(pool: sqlx::PgPool) {
let a = seed_entry(&pool).await;
let b = correct(&pool, a, /*..*/).await;
// Now attempt to correct b with correction_to = a → would form A→B→A cycle.
let err = insert_with_correction_to(&pool, /*new_id=*/Uuid::new_v4(), /*correction_to=*/a).await.unwrap_err();
assert!(format!("{err}").contains("prior_row_already_corrected"));
// (chain topology check fires first; cycle check fires for self-targeting case)
}
#[sqlx::test]
async fn self_reference_cycle_rejected(pool: sqlx::PgPool) {
let id = Uuid::new_v4();
let err = insert_self_referencing(&pool, id).await.unwrap_err();
assert!(format!("{err}").contains("correction_cycle_detected"));
}
// services/time/tests/current_view_test.rs
#[sqlx::test]
async fn corrected_rows_omitted_from_current_view(pool: sqlx::PgPool) {
let a = seed_entry(&pool).await;
let b = correct(&pool, a, /* new duration */).await;
let rows: Vec<Uuid> = sqlx::query_scalar("SELECT id FROM current_time_entries_view WHERE id IN ($1, $2)")
.bind(a).bind(b).fetch_all(&pool).await.unwrap();
assert_eq!(rows, vec![b]);
}
// services/time/tests/correction_chain_test.rs
#[sqlx::test]
async fn entry_chain_walker_returns_oldest_first(pool: sqlx::PgPool) {
let a = seed_entry(&pool).await;
let b = correct(&pool, a, ()).await;
let c = correct(&pool, b, ()).await;
let chain: Vec<Uuid> = sqlx::query_scalar("SELECT entry_chain_walker($1)").bind(c).fetch_all(&pool).await.unwrap();
assert_eq!(chain, vec![a, b, c]);
}
§6 — Implementation skeleton
(API contract above is the skeleton; cycle/chain triggers are in §3.1; chain walker in §3.2.)
§7 — Dependencies
Upstream:
- TASK-AUTH-003 — RLS enforcement; same
current_setting('auth.tenant_id')pattern. - TASK-AUTH-101 — RBAC catalogue;
Resource::TimeEntry + Action::Write/Readmatrix entry.
Downstream (8 placeholders):
- TASK-TIME-002 — timer start/stop UI (creates entries via this task's API).
- TASK-TIME-003 — manual entry form + VN Labour Code cap validation.
- TASK-TIME-005 — billable cascade (populates
billable+rate_card_snapshot). - TASK-TIME-006 — weekly approval flow (transitions
entry_status). - TASK-TIME-007 — VN Labour Code Art. 107 OT cap hard-block at write.
- TASK-TIME-009 — per-cycle billable rollup → INV.
- TASK-HR-008 — performance signal aggregator (read-only consumer).
- TASK-RES-001 — capacity-vs-demand matrix (joins on member × time).
Cross-module:
- TASK-AI-003 — memory audit bridge; receives
time.entry_recorded,time.entry_corrected. - TASK-PROJ-001 — issue schema;
issue_idFK target. - TASK-PROJ-005 — rate card schema;
rate_card_snapshotsource. - TASK-MEMORY-111 — PII detection for
descriptionscrubbing.
§8 — Example payloads
8.1 — POST /v1/time/entries request
{
"member_subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"engagement_id": "e1234567-1234-1234-1234-123456789012",
"issue_id": "i1234567-1234-1234-1234-123456789012",
"ts_start": "2026-05-16T09:00:00Z",
"duration_minutes": 90,
"entry_kind": "regular",
"entry_currency": "VND",
"description": "Worked on TASK-AUTH-101 spec review"
}
8.2 — 201 CREATED response
{
"id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
"tenant_id": "5e8f1d2a-...",
"member_subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"engagement_id": "e1234567-1234-1234-1234-123456789012",
"issue_id": "i1234567-1234-1234-1234-123456789012",
"ts_start": "2026-05-16T09:00:00Z",
"duration_minutes": 90,
"entry_kind": "regular",
"entry_status": "draft",
"billable": false,
"rate_card_snapshot": {},
"entry_currency": "VND",
"description": "Worked on TASK-AUTH-101 spec review",
"correction_to": null,
"created_at": "2026-05-16T09:01:00Z",
"created_by_subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"
}
8.3 — POST correct request
{ "duration_minutes": 105, "description": "Worked on TASK-AUTH-101 spec review — corrected: forgot to add 15min lunch overlap" }
8.4 — time.entry_recorded memory row
{
"kind": "time.entry_recorded",
"tenant_id": "5e8f1d2a-...",
"entry_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
"member_subject_id_hash16": "9b1deb4d3b7d4bad",
"engagement_id": "e1234567-1234-1234-1234-123456789012",
"issue_id": "i1234567-1234-1234-1234-123456789012",
"duration_minutes": 90,
"entry_kind": "regular",
"entry_status": "draft",
"billable": false,
"entry_currency": "VND",
"description_scrubbed": "Worked on TASK-AUTH-101 spec review",
"ts_start": "2026-05-16T09:00:00Z",
"ts_ns_recorded": 1747920731000000000
}
8.5 — time.entry_corrected memory row
{
"kind": "time.entry_corrected",
"tenant_id": "5e8f1d2a-...",
"entry_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8N",
"correction_to": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
"member_subject_id_hash16": "9b1deb4d3b7d4bad",
"engagement_id": "e1234567-1234-1234-1234-123456789012",
"issue_id": "i1234567-1234-1234-1234-123456789012",
"duration_minutes_old": 90,
"duration_minutes_new": 105,
"ts_ns_recorded": 1747921000000000000
}
§9 — Open questions
Deferred:
- Standalone-engagement entries (no issue_id) — slice 2; some non-billable internal time has no specific issue.
- Holiday-list driving entry_kind defaulting — TASK-TIME-007 ships the holiday table.
- VN Labour Code OT cap enforcement — TASK-TIME-007 (this task allows the kind = overtime; the cap is TASK-TIME-007's gate).
- Billable cascade computation — TASK-TIME-005.
- Approval flow transitions — TASK-TIME-006.
- Per-cycle rollup emit to INV — TASK-TIME-009.
- Time-entry edit UI — TASK-TIME-002 + TASK-TIME-003.
All other questions resolved.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| UPDATE on time_entries | SQL grant REVOKE UPDATE | Permission denied at DB | None — designed |
| DELETE on time_entries | SQL grant REVOKE DELETE | Permission denied at DB | None — designed |
| Duration < 1 minute | DB CHECK + handler validation | 400 + duration_out_of_range | Caller fixes |
| Duration > 1440 minutes | DB CHECK + handler validation | 400 + duration_out_of_range | Split into multiple entries |
| ts_start in future > 5 min | Handler validation | 400 + ts_start_in_future | Use current time |
| Two correctors of same prior | prior_row_already_corrected trigger | 400 + error | Correct the chain head instead |
| A → B → A cycle | correction_cycle_detected trigger | 400 + error | Re-do correction without cycle |
| Chain > 100 rows deep | correction_chain_too_deep trigger | 400 + error | Investigate workflow producing deep chains |
| Cross-tenant correction | correction_cross_scope trigger | 400 + error | New entry under correct tenant |
| Cross-engagement correction | correction_cross_scope trigger | 400 + error | Revert + new entry under correct engagement (slice 2 reverts) |
| Cross-issue correction | same | same | same |
| Cross-member correction | same | same | same |
correction_to references non-existent | correction_target_missing trigger | 400 + error | Verify prior id |
| Description > 1000 chars | Handler validation | 400 + description_too_long | Shorten or move detail to PROJ comment |
entry_currency not 3 uppercase | DB CHECK | INSERT fails | Use valid ISO-4217 |
| RLS bypass attempt | RLS USING predicate | 0 rows returned | None — designed |
member_subject_id deleted while entries exist | FK ON DELETE RESTRICT | DELETE auth.subjects fails | Use HR termination flow |
| memory row emit fails mid-transaction | Outer rollback | 500 audit_failed; entry not persisted | memory_writer diagnosis |
| Idempotency-Key reused with different body | Idempotency layer | 409 idempotency_key_reuse | New key |
current_time_entries_view slow on 10K-row tenant | Perf test | Sev-3 | Verify correction_to index health |
entry_chain_walker exceeds depth 100 | Function returns up to 100 rows | Truncated chain returned | Alarmable |
| OTel span attribute missing | otel_attrs_test | CI fails | Fix span builder |
entry_kind enum drift (someone adds night_shift) | Closed-enum test | CI fails | ADR + migration + code together |
entry_status enum drift | same | CI fails | same |
rate_card_snapshot mutated after row write | Append-only enforcement | Permission denied | None — designed |
| Race: concurrent corrections to same prior | First INSERT wins; second fails prior_row_already_corrected | Second caller sees 400 | Caller re-fetches and retries |
description contains PII not scrubbed | memory PII test | Pre-commit failure | Add PII rule |
| Chain walker called on entry id from different tenant | RLS filters out | Returns 0 rows | None — designed |
| Subject deleted but cleanup ordering wrong | FK ON DELETE RESTRICT | Migration fails | Restore subject first |
| Daily aggregation exceeds 24h via many sub-row entries | This task's per-row cap is 24h; aggregate cap is TASK-TIME-007 | Out-of-scope here | TASK-TIME-007 |
| Time-zone confusion: ts_start interpreted in local time | DB stores TIMESTAMPTZ — always UTC | None | Document for UI implementers |
| Description-edit on existing entry attempted | Handler omits from PATCH — there is no PATCH | Use correct endpoint | None — designed |
billable field set in create request | Handler ignores (slice 1; cascade sets) | Default false applied | TASK-TIME-005 takes over |
§11 — Implementation notes
- Append-only is the design assertion — every other invariant rests on it. SQL grant enforcement (not handler discipline) makes accidental UPDATE impossible.
- Correction chains, not trees — operators occasionally want "two parallel corrections" (e.g. "what if duration was 90, what if it was 105"). The chain rule forces them to pick one. The 1% of cases needing branching go through reverted-status + new entry.
- Cycle detection at trigger, not application — the trigger sees the actual DB state including the new row; application-layer checks would race with concurrent inserts.
current_time_entries_viewis the default query target — downstream code shouldSELECT * FROM current_time_entries_view WHERE engagement_id = $1rather than the raw table. The index oncorrection_to WHERE correction_to IS NOT NULLkeeps the NOT IN cheap.rate_card_snapshot JSONBnot FK — the rate card has nested shape (per-role rates, member overrides, time-of-day adjustments); FK to a rate-card-version table would cascade unwantedly. JSONB snapshot is the audit-grade pattern.entry_currency CHAR(3)not VARCHAR — fixed-width for ISO-4217 codes; the CHECK constraint enforces uppercase 3-letter shape.- Chain walker max-depth 100 — safety floor; production chains > 5 are rare. Alarmable.
- DEFERRABLE INITIALLY IMMEDIATE on self-FK — Postgres-specific quirk. Without it, the cycle-detection trigger fires before the new row's FK validates; with it, FK validation defers to statement end (which is fine because trigger handles cycle).
created_by_subject_id≠member_subject_idfor AM-on-behalf-of entries — both fields are immutable; the memory row carries both hash16.- PII scrubbing applies to
description— operators may inadvertently log "called Person A re: their salary inquiry"; TASK-MEMORY-111 rules strip. entry_statusdefaultdraft— entries start in draft until submitted; TASK-TIME-006 ships the transition handlers. Reading drafts is allowed (caller can review own draft).billabledefault false — conservative. Cascade (TASK-TIME-005) updates to true via correction (creating a new row with billable=true) at row-creation time, before the row is committed. Slice-1 entries are non-billable by default.- Per-row 24h cap protects daily aggregator — TASK-TIME-007's daily-cap logic operates on rows; if one row could be 100h, the daily cap math underestimates. Per-row cap closes that hole.
- Idempotency-Key applies to create only — corrections are not idempotent in the same sense (the prior row determines the new row's contents); retrying a correction creates a new chain entry. Operators should be aware.
- PROJ issue FK is a logical foreign key — declared in §1 #1 but not enforced as SQL FK at slice 1 (cross-service FK is operationally complex). TASK-PROJ-001's data integrity is trusted; if an issue is deleted, time entries pointing at it become orphaned (still queryable, just decorative).
engagement_idsimilarly soft-FK — same reason; TASK-PROJ-005 owns the engagements table.- No
updated_at— append-only means there's no update;created_atis the only timestamp. - The cycle-detection trigger walks depth 100 max — beyond that, raises
correction_chain_too_deep. Production chains hitting this are bugs. entry_chain_walkerreturns oldest-first — that's the natural temporal order ("here's how this entry evolved"). UIs may render newest-first if appropriate.- The view's
NOT INquery — Postgres optimises with thecorrection_to_idxpartial index; performance is bounded by correction count (~5% of rows). - REVOKE applies to
cyberos_approle only — superuser + migration role can mutate (for backups, manual repair, etc.). Production app code uses cyberos_app. entry_kindandentry_statusare orthogonal axes — workflow status (draft → submitted → approved) is independent of working-time classification (regular vs overtime).descriptionis NOT searchable via full-text at slice 1 — that's a task-TIME-2xx ambition. Slice 1 stores as TEXT.
End of TASK-TIME-001.