tRPC Procedure Hierarchy
publicProcedure # No auth
├── authedProcedure # Requires session.user
│ ├── adminProcedure # Requires admin role
│ ├── workspaceAwareProcedure # Auth + workspace scoping (most common)
│ └── workspaceAdminProcedure # Admin + workspace scoping
├── portalPublicProcedure # No auth (magic link requests)
└── portalProcedure # Portal session auth (cookie-based)Authorization (RBAC)
Hybrid role model. Nine seeded immutable system role templates in packages/permissions/src/roles.ts, mirrored to the DB by seedAuthzSystem; workspace-scoped custom roles in roles. Permissions are atomic entity:action keys in definitions.ts. Memberships → roles via membership_roles.
Enforcement is declarative:
someProcedure: workspaceAwareProcedure
.meta({ permission: "invoice:approve" })
.input(...).mutation(...);A single enforceAuthz middleware validates meta against the caller's effective permissions (@zrm/authz.resolveEffectivePermissions); deny-by-default on missing meta. resolveWorkspace hydrates ctx.user.{permissions,roles} from the same source so inline checks see workspace-scoped truth.
Platform superadmin is the tightly-controlled users.is_platform_superadmin flag (only superadmins reach the Master Workspace 00000000-0000-0000-0000-000000000001). Cross-workspace leakage is blocked by scopedFilter()/workspaceOrMaster() on every workspace-bound query + a generated L4 isolation test matrix over all non-public procedures. Phase 6 admin UIs: roles (/dashboard/admin/roles), sessions (/dashboard/me/sessions, /dashboard/admin/sessions), audit (/dashboard/admin/activity).
Passwordless login + invitations (Phase 5a)
15-min staff magic-link (MagicLinkService) + workspace invites (InvitationService) on the unified auth_tokens table (opaque, SHA-256-hashed, single-use, purpose discriminator) — same sessions row + JWT sessionId as password login. Flag ENABLE_MAGIC_LINK_LOGIN (+ NEXT_PUBLIC_*), off → 404. Entry: Hono POST /auth/magic-link/{request,consume}, /invite/accept; tRPC mirrors auth.magicLink.*/invitation.*. Admin UI /dashboard/admin/members (workspace_membership:manage). Runbook: docs/auth/magic-link-runbook.md.
Google OAuth login (Phase 5b)
"Sign in with Google" for staff. No self-signup: handleCallback rejects unless the Google email already exists in users (no_matching_user); first-time linking needs password/magic-link proof (google_oauth rows never minted from OAuth alone). Separate OAuth client (google-auth.service.ts). Flag ENABLE_GOOGLE_OAUTH_LOGIN + NEXT_PUBLIC_*, off → 404. Env GOOGLE_LOGIN_CLIENT_ID/SECRET/REDIRECT_URI (distinct from GOOGLE_CLIENT_*). PKCE state in auth_tokens(purpose='oauth_state') 10min, link-confirm 15min; identity user_identities(provider='google_oauth', providerUserId=<sub>). Entry: Hono GET /auth/oauth/google/{start,callback} + trpc.auth.googleLogin.*. Defenses (state hashing, same-origin returnTo, enumeration-floor latency, 20 callbacks/IP/hr, link-confirm session binding) in docs/auth/google-oauth-login-runbook.md.
Mobile bearer transport (Phase 5c)
1-hour access JWT (same {userId, sessionId} as web cookie) + 30-day opaque refresh in auth_tokens(purpose='mobile_refresh'). POST /auth/refresh consumes the old row + mints a new one in one tx — replay of a consumed token revokes the session (revoked_reason='refresh_token_replay'). Flag ENABLE_MOBILE_BEARER (server only), off → /auth/refresh 404 + bearer parsing short-circuits. Isolation: bearer parser rejects device_type='web'; cookie reader ignores Authorization. Clients mint sessions by passing deviceType: 'ios'|'android'|'api_client' to trpc.auth.magicLink.consume / trpc.auth.googleLogin.{callback,confirmWithPassword,confirmWithMagicLink} / trpc.invitation.accept — response adds {accessToken, accessExpiresAt, refreshToken, refreshExpiresAt}. /auth/refresh is Zod-bodied, IP rate-limited (RATE_LIMIT_REFRESH_IP_PER_HOUR, 120/hr). Replay defenses (row lock, opaque-collapse rotate, partial index) in docs/auth/mobile-bearer-runbook.md (≤1h worst-case revocation).
Router → Service Pattern
Routers are thin (validate input → call service → throw TRPCError). Services contain all business logic: DB queries, event publishing, search indexing, transactions.
// Router
export const accountRouter = router({
create: workspaceAwareProcedure
.input(createAccountSchema)
.mutation(async ({ ctx, input }) => new AccountService(ctx.db).create(input, ctx.workspaceId)),
});Workspace Scoping (Multi-Tenancy)
All data queries use scopedFilter() to enforce workspace isolation. The master workspace (00000000-0000-0000-0000-000000000001) bypasses filtering for admin access.
scopedFilter(table.id, entityId, table.workspaceId, workspaceId)For queries that include shared/master workspace data (product catalog, categories), use workspaceOrMaster(products.workspaceId, workspaceId).
Always use scopedFilter for getById/update/delete, and .where(eq(table.workspaceId, workspaceId)) on list queries.
Monetary Field Convention
Monetary columns are PostgreSQL numeric (typically numeric(12,2)); Drizzle returns strings.
- Read: services convert at the return boundary via
numericToNumber()/numericToNumberNN()(@zrm/db); API sends numbers. - Aggregate (totals, tax/discount) in SQL
numeric— never sum money in JS loops; simple math (surcharge) may stay JS withMath.round(v*100)/100. - Write: Drizzle takes numbers or strings; pass Zod-validated numbers (
String()when it infersstring);nullclears a nullable field,undefinedskips it (handleSavein product detail). - Frontend:
EditableDetailRowonChange returns strings —Number()before tRPC.
Quote → Project Conversion
quote.accepted triggers: (1) project w/ budget/scope/logistics; (2) payment-term milestones → project milestones (type payment); (3) draft invoice w/ BOM→line mapping (material→materials, labor→labor, recurring→service); (4) opportunity → won; (5) Temporal workflow for signed PDF + notifications. Idempotent via acceptedQuoteId/quoteId; milestoneType separates payment (auto) from project (manual).
Cost Roll-Up Cascades
time_entry CRUD
→ recalculateWorkOrderTotals(workOrderId, tx, laborRate)
→ actualLaborHours/Cost, actualMaterialCost, totalCost, laborEfficiency, costVariance
→ if wo.projectId: recalculateProjectCosts → actualTotalCost, budgetVariance, budgetConsumedPercentAlso: bubbleTicketLaborHours sums WO labor to the parent ticket; WO completion bumps totalServiceCount/failureCount on the linked asset (ticket.relatedAssetId); PM schedule changes recompute plan metrics + nextServiceDue. All roll-ups include workspaceId to block cross-workspace FK manipulation.
Registry-Driven Detail Pages
Entity detail pages use RegistrySection from @zrm/ui driven by FieldDefinition[] arrays from packages/domain-model/src/field-registry/:
const mergedData = useMemo(() => ({ ...(entity.data ?? {}), ...editData }), [entity.data, editData]);
<RegistrySection fields={entityFields} section="Identity" data={mergedData}
editing={isEditing} onChange={handleFieldChange} variant="rows" collapsible />All Phase 2+ entities use this (customer, contact, opportunity, quote, ticket, work order, project, asset, maintenance plan, monitoring).
SLA Templates & Auto-Resolution
Contract-based SLA w/ customer inheritance. sla_templates sets response/resolution times per priority; ticket creation resolves the customer template (or workspace default) → slaResponseDueAt/slaResolutionDueAt. Tickets auto-resolve with breach detection when all WOs reach terminal status.
Monitoring Account Billing
automationSchedulerWorkflow daily: monitoring_accounts WHERE nextBillingDate <= CURRENT_DATE AND autoBilling AND status='active' → draft invoices w/ line items, advances nextBillingDate, in-app notifies owners/admins.
Automation Engine (Rules, Actions, Templates)
AutomationEventHandler subscribes via subscriber.onAll(), matches enabled rules by triggerConfig.eventType, evaluates filters + cooldown, executes or drafts.
ActionExecutor dispatches by actionType: create_record (ticket/work_order/invoice); update_status (against UPDATABLE_FIELDS — ticket status/priority/assignedUserId, work_order status/priority, invoice status); send_notification (by userId/role, inserts automation_notifications, optional email); run_workflow (Temporal); create_reorder_po.
Template vars: {{event.field}}/{{payload.field}} via resolveTemplateVars() (unresolved → empty). Autonomy: auto_execute immediate; draft_for_review/agent_review write a drafted trace, approval re-runs ActionExecutor.execute() with stored payloads. Templates: 14 static TS in services/api/src/automation/templates/; activateTemplate creates a disabled automation_rule. Bell: polls notification.unreadCount every 30s.
Operations Dashboard
/dashboard real-time hub: main column (6 KPIs, role-aware "My Work", revenue chart, pipeline, activity feed, agent stats) + collapsible Attention sidebar (SLA breaches, overdue invoices, stale opps, maintenance due, automation approvals; mobile → slide-over). Data via dashboardOps → DashboardOpsService (parallel aggregates, no new tables). Polling: KPIs 60s, attention/activity 30s, automations 5min.
Mission Control (/dashboard/mission-control)
Operator-facing internal-state surface — scheduled-job rollup, activity spotlight, guidance cards. Read-only aggregates from automation_schedule_runs + activity_log. (Dashboards V2 reimplements it as the system dashboard.)
Dashboards V2 (/dashboards/[slug])
Role-aware customizable dashboards in @zrm/dashboards (dashboard + widget defs, layout schemas, access gating) + @zrm/dashboard-snapshot (compute composables). Ten slugs: operations, executive, field, finance, sales, service, dispatch, projects, fleet, system. /dashboards resolves the role-default slug; /dashboards/[slug] validates against the registry. Gated by ENABLE_DASHBOARD_V2 (+ NEXT_PUBLIC_*) + workspace_settings.dashboard_v2_enabled.
Data flow: 9 of 10 are snapshot-backed — a per-workspace Temporal workflow runs DashboardSnapshotService.compute<Dashboard>Payload() (composables under Promise.allSettled, keyed dashboard.widget) into dashboard_snapshots (PK workspace_id, dashboard_id); client reads via dashboardData.getSnapshot. Widgets with a live source also poll the live proc and swap in via placeholderData. The field ("My Day") dashboard is live per-user — dashboardField.getSnapshot computes per request from ctx.user.id (input z.object({}).strict() rejects a spoofed userId); never snapshotted.
Customization: per-user layouts persist to user_dashboard_layouts (PK user_id, workspace_id, dashboard_id; layout_json + schema_version) via dashboardLayout.{get,save,reset}. Absent/invalid/stale-version rows fall back to DashboardDef.defaultLayout; saved layouts are server-side filtered to strip widgets the caller can't access or that aren't on the dashboard. Read mode is CSS Grid; customize mode lazy-loads react-grid-layout (≥md) for drag-resize + a permission-filtered widget library. AI banners are stubs. Authoring recipes in AGENTS.md.
Mobile Capture
Mobile bottom-nav Capture button (gated on agent_operator — user.me hydrates roles). Two-step sheet: source-picker (camera/file) → destination-picker over search_index (8 entity types). Artifacts PUT to S3 (presigned), then capture.commit pairs to the destination via capture-entity-tables.ts (asset → media_artifacts, customer → attachments) — the single source of truth for entity-type → table routing; don't branch on entity type elsewhere.
Daily Briefing (@zrm/briefing)
Morning brief: brief-pack.service.ts (open tickets, due maintenance, recent quotes, pipeline deltas) → narrated by Claude (narrator.service.ts) → dispatched via briefing/channels/* (in-app + Slack, encrypted webhook). Cron in automation_rules via Temporal. History /dashboard/me/briefings; AI spend /dashboard/admin/activity.
Points of Interest (POI)
Cross-entity flag/note on 10 detail pages. pois stores (entity_type, entity_id, flagType, note, status, workspaceId); poi-target-validator.ts is the single guard against flagging a foreign-workspace entity. List at /dashboard/pois.
Quote Versioning
quotes.versionGroupId + versionNumber group revisions; revise_quote clones into the same group, previous → superseded on new send. Numbers YYYYMM-#### per workspace/month. Portal renders the latest; portalQuote.getDiff powers a per-line diff (added/removed/modified BOM lines + financial deltas). Quotes also carry editable narrative fields (executiveSummary, projectOverview, projectType, estimatedStart, estimatedCompletion, changeOrderTerms) plus an ordered quote_scope_sections child table (atomic quoteScopeSection.replace; cloned on revision; rendered as Project Overview + Scope sub-sections in the quote PDF).
Procurement (Vendors, POs, Receiving)
State transitions in procurement-helpers.ts (free-standing tx-taking fns).
PO state machine: draft → pending_approval → submitted → acknowledged → partially_received → received; reject/cancel → canceled. PurchaseOrderService.update() ignores status — transitions only via submitPo/approvePo/rejectPo/receivePoLine; editing past the approval threshold reverts to draft + clears the trail. Threshold: submitPo with po.totalAmount >= workspaceSettings.approvalRequiredAtAmount (nullable) → pending_approval (needs procurement:approve_po). Helpers SELECT ... FOR UPDATE the PO row.
Receiving → cost cascade: receivePoLine is the only receipt path — WO-linked POs auto-upsert a work_order_materials row keyed sourcePoLineItemId (idempotent) → recalculateWorkOrderTotals → actualTotalCost; non-WO just update received_quantity. Vendor-Product: vendor_products m2m (vendor pricing/lead-time/MOQ/SKU); partial unique caps ≤1 isPreferred per (productId, workspaceId), flip via setPreferredVendorProduct. Perms procurement:{manage_vendors,manage_purchase_orders,approve_po,receive_po} — admin all; sales_rep vendors+POs; technician receive_po.
Inventory (Warehouses, Stock Levels, Transactions)
Warehouse → bin hierarchy (one default warehouse/workspace + one default bin/warehouse, partial unique indexes). stock_levels is a materialized aggregate of the immutable stock_transactions ledger, updated per insert in-tx; quantityAvailable = quantityOnHand - quantityReserved (query-time).
All mutations go through inventory-helpers.ts (receiveStock, issueStock, adjustStock, transferStock, returnToVendor, returnFromWorkOrder, isBelowReorderPoint): outbound ops lock stock_levels FOR UPDATE. Dual-path receiving: receivePoLine checks product.trackInventory — tracked → receive (PO warehouse → workspace default), WO-linked also issue to the WO; non-tracked unchanged. Reorder threshold COALESCEs product_stock_settings override → products.reorderPoint (both null = none); crossing fires product_stock.below_reorder_point with preferredVendorId. Opt-in (trackInventory=false default). Perms inventory:{manage_warehouses,manage_stock,adjust_stock,transfer_stock,issue_stock}; admin all, technician issue_stock only.
Procurement Automation (Auto-Draft POs, Reorder Rules)
Auto-draft POs from BOM: on quote acceptance with workspace_settings.autoDraftPosOnQuoteAcceptance=true, QuoteAcceptedHandler groups material BOM lines by preferred vendor → draft POs (idempotent via purchase_orders.sourceQuoteId); products without preferred vendor skip with admin notification.
Reorder rules: create_reorder_po reacts to product_stock.below_reorder_point, resolves preferred vendor + qty (warehouse override → product default → reorderPoint × 2) via reorder-helpers.ts. Template auto_reorder_low_stock: 24h cooldown, draft_for_review. Helper batches REORDER-prefixed POs into an open same-vendor draft (< 24h) rather than minting new; quote-sourced (AUTO-prefix) always mint new. Dashboard: stock.createReorderPo/bulkCreateReorderPos.
AI Tool Execution Context
AI tools do NOT take workspaceId/userId from LLM params — injected server-side via ToolExecutionContext (anti-prompt-injection). Flow: SpecialistExecutor.delegate() → ExecuteToolLoopFn → ToolUseExecutor → ToolRegistry.execute(). New tools: omit workspace/user from the Zod schema, cast via InjectedContext, guard userId at runtime when audit trails need it.
Products & Sales Agent Tools
Workspace-scoped tools registered at startup via registerSalesAgentTools(db): search_products (workspace+master scoping), get_labor_rate, generate_quote (quote + BOM atomically), revise_quote (new version w/ line mods), get_agent_rules (rules + learned patterns), get_email_context (threads for account/opp).
Agent Rules & Outcome Patterns
Admin rules guide AI (pricing, product selection, labor estimation); patterns auto-learned from quote outcomes. agentRule router (admin mutations, workspace-aware reads); agentOutcomePattern router (system-managed via QuoteOutcomeHandler). Scopes global/account/category/project_type — scopeRefId varchar(255) (non-UUID scopes).
Dual Google Workspace Integration
Two connections per workspace in external_connections (connectionRole enum): Orchestrator — the AI's business Google account, one/workspace, admin-connected via Workspace Settings, all scopes, AI-referenceable for any user (browsing needs google_orchestrator_* perms); Personal — per-user via My Settings, visible ONLY to owning_user_id (not bypassable), opt-in ai_access_enabled gates AI.
GoogleConnectionResolver: getOrchestratorConnection(workspaceId), getPersonalConnection(workspaceId, userId), getAIAccessibleConnections(workspaceId, userId) (orchestrator + optional personal). Perms (packages/permissions/src/definitions.ts): google_orchestrator_{gmail,calendar,drive}:{read,write/send}, google_orchestrator:manage (admin default all); personal connections have NO perm checks — only owning_user_id === ctx.user.id. UI: purple "AI Orchestrator" vs blue "Personal" badge.
Email Thread Processing
EmailThreadProcessorService resolves addresses to accounts: P1 exact contact email (0.95, case-insensitive), P2 domain match (0.80). confirmResolution validates workspace ownership + preserves opp links; getRelevantMessages returns ≤50 most recent (LLM-bounded).
Accounting Settings (Tax Agencies & Payment Terms)
Workspace-level at /dashboard/settings/accounting, gated by accounting:manage (admin default).
Tax Agencies: per-jurisdiction rates (state/county/city/special_district) assigned per-site via site_tax_agencies; quotes inherit from the linked site. Customer taxExempt zeros; quote-level override wins. Payment Term Templates: milestone schedules (e.g. "Net 30", "50/50"). Milestones: label, percentage (sum 100%), trigger (on_acceptance/on_completion/net_days/milestone); one default/workspace, managed atomically (delete + re-insert). Customers/quotes FK to templates.
Quote Delivery & Engagement Tracking
quote.send validates guards (draft/changes_requested/sent, BOM lines, totals, linked site, primary contact w/ email) → quote.sent → Temporal workflow (magic link → PDF → branded email + PDF → SMTP). Tracking: 1×1 pixel (opens) + redirect endpoint (clicks) via signed JWT with the redirect URL inside the token (open-redirect defense); QuoteEngagementService logs email_{sent,open}/link_click/portal_view/pdf_download. PDF: QuotePdfService (@react-pdf/renderer), lazy-imported in the workflow, attached multipart/mixed.
Invoice Delivery, Payments & Auto-Generation
Mirrors quote delivery: InvoiceSendService.send() → invoice.sent → Temporal invoiceDeliveryWorkflow (context, PDF via InvoicePdfService, portal magic link, branded email + pixel). Guards draft/approved/sent, line items, totalAmount, contact email. Engagement: InvoiceEngagementService + invoice_engagements (types incl. payment_{initiated,completed,failed}); routes /tracking/invoice/:token/{pixel.png,open}.
Auto-gen: QuoteAcceptedHandler creates the invoice in the same tx as the project (idempotent via quoteId); generateMonitoringInvoices drafts dueDate +30d. recalculateInvoiceTotals() on line create/delete sums totalPrice, preserves manual taxAmount, nullifies pdfS3Key. Stripe: InvoicePaymentService.createCheckoutSession() → POST /api/webhooks/stripe handles checkout.session.{completed,expired} (keys in payment_settings, idempotent via stripeCheckoutSessionId); portal pay UI = hero card, Pay Now, PDF, due-date countdown.
Product Catalog UI
/dashboard/products category-grouped table, server filtering (search/category/status/manufacturer) + inline create; detail /dashboard/products/[id] dense 2-col grid, inline editing + tabs. Product↔Asset: products.productType uses assetTypeEnum (camera/controller/reader/sensor/…), carried over when a product installs as an asset. Cut sheet: S3 presigned — getCutSheetUploadUrl PUT → cutSheetS3Key → getCutSheetDownloadUrl (4-min staleTime); content-type restricted server-side, keys timestamped (no user filenames).
Category attributes: attributeSchema JSONB {name, label, type, options?} rendered as EditableDetailRow (text/number/boolean/enum).
Scheduling & Dispatch
Visit-centric model in schedule_visits drives the dispatch board, auto time-entry, and Google Calendar sync.
Status machine: scheduled → en_route → in_progress → completed, plus canceled (escape) / no_show (terminal). VisitService.updateStatus uses a status-gated UPDATE (WHERE id=? AND status=<expected>) — losing caller gets CONFLICT; transitions emit visit.scheduled/rescheduled/reassigned/completed/canceled.
Availability (AvailabilityService): layers (highest first) approved time-off → Google Calendar blocks → per-day overrides → tech shift assignments → workspace default shift. getForUserOnDate runs 5 reads in one Promise.all (shift via workspaceOrMaster); range version fans out per-day. Skills: skill_definitions workspace-scoped (master seeds), technician_skills maps users; TechnicianSkillService.assign idempotent, bulkAssign one tx with scope validation.
Auto time entry: handleVisitCompleted on visit.completed creates an auto_tracked row, publishes time_entry.created, drives recalculateWorkOrderTotals. Idempotent via (workOrderId, userId, startTime, endTime, entryType) fingerprint.
Dispatch UI (/dashboard/dispatch): hour-grid day/week views (per-tech rows) + unassigned queue. DnD: queue→row creates, within-row reschedules, row→row reassigns; drag payload rides a module-level ref (…/dispatch/drag-state.ts, since browsers hide dataTransfer.getData during dragover); receiving row fires dispatch.getSkillMatch. Tech pages: /dashboard/me/schedule, /dashboard/visits. Detail inline-edits Notes only; status/scheduling/assignment transitions stay on the dispatch slide-over so the status machine + authorizeVisitMutation (assignee OR scheduling:manage_dispatch) stay the single enforcement point.
Google Calendar (Temporal): push visits/time-off to orchestrator + cron pull from per-tech personal calendars into external_calendar_events (ai_access_enabled=true only). Client services/workflows/src/lib/google-calendar-client.ts (token cache + refreshInFlight dedup); needs GOOGLE_TOKEN_ENCRYPTION_KEY. Events keyed (google_event_id, user_id, workspace_id). Permissions: 8 perms (view_dispatch_board, manage_{dispatch,shifts,skills}, {request,approve}_time_off, {self,others}_assign); field_supervisor = technician + dispatch + time-off approval.
Semantic Search (Ask)
Hybrid retrieval + Claude-grounded answers over search_index. Question-shaped queries surface an answer card above keyword results at /dashboard/search; any query opts in via ?ask=1.
Retrieval: HybridRetriever (@zrm/search) runs BM25 + pgvector (cosine clamp < 0.6) in parallel, fuses via RRF (k=60). Query vector is caller-provided. ACL: aclPredicate({workspaceId, userId}) gates both retrieval halves and citation-hydrate (search.getByIdForCitation) — two SQL gates with two anti-exfil barriers between (citation-id validation drops hallucinated IDs; XML-tagged context block isolates retrieved text from the prompt).
AskService (services/api/src/services/ask/): parallel budget+cache lookup → embed → retrieve → build context (2KB/row, 48K-char ≈ 12K-token cap) → two-block system prompt (cacheControl: "ephemeral" on context) → generate → validate citations → log activity_log → cache if high-confidence and not owner-only. Cache ask_answer_cache keyed sha256(workspace_id::normalized_query), 5-min TTL, swept hourly by askCacheCleanupWorkflow. Budgets 60/user/hr, 500/workspace/day; breach → 429. AskQueryResult.degradationReason → embedder_unavailable | no_retrieval | suppressed_hallucination.
Embedding worker: embeddingBackfillWorkflow (Temporal singleton) polls search_index WHERE embedding IS NULL every 30s, batch 200, continueAsNew every 120 iter; indexer nulls embedding via CASE in ON CONFLICT DO UPDATE when title|subtitle|body change. UX: ASK_MODEL_ID in …/ask/model.ts; AskUsageWidget at /dashboard/admin/activity; all calls (incl. cache hits) log as ai_call/semantic_search_answer. Question-shaped queries auto-trigger AskAnswerCard (900ms debounce); keyword queries show an opt-in "Ask AI" prompt.
Google Artifact Auto-Linking
Persistent Google↔CRM links via hybrid classifier + per-customer inbound aliases at inbound.zrm.app. Each classified artifact writes one entity_google_link row; UI reads persisted links instead of re-matching.
Classifier tiers (packages/linker/src/artifact-linker.service.ts): T0 inbound alias <token>@inbound.zrm.app → crm_customers.inbound_email_token (1.0). T1 exact contact-email, case-insensitive (0.95). T2a sender/recipient domain matches a contact's domain (0.80, emails only). T2b embedding → account cosine-nearest ≥0.80. T2c embedding → opp/ticket within the matched account: ≥0.75 auto, 0.60–0.75 suggested. Runs inline in the outbox tx on email_message.indexed/external_calendar_event.indexed/contact.upserted (~50ms); googleLinkBackfillWorkflow is a per-workspace Temporal singleton (90-day window) auto-started by bootstrapWorkflows.
Inbound email: SES → S3 → SNS HTTPS → POST /api/webhooks/inbound-email. RFC822 Message-ID de-duped against Gmail copies via partial unique uniq_email_messages_ws_rfc822 (CC'ing both Gmail + alias → one row); SNS audit in inbound_email_events. Runbook: docs/deployment/inbound-email-setup.md.
Schema: entity_google_link (status auto/suggested/confirmed/corrected/rejected); google_link_feedback (future learner); email_messages.source (gmail|inbound) + rfc822_message_id; crm_customers.inbound_email_token (8-char Crockford base32). ArtifactLinkerService lives in @zrm/linker (breaks the @zrm/workflows ↔ @zrm/api cycle). UI: LinkedArtifactsWidget (customer/opp/ticket detail), review queue /dashboard/admin/google-links, InboundAliasCopyBadge, LinkedToChip on search rows.
Customer Notifications & SMS
Outbound customer notifications over email + SMS. CustomerNotificationService resolves recipients, applies opt-out / quiet-hours / dedup guards, dispatches each channel via customerNotificationDeliveryWorkflow (Temporal); sendCustomerNotificationSmsActivity composes the body with @zrm/sms composeSmsMessage() and sends through SmsService (Twilio). Every attempt is audited in customer_notification_deliveries (channel email|sms, status queued/sent/failed/skipped_*; providerMessageId = Twilio SID).
@zrm/sms: Twilio client + validateTwilioSignature, normalizeMobile/isValidE164, matchKeyword (STOP/HELP/CANCEL/END/QUIT/UNSUBSCRIBE), templates. Inbound: POST /api/webhooks/twilio/inbound verifies the signature, then an opt-out keyword flips contacts.smsNotificationsEnabled=false (publishes contact.sms_opted_out), or replies to HELP via TwiML. Portal opt-in: portalNotificationPrefs.{requestSmsCode,confirmSmsCode} runs a hashed 6-digit check against sms_opt_in_codes (rate-limited per contact + IP), setting contacts.smsVerifiedAt.
Flags: ENABLE_CUSTOMER_NOTIFICATIONS (master, email+SMS) + independent ENABLE_CUSTOMER_SMS (gates Twilio; needs TWILIO_ACCOUNT_SID/AUTH_TOKEN/FROM_NUMBER), each with NEXT_PUBLIC_* mirrors. Off → pipeline inert.
Fleet Monitoring (GPS)
Vehicle GPS on the fleet dashboard + a Google-Maps route view. fleet router reads vehicles + locations; FleetLocationService.syncFromProvider pulls fixes from a FleetGpsProvider (onestep live + demo), FleetStopDetectionService derives stops, FleetRouteService smooths tracks into a precision-5 encoded polyline (~3,000-point budget). Tables: vehicles, vehicle_locations, vehicle_stops, fleet_gps_devices, fleet_provider_sync_logs. Runbook: docs/fleet/fleet-monitoring-setup.md.
Domain Events
Published inside DB transactions via publishEvent(). Polling dispatcher delivers asynchronously.
await this.db.transaction(async (tx) => {
const [record] = await tx.insert(table).values(data).returning();
await publishEvent(tx, { eventType: "entity.created", aggregateType: "entity",
aggregateId: record.id, payload: record, metadata: { userId } });
return record;
});