Skip to main content

Schema reference

What this page is

A flat tabular reference for every table in the Ratiba schema — one section per table, columns called out with their types, constraints, and the ADR / migration that introduced them. Lookup material, not narrative.

For the why behind the shape — the migration ladder (public.0001 … 0009, tenant.0001 … 0013), the orm-vs-migration drift fences, and the schema invariants — read Architecture → Schema evolution. That page covers the story; this page covers the columns.

For the boundary concept between the two pools, see How it works → Identity and tenancy.

For column-level detail beyond what's printed here, the source of truth is psql — see the psql cheat sheet at the bottom.


ER overview

The diagrams below give a structural overview before the per-table column lists. Two separate ER diagrams reflect the two-pool topology (see ADR-0002 and Schema evolution for the full account). public.* holds the shared registry; tenant_<slug>.* holds per-tenant domain rows — the two pools never share FK constraints at the migration level.

public.* registry pool

tenant_<slug>.* core booking domain

The most-queried cluster. All tables live in the same per-tenant schema with a shared transactional boundary; FK constraints are intra-schema.

tenant_<slug>.* knowledge and conversation

KNOWLEDGE_SNIPPETS is intentionally standalone — no FK to any other table. It is scoped to the tenant by search_path alone (ADR-0013). The category field drives intent-routing in app/services/knowledge.py::fetch_snippets(). NO embedding column in Phase 0; that is the single additive Phase-1 upgrade trigger.


public.* tables (shared registry)

Five tables plus two archive tables in the public schema. Every tenant in the platform is a row in public.tenants; every cross-tenant lookup (webhook routing, dead-letter queues, tenancy registry) lives here. Schema-per-tenant isolation (ADR-0002) means every per-tenant table lives outside this schema.

public.tenants — tenant registry

The canonical registry row. One row per tenant. Carries every channel credential, every per-tenant dial, every payment configuration knob, every cost ceiling.

