Task — engineering-spec@1

"CRM VN account types + MST — legal entity classification (Sole/LLC/JSC/FDI) + tax ID field with format validation"

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

§1 — Description (BCP-14 normative)

The CRM service MUST extend Account schema with VN-specific fields at services/crm/src/vn/ — legal entity type + MST + validation gated on residency, 3 memory audit kinds.

  1. MUST define table extension at migration 0003: ``sql ALTER TABLE crm_accounts ADD COLUMN vn_account_type TEXT CHECK (vn_account_type IS NULL OR vn_account_type IN ('sole','llc_1','llc_2plus','jsc','fdi','partnership')); ALTER TABLE crm_accounts ADD COLUMN mst TEXT; ALTER TABLE crm_accounts ADD COLUMN mst_validated_at TIMESTAMPTZ; ALTER TABLE crm_accounts ADD CONSTRAINT mst_format CHECK (mst IS NULL OR mst ~ '^[0-9]{10}(-[0-9]{3})?$'); CREATE INDEX accounts_vn_mst_idx ON crm_accounts(tenant_id, mst) WHERE mst IS NOT NULL; GRANT UPDATE (vn_account_type, mst, mst_validated_at) ON crm_accounts TO cyberos_app; ``
  1. MUST validate vn_account_type against closed enum per DEC-1631.
  1. MUST validate MST format at vn/mst_format.rs::validate(mst) per DEC-1632:
  1. MUST require both fields when account.residency='vn-1' per DEC-1633 — at INSERT/UPDATE, if residency=vn-1 and either NULL → reject 400.
  1. MUST allow both fields NULL for non-VN accounts.
  1. MUST emit 3 memory audit kinds per DEC-1634. Audit body: account_id, vn_account_type (enum); MST SHA-256 hashed per TASK-MEMORY-111 (treat as PII — could be confidential).
  1. MUST thread trace_id from account create/update → validation → audit.
  1. MUST NOT accept MST formats outside DEC-1632 — CHECK constraint enforces.
  1. MUST NOT require fields on non-VN accounts per DEC-1633.

§2 — Why this design

Why 6 entity types (DEC-1631)? Enterprise Law 59/2020 enumerates these as the legal forms; LLC is split into 1-owner vs 2+ because they have distinct registration/tax treatment.

Why MST format (DEC-1632)? GDT rejects malformed MST in hóa đơn; pre-validate at CRM level saves a downstream failure.

Why residency-gated (DEC-1633)? Non-VN accounts don't have MST; making it mandatory annoys global users.


§3 — API contract

Account fields (extension):

{
  "account_id": "uuid",
  "name": "Acme JSC",
  "residency": "vn-1",
  "vn_account_type": "jsc",
  "mst": "0312345678"
}

Branch MST (13 digits):

{ "mst": "0312345678-001" }

§4 — Acceptance criteria

  1. 6 account types enum + cardinality test. 2. MST 10-digit accepted. 3. MST 13-digit (with dash) accepted. 4. MST 9/11/12/14 rejected (400 + CHECK). 5. MST with letters rejected. 6. MST optional for non-VN. 7. MST required for vn-1 residency. 8. vn_account_type optional for non-VN. 9. vn_account_type required for vn-1. 10. 3 memory audit kinds emitted. 11. PII scrubbed (MST SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Index on mst for lookup. 15. mst_validated_at populated on successful validation. 16. Append-only via REVOKE UPDATE except 3 cols. 17. CRO UI picker shows 6 types. 18. TASK-CRM-008 future validation skill leverages this format check. 19. TASK-INV-007 hóa đơn emit reads mst from this column. 20. Multi-line FDI/JSC company name OK in name field (not affected).

§5 — Verification

#[tokio::test]
async fn mst_10_digit_accepted() {
    let ctx = TestContext::vn_residency_account().await;
    let r = ctx.update_account_mst(ctx.account_id, "0312345678").await;
    assert!(r.is_ok());
}

#[tokio::test]
async fn mst_13_digit_branch_accepted() {
    let ctx = TestContext::vn_residency_account().await;
    let r = ctx.update_account_mst(ctx.account_id, "0312345678-001").await;
    assert!(r.is_ok());
}

#[tokio::test]
async fn mst_required_for_vn_residency() {
    let ctx = TestContext::new_tenant().await;
    let r = ctx.create_account_no_mst(ctx.tenant_id, "vn-1").await;
    assert!(r.is_err());
}

#[tokio::test]
async fn mst_not_required_for_sg() {
    let ctx = TestContext::new_tenant().await;
    let r = ctx.create_account_no_mst(ctx.tenant_id, "sg-1").await;
    assert!(r.is_ok());
}

#[tokio::test]
async fn invalid_mst_rejected() {
    for bad in ["0312345", "12345678901", "031234567A", "0312345678-12", "0312345678 "] {
        let ctx = TestContext::vn_residency_account().await;
        let r = ctx.update_account_mst(ctx.account_id, bad).await;
        assert!(r.is_err(), "bad mst accepted: {bad}");
    }
}

// 5.6..5.9 — enum cardinality, audit emission

§7 — Dependencies

Upstream: TASK-CRM-001. Downstream: TASK-CRM-008 (validation skill), TASK-INV-007 (reads MST for hóa đơn). Cross-module: TASK-MEMORY-111 (PII scrub).

§10 — Failure modes

FailureDetectionOutcomeRecovery
MST format invalidCHECK constraint400fix input
Residency=vn-1 without MSTtrigger/handler400provide MST
Residency change vn-1 → sg-1leave MST as-isinherentinherent
MST duplicate across tenantsper-tenant index OK (cross-tenant allowed)inherentinherent
TASK-CRM-008 skill validates externalfuture taskoptional confirm via GDTinherent
Account legacy missing fieldsmigration backfillNULL preservedmanual fill
MST with whitespacereject400trim client-side

§11 — Implementation notes


End of TASK-CRM-003 spec.