Task — engineering-spec@1

"DOC expiry alert cascade — 90/30/7-day notifications to parties + CLO with deduplication and snooze support"

draftTASK-DOC-008
module doc · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-DOC-007 · blocks none

§1 — Description (BCP-14 normative)

The DOC service MUST ship expiry alert cascade at services/doc/src/expiry/ triggered daily, sending at 90/30/7-day thresholds via TASK-EMAIL-009 + TASK-CHAT-005, deduplicated, snooze-able, 4 memory audit kinds.

  1. MUST schedule daily scan at 06:00 tenant_tz per DEC-1720 via TASK-MCP-007 cron.
  1. MUST validate alert_threshold against closed enum per DEC-1721.
  1. MUST scan at scanner.rs::scan(tenant, today):
  1. MUST dedup per DEC-1722 via UNIQUE constraint — skip if (document_id, threshold) row exists.
  1. MUST dispatch at notifier.rs::notify(doc, threshold, parties):
  1. MUST define table at migration 0003: ```sql CREATE TABLE doc_expiry_alerts ( alert_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, document_id UUID NOT NULL, threshold TEXT NOT NULL CHECK (threshold IN ('d90','d30','d7')), sent_at TIMESTAMPTZ NOT NULL DEFAULT now(), recipients_count INT NOT NULL, trace_id CHAR(32), UNIQUE (tenant_id, document_id, threshold) ); ALTER TABLE doc_expiry_alerts ENABLE ROW LEVEL SECURITY; CREATE POLICY alerts_rls ON doc_expiry_alerts USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON doc_expiry_alerts FROM cyberos_app;

CREATE TABLE doc_expiry_snoozes ( snooze_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, document_id UUID NOT NULL, snoozed_until TIMESTAMPTZ NOT NULL, reason TEXT, snoozed_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, document_id) ); ALTER TABLE doc_expiry_snoozes ENABLE ROW LEVEL SECURITY; CREATE POLICY snoozes_rls ON doc_expiry_snoozes USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); GRANT DELETE ON doc_expiry_snoozes TO cyberos_app; -- CLO can un-snooze ```

  1. MUST support CLO snooze per DEC-1724: ``text POST /v1/doc/documents/{id}/snooze-alerts body: {until, reason} DELETE /v1/doc/documents/{id}/snooze-alerts (un-snooze) ``
  1. MUST emit 4 memory audit kinds per DEC-1725. PII per TASK-MEMORY-111: title hashed; threshold + counts ok.
  1. MUST thread trace_id from cron → scanner → notifier → audit.
  1. MUST NOT send duplicate alert per DEC-1722.
  1. MUST NOT ignore active snooze per DEC-1724.

§2 — Why this design

Why 90/30/7 (DEC-1720)? Industry standard escalation cadence; gives renewal negotiation runway.

Why dedup (DEC-1722)? Daily scan re-checks; without UNIQUE, same threshold fires repeatedly.

Why snooze (DEC-1724)? Active renewal negotiation makes alerts noise; CLO suppresses until they need them again.

Why both email + chat (DEC-1723)? Parties (external) need email; CLO (internal) needs chat.


§3 — API contract

POST   /v1/doc/documents/{id}/snooze-alerts   body: {until: ISO8601, reason}
DELETE /v1/doc/documents/{id}/snooze-alerts
GET    /v1/doc/expiry-alerts                  (list sent + scheduled)
POST   /v1/doc/expiry-scan                    (CLO manual trigger)

§4 — Acceptance criteria

  1. Daily scan at 06:00 tenant_tz. 2. 3-threshold enum + cardinality test. 3. Dedup via UNIQUE constraint. 4. Snooze suppresses all thresholds until snoozed_until. 5. Un-snooze (DELETE) re-enables. 6. Email to each party with email field. 7. Chat to tenant CLO. 8. 4 memory audit kinds emitted. 9. PII scrubbed (title SHA256). 10. RLS denies cross-tenant. 11. Trace_id preserved. 12. Expired docs excluded from scan. 13. Terminated docs excluded from scan. 14. Boundary day exact match (90d = scan day matches). 15. CLO manual trigger via POST. 16. Append-only alerts table. 17. Snooze can be re-set (UNIQUE on doc_id replaces). 18. Failed send → status=failed; retry. 19. High-volume tenant (1000+ docs/day) handled. 20. No alert if expiry=null (legacy docs).

§5 — Verification

#[tokio::test]
async fn alerts_at_exact_thresholds() {
    let ctx = TestContext::doc_expires_in_days(90).await;
    ctx.run_expiry_scan(today()).await;
    let alerts = ctx.fetch_alerts(ctx.doc_id).await;
    assert_eq!(alerts.len(), 1);
    assert_eq!(alerts[0].threshold, "d90");
}

#[tokio::test]
async fn dedup_skips_second_scan() {
    let ctx = TestContext::doc_expires_in_days(90).await;
    ctx.run_expiry_scan(today()).await;
    ctx.run_expiry_scan(today()).await;  // same day, same threshold
    let alerts = ctx.fetch_alerts(ctx.doc_id).await;
    assert_eq!(alerts.len(), 1);
}

#[tokio::test]
async fn snooze_suppresses_alerts() {
    let ctx = TestContext::doc_expires_in_days(30).await;
    ctx.snooze_alerts(ctx.doc_id, today() + Duration::days(60)).await;
    ctx.run_expiry_scan(today()).await;
    let alerts = ctx.fetch_alerts(ctx.doc_id).await;
    assert_eq!(alerts.len(), 0);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-DOC-007. Cross-module: TASK-EMAIL-009 (email send), TASK-CHAT-005 (chat notify), TASK-MCP-007 (cron), TASK-AUTH-101 (CLO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Email send failsretry 3xfailed auditnext scan retries
Chat send failsretryfailed auditnext scan retries
No party emailswarnCLO chat onlyinherent
Scanner missed runlast_run checkcatch-up next bootinherent
High doc volumepaginationbatchedinherent
Snooze past expiryOKwon't alert post-expiryinherent
Duplicate snooze raceUNIQUEsecond wins (replace)inherent
Doc has no expiry_datefilterexcludedinherent
Doc status terminatedfilterexcludedinherent
Cross-tenant snoozeRLS0 rowsinherent

§11 — Implementation notes


End of TASK-DOC-008 spec.