Status · revised, supersedes prior version

ConnectIQ — Architecture

A single physical Postgres database, logically separated by domain — built to behave like microservices today, and to split into real ones later without a rewrite.

This page defines rules, not schema. Sections 2–9 are binding and are mirrored in the Lovable knowledge file. Domain names and table lists elsewhere in this document are planning aids only — finalised by whichever team builds that phase.
This page is the authority on architecture — domains, ownership, table naming and cross-domain access. For what gets built and in what order, see the delivery plan; where the two disagree on anything structural, this page wins.
1

Core principles

One database. Domains behave like separate services anyway — no domain writes into another domain's tables, ever. That single constraint is what keeps a shared database safe to build fast on now, and cheap to physically split later.

The shared database, drawn as if it were already split

ONE PHYSICAL POSTGRES DATABASE cl_ Customer writes: cl_ only ops_ Field Ops writes: ops_ only inv_ Inventory writes: inv_ only sub_ Subscription writes: sub_ only plat_domain_events (outbox) service call rpt_* views only

No arrows cross directly into another domain's boxes. The only three legal paths across a boundary: a service call, an event through the outbox, or a read through a rpt_* view.

1.1 · Why prefixes, not schemas

Supabase supports Postgres schemas fully. Lovable's generated code assumes default-schema access — steering it into .schema('x') calls on every prompt is fragile. See Appendix A.

Cost accepted: schemas would give a one-line permission boundary. Prefixes give none by default — the five substitutes below are mandatory, not optional.
1Per-domain Postgres roles + table grants
2RLS on every table
3CI lint: no cross-domain import
4Knowledge-file guardrails per prompt
5Code review against §2 matrix

1.2 · Prefix registry — locked

The prefix is the ownership declaration. Never invent a variant for an existing domain.

cl_Customer & Location
com_CRM / Commerce
plan_Sales Handover
ops_Field Operations
inv_Inventory / Asset
sub_Subscription
sup_Support / Incident
mon_Device Health
int_Integration / Sync
rpt_Reporting
plat_Platform
2

Cross-domain interaction rules

The operative section — every possible interaction between two domains falls into exactly one of these thirteen rows.

#InteractionAllowedNotes
1Store another domain's ID as a referenceYesAnywhere
2Call another domain's service client / APIYesThe only operational path for cross-domain data
3Snapshot a value at event timeYesSubject to §5
4Cross-domain read joinRestrictedOnly inside a rpt_* view, owned by Reporting
5Application reads a rpt_* viewYesThe only cross-domain read surface for app code
6Insert into shared audit / log tablesYesInsert-only
7Emit a domain eventYesThe only way to trigger a cross-domain write
8Same-domain direct readYesUnrestricted
9App reads another domain's raw tableNoIncluding reporting and dashboard code
10Nested/embedded PostgREST select across prefixesNoSee §2.2
11Direct write to another domain's tableNoNo exception
12Cross-domain database transactionNoNo exception
13Admin repair scriptExceptionApproved, logged, never part of a normal workflow

2.1 · The operational read rule

Jobs screen cl_locations ✕ direct join CL service client or a snapshot taken at job creation

Stricter than typical shared-DB design, on purpose — exactly one operational path survives a future physical split.

2.2 · Cross-prefix foreign keys — kept

ON DELETE RESTRICT. Reversal asymmetry decides it: dropping a constraint is instant; adding one later means repairing every orphan first.

Keep FKNo FK
IntegrityOrphans impossibleFound late
Reverse costOne statementUnknown cleanup
BackfillBad rows rejectedLand quietly
The one real cost: an FK makes PostgREST's nested select available — exactly the join rule 10 forbids. FK stays for integrity; the join stays forbidden in code review.
3

Domain events — the outbox pattern

An event is written in the same transaction as the state change that caused it. A dispatcher delivers it later. This guarantees an event is never lost on commit, and never fired on rollback.

com_ CRM/Commerce 1 transaction: update quote status + insert event row plat_domain_events QuoteApproved processed_at: null dispatcher plan_ Planning consumes event creates JobCreated idempotent QuoteApproved → JobCreated → ResourceRequired → SubscriptionActivated

