Skip to content

LLD โ€” Data model

PostgreSQL 16 with pgvector and pg_trgm. Six schemas: core, ledger, kyc, risk, ops, kb. Column-level detail and synthetic volumes: plan v0.2.

1. Conventions

Convention Rule
Identifiers Prefixed ULIDs stored as text (cus_, acc_, txn_, kyc_, alr_, dsp_, doc_)
Money BIGINT paise; column names end in _paise
Time timestamptz, stored UTC, rendered IST
Enumerations Postgres ENUM types generated from kosha-domain enums (one source of truth)
Soft state Never delete business rows; status transitions are recorded in history tables
Naming snake_case, singular enum names, plural table names

2. Core and ledger

erDiagram
    CUSTOMERS ||--o{ ADDRESSES : "has"
    CUSTOMERS ||--o{ ACCOUNTS : "owns"
    CUSTOMERS ||--o{ VPAS : "registers"
    CUSTOMERS ||--o{ BENEFICIARIES : "adds"
    ACCOUNTS ||--o{ CARDS : "issues"
    ACCOUNTS ||--o{ VPAS : "linked to"
    ACCOUNTS ||--o{ TRANSACTIONS : "records"
    MERCHANTS ||--o{ TRANSACTIONS : "receives"
    TRANSACTIONS ||--o{ TRANSACTION_STATUS_HISTORY : "moves through"
    TRANSACTIONS ||--o| JOURNAL_ENTRIES : "posted as"
    JOURNAL_ENTRIES ||--|{ POSTINGS : "balanced by"
    ACCOUNTS ||--o{ POSTINGS : "sub-ledger of customer_deposits"

    CUSTOMERS {
        text customer_id PK
        text cif_number UK
        text persona
        text segment
        text status
        text risk_category
        date relationship_start_date
    }
    ACCOUNTS {
        text account_id PK
        text customer_id FK
        text ifsc
        text product_code
        text status
        bigint ledger_balance_paise
        bigint available_balance_paise
        bigint hold_amount_paise
    }
    TRANSACTIONS {
        text txn_id PK
        timestamptz initiated_at PK
        text account_id FK
        text customer_id FK
        text direction
        bigint amount_paise
        text rail
        text txn_type
        text status
        text utr
        text rrn
        text merchant_id FK
        smallint risk_score
    }
    TRANSACTION_STATUS_HISTORY {
        text history_id PK
        text txn_id FK
        timestamptz txn_initiated_at FK
        text from_status
        text to_status
        text reason_code
        text actor
        timestamptz occurred_at
    }
    JOURNAL_ENTRIES {
        text entry_id PK
        text txn_id FK
        timestamptz txn_initiated_at FK
        timestamptz posted_at
    }
    POSTINGS {
        text posting_id PK
        text entry_id FK
        text gl_account
        text account_id FK
        bigint amount_paise
    }
    MERCHANTS {
        text merchant_id PK
        text display_name
        smallint mcc
        text category
        text gstin
    }
    CARDS {
        text card_id PK
        text account_id FK
        text network
        text status
    }
    VPAS {
        text vpa_id PK
        text vpa UK
        text account_id FK
    }
    BENEFICIARIES {
        text beneficiary_id PK
        text customer_id FK
        timestamptz cooling_period_ends_at
        text status
    }
    ADDRESSES {
        text address_id PK
        text customer_id FK
        text pincode
        text state_code
    }

Notice: TRANSACTIONS is partitioned by initiated_at, so its key is (txn_id, initiated_at) and every table that references it carries txn_initiated_at in a composite foreign key (ADR 0011).

Notice: balances on ACCOUNTS are a cache; POSTINGS is the source of truth, and every transaction that moves money has exactly one balanced journal entry. A posting's gl_account is customer_deposits (and then account_id names the customer Account) or an internal GL account such as upi_settlement, fee_income, gst_payable or suspense; a CHECK enforces that account_id is set exactly for customer_deposits. available_balance_paise is a generated column, ledger_balance_paise โˆ’ hold_amount_paise.

3. KYC, risk and operations

erDiagram
    CUSTOMERS ||--o{ KYC_PROFILES : "verified by"
    KYC_PROFILES ||--o{ KYC_DOCUMENTS : "evidenced by"
    KYC_PROFILES ||--o{ KYC_EVENTS : "moves through"
    CUSTOMERS ||--o{ AML_ALERTS : "subject of"
    AML_RULES ||--o{ AML_ALERTS : "fires"
    CUSTOMERS ||--o{ SCREENING_RESULTS : "screened in"
    TRANSACTIONS ||--o{ DISPUTES : "disputed in"
    TRANSACTIONS ||--o{ TRANSACTION_REVIEWS : "reviewed in"

    KYC_PROFILES {
        text kyc_id PK
        text customer_id FK
        text kyc_type
        text kyc_status
        text ckyc_number
        text pan_token
        text pan_last4
        text aadhaar_ref_token
        text aadhaar_last4
        boolean pep_flag
        boolean sanctions_flag
        date next_review_due
    }
    KYC_DOCUMENTS {
        text document_id PK
        text kyc_id FK
        text doc_type
        text sha256
        text status
    }
    KYC_EVENTS {
        text event_id PK
        text kyc_id FK
        text from_status
        text to_status
        timestamptz occurred_at
    }
    AML_RULES {
        text rule_id PK
        text typology
        jsonb params
        text severity
    }
    AML_ALERTS {
        text alert_id PK
        text customer_id FK
        text rule_id FK
        text status
        numeric score
        text_array triggering_txn_ids
        timestamptz sla_due_at
    }
    SCREENING_RESULTS {
        text screening_id PK
        text customer_id FK
        text list_source
        numeric match_score
        text decision
    }
    DISPUTES {
        text dispute_id PK
        text txn_id FK
        timestamptz txn_initiated_at FK
        text reason_code
        text status
        bigint amount_paise
        timestamptz tat_due_at
        text idempotency_key UK
    }
    TRANSACTION_REVIEWS {
        text review_id PK
        text txn_id FK
        timestamptz txn_initiated_at FK
        text status
        text idempotency_key UK
    }
    CUSTOMERS {
        text customer_id PK
    }
    TRANSACTIONS {
        text txn_id PK
        timestamptz initiated_at PK
    }

Operational tables with no foreign keys to business data:

Table Purpose Key properties
ops.outbox Domain events awaiting publication published_at IS NULL partial index
ops.audit_log One row per tool call Append-only: UPDATE/DELETE revoked from kosha_app, and a kosha.forbid_change() trigger rejects UPDATE, DELETE and TRUNCATE from anyone, the owner included
ops.idempotency_keys Exactly-once effect for writes PK (scope, key); expires_at

4. Knowledge base

erDiagram
    DOCUMENTS ||--|{ CHUNKS : "split into"
    DOCUMENTS ||--o| DOCUMENTS : "supersedes"

    DOCUMENTS {
        text doc_id PK
        text doc_code
        text department
        text doc_type
        text version
        text status
        date effective_date
        text classification
        text source_format
        text sha256
        text supersedes_doc_id FK
    }
    CHUNKS {
        text chunk_id PK
        text doc_id FK
        int chunk_index
        text section_path
        text content
        vector embedding
        tsvector tsv
        jsonb metadata
    }

Notice: classification lives on the document but RLS is enforced on chunks through a denormalised copy, so every retrieval path is filtered in the database.

5. Status lifecycles

All lifecycles are encoded once in kosha-domain as transition tables, mirrored into kosha.allowed_transitions (a parity test compares them edge for edge) and enforced by a BEFORE UPDATE trigger on each status column. An illegal transition raises check_violation with the trigger's name (*_lifecycle) as the constraint and the domain's own wording, and core-api maps it to conflict. Inserts are not checked, because historical rows are loaded in their final status together with their history.

Transaction

stateDiagram-v2
    [*] --> initiated
    initiated --> authorised
    initiated --> declined
    initiated --> cancelled
    initiated --> expired
    authorised --> processing
    processing --> pending
    processing --> success
    processing --> failed
    processing --> timed_out
    processing --> on_hold
    on_hold --> under_review
    under_review --> processing
    under_review --> declined
    pending --> success
    pending --> credit_delayed
    pending --> deemed_success
    pending --> failed
    pending --> returned
    credit_delayed --> success
    credit_delayed --> reversal_initiated
    deemed_success --> success
    deemed_success --> reversal_initiated
    timed_out --> reversal_initiated
    success --> pending_settlement
    pending_settlement --> settled
    success --> reversal_initiated
    settled --> refunded
    settled --> chargeback_initiated
    reversal_initiated --> reversed
    reversal_initiated --> partially_reversed
    returned --> reversed
    failed --> [*]
    declined --> [*]
    cancelled --> [*]
    expired --> [*]
    reversed --> [*]
    partially_reversed --> [*]
    refunded --> [*]
    settled --> [*]

Notice: credit_delayed, deemed_success and timed_out are the states customers dispute; each has a path to reversal, which drives the RBI TAT compensation logic.

Account

stateDiagram-v2
    [*] --> pending_activation
    pending_activation --> kyc_pending
    pending_activation --> active
    kyc_pending --> active
    active --> dormant
    dormant --> inoperative
    dormant --> active
    inoperative --> active
    active --> debit_freeze
    active --> credit_freeze
    active --> total_freeze
    active --> lien_marked
    active --> on_hold
    debit_freeze --> active
    credit_freeze --> active
    total_freeze --> active
    lien_marked --> active
    on_hold --> active
    active --> pending_closure
    pending_closure --> closed
    closed --> [*]

Notice: inoperative (no customer-induced transaction for two years) is reachable only from dormant, matching RBI's definition.

KYC profile

stateDiagram-v2
    [*] --> not_started
    not_started --> pending_documents
    pending_documents --> pending_vcip
    pending_documents --> in_review
    pending_vcip --> in_review
    in_review --> verified
    in_review --> rejected
    in_review --> on_hold
    in_review --> escalated
    on_hold --> in_review
    escalated --> verified
    escalated --> rejected
    verified --> re_kyc_due
    re_kyc_due --> in_review
    re_kyc_due --> re_kyc_overdue
    re_kyc_overdue --> in_review
    re_kyc_overdue --> expired
    rejected --> [*]
    expired --> [*]

Notice: next_review_due (2, 8 or 10 years by risk category) is what moves verified to re_kyc_due.

Dispute

stateDiagram-v2
    [*] --> raised
    raised --> acknowledged
    raised --> auto_reversed
    acknowledged --> under_investigation
    under_investigation --> awaiting_customer_info
    awaiting_customer_info --> under_investigation
    under_investigation --> on_hold
    on_hold --> under_investigation
    under_investigation --> chargeback_raised
    chargeback_raised --> pre_arbitration
    pre_arbitration --> arbitration
    chargeback_raised --> resolved_customer_favour
    chargeback_raised --> resolved_merchant_favour
    pre_arbitration --> resolved_customer_favour
    pre_arbitration --> resolved_merchant_favour
    arbitration --> resolved_customer_favour
    arbitration --> resolved_merchant_favour
    under_investigation --> resolved_customer_favour
    under_investigation --> resolved_merchant_favour
    raised --> withdrawn
    acknowledged --> withdrawn
    auto_reversed --> closed
    resolved_customer_favour --> closed
    resolved_merchant_favour --> closed
    withdrawn --> closed
    closed --> [*]

Notice: failed_but_debited disputes on UPI can go straight to auto_reversed, mirroring the T+1 auto-reversal rule.

AML alert

stateDiagram-v2
    [*] --> open
    open --> assigned
    assigned --> in_review
    in_review --> pending_info
    pending_info --> in_review
    in_review --> on_hold
    on_hold --> in_review
    in_review --> escalated
    in_review --> closed_false_positive
    in_review --> closed_no_action
    escalated --> closed_str_filed
    escalated --> closed_no_action
    closed_false_positive --> [*]
    closed_no_action --> [*]
    closed_str_filed --> [*]

Notice: only escalated alerts can end in an STR filing, which is what the compliance SOPs require.

Card

stateDiagram-v2
    [*] --> inactive
    inactive --> active
    inactive --> expired
    active --> temp_blocked
    temp_blocked --> active
    active --> pin_blocked
    pin_blocked --> active
    active --> blocked
    active --> hotlisted
    temp_blocked --> hotlisted
    pin_blocked --> hotlisted
    active --> expired
    blocked --> replaced
    hotlisted --> replaced
    expired --> replaced
    replaced --> [*]

Notice: temp_blocked is the customer's own reversible switch; hotlisted (lost or stolen) is permanent and ends only in a replacement card.

Transaction review

stateDiagram-v2
    [*] --> queued
    queued --> in_review
    in_review --> on_hold
    on_hold --> in_review
    in_review --> cleared
    in_review --> escalated_to_aml
    in_review --> blocked
    cleared --> [*]
    escalated_to_aml --> [*]
    blocked --> [*]

Notice: a review never closes an AML question itself; it either clears the Transaction or hands it to the AML alert queue.

Beneficiary

stateDiagram-v2
    [*] --> pending_cooling
    pending_cooling --> active
    pending_cooling --> deleted
    active --> disabled
    disabled --> active
    active --> deleted
    disabled --> deleted
    deleted --> [*]

Notice: a new Beneficiary starts in its Cooling period; transfers to it stay capped until it becomes active.

6. Indexes and partitioning

Table Index / strategy Serves
core.transactions PARTITION BY RANGE (initiated_at), monthly; default partition for out-of-range Pruning for date-bounded queries
core.transactions (account_id, initiated_at DESC, txn_id DESC) search_transactions keyset pagination
core.transactions (customer_id, initiated_at DESC) Customer-wide views, AML rules
core.transactions partial (merchant_id), partial (utr) Merchant drill-down, UTR lookup. UTR is deliberately not unique: a unique index must include the partition key, which would only forbid duplicates at the same instant, and both legs of an on-us transfer share a UTR
core.transactions GIN narration gin_trgm_ops Text filter
core.transactions partial (account_id) WHERE status IN ('on_hold','credit_delayed','under_review') Ops queues
core.customers GIN trigram on first_name || ' ' || last_name; unique cif_number search_customers
ledger.postings (account_id) INCLUDE (amount_paise) Index-only reconciliation of cached balances
risk.aml_alerts (status, sla_due_at) Alert queue ordering
ops.outbox partial (created_at) WHERE published_at IS NULL Relay polling
kb.chunks HNSW embedding vector_cosine_ops (m = 16, ef_construction = 64) Vector half of hybrid search
kb.chunks GIN tsv Full-text half of hybrid search

Partitions are monthly in UTC. kosha.create_transaction_partitions(first_month, last_month) creates any that are missing; the first migration covers 18 months back and 3 ahead, and a scheduled job keeps 3 months ahead. A month whose rows already sit in the default partition cannot be split out without moving them first, so the job must run before the month arrives.

7. Row-level security for the knowledge base

CREATE TYPE kb.classification AS ENUM ('public', 'internal', 'confidential', 'restricted');

-- The application sets the caller's ceiling per transaction; default is the lowest level.
CREATE FUNCTION kb.caller_ceiling() RETURNS kb.classification
    LANGUAGE sql STABLE PARALLEL SAFE
    RETURN coalesce(
        nullif(current_setting('kosha.kb_ceiling', true), '')::kb.classification,
        'public'::kb.classification
    );

ALTER TABLE kb.chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE kb.chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY chunks_within_ceiling ON kb.chunks FOR SELECT TO kosha_app
    USING (classification <= kb.caller_ceiling());

-- kb.documents carries the same policy, so titles and codes of documents above the
-- ceiling are invisible too.
async with uow.read_only() as tx:
    await tx.execute("SELECT set_config('kosha.kb_ceiling', %s, true)", [principal.kb_ceiling])
    rows = await kb_repo.hybrid_search(tx, query)
Role Ceiling
demo_readonly public
support_agent internal
analyst confidential
compliance_officer restricted

set_config(..., true) scopes the value to the transaction, so a pooled connection cannot leak a ceiling to the next request. FORCE applies the policy even to the table owner. An unknown ceiling fails the cast to kb.classification, so the query fails closed rather than defaulting. chunks.classification is pinned to its document's by a composite foreign key with ON UPDATE CASCADE, so reclassifying a document re-filters its chunks.

8. PII handling

Data Stored as Who can see it
Aadhaar number Never stored. aadhaar_ref_token (vault reference) + aadhaar_last4; a CHECK rejects a 12-digit token, so a raw number written by mistake fails Last 4 only, everyone with kyc:read
PAN pan_token: KMS envelope-encrypted ciphertext + pan_last4; a CHECK rejects a PAN-shaped token Full value decrypted only for kyc:pii
Mobile, email, DOB Plain columns, masked at the gateway Unmasked for kyc:pii
Account number Plain column Never returned unmasked over MCP
Device ID, IP address Plain columns aml:read
KYC document images Not generated; s3_key + sha256 placeholders Metadata only

Retention follows the fictional Record Retention Policy (PMLA: five years after relationship end); the generator marks closed relationships accordingly.