Skip to main content

Number Intelligence Service — Data Model

Version: 1.0 Status: Draft Owner: Messaging Core Last Updated: 2026-04-21 Companion: DOMAIN_MODEL · SECURITY_MODEL · SYNC_CONTRACT

Schema: numint. Owned exclusively by number-intelligence-service. No other service reads or writes these tables directly — integration is over gRPC (ResolveMsisdn, ResolveBatch, ProbeHlr, LookupPorting, LookupEir, GetMnpHistory), HTTP REST (admin / tenant / regulator), or NATS events.

1. Tables

-- =====================================================================
-- ENUM TYPES
-- =====================================================================

CREATE SCHEMA IF NOT EXISTS numint;

CREATE TYPE numint.line_type AS ENUM ('MOBILE','FIXED','VOIP','UNKNOWN');
CREATE TYPE numint.mnp_status AS ENUM ('NATIVE','PORTED_IN','PORTED_OUT','UNKNOWN');
CREATE TYPE numint.confidence AS ENUM ('HIGH','MEDIUM','LOW','UNKNOWN');
CREATE TYPE numint.attribution_source AS ENUM (
'LRU','REDIS','POSTGRES',
'LIVE_HLR_MAP','LIVE_HLR_REST',
'MNP_RECON','PREFIX_FALLBACK','STALE_THROTTLED','ADMIN_OVERRIDE','MNO_HLR_DUMP'
);
CREATE TYPE numint.eir_state AS ENUM ('WHITELIST','GREYLIST','BLACKLIST','UNKNOWN');
CREATE TYPE numint.port_direction AS ENUM ('IN','OUT');
CREATE TYPE numint.probe_transport AS ENUM ('MAP_SRI_SM','REST_ADAPTER');
CREATE TYPE numint.probe_status AS ENUM ('OK','TIMEOUT','MAP_ABORT','REST_5XX','THROTTLED','ADAPTER_DOWN');
CREATE TYPE numint.recon_kind AS ENUM ('MNP','EIR');
CREATE TYPE numint.recon_status AS ENUM ('PENDING','RUNNING','COMPLETED','FAILED');
CREATE TYPE numint.conflict_severity AS ENUM ('HIGH','MEDIUM','LOW');
CREATE TYPE numint.conflict_resolution AS ENUM ('A_WINS','B_WINS','KEEP_BOTH_PENDING_VENDOR_CONFIRM','DISCARDED');
CREATE TYPE numint.lookup_result_class AS ENUM ('SUCCESS','INVALID_MSISDN','QUOTA_EXCEEDED','RATE_LIMITED','ERROR');
CREATE TYPE numint.lookup_tier AS ENUM ('LRU','REDIS','PG','LIVE','FALLBACK');
CREATE TYPE numint.risk_flag AS ENUM ('STOLEN_DEVICE','MNP_DIVERGENCE','ABNORMAL_MNP_CHURN','PREFIX_MISMATCH','UNUSUAL_VLR');

-- =====================================================================
-- NUMBER RECORDS (authoritative)
-- =====================================================================

CREATE TABLE numint.number_records (
msisdn_hash BYTEA PRIMARY KEY, -- sha256(e164 || pepper)
e164 TEXT NOT NULL, -- canonical E.164
mno_id TEXT NOT NULL, -- slug
original_mno_id TEXT,
line_type numint.line_type NOT NULL DEFAULT 'UNKNOWN',
country CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
mnp_status numint.mnp_status NOT NULL DEFAULT 'UNKNOWN',
vlr TEXT,
imsi_prefix TEXT, -- MCC+MNC (6 digits)
source numint.attribution_source NOT NULL,
confidence numint.confidence NOT NULL,
risk_flags numint.risk_flag[] NOT NULL DEFAULT '{}',
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_at TIMESTAMPTZ NOT NULL DEFAULT now(),
lookup_count BIGINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
pepper_version TEXT NOT NULL DEFAULT 'v1',
CONSTRAINT chk_native_no_original_mno CHECK (
(mnp_status = 'NATIVE' AND original_mno_id IS NULL)
OR mnp_status <> 'NATIVE'
),
CONSTRAINT chk_e164 CHECK (e164 ~ '^\+[1-9][0-9]{6,14}$')
);