ColumnTypeNotes
idUUID PKgen_random_uuid() server default
nameVARCHAR(255) NOT NULLDisplay name
slugVARCHAR(100) UNIQUEURL-safe tenant identifier
schema_nameVARCHAR(100)Partial UNIQUE WHERE NOT NULL — tenant_<slug> until ADR-0002 D6 finalize-deletion sets NULL
verticalVARCHAR(20) NOT NULL CHECK'spa' | 'salon' | 'barbershop' | 'dental' | 'physio' | 'medical' | 'tutoring' | 'legal'
whatsapp_numberVARCHAR(20)Partial UNIQUE WHERE NOT NULL — inbound DID for WhatsApp
voice_numberVARCHAR(20)Partial UNIQUE WHERE NOT NULL — inbound DID for voice
timezoneVARCHAR(50)'Africa/Nairobi' server default
currencyVARCHAR(3)'KES' server default
localeVARCHAR(10)'en' server default
mpesa_enabledBOOLEANFALSE server default — opt-in payment rail (M-Pesa direct)
mpesa_shortcodeVARCHAR(20)Daraja shortcode
mpesa_passkeyTEXTDaraja passkey (legacy field; M6+ uses daraja_credentials)
statusVARCHAR(20) CHECK'active' | 'suspended' | 'trial' | 'deleted'
trial_ends_atTIMESTAMPTZ
deleted_atTIMESTAMPTZADR-0002 D6 soft-delete marker
pesapal_consumer_keyTEXT (encrypted)Encrypted at rest via EncryptedText
pesapal_consumer_secretTEXT (encrypted)Encrypted at rest via EncryptedText
pesapal_ipn_idUUIDPesaPal IPN registration handle
cost_ceiling_soft_usdNUMERIC(6,4) NOT NULL0.05 default — ADR-0005 D3 per-booking soft cap
cost_ceiling_hard_usdNUMERIC(6,4) NOT NULL0.20 default — ADR-0005 D3 per-booking hard cap
llm_role_overridesJSONB NOT NULL'{}' default — ADR-0005 D4 per-tenant LLM router overrides
handoff_admin_notice_secondsINT NOT NULL120 default — ADR-0006 D2
handoff_admin_reminder_secondsINT NOT NULL600 default — ADR-0006 D2
handoff_escalation_secondsINT NOT NULL3600 default — ADR-0006 D2
handoff_thresholdsJSONB NOT NULL'{}' default — ADR-0006 D5 per-tenant trigger thresholds
pesapal_nudge_secondsINT NOT NULL480 (8min) default — ADR-0007 D1
pesapal_abandon_secondsINT NOT NULL1800 (30min) default — ADR-0007 D1
voice_stk_max_hold_secondsINT NOT NULL90 default — ADR-0007 D3 voice STK hold cap
whatsapp_phone_number_idVARCHAR(50)Partial UNIQUE WHERE NOT NULL — Cloud API channel-boundary key (ADR-0008)
whatsapp_access_tokenTEXT (encrypted)Encrypted at rest via EncryptedText
whatsapp_business_account_idVARCHAR(50)Public WABA identifier (ADR-0008)
daraja_credentialsBYTEA (encrypted)Fernet-ciphered JSON blob — Daraja credential quartet (M6)
pesapal_credentialsBYTEA (encrypted)Fernet-ciphered JSON blob — PesaPal credential pair (M6)
payment_environment_overrideTEXT CHECKNULL or 'sandbox' | 'production' — overrides Settings.payment_environment
payment_enabledBOOLEAN NOT NULLFALSE default — opt-in payment rail at all (M6)
preferred_payment_providerTEXT NOT NULL CHECK'mpesa' | 'pesapal''mpesa' default (ADR-0007 D6)
voice_enabledBOOLEAN NOT NULLFALSE default — opt-in voice channel (M7)
voice_greeting_languageTEXT CHECKNULL or 'en' | 'sw'
voice_default_speakerTEXTElevenLabs voice id override
voice_eot_silence_msINTEnd-of-turn silence override (ms); NULL = use project default
voice_full_duplex_enabledBOOLEAN NOT NULLFALSE default — master switch for full-duplex turn-handling (voice-duplex W0)
voice_backchannel_filterBOOLEAN NOT NULLFALSE default — suppress false barge-in on filler words ("mm", "uh-huh")
voice_hard_interruptBOOLEAN NOT NULLFALSE default — repeated interrupts cancel TTS and return floor to caller
voice_streamingBOOLEAN NOT NULLFALSE default — sentence-stream seam to ElevenLabs (reduces first-audio latency)
voice_listening_ackBOOLEAN NOT NULLFALSE default — mid-utterance presence signals ("ndiyo", "okay")
voice_adaptive_speedBOOLEAN NOT NULLFALSE default — WPM adjusted to measured caller speech rate
voice_endpointing_min_delay_msINTNULL = use project default; floor of silence endpointing window
voice_endpointing_max_delay_msINTNULL = use project default; ceiling of silence endpointing window
admin_whatsapp_briefing_enabledBOOLEAN NOT NULLFALSE default — opt-in WhatsApp briefing-card rail (M9)
instagram_business_account_idTEXTInstagram channel id (ADR-0009 / M10)
instagram_access_tokenTEXTEncrypted at app layer
instagram_enabledBOOLEAN NOT NULLFALSE default
facebook_page_idTEXTMessenger DM channel id (M10)
facebook_page_access_tokenTEXTEncrypted at app layer
messenger_enabledBOOLEAN NOT NULLFALSE default
widget_primary_colorTEXT NOT NULL'#0F766E' default — web widget brand colour
widget_logo_urlTEXTWeb widget logo
widget_display_nameTEXTWeb widget header label
channel_steering_enabledBOOLEAN NOT NULLFALSE default — opt-in channel-switch chip (ADR-0009 D11)
phone_otp_requiredBOOLEAN NOT NULLFALSE default — STK-as-oracle when FALSE; SMS OTP when TRUE
reminder_lead_secondsINT NOT NULL86400 (24h) default — ADR-0009 reminder lead time
created_at / updated_atTIMESTAMPTZNOW() server defaults

