27 KiB
QBit CRM: design (0.2.0)
This is the design for qbit-crm 0.2.0, a full-featured CRM for QQQ 4.1 applications. It also
works with qbit-marketing and with a host CMS such as the kof22 website (cms_ tables,
the /v2/contact form and newsletter capture). The document records three things:
- the gap audit of 0.1.0
- the target design
- the integration contracts and the delivery phases
0.2.0 is a breaking, fresh-install release: tables are renamed, and several bespoke features are replaced by platform features. 0.1.0 never went live anywhere (Voyage was a proof of concept), so there is no upgrade path or compatibility layer.
Goals
- The whole sales and relationship workflow in one app:
- contacts and companies
- deals through pipelines
- logged activities
- lists and consent
- web leads with attribution
- sequences
- scoring and assignment
- goals
- reporting
- Standard QQQ throughout. Every record is in a visible
crm_table with a full record screen. Work runs as table and record actions on the standard streamed ETL. The app has one real home. - Reuse the platform instead of rebuilding it:
- email through qbit-email or core messaging
- tags, comments, attachments, follow and tasks through collaboration
- notifications through alerts
- long work as durable jobs
- imports through bulk load with saved profiles
- reporting through data analysis
- PII through secured data
- Honest consent. Nobody gets marketing email without a recorded, unexpired Email consent. Every suppression is respected and every change is auditable.
- Integrations through published contracts only. The
com.kingsrook.qbits.crm.apipackage is the only thing other qbits and hosts compile against (guardrail G1).
Gap audit of 0.1.0 (develop @ 547c923)
What 0.1.0 claims versus what works
| Area | README claim | Actual state | 0.2.0 action |
|---|---|---|---|
| App and nav | CRM section links every table, 14 dashboard widgets | produceAppSection() returns an empty section; no app, no dashboard; the widgets are on nothing |
One crm app, real home, main tables only (see App) |
| Email sending | Send, track opens/clicks/bounces | Nothing is sent. sendEmail writes an activity and a SENT message row; fromAddress is a user id; templates have no merge. Sequence email steps only log an activity |
Send through qbit-email EmailApi or core SendMessageAction; delivery state from qbit-email |
| Scheduled maintenance | Sequences every 5 min, scoring hourly, overdue tasks daily | No schedules exist; nothing runs unless the host adds jobs | Shipped schedules that run as durable jobs |
| Auto-assign | POST_INSERT hook | A standalone process only; never triggered | Insert customizer on contact and deal plus the record action |
| Lead score decay | DAYS_AGO_GT/LT operators |
Enum values exist, evaluation returns false | Implemented |
| Reminders | Reminder notifications | fireReminders clears the flag ("for now") |
Collaboration tasks and alerts notifications |
| Import | CSV import with dedup | Needs pre-parsed records; loads every contact into memory | Core bulk load with saved profiles; dedup by unique key and the Find Duplicates action |
| Multi-tenant | Locks on all tables, propagated to children | Locks on contact, company and deal only; no child propagation; widgets and processes query unscoped | Lock on every table (direct or by join chain), configurable key type |
| Permissions | Not stated | No permission rules at all | crm.* permission rules on every table and action |
| Webhooks, quick search, workflows | Config namespaces | Dead config, never read | Removed. Search via table search fields; events via ESB |
| GDPR erase | Anonymizes a contact | Leaves PII in email messages, form submissions, list members, activities, consent IP, external mappings | Erase covers every table that holds the contact's PII |
| Audit | Field-level audit | Bespoke crmAuditLog on 4 tables, stores PII copies |
Core table audit; erase redacts audit details |
| Tags, attachments | Bulk tag management, attachments | Junction tables; attachments are metadata only, with no storage | Collaboration TAGS and ATTACHMENTS (object storage) |
bulkConvertForms |
Listed | Does not exist | Convert to Contact is a multi-record action |
| Tests | 73 classes / 271 tests | 81 classes / 296 tests, memory backend only | Add RDBMS (H2) tests for DDL, unique keys and snake_case columns |
These parts are real and are kept, adapted to 0.2.0:
- pipelines and stages with required-field gating and stage history
- deal products with total recalculation
- multi-currency conversion
- merge contacts and merge companies
- sequence timing (business days, send windows, contact time zone)
- the assignment strategies (specific user, round robin, least active)
- scoring operators 1-7
- form-submission auto-linking
- list subscribe and unsubscribe
- the 24 enum possible-value sources
- the three contingent dropdowns from #6
Against the current platform
| Standard | 0.1.0 | 0.2.0 |
|---|---|---|
| Build | parent 1.4.0, QQQ 0.27.9, Java 17, orb 0.3.8 | parent 2.1.2 (guardrails profile), QQQ 4.1.0-RC.6 (RC.7 when cut), Java 21, ./mvnw, orb 0.6.9 |
| Table names | crmContact (camelCase) |
crm_contact, backend name = table name, snake_case columns |
| Permissions | none | crm, crm.manage, crm.admin bases (see Security) |
| UI | n/a | /qqq/v1 only; the one public machine route is under /qqq/hooks/crm/ (G2, G3) |
| Messaging | own (fake) sending | qbit-email public API (allow-listed by G1) or core SendMessageAction |
| Collaboration | bespoke tags, attachments, reminders | core RecordFeaturesMetaData (TAGS, COMMENTS, ATTACHMENTS, FOLLOW, TASKS, ASSIGNMENT, TIMELINE) |
| Alerts | none | QAlerts producer SPI, crm.* alert types |
| Heavy work | synchronous steps over whole tables | durable jobs (DurableProcessMetaData), streamed ETL, keyset pages |
| Imports | importContacts |
core bulk load + saved bulk-load profiles |
| PII | none | DataClassification.PII on every personal field; secured-data tiers apply |
| Reporting | 14 bespoke widgets | home widgets for the daily view; trend reports via data analysis (crm_*.analyze) |
| Health | none | crm health checks (stand-in until core #1199) |
| Help | none | help/qbit-crm.yaml bundle (stand-in loader until core #1198) |
| Tenancy | clientId lock on 3 tables | configurable tenant key type on every table; the host maps Authentik claims to keys |
Data model (33 tables: 6 main, 15 settings, 12 sub-tables)
All names are crm_ snake_case. Fields stay camelCase in Java and snake_case in the database
(inferBackendNames). Each table has a reference DDL in docs/ddl/postgres.sql and a
Liquibase changelog in src/main/resources/db/liquibase/qbit-crm/.
Main tables (in the nav; ESB events, quick search, API):
| Table | Holds |
|---|---|
crm_contact |
Person: names, emails, phones, address, title, company, owner, lifecycle stage, lead source, lead score, time zone, doNotEmail / doNotCall, unsubscribe date, first- and last-touch attribution (UTM source, medium, campaign, landing page, referrer, touch dates), last activity date. |
crm_company |
Organization: name, domain, industry, type, size, revenue, parent company, owner, address, last activity date. |
crm_deal |
Opportunity: pipeline, stage, amount, currency, base-currency amount, weighted amount, probability, expected close, owner, company, primary contact, priority, win/loss reason, stage entered date, attribution copied from the contact at creation. |
crm_activity |
Logged interaction (call, meeting, email, note, sequence step) with type, outcome, direction, times, related contact, company and deal. Future to-dos are collaboration tasks, not activities. |
crm_form_submission |
One web form submission: form key and type, name, email, phone, company, message, consent given and its text, UTM fields, landing page, referrer, visitor key (hashed), contact link, conversion status. Raw IP is never stored; only a salted client hash. |
crm_email_list |
Mailing list: key, name, description, double opt-in flag, consent wording, active. |
Settings tables (Settings section of the nav):
crm_pipeline, crm_product, crm_email_template, crm_email_sequence,
crm_lead_score_rule, crm_assignment_rule, crm_sales_goal, crm_currency,
crm_lifecycle_stage, crm_lead_source, crm_industry, crm_activity_type,
crm_activity_outcome, crm_contact_role, crm_win_loss_reason.
Sub-tables (child lists on their parents, not in the nav):
| Table | Parent | Notes |
|---|---|---|
crm_pipeline_stage |
pipeline | Probability, rot days, required fields, won/lost flags. |
crm_consent_record |
contact | Append-only: type, status, legal basis, source, wording shown, evidence (form submission), recorded and expiry dates. |
crm_email_list_member |
list, contact | Status (pending confirmation, subscribed, unsubscribed, bounced), confirmation token hash, dates, source submission. |
crm_email_message |
contact, deal | CRM view of a sent email: template, subject, status, the qbit-email message id and idempotency key, sent/opened/clicked/bounced dates. Bodies stay in qbit-email. |
crm_sequence_step |
sequence | |
crm_sequence_enrollment |
sequence, contact | Status, current step, next step date, failures. |
crm_deal_contact |
deal | Role on the deal. |
crm_deal_product |
deal | Line items. |
crm_deal_stage_history |
deal | Read-only. |
crm_activity_participant |
activity | |
crm_assignment_rule_member |
assignment rule | |
crm_external_mapping |
any | External system ids for sync connectors. |
Removed:
| Removed | Replaced by |
|---|---|
crmTag, crmContactTag, crmCompanyTag, crmDealTag |
Collaboration TAGS |
crmAttachment |
Collaboration ATTACHMENTS (object storage) |
crmAuditLog |
Core table audit (withAuditRules, FIELD level) |
App, home and nav
There is one app, crm ("CRM"). Its home is the only CRM dashboard. All widgets are standard,
full-width and real-data:
- Today (statistics row):
- open pipeline (base currency)
- won this month
- new contacts (30 days)
- web leads (7 days)
- my overdue tasks
- Pipeline by stage: stage, deals, amount and weighted amount. The first pipeline is the default; a pipeline picker is added when there is more than one.
- Forecast: expected close by month for the next 6 months, weighted.
- Needs attention: rotting deals (days in stage past the stage's
rotDays). - Recent web leads: the last 10 form submissions, with their contact and conversion status.
- Goals: attainment for the current period. It is shown only when goals exist.
- Leaderboard: won amount by owner this quarter.
Every table widget has at most 3 columns, and each column links to its records. On a contact:
- a Timeline child widget (activities, messages, submissions, consent, in date order)
- the child lists for deals, lists, consent and enrollments
On a deal:
- stage history
- line items
- contacts
The nav has two parts:
- Main tables: contacts, companies, deals, activities, web leads (
crm_form_submission) and lists. - Settings: the settings tables.
Processes are never in the nav. The trend reports from 0.1.0 (win rate, stage duration, sales
cycle, conversion, average deal size) become data-analysis analyses. Deals carry the computed
fields daysInStage and cycleDays, and the analyses are seeded as saved analyses when
qbit-data-analysis is installed. Without it, they are table views with group-by. They are not
custom widgets.
Actions
Every action is a table or record action with a plain label. Each runs on
StreamedETLWithFrontendProcess with the standard Review and Result summary lines, and has no
custom dry-run forms. Large selections run as durable jobs.
| Table | Actions |
|---|---|
| Contact | Assign Owner, Auto-Assign, Add to List, Remove from List, Enroll in Sequence, Unenroll, Send Email, Log Activity, Recalculate Score, Merge (2+ selected), Find Duplicates, Erase Personal Data (GDPR, crm.admin), Record Consent |
| Company | Assign Owner, Merge, Find Duplicates, Log Activity |
| Deal | Move Stage, Mark Won, Mark Lost, Reopen, Clone, Assign Owner, Add Contact, Log Activity |
| Web lead | Convert to Contact (multi-record), Mark Spam |
| List | Send Confirmation Again (pending members) |
| Sequence | Activate, Pause |
| Email message | Refresh Delivery Status |
Scheduled work, shipped as QScheduleMetaData (the host can turn each one off in config).
Each runs as a durable job:
| Job | Schedule | Work |
|---|---|---|
| Run Sequence Steps | every 5 min | Due enrollments, in pages of 200 |
| Recalculate Lead Scores | hourly | Contacts whose inputs changed since the last run, plus a full pass nightly |
| Sync Email Delivery | every 15 min | qbit-email status for messages sent by the CRM in the last 30 days. Hard bounces and complaints set doNotEmail and add a consent record |
| Expire Consents | daily | Expired consents and stale pending confirmations (14 days) |
Platform reuse
Email sending goes in this order:
-
qbit-email, when installed.
CrmEmailTransportcallsEmailApi.sendOncewith:- category MARKETING for list and sequence mail, or TRANSACTIONAL for one-to-one mail
- idempotency key
crm:<purpose>:<id> - a linked record (the contact)
The transport is the only class that references
com.kingsrook.qbits.email.api. That package is allow-listed by G1, and the dependency is optional. qbit-email owns branding, tracking, the transport-level suppression and RFC 8058 one-click unsubscribe. -
Core
SendMessageAction, otherwise. It uses the host's provider name from config. -
Neither. Send actions are hidden, and the readiness check reports Not configured.
Templates are crm_email_template rows, rendered by qbit-email's templating when available.
They have merge fields for:
- the contact (
firstName,company.name, ...) - the owner
- the unsubscribe link
Before every marketing send, the CRM checks eligibility: consent, doNotEmail, list status and
unsubscribe date. qbit-email then applies its own suppression.
Collaboration
The CRM declares RecordFeaturesMetaData on contact, company, deal and activity. Features:
- TAGS, COMMENTS, ATTACHMENTS, FOLLOW, TIMELINE, RECENTS and FAVORITES on all four
- TASKS and ASSIGNMENT on contact, company and deal
Ownership is RecordOwnership.withOwnerFieldName("ownerUserId"). The record screens and
"My Work" come from qbit-collaboration when it is installed. Without it, the core declarations
are inert.
Alerts and notifications
| Kind | Name | Fires when |
|---|---|---|
| Alert type | crm.sequence.failing |
An enrollment fails 3 times |
| Alert type | crm.email.unavailable |
The transport is not ready while sequences are active |
| Notification | crm.lead.assigned |
A new web lead is assigned |
| Notification | crm.deal.rotting |
A deal passes its stage's rot days |
| Notification | crm.deal.won |
A deal is won |
| Notification | crm.task.due |
A collaboration task comes due |
Notifications go through QAlerts.notify. qbit-alerts then delivers each one to the user's
channels (in-app, email, Slack) per their notification preferences. Without qbit-alerts, the
signals are logged.
Durable jobs and bulk load
- The four scheduled processes, Recalculate Score, Merge and Erase are
DurableProcessMetaData(eligible). Recalculate and the sequence runner arealwaysDurable. - Imports use core bulk load on contact, company, deal and activity. The host registers
SavedBulkLoadProfileMetaDataProvideronce. The CRM ships starter profiles (contacts from a generic CSV, companies, deals). Duplicate emails are refused by thecrm_contactunique key (email, tenant) and reported in the bulk-load result.
Health
CrmHealthChecks reports three checks:
- email transport readiness
- sequence lag (the oldest due enrollment more than 30 min late)
- the scoring job's last success time
It is a STAND-IN(core=QRun-IO/qqq#1199, removeBy=4.1.0-RC.9) adapter onto the
qqq-middleware-health HealthIndicator until the central HealthCheck SPI ships.
Help
src/main/resources/help/qbit-crm.yaml is in the qqq#1198 bundle format. It has entries for:
- the app
- every table, field, action and possible-value source
HelpBundleAssertions checks that it is complete. Until HelpBundles.register is in a core RC,
CrmHelpBundleStandIn (STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.9)) loads the same
file into QHelpContent.
Contingent dropdowns
These dropdowns are contingent on another field:
- pipeline stage on pipeline (from #6)
- activity outcome on activity type (from #6)
- list-member submission on contact (from #6)
- deal primary contact on company (
SHOW_ALL_UNTIL_SET) - deal contact on deal company
- sequence step template on nothing (all templates)
- sales goal pipeline stage on pipeline
Server-side validation (ValidateContingentPossibleValuesHelper) is on for stage and outcome.
API, ESB and search
- API: the host's API name and version come from config. When set, main and settings tables
get
ApiTableMetaDataContainerentries, and sub-tables are reachable as children. The published API matches the admin, and a parity test compares the API field list with the table fields. - ESB: main tables carry
EsbTableMetaDatapublications to the configured destination, with INSERT, UPDATE and DELETE, and classified values excluded. - Quick search: main tables declare
searchFields, so qbit-quick-search indexes them without a compile dependency.
Security, tenancy and PII
Permissions use the full qbit name:
| Permission | Allows |
|---|---|
crm.hasAccess, crm.read, crm.write |
The app; main and sub-tables; everyday actions |
crm.manage.* |
Settings tables (including currencies); merge, bulk assign, list and sequence management |
crm.admin.* |
Erase personal data and consent overrides |
Each table's standard QQQ suffixes map onto these, through QPermissionRules with
DenyBehavior.HIDDEN.
Tenancy (multi-company). CrmQBitConfig.withTenantSecurityKeyType("crmTenant") (off by
default) does three things:
- adds a nullable
tenantKeycolumn and aRecordSecurityLockto every main and settings table - locks sub-tables by join chain
- tells collaboration to mirror the key type
The host maps Authentik groups or claims to keys in its AuthentikPolicyClaimMapper override.
An all-access key is configurable. Widgets, processes and the API query with the session, so
every read is scoped. kof22 is single-tenant and leaves this off. The 0.1.0 securityFields and
recordSecurityLocks config is replaced.
PII classification (DataClassification.PII):
- contact: names, emails, phones, address, LinkedIn, time zone
- company: phone and address
- form submission: name, email, phone, message, client hash
- list member: email and name
- email message: addresses and subject
- consent: evidence text
- activity: description
With qbit-secured-data, these fields follow its tiers (masked, revealable or hidden), and classified values are left out of ESB and AI tool output.
Erase personal data (GDPR Art. 17) removes or anonymizes everything that names the contact:
- the contact's PII fields
- form submissions, list memberships and email-message addresses
- activity descriptions
- external mappings
- audit details on the contact's records
It keeps one consent record, "erased on ", with no PII. It runs as a durable job and leaves an alert-free audit line.
Integration contracts (com.kingsrook.qbits.crm.api)
The package is published inside the qbit-crm jar. Hosts and qbits call it in-process, and it is the only supported surface.
CrmApi
isInstalled(QInstance)readiness()
CrmIntakeApi (for host websites such as kof22)
submitForm(CrmFormRequest) returns CrmFormResult(submissionId, contactId, contactCreated).
- Request fields:
- form key and type (CONTACT, NEWSLETTER, DEMO, OTHER)
- name, email, phone, company, message
marketingConsent(boolean) and the exact consent wording shown- UTM fields, landing page, referrer
- an optional hashed visitor key and client hash
- The CRM:
- stores the submission
- links the contact by email, or creates it (lifecycle "Lead", source from UTM or "Website")
- sets first-touch attribution when it is empty and always sets last-touch attribution
- logs a "Form submission" activity
- auto-assigns the contact
- raises
crm.lead.assigned
- Consent: a contact form with no consent box is recorded as legitimate interest to reply only. It never grants marketing email. Marketing consent is recorded only from an explicit opt-in, with the wording as evidence.
subscribe(CrmSubscribeRequest) takes a list key, email, name, consent wording, source, UTM and
an optional visitor key. It returns PENDING_CONFIRMATION, SUBSCRIBED or ALREADY_SUBSCRIBED.
- With double opt-in (the default for lists), it creates a pending member and sends a confirmation email.
- The member's consent is granted only by
confirm(token).
unsubscribe(token) and unsubscribeByContact(contactId, listKey-or-null, source) both record
the unsubscribe and a withdrawn consent.
CrmMarketingApi (for qbit-marketing; agreed with the marketing agent 2026-10-14)
| Method | Behavior |
|---|---|
resolveRecipients(scope) |
Scopes crm-list:<id> and crm-contact:<id>. Returns (contactId, address only when eligible, eligibility). |
checkEmailEligibility(contactId) |
The latest Email consent decides (granted and not expired; none means no consent). Suppressed by doNotEmail, an unsubscribe date, or a non-subscribed membership. Returns reason codes, no PII. |
listFormSubmissions(utmCampaign, since) |
Ids and times only. |
recordActivity(contactId, ActivityKind.NOTE, subject, description) |
The CRM picks the activity type by code, so marketing no longer looks up "Note" by name. |
recordUnsubscribe(contactId, listId-or-null, source) |
Records the unsubscribe. |
buildUnsubscribeUrl(contactId, listId-or-null) |
A signed, expiring token link to /qqq/hooks/crm/unsubscribe/{token} that marketing puts in its footer. The CRM owns list-level unsubscribe; qbit-email's one-click covers its category. |
recipientScopeOptions() |
Lists and their labels, for marketing's recipient-scope dropdown. |
Public route. /qqq/hooks/crm/unsubscribe/{token} (GET shows a confirm page, POST does it)
and /qqq/hooks/crm/confirm/{token} (double opt-in). These are the only HTTP routes outside
/qqq/v1, and they live in a route provider. Tokens are HMAC-signed with a host secret from
config, expire after 90 days, and are stored only as hashes.
Attribution from analytics. Hosts pass the UTM fields they already capture: for kof22, the
cms_analytics session's first and last UTM for a consenting visitor, or else the page URL
parameters. Without visitor-identity consent, the CRM stores no visitor key.
kof22 adoption (Website-Backend)
The adoption changes:
- Dependency and wiring: add
qbit-crmand register it throughWebsiteCrmMetaData.configurefromWebsiteMetaDataProvider. It uses the CMS backend, with tenancy off. - Permissions: grant them in
AuthentikAuthenticationCustomizer(admins getcrm.*). - Schema: a dated Liquibase file,
changesets/features/2026-10-14-qbit-crm.sql. kof22's numbered files stop at 0041, and newer changes use dated files. /v2/contact: keeps its honeypot, timing, validation and rate limits, then callsCrmIntakeApi.submitForm(form keykof22-contact).- The notification to the site owner keeps today's delivery path (qbit-email's outbox when it is installed, otherwise the existing mailer). It links to the CRM web lead.
cms_contact_messagekeeps receiving rows until one release after adoption. Existing rows are copied intocrm_form_submission, and a later cleanup PR drops the table.
- Newsletter: new
POST /v2/newsletter/subscribe(same protections) callsCrmIntakeApi.subscribefor listkof22-newsletter, with double opt-in. The confirm and unsubscribe links use the CRM routes. The frontend form is a separate Website-Frontend PR. - Marketing: qbit-marketing on kof22 uses
QbitCrmGateway, which delegates toCrmMarketingApi.
No upgrade path from 0.1.0
0.1.0 never went live. 0.2.0 is a fresh install: there are no rename scripts and no compatibility shims. These 0.1.0 config options are removed:
tableNamePrefixquickSearchQBitNamespacewebhooksQBitNamespaceworkflowsQBitNamespace
securityFields and recordSecurityLocks are replaced by withTenantSecurityKeyType in P4.
Delivery phases
| Phase | Delivers | Demonstrable result |
|---|---|---|
| P1 Platform | Parent 2.1.2, QQQ 4.1.0-RC.6, Java 21, orb 0.6.9, guardrails; crm_ rename with snake_case columns; permission rules; reference DDL and Liquibase; H2 tests |
./mvnw verify green on the new stack |
| P2 Standards | App, home and nav; record actions on streamed ETL; PII classification; contingent dropdowns; API, ESB and search metadata; help bundle; core audit | The CRM app runs in a sample server with the home and record screens |
| P3 Intake and contracts | crm.api (CrmIntakeApi, CrmMarketingApi), consent, double opt-in, the unsubscribe and confirm routes, attribution |
A form post creates a contact and lead, and marketing resolves eligibility |
| P4 Platform reuse | Email transport, collaboration features, alerts, durable jobs and schedules, bulk-load profiles, tenancy, health, GDPR erase coverage, scoring decay, auto-assign hook | Sequences send through qbit-email; imports use saved profiles |
| P5 kof22 | Website-Backend draft PR (install, Liquibase, /v2/contact, newsletter) |
Contact form leads appear in the CRM on a local stack |
| P6 Release | Screenshots (home and records, light and dark, 390 and 1440 px), CHANGELOG, "ready: qbit-crm release" | 0.2.0 RC |
Owner decisions (2026-10-14): all recommendations accepted (standing approval)
The owner's standing approval (2026-10-12, and repeated for this program on 2026-10-14) accepts every recommendation above. Specifically:
| # | Decision |
|---|---|
| 1 | 0.2.0 is a breaking, fresh-install release with crm_ names and no compatibility work for 0.1.0. Voyage was a proof of concept (owner, 2026-10-14). |
| 2 | Tags, attachments, reminders and audit move to collaboration, alerts and core audit. The bespoke tables are removed. |
| 3 | The CRM never sends email itself: it uses qbit-email first, then core messaging. |
| 4 | Lists use double opt-in by default. Contact forms never grant marketing consent without an explicit opt-in. |
| 5 | The CRM owns list-level unsubscribe and the confirm and unsubscribe routes. qbit-email owns transport suppression. |
| 6 | Trend reports move to data analysis. The home keeps the daily-work widgets. |
| 7 | Tenancy is a configurable key type, off by default. kof22 runs single-tenant. |
| 8 | kof22 keeps cms_contact_message for one release after adoption, then drops it. |
| 9 | Raw IP addresses are never stored. Only salted client hashes and hashed visitor keys are kept. |
| 10 | Stand-ins for central health (#1199) and help bundles (#1198) are allowed, with removeBy=4.1.0-RC.9. |