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. Eleven seeded immutable system role templates in …/permissions/src/roles.ts (nine general + support_agent/support_lead), mirrored to DB by seedAuthzSystem, which also writes a permissionless no_access sentinel (is_template=false) for the membership-role invariant trigger (so the DB holds 12 workspace-less rows); workspace-scoped custom roles in roles. Permissions: 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 caller's effective permissions (@zrm/authz.resolveEffectivePermissions); deny-by-default on missing meta. resolveWorkspace hydrates ctx.user.{permissions,roles} so inline checks see workspace-scoped truth.
Platform superadmin is tightly-controlled users.is_platform_superadmin flag (only superadmins reach Master Workspace 00000000-0000-0000-0000-000000000001). Cross-workspace leakage is blocked by scopedFilter()/workspaceOrMaster() on every workspace-bound query + hand-written *-isolation.test.ts suites (no generated matrix—add one per new by-id procedure). Admin UIs: roles (/dashboard/admin/roles), sessions (/dashboard/me/sessions, /dashboard/admin/sessions), audit (/dashboard/admin/activity).
Staff auth flows (magic link, invites, Google OAuth, mobile bearer)
Four paths—password, 15-min magic link + invites, Google sign-in, mobile bearer—all land on one sessions row + JWT sessionId. No self-signup: Google OAuth rejects an email not already in users (no_matching_user), and a first Google link needs password/magic-link proof first; it runs on its own client (GOOGLE_LOGIN_CLIENT_*, distinct from GOOGLE_CLIENT_*). auth_tokens is not universal—only the five in authTokenPurposeEnum; password reset and legacy staff-invite-setup keep their own tables. Flags (off→404): ENABLE_MAGIC_LINK_LOGIN, ENABLE_GOOGLE_OAUTH_LOGIN (each + NEXT_PUBLIC_*), ENABLE_MOBILE_BEARER (server-only). Device attestation defaults off per workspace and a verifier outage records unevaluated, never failed. Invariants in AGENTS.md § "Extend the auth/identity flow"; ops, rate limits + device enrollment in docs/auth/{magic-link,google-oauth-login,mobile-bearer}-runbook.md.
Router→Service Pattern
Routers are thin (validate input→call service→throw TRPCError). Services contain all business logic: DB queries, event publishing, search indexing, transactions. Worked example in services/api/CLAUDE.md.
Workspace Scoping (Multi-Tenancy)
Use scopedFilter(table.id, entityId, table.workspaceId, workspaceId) for getById/update/delete + .where(eq(table.workspaceId, workspaceId)) on list queries; the master workspace (UUID above) bypasses filtering for admin access. For shared/master-data queries (product catalog, categories), use workspaceOrMaster(products.workspaceId, workspaceId).
Write-time references get no master bypass. A client-supplied FK goes through assertFkInWorkspace, which compares against the workspace the row is written to (for an update, the row's own), so a record created from the master can't point into a tenant — that stray reference is what blocks the tenant record's deletion. A platform superadmin can still delete through one: the related-record review offers "Include records in other workspaces".
Monetary Field Convention
Monetary columns are PostgreSQL numeric (typically numeric(12,2)); Drizzle returns strings.
- Read: services convert at 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 w/Math.round(v*100)/100. - Write: Drizzle takes numbers or strings; pass Zod-validated numbers (
String()when it infersstring);nullclears nullable field,undefinedskips it. - Frontend:
EditableDetailRowonChange returns strings—Number()before tRPC.
Quote→Project Conversion
quote.accepted triggers, in one tx: project; payment-term→project milestones; draft invoice(s) w/ BOM→line mapping; opportunity→won; Temporal signed-PDF workflow. A change order skips the project step—a quote carrying projectId (#779) reuses that project, so roles are not re-seeded and idempotency keys off this quote's invoices. Full mechanics: docs/subsystems/quote-to-project-conversion.md.
Cost Roll-Up Cascades
time_entry CRUD
→recalculateWorkOrderTotals(workOrderId, tx, laborRate)
→actualLaborHours/Cost, actualMaterialCost, totalCost, laborEfficiency, costVariance
→if wo.projectId: recalculateProjectCosts→actualTotalCost, budgetVariance, budgetConsumedPercentbubbleTicketLaborHours sums WO labor to parent ticket; WO completion bumps totalServiceCount/failureCount on linked asset (ticket.relatedAssetId); PM schedule changes recompute plan metrics + nextServiceDue. All roll-ups include workspaceId to block cross-workspace FK manipulation. Sign-off prices labor via work_orders.serviceTypeId—customer→site→service-type→workspace, one ladder shared with quoting.
Registry-Driven Detail Pages
Entity detail pages use RegistrySection from @zrm/ui driven by FieldDefinition[] arrays from …/domain-model/src/field-registry/:
<RegistrySection fields={entityFields} section="Identity" data={mergedData}
editing={isEditing} onChange={handleFieldChange} variant="rows" collapsible />All Phase 2+ entities use this.
SLA Templates & Auto-Resolution
Contract-based SLA w/ customer inheritance. sla_templates sets response/resolution times per priority; ticket creation resolves customer template (or workspace default)→slaResponseDueAt/slaResolutionDueAt. Tickets auto-resolve w/ 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. Accounts without it are nobody's job until the weekly @zrm/billables sweep flags them.
Automation Engine (Rules, Actions, Templates)
AutomationEventHandler (onAll) skips unheard event types from memory, then per rule writes an event-id-keyed trace, claims cooldown and acts in one tx—drafts claim none, failures roll it back. Rules soft-delete. See docs/subsystems/automation-engine.md.
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 (userId/role→automation_notifications); run_workflow (Temporal); create_reorder_po.
Template vars: {{event.field}}/{{payload.field}} (unresolved→empty). Autonomy: draft_for_review/agent_review draft; approval validates, claims and runs the stored payload in one tx. Templates: 34 static TS in …/automation/templates/; activateTemplate creates a disabled rule.
Operations Dashboard
/dashboard real-time hub: KPIs, role-aware "My Work", revenue/pipeline/activity, agent stats + an Attention sidebar (SLA breaches, overdue invoices, stale opps, maintenance due, automation approvals). Data via dashboardOps→DashboardOpsService (parallel aggregates, no new tables); polling 30s–5min per panel.
Dashboards V2 (/dashboards/[slug])
Role-aware customizable dashboards in @zrm/dashboards + @zrm/dashboard-snapshot, ten slugs at /dashboards/[slug], gated by ENABLE_DASHBOARD_V2 (+ NEXT_PUBLIC_*) and workspace_settings.dashboard_v2_enabled. Nine are snapshot-backed by a per-workspace Temporal workflow writing dashboard_snapshots; field ("My Day") is live per-user and never snapshotted—its input is z.object({}).strict(), so a spoofed userId is rejected. Per-user layouts, the widget registry and compute composables: docs/subsystems/dashboards-v2.md; render/customize modes in apps/web/CLAUDE.md.
Multi-party Commercial Reports
Grouped/tabular reporting at /dashboard/reports/[category], gated by report:read—not analytics:read (technician/sales_rep/viewer hold that; these carry vendor cost, retainage and margin). ReportRegistry in @zrm/analytics: a definition declares its columns and query returns only rows, so resolve() welds them and they cannot drift. roleBasis is four-valued (invoice_snapshot/current_roles/mixed/n_a) and report-sql-conformance.test.ts renders each definition's SQL to hold the declaration to it. Full mechanics: docs/subsystems/multiparty-commercial-reports.md.
Asset Provenance & Guided Role Split
Two answers to "records made before the multi-party model existed" (#473 phase 6). Assets store only the exception: assets.owning_organization_id nullable, NULL = derive from the site's active owner role — deliberately unbackfilled, because a column that silently equals the site owner is a second source of truth. Installed-by, supplied-by and warranties are derived by AssetProvenanceService, and each empty answer carries the reason it is empty ("found nothing" and "had nothing to search with" are different facts). asset.provenance is asset:read but folds in warranties only for a caller holding warranty:read. The guided split (RoleSplitService) walks a 0169-backfilled record — one org holding every role — to the right shape: preview-then-commit, and every row still written by assignProject/SiteRoleTx, so there is no second write path. correction voids the superseded row (zero-length, never true); handoff end-dates it (history survives). Full mechanics: docs/subsystems/asset-provenance-and-role-split.md.
Contacts Hang Off Organizations
contacts.organizationId FKs the party supertype, not crm_customers — an
end-user party holds people before it is ever billed (#678). "Contacts of this
customer" resolves through contactsOfCustomer() (contact-scope.ts), never a
direct column compare. Portal scoping has two lanes: service surfaces union
the customer_id predicate with an active customer-side organization role;
financial surfaces (invoices, quotes, opportunities, account) stay AR-only
behind requireCustomerProfile(). Both live in portal-scope.ts — no router
hand-rolls a scope. Tickets are deliberately not site-derived: a shared
building has two occupants. Full mechanics:
docs/superpowers/specs/2026-08-29-contacts-on-organizations-design.md.
Project Contact Roles
project_contacts records which person holds which responsibility on a
project (#762), where project_organization_roles records which party does.
change_order_approver and closeout_recipient are informational (#781): no
flow consumes them yet. Deliberately not effective-dated — a person leaving
a routing table is not a commercial interval, so removal is a real DELETE and
the audit row is the history. communication_allowed is nullable and that is
load-bearing: NULL inherits projects.communication_policy, true/false
override it either way — a non-null boolean cannot say "inherit", and either
default answers for someone nobody asked about.
resolveProjectResponsibilities takes projectPolicyAllows as a parameter so
the ticket banner and the rows under it cannot reach different conclusions from
one policy. Quote delivery shares the reader via
pickProjectDocumentRecipient (#779). Full mechanics:
docs/subsystems/project-contact-roles.md.
Multi-party Visibility
{project,site}_organization_roles.portal_visible is a nullable tri-state
override of the PORTAL_SITE_ROLES/PORTAL_PROJECT_ROLES allow-lists — NULL
inherits, true/false grant/revoke per row — nullable for the same reason
communication_allowed is. Communication policy is a hard block with no
override; the one escape hatch is project_contacts.communication_allowed. A
blocking policy restricts end-user contact, not the counterparty that hired us.
Quote send enforces it too now, keyed on quotes.project_id (#779). Staff
standing at a site is a separate list (#884). Full mechanics:
docs/subsystems/multiparty-visibility.md.
Organization-Only Create
/dashboard/organizations indexes the party supertype (#677); its Party only facet (profile: "none") filters in SQL, never client-side past the row cap. CreateOrganizationDialog is the one create path, and there is deliberately no promote button — assignProject/SiteRole/ensureCustomerProfileForOrg mint the AR/AP profile when it becomes true. Full mechanics: docs/subsystems/organization-only-create.md.
Party ↔ Profile Projection
organizations ↔ its AR/AP profiles projects both ways, from one module
(…/db/src/party/organization-projection.ts) — the same bug landed in each
direction separately (#495, #549). Status folds lossily outward, so the inverse
picks a preimage by round-trip stability: archived → churned (customer) /
inactive (vendor), never suspended. ensure{Customer,Vendor}ProfileForOrg
take the whole org row; the old four-column parameter could not carry
status. Mechanics: docs/subsystems/party-profile-projection.md.
Mobile Capture
Mobile bottom-nav Capture button (gated on agent_operator—user.me hydrates roles). Two-step sheet picks a source (camera/file) then a destination over search_index (8 entity types). Artifacts PUT to S3 (presigned); capture.finalize writes a linked entity_attachments+media_artifacts pair for every destination—no per-entity split (validation map in capture-entity-tables.ts).
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). 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 guards against flagging a foreign-workspace entity. List at /dashboard/pois.
Secrets (Device & Software Credentials)
Encrypted logins (not credentials, which are physical badges), attached to exactly one asset XOR system (DB CHECK). Create validates the target's workspace (forged-FK guard). list/get strip ciphertext — plaintext only via secret.reveal (activity-logged; UI uses the vanilla client, never useQuery). Systems link to 0..n sites via system_sites; systems.siteId is gone. Full mechanics: docs/subsystems/secrets.md.
Quote Versioning
quotes.versionGroupId + versionNumber group revisions; revise_quote clones into same group, previous→superseded on new send. Numbers YYYYMM-#### per workspace/month. Portal renders latest; portalQuote.getDiff powers a per-line diff (added/removed/modified BOM lines + financial deltas). Quotes also carry editable narrative fields (summary, overview/type, est. dates, change-order terms) plus an ordered quote_scope_sections table (atomic quoteScopeSection.replace, cloned on revision, rendered as quote-PDF Scope sections).
Quote Content Sections
quote_content_sections: workspace narrative library, section_type (assumptions/exclusions/warranty_terms/terms/change_order_terms). Selecting copies the body into the quote's column—edits/deletes never touch a sent quote. ≤1 default/type; QuoteService.create fills only caller-blank fields (whitespace=blank)—QuoteRevisionService never does. quote:read reads, admin:system_config writes; caps mirror quotes (warranty_terms≤2000, rest≤5000). Not quote_scope_sections (per-quote Scope blocks).
Procurement (Vendors, POs, Receiving)
PO state machine draft→pending_approval→submitted→acknowledged→partially_received→received; reject/cancel→canceled. Transitions live in procurement-helpers.ts. PurchaseOrderService.update() ignores status—it moves only through submitPo/approvePo/rejectPo/receivePoLine, and receivePoLine is the only receipt path, driving the work-order material upsert and cost roll-up. Bills are three-way matched (PO ↔ receipt ↔ bill, cumulative; tolerance in workspace_settings): over tolerance blocks markPaid until vendorBill.approveVariance records a reason under accounting:manage, not the PO permission. Approval threshold, inbound vs outbound direction, vendor quotes and preferred-vendor pricing: docs/subsystems/procurement.md; the four accounting controls: docs/subsystems/accounting-controls.md.
Inventory (Warehouses, Stock Levels, Transactions)
stock_levels is a materialized aggregate of the immutable stock_transactions ledger, updated in-tx. All mutations go through inventory-helpers.ts (outbound ops lock stock_levels FOR UPDATE); receivePoLine receives into stock only when product.trackInventory (opt-in, default false). Crossing the reorder threshold fires product_stock.below_reorder_point. Full mechanics: docs/subsystems/inventory.md.
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. Batches REORDER-prefixed POs into an open same-vendor draft (< 24h) rather than minting new; quote-sourced (AUTO-prefix) always mints new. Dashboard: stock.createReorderPo/bulkCreateReorderPos.
AI Provider Settings & Usage Monitoring
Per-provider API keys (AES-GCM, write-only, masked-hint reads) + chat/ask model at /dashboard/settings/ai, resolved workspace→master→env by @zrm/ai-settings. Add or reprice a model in MODEL_CATALOG (@zrm/ai/catalog), never in pricing.ts—getPricing throws on unknown ids, so superseded models stay as legacy. askModel and chatModel are asymmetric—don't collapse them: askModel applies under any provider, chatModel only when the resolved provider is anthropic, since it legitimately holds a foreign model. Budgets, alerting and the full resolution rules: docs/ops/ai-providers-and-usage.md.
Governed Generation
Every chat-model call goes through @zrm/ai generate* (#813): retries, fallback, spans, metrics. It never writes activity_log — the caller writes the ai_call row (failures too) with the prompt's promptVersion; editing a prompt fails prompt-versions.test.ts until bumped. See docs/subsystems/governed-generation.md.
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: 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; Personal—per-user via My Settings, visible ONLY to owning_user_id (not bypassable), opt-in ai_access_enabled gates AI.
Resolve through GoogleConnectionResolver, never a raw query. Perms 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.
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 linked site. Customer taxExempt zeros; quote-level override wins. Payment Term Templates: milestone schedules (label, percentage sum 100%, trigger on_acceptance/on_completion/net_days/milestone); one default/workspace, managed atomically. 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 (see the open-redirect gotcha); QuoteEngagementService logs email_{sent,open}/link_click/portal_view/pdf_download. PDF: QuotePdfService (@react-pdf/renderer), lazy-imported, attached multipart/mixed.
Invoice Delivery, Payments & Auto-Generation
Mirrors quote delivery (InvoiceSendService.send()→invoiceDeliveryWorkflow); Stripe Checkout lands on POST /api/webhooks/stripe, idempotent via stripeCheckoutSessionId. recalculateInvoiceTotals() is the only writer of subtotal/totalAmount on the tRPC path—an invoice worth X needs a line worth X; the generators still write their own header (docs/subsystems/accounting-controls.md). Full mechanics: docs/subsystems/invoice-delivery-and-payments.md.
Product Catalog UI
/dashboard/products + /dashboard/settings/products (shared Product/Asset category tree; master categories read-only). products.productType reuses assetTypeEnum. Cut sheets are S3-presigned with server-restricted content types and timestamped keys (no user filenames). Manufacturer text the vendor backfill left unlinked is reconciled at /dashboard/settings/products/manufacturers (#668). Full mechanics: docs/subsystems/product-catalog.md.
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/no_show, enforced by a status-gated UPDATE—the loser of a concurrent transition gets CONFLICT, never a silent overwrite. The graph is visitStatusTransitions (@zrm/domain-model), read by the service and both status controls, never re-declared. authorizeVisitMutation (assignee OR scheduling:manage_dispatch) is the single enforcement point for every status/scheduling/assignment change. Availability is batched: dispatch.getTechAvailability takes {userIds[], from, to} and costs six queries at any roster size—calling it per technician row is the #850 bug. visit.list requires a date window and takes a limit. Layering, skills, auto time-entry and the 8 permissions: docs/subsystems/scheduling-dispatch.md; board mechanics in apps/web/CLAUDE.md.
Semantic Search (Ask)
Hybrid retrieval + Claude-grounded answers over search_index, at /dashboard/search. Rows are indexed off the outbox (search-reindex handler), never in-request: no event, no search. HybridRetriever (@zrm/search) fuses BM25 and pgvector ids with RRF, then hydrates. aclPredicate({workspaceId, userId}) gates both halves and citation hydration, behind two anti-exfil barriers: citation-id validation, XML-tagged context. Budgets 60/user/hr, 500/workspace/day (429). HNSW-under-ACL, cache, embedding worker: docs/subsystems/semantic-search-ask.md.
Google Artifact Auto-Linking
Persistent Google↔CRM links via a hybrid classifier. Each classified artifact writes one entity_google_link row and the UI reads those rather than re-matching. Tiers run inline in the outbox tx (~50ms), exact alias/contact-email hit down to embedding nearest-neighbour; a middle confidence band lands as suggested for review at /dashboard/admin/google-links. ArtifactLinkerService lives in @zrm/linker to break the @zrm/workflows ↔ @zrm/api cycle. Tier thresholds, schema and the inbound-email path: docs/integrations/google-artifact-linking.md.
Customer Notifications & SMS
CustomerNotificationService → customerNotificationDeliveryWorkflow per channel; every attempt lands in customer_notification_deliveries. Guards are opt-in + a 60s dedup — no quiet hours (#735; skipped_quiet_hours is only a status value). Twilio inbound verifies its signature; a STOP-family keyword flips contacts.smsNotificationsEnabled=false. Flags ENABLE_CUSTOMER_NOTIFICATIONS (master) + ENABLE_CUSTOMER_SMS (Twilio), each w/ NEXT_PUBLIC_*; off→inert. Full mechanics: docs/subsystems/customer-notifications-sms.md.
Fleet Monitoring (GPS)
Vehicle GPS at /dashboard/fleet-monitoring; provider + services in @zrm/fleet (shared by API + worker). OneStepGpsProvider (ONESTEP_TOKEN; absent→demo, which has no trips/alerts/diagnostics so those sections hide). Temporal schedules keep caches warm so locations.latest is a pure read.
Watermarks, not cursors—OneStep's feed is newest-first, so a stored cursor points into old history; sync-fleet-activity (5m) resumes from its last non-failed run's to_time. It fills vehicle_alerts/vehicle_dtc_logs and publishes fleet.alert.received/fleet.dtc.detected for first-seen rows only (exactly-once via onConflictDoNothing().returning(), from the insert tx). Reads are persisted-first; activity.feed is persisted-only. create_fleet_ticket files against workspace_settings.fleet_maintenance_{customer,site}_id—unset→skip + notify admins, never throw.
Playback/drawer mechanics (incl. the time-not-index join) in apps/web/CLAUDE.md; sync internals, watermark arithmetic and provider setup in docs/fleet/fleet-monitoring-setup.md.
Technician Location Breadcrumbs
technician_location_pings — a person, not a vehicle: never vehicle_locations. Only writer TechnicianLocationService; each ping is judged against the policy revision in force at its recordedAt. Trails order by (bootId epoch, deviceSeq), never received_at. Full mechanics: docs/subsystems/technician-location-pings.md.
Metrics
…/observability/src/instruments.ts is the only place a metric name is written; emit via counter/histogram/observeGauge(key). Never cache an instrument—metrics.getMeter() has no proxy indirection (unlike trace.getTracer()), so one built before initMetrics() is permanently no-op; that was the original bug. initTracing is traces only. No workspace/tenant/user label ever (unbounded cardinality; per-workspace AI cost is activity_log+@zrm/ai-usage)—a catalog test enforces it. Prometheus exposition on its own port, not a Hono route; bind failure aborts boot. Full mechanics: docs/ops/metrics.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;
});