[{"data":1,"prerenderedAt":2360},["ShallowReactive",2],{"search":3,"content-developer\u002Fplatform-operations":442,"surround-\u002Fdeveloper\u002Fplatform-operations":2355},[4,8,12,16,20,24,28,32,36,40,44,48,52,56,60,64,68,72,76,80,84,88,92,96,100,104,108,112,116,120,124,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,260,264,268,272,276,280,284,288,292,296,300,304,308,312,316,320,323,327,331,335,339,343,347,351,355,359,363,367,371,375,379,383,387,391,395,399,403,407,411,415,419,423,426,430,434,438],{"path":5,"title":6,"description":7},"\u002Fguide","Guide","Product guides for Owlat — a modular, self-hosted email platform. Learn how to send campaigns, run a personal mailbox, manage a team inbox, and more.",{"path":9,"title":10,"description":11},"\u002Fguide\u002Fgetting-started","Welcome to Owlat","Set up your Owlat workspace and send your first email — from deploying the stack to verifying a domain, building your audience, and launching a campaign.",{"path":13,"title":14,"description":15},"\u002Fguide\u002Fcontact-properties","Contact Properties","Custom fields that extend built-in contact data with your own values for segmentation.",{"path":17,"title":18,"description":19},"\u002Fguide\u002Ftopics","Topics","Topics are explicit audience groups you manage by hand — ideal for opt-in subscribers, imported cohorts, and organized contact buckets you target with campaigns.",{"path":21,"title":22,"description":23},"\u002Fguide\u002Fsegments","Segments","Build dynamic, rule-based contact groups from properties, email activity, and topic membership, re-evaluated from current data each time they're used.",{"path":25,"title":26,"description":27},"\u002Fguide\u002Fforms","Forms","Form Endpoints collect new contacts from your website or landing pages by exposing a public endpoint that accepts submissions and feeds them into a topic.",{"path":29,"title":30,"description":31},"\u002Fguide\u002Fcampaigns","Campaigns & Reporting","Build and send marketing campaigns to a topic or segment with the five-step wizard, optional A\u002FB testing, and full delivery reporting.",{"path":33,"title":34,"description":35},"\u002Fguide\u002Fab-testing","A\u002FB Testing","Compare two variants of a campaign on a test group, then automatically or manually send the winning version to the rest of your audience.",{"path":37,"title":38,"description":39},"\u002Fguide\u002Fautomations","Automations","Send emails automatically based on triggers, delays, and conditions — build welcome series, trial flows, and follow-ups once and let Owlat run them.",{"path":41,"title":42,"description":43},"\u002Fguide\u002Ftransactional","Transactional Emails","One-to-one emails your application triggers in response to a user action — password resets, order confirmations, welcome emails, and similar notifications.",{"path":45,"title":46,"description":47},"\u002Fguide\u002Fcreate-campaign","Create a Campaign","Walk through Owlat's five-step campaign wizard: Basics, Audience, Content, A\u002FB Test, and Review & Send.",{"path":49,"title":50,"description":51},"\u002Fguide\u002Fsend-campaign","Send & Monitor a Campaign","How to send your campaign and track its performance with real-time metrics.",{"path":53,"title":54,"description":55},"\u002Fguide\u002Fquick-start","Quick Start","The fastest path from a blank Owlat workspace to a live email campaign, from your first template through sending and reviewing results.",{"path":57,"title":58,"description":59},"\u002Fguide\u002Ftransactional-setup","Transactional Email Setup","Set up and send transactional emails like password resets and order confirmations via the Owlat API and SDKs.",{"path":61,"title":62,"description":63},"\u002Fguide\u002Fdeliverability","Deliverability","Verify sending domains, manage your blocklist, monitor sending reputation, and stay compliant so your emails reach the inbox.",{"path":65,"title":66,"description":67},"\u002Fguide\u002Fapi-keys-webhooks","API Keys & Webhooks","Create API keys for programmatic access and set up outbound webhooks to receive real-time notifications for email and contact events.",{"path":69,"title":70,"description":71},"\u002Fguide\u002Ffeature-flags","Feature flags","Owlat is modular — every feature listed in this guide can be turned on or off. This page is the user-facing overview of how to do it.",{"path":73,"title":74,"description":75},"\u002Fguide\u002Fteam-permissions","Team & Permissions","Use role-based access to control what each member of your organization can do, with Owner, Admin, and Editor roles.",{"path":77,"title":78,"description":79},"\u002Fguide\u002Faudit-logs","Audit Logs","A chronological record of significant actions in your Owlat organization, so you can see who did what and when.",{"path":81,"title":82,"description":83},"\u002Fguide\u002Fshare-links","Share Links","Create temporary preview links to share email designs with stakeholders who don't have dashboard access.",{"path":85,"title":86,"description":87},"\u002Fguide\u002Fpostbox","Postbox — Personal Email","Per-user mailboxes with a webmail interface and native IMAP\u002FSMTP support. Run your own Gmail-equivalent personal mailbox on your Owlat instance.",{"path":89,"title":90,"description":91},"\u002Fguide\u002Fmigrate-from-google","Migrate from Google","Import your full Gmail history into Owlat over IMAP, and let your AI assistant learn from every imported conversation.",{"path":93,"title":94,"description":95},"\u002Fguide\u002Fteam-inbox","Team Inbox","Triage inbound email as a team: read AI-classified threads, approve, edit or reject agent drafts, work the review queue, and manage quarantine.",{"path":97,"title":98,"description":99},"\u002Fguide\u002Femail-editor","Email Editor","A block-based visual editor for building responsive emails that render consistently across desktop, mobile, Outlook, Gmail, and Apple Mail.",{"path":101,"title":102,"description":103},"\u002Fguide\u002Fai-agent","AI Agent & Autonomy","Configure the AI agent that classifies and drafts replies to inbound mail: auto-reply settings, the health dashboard, circuit breakers, autonomy rules, and the knowledge backfill.",{"path":105,"title":106,"description":107},"\u002Fguide\u002Fknowledge-graph","Knowledge Graph","Browse, search, and manage Owlat's typed organizational knowledge — the 7 entry types, source attribution, confidence decay, relations, and how entries are extracted from mail.",{"path":109,"title":110,"description":111},"\u002Fguide\u002Ffiles","Files","Upload, browse, search, tag, and version documents in the file library.",{"path":113,"title":114,"description":115},"\u002Fguide\u002Fchat","Team Chat","Use Owlat's built-in team chat: public and private channels, direct messages, mentions, attachments, and channels linked to an inbox conversation.",{"path":117,"title":118,"description":119},"\u002Fguide\u002Fcode-tasks","Code Tasks","Queue coding-agent tasks, watch them move from queued through review, and run the code-worker sidecar that opens the pull requests.",{"path":121,"title":122,"description":123},"\u002Fguide\u002Faudience-data","Audience Data: Identities, Relationships & Timeline","Unify a contact across email, phone, and messaging channels, merge duplicates, map relationships, and read the cross-channel interaction timeline.",{"path":125,"title":126,"description":127},"\u002Fguide\u002Fimporting-contacts","Importing & Exporting Contacts","Bring contacts into Owlat from a CSV or from Mailchimp and Stripe, export them back out, and run bulk operations on your audience.",{"path":129,"title":130,"description":131},"\u002Fguide\u002Faccount","Your Account & Data","Export your data as JSON or CSV, request account deletion with a 30-day grace period, and use the onboarding checklist and the public preference center.",{"path":133,"title":134,"description":135},"\u002Fguide\u002Fchannels","Communication Channels","Configure SMS, WhatsApp, and generic-webhook channels, monitor channel health, and understand which channels are fully live today.",{"path":137,"title":138,"description":139},"\u002Fguide\u002Fdesktop-app","Desktop App","Install the Owlat desktop app, connect one or more workspaces, switch between them, and use native notifications, tray badges, shortcuts, and deep links.",{"path":141,"title":142,"description":143},"\u002Fguide\u002Femail-templates","Email Templates","Reusable email designs that define the structure, content, and personalization of every campaign and transactional message you send in Owlat.",{"path":145,"title":146,"description":147},"\u002Fguide\u002Fai-assistant","AI Assistant","Owlat's multi-turn, streaming, tool-calling AI assistant — a private chat surface that can search your workspace and draft copy, plus @assistant replies inside team chat.",{"path":149,"title":150,"description":151},"\u002Fguide\u002Fsecurity-scanning","Sending Security & Scanning","Owlat's security scanning: a content check for spam and phishing, an attachment scan for malware, and a Google Safe Browsing URL check. Suspicious content goes to a review queue.",{"path":153,"title":154,"description":155},"\u002Fguide\u002Fsystem-updates","System & Updates","The owner-only System & Updates screen: your current Owlat version, container health, LLM spend, and the in-app one-click updater with history.",{"path":157,"title":158,"description":159},"\u002Fguide\u002Foperating-modes","Operating Modes","The different ways to run Owlat at a company — read external mailboxes over IMAP, send transactional or marketing email through a delivery provider, host your own mail server, or run a team inbox with AI — and the rules that keep each combination coherent.",{"path":161,"title":162,"description":163},"\u002Fguide\u002Fsaved-blocks","Saved Blocks","Create reusable, linked content blocks you can drop into any email — edit one and every email that uses it updates automatically.",{"path":165,"title":166,"description":167},"\u002Fguide\u002Fmedia-library","Media Library","Manage, organize, search, and reuse images and files across your emails from one centralized hub.",{"path":169,"title":170,"description":171},"\u002Fguide\u002Femail-theme","Email Theme","Set your organization's default colors, font, and email width so every new template starts from a consistent baseline.",{"path":173,"title":174,"description":175},"\u002Fguide\u002Ftranslations","Translations","Send one email in multiple languages: add per-language translations to a single template and Owlat picks the right version for each recipient.",{"path":177,"title":178,"description":179},"\u002Fguide\u002Fcontacts","Contacts","How to add, view, organize, and manage contacts in Owlat, including sources, the contact detail tabs, and subscription compliance.",{"path":181,"title":182,"description":183},"\u002Fapi","API Overview","Owlat exposes authenticated API endpoints under your Convex site URL.",{"path":185,"title":186,"description":187},"\u002Fapi\u002Fwebhooks","Webhooks","Owlat supports both outbound customer webhooks and inbound provider webhooks.",{"path":189,"title":190,"description":191},"\u002Fapi\u002Fpublic-endpoints","Public Endpoints","These routes are public-facing and usually accessed from email links or embedded forms.",{"path":193,"title":194,"description":195},"\u002Fapi\u002Fwebhook-payloads","Webhook Payloads","The authoritative wire contract for outbound webhooks: envelope, signature headers, per-event data shapes, and payload versioning.",{"path":197,"title":198,"description":199},"\u002Fapi\u002Finbound-channels","Inbound Channel Webhooks","Provider webhook reference for inbound SMS, WhatsApp, and generic-channel messages, plus the MTA mailbox and credential callbacks.",{"path":201,"title":202,"description":203},"\u002Fapi\u002Fauthentication","Authentication","Secure API access with organization-scoped API keys.",{"path":205,"title":206,"description":207},"\u002Fapi\u002Fsdk","TypeScript SDK","Typed client for the Owlat API, usable from Node.js, Bun, Deno, or any server-side JavaScript runtime.",{"path":209,"title":210,"description":211},"\u002Fapi\u002Fsdk-java","Java SDK","The official `owlat-sdk` package provides a typed client for interacting with the Owlat API from any JVM application. Requires Java 11+.",{"path":213,"title":214,"description":215},"\u002Fapi\u002Fcontacts","Contacts API","Manage contacts for your organization.",{"path":217,"title":218,"description":219},"\u002Fapi\u002Ftopics","Topics API","Manage topic membership through authenticated endpoints.",{"path":221,"title":222,"description":223},"\u002Fapi\u002Fevents","Events API","Send contact events to drive segmentation and automation triggers.",{"path":225,"title":226,"description":227},"\u002Fapi\u002Ftransactional","Transactional API","Send published transactional templates to a recipient.",{"path":229,"title":230,"description":231},"\u002Fapi\u002Fforms","Forms API","Capture subscribers through public form endpoints.",{"path":233,"title":234,"description":235},"\u002Fdeveloper","Developer Guide","Technical architecture, feature-flag model, and provider abstractions used by Owlat.",{"path":237,"title":238,"description":239},"\u002Fdeveloper\u002Fmta-system","MTA System","Owlat's custom Mail Transfer Agent for direct SMTP delivery with intelligent rate limiting, bounce processing, and IP warming.",{"path":241,"title":242,"description":243},"\u002Fdeveloper\u002Ffeature-flags","Feature flags — developer reference","How the Owlat feature flag system works: single source of truth, dependency resolution, docker profile mapping, and how to add a new flag.",{"path":245,"title":246,"description":247},"\u002Fdeveloper\u002Fhow-email-works","How Email Works","A technical deep-dive into how email actually works — from SMTP and DNS to authentication, deliverability, and the differences between marketing and private email.",{"path":249,"title":250,"description":251},"\u002Fdeveloper\u002Femail-security","Email Security","Content scanning, attachment validation, URL reputation checking, and malware detection for outbound emails.",{"path":253,"title":254,"description":255},"\u002Fdeveloper\u002Fpostbox-architecture","Postbox Architecture","How the Postbox personal-mail feature is wired — schema, IMAP server, app-password auth, outbound relay, inbound delivery, and external mailboxes.",{"path":257,"title":258,"description":259},"\u002Fdeveloper\u002Fproviders","Providers","Pluggable provider abstractions for LLM, email sending, notifications, vector stores, and analytics, selected per-deployment so self-hosters can swap implementations without code changes.",{"path":261,"title":262,"description":263},"\u002Fdeveloper\u002Fcampaign-internals","Campaign Internals","How the campaign backend works: two status machines, send pre-flight, the send orchestrator, emailSends records, and the priority workpools.",{"path":265,"title":266,"description":267},"\u002Fdeveloper\u002Faudience-internals","Audience Internals","Backend reference for contact resolution, the double opt-in lifecycle, topic subscription, the conditions registry, and segment evaluation.",{"path":269,"title":270,"description":271},"\u002Fdeveloper\u002Fautomation-internals","Automation Internals","How the automation run engine works: the step walker, the lifecycle state machine, trigger fanout, the three step types, and the resilience cron.",{"path":273,"title":274,"description":275},"\u002Fdeveloper\u002Fdeliverability-infrastructure","Deliverability Infrastructure","The Convex-side deliverability backend: provider routing, health-aware failover, sending reputation with auto-enforcement, IP warming cache, the blocklist, and the content-scan gate.",{"path":277,"title":278,"description":279},"\u002Fdeveloper\u002Farchitecture","Architecture Overview","Owlat follows a modern serverless architecture with real-time capabilities.",{"path":281,"title":282,"description":283},"\u002Fdeveloper\u002Fplatform-operations","Platform Operations","Operator reference for abuse status and the sending gate, the platform-admin roster, content review, org deletion, in-app self-update, dev endpoints, crons, and migrations.",{"path":285,"title":286,"description":287},"\u002Fdeveloper\u002Fscopes","Scopes","What each app and package in the Owlat monorepo is responsible for.",{"path":289,"title":290,"description":291},"\u002Fdeveloper\u002Fself-hosting","Self-Hosting","Deploy Owlat on your own infrastructure with Docker Compose. Complete guide from first boot to production.",{"path":293,"title":294,"description":295},"\u002Fdeveloper\u002Fself-hosting-config","Self-Hosting Configuration","Complete reference for Docker environment variables, Convex backend variables, service topology, and volume persistence.",{"path":297,"title":298,"description":299},"\u002Fdeveloper\u002Fself-hosting-dns-email","DNS & Email Setup","Configure DNS records, DKIM signing, SPF, DMARC, and bounce handling for reliable email delivery.",{"path":301,"title":302,"description":303},"\u002Fdeveloper\u002Fself-hosting-production","Production Deployment","Secure your self-hosted Owlat instance with TLS, firewall rules, backups, and monitoring.",{"path":305,"title":306,"description":307},"\u002Fdeveloper\u002Fself-hosting-maintenance","Maintenance & Updates","Keep your self-hosted Owlat instance up to date, manage backups, scale performance, and troubleshoot common issues.",{"path":309,"title":310,"description":311},"\u002Fdeveloper\u002Fself-hosting-desktop","Desktop Installer","Install Owlat on a bare Linux VPS straight from the desktop app over SSH — no terminal — with a live, animated provisioning timeline.",{"path":313,"title":314,"description":315},"\u002Fdeveloper\u002Fsetup-cli","Setup CLI & Installer","Operator reference for the Owlat self-host tooling: the install.sh one-liner, the owlat-setup CLI, the convex-deploy flow, and admin bootstrap.",{"path":317,"title":318,"description":319},"\u002Fdeveloper\u002Fconvex","Convex Backend","Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.",{"path":321,"title":202,"description":322},"\u002Fdeveloper\u002Fauthentication","Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.",{"path":324,"title":325,"description":326},"\u002Fdeveloper\u002Femail-system","Email System","Owlat's email system consists of a visual editor, template management, and multi-provider sending infrastructure.",{"path":328,"title":329,"description":330},"\u002Fdeveloper\u002Femail-renderer","Email Renderer","The @owlat\u002Femail-renderer package converts editor JSON blocks into production-ready HTML emails with cross-client compatibility, CSS inlining, dark mode, and Outlook VML fallbacks.",{"path":332,"title":333,"description":334},"\u002Fdeveloper\u002Fenvironment-variables","Environment Variables","Reference for every environment variable Owlat reads across the Convex backend, web app, MTA, IMAP server, and mail-sync worker.",{"path":336,"title":337,"description":338},"\u002Fdeveloper\u002Fcomponents","Component Library","Reference for the reusable, auto-imported Vue UI components shipped in the packages\u002Fui layer.",{"path":340,"title":341,"description":342},"\u002Fdeveloper\u002Fdecisions","Architectural Decision Records","The architectural decision records for the Owlat project, each capturing the context, the decision, and the trade-offs involved.",{"path":344,"title":345,"description":346},"\u002Fdeveloper\u002Fdecisions\u002F009-model-routing","ADR-009: Task-Based Model Routing","Why Owlat supports per-task LLM model selection instead of using a single model for all pipeline steps.",{"path":348,"title":349,"description":350},"\u002Fdeveloper\u002Fdecisions\u002F010-listing-engine","ADR-010: Listing Engine","Why Owlat replaced four incompatible list-query contracts with one generic listing engine driven by per-entity descriptors.",{"path":352,"title":353,"description":354},"\u002Fdeveloper\u002Fdecisions\u002F001-custom-email-renderer","ADR-001: Custom Email Renderer Over MJML","Why Owlat built a custom table-based HTML email renderer instead of using MJML, gaining full control over VML, dark mode, and per-client rendering.",{"path":356,"title":357,"description":358},"\u002Fdeveloper\u002Fdecisions\u002F002-convex-backend","ADR-002: Convex as Backend","Why Owlat chose Convex over PostgreSQL and Firebase for real-time reactivity, co-located TypeScript logic, and zero-config scaling.",{"path":360,"title":361,"description":362},"\u002Fdeveloper\u002Fdecisions\u002F003-notion-like-builder","ADR-003: Notion-like Email Builder","Why Owlat replaced the traditional 3-panel email editor with a Notion-like single-column canvas for inline WYSIWYG editing.",{"path":364,"title":365,"description":366},"\u002Fdeveloper\u002Fdecisions\u002F004-monorepo-bun-workspaces","ADR-004: Monorepo with Bun Workspaces","Why Owlat uses a monorepo with Bun workspaces and Turborepo for fast installs, atomic cross-package changes, and cached CI.",{"path":368,"title":369,"description":370},"\u002Fdeveloper\u002Fdecisions\u002F005-custom-mta","ADR-005: Custom MTA","Why Owlat built a custom Mail Transfer Agent instead of relying solely on third-party email providers.",{"path":372,"title":373,"description":374},"\u002Fdeveloper\u002Fdecisions\u002F006-self-hosted-convex","ADR-006: Self-Hosted Convex","Why Owlat uses the open-source Convex backend for self-hosting instead of migrating to a different database.",{"path":376,"title":377,"description":378},"\u002Fdeveloper\u002Fdecisions\u002F007-pluggable-llm","ADR-007: Pluggable LLM Provider","Why Owlat uses the Vercel AI SDK with a provider abstraction layer instead of hardcoding a single LLM vendor.",{"path":380,"title":381,"description":382},"\u002Fdeveloper\u002Fdecisions\u002F008-process-architecture","ADR-008: Agent Process Architecture","Why Owlat processes inbound messages with a self-scheduling step walker plus a lifecycle coordinator instead of one sequential function.",{"path":384,"title":385,"description":386},"\u002Fexamples","Examples","Copy-pasteable integration patterns for common Owlat use cases.",{"path":388,"title":389,"description":390},"\u002Fexamples\u002Fwelcome-email","Welcome Email","Send a personalized welcome email when a new user signs up.",{"path":392,"title":393,"description":394},"\u002Fexamples\u002Fbilling-email","Billing Email","Send a billing receipt with an invoice PDF attached after a successful payment.",{"path":396,"title":397,"description":398},"\u002Fexamples\u002Fevent-automation","Event Automation","Trigger automations with custom events for trial lifecycle, feature adoption, and more.",{"path":400,"title":401,"description":402},"\u002Fexamples\u002Fcontact-sync","Contact Sync","Sync contacts from your database to Owlat using upsert patterns and bulk operations.",{"path":404,"title":405,"description":406},"\u002Fexamples\u002Fwebhook-handler","Webhook Handler","Handle Owlat delivery webhooks with signature verification and event routing.",{"path":408,"title":409,"description":410},"\u002Fexamples\u002Fmultilingual-email","Multilingual Email","Send emails in the recipient's preferred language using template translations.",{"path":412,"title":413,"description":414},"\u002Fvision","Vision","Where Owlat is heading — from email platform to unified communication intelligence powered by AI agents.",{"path":416,"title":417,"description":418},"\u002Fvision\u002Fself-hosting","Self-Hosting Architecture","How Owlat runs as a fully self-hosted stack using Docker Compose — open-source Convex backend, custom MTA, and a pluggable LLM provider.",{"path":420,"title":421,"description":422},"\u002Fvision\u002Fagent-pipeline","Agent Pipeline","Technical architecture for the inbound email agent pipeline — step modules, the walker, security scanning, threading, and human review.",{"path":424,"title":106,"description":425},"\u002Fvision\u002Fknowledge-graph","Technical architecture for Owlat's typed knowledge storage — how organizational knowledge is stored, searched, decayed, and maintained.",{"path":427,"title":428,"description":429},"\u002Fvision\u002Fmulti-channel","Multi-Channel & CRM","Technical architecture for channel adapters, unified messaging, contact identity unification, and the CRM hub.",{"path":431,"title":432,"description":433},"\u002Fvision\u002Ffile-system","Semantic File System","Technical architecture for Owlat's semantic file storage — version tracking with provenance today, plus the planned embedding-based retrieval and auto-tagging layer.",{"path":435,"title":436,"description":437},"\u002Fvision\u002Fdesktop-app","Desktop App & Advanced Agents","Architecture of the Owlat desktop shell, visualization agent, adaptive dashboard, agent health, graduated autonomy, and coding agents.",{"path":439,"title":440,"description":441},"\u002Fvision\u002Froadmap","Roadmap","What's planned next for Owlat — the documented-but-unbuilt pieces still being wired, and the enhancements on our radar.",{"id":443,"title":282,"body":444,"description":283,"extension":2349,"meta":2350,"navigation":2351,"path":281,"seo":2352,"stem":2353,"__hash__":2354},"content\u002F3.developer\u002F20.platform-operations.md",{"type":445,"value":446,"toc":2321},"minimark",[447,456,469,493,498,509,601,608,681,686,689,752,770,774,854,886,890,915,922,1005,1023,1027,1041,1211,1220,1224,1230,1289,1293,1302,1312,1393,1411,1470,1476,1480,1505,1518,1521,1612,1619,1623,1632,1636,1661,1721,1728,1732,1743,1830,1834,1852,1941,1954,1966,1971,1975,1979,2006,2080,2092,2096,2102,2232,2248,2252,2270,2273,2294,2300,2304,2308,2311,2314,2317],[448,449,450,451,455],"p",{},"This page documents the operational surface that sits ",[452,453,454],"em",{},"behind"," the product: the abuse-status lifecycle that gates sending, the platform-admin role and its queries, the content review queue, the organization-deletion walker, system health and in-app self-update, and the dev-mode endpoints, crons, and migrations that keep a deployment maintainable. It is written for operators and contributors working on a self-hosted Owlat instance.",[457,458,461],"callout",{"title":459,"type":460},"One organization per deployment","info",[448,462,463,464,468],{},"Owlat is single-org-per-deployment. The data plane belongs to exactly one organization, and the abuse-status, settings, and stats described here all live on the singleton ",[465,466,467],"code",{},"instanceSettings"," row. There is no multi-tenant org switcher.",[457,470,473],{"title":471,"type":472},"No platform-admin dashboard in this repo","warning",[448,474,475,476,480,481,484,485,488,489,492],{},"This OSS repository ships ",[477,478,479],"strong",{},"one"," platform-admin-gated page: ",[477,482,483],{},"Settings → System & Updates"," (",[465,486,487],{},"\u002Fdashboard\u002Fsettings\u002Fsystem","). The richer control-plane UI that consumed the queries below was extracted into a separate private repo. Treat the roster, abuse-status, and content-review functions documented here as a ",[477,490,491],{},"backend\u002FAPI surface"," you call from your own tooling or scripts — not as an in-product dashboard.",[494,495,497],"h2",{"id":496},"abuse-status-lifecycle-and-the-sending-gate","Abuse status lifecycle and the sending gate",[448,499,500,501,504,505,508],{},"Every deployment carries a single abuse status on ",[465,502,503],{},"instanceSettings.abuseStatus",". It drives whether the instance is allowed to send. The four statuses, defined in ",[465,506,507],{},"apps\u002Fapi\u002Fconvex\u002Forganizations\u002FabuseStatus.ts",", form a severity ladder:",[510,511,512,531],"table",{},[513,514,515],"thead",{},[516,517,518,522,525,528],"tr",{},[519,520,521],"th",{},"Status",[519,523,524],{},"Severity",[519,526,527],{},"Sending",[519,529,530],{},"Meaning",[532,533,534,551,566,584],"tbody",{},[516,535,536,542,545,548],{},[537,538,539],"td",{},[465,540,541],{},"clean",[537,543,544],{},"0",[537,546,547],{},"Allowed",[537,549,550],{},"Normal operation (also the default when no status was ever written)",[516,552,553,558,561,563],{},[537,554,555],{},[465,556,557],{},"warned",[537,559,560],{},"1",[537,562,547],{},[537,564,565],{},"Advisory warning issued — still fully operational",[516,567,568,573,576,581],{},[537,569,570],{},[465,571,572],{},"suspended",[537,574,575],{},"2",[537,577,578],{},[477,579,580],{},"Blocked",[537,582,583],{},"All sending blocked; the account stays accessible",[516,585,586,591,594,598],{},[537,587,588],{},[465,589,590],{},"banned",[537,592,593],{},"3",[537,595,596],{},[477,597,580],{},[537,599,600],{},"Account fully disabled; terminal for internal writers",[448,602,603,604,607],{},"The read and write halves are deliberately split into sibling modules (ADR-0011, ",[465,605,606],{},"docs\u002Fadr\u002F0011-abuse-status-modules.md","):",[609,610,611,655],"ul",{},[612,613,614,484,617,620,621,624,625,628,629,632,633,635,636,638,639,641,642,645,646,649,650,638,652,654],"li",{},[477,615,616],{},"Abuse gate",[465,618,619],{},"apps\u002Fapi\u002Fconvex\u002Forganizations\u002FabuseGate.ts",") is the home of the sending predicate. It exports two surfaces: ",[465,622,623],{},"isSendingAllowed(status)",", the pure predicate that the live send paths call (the transactional dispatch path in ",[465,626,627],{},"transactional\u002Fdispatch.ts","), and ",[465,630,631],{},"requireSendingAllowed(ctx)",", a mutation-context helper that fetches ",[465,634,467],{}," and throws on ",[465,637,572],{},"\u002F",[465,640,590],{},". The campaign send path enforces the same predicate inline through its pre-flight query (",[465,643,644],{},"campaigns\u002Fpreflight.ts",", which checks ",[465,647,648],{},"abuseStatus"," against ",[465,651,572],{},[465,653,590],{}," directly) rather than calling the helper. Either way the gate always runs — it is not behind a feature flag.",[612,656,657,484,660,662,663,666,667,669,670,673,674,673,677,680],{},[477,658,659],{},"Abuse status",[465,661,507],{},") is the ",[452,664,665],{},"only"," writer of ",[465,668,648],{}," and its companion fields (",[465,671,672],{},"abuseStatusReason",", ",[465,675,676],{},"abuseStatusChangedAt",[465,678,679],{},"abuseStatusChangedBy",").",[682,683,685],"h3",{"id":684},"transition-rules","Transition rules",[448,687,688],{},"There are two write entry points. The internal-writer path enforces severity rules; the admin path bypasses them.",[510,690,691,704],{},[513,692,693],{},[516,694,695,698,701],{},[519,696,697],{},"Entry point",[519,699,700],{},"Used by",[519,702,703],{},"Rules",[532,705,706,729],{},[516,707,708,714,717],{},[537,709,710,713],{},[465,711,712],{},"transition"," (internal mutation)",[537,715,716],{},"MTA circuit breaker, reputation auto-enforcement",[537,718,719,721,722,725,726,728],{},[465,720,590],{}," is terminal; downgrades are refused ",[452,723,724],{},"except"," down to ",[465,727,541],{}," (the auto-recover path); a same-state attempt is recorded but not patched",[516,730,731,736,743],{},[537,732,733,713],{},[465,734,735],{},"adminOverride",[537,737,738,739,742],{},"The platform-admin ",[465,740,741],{},"setOrganizationStatus"," mutation",[537,744,745,746,748,749,751],{},"Bypasses all severity rules — an admin can demote a ",[465,747,590],{}," org back to ",[465,750,541],{}," to resolve an appeal",[448,753,754,755,758,759,762,763,765,766,769],{},"Both paths write an ",[465,756,757],{},"abuse_status_changed"," audit-log row on ",[477,760,761],{},"every"," call, including same-state no-ops (so \"circuit breaker tripped again while already warned\" is observable). A missing ",[465,764,467],{}," row returns ",[465,767,768],{},"{ ok: false, reason: 'no_settings_row' }"," rather than throwing — an early-deployment edge case.",[682,771,773],{"id":772},"what-flips-the-status-automatically","What flips the status automatically",[510,775,776,789],{},[513,777,778],{},[516,779,780,783,786],{},[519,781,782],{},"Trigger",[519,784,785],{},"Source",[519,787,788],{},"Target status",[532,790,791,817,836],{},[516,792,793,800,813],{},[537,794,795,796,799],{},"Reputation reaches ",[465,797,798],{},"critical"," risk",[537,801,802,805,806,809,810],{},[465,803,804],{},"analytics\u002FsendingReputation.ts"," → ",[465,807,808],{},"evaluateAutoEnforce"," (hourly cron) → ",[465,811,812],{},"autoEnforceReputation",[537,814,815],{},[465,816,572],{},[516,818,819,824,832],{},[537,820,795,821,799],{},[465,822,823],{},"high",[537,825,826,805,828,809,830],{},[465,827,804],{},[465,829,808],{},[465,831,812],{},[537,833,834],{},[465,835,557],{},[516,837,838,841,850],{},[537,839,840],{},"MTA circuit breaker trips",[537,842,843,484,846,849],{},[465,844,845],{},"webhooks\u002Fdispatcher.ts",[465,847,848],{},"internal.circuit_breaker_tripped",")",[537,851,852],{},[465,853,557],{},[448,855,856,857,860,861,864,865,868,869,871,872,874,875,864,878,881,882,885],{},"Reputation is derived on read (a rolling 30-day window — see ",[858,859,274],"a",{"href":273},"), but auto-enforcement is deliberately kept off the read hot path: an hourly ",[465,862,863],{},"evaluate reputation auto-enforce"," cron (",[465,866,867],{},"apps\u002Fapi\u002Fconvex\u002Fcrons.ts",") calls ",[465,870,808],{},", which summarizes the org window and schedules ",[465,873,812],{}," when risk is high\u002Fcritical. A separate hourly ",[465,876,877],{},"cleanup sending reputation",[465,879,880],{},"recalculateAll",") ages out >60-day buckets. Because severity downgrades are refused, a ",[465,883,884],{},"critical → warned"," transition never silently relaxes an existing suspension.",[494,887,889],{"id":888},"platform-admin-role-roster-and-operational-queries","Platform admin role, roster, and operational queries",[448,891,892,893,896,897,900,901,904,905,908,909,911,912,914],{},"Platform admins are rows in the ",[465,894,895],{},"platformAdmins"," table, keyed by BetterAuth user id. Two roles exist: ",[465,898,899],{},"admin"," and ",[465,902,903],{},"superadmin",". The first admin is created with ",[465,906,907],{},"seedPlatformAdmin"," (an internal mutation that only succeeds while the table is empty and always seeds a ",[465,910,903],{},"); after that, ",[465,913,903],{},"s manage the roster.",[448,916,917,918,921],{},"Gating is centralized in ",[465,919,920],{},"apps\u002Fapi\u002Fconvex\u002FplatformAdmin\u002FplatformAdmin.ts",":",[510,923,924,937],{},[513,925,926],{},[516,927,928,931,934],{},[519,929,930],{},"Function",[519,932,933],{},"Kind",[519,935,936],{},"Purpose",[532,938,939,962,975,991],{},[516,940,941,946,949],{},[537,942,943],{},[465,944,945],{},"requirePlatformAdmin(ctx)",[537,947,948],{},"Helper",[537,950,951,952,955,956,958,959],{},"Throws ",[465,953,954],{},"FORBIDDEN"," unless the caller is in ",[465,957,895],{},"; returns ",[465,960,961],{},"{ authUserId, email, role }",[516,963,964,969,972],{},[537,965,966],{},[465,967,968],{},"isPlatformAdmin",[537,970,971],{},"Public query",[537,973,974],{},"Boolean nav helper, safe for anonymous callers",[516,976,977,982,985],{},[537,978,979],{},[465,980,981],{},"isPlatformAdminByUserId",[537,983,984],{},"Internal query",[537,986,987,988],{},"Admin check by user id, for HTTP\u002Faction contexts that lack a ",[465,989,990],{},"QueryCtx",[516,992,993,997,1000],{},[537,994,995],{},[465,996,907],{},[537,998,999],{},"Internal mutation",[537,1001,1002,1003],{},"One-shot bootstrap of the first ",[465,1004,903],{},[448,1006,1007,1008,1011,1012,1015,1016,1018,1019,1022],{},"The web app gates the single admin page with the ",[465,1009,1010],{},"platform-admin"," route middleware (",[465,1013,1014],{},"apps\u002Fweb\u002Fapp\u002Fmiddleware\u002Fplatform-admin.ts","), which calls ",[465,1017,968],{}," and redirects non-admins to ",[465,1020,1021],{},"\u002Fdashboard",".",[682,1024,1026],{"id":1025},"operational-queries","Operational queries",[448,1028,1029,1030,1033,1034,1037,1038,1040],{},"All of these live in ",[465,1031,1032],{},"apps\u002Fapi\u002Fconvex\u002FplatformAdmin\u002Fqueries.ts"," and start with ",[465,1035,1036],{},"requirePlatformAdmin",". They read the singleton ",[465,1039,467],{}," row plus derived reputation\u002Fstats:",[510,1042,1043,1053],{},[513,1044,1045],{},[516,1046,1047,1050],{},[519,1048,1049],{},"Query",[519,1051,1052],{},"Returns",[532,1054,1055,1065,1083,1103,1124,1140,1152,1166,1187,1201],{},[516,1056,1057,1062],{},[537,1058,1059],{},[465,1060,1061],{},"getPlatformStats",[537,1063,1064],{},"Contact count, abuse status, 30-day send\u002Fdelivery\u002Fbounce\u002Fcomplaint aggregates, signups-by-day",[516,1066,1067,1072],{},[537,1068,1069],{},[465,1070,1071],{},"getDeliveryStats",[537,1073,1074,1075,1078,1079,1082],{},"In-flight ",[465,1076,1077],{},"sending"," campaigns, ",[465,1080,1081],{},"scheduled"," count, aggregate delivery metrics, last-7-day blocked-email counts by reason",[516,1084,1085,1090],{},[537,1086,1087],{},[465,1088,1089],{},"getOrganizationDetail",[537,1091,1092,1093,638,1096,638,1099,1102],{},"Settings, reputation summary, blocked-email counts (",[465,1094,1095],{},"bounced",[465,1097,1098],{},"complained",[465,1100,1101],{},"manual","), recent content scans and campaigns",[516,1104,1105,1110],{},[537,1106,1107],{},[465,1108,1109],{},"listFlaggedOrganizations",[537,1111,1112,1113,638,1115,638,1117,1119,1120,638,1122],{},"The instance entry, but only when ",[465,1114,557],{},[465,1116,572],{},[465,1118,590],{}," or reputation risk is ",[465,1121,823],{},[465,1123,798],{},[516,1125,1126,1131],{},[537,1127,1128],{},[465,1129,1130],{},"listAllOrganizations",[537,1132,1133,1134,638,1137],{},"The instance entry with optional ",[465,1135,1136],{},"search",[465,1138,1139],{},"statusFilter",[516,1141,1142,1147],{},[537,1143,1144],{},[465,1145,1146],{},"listAllUsers",[537,1148,1149,1150],{},"User profiles with optional ",[465,1151,1136],{},[516,1153,1154,1159],{},[537,1155,1156],{},[465,1157,1158],{},"listAllDomains",[537,1160,1161,1162,638,1164],{},"Domains with optional ",[465,1163,1139],{},[465,1165,1136],{},[516,1167,1168,1173],{},[537,1169,1170],{},[465,1171,1172],{},"listRecentAbuse",[537,1174,1175,1176,638,1179,1182,1183,1186],{},"Flagged content scans (",[465,1177,1178],{},"suspicious",[465,1180,1181],{},"blocked",") plus ",[465,1184,1185],{},"pending_review"," campaigns",[516,1188,1189,1194],{},[537,1190,1191],{},[465,1192,1193],{},"getAdminAuditLog",[537,1195,1196,1197,1200],{},"Up to 200 ",[465,1198,1199],{},"platform_admin","-resource audit-log rows, with admin-email resolution",[516,1202,1203,1208],{},[537,1204,1205],{},[465,1206,1207],{},"listPlatformAdmins",[537,1209,1210],{},"The full admin roster",[457,1212,1214],{"title":1213,"type":460},"Billing is out of OSS",[448,1215,1216,1219],{},[465,1217,1218],{},"getBillingOverview"," exists for backward compatibility and returns empty data — invoice-based billing was removed during the billing simplification and now lives only in the separate private repo. Do not build against it.",[682,1221,1223],{"id":1222},"roster-status-mutations","Roster + status mutations",[448,1225,1226,1227,921],{},"These live in ",[465,1228,1229],{},"apps\u002Fapi\u002Fconvex\u002FplatformAdmin\u002Fmutations.ts",[510,1231,1232,1242],{},[513,1233,1234],{},[516,1235,1236,1239],{},[519,1237,1238],{},"Mutation",[519,1240,1241],{},"Effect",[532,1243,1244,1263,1276],{},[516,1245,1246,1250],{},[537,1247,1248],{},[465,1249,741],{},[537,1251,1252,1253,1255,1256,1258,1259,1262],{},"Sets abuse status via ",[465,1254,735],{}," (bypassing severity rules); writes both ",[465,1257,757],{}," and the legacy ",[465,1260,1261],{},"platform_admin.org_status_changed"," audit rows",[516,1264,1265,1270],{},[537,1266,1267],{},[465,1268,1269],{},"addPlatformAdmin",[537,1271,1272,1273,1275],{},"Adds an admin (",[465,1274,903],{},"-only; rejects duplicates)",[516,1277,1278,1283],{},[537,1279,1280],{},[465,1281,1282],{},"removePlatformAdmin",[537,1284,1285,1286,1288],{},"Removes an admin (",[465,1287,903],{},"-only; cannot remove yourself)",[494,1290,1292],{"id":1291},"content-review-queue-approvereject-pending-content","Content review queue (approve\u002Freject pending content)",[448,1294,1295,1296,1298,1299,1301],{},"Campaigns and transactional emails can land in a ",[465,1297,1185],{}," status (for example when content scanning flags them — see ",[858,1300,250],{"href":249},"). The review surface is platform-admin only.",[448,1303,1304,1307,1308,1311],{},[465,1305,1306],{},"getContentReviewQueue"," (in ",[465,1309,1310],{},"platformAdmin\u002Fqueries.ts",") returns the pending items joined with their latest content-scan result, plus the recently-reviewed log:",[1313,1314,1319],"pre",{"className":1315,"code":1316,"language":1317,"meta":1318,"style":1318},"language-ts shiki shiki-themes github-light github-dark-dimmed","{\n  pending: [{ type: 'campaign' | 'transactional', id, name, subject, scan: { score, level } | null, ... }],\n  pendingCount: number,\n  recentlyReviewed: [{ action, details, userId, createdAt }],\n}\n","ts","",[465,1320,1321,1330,1369,1378,1387],{"__ignoreMap":1318},[1322,1323,1326],"span",{"class":1324,"line":1325},"line",1,[1322,1327,1329],{"class":1328},"sYgZi","{\n",[1322,1331,1333,1337,1340,1344,1348,1351,1354,1357,1361,1363,1366],{"class":1324,"line":1332},2,[1322,1334,1336],{"class":1335},"sOLd2","  pending",[1322,1338,1339],{"class":1328},": [{ type: ",[1322,1341,1343],{"class":1342},"s-HuK","'campaign'",[1322,1345,1347],{"class":1346},"s7YZ4"," |",[1322,1349,1350],{"class":1342}," 'transactional'",[1322,1352,1353],{"class":1328},", id, name, subject, scan: { score, level } ",[1322,1355,1356],{"class":1346},"|",[1322,1358,1360],{"class":1359},"sviXB"," null",[1322,1362,673],{"class":1328},[1322,1364,1365],{"class":1346},"...",[1322,1367,1368],{"class":1328}," }],\n",[1322,1370,1372,1375],{"class":1324,"line":1371},3,[1322,1373,1374],{"class":1335},"  pendingCount",[1322,1376,1377],{"class":1328},": number,\n",[1322,1379,1381,1384],{"class":1324,"line":1380},4,[1322,1382,1383],{"class":1335},"  recentlyReviewed",[1322,1385,1386],{"class":1328},": [{ action, details, userId, createdAt }],\n",[1322,1388,1390],{"class":1324,"line":1389},5,[1322,1391,1392],{"class":1328},"}\n",[448,1394,1395,1396,1399,1400,1402,1403,1406,1407,1410],{},"The three review mutations (in ",[465,1397,1398],{},"platformAdmin\u002Fmutations.ts",") all require ",[465,1401,1185],{}," as the current status and write a ",[465,1404,1405],{},"platform_admin.content_approved"," or ",[465,1408,1409],{},"platform_admin.content_rejected"," audit row:",[510,1412,1413,1422],{},[513,1414,1415],{},[516,1416,1417,1419],{},[519,1418,1238],{},[519,1420,1421],{},"Effect on the resource",[532,1423,1424,1438,1455],{},[516,1425,1426,1431],{},[537,1427,1428],{},[465,1429,1430],{},"approveCampaign",[537,1432,1433,1434,1437],{},"Campaign → ",[465,1435,1436],{},"draft"," (the owner can then send it)",[516,1439,1440,1445],{},[537,1441,1442],{},[465,1443,1444],{},"approveTransactional",[537,1446,1447,1448,1451,1452,849],{},"Transactional email → ",[465,1449,1450],{},"published"," (sets ",[465,1453,1454],{},"publishedAt",[516,1456,1457,1462],{},[537,1458,1459],{},[465,1460,1461],{},"rejectContent",[537,1463,1433,1464,1466,1467,1469],{},[465,1465,1436],{},", or transactional email → ",[465,1468,1436],{},"; reason is recorded",[448,1471,1472,1473,1475],{},"Both approval paths and the rejection path move the resource out of the queue; there is no separate \"blocked\" terminal state for reviewed content — rejection reverts to ",[465,1474,1436],{}," with the reason captured in the audit log.",[494,1477,1479],{"id":1478},"organization-deletion-walker","Organization deletion walker",[448,1481,1482,1483,1307,1486,1489,1490,1493,1494,1497,1498,1501,1502,680],{},"Deleting the organization is a hard, irreversible wipe of the entire data plane. It is owner-only: ",[465,1484,1485],{},"organizationSettings.remove",[465,1487,1488],{},"apps\u002Fapi\u002Fconvex\u002Forganizations\u002Fsettings.ts",") checks ",[465,1491,1492],{},"session.role === 'owner'"," and schedules ",[465,1495,1496],{},"internal.organizations.deletion.walker.start",". The walker lives in ",[465,1499,1500],{},"apps\u002Fapi\u002Fconvex\u002Forganizations\u002Fdeletion\u002Fwalker.ts"," and implements ADR-0025 (",[465,1503,1504],{},"docs\u002Fadr\u002F0025-organization-deletion-module-family.md",[457,1506,1509],{"title":1507,"type":1508},"Irreversible","danger",[448,1510,1511,1514,1515,1517],{},[465,1512,1513],{},"remove"," deletes every per-organization table, including storage blobs, audit logs, and finally the singleton ",[465,1516,467],{}," row that owns the org's existence. There is no soft-delete and no recovery short of a database restore.",[448,1519,1520],{},"How it works:",[609,1522,1523,1550,1588],{},[612,1524,1525,1528,1529,1532,1533,673,1536,673,1539,1542,1543,1546,1547,1549],{},[477,1526,1527],{},"Ordered cascade."," ",[465,1530,1531],{},"STEPS"," is an ordered list of the full per-organization table set, ~90 tables. Children are deleted before parents; storage-bearing tables (e.g. ",[465,1534,1535],{},"mediaAssets",[465,1537,1538],{},"semanticFiles",[465,1540,1541],{},"mailMessages",") purge their blobs before the row delete; ",[465,1544,1545],{},"auditLogs"," is second-to-last (it keeps accumulating from delegated lifecycle calls during the wipe); ",[465,1548,467],{}," is the terminal step.",[612,1551,1552,1555,1556,1559,1560,1563,1564,1567,1568,1571,1572,673,1575,673,1578,673,1581,673,1584,1587],{},[477,1553,1554],{},"One module per table."," Each entry has a sibling step module under ",[465,1557,1558],{},"deletion\u002Fsteps\u002F\u003Ctable>.ts"," implementing the ",[465,1561,1562],{},"deleteBatch(ctx)"," contract from ",[465,1565,1566],{},"steps\u002F_common.ts"," (default batch size 100). A few tables delegate — for example the ",[465,1569,1570],{},"contacts"," step sweeps five child tables (",[465,1573,1574],{},"contactTopics",[465,1576,1577],{},"contactPropertyValues",[465,1579,1580],{},"contactActivities",[465,1582,1583],{},"contactIdentities",[465,1585,1586],{},"contactRelationships",") that are not standalone steps.",[612,1589,1590,1528,1593,1596,1597,1600,1601,1604,1605,1607,1608,1611],{},[477,1591,1592],{},"Self-scheduled hop.",[465,1594,1595],{},"runStep"," runs one batch, re-fires itself while ",[465,1598,1599],{},"hasMore"," is true, advances to ",[465,1602,1603],{},"nextTable"," when a step drains, and terminates when there is no next step. The ",[465,1606,510],{}," argument is validated against the literal union in ",[465,1609,1610],{},"_common.ts",", so a typo is a compile-time and boot-time error rather than a silent no-op.",[448,1613,1614,1615,1618],{},"Any new per-organization table added to the schema must also add a literal to ",[465,1616,1617],{},"OrganizationDeletionTable"," and a sibling step module, or the deletion cascade will leave it orphaned.",[494,1620,1622],{"id":1621},"system-health-and-in-app-self-update-updater-sidecar","System health and in-app self-update (updater sidecar)",[448,1624,1625,1626,1628,1629,1631],{},"The ",[477,1627,154],{}," page (",[465,1630,487],{},", platform-admin only) surfaces the current version, container health, the GitHub release check, the update flow, and update history.",[682,1633,1635],{"id":1634},"update-check","Update check",[448,1637,1638,1641,1642,1645,1646,1649,1650,1653,1654,1657,1658,1022],{},[465,1639,1640],{},"apps\u002Fapi\u002Fconvex\u002FsystemUpdates.ts"," polls the GitHub Releases API (",[465,1643,1644],{},"https:\u002F\u002Fapi.github.com\u002Frepos\u002Fwolvesdotink\u002Fowlat\u002Freleases\u002Flatest","), caches the result in a singleton ",[465,1647,1648],{},"systemUpdates"," row (",[465,1651,1652],{},"kind: 'latestCheck'",") with a 1-hour TTL, and computes ",[465,1655,1656],{},"updateAvailable"," by comparing the cached latest version to the running ",[465,1659,1660],{},"OWLAT_VERSION",[510,1662,1663,1674],{},[513,1664,1665],{},[516,1666,1667,1669,1671],{},[519,1668,930],{},[519,1670,933],{},[519,1672,1673],{},"Notes",[532,1675,1676,1693,1705],{},[516,1677,1678,1683,1686],{},[537,1679,1680],{},[465,1681,1682],{},"checkForUpdates",[537,1684,1685],{},"Action",[537,1687,1688,1689,1692],{},"Admin-gated; honours the 1-hour cache unless ",[465,1690,1691],{},"force: true","; returns cache on GitHub 403\u002F429 rate-limit",[516,1694,1695,1700,1702],{},[537,1696,1697],{},[465,1698,1699],{},"getLatestRelease",[537,1701,1049],{},[537,1703,1704],{},"Reads the cached latest-check row",[516,1706,1707,1712,1714],{},[537,1708,1709],{},[465,1710,1711],{},"listUpdateHistory",[537,1713,1049],{},[537,1715,1716,1717,1720],{},"Lists ",[465,1718,1719],{},"kind: 'updateRun'"," rows (most recent first; capped at 200)",[448,1722,1723,1724,1727],{},"A local build reporting ",[465,1725,1726],{},"OWLAT_VERSION=dev"," (or a non-semver value) is always treated as \"no update available\", because Owlat cannot tell whether a dev build is ahead of or behind the release tag.",[682,1729,1731],{"id":1730},"applying-an-update","Applying an update",[448,1733,1734,1735,1738,1739,1742],{},"Clicking ",[477,1736,1737],{},"Update now"," posts to the Nitro route ",[465,1740,1741],{},"apps\u002Fweb\u002Fserver\u002Fapi\u002Fsystem\u002Fupdate.post.ts",", which:",[1744,1745,1746,1750,1757,1761,1776,1780,1797,1801,1812,1816],"steps",{},[682,1747,1749],{"id":1748},"verify-the-caller","Verify the caller",[448,1751,1752,1753,1756],{},"The route re-checks platform-admin status via the session cookie, and requires ",[465,1754,1755],{},"INSTANCE_SECRET"," to be configured.",[682,1758,1760],{"id":1759},"download-and-verify-the-pinned-compose-file","Download and verify the pinned compose file",[448,1762,1763,1764,1767,1768,1771,1772,1775],{},"It fetches ",[465,1765,1766],{},"docker-compose-\u003Cversion>.yml"," and its ",[465,1769,1770],{},".sha256"," manifest from the GitHub Release, verifies the SHA-256 hash, and confirms the body references ",[465,1773,1774],{},"ghcr.io\u002Fwolvesdotink\u002Fweb:\u003Cversion>",". A hash mismatch aborts the update.",[682,1777,1779],{"id":1778},"record-the-attempt","Record the attempt",[448,1781,1782,1783,1786,1787,1790,1791,638,1794,1022],{},"It records an ",[465,1784,1785],{},"updateRun"," row via ",[465,1788,1789],{},"recordUpdateStart",", capturing ",[465,1792,1793],{},"versionFrom",[465,1795,1796],{},"versionTo",[682,1798,1800],{"id":1799},"dispatch-to-the-updater-sidecar","Dispatch to the updater sidecar",[448,1802,1803,1804,1807,1808,1811],{},"It POSTs the verified compose template to ",[465,1805,1806],{},"http:\u002F\u002Fupdater:3200\u002Fupdate"," with the ",[465,1809,1810],{},"X-Instance-Secret"," header.",[682,1813,1815],{"id":1814},"record-the-outcome","Record the outcome",[448,1817,1818,1819,1822,1823,638,1826,1829],{},"It records ",[465,1820,1821],{},"recordUpdateFinish"," with ",[465,1824,1825],{},"success",[465,1827,1828],{},"failed"," and the step output. Both record mutations also emit a structured JSON log line for external log sinks.",[682,1831,1833],{"id":1832},"the-updater-sidecar","The updater sidecar",[448,1835,1836,1837,1840,1841,1844,1845,1848,1849,1851],{},"The updater (",[465,1838,1839],{},"apps\u002Fupdater\u002Fsrc\u002Findex.ts",", image ",[465,1842,1843],{},"ghcr.io\u002Fwolvesdotink\u002Fupdater",", listening on port 3200, exposed only on an internal docker network (no host port mapping) so only the web container can reach it via ",[465,1846,1847],{},"http:\u002F\u002Fupdater:3200",") is the only component that touches Docker. It authenticates every request with a timing-safe ",[465,1850,1810],{}," compare and reaches Docker through a least-privilege socket proxy (it can pull\u002Frecreate\u002Flist containers but not exec, build, or touch volumes).",[510,1853,1854,1866],{},[513,1855,1856],{},[516,1857,1858,1861,1864],{},[519,1859,1860],{},"Endpoint",[519,1862,1863],{},"Method",[519,1865,936],{},[532,1867,1868,1896,1909,1925],{},[516,1869,1870,1875,1878],{},[537,1871,1872],{},[465,1873,1874],{},"\u002Fupdate",[537,1876,1877],{},"POST",[537,1879,1880,1881,1884,1885,1888,1889,1892,1893],{},"Validate the compose template against an image allowlist + dangerous-mount\u002Fprivileged-mode rules, write it, ",[465,1882,1883],{},"docker compose pull",", run a one-shot ",[465,1886,1887],{},"convex-deploy"," (function deploy ",[452,1890,1891],{},"before"," restart — a bad schema aborts here and the old containers keep serving), then ",[465,1894,1895],{},"docker compose up -d",[516,1897,1898,1903,1906],{},[537,1899,1900],{},[465,1901,1902],{},"\u002Fhealth",[537,1904,1905],{},"GET",[537,1907,1908],{},"Per-service container state, image tag, and health (auth-required to prevent enumeration)",[516,1910,1911,1916,1918],{},[537,1912,1913],{},[465,1914,1915],{},"\u002Fconfigure-ip",[537,1917,1877],{},[537,1919,1920,1921,1924],{},"Attach\u002Fdetach a floating IPv4 to ",[465,1922,1923],{},"eth0"," and the campaign IP pool, then restart the MTA",[516,1926,1927,1932,1934],{},[537,1928,1929],{},[465,1930,1931],{},"\u002Frotate-env",[537,1933,1877],{},[537,1935,1936,1937,1940],{},"Rewrite rotated secrets in ",[465,1938,1939],{},".env"," and force-recreate containers (requires all secret fields; tightest rate limit)",[448,1942,1943,1944,1946,1947,1950,1951,1953],{},"The System & Updates page reads ",[465,1945,1902],{}," (proxied by ",[465,1948,1949],{},"apps\u002Fweb\u002Fserver\u002Fapi\u002Finternal\u002Fupdater-health.get.ts",") to render the container-health table. The ",[465,1952,1887],{},"-before-restart ordering is the safety property: an incompatible schema fails the deploy step and the running Web\u002FMTA containers are never restarted against a half-deployed backend.",[457,1955,1957],{"title":1956,"type":472},"Operator-provided distribution",[448,1958,1959,1960,1963,1964,1022],{},"The in-app updater applies images and compose templates from the GitHub Release pipeline; it is the supported self-update path. The ",[452,1961,1962],{},"desktop"," app's auto-update key is an empty placeholder, so desktop auto-update is not production-ready and desktop distribution is operator-provided — see ",[858,1965,138],{"href":137},[448,1967,1968,1969,1022],{},"For the operator-facing update playbook (rollback, recovery, automation), see ",[858,1970,306],{"href":305},[494,1972,1974],{"id":1973},"dev-mode-endpoints-crons-and-migrations","Dev-mode endpoints, crons, and migrations",[682,1976,1978],{"id":1977},"dev-mode-endpoints","Dev-mode endpoints",[448,1980,1981,1982,1985,1986,1989,1990,1993,1994,1997,1998,2001,2002,2005],{},"A small set of destructive shortcuts exist for local development. They are ",[477,1983,1984],{},"fail-closed",": the guard in ",[465,1987,1988],{},"apps\u002Fapi\u002Fconvex\u002FdevShortcuts\u002F_guard.ts"," treats the deployment as production unless the operator explicitly sets ",[465,1991,1992],{},"OWLAT_DEV_MODE=true"," in the Convex backend runtime env (",[465,1995,1996],{},"npx convex env set OWLAT_DEV_MODE true","). The CLI-side ",[465,1999,2000],{},"CONVEX_DEPLOYMENT"," env var is ",[452,2003,2004],{},"not"," propagated into the function runtime, so it cannot be used as a security boundary.",[510,2007,2008,2019],{},[513,2009,2010],{},[516,2011,2012,2014,2017],{},[519,2013,1860],{},[519,2015,2016],{},"Guard",[519,2018,936],{},[532,2020,2021,2043,2059],{},[516,2022,2023,2028,2036],{},[537,2024,2025],{},[465,2026,2027],{},"POST \u002Fdev\u002Freset",[537,2029,2030,2033,2034],{},[465,2031,2032],{},"OWLAT_DEV_MODE"," + ",[465,2035,1810],{},[537,2037,2038,2039,2042],{},"Wipe the instance back to a blank slate (tenant tables, BetterAuth tables, and local auth tables) so the signup flow can be re-exercised without ",[465,2040,2041],{},"docker compose down -v","; idempotent",[516,2044,2045,2050,2056],{},[537,2046,2047],{},[465,2048,2049],{},"POST \u002Fseed\u002Fdemo",[537,2051,2052,2033,2054],{},[465,2053,2032],{},[465,2055,1810],{},[537,2057,2058],{},"Seed realistic demo content (idempotent)",[516,2060,2061,2066,2073],{},[537,2062,2063],{},[465,2064,2065],{},"forceVerifyDomain",[537,2067,2068,2033,2070],{},[465,2069,2032],{},[465,2071,2072],{},"organization:manage",[537,2074,2075,2076,2079],{},"Force a domain to ",[465,2077,2078],{},"verified"," with synthesised DNS results, bypassing live DNS lookups",[457,2081,2083],{"title":2082,"type":1508},"Never enable OWLAT_DEV_MODE in production",[448,2084,2085,2088,2089,2091],{},[465,2086,2087],{},"\u002Fdev\u002Freset"," deletes everything tenant-side — not just seed-tagged rows. Leave ",[465,2090,2032],{}," unset on any deployment that holds real data.",[682,2093,2095],{"id":2094},"crons","Crons",[448,2097,2098,2099,2101],{},"Scheduled jobs are registered in ",[465,2100,867],{},". Operationally relevant ones include:",[510,2103,2104,2117],{},[513,2105,2106],{},[516,2107,2108,2111,2114],{},[519,2109,2110],{},"Cron",[519,2112,2113],{},"Interval",[519,2115,2116],{},"Job",[532,2118,2119,2134,2148,2159,2170,2180,2190,2200,2211,2221],{},[516,2120,2121,2124,2127],{},[537,2122,2123],{},"process scheduled campaigns",[537,2125,2126],{},"1 min",[537,2128,2129,2130,2133],{},"Backup dispatch for campaigns whose ",[465,2131,2132],{},"scheduledAt"," has passed",[516,2135,2136,2139,2141],{},[537,2137,2138],{},"reconcile sending campaigns",[537,2140,2126],{},[537,2142,2143,2144,2147],{},"Advance ",[465,2145,2146],{},"sending → sent"," when no queued sends remain",[516,2149,2150,2153,2156],{},[537,2151,2152],{},"process account deletions",[537,2154,2155],{},"24 h",[537,2157,2158],{},"Process accounts past their 30-day grace period",[516,2160,2161,2164,2167],{},[537,2162,2163],{},"cleanup webhook logs",[537,2165,2166],{},"weekly",[537,2168,2169],{},"Remove delivery logs older than 30 days",[516,2171,2172,2174,2177],{},[537,2173,877],{},[537,2175,2176],{},"1 h",[537,2178,2179],{},"Age out reputation buckets older than 60 days (both scopes)",[516,2181,2182,2185,2187],{},[537,2183,2184],{},"cleanup soft-deleted contacts",[537,2186,2155],{},[537,2188,2189],{},"Permanently delete contacts past their 30-day retention",[516,2191,2192,2195,2197],{},[537,2193,2194],{},"knowledge graph maintenance",[537,2196,2155],{},[537,2198,2199],{},"Confidence decay + expiry cleanup",[516,2201,2202,2205,2208],{},[537,2203,2204],{},"channel health checks",[537,2206,2207],{},"5 min",[537,2209,2210],{},"Probe channel connectivity",[516,2212,2213,2216,2218],{},[537,2214,2215],{},"agent metrics rollup",[537,2217,2207],{},[537,2219,2220],{},"Compute queue depth\u002Flatency\u002Ferror rates and evaluate circuit breakers",[516,2222,2223,2226,2229],{},[537,2224,2225],{},"report analytics",[537,2227,2228],{},"15 min",[537,2230,2231],{},"Report instance analytics to the control plane",[448,2233,2234,2235,673,2238,673,2241,673,2244,2247],{},"Counter-reconciliation crons (",[465,2236,2237],{},"reconcile contact counts",[465,2239,2240],{},"reconcile topic member counts",[465,2242,2243],{},"refresh segment counts",[465,2245,2246],{},"reconcile transactional send counts",") keep denormalized counters honest against any drift from partial failures.",[682,2249,2251],{"id":2250},"migrations","Migrations",[448,2253,2254,2255,2258,2259,2262,2263,2266,2267,1022],{},"Schema\u002Fdata migrations are one-shot internal mutations under ",[465,2256,2257],{},"apps\u002Fapi\u002Fconvex\u002Fmigrations\u002F",", each exporting a ",[465,2260,2261],{},"run"," mutation. They follow the pre-prod \"atomic breaking change\" pattern: each migration is ",[477,2264,2265],{},"idempotent"," (re-running is a no-op once every row is rewritten) and, because deployments are single-org and resettable, most are written to run synchronously against ",[465,2268,2269],{},".collect()",[448,2271,2272],{},"You invoke one by name through the Convex CLI, for example:",[1313,2274,2278],{"className":2275,"code":2276,"language":2277,"meta":1318,"style":1318},"language-sh shiki shiki-themes github-light github-dark-dimmed","npx convex run migrations\u002F0033_campaign_audience:run\n","sh",[465,2279,2280],{"__ignoreMap":1318},[1322,2281,2282,2285,2288,2291],{"class":1324,"line":1325},[1322,2283,2284],{"class":1335},"npx",[1322,2286,2287],{"class":1342}," convex",[1322,2289,2290],{"class":1342}," run",[1322,2292,2293],{"class":1342}," migrations\u002F0033_campaign_audience:run\n",[457,2295,2297],{"title":2296,"type":460},"Prefer reset over backfill in pre-prod",[448,2298,2299],{},"Owlat's convention is clean breaking changes plus a data reset rather than two-phase backward-compat migration ceremony. Several migrations note that on a freshly-seeded deployment they see no legacy rows and are a no-op record of intent — if a deployment ever does carry legacy rows that a migration cannot map, the migration throws loudly instead of silently dropping data.",[494,2301,2303],{"id":2302},"related-pages","Related pages",[2305,2306],"link-card",{"description":2307,"title":306,"to":305},"The operator playbook for updating, rollback, and recovery from a failed update.",[2305,2309],{"description":2310,"title":274,"to":273},"How sending reputation is derived and how risk levels drive abuse auto-enforcement.",[2305,2312],{"description":2313,"title":318,"to":317},"The backend conventions these operational modules are built on.",[2305,2315],{"description":2316,"title":70,"to":241},"How runtime features map to docker profiles on a self-hosted deployment.",[2318,2319,2320],"style",{},"html pre.shiki code .sYgZi, html code.shiki .sYgZi{--shiki-default:#24292E;--shiki-dark:#ADBAC7}html pre.shiki code .sOLd2, html code.shiki .sOLd2{--shiki-default:#6F42C1;--shiki-dark:#F69D50}html pre.shiki code .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--shiki-dark:#96D0FF}html pre.shiki code .s7YZ4, html code.shiki .s7YZ4{--shiki-default:#D73A49;--shiki-dark:#F47067}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":1318,"searchDepth":1332,"depth":1332,"links":2322},[2323,2327,2331,2332,2333,2343,2348],{"id":496,"depth":1332,"text":497,"children":2324},[2325,2326],{"id":684,"depth":1371,"text":685},{"id":772,"depth":1371,"text":773},{"id":888,"depth":1332,"text":889,"children":2328},[2329,2330],{"id":1025,"depth":1371,"text":1026},{"id":1222,"depth":1371,"text":1223},{"id":1291,"depth":1332,"text":1292},{"id":1478,"depth":1332,"text":1479},{"id":1621,"depth":1332,"text":1622,"children":2334},[2335,2336,2337,2338,2339,2340,2341,2342],{"id":1634,"depth":1371,"text":1635},{"id":1730,"depth":1371,"text":1731},{"id":1748,"depth":1371,"text":1749},{"id":1759,"depth":1371,"text":1760},{"id":1778,"depth":1371,"text":1779},{"id":1799,"depth":1371,"text":1800},{"id":1814,"depth":1371,"text":1815},{"id":1832,"depth":1371,"text":1833},{"id":1973,"depth":1332,"text":1974,"children":2344},[2345,2346,2347],{"id":1977,"depth":1371,"text":1978},{"id":2094,"depth":1371,"text":2095},{"id":2250,"depth":1371,"text":2251},{"id":2302,"depth":1332,"text":2303},"md",{},true,{"title":282,"description":283},"3.developer\u002F20.platform-operations","5yPCQFZ3GhZVIV30IkfTcFEMUQcTNPq3neiGCf3yeOU",[2356,2358],{"title":278,"path":277,"stem":2357,"children":-1},"3.developer\u002F2.architecture",{"title":286,"path":285,"stem":2359,"children":-1},"3.developer\u002F3.scopes",1782846428977]