Indexes: idx_tenants_status, plus the four partial-UNIQUE indexes on whatsapp_number, voice_number, schema_name, whatsapp_phone_number_id (each WHERE <col> IS NOT NULL).

public.tenant_admins

Phone-OTP login map for the dashboard rail. Composite UNIQUE on (tenant_id, phone_number) — same phone can administer multiple tenants but cannot duplicate within one.

ColumnTypeNotes
idUUID PKgen_random_uuid() server default
tenant_idUUID FK → tenants.id
phone_numberVARCHAR(20) NOT NULLComposite UNIQUE with tenant_id
keycloak_user_idUUID
roleVARCHAR(20)'owner' server default
is_activeBOOLEANTRUE server default
created_atTIMESTAMPTZNOW() server default

Indexes: idx_tenant_admins_phone.

public.payment_routing — STK correlation bridge (ADR-0002 D1 / ADR-0007)

Tenant-less webhook routing. Daraja / PesaPal callbacks arrive carrying only merchant_reference — this table maps that opaque ID back to its owning tenant + thread. Daily 3 AM EAT consolidated reaper trims rows by expires_at (ADR-0007 D5).

ColumnTypeNotes
merchant_referenceVARCHAR(40) PKProvider-echoed correlation id
tenant_idUUID NOT NULL FK → tenants.id
schema_nameVARCHAR(100) NOT NULLtenant_<slug> for the routing handler
thread_idVARCHAR(100) NOT NULLLangGraph conversation thread
providerVARCHAR(20) NOT NULL CHECK'mpesa' | 'airtel' | 'equitel' | 'pesapal'
expires_atTIMESTAMPTZ NOT NULL24h TTL — reaper sweeps past this
created_atTIMESTAMPTZ NOT NULLNOW() server default

Indexes: idx_payment_routing_tenant, idx_payment_routing_expires.

public.payment_callbacks_unrouted — dead-letter (ADR-0007 D6)

Late-arriving callbacks (past the 24h payment_routing TTL). Rare path; provider-outage scenarios only.

ColumnTypeNotes
idUUID PKgen_random_uuid()
providerVARCHAR(20) NOT NULL CHECKSame enum as payment_routing.provider
raw_payloadJSONB NOT NULLProvider callback body verbatim
received_atTIMESTAMPTZ NOT NULLNOW() server default
attempted_merchant_referenceVARCHAR(40)What merchant_reference we tried to match against
reviewed_atTIMESTAMPTZOps triage timestamp
reviewed_byUUIDReviewing admin user id
resolution_noteTEXTFree-text triage note

Partial index: idx_payment_callbacks_unrouted_unreviewed (received_at WHERE reviewed_at IS NULL) — keeps the daily ops dashboard query bounded as the queue grows.

public.inbound_quarantine — WhatsApp dead-letter (ADR-0008 / M4)

Inbound webhooks whose phone_number_id doesn't match any tenant. Channel-boundary handler writes here and returns 200 silently — no outbound reply, no information leak about which numbers Ratiba routes.

ColumnTypeNotes
idUUID PKgen_random_uuid()
received_atTIMESTAMPTZ NOT NULLNOW() server default
to_phone_number_idVARCHAR(50) NOT NULLThe unmatched Cloud API id
from_phoneVARCHAR(20) NOT NULLInbound sender
raw_payloadJSONB NOT NULLWebhook body verbatim
reviewed_at / reviewed_by / resolution_note(same shape as payment_callbacks_unrouted)

Partial index: idx_inbound_quarantine_unreviewed (mirrors the payment-side DLQ shape so ops queries against the two queues share a mental model).


tenant_<slug>.* tables (per-tenant)

Twenty-three tables per tenant (M3 baseline through Phase 0). Migration chain owned by backend/alembic/versions/tenant/ and replayed from baseline on every new tenant onboarding (ADR-0002). The two-pool boundary is explained in How it works → Identity and tenancy.

