Changelog
What's new.
Every improvement to the platform, as it ships.
· MediaService
AUTO_ASSIGNMENT, and raise an assigned conversation on the agent's screen
A chat request and a submitted ticket are the same unit of work; the topic's strategy is the only thing that decides how either is handed over. The PRE_TICKET special case is gone. AUTO_ASSIGNMENT picks the least-busy agent who is genuinely online — a live WebSocket via PresenceService, never availability_status, which only moves on login, logout and a manual toggle and so reads AVAILABLE indefinitely for anyone who never signs out. With nobody connected it decoders rather than pinning work to an absent agent: an assigned ticket leaves the claimable queue, so a bad pick strands it where no popup can reach it. For the same reason AUTO_ASSIGNMENT joins the claimable strategies — the tickets it decoders are exactly the ones that arrived while nobody was online, and they still have to reach somebody. ADMIN_ASSIGNS remains the only strategy that is never claimable. Workload counts every live status including PRE_TICKET (a person waiting in a chat is the heaviest thing an agent holds) and no terminal one. Two endpoints back the handover: GET /tickets/assigned-unopened, which the client reads on sign-in, and POST **************** which retires one. The read exists because the ASSIGNED push is core NATS with no replay — without it, work assigned while an agent was signed out simply waits in a list to be noticed. The write is scoped to the assignee and stamps only once, so two tabs racing is a no-op rather than a conflict.
· kamo-shared-library
AUTO_ASSIGNMENT strategy and a durable record of an unseen handover
AUTO_ASSIGNMENT(3) is appended to SupportAssignmentStrategyType — assign immediately to an agent who is genuinely online, preferring whoever holds the least work, rather than ROUND_ROBIN's strict presence-blind rotation. Appended, never reordered: the column is @Enumerated(ORDINAL). The column also carries a numeric range CHECK Hibernate emitted on CREATE TABLE and never revises. Because that clause contains AND it is not an enum whitelist, so SchemaCleanupRunner's generic dropStaleEnumWhitelistChecks() sweep skips it by design; widening it is a matching change in that runner, without which every insert using the new value fails. SupportTicket gains ASSIGNMENT_OPENED_AT. Assignment is announced over core NATS with no replay, so an agent who was signed out when work landed never hears about it. Null from assignment until a client actually puts the conversation on screen, it makes "assigned to me and never seen" a server-side fact that survives a logout, a crash, a redeploy or a different machine. Two repository queries to go with it: countOpenForAgent (the workload tie-break) and findUnopenedAssignmentsForAgent (the sign-in catch-up).
· kamo-internal
A pinned app's chip launches the app, not its settings
The leading chip of a pinned band was linking to the app's settings landing — the same place its first entry already goes. It now borrows that app's nav option wholesale, so it carries the app's own icon and behaves exactly as the nav does: CRM opens /leads under the Leads bolt, AI Support opens the assistant window, Support and Mail and Docs and Phone drop down their sub-option menus, Chat opens the interaction center, Meetings opens the new-meeting invite. Chat and Meetings are fixed nav chrome with no registry option, so the band opens them itself — the interaction center through a window event NavTop listens for, since its open state lives there. A member who can configure an app but cannot use it holds no nav option for it, and that band keeps the old settings-landing chip rather than being handed a door that would refuse them. Also fixes the band eyebrow, which was inheriting the operator-label ink meant for light surfaces and disappearing into the org band.
· EmailService
Sync the organisation calendar to phones, without opening a hole
The shared calendar has no owner, so the account-scoped DAV listing could never return it: company events were plainly there on the web and invisible on every phone. The home collection now unions the account's own calendars with its organisation's shared one. The reason this needed care rather than one extra query: CalDAV authenticates an account, not a membership, so simply exposing the calendar would have made it writable by anyone whose phone could reach it — a way round the publishing right the web path enforces. PUT and DELETE now resolve the membership behind the DAV credentials and apply the same rule: CREATE_ORG_CALENDAR_EVENTS to write to the shared calendar, ownership to write to a personal one, 403 otherwise. Read access stays deliberately wider than write. The shared calendar is meant to be visible to everyone in the organisation; that is the point of it. Only publishing is restricted. Tests cover the bypass specifically: no right, no membership in that organisation, and another organisation's shared calendar are each refused, while a holder of the right is allowed.
· InitializerService
Add and backfill accounts.date_created
Hibernate cannot add this one itself: ddl-auto emits it NOT NULL, which CockroachDB refuses on a table that already has rows. So it is added nullable here, backfilled, then constrained. The backfill decodes each account's unique_rowid() primary key, which packs its insert time into the high bits — that recovers each account's real creation time instead of flattening all 23 existing rows onto the migration timestamp, which would have made the list's ascending date order meaningless for exactly the accounts that have been around longest. That layout is a CockroachDB implementation detail, so the decoder is first checked against members, which have both a unique_rowid() key and a recorded creation date; it is only applied if it reproduces theirs. On the production run it matched 62 of 62 and recovered all 23 accounts, none falling to the fallback. Order 0, ahead of every other runner: the entity maps the column as soon as this build starts, so any runner that loads an Account emits it in its SELECT. Ordered late, this one never ran at all — LeadContactPointSeedRunner died on "column a1_0.date_created does not exist" and took the run down with it.
· kamo-shared-library
Record when an account was created
Account carried no creation timestamp at all — not a null column, no column. The account list has a Date Created column and orders by it, so that column has been blank for every account since the table was created. Sourced from the database rather than the JVM, per the house rule for timestamps. The matching column and the backfill for existing rows are in KamoInitializer's AccountDateCreatedMigration, which has already been run.
· kamo-internal
Pin an Enabled App to the home launchpad
Each Enabled Apps topic now carries a pin toggle, in the Settings sub-option menu and on its section header on the settings landing page. A pinned app gets its own horizontal shortcut band on the home launchpad — the member-chosen counterpart to the administrator bar, carrying every screen that app opens. A pin is a display preference, never an entitlement: the pinned list is intersected with the member's live gates on every render, so a revoked right or an app the org switches off removes the band immediately, and the stored preference is pruned to match rather than lying dormant. The settings tree behind both the menu and the bands now comes from one resolver (useSettingsTopics), so they cannot drift.
· kamo-internal
Give Mobile Copilot setup its own full-width page
The phone-provisioning surface was the third tab of the Interaction Center slideout, which capped it at a 500px rail — too narrow to show the install code and the sign-in code side by side, so the two steps were collapsed behind a toggle and the whole flow read as a column of stacked fragments. band whose sign-in cell goes live and counts the code down, then a deck of panels that widens with the viewport — two on a tablet, three on a laptop, four across past 1800px. Both codes share one viewfinder frame with a scan sweep, which is what makes the two steps read as one instrument. The OTK flow moved into useMobileSignIn so the hero and the sign-in panel read the same state. The nav rail and home launchpad entries now route to the page instead of dispatching open-interaction-center, which leaves that event with no dispatcher, so it and the panel's initialTab prop are gone.
· kamo-internal
Rebuild the calendar around the current moment
The page was a functional grid with the chrome doing the talking: a 3px rainbow rule under the toolbar, a second one over the content, gradient buttons, and four loose icon buttons for the view switch. Loud where it did not matter and quiet where it did — nothing on screen answered the question a calendar is actually opened to answer. The organising idea is now: what is next, and how much of the day is left. - NextUpBar sits above the grid with the time, the next event and a live countdown, over a hairline showing how far through the working day it is. - NowSpine draws the current moment across the week grid with its time in the gutter, and PastVeil desaturates the hours already gone so what is left of today reads at a glance. The colour is reserved for these: they are the only live thing on the page, so they are the only thing allowed the tenant's colour at full strength. - ViewSwitcher replaces the four icon buttons. Four icons read as four unrelated actions; a segmented control reads as one setting with four positions, which is what it is. The selected pill slides between them via a shared layoutId rather than crossfading. Time is set in Geist Mono with tabular figures throughout — hour rail, clock, countdown, date numerals — so columns of time are a straight edge and digits do not jitter as they tick. CalendarThemeScope mirrors the contacts console: MUI's alpha() cannot parse var(), so painted properties are overridden and color-mix supplies the shades. Every token derives from --color-primary, so this reads as designed under any tenant's brand rather than assuming one. Month event chips become a tinted ground with a solid leading edge instead of white text on saturated fill, which was illegible at 11px. Week rows are a fixed height because the spine converts time to pixels — a row that grew would leave it pointing at the wrong time; a crowded hour scrolls instead. Reduced motion is respected: the pulse and the slide stop, the clock does not. Its position is information, and a stopped clock would be wrong rather than calm. Also fixes two real bugs found while working here: the calendar picker ran UUID ids through Number(), so every value was NaN, and it offered calendars the member cannot write to. It now lists only writable ones, which is how the organisation calendar stays read-only without the publishing right.
· InitializerService
Move calendars onto memberships and give each org a shared one
Calendars and events were owned by an account and carried no organisation. This attaches each to its owner's earliest membership — the organisation the account has belonged to longest, which in practice is where the calendar was made — so a calendar lands in exactly one organisation rather than being duplicated across all of them. Accounts where that choice was material are counted in the log, because for them the calendar stops being visible from their other organisations. Events follow the calendar they live on rather than their own user, so an event can never end up in a different organisation from the calendar displaying it. The DROP DEFAULT on user_id is the load-bearing statement. That column predates this change and had carried DEFAULT unique_rowid() from User.id all along, harmlessly, because it was NOT NULL and always written explicitly. Making it nullable so the shared calendar could exist without an owner turned it lethal: an INSERT that omits user_id fires the default, invents a user id belonging to nobody, and fails the foreign key to users. That is exactly how the first run provisioned zero organisation calendars. The entities now pin the column type so ddl-auto cannot reinstate it. Idempotent throughout: the backfills only touch rows with no member yet, and the shared calendar is inserted only where an organisation has none.
· kamo-shared-library
Scope calendars to the member, and add the org-wide publishing right
UserCalendar and CalendarEvent hung off USER_ID alone and carried no organisation. A user is global and a member is that user inside one organisation, so an account belonging to several organisations had one pile of calendars visible from all of them. Both entities gain MEMBER_ID and ORGANIZATION_ID, and a calendar gains IS_ORG_CALENDAR — the one shared calendar an organisation has, readable by every member and writable only with the new right. USER_ID stays, populated and nullable: CalDAV and the external-sync jobs authenticate an account rather than a membership, so they legitimately read by user. It simply stops being the authorisation scope. Every new join column pins columnDefinition = "INT8", including the pre-existing user ones. Left unpinned, Hibernate copies the referenced key's INT8 DEFAULT unique_rowid() onto the FK; an insert that omits the column then invents an id belonging to nothing and fails the foreign key. That is not hypothetical — it is what stopped the first migration run from provisioning a single organisation calendar. New right CREATE_ORG_CALENDAR_EVENTS (196). Reading the calendar stays VIEW_CALENDAR; this governs only who may put something on the calendar the whole organisation sees. findByOrganizationMemberId is renamed findByUserId. It never scoped by member — the query is and was c.user.id = :userId — and the name is what invited this class of bug. findVisibleToMember and its event equivalent are the member-scoped reads.
· InitializerService
Hand shared address-book contacts back to their owners
EmailService now scopes every contact read to contact_books.owner_id, which leaves the contacts already sitting in an ownerless organisation-default book unreachable. CONTACT.OWNER_ID is NOT NULL and has been populated on every row since the create path started setting it, so the member who added each contact is known exactly — nothing is inferred. This provisions a personal book per (organisation, owner) and moves each contact into its own owner's. Two things are deliberately stranded and counted in the log rather than forced: - UID collisions. uk_contact_uid_book is unique on (UID, CONTACT_BOOK_ID), so a contact whose UID already exists in the destination would fail the whole UPDATE and move nothing. Those rows stay put; rewriting a UID would break the CardDAV client that issued it. - Mixed-ownership groups. A contact_groups row has a book but no owner, so a group whose members belong to several people cannot be attributed to one of them. Only groups whose members share a single owner are moved. The ownerless book itself is kept: its check constraint is what makes it legal, and dropping it would cascade into groups and memberships. The stranded-count check tests contacts and groups, not just contacts. A group is attributed through its members' owners rather than through where those members live, so groups stay movable after every contact has already moved; counting contacts alone would skip them on a second run.
· VOIPService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· SecurityService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· RAGService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· MediaService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· MCPGatewayService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· KBService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· kamolos
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· ESigService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· EmailService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· DocsService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· DaemonService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· ConversionService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· APIService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· BillingService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· AIService
Point at YugabyteDB and use PostgreSQLDialect
CockroachDB has been replaced by YugabyteDB. Connection strings move from cockroachdb-public:26257 to yb-tserver-service:5433, and the Hibernate dialect from **************** to PostgreSQLDialect.
· KlusterServices
Unique_rowid() shim and non-public schemas in YugabyteDB
The shim is a sequence seeded at the production maximum plus a margin. The seed carries two requirements, not one: it must exceed every existing id or the first insert after cutover collides, and it must exceed 2^53 because ids above that serialize as strings and the API contracts already assume it. Verified: returns > 2^53 and > production max, 1000 calls all distinct, and `INT8 DEFAULT unique_rowid()` works verbatim as a column default -- which is the point, since it lets all 520 columnDefinition strings in the donotmodify shared library stay byte-for-byte unchanged. Also pre-creates the five non-public schemas. Hibernate does not create schemas unless hbm2ddl.create_namespaces=true, which KamoInitializerService does not set, so without these the same 34 tables would go missing again.
· KlusterServices
Unique_rowid() shim and non-public schemas in YugabyteDB
The shim is a sequence seeded at the production maximum plus a margin. The seed carries two requirements, not one: it must exceed every existing id or the first insert after cutover collides, and it must exceed 2^53 because ids above that serialize as strings and the API contracts already assume it. Verified: returns > 2^53 and > production max, 1000 calls all distinct, and `INT8 DEFAULT unique_rowid()` works verbatim as a column default -- which is the point, since it lets all 520 columnDefinition strings in the donotmodify shared library stay byte-for-byte unchanged. Also pre-creates the five non-public schemas. Hibernate does not create schemas unless hbm2ddl.create_namespaces=true, which KamoInitializerService does not set, so without these the same 34 tables would go missing again.
· KlusterServices
Deploy single-node YugabyteDB 2026.1.0.1 alongside CockroachDB
Both engines now run side by side on k1m1; CockroachDB is untouched and remains the rollback path. Three things that were not obvious: - The Helm chart version is not the Docker tag. Chart 2026.1.0 maps to app version 2026.1.0.0-b118, so `image: yugabytedb/yugabyte:2026.1.0` is ImagePullBackOff. Pinned to 2026.1.0.1-b1, newest patch of stable 2026.1. - hostNetwork puts the pods on k1m1's real interfaces, where two Yugabyte defaults are already taken: 9000 by MinIO and 9100 by node_exporter. The tserver webserver moves to 9010 and its RPC to 9110. Both host services verified unaffected afterwards. - TLS is not optional. All 31 consumer connection strings carry sslmode=require, so YSQL without encryption would refuse every client at cutover. Certs are created out of band, with the cockroachdb-public alias name in the SAN list because consumers keep using it through cutover. Deliberately not wired into CI: adding a deploy step edits .forgejo/, which triggers a full reconcile that reverts live drift.

Your universe is waiting.
Start free in minutes. Add products whenever you are ready — no migration, no new logins.
Free forever — no credit card required