CREATE INDEX ix_number_records_mno ON numint.number_records (mno_id);
CREATE INDEX ix_number_records_country ON numint.number_records (country);
CREATE INDEX ix_number_records_mnp ON numint.number_records (mnp_status) WHERE mnp_status <> 'NATIVE';
CREATE INDEX ix_number_records_updated ON numint.number_records USING brin (cached_at); -- hot-scan of recently-updated rows
CREATE INDEX ix_number_records_popular ON numint.number_records (lookup_count DESC) INCLUDE (msisdn_hash) WHERE lookup_count > 100;

-- =====================================================================
-- PORTABILITY HISTORY (hash-chained, append-only)
-- =====================================================================

CREATE TABLE numint.portability_history (
port_id TEXT NOT NULL, -- 'ni_<ULID>'
msisdn_hash BYTEA NOT NULL,
donor_mno_id TEXT NOT NULL,
recipient_mno_id TEXT NOT NULL,
port_date DATE NOT NULL,
direction numint.port_direction NOT NULL DEFAULT 'IN',
source_feed TEXT NOT NULL,
recon_run_id TEXT NOT NULL,
seq BIGINT NOT NULL, -- monotonic per msisdn_hash
prev_chain_hash BYTEA NOT NULL,
record_hash BYTEA NOT NULL,
signing_key_id TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (observed_at, msisdn_hash, port_date, recipient_mno_id)
) PARTITION BY RANGE (observed_at);

-- Partition example (monthly, provisioned by PartitionMaintainer)
-- CREATE TABLE numint.portability_history_2026_04 PARTITION OF numint.portability_history
-- FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

CREATE INDEX ix_port_hist_msisdn ON numint.portability_history (msisdn_hash, port_date DESC);
CREATE INDEX ix_port_hist_mno ON numint.portability_history (recipient_mno_id, observed_at);
CREATE INDEX ix_port_hist_recon ON numint.portability_history (recon_run_id);
CREATE UNIQUE INDEX ux_port_hist_dedup
ON numint.portability_history (msisdn_hash, port_date, recipient_mno_id, source_feed);

-- Append-only protection
CREATE RULE port_hist_no_update AS ON UPDATE TO numint.portability_history DO INSTEAD NOTHING;
CREATE RULE port_hist_no_delete AS ON DELETE TO numint.portability_history DO INSTEAD NOTHING;

-- =====================================================================
-- RECONCILIATION RUNS
-- =====================================================================

CREATE TABLE numint.reconciliation_runs (
run_id TEXT PRIMARY KEY, -- 'rcn_<ULID>'
kind numint.recon_kind NOT NULL,
mno_id TEXT,
file_sha256 TEXT,
total_records INT NOT NULL DEFAULT 0,
accepted INT NOT NULL DEFAULT 0,
rejected INT NOT NULL DEFAULT 0,
conflicts_count INT NOT NULL DEFAULT 0,
duration_ms INT,
status numint.recon_status NOT NULL DEFAULT 'PENDING',
prev_chain_hash BYTEA, -- per-MNO chain
record_hash BYTEA,
signing_key_id TEXT,
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);

CREATE INDEX ix_recon_runs_mno_time ON numint.reconciliation_runs (mno_id, started_at DESC);
CREATE INDEX ix_recon_runs_status ON numint.reconciliation_runs (status) WHERE status IN ('PENDING','RUNNING','FAILED');

-- =====================================================================
-- RECONCILIATION CONFLICTS
-- =====================================================================

CREATE TABLE numint.reconciliation_conflicts (
conflict_id TEXT PRIMARY KEY, -- 'cfl_<ULID>'
msisdn_hash BYTEA NOT NULL,
candidate_a JSONB NOT NULL, -- { mno_id, port_date, source_feed }
candidate_b JSONB NOT NULL,
severity numint.conflict_severity NOT NULL,
resolution numint.conflict_resolution,
resolved_by UUID,
resolved_at TIMESTAMPTZ,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_conflicts_open ON numint.reconciliation_conflicts (created_at) WHERE resolution IS NULL;
CREATE INDEX ix_conflicts_msisdn ON numint.reconciliation_conflicts (msisdn_hash);

-- =====================================================================
-- HLR PROBES (audit)
-- =====================================================================

CREATE TABLE numint.hlr_probes (
probe_id TEXT NOT NULL, -- 'prb_<ULID>'
msisdn_hash BYTEA NOT NULL,
mno_hint TEXT,
transport numint.probe_transport NOT NULL,
gcc_invoke_id INT,
status numint.probe_status NOT NULL,
duration_ms INT NOT NULL,
result_snapshot JSONB,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
PRIMARY KEY (started_at, probe_id)
) PARTITION BY RANGE (started_at);

CREATE INDEX ix_hlr_probes_msisdn ON numint.hlr_probes (msisdn_hash, started_at DESC);
CREATE INDEX ix_hlr_probes_mno ON numint.hlr_probes (mno_hint, started_at DESC);

-- =====================================================================
-- LOOKUP AUDIT (hash-chained, partitioned monthly)
-- =====================================================================

CREATE TABLE numint.lookup_audit (
audit_id TEXT NOT NULL, -- 'nia_<ULID>'
partition_name TEXT NOT NULL,
seq BIGINT NOT NULL,
tenant_id UUID NOT NULL,
actor_sub TEXT,
msisdn_hash BYTEA NOT NULL, -- tenant-salted hash
result_class numint.lookup_result_class NOT NULL,
result_mno TEXT,
staleness_seconds INT,
source numint.attribution_source,
tier numint.lookup_tier,
ip_address INET,
user_agent TEXT,
request_id TEXT,
prev_hash BYTEA NOT NULL,
record_hash BYTEA NOT NULL,
signing_key_id TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (occurred_at, partition_name, seq)
) PARTITION BY RANGE (occurred_at);

CREATE INDEX ix_lookup_audit_tenant_time ON numint.lookup_audit (tenant_id, occurred_at DESC);
CREATE INDEX ix_lookup_audit_msisdn_time ON numint.lookup_audit (msisdn_hash, occurred_at DESC);
CREATE UNIQUE INDEX ux_lookup_audit_partition_seq
ON numint.lookup_audit (partition_name, seq);

CREATE RULE lookup_audit_no_update AS ON UPDATE TO numint.lookup_audit DO INSTEAD NOTHING;
CREATE RULE lookup_audit_no_delete AS ON DELETE TO numint.lookup_audit DO INSTEAD NOTHING;

-- =====================================================================
-- EIR RECORDS
-- =====================================================================

CREATE TABLE numint.eir_records (
imei_hash BYTEA PRIMARY KEY,
imei TEXT, -- retained for admin only; can be null if sourced as hash
status numint.eir_state NOT NULL,
effective_status numint.eir_state NOT NULL,
reason_code TEXT,
reported_by TEXT[] NOT NULL DEFAULT '{}',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_imei_luhn CHECK (imei IS NULL OR imei ~ '^[0-9]{15}$')
);

CREATE INDEX ix_eir_effective_status ON numint.eir_records (effective_status) WHERE effective_status <> 'WHITELIST';
CREATE INDEX ix_eir_last_updated ON numint.eir_records USING brin (last_updated_at);

-- =====================================================================
-- MSISDN ↔ IMEI OBSERVATION
-- =====================================================================

CREATE TABLE numint.msisdn_imei_observation (
msisdn_hash BYTEA NOT NULL,
imei_hash BYTEA NOT NULL,
first_observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
observation_count INT NOT NULL DEFAULT 1,
source numint.attribution_source NOT NULL,
PRIMARY KEY (msisdn_hash, imei_hash)
);

CREATE INDEX ix_mi_obs_msisdn ON numint.msisdn_imei_observation (msisdn_hash, last_observed_at DESC);

-- =====================================================================
-- MNO SNAPSHOT (mirror from operator-management)
-- =====================================================================

CREATE TABLE numint.mno_snapshots (
mno_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
country CHAR(2) NOT NULL,
prefixes TEXT[] NOT NULL DEFAULT '{}',
hlr_endpoint JSONB NOT NULL, -- { kind: 'MAP'|'REST', ... }
mnp_sftp_endpoint TEXT,
tps_limit INT NOT NULL DEFAULT 50,
map_timeout_ms INT NOT NULL DEFAULT 1500,
rest_timeout_ms INT NOT NULL DEFAULT 800,
active BOOLEAN NOT NULL DEFAULT TRUE,
config_version INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- =====================================================================
-- TENANT LOOKUP QUOTAS
-- =====================================================================

CREATE TABLE numint.tenant_lookup_quotas (
tenant_id UUID PRIMARY KEY,
rps_limit INT NOT NULL DEFAULT 10,
fresh_lookup_rps_limit INT NOT NULL DEFAULT 2,
monthly_quota INT NOT NULL DEFAULT 100000,
current_month_used INT NOT NULL DEFAULT 0,
overage_mode TEXT NOT NULL DEFAULT 'REJECT',
plan_version INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- =====================================================================
-- AUDIT LOG (administrative)
-- =====================================================================

CREATE TABLE numint.audit_log (
audit_id TEXT PRIMARY KEY, -- 'aud_<ULID>'
entity_type TEXT NOT NULL, -- MNP_CONFLICT, OVERRIDE, ADAPTER, QUOTA, ...
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
actor_user_id UUID,
before_state JSONB,
after_state JSONB,
ip INET,
user_agent TEXT,
trace_id TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (occurred_at);

CREATE INDEX ix_audit_log_entity ON numint.audit_log (entity_type, entity_id, occurred_at DESC);

CREATE RULE audit_log_no_update AS ON UPDATE TO numint.audit_log DO INSTEAD NOTHING;
CREATE RULE audit_log_no_delete AS ON DELETE TO numint.audit_log DO INSTEAD NOTHING;

-- =====================================================================
-- OUTBOX
-- =====================================================================

CREATE TABLE numint.outbox (
event_id UUID PRIMARY KEY,
subject TEXT NOT NULL, -- e.g., 'numint.mnp.changed.v1'
payload JSONB NOT NULL,
published_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_outbox_unpublished ON numint.outbox (created_at) WHERE published_at IS NULL;

-- =====================================================================
-- IDEMPOTENCY KEYS
-- =====================================================================

CREATE TABLE numint.idempotency_keys (
tenant_id UUID NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash BYTEA NOT NULL,
response JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '24 hours'),
PRIMARY KEY (tenant_id, idempotency_key)
);
CREATE INDEX ix_numint_idempotency_expires ON numint.idempotency_keys (expires_at);

2. Redis keys

Namespace numint:. Redis cluster mode; {hash} slot-tag used where per-MSISDN atomicity matters.

KeyTTLPurpose
numint:lookup:{msisdnHash}per-class (30 d line_type / 24 h mno / 5 min vlr)Hot cache for ResolveMsisdn
numint:mnp:{msisdnHash}24 hFast LookupPorting response
numint:eir:{imeiHash}24 hLookupEir response
numint:mno_snapshot:{mnoId}300 sOperator config snapshot
numint:quota:{tenantId}60 sTenantLookupQuota snapshot
numint:tps:hlr:{mno}3600 sPer-MNO SS7 TPS token bucket
numint:tps:lookup:{tenantId}3600 sPer-tenant RPS bucket
numint:quota:lookup:{tenantId}:{yyyymm}until 1st next monthMonthly counter
numint:lock:mnp_recon:{mnoId}1800 sDistributed cron lock
numint:lock:eir_recon1800 s
numint:lock:audit_verifier3600 s
numint:lock:cache_warm600 s
numint:warm:mnos21 600 sList of MSISDNs to pre-warm

3. Append-only & access rules

3.1 Append-only tables

  • numint.portability_history — rules reject UPDATE / DELETE.
  • numint.lookup_audit — same.
  • numint.audit_log — same.

Retention is enforced by detaching and archiving partitions, never by DELETE.

3.2 RLS intentionally absent on attribution tables

Per DOMAIN_MODEL §6, number attribution is platform-public authoritative fact (like an E.164 prefix-to-MNO table would be). No Row-Level Security is enabled on number_records, portability_history, eir_records, or msisdn_imei_observation. Access control is enforced at the API layer (SPIFFE SAN allowlist on gRPC, Kong+JWT on REST).

3.3 RLS on lookup_audit (tenant scope)

ALTER TABLE numint.lookup_audit ENABLE ROW LEVEL SECURITY;

CREATE POLICY lookup_audit_tenant_read ON numint.lookup_audit
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id', true)::uuid
OR current_setting('app.caller_role', true) IN ('platform.numint.admin','platform.regulator')
);

Tenants read their own audit; admins/regulators read all.

4. ID prefixes

PrefixEntity
ni_PortabilityRecord (port event)
rcn_ReconciliationRun
cfl_ReconciliationConflict
prb_HlrProbe
nia_LookupAuditEntry
aud_AdminAuditLog
cvr_ChainVerifierRun

ULIDs for all *_id values; prefixes only on externally-exposed identifiers.

5. PII, encryption & retention

FieldClassProtection
number_records.e164INTERNAL-PLATFORM (carrier attribution is public telecom fact, not subscriber PII per ATRA guidance; retained for joinability)No RLS; TLS in transit; encryption-at-rest via PG transparent disk encryption per platform 13-sct §3
number_records.msisdn_hashINTERNALsha256 with platform pepper (Vault KV secret/ghasi/numint/msisdn_pepper); rotated quarterly with envelope re-keying; pepper_version column tracks
portability_history.msisdn_hashINTERNALSame
lookup_audit.msisdn_hashINTERNALsha256 with per-tenant salt (secret/ghasi/numint/tenant-salts/{tenantId}); cross-tenant re-derivation requires salt
eir_records.imeiRESTRICTEDOptional plain retention for admin view; hash is the key
eir_records.imei_hashINTERNALsha256 with platform pepper
hlr_probes.result_snapshotINTERNALJSONB; MSISDN only as hash; VLR retained
mno_snapshots.hlr_endpoint auth materialRESTRICTEDCredentials stored in Vault KV; snapshot holds only endpoint URL + auth-profile reference

5.1 Retention

TableHot (Postgres)Cold (S3)Rule
number_recordsIndefiniteActive records; decommissioned MSISDNs hard-delete after 24 months of last_seen inactivity
portability_history24 months7 y Object LockRegulator audit
reconciliation_runs24 months7 y Object Lock
reconciliation_conflictsIndefinite (while unresolved) + 12 months after resolve
hlr_probes90 daysSS7 PCAP correlation window
lookup_audit13 months7 y Object LockRegulator + billing dispute
audit_log13 months7 y Object Lock
eir_recordsIndefiniteAuthoritative mirror
msisdn_imei_observation180 daysObservational; not audit evidence
outbox7 days post-publish
idempotency_keys24 h

5.2 Cold archive flow

Per partitioned table:

  1. Daily cron numint-partition-archiver identifies partitions older than hot window.
  2. pg_dump --schema-only --table=… + COPY … TO PROGRAM 'zstd -19 > /tmp/….zst' to S3 with SSE-KMS.
  3. Object Lock governance mode retention per rule above (7 y where applicable).
  4. Verification: re-read first/last/middle rows; for hash-chained partitions, recompute chain across boundary.
  5. Drop partition from Postgres.

6. Migration strategy

  • Partitions provisioned 3 months ahead by PartitionMaintainer. Missing future partition fires NumIntPartitionMissing (HIGH).
  • Schema changes use expand/contract; forward-only migrations; Security review for any column holding hashable material.
  • ENUM evolution: ALTER TYPE numint.<enum> ADD VALUE 'NEW' BEFORE 'UNKNOWN' only; never rename or remove within v1.
  • Index changes: CREATE INDEX CONCURRENTLY; pg_repack for rewrites > 100 GB.
  • Redis schema versioning: key prefix change on shape change (numint:lookup:v2:…); old keys expire naturally.
  • Pepper rotation is envelope-style: each row carries pepper_version; a background job re-derives msisdn_hash with the new pepper and double-writes until cutover, then drops the old-version rows. Tenant salts rotate independently; cross-tenant correlation attacks via salt replay are thereby bounded in time.