System Tables¶
Addresses¶
Addresses (addresses)¶
Purpose: Store physical addresses for billing, shipping, and vendor locations
Use Case Example: Store the production office address, vendor billing addresses, or equipment delivery locations.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| address_line_1 | TEXT | Primary address line (street number and name) |
| address_line_2 | TEXT | Secondary address line (suite, unit, etc.) |
| city | TEXT | City name |
| state_province | TEXT | State or province name |
| postal_code | TEXT | Postal or ZIP code |
| country | TEXT | Country name |
| location | geography(Point) | PostGIS geographic point for mapping/distance calculations |
| metadata | JSONB | JSON metadata including external_address_id (e.g., Google Place ID), provider, etc. |
| search_query | TSVECTOR | Full-text search vector for fast address matching and deduplication (auto-generated) |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- PostGIS support for geographic queries
- Full-text search support for efficient address matching
- Unique constraint on all address fields combined
- Can be referenced by multiple entities
- RLS Policies:
- SELECT is scoped to referencing-row accessibility: a caller may read an address only if they can see a row that references it —
entities.address_id,transactions.billing_address_id/customer_address_id, or thepayment_details.bank.address_idJSONB pointer onproject_relationships/entities. This is enforced by thecaller_can_access_address(uuid)SECURITY INVOKER function (each innerEXISTSruns under the caller's own RLS, so it matches only references the caller can see). Previously SELECT wastrue— any user could read every address (a cross-tenant PII read hole). - UPDATE: no policy — addresses are immutable for the
authenticatedrole. addresses are globally deduplicated/shared across tenants (the all-fields unique constraint collapses identical addresses into one row), so an in-place UPDATE — even scoped tocaller_can_access_address— let any referencing tenant tamper with a row another tenant also references. There is no legitimate in-place edit: every flow that "changes an address" find-or-creates a new address row viaaddress/server.findOrCreateAddressand re-points the parent FK under the parent's own edit permission.service_rolestill updates addresses (RLS bypass) for the system geocoding-enrichment path insidefindOrCreateAddress. - Adding a new FK to addresses: add one more
EXISTSclause tocaller_can_access_address— that function is the single chokepoint; nothing else in the addresses RLS changes. - Sanctioned RLS bypass for display:
private.format_address(uuid)(SECURITY DEFINER, not API-exposed) formats an address WITHOUT re-runningcaller_can_access_address, for view columns whose address id is already visibility-gated upstream (a visibleentities.address_id, or a bankaddress_idsurviving thepayment_detailsfield-mask). It is a formatter, not a read surface — any new call site needs a security review against those provenance rules; see VIEWS_AND_FUNCTIONS.md § Field-masking chokepoint helpers. - INSERT: any authenticated user (a new address is created before it is linked; an orphan discloses nothing). Because the scoped SELECT policy can't see a brand-new, not-yet-referenced row, address creation runs under service-role in the server layer (
address/server.findOrCreateAddress); the relationship bank-address bag is resolved inproject/server.updateProjectRelationshipfor the same reason. - DELETE intentionally omitted — addresses are preserved for audit history and linked records.
Abuse Prevention¶
Rate Limit Configs (rate_limit_configs)¶
Purpose: Configurable rate limits by channel and user type
Use Case Example: WhatsApp limited to 5 messages/minute for regular users, 10 for premium users.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| channel | TEXT | Channel to apply limit to |
| limit_type | TEXT | per_minute/per_hour/per_day/per_instance |
| message_count | INTEGER | Maximum messages allowed |
| user_type | TEXT | User category for differentiated limits |
| is_active | BOOLEAN | Whether this limit is enforced |
| created_at | TIMESTAMPTZ | Creation timestamp |
| updated_at | TIMESTAMPTZ | Last update timestamp |
Key Features:
- Flexible limits per channel
- Different limits for user types
- Can be updated without code changes
- Per-instance limits prevent endless conversations
- RLS Policies: Authenticated read-only. Rate limit configuration managed by service_role
User Type Examples:
The user_type field enables differentiated rate limiting based on user categories:
- 'all' (default): Standard rate limits applied to all users
-
Example: 5 messages/minute, 60/hour, 200/day
-
'new': Stricter limits for newly registered users (e.g., first 7 days)
- Example: 3 messages/minute, 30/hour, 100/day
-
Use case: Prevent spam from newly created accounts
-
'verified': Standard limits for users who have verified their phone/email
- Example: 5 messages/minute, 60/hour, 200/day
-
Use case: Normal usage for authenticated users
-
'premium': Higher limits for paid subscribers
- Example: 10 messages/minute, 120/hour, 500/day
-
Use case: Enhanced service for paying customers
-
'trusted': Minimal limits for long-standing users with good history
- Example: 20 messages/minute, 240/hour, 1000/day
- Use case: Reward loyal users with fewer restrictions
Implementation Note: The check_rate_limits_and_abuse() function accepts a p_user_type parameter (defaults to 'all') to apply the appropriate limits based on user category.
Limit Types:
The three time-window limits (per_minute, per_hour, per_day) are counted from conversations.rate_limit_data and reset on a clock. per_instance is different in kind:
per_instancecaps the number of messages on a singleconversation_instancesrow (500 for WhatsApp/'all';check_rate_limits_and_abusefalls back to 500 when no active config row matches). It exists to bound the context an AI conversation carries, not to punish the user.- It is counted against the ACTIVE instance only, never the sum across the conversation's closed and summarized instances — a historical sum only ever grows, which would make the cap permanent and permanently reject every further message.
- Reaching it produces
allowed = false, limit_type = 'instance', reset_at = NULL. Unlike the time-window limits there is nothing to wait for: the caller rolls the conversation onto a fresh instance viarollover_conversation_instanceand retries. SeeVIEWS_AND_FUNCTIONS.md§ Messaging System Functions. - Time-window limits apply a throttle (writing
is_throttled/throttled_untilback to the conversation); the instance limit writes nothing.
Abuse Patterns (abuse_patterns)¶
Purpose: Tracks detected abuse patterns in messaging
Use Case Example: User sends identical message 10 times rapidly, system detects spam pattern with 95% confidence.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| user_id | UUID | Reference to user |
| conversation_id | UUID | Reference to conversation |
| pattern_type | TEXT | spam/flooding/gibberish/profanity/phishing/api_abuse |
| confidence_score | NUMERIC | Pattern detection confidence (0.00-1.00) |
| detected_at | TIMESTAMPTZ | When pattern was detected |
| sample_messages | TEXT[] | Sample of problematic messages |
| action_taken | TEXT | warning/throttle/block/report |
| metadata | JSONB | Additional pattern details |
| created_at | TIMESTAMPTZ | Creation timestamp |
Key Features:
- Multiple pattern types detected
- Confidence scoring for accuracy
- Preserves evidence samples
- Tracks enforcement actions
Abuse Actions (abuse_actions)¶
Purpose: Progressive penalty system for repeat offenders
Use Case Example: User's first spam gets warning, second gets 15min throttle, third gets 1hr throttle, etc.
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier |
| user_id | UUID | Reference to user |
| action_type | TEXT | Type of action taken |
| action_severity | INTEGER | 1=warning, 2=short throttle, 3=long, 4=block |
| expires_at | TIMESTAMPTZ | When action expires |
| created_at | TIMESTAMPTZ | Creation timestamp |
Key Features:
- Progressive penalties increase
- Time-based expiration
- 30-day lookback for repeat offenses
- Automatic enforcement
Export System¶
The export system enables users to download data exports (transactions, compliance documents, etc.) with associated attachments in a ZIP format. Exports are processed asynchronously, stored temporarily in the exports storage bucket, and made available via time-limited signed URLs.
Exports (exports)¶
Purpose: Tracks export requests, their processing status, and download information
Schema:
| Column | Type | Description |
|---|---|---|
| id | UUID | Unique identifier for the export |
| user_id | UUID | User who requested the export |
| project_id | UUID | Project the export is for (nullable for organisation-level exports) |
| organisation_id | UUID | Organisation the export belongs to |
| type | export_type | Type of export: transactions, compliance |
| status | export_status | Current status: pending, processing, completed, failed |
| file_path | TEXT | Path to the zip file in the exports bucket (e.g., user_id/transactions_export_timestamp.zip) |
| download_url | TEXT | Current signed URL for downloading (valid until expires_at) |
| file_size | BIGINT | Size of the exported file in bytes |
| record_count | INTEGER | Number of records included in the export |
| filters | JSONB | JSON object containing the filters applied during export (for audit trail) |
| error_message | TEXT | Error details if the export failed |
| expires_at | TIMESTAMPTZ | When the download link expires (typically 24 hours after completion) |
| metadata | JSONB | Additional metadata: {download_link_regenerations, last_regenerated_at, etc.} |
| created_at | TIMESTAMPTZ | When the export was requested |
| updated_at | TIMESTAMPTZ | Last time the export record was updated |
| created_by_user_id | UUID | User who created the export (default: auth.uid()) |
| updated_by_user_id | UUID | User who last updated the export (default: auth.uid()) |
Key Features:
- Tracks export lifecycle from request to completion
- Stores signed download URLs with expiration times
- Supports multiple export types (transactions, compliance, etc.)
- Includes audit trail with filters and metadata
- Links to organisation and optionally to project
- Automatic cleanup after expiration
Indexes:
idx_exports_user_id- User's exportsidx_exports_project_id- Project-specific exportsidx_exports_organisation_id- Organisation exportsidx_exports_status- Query by statusidx_exports_created_at- Order by creation timeidx_exports_expires_at- Find expired exports (partial index where status = 'completed')
RLS Policies:
- Users can view their own exports
- Users can create exports
- Users can update their own exports
- Service role has full access
Storage Bucket: exports
- Private bucket (requires signed URLs)
- File structure:
{user_id}/{export_filename}.zip - 500MB max file size
- Allowed MIME types:
application/zip,application/x-zip-compressed,application/octet-stream - RLS policies ensure users can only access their own export files
Notification Templates:
export_ready- Sent when export is complete (in-app + optional email)export_failed- Sent when export fails (in-app + optional email)
Storage Buckets Summary¶
| Bucket | Public | Contents | Read access | Write access |
|---|---|---|---|---|
files |
No | Tenant data: receipts, invoices, contracts, identity documents | Uploader, OR a member of the attachment's project (via attachments join + caller_accessible_project_ids) |
Members of the org named in the path (organisations/<code>/...), or the user's own users/... |
exports |
No | Per-user data exports (ZIP) | The exporting user only ({user_id}/... folder scope) |
The exporting user only |
public-assets |
Yes | Global, non-tenant reference assets (provider logos, CSV-import templates) | Any authenticated user | Service-role only (seed / admin) |
- The
filesbucket is tenant-scoped: its SELECT policy joinsstorage.objects.nameback topublic.attachments.storage_path_or_url('files/' || name) and admits the row only when the caller uploaded it or can access itsproject_id. The INSERT policy scopes uploads to the caller's organisation viacaller_can_access_organisation_code((storage.foldername(name))[2])(aSECURITY DEFINERhelper — asecurity_invokerview does not resolve inside astorage.objectspolicy). UPDATE/DELETE remain owner-scoped. - Global reference assets (provider logos, import templates) live in
public-assets, NOTfiles, so the private bucket carries no world-readable carve-out.