3.2 · Transport

Supabase Edge Function on a schedule, or pg_notify for latency-sensitive cases. Transport is replaceable — the outbox table is not.

3.3 · Naming

Past-tense facts, not commands: QuoteApproved, JobCreated, UnitReplaced. The consumer decides what to do.

3.4 · Not webhooks

int_webhook_events is inbound-external. plat_domain_events is internal. Different tables, never merged.

4

Read projections

A read-only representation of data owned by another domain, exposed without transferring ownership. Three permitted forms:

View projection

rpt_* view

security_invoker = true. For reporting and dashboard reads.

Service projection

Domain service client

The default. For operational reads.

Mirror projection

int_*_mirror

Physical synced table. Migration only — source of truth still lives outside Supabase.

All three: read-only to the consumer, owning domain stays sole source of truth, never written back to. Where the planning maps say "read projection," assume service projection unless a table is explicitly a mirror.
5

Snapshot vs reference

Snapshot when the value must be frozen in time. Reference when the current value is wanted.

Snapshot — frozen at the moment

customer today quote.customer_name_snapshot frozen forever name changes later → quote still reads old name ✓

Quote pricing, contract snapshots, delivery-note recipient, invoice line description.

Reference — always current

customer today job.cl_customer_id ID only job screen shows live name ✓

Job-screen customer, dashboard KPIs, active location list, support ticket site.

Test: if the source record changes tomorrow, should this document read differently? No → snapshot. Yes → reference.
6

Identifier & key conventions

Every table: id uuid primary key default gen_random_uuid(). Decided by Supabase branching — preview branches, Airtable-loaded data, and production are three ID spaces that bigint sequences would collide across; UUIDs never do.

cl_customers.id uuid · primary key customer_code CUST-00045 · human-facing int_external_system_mappings system: 'airtable' external_id: 'recABC123' internal_id int_external_system_mappings system: 'hubspot' external_id: '9981234'

Airtable, HubSpot, Freshdesk and Xero IDs never live in domain tables — only in the mapping table. That's what keeps migration reversible.

6.2 · API routes

Address records by UUID. Codes are for humans and lookup, not addressing.

6.4 · Same-domain FK

<entity>_id — e.g. job_id

6.4 · Cross-domain FK

<prefix><entity>_id — e.g. cl_location_id

