Task — engineering-spec@1

"OKR auto-progress nightly batch — resolves all KR progress_sources + updates current_value + emits drift alerts"

draftTASK-OKR-004
module okr · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-OKR-003 · blocks none

§1 — Description (BCP-14 normative)

The OKR service MUST ship auto-progress batch at services/okr/src/auto_progress/ running nightly via TASK-MCP-007 cron, resolving DSL per KR, drift detection, 5 memory audit kinds.

  1. MUST schedule daily batch at 03:00 tenant_tz per DEC-1990.
  1. MUST validate batch_run_status against closed enum per DEC-1991.
  1. MUST run at batch_runner.rs::run(tenant, run_date):
  1. MUST detect drift at drift_detector.rs::check(kr, new_value, old_value) per DEC-1992 — if abs((new-old)/old) > 0.10, emit sev-2 audit.
  1. MUST define table at migration 0004: ``sql CREATE TABLE okr_auto_progress_runs ( run_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, run_date DATE NOT NULL, status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','partial','failed')), krs_total INT NOT NULL DEFAULT 0, krs_succeeded INT NOT NULL DEFAULT 0, krs_failed INT NOT NULL DEFAULT 0, krs_drift_alerted INT NOT NULL DEFAULT 0, started_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, trace_id CHAR(32), UNIQUE (tenant_id, run_date) ); ALTER TABLE okr_auto_progress_runs ENABLE ROW LEVEL SECURITY; CREATE POLICY runs_rls ON okr_auto_progress_runs USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON okr_auto_progress_runs FROM cyberos_app; GRANT UPDATE (status, krs_total, krs_succeeded, krs_failed, krs_drift_alerted, completed_at) ON okr_auto_progress_runs TO cyberos_app; ``
  1. MUST be idempotent per DEC-1990 — UNIQUE on (tenant_id, run_date); duplicate run skipped.
  1. MUST expose endpoints: ``text POST /v1/okr/auto-progress/trigger (CEO; manual run for today) GET /v1/okr/auto-progress/runs (list) GET /v1/okr/auto-progress/runs/{id} (detail per KR) ``
  1. MUST emit 5 memory audit kinds per DEC-1994. PII per TASK-MEMORY-111: value diffs SHA-256 hashed; counts ok.
  1. MUST thread trace_id from cron → batch → resolver → audit.
  1. MUST NOT mutate prior batch run per DEC-1990 (REVOKE except status cols).
  1. MUST NOT halt batch on single failure per DEC-1993.

§2 — Why this design

Why nightly (DEC-1990)? Weekly check-ins need fresh data Monday; daily run guarantees ≤24h freshness.

Why per-KR isolation (DEC-1993)? One bad upstream module shouldn't blank entire tenant's OKR view.

Why drift alert (DEC-1992)? Large jumps usually mean data corruption (e.g. upstream bug doubled count); humans investigate.

Why idempotent (DEC-1990)? Cron retry on failure must not double-resolve.


§3 — API contract

Sample run status:

{
  "run_id": "uuid",
  "run_date": "2026-05-17",
  "status": "partial",
  "krs_total": 47,
  "krs_succeeded": 45,
  "krs_failed": 2,
  "krs_drift_alerted": 1,
  "completed_at": "2026-05-17T03:05:00Z"
}

§4 — Acceptance criteria

  1. Nightly 03:00 tenant_tz. 2. batch_run_status enum cardinality 4. 3. All active KRs with progress_source resolved. 4. Per-KR failure isolated. 5. Drift alert at >10% delta. 6. Idempotent (UNIQUE run_date). 7. 5 memory audit kinds emitted. 8. PII scrubbed (value diffs SHA256). 9. RLS denies cross-tenant. 10. CEO-only manual trigger. 11. Trace_id preserved. 12. Append-only via REVOKE except status cols. 13. status=completed when 100% success. 14. status=partial when any failure. 15. status=failed when 100% failure. 16. Cron skip if 0 active KRs. 17. Backfill via CEO trigger with run_date. 18. Run history queryable. 19. Concurrent run blocked (UNIQUE). 20. First-run no drift alert (no prior value).

§5 — Verification

#[tokio::test]
async fn batch_resolves_all_active() {
    let ctx = TestContext::with_5_active_krs().await;
    ctx.run_batch(today()).await;
    let run = ctx.fetch_latest_run().await;
    assert_eq!(run.krs_succeeded, 5);
    assert_eq!(run.status, "completed");
}

#[tokio::test]
async fn per_kr_failure_isolated() {
    let ctx = TestContext::with_5_krs_one_will_fail().await;
    ctx.run_batch(today()).await;
    let run = ctx.fetch_latest_run().await;
    assert_eq!(run.krs_succeeded, 4);
    assert_eq!(run.krs_failed, 1);
    assert_eq!(run.status, "partial");
}

#[tokio::test]
async fn drift_alert_at_15pct() {
    let ctx = TestContext::with_kr_current_100().await;
    ctx.mock_resolver_returns(115).await;
    ctx.run_batch(today()).await;
    let audits = ctx.fetch_memory_audits("okr.batch_kr_drift_alert").await;
    assert!(!audits.is_empty());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-OKR-003. Cross-module: TASK-MCP-007 (cron), TASK-AUTH-101 (CEO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Cron skippedlast_run checkcatch-up nextinherent
Resolver timeout per KRretry 1xmark KR failed; continueinherent
All KRs failaggregatestatus=failed; sev-1investigate
Concurrent batchUNIQUEsecond skippedinherent
Drift on first resolutionno priorskip alertinherent
Decimal precisionrust_decimalinherentinherent
Cross-tenant queryRLS0 rowsinherent
run_date in futurereject400use today
Manual trigger for old dateallow (backfill)inherentinherent
Mid-batch system crashresume from last succeededstatus=partialmanual retry

§11 — Implementation notes


End of TASK-OKR-004 spec.