Task — engineering-spec@1

"CRM vietnam-mst-validate skill — synchronous GDT lookup on Account write to confirm MST format + entity name match"

draftTASK-CRM-008
module crm · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-CRM-003 · blocks none

§1 — Description (BCP-14 normative)

The CRM service MUST ship vietnam-mst-validate@1 skill at services/crm/src/vn/mst_validate_skill.rs calling GDT TIN lookup on VN account writes, non-blocking result, 30d cache, 3 memory audit kinds.

  1. MUST register skill name vietnam-mst-validate@1 per DEC-1680.
  1. MUST hook into account create/update — if residency='vn-1' and mst set, invoke skill async.
  1. MUST validate mst_validation_result against closed enum per DEC-1681.
  1. MUST call GDT API at gdt_client.rs::lookup(mst) returning {registered_name, entity_type, address}.
  1. MUST compare returned name with account.name per DEC-1682 — Levenshtein distance ≤3 → confirmed; else name_mismatch.
  1. MUST cache lookup 30 days per DEC-1684 — key mst:lookup:{mst}, per-tenant namespace.
  1. MUST be non-blocking per DEC-1683 — write proceeds; validation result stored separately.
  1. MUST persist result at migration 0008: ``sql CREATE TABLE crm_mst_validations ( validation_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, account_id UUID NOT NULL, mst TEXT NOT NULL, result TEXT NOT NULL CHECK (result IN ('confirmed','name_mismatch','mst_not_found','gdt_unavailable','format_invalid')), gdt_returned_name TEXT, account_name_at_check TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX mst_validations_account_time_idx ON crm_mst_validations(tenant_id, account_id, created_at DESC); ALTER TABLE crm_mst_validations ENABLE ROW LEVEL SECURITY; CREATE POLICY mst_val_rls ON crm_mst_validations USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON crm_mst_validations FROM cyberos_app; -- Append-only ``
  1. MUST emit 3 memory audit kinds per DEC-1685. PII per TASK-MEMORY-111: MST + names SHA256 hashed.
  1. MUST thread trace_id from account hook → skill → GDT call → audit.
  1. MUST NOT block account save per DEC-1683.
  1. MUST NOT call GDT directly when cache hit per DEC-1684 — saves rate.

§2 — Why this design

Why non-blocking (DEC-1683)? GDT can be slow/down; account save shouldn't fail. CRO sees name_mismatch flag on dashboard and resolves async.

Why 30d cache (DEC-1684)? TIN registry rarely changes; GDT rate-limits aggressive callers. 30d is industry standard.

Why Levenshtein ≤3 (DEC-1682)? Allows minor transcription variations (Acme Corp vs Acme Corporation) without false-positive mismatches.

Why closed enum (DEC-1681)? Bounded outcomes; gdt_unavailable distinct from mst_not_found is operationally important.


§3 — API contract

POST   /v1/crm/accounts/{id}/validate-mst    (manual re-trigger by CRO)
GET    /v1/crm/accounts/{id}/mst-validation  (latest result)

Sample result:

{
  "validation_id": "uuid",
  "account_id": "uuid",
  "result": "name_mismatch",
  "gdt_returned_name": "Công ty TNHH Acme Việt Nam",
  "account_name_at_check": "Acme Corp",
  "checked_at": "2026-05-17T10:00:00Z"
}

§4 — Acceptance criteria

  1. Skill registered as vietnam-mst-validate@1. 2. Hook on account create+update when vn-1 + mst set. 3. Non-blocking (account save succeeds even if GDT down). 4. GDT API called for new MST. 5. Cache hit on repeat call within 30d. 6. Levenshtein ≤3 → confirmed. 7. >3 → name_mismatch. 8. 404 from GDT → mst_not_found. 9. GDT 5xx → gdt_unavailable. 10. Format invalid → format_invalid (before HTTP). 11. 5-result enum + cardinality test. 12. 3 memory audit kinds emitted. 13. PII scrubbed (MST+names SHA256). 14. RLS denies cross-tenant. 15. Trace_id preserved. 16. Result row append-only. 17. Manual revalidate via POST. 18. History queryable (latest in GET). 19. Account update doesn't block on pending validation. 20. GDT cache namespaced per-tenant.

§5 — Verification

#[tokio::test]
async fn confirmed_when_name_matches() {
    let ctx = TestContext::vn_account("Acme Corp", "0312345678").await;
    ctx.gdt_returns("0312345678", "Acme Corp").await;
    ctx.update_account_mst(ctx.account_id, "0312345678").await;
    tokio::time::sleep(Duration::from_secs(1)).await;
    let v = ctx.latest_validation(ctx.account_id).await;
    assert_eq!(v.result, "confirmed");
}

#[tokio::test]
async fn cache_avoids_second_gdt_call() {
    let ctx = TestContext::vn_account("Acme", "0312345678").await;
    ctx.gdt_returns("0312345678", "Acme").await;
    ctx.update_account_mst(ctx.account_id, "0312345678").await;
    ctx.update_account_mst(ctx.account_id, "0312345678").await;
    assert_eq!(ctx.gdt_call_count("0312345678").await, 1);
}

#[tokio::test]
async fn non_blocking_on_gdt_down() {
    let ctx = TestContext::vn_account("Acme", "0312345678").await;
    ctx.gdt_returns_5xx().await;
    let r = ctx.update_account_mst(ctx.account_id, "0312345678").await;
    assert!(r.is_ok());  // account update succeeded
    let v = ctx.latest_validation(ctx.account_id).await;
    assert_eq!(v.result, "gdt_unavailable");
}

// 5.4..5.8 — name_mismatch, mst_not_found, enum cardinality, audit

§7 — Dependencies

Upstream: TASK-CRM-003. Cross-module: TASK-SKILL-107 (skill registry), TASK-MEMORY-111 (PII), TASK-MCP-007 (async).

§10 — Failure modes

FailureDetectionOutcomeRecovery
GDT API downclient errgdt_unavailable; non-blockingretry async
GDT rate-limit429cache hit serves; sev-3 if no cacheinherent
MST format invalidTASK-CRM-003 CHECKformat_invalid (no HTTP)data fix
MST not in GDT registry404mst_not_foundaccount holder explains
Name match too liberal/strictLevenshtein tuningCRO can manually markconfigurable
Cache stale (entity name changed)30d TTLrefresh next checkinherent
Concurrent validationOK (idempotent)inherentinherent
Account update during validationnon-blockinginherentnext save re-validates if changed
Cross-tenant cache leaknamespaced keyinherentinherent
Hook fires on non-vn accountearly skipno-opinherent

§11 — Implementation notes


End of TASK-CRM-008 spec.