Task — engineering-spec@1

"Blocker detector from comment stream — `blocked by` parser + dwell-time monitor + CUO Notify on stale blockers"

doneTASK-PROJ-011
module proj · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-PROJ-003, TASK-CUO-101 · blocks none

§1 — Description (BCP-14 normative)

The blocker detector MUST parse Issue comments for "blocked by" patterns and monitor dwell time. The contract:

  1. MUST parse comments on insert/edit (TASK-PROJ-003 CRDT events) for the regex (?i)\b(block(?:ed|s|ing))\s+by[:\s]+([^\n,;.]+). Captures: keyword (blocked|blocks|blocking) + target reference.
  2. MUST classify target as:
  1. MUST record blockers in blocker_state table: (issue_id, comment_id, blocker_kind, target_ref, detected_at, detected_by, resolved_at, resolved_by_comment_id, tenant_id). Active = resolved_at IS NULL.
  2. MUST auto-resolve a blocker when:
  1. MUST compute dwell_business_days since detected_at, excluding Sat/Sun and configured VN public holidays (CYBEROS_HOLIDAYS_VN env: comma-separated YYYY-MM-DD). ≥ 3 business days → mark stale.
  2. MUST emit memory audit rows:
  1. MUST notify via TASK-CUO-101 webhook on stale events (placeholder URL https://cuo-internal.cyberos.world/notify; OPSEC: not exposed publicly). Payload: {tenant_id, issue_id, blocker_id, target_ref, dwell_business_days, assignee_subject_id}.
  2. MUST run hourly dwell scan; on-demand via cyberos blocker scan [--tenant <uuid>] CLI.
  3. MUST RLS-enforce.
  4. MUST emit OTel metrics:
  1. MUST redact PII from target_ref (especially FreeText kind) before memory audit emit via TASK-MEMORY-111 ruleset.
  2. MUST support manual operator override: POST /api/proj/blockers/:id/resolve with reason; emits proj.blocker_resolved with auto_reason="manual".
  3. MUST support manual creation: POST /api/proj/issues/:id/blockers for cases where parser missed (e.g. blocker discussed verbally). Tracks detected_by_subject_id as the operator.
  4. MUST track escalation: after stale_notified_at + 7 business days without resolution, emit proj.blocker_escalated SEV-2 + notify tenant admin (in addition to assignee).
  5. MUST support per-tenant dwell threshold override: cyberos_proj_tenant_settings.blocker_stale_business_days (default 3); SLA-heavy tenants may want stricter (1d).
  6. MUST include cuo_notify_attempts INT + last_cuo_notify_at columns for tracking notification reliability; if 3 consecutive notification failures, SEV-1 alert on CUO health.
  7. MUST support a "dependency cycle" detection: if Issue A is blocked by Issue B AND Issue B is blocked by Issue A, emit proj.blocker_cycle_detected SEV-2; do NOT auto-break.
  8. MUST include blocker_age_distribution histogram in metrics — bucketed by business days (0-1, 1-3, 3-7, 7-14, 14+).
  9. MUST support a "snooze" action: operator can snooze a blocker's stale notification for N days (max 14); snoozed_until column; scan skips snoozed blockers.
  10. MUST include mentioned_users (parsed @-mentions from the blocker comment) in audit payload — useful for "who was tagged as the unblock owner."
  11. MUST emit proj.blocker_resolved_diff audit row containing the comment text that triggered auto-resolution (e.g. the "unblocked" comment). Operators reviewing resolution can see the context.
  12. MUST support cross-engagement blocker references: Issue X (engagement A) blocked by Issue Y (engagement B) — link is valid if caller has read scope on both engagements.

§2 — Why this design (rationale for humans)

Why comment-stream parsing (DEC-320)? Operators already write "blocked by X" in comments; structured-label workflows (drag to "Blocked" column) are friction. Parsing existing prose = zero friction adoption.

Why three target classes (§1 #2)? IssueMention is the strongest signal (machine-resolvable). MemoryPath catches memory-anchored decisions. FreeText handles human context ("blocked by customer's response") — can't auto-resolve but still timed.

Why business days not calendar (§1 #5)? A blocker filed Friday afternoon shouldn't ping Monday morning as "3 days stale." Business-day math + VN holidays = culturally-correct.

Why CUO Notify routing (§1 #7, DEC-322)? Direct emails create email-noise that operators filter to a forgotten folder. CUO is the unified inbox; routing through it makes blockers visible alongside other surfaces.

Why hourly scan (§1 #8)? Dwell is wall-clock-driven; hourly granularity is the right resolution for "3 business days = 72 working hours." More-frequent burns CPU; less-frequent delays signal.

Why audit per state transition (§1 #6)? Operators investigating "did this issue ever get blocked" need first-class events. Generic comment-history doesn't separate "blocked" from "everything else."

Why redact target_ref (§1 #11)? FreeText blockers ("waiting on alice@x.com response") embed PII. Years of accumulated audit = PII bloat.

Why manual resolve (§1 #12)? Auto-resolve covers known patterns; real-world blockers resolve in ways the parser misses (operator agrees verbally to unblock). Manual override prevents indefinite-stale.

Why manual create (§1 #13)? Operators discussing blockers in meetings (not comments) want to record them in PROJ for tracking. Manual create closes that gap.

Why escalation at 7d (§1 #14)? 3-day stale = assignee should fix; 10-day total = needs management attention. Escalation forces visibility.

Why per-tenant dwell threshold (§1 #15)? Enterprise SLAs vary; some clients want next-day escalation, others week.

Why CUO notification health tracking (§1 #16)? Silent notification failures = operators don't know blockers exist. Tracking + SEV-1 alert ensures detection.

Why cycle detection (§1 #17)? A↔B blocker cycle = both stuck indefinitely; auto-break would lose data. Detection alerts operator to intervene.

Why age distribution histogram (§1 #18)? Operators tracking team health see "we have 5 blockers in 7-14 day range" — actionable.

Why snooze (§1 #19)? Some blockers are legitimately long-running (vendor contract negotiation); snooze prevents nagging.

Why mentioned_users in payload (§1 #20)? Comment "blocked by Bob (@bob): waiting for design review" tags Bob as the unblock owner. Capturing the @-mention provides actionability.

Why blocker_resolved_diff (§1 #21)? Operators reviewing the auto-resolution see "Alice replied 'unblocked because customer approved'" — full context.

Why cross-engagement blockers (§1 #22)? Real engagements have dependencies across project boundaries; restricting to same-engagement misses these.


§3 — API contract

Migration

-- services/proj-sync/migrations/0011_blocker_state.sql

CREATE TABLE blocker_state (
    id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    issue_id             UUID NOT NULL,
    comment_id           TEXT NOT NULL,
    blocker_kind         TEXT NOT NULL CHECK (blocker_kind IN ('issue_mention','memory_path','free_text')),
    target_ref           TEXT NOT NULL,
    detected_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    detected_by_subject_id UUID NOT NULL,
    resolved_at          TIMESTAMPTZ,
    resolved_by_comment_id TEXT,
    stale_notified_at    TIMESTAMPTZ,
    tenant_id            UUID NOT NULL
);
CREATE INDEX idx_blocker_active ON blocker_state (issue_id) WHERE resolved_at IS NULL;
CREATE INDEX idx_blocker_dwell  ON blocker_state (detected_at) WHERE resolved_at IS NULL AND stale_notified_at IS NULL;

ALTER TABLE blocker_state ENABLE ROW LEVEL SECURITY;
CREATE POLICY blocker_tenant_iso ON blocker_state
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

Rust API

// services/proj-sync/src/blocker/mod.rs
use serde::Serialize;

#[derive(Clone, Copy, Debug, Serialize, sqlx::Type, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "TEXT", rename_all = "snake_case")]
pub enum BlockerKind { IssueMention, MemoryPath, FreeText }

#[derive(Clone, Debug, Serialize)]
pub struct Blocker {
    pub id:                     uuid::Uuid,
    pub issue_id:               uuid::Uuid,
    pub comment_id:             String,
    pub blocker_kind:           BlockerKind,
    pub target_ref:             String,
    pub detected_at:            chrono::DateTime<chrono::Utc>,
    pub resolved_at:            Option<chrono::DateTime<chrono::Utc>>,
    pub dwell_business_days:    Option<i32>,
}

Parser

// services/proj-sync/src/blocker/parser.rs
use crate::blocker::BlockerKind;
use once_cell::sync::Lazy;
use regex::Regex;

static BLOCKER_RX: Lazy<Regex> = Lazy::new(||
    Regex::new(r"(?i)\b(?:block(?:ed|s|ing))\s+by[:\s]+([^\n,;.]+)").unwrap());
static ISSUE_REF_RX: Lazy<Regex> = Lazy::new(||
    Regex::new(r"^\s*#([a-zA-Z0-9-]+)\s*$").unwrap());
static MEMORY_REF_RX: Lazy<Regex> = Lazy::new(||
    Regex::new(r"^\s*(memories/[^\s]+)\s*$").unwrap());

#[derive(Clone, Debug)]
pub struct ParsedBlocker {
    pub kind:        BlockerKind,
    pub target_ref:  String,
}

pub fn parse(comment_body: &str) -> Vec<ParsedBlocker> {
    BLOCKER_RX.captures_iter(comment_body)
        .filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string()))
        .map(|raw| {
            let kind = if ISSUE_REF_RX.is_match(&raw) { BlockerKind::IssueMention }
                       else if MEMORY_REF_RX.is_match(&raw) { BlockerKind::MemoryPath }
                       else { BlockerKind::FreeText };
            ParsedBlocker { kind, target_ref: raw }
        })
        .collect()
}

Dwell scan

// services/proj-sync/src/blocker/dwell.rs
pub async fn scan_stale(pool: &sqlx::PgPool, tenant_id: uuid::Uuid) -> anyhow::Result<i32> {
    sqlx::query("SELECT set_config('app.tenant_id', $1, true)")
        .bind(tenant_id.to_string()).execute(pool).await?;

    let active: Vec<(uuid::Uuid, uuid::Uuid, chrono::DateTime<chrono::Utc>, String)> = sqlx::query_as(
        "SELECT id, issue_id, detected_at, target_ref
         FROM blocker_state
         WHERE resolved_at IS NULL AND stale_notified_at IS NULL"
    ).fetch_all(pool).await?;

    let now = chrono::Utc::now();
    let mut notified = 0;
    for (id, issue_id, detected_at, target_ref) in active {
        let dwell = business_days_between(detected_at, now);
        if dwell >= 3 {
            sqlx::query("UPDATE blocker_state SET stale_notified_at = NOW() WHERE id = $1")
                .bind(id).execute(pool).await?;
            cuo_notify(tenant_id, issue_id, id, &target_ref, dwell).await?;
            emit_memory_row("proj.blocker_stale", serde_json::json!({
                "blocker_id": id, "issue_id": issue_id,
                "target_ref": target_ref, "dwell_business_days": dwell,
            })).await;
            metrics::counter!("proj_blockers_stale_total").increment(1);
            notified += 1;
        }
    }
    Ok(notified)
}

fn business_days_between(start: chrono::DateTime<chrono::Utc>, end: chrono::DateTime<chrono::Utc>) -> i32 {
    use chrono::Datelike;
    let mut days = 0;
    let mut d = start.date_naive();
    let end_d = end.date_naive();
    let holidays = parse_vn_holidays_env();
    while d < end_d {
        d = d + chrono::Duration::days(1);
        if matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) { continue; }
        if holidays.contains(&d) { continue; }
        days += 1;
    }
    days
}

fn parse_vn_holidays_env() -> std::collections::HashSet<chrono::NaiveDate> {
    std::env::var("CYBEROS_HOLIDAYS_VN").ok()
        .map(|s| s.split(',').filter_map(|d| d.trim().parse().ok()).collect())
        .unwrap_or_default()
}

§4 — Acceptance criteria

  1. Parse "blocked by #abc123" → 1 ParsedBlocker, kind=IssueMention, target_ref="#abc123".
  2. Parse "blocks: memories/x/y.md" → kind=MemoryPath.
  3. Parse "blocked by customer feedback" → kind=FreeText.
  4. Multiple blockers in one comment — "blocked by #X, blocked by #Y" → 2 parsed.
  5. Comment without blocker prose → 0 parsed.
  6. Insert detected blocker into blocker_state — on comment insert → row appears; proj.blocker_detected audit row.
  7. Auto-resolve on target Done — referenced issue transitions to Done → blocker row resolved_at set; proj.blocker_resolved row.
  8. Auto-resolve on unblocked comment — followup comment "unblocked" → resolved.
  9. Auto-resolve on issue cancellation — block-owning issue cancelled → all its blockers resolved (moot).
  10. Dwell scan ≥ 3 business days — fixture: detected_at = Mon 09:00; current = Thu 11:00 → dwell = 3; stale_notified_at set; CUO webhook fired.
  11. Weekend doesn't count — Fri detected → Mon = dwell 1 (not 3).
  12. VN holiday env honoured — config holiday 2026-09-02; Tue–Wed straddles it → dwell skips that day.
  13. Stale notification idempotent — second scan after notification → no duplicate webhook; metric unchanged.
  14. memory audit on detect/resolve/stale — all 3 kinds emitted at correct events.
  15. CUO Notify webhook payload schema — payload matches §1 #7 spec.
  16. RLS tenant isolation — tenant A's blockers invisible to tenant B.
  17. Gauge proj_blockers_active — accurate count of unresolved blockers per tenant.
  18. target_ref PII redacted in audit — FreeText "waiting on alice@x.com" → audit row contains "<EMAIL>" (AC for §1 #11).
  19. Manual resolve emits proj.blocker_resolved with reason=manual — POST /:id/resolve → audit row (AC for §1 #12).
  20. Manual create works — POST /blockers with fields → row inserted; detected_by_subject_id = operator (AC for §1 #13).
  21. Escalation at 10 business days — stale + 7 more business days → proj.blocker_escalated SEV-2 (AC for §1 #14).
  22. Per-tenant dwell override — set blocker_stale_business_days=1; blocker after 1 day flagged stale (AC for §1 #15).
  23. CUO notification failure tracking — 3 consecutive failures → SEV-1 on CUO health (AC for §1 #16).
  24. Cycle detected — A blocked by B, B blocked by A → proj.blocker_cycle_detected SEV-2 (AC for §1 #17).
  25. Age distribution histogram populated — distinct blocker ages across buckets (AC for §1 #18).
  26. Snooze skips stale scan — operator snoozes for 7d; scan during snooze window → no notification (AC for §1 #19).
  27. mentioned_users captured — comment "blocked by @bob waiting on review" → audit payload mentioned_users=["bob"] (AC for §1 #20).
  28. blocker_resolved_diff contains comment — auto-resolve via "unblocked" comment → audit row contains comment text (AC for §1 #21).
  29. Cross-engagement blocker validated — blocker referring to another engagement's issue allowed only with read scope (AC for §1 #22).

§5 — Verification

#[test]
fn parses_issue_mention() {
    let p = parse("This is blocked by #ABC-123 right now.");
    assert_eq!(p.len(), 1);
    assert_eq!(p[0].kind, BlockerKind::IssueMention);
    assert_eq!(p[0].target_ref, "#ABC-123");
}

#[test]
fn parses_memory_path() {
    let p = parse("blocks: memories/projects/cyberos/decisions/DEC-300.md");
    assert_eq!(p[0].kind, BlockerKind::MemoryPath);
}

#[test]
fn parses_free_text() {
    let p = parse("blocked by customer response");
    assert_eq!(p[0].kind, BlockerKind::FreeText);
}

#[test]
fn parses_multiple_per_comment() {
    let p = parse("blocked by #X, blocked by #Y. blocking #Z");
    assert_eq!(p.len(), 3);
}

#[test]
fn weekend_skipped() {
    let mon = chrono::DateTime::parse_from_rfc3339("2026-05-11T09:00:00Z").unwrap().with_timezone(&chrono::Utc);
    let next_mon = chrono::DateTime::parse_from_rfc3339("2026-05-18T09:00:00Z").unwrap().with_timezone(&chrono::Utc);
    // Mon-Mon = 5 business days
    assert_eq!(business_days_between(mon, next_mon), 5);
}

#[tokio::test]
async fn auto_resolve_on_target_done() {
    let env = TestEnv::new().await;
    let target = env.create_issue().await;
    let blocker_issue = env.create_issue_with_blocker_comment(target).await;
    apply_transition(&env.pool, target, IssueStatus::Done, env.alice(), None).await.unwrap();
    let blk: Blocker = env.read_blocker(blocker_issue).await;
    assert!(blk.resolved_at.is_some());
}

#[tokio::test]
async fn stale_notifies_at_three_business_days() {
    let env = TestEnv::with_paused_time().await;
    let blocker_issue = env.create_issue_with_blocker_comment_at("monday 9am").await;
    env.advance_to("thursday 9am").await;
    let notified = scan_stale(&env.pool, env.tenant_id()).await.unwrap();
    assert_eq!(notified, 1);
    let alert = env.cuo.latest_notification().await;
    assert_eq!(alert["dwell_business_days"], 3);
}

§6 — Implementation skeleton

(API + DB above.)


§7 — Dependencies


§8 — Example payloads

{
  "kind": "proj.blocker_detected",
  "payload": {
    "blocker_id": "blk-...",
    "issue_id": "iss-...",
    "comment_id": "cmt-...",
    "blocker_kind": "issue_mention",
    "target_ref": "#ABC-123",
    "detected_at_ns": 1747407137483000000,
    "trace_id": "0af..."
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Comment parse regex fails on edge caseunit test fixtures catchFalse negativeOperator files bug
Target issue mentioned doesn't existresolution lookup ErrBlocker stored with kind=FreeText (degraded)Operator inspects
CUO webhook timeoutretry once; failsev-3 alarm via TASK-OBS-007Operator restores CUO
Holiday env malformedparse ErrFalls back to weekend-only skipOperator fixes env
Stale_notified_at race (two scans concurrent)UPDATE WHERE NULL → second sees row already notifiedNo duplicateNone
Issue transitions but blocker resolve failssqlx ErrBlocker stays active; next scan retriesAcceptable
Comment edited to remove "blocked by"edit not handled in slice 3Original blocker staysSlice 4+
10K active blockers in one tenantscan slowsev-2 latency alarmSlice 4+ paginate
RLS bypassRLS policy0 rowsNone
Concurrent unblocked comment + Done transitionboth resolveIdempotent UPDATENone
MemoryEmit failsrow created; audit lostsev-2Operator restores
Free-text blocker with secretsredacted via TASK-MEMORY-111 at audit emitSafeNone
Manual resolve without reason400NoneCaller
Manual create with invalid targetfalls back to FreeTextNoneNone
Escalation fires before assignee fixes (race)dedup at OBSNoneNone
Per-tenant dwell = 0every blocker immediately staleas-configuredOperator
CUO notify down 3+ daysSEV-1 alert; blockers accumulateoperator restoresNone
Cycle detection misses indirect cycle (A→B→C→A)DFS depth 100 coversdetectedNone
Histogram bucket out of range (>14d)overflow bucket countsNoneNone
Snooze longer than allowedrejected400Caller
Snoozed blocker resolves naturallycancel-on-resolve cleans snoozeNoneNone
Mention contains non-existent userstored verbatim; resolver returns nullNoneNone
Resolved_diff > 5KBtruncated to 5KBNoneNone
Cross-engagement target without scope403NoneCaller
Engagement archived mid-blockerresolve_on_archive auto-resolvesproj.blocker_resolvedNone
Concurrent manual + auto resolvelast wins; idempotentNoneNone
Two operators snooze same blockerlatest winsNoneNone
Snooze on already-resolved blockerrejected409Caller
Mentioned_users with > 50 mentionstruncated to 50 + warningNoneNone

§11 — Implementation notes


End of TASK-PROJ-011.