services (M3 baseline)

Catalog row. Edited by M11 dashboard + slash + NL routing. The description field is surfaced in Phase 0 knowledge answers via fetch_snippets() — see ADR-0013.

ColumnTypeNotes
idUUID PKgen_random_uuid()
nameVARCHAR(255) NOT NULL
name_swVARCHAR(255)Swahili service name
descriptionTEXTLong-form description; surfaced in Phase 0 KB answers
duration_minINT NOT NULLAppointment duration in minutes
priceNUMERIC(10,2) NOT NULL
currencyVARCHAR(3)'KES' default
buffer_minINT15 default — post-appointment gap before next booking
is_activeBOOLTRUE default
sort_orderINT0 default
created_at / updated_atTIMESTAMPTZ

staff (M3 baseline + M13 employment attrs)

Core staff record. M13 adds Workpay-authoritative employment fields (cached read-only mirror; M17 will sync from Workpay monthly).

ColumnTypeNotes
idUUID PK
nameVARCHAR(255) NOT NULL
phone_numberVARCHAR(20)
is_activeBOOLTRUE default
created_at / updated_atTIMESTAMPTZ
kra_pinTEXTM13; CHECK regex ^[AP][0-9]{9}[A-Z]$
nssf_numberTEXTM13
shif_numberTEXTM13 (formerly NHIF)
hire_dateDATEM13
employment_typeVARCHAR(20)M13; CHECK 'permanent' | 'casual' | 'commission_only' | 'apprentice'; default 'permanent'
base_pay_kes_minorINTM13; gross monthly base pay in KES minor units (divide by 100 for KES)

staff_services (M3 baseline)

Many-to-many staff↔service. Composite UNIQUE on (staff_id, service_id).

ColumnTypeNotes
idUUID PK
staff_idUUID FK → staff.idCASCADE delete
service_idUUID FK → services.idCASCADE delete

staff_schedules (M3 baseline)

Recurring weekly availability per staff member. Composite UNIQUE on (staff_id, day_of_week).

ColumnTypeNotes
idUUID PK
staff_idUUID FKCASCADE delete
day_of_weekSMALLINT NOT NULL CHECK0 (Mon) – 6 (Sun)
start_timeTIME NOT NULL
end_timeTIME NOT NULL
is_availableBOOLTRUE default

staff_blocks (M3 baseline)

Ad-hoc unavailability windows (holiday, sick leave, personal time).

ColumnTypeNotes
idUUID PK
staff_idUUID FKCASCADE delete
start_atTIMESTAMPTZ NOT NULL
end_atTIMESTAMPTZ NOT NULL
reasonVARCHAR(255)
created_atTIMESTAMPTZ

contacts (M3 baseline)

Pre-M10 customer record. Co-exists with M10's customers table — contacts is the M3-era identity table for appointment booking; M10's customers carries the channel-agnostic identity layer. The two are bridged at the DAO layer (no FK between them).

ColumnTypeNotes
idUUID PK
phone_numberVARCHAR(20) NOT NULL UNIQUEAuto-indexed
nameTEXT
emailTEXT
language_prefTEXT'en' default
tagsTEXT[]'{}' default
notesTEXT
total_bookingsINTRunning count
total_spentNUMERICRunning sum
last_visit_atTIMESTAMPTZ
created_at / updated_atTIMESTAMPTZ

appointments (M3 baseline + M13 rendering actuals)

The booking row — the workhorse table. M13 adds four columns for commission rendering actuals (actual clock-in/out, tip, staff notes).