Rejected: bigint identity (branch/seed collision is fatal here); ULID/text (not native to Postgres, Lovable won't generate it).

7

Ownership tiebreak rule

The domain that performs the write owns the table. If two domains write, the concept is two tables.
Can it move to another unit? yes no sub_licence_entitlements owned by Subscriptions inv_unit_licences owned by Inventory
1Naming forces the decision — prefix is the declaration
2Sets dependency direction — which domain calls which
3Unowned means built twice, or not at all
8

Reporting layer

Reporting reads domain tables directly — but only through rpt_* views. The view is the seam that makes deferring a real read-model layer safe.

cl_ tables ops_ tables inv_ tables rpt_* views security_invoker = true owned by Reporting, read-only across all domains Dashboards / exports Customer portal ✕ portal / dashboards never read raw domain tables directly
Non-negotiable: a Postgres view runs with the creator's rights by default, bypassing RLS. Without security_invoker = true, a portal user could see every customer's rows through a view that looks correct.
Same-domain dashboards may read their own tables directly — no view required. Only crossing a prefix boundary forces the view.
9

Transactions

Allowed

Within one domain — e.g. creating a Job and its Job Tasks together.

Forbidden

Across domains — sequenced instead by events and statuses: QuoteApproved → JobCreated → ResourceRequired → SubscriptionActivated, each in its own domain transaction.

10

Migration: backfill and cutover

Not a continuous two-way sync — a transform-and-load into real domain tables, followed by a permanent cutover.

Cutover entities — customers, locations, contacts, brands

01
Load
Transform & load into cl_ / com_
02
Validate
Build & test against real data
03
Freeze
Source read-only, final delta
04
Reconcile
Row counts + integrity checks
05
Flip
CRM becomes source of truth
06
Disable
Old sync switched off at source

Mirror entities

Source of truth stays in Airtable/Freshdesk a while longer: loaded into int_*_mirror, read-only, carry synced_at, never joined into an operational write path, retired per-domain as each is migrated.

10.2 · Critical requirements, by consequence

1UUID stability across load runs — highest-risk detail in the migration
2Idempotent upsert keyed on external ID, never name/email
3Reverse kill switch — disable old sync at source
4Freeze window — source read-only during final delta
5Reconcile, with a stated rollback, before the flip
11

Indicative domain map

Planning only — not binding. Finalised by whichever team builds each phase.
cl_

Customer & Location

Customer, brand, location, geography, arrangement master data

com_

CRM / Commerce

Opportunities, quotes, quote lines, pricing, contract creation

plan_

Sales Handover

Converts signed quote to execution-ready scope; BOM, readiness

ops_

Field Operations

Jobs, tasks, schedules, partners, workers, maintenance

inv_

Inventory / Asset

Resources, units, stock locations, movements, scans, delivery notes

sub_

Subscription

Contracts, active subscriptions, licences, renewals

sup_

Support / Incident

Tickets, SLA, OOS items, compensation, ticket logs

mon_

Device Health

Device status, heartbeat, TargetR/Lisa status, uptime alerts

int_

Integration / Sync

Airtable, HubSpot, Freshdesk, imports, webhook logs, ID mapping

rpt_

Reporting

Cross-domain read models and dashboards

plat_

Platform

Domain events, audit, configuration, identity

gap

Unresolved

Identity & Access, Finance & Billing, Procurement, Notifications, Audit — owner assigned when needed

12

Indicative table-to-domain map

Planning only. Open questions resolved at build time using the §7 tiebreak rule.
cl_

Customer & Location

Customers, Locations, Brands, Brand owners, Stores, Countries, Regions, Subdivisions, Location types, Arrangements. Import locations → Integration/Sync.

? Where do contacts live — here or CRM/Commerce
ops_

Field Operations

Jobs, Tasks, Schedule log, Maintenance packages/items/cycles/jobs, FS Partners, FS Workers. Locations, Resources, Inventory, Subscriptions, Tickets → read projections only.

? Job creation batches & templates — Planning or Ops
plan_

Sales Handover / Planning

BOM, BOM resources, Job creation batches, Readiness checklist — the bridge between quotation and operations.

inv_

Inventory / Asset

Inventory units, Resources, Stock locations, Inventory log, Sets, Delivery notes, Scan line items, Consumable movements, Attributes, Specs.

? Licences — resolved by the movability test (§7)
sub_

Subscription / Contract

Subscription contracts, Subscriptions, Subscription types, subscription-to-unit assignment (§13).

? Contracts — CRM/Commerce or Subscription; may be two concepts
sup_

Support / Incident

Tickets, Ticket log, SLA, OOS items, Menu items, Compensation. Customers/Locations/Jobs → read projections.

? Agents & Weeks — ownership open
com_

CRM / Commerce

Quotes, Quote line items, Products, Price books, Contracts, Billing entities, VAT/payment terms. Keep HubSpot stable while foundational domains are built.

mon_

Device Health

TargetR Status, TargetR PoP, Lisa Box Status, Monitor, Last online, Time offline. Inventory knows a serial is a device; Monitoring knows if it's healthy.

int_

Integration / Sync

Import staging, HubSpot/Freshdesk sync, scan ingestion, external system mappings, sync logs, webhook events, mirror tables. A technical service, not a business owner — it moves data and preserves mapping, owns no business decisions.

13

Decision record — Subscriptions separate from Inventory

Deciding scenario: the device swap. A player fails and is replaced. Fold subscription into Inventory, and it dies with the unit. Keep it separate, and the swap is a non-event.

The device swap, drawn

sub_subscriptions — never moves unit A — failed valid_to: today unit B — replacement valid_from: today Inventory emits UnitReplaced Subscriptions consumes it — closes row A, opens row B
✓ Chosen

Separate domain

  • Commercial, time-based lifecycle: start, renew, suspend, cancel
  • Device swap is a non-event
  • 1 subscription : many units, many locations
  • Supports software-only products
  • Ops actions cannot affect billing
  • Failure mode: drift — caught by a weekly reconciliation report
✕ Rejected

Folded into Inventory

  • One lifecycle, wrong for half the cases
  • Swap breaks continuity and revenue reporting
  • Forces a false 1:1 cardinality
  • Software-only needs a fake inventory record
  • Warehouse edits touch billing
  • Failure mode: structural, unrecoverable without remodelling

13.1 · Connective tissue

sub_subscription_units
  subscription_id  uuid
  inv_unit_id      uuid
  valid_from       date
  valid_to         date null

Also resolves licences: movable entitlement → Subscriptions; serial-bound attribute → Inventory; both exist → two tables.

A

Appendix A — Rejected: schema-based separation

Postgres and Supabase both support custom schemas well:

const supabase = createClient(URL, KEY,
  { db: { schema: 'quotation' } })

// or per query
await supabase.schema('quotation')
  .from('quotes').select('*')

Why rejected

Lovable's generated code assumes default-schema access. Steering it into .schema() calls on every prompt is workable hand-written, fragile AI-generated.

Reversible: ALTER TABLE ... SET SCHEMA plus a rename is the natural first step of a real microservice split.
B

Appendix B — Lovable knowledge-file rules

Paste the block below directly into the Lovable knowledge file. It is self-contained — no reference back to this page is required.

knowledge-file.md
### ConnectIQ — Domain & Database Rules

These rules are binding for all generated code in this project. Apply them to every table, query, and migration, regardless of what a specific feature prompt asks for. If a request conflicts with a rule below, follow the rule and flag the conflict instead of silently resolving it.

1. Tables are prefixed by domain (cl_, com_, plan_, ops_, inv_, sub_, sup_, mon_, int_, rpt_, plat_). Never invent a prefix variant (e.g. cust_) for a domain that already has one.

2. Work only inside the domain you were asked to build. Do not modify global routing, layout, auth, the Supabase client, shared components, package files, or migrations belonging to another domain without explaining why first.

3. Never write to a table outside your domain's prefix.

4. Never read another domain's raw tables. Use that domain's service client for operational reads, or a rpt_* view for reporting/dashboard reads.

5. Never use PostgREST nested or embedded selects across prefixes (e.g. .select('*, cl_locations(*)')), even where a foreign key makes it technically available.

6. Never open a database transaction that spans more than one domain prefix.

7. Cross-domain effects are triggered by writing an event row to plat_domain_events, in the same transaction as the state change that caused it. Event names are past tense (e.g. QuoteApproved, JobCreated, UnitReplaced), describing a fact, not a command. Consumers must be idempotent — the same event delivered twice must produce the same result.

8. Every table has: id uuid primary key default gen_random_uuid(). Human-facing business codes (e.g. customer_code, job_no) are separate columns, generated by a database function, and are never the primary key.

9. Cross-domain foreign key / reference columns are named <prefix><entity>_id (e.g. cl_location_id, inv_unit_id). Same-domain foreign keys are named <entity>_id (e.g. job_id).

10. External system record IDs (Airtable, HubSpot, Freshdesk, Xero) never live in domain tables. They are stored only in int_external_system_mappings (system, entity_type, internal_id, external_id).

11. Snapshot a value only when it must be frozen at a point in time (e.g. quote pricing, contract customer name/address, delivery note recipient). Otherwise store a reference (ID) so the value stays current. Snapshotted columns are named to make this obvious, e.g. customer_name_snapshot.

12. Every rpt_* view is created WITH (security_invoker = true). This is not optional — without it, a view bypasses RLS on the underlying tables.

13. The customer portal never reads a raw domain table, under any circumstance. Portal reads go through rpt_* views or dedicated read models with RLS.

14. Cross-prefix foreign keys exist for referential integrity only. They do not authorise joins or nested selects in application code — see rule 5.

15. Use shared status constants/enums for any status field. Never write or compare a raw status string.

16. Before writing code, list the files you intend to create or edit, and confirm they stay within the current domain's boundary (rule 2).