ColumnTypeNotes
idUUID PK
contact_idUUID FK → contacts.id
service_idUUID FK → services.id
staff_idUUID FK → staff.id
start_atTIMESTAMPTZ NOT NULLScheduled start
end_atTIMESTAMPTZ NOT NULLScheduled end
statusVARCHAR(20) CHECK'pending' | 'confirmed' | 'cancelled' | 'completed' | 'no_show'
payment_statusVARCHAR(20) CHECK'unpaid' | 'pending' | 'paid' | 'refunded'
payment_refTEXT
amountNUMERIC
booking_channelTEXT CHECK'whatsapp' | 'voice' | 'dashboard' | 'web' | 'instagram' | 'messenger'
cancelled_byTEXT CHECK'customer' | 'admin' | 'system'
cancel_reasonTEXT
reminder_sentBOOLFALSE default
actual_start_atTIMESTAMPTZM13 — clock-in (may differ from start_at)
actual_end_atTIMESTAMPTZM13 — clock-out; CommissionEngine falls back to end_at if NULL
tip_amountNUMERIC(10,2)M13 — tip in KES; NULL = no tip
staff_notesTEXTM13 — freeform notes added by staff post-service
created_at / updated_atTIMESTAMPTZ

Indexes: idx_appointments_staff_date (partial WHERE status != 'cancelled'), idx_appointments_contact, idx_appointments_status_date.

interactions (M3 baseline)

Per-message audit log. Every inbound and outbound message across all channels creates a row here.

ColumnTypeNotes
idUUID PK
contact_idUUID FK
channelVARCHAR(20) NOT NULL CHECK'whatsapp' | 'voice' | 'dashboard'
directionVARCHAR(10) NOT NULL CHECK'inbound' | 'outbound'
message_typeTEXT
contentTEXT
intentTEXTClassifier output
languageTEXT'en' | 'sw'
duration_msINTVoice message duration
metadataJSONBChannel-specific extras
created_atTIMESTAMPTZ

Index: idx_interactions_contact (contact_id, created_at DESC).

payments (M3 baseline + ADR-0007 D9)

Payment attempt row. UNIQUE DEFERRABLE INITIALLY DEFERRED on mpesa_receipt (ADR-0007 §62 — triple-layer idempotency layer 1).

ColumnTypeNotes
idUUID PK
appointment_idUUID FK
contact_idUUID FK
amountNUMERIC(10,2) NOT NULL
currencyVARCHAR(3)'KES' default
providerVARCHAR(20) CHECK'mpesa' | 'airtel' | 'equitel' | 'pesapal'
mpesa_receiptVARCHAR(50)UNIQUE DEFERRABLE INITIALLY DEFERRED
mpesa_checkout_idVARCHAR(100)STK checkout request id
phone_numberTEXTPaying phone
statusVARCHAR(30) CHECK'initiated' | 'pending' | 'completed' | 'failed' | 'timeout' | 'cancelled_by_customer' (widened to 30 chars, ADR-0007 D9)
initiated_atTIMESTAMPTZ
completed_atTIMESTAMPTZ
callback_payloadJSONBRaw provider callback
created_atTIMESTAMPTZ

Indexes: idx_payments_checkout, idx_payments_appointment.

conversation_threads (M3 baseline + ADR-0003 D4 + ADR-0005 D5)

LangGraph thread pointer table. Composite PK (customer_phone, thread_id).

ColumnTypeNotes
customer_phoneVARCHAR(20) NOT NULLPart of composite PK
thread_idVARCHAR(100) NOT NULLULID-based; part of composite PK
created_atTIMESTAMPTZ
closed_atTIMESTAMPTZ
closed_reasonTEXT CHECK'done' | 'abandon' | 'closed_by_human'
channel_startedTEXT NOT NULL CHECK'whatsapp' | 'voice' | 'web' | 'instagram' | 'messenger'
actor_typeTEXT NOT NULL CHECK'customer' | 'admin'

Partial indexes: idx_threads_active_customer, idx_threads_active_admin (each WHERE closed_at IS NULL AND actor_type = '<…>').

handoff_log (M3 baseline + M9 admin-claim columns)

Verbatim transcript fold per ADR-0006 D7. M9 added four claim columns (mirrored on handoff_log_archive).

ColumnTypeNotes
idUUID PK
thread_idTEXT NOT NULL
actorTEXT NOT NULL CHECK'customer' | 'admin'
actor_phoneTEXT NOT NULL
contentTEXT NOT NULLVerbatim message text
languageTEXT'en' | 'sw'
sent_atTIMESTAMPTZNOW() default
message_metadataJSONBMeta read-receipts (360dialog / Cloud API)
claimed_by_admin_idUUIDM9; nullable
claimed_atTIMESTAMPTZM9; nullable
admin_replied_atTIMESTAMPTZM9; nullable
claimed_viaTEXT CHECKM9; 'dashboard' | 'whatsapp'

Index: idx_handoff_log_thread (thread_id, sent_at).

handoff_log_archive (M3 baseline)

CREATE TABLE … (LIKE handoff_log INCLUDING ALL) clone — the daily 3 AM EAT reaper INSERTs past-90-day rows here then DELETEs from the live table. Column-set parity with handoff_log is enforced by the test_handoff_log_archive_clones_via_like test (M9 added four claim columns to BOTH tables in the same migration to maintain parity).

customers (M10 — channel-agnostic identity)

Per ADR-0009 D5. phone_e164 is nullable — Tier-2 sessions start phoneless and get progressively phone-captured.

ColumnTypeNotes
customer_idUUID PK
phone_e164TEXTPartial UNIQUE WHERE NOT NULL (uq_customers_phone_e164)
nameTEXT
dobDATE
language_prefTEXT
created_atTIMESTAMPTZ

customer_sessions (M10)

One row per active conversation thread. Tier-2 sessions start with customer_id = NULL + unverified = TRUE; the COLLECT_PHONE FSM node back-fills customer_id and stamps promoted_at.

ColumnTypeNotes
idUUID PK
customer_idUUID FK → customers.customer_idSET NULL on delete — preserves audit trail
nameTEXT
language_prefTEXT
unverifiedBOOL NOT NULLFALSE default
handoff_tokenTEXTPartial UNIQUE WHERE NOT NULL (uq_customer_sessions_handoff_token) — channel-switch token
handoff_token_expires_atTIMESTAMPTZ24h TTL (ADR-0009 D11)
created_atTIMESTAMPTZ
promoted_atTIMESTAMPTZSet when phone collected by COLLECT_PHONE node

customer_identities (M10)

Phone-only deterministic cross-channel identity per ADR-0009 D5. Composite PK on (customer_id, provider, external_id).

ColumnTypeNotes
customer_idUUID NOT NULL FK → customers.customer_idCASCADE delete
providerTEXT NOT NULL CHECK'whatsapp' | 'voice' | 'web_cookie' | 'instagram' | 'messenger'
external_idTEXT NOT NULLChannel-native identifier
created_atTIMESTAMPTZ

Index: ix_customer_identities_alias (provider, external_id) — hot-path reverse lookup for every Tier-2 inbound turn.

service_relations (M11 / ADR-0010 D5)

LLM-inferred relation graph populated at catalog onboarding. Composite PK (service_a_id, service_b_id, relation_type).

ColumnTypeNotes
service_a_idUUID NOT NULLSource service (no FK — app-enforced)
service_b_idUUID NOT NULLTarget service (no FK — app-enforced)
relation_typeTEXT NOT NULL CHECK'complementary' | 'alternative' | 'sequential'
sourceTEXT NOT NULL CHECK'llm_inferred' | 'tenant_pinned' | 'association_rule'
confidenceDOUBLE PRECISIONNullable for admin pins (tenant_pinned)
reasonTEXTLLM explanation
created_atTIMESTAMPTZ

Partial index: idx_service_relations_complementary (service_a_id, confidence DESC WHERE relation_type = 'complementary' AND confidence >= 0.7) — hot-path v1 cross-sell lookup. No FK to services — referential integrity enforced at app layer (M5's services PK shape predates this design).

catalog_imports (M11 / ADR-0010 D8 + D12)

One row per upload (image / multi_image / pdf / text / csv). The snapshot_jsonb enables a single-snapshot rollback within a 7-day window.

ColumnTypeNotes
import_idUUID PK
modalityTEXT NOT NULL CHECK'image' | 'multi_image' | 'pdf' | 'text' | 'csv'
typeTEXT NOT NULL CHECK'initial' | 're-import' | 'manual'
snapshot_jsonbJSONB NOT NULLPre-import state — D12 7-day rollback window
extracted_jsonbJSONB NOT NULLRaw extraction output
committed_jsonbJSONBNullable; populated on admin commit
actor_admin_idUUIDNullable
confidence_summaryJSONBPer-field confidence scores
created_atTIMESTAMPTZ
committed_atTIMESTAMPTZ

Index: idx_catalog_imports_actor (actor_admin_id, created_at).

catalog_audits (M11 / ADR-0010 D11)

Per-row edit trail (one row per service-level mutation).

ColumnTypeNotes
audit_idUUID PK
actionTEXT NOT NULL CHECK'insert' | 'update' | 'delete' | 'toggle'
service_idUUID NOT NULLNo FK — app-enforced referential integrity
beforeJSONBNullable — absent on inserts
afterJSONBNullable — absent on deletes
actor_admin_idUUIDNullable (system-initiated mutations)
sourceTEXT NOT NULL CHECK'slash' | 'nl' | 'import' | 'dashboard'
created_atTIMESTAMPTZ

Index: idx_catalog_audits_service_time (service_id, created_at).

tenant_personality_config (M11 / ADR-0010 D2 + D4 + D10)

Per-tenant override row for the six personality dials. Singleton — partial UNIQUE on is_singleton WHERE is_singleton = TRUE. NULL on a dial column = inherit per-vertical YAML default (D4). Enum validation is enforced by PersonalityConfig in the app layer, not at the DB level.

ColumnTypeNotes
tenant_personality_idUUID PK
is_singletonBOOL NOT NULLTRUE default — partial UNIQUE uq_tenant_personality_singleton
toneTEXTNullable — NULL inherits YAML default
greeting_modeTEXTNullable
greeting_customTEXTNullable; max 140 chars (app-enforced)
upsellTEXTNullable
cancellation_toneTEXTNullable
honorificTEXTNullable
cross_sellTEXTNullable
updated_atTIMESTAMPTZ
updated_by_admin_idUUIDNullable

dial_audits (M11 / ADR-0010 D11)

Dial-mutation audit row. 90-day retention parity with handoff_log (daily 3 AM EAT reaper).

ColumnTypeNotes
audit_idUUID PK
dialTEXT NOT NULL CHECK'tone' | 'greeting_mode' | 'greeting_custom' | 'upsell' | 'cancellation_tone' | 'honorific' | 'cross_sell'
beforeJSONB
afterJSONB
actor_admin_idUUID
created_atTIMESTAMPTZ

Index: idx_dial_audits_time (created_at).

commission_rules (M13 / ADR-0012)

Per-staff or per-service-category commission rules. The staff_id XOR service_category constraint (ck_commission_rules_staff_xor_category) ensures every rule applies to exactly one target axis.

ColumnTypeNotes
idUUID PK
staff_idUUID FK → staff.idCASCADE; XOR with service_category
service_categoryVARCHAR(50)XOR with staff_id
rule_typeVARCHAR(10) NOT NULL CHECK'pct' | 'fixed' | 'tiered'
valueJSONB NOT NULLpct: 0–100; fixed: KES minor units; tiered: [{threshold, rate}] array
valid_fromTIMESTAMPTZ NOT NULLNOW() default
valid_toTIMESTAMPTZNULL = currently active; CHECK valid_to > valid_from
created_atTIMESTAMPTZ NOT NULL

Index: idx_commission_rules_staff_active (staff_id WHERE valid_to IS NULL) — hot-path for active-rule lookup.

commission_period (M13 / ADR-0012)

Frozen monthly commission snapshot per staff member. One row per (staff_id, period_start, period_end, type) — idempotency key uq_commission_period_idempotency makes re-running CommissionEngine for the same period a no-op. Amendments create a new row with type='amendment' pointing back to the original close row.

ColumnTypeNotes
idUUID PK
staff_idUUID NOT NULL FK → staff.idCASCADE
period_startDATE NOT NULLInclusive start of period
period_endDATE NOT NULLInclusive end; CHECK >= period_start
commission_kes_minorINT NOT NULLComputed commission in KES minor units; 0 default
tip_kes_minorINT NOT NULLTotal tips in KES minor units; 0 default
hours_workedNUMERIC(8,2) NOT NULLDerived from actual_start_at/actual_end_at; 0 default
services_rendered_countINT NOT NULLCount of completed appointments in period; 0 default
typeVARCHAR(15) NOT NULL CHECK'close' | 'amendment'; 'close' default
amendment_of_idUUID FK → commission_period.idNULL for close rows; required for amendment rows
frozen_atTIMESTAMPTZ NOT NULLNOW() default
vendor_period_idVARCHAR(100)Workpay period id for future M17 sync

Unique: uq_commission_period_idempotency (staff_id, period_start, period_end, type). Index: idx_commission_period_staff_period (staff_id, period_start).

knowledge_snippets (Phase 0 — ADR-0013)

Per-tenant curated knowledge for free-form inquiry answers. Phase 0 "no-RAG RAG" approach — snippets are injected directly into the answer_shaper prompt via raw_data["knowledge"]. No embedding column — that is the single additive Phase-1 upgrade trigger. Scoped to the tenant by search_path alone (no tenant-id column needed). Hand-seeded via scripts/seed_knowledge.py; no conversational or dashboard authoring in Phase 0.

ColumnTypeNotes
idUUID PKgen_random_uuid()
categoryTEXT NOT NULLIntent-routing key: services, hours, other, general
titleTEXT NOT NULLShort heading for the snippet
bodyTEXT NOT NULLAnswer text (Swahili or English)
languageTEXT NOT NULL'en' default — EN snippets render in SW answers (not filtered)
is_activeBOOLEAN NOT NULLTRUE default — FALSE = soft-delete without data loss
created_atTIMESTAMPTZ NOT NULL
updated_atTIMESTAMPTZ NOT NULL

Index: idx_knowledge_snippets_category (category WHERE is_active) — covers the hot-path query in fetch_snippets(intent).

Cap: ~20 snippets / ~1,500 tokens per tenant. Exceeding the cap emits a knowledge_overflow WARN log — the observable Phase-0 → Phase-1 graduation signal (see ADR-0013).

LangGraph checkpoints family (M3 — owned by PostgresSaver)

Three tables — checkpoints, checkpoint_blobs, checkpoint_writes — created by PostgresSaver.setup() at onboarding-time, NOT by an Alembic migration (ADR-0003 D2). Their archive twins (checkpoints_archive, etc.) are the destination of the daily reaper sweep at ADR-0003's 90-day retention window. Schema is owned by the LangGraph library — see LangGraph PostgresSaver docs rather than enumerating columns here.



psql cheat sheet

For column-level questions beyond what's listed above, the source of truth is psql against the live database. Connect to the development Postgres on :5434 (per project port assignments):

# Connect (development)
psql -h localhost -p 5434 -U ratiba ratiba

# List all schemas (one per tenant + public)
\dn

# List all tables in a tenant schema
\dt tenant_<slug>.*

# Full column-level detail for a single table
\d tenant_<slug>.appointments
\d public.tenants

# Migration head per chain
SELECT version_num FROM public.alembic_version;
SELECT version_num FROM tenant_<slug>.alembic_version;

# Quick row-count snapshot for a tenant's hot tables
SELECT
(SELECT count(*) FROM tenant_<slug>.appointments) AS appointments,
(SELECT count(*) FROM tenant_<slug>.payments) AS payments,
(SELECT count(*) FROM tenant_<slug>.knowledge_snippets
WHERE is_active) AS active_snippets,
(SELECT count(*) FROM tenant_<slug>.conversation_threads
WHERE closed_at IS NULL) AS active_threads;

When in doubt, \d <table> always wins — it prints every column, every constraint, every index, every default in one go.