Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-internal
Sell the three-year term, and stop four copies of the cycle drifting
The billing cycles were typed out by hand in five places. One of them, the plan-pricing editor, offered "BIENNIAL" — a value the server has never had — so choosing it sent a string BillingCycle.valueOf throws on and saving a price 500'd with nothing to suggest the option itself was the problem. posApi now owns BILLING_CYCLES, BILLING_CYCLE_MONTHS and BILLING_CYCLE_LABELS, and a vitest guard reads the Java enum source and fails if the two ever disagree on constants, order or month counts. Two computeMRR copies (the subscription detail page and the pipeline signups tab) divided the order subtotal by the cycle length. That subtotal is ALREADY monthly — calculatePrice builds it from a per-seat-per-month unit price — so an annual subscriber was reported at a twelfth of their real MRR, and a three-year one would have been reported at a thirty-sixth. Both now use the subtotal directly, matching the server-side fix. planQuote gains the third term. A missing term falls back to the next SHORTER one rather than straight to monthly: quoting month-to-month against a three-year commitment undercharges by the whole commitment discount. chargesPerYear becomes monthsPerCharge, because 12/36 is 0.33 and "billed 0.33 times per year" is not a sentence an interface can use. The billing console's monthly/annual Switch becomes a three-way ToggleButtonGroup — a switch cannot express three states, and the third is the cheapest rate we sell, so it has to be visible rather than reachable by toggling twice. Its saving badge now fires on any committed term, not the annual one alone.
· BillingService
Bill the three-year term, and quote its rate to the console
BillingCycles gains TRIENNIAL, expressed to Stripe as three YEARs rather than thirty-six MONTHs so the renewal tracks the signup anniversary — the same reason ANNUAL is one year. monthsIn now delegates to the enum, which owns the number: it was stated here and twice more in SubscriptionService, where both copies had it backwards. **************** annualised via `12 / monthsIn(cycle)` charges per year. That is integer division, correct only while every cycle divides into a year — a three-year term makes it 12/36 = 0 and annualises every plan to nothing. The invariant is about the ratio of amount to months, so it is expressed that way now and holds for a cycle of any length. A new test also pins interval x intervalCount to monthsIn, so the Stripe recurrence cannot drift from the span it claims to charge for. PlanCatalogService quotes the third term to the self-serve billing console. A term with neither a price row nor a plan column reports null rather than borrowing the monthly rate: quoting month-to-month against a three-year commitment would undercharge by a third.
· kamo-shared-library
A three-year term, and the promotion rules that were never read
Adds BillingCycle.TRIENNIAL so a subscriber can commit for three years. The column is varchar with no CHECK constraint anywhere, so this needs no migration — but every service that READS such a row must be running this enum first, because valueOf throws on an unknown name and getCatalog loads every price in one query. Three defects had to be fixed for the term to be safe to add: 1. unitPrice is a per-seat-per-MONTH rate on every cycle — BillingCycles in BillingService says so and multiplies by the month count before it reaches Stripe. SubscriptionService did the opposite: calculateMrr DIVIDED an annual rate by 12 (reporting a $59 seat as $4.92 of MRR) and generateInvoice billed one month's rate for a whole year of service. At 36 months those become 36x errors. The month count now lives on the enum, where a new constant cannot be added without answering for it, and invoicing multiplies by it. 2. calculatePrice checked nothing about a promotion but isActive. Applicable plans, applicable billing cycles, the start and end dates, the seat minimum and the redemption cap were all persisted and none were read — a code written for two tiers discounted all four, and an expired code never stopped working. validatePromoCode checked the dates and the cap but not the plan or the cycle, so the endpoint that exists to answer "does this code apply" disagreed with the arithmetic that applied it. Both now ask SubscriptionPromotions. 3. FLAT_PER_SEAT returned the bare discount value without multiplying by seats, making it a silent synonym for FLAT_AMOUNT: an "$18 per seat" code took $18 off a 25-seat order instead of $450. A percentage promotion now comes off the plan's per-seat subtotal after the term discount, not off plan+addons — "33% off the base per seat cost", applied to the rate the customer actually committed to.
· kamo-internal
Show the report title in Send To AI, editable
The dialog let an operator edit the instruction and the description but never showed the title, and the title was not in the message either — the session at the other end got the paragraphs about what happened without the line saying what the report is about. It now sits between the two, prefilled from the report and capped at 255 like the report form, so the three boxes read top to bottom in the exact order the server joins them: the dialog IS the message. Editing it renames the report, which the helper line says out loud — the box directly beneath it does not write back, and two adjacent fields behaving differently is only fair if the screen admits it.
· SecurityService
Put the report title in the message, and let it be corrected
A dispatch composed the operator's instruction and the bug description with the separator between them. The title was never in it, so the session opening the message read the paragraphs about what happened without the one line saying what the report is about. It now heads the second half, above the description: the first half is the instruction to the AI, the second is the report, and the title belongs to the report. Absent from the request it falls back to the bug's stored title, so an unattended dispatch and a console tab predating the field both still land instead of losing a written prompt to a 400. An operator who sharpens a vague title has corrected the REPORT, so the edited title is written back onto the bug — the one field in that dialog that does. It is applied beside the audit row and before the network call: the correction was true whether or not the dev machine answers, and a report should not go back to being badly titled because a hand-off timed out. The description still never writes back; restating a bug for an AI is not rewriting what the member filed.
· kamo-shared-library
Carry the report title through a Send To AI dispatch
The dispatch sent the operator's instruction and the bug description and nothing else, so the one line saying what the report is ABOUT never reached the session reading it. AiDispatchRequest gains titleText, and the audit row gains a column to record what was sent. titleText is optional: a request naming no title uses the bug's stored one, which is what keeps an unattended dispatch — and a console tab opened before the field existed — working rather than losing a written prompt to a 400. Unlike bugText, a title the operator sharpens is also written back onto the report. A vague title is wrong in the queue for everybody, not just in one hand-off; a restated description is not a rewrite of what the member filed.
· kamo-internal
Reply to a message, and take the reader to what it answers
MediaService has carried a reply's parent and the quote to draw for it since "send the quote with a reply, not just the pointer"; this is the half that shows it. Three windows draw conversations — member chat, social, and support tickets — and all three get the same reply, because a member who learns it in one and does not find it in the next reads that as the feature being broken rather than absent. One ReplyQuote component in two placements: the strip above the composer while the reply is being written, and the block inside the bubble once it has been sent. They are the same object at two moments, and splitting them is how the two drift — you pick a message, read one rendering of it, send, and are shown another. Its colours all derive from currentColor so the one component sits in a primary own-message bubble, a grey received bubble and on the composer surface without being told which. The quote is resolved through chat-core's resolveReplyQuote, which prefers the loaded parent over the server's stored preview. That is what makes a correction to a message reach every quote pointing at it, and an unsent one say so, rather than freezing whatever the parent said at the moment it was quoted. Resolved once per history change into a map rather than per bubble per render: MessageBubble is memoized on shallow props, so a quote rebuilt inline would re-render every reply in the conversation on every keystroke. Clicking a quote scrolls to the original and flashes it (app/lib/chat/messageJump.ts). The flash is the point — a thread of similar-looking bubbles sliding does not tell the reader which message they were taken to. It is DOM-level rather than a highlighted-id in state, which would re-render the whole list twice for an effect that touches one element for a second, and it honours prefers-reduced-motion. A reply whose parent is older than the loaded page still draws its quote, from the server's preview; it simply is not a link. The target is owned by the window rather than the composer, because two things need it — the strip and the parentId on the send — and one of them is not the composer. It is captured when the send starts, the strip's dismiss is disabled for the duration, and unsending the message being answered drops the reply rather than threading it under something the sender has taken back.
· kamo-internal
Offer Move on a right-clicked message, not only on a ticked one
Right-clicking a message offered Reply, Reply all, Forward, Report Spam, Archive, Label and Delete. Filing it anywhere other than Archive meant dismissing the menu, ticking the row's box, and using the selection bar — a detour nobody has cause to guess at, and the reason this came in as a request. The reading pane's own menu had the same gap. The two are one menu, so one entry fixes both, placed between Archive and Label: Archive is a move with the destination already chosen, and that is the order the selection bar already lists them in. Which messages it moves follows the rule `v` has always followed. Right- clicking a row that is part of the ticked selection is a gesture about the selection, so the bulk picker opens on all of them and its header says how many — that is the "(s)" in the request. A row nobody ticked, and the reading pane, are about the one message the menu was opened on; borrowing the selection there would move mail the member never pointed at, or, with the boxes empty, nothing at all. Archive now goes through that same single-message move rather than its own fetch(). It had been leaving the reading pane showing a message it had just filed elsewhere, because a move has to take the row out of the list, close the pane over it, and mark both folders' counts stale — which is not obvious, and is worth deriving once. Verified: 375 tests pass across app/components/messages and app/lib/email, 4302 across the repo; tsc clean on the .next-excluded config; eslint on the touched files byte-identical to baseline (0 errors, 8 pre-existing warnings). The menu was rendered and screenshotted to confirm the entry sits where the report asked. No new translation keys — browser.moveToFolder already ships in all 21 locales. Note: check-i18n-keys is red on main, 15 keys, all from the smart-folders commit at 0357c52b awaiting its dictionary re-sync. Identical before and after this change.
· kamo-internal
Go To Settings leads the Settings menu, above the app list
Reported with two screenshots — one org with eleven enabled apps, one with two — each circling "Go To Settings" at the bottom of the Settings sub-option menu with a line drawn from it up to the top of the list. On the eleven-app org the entry sat past a scrollbar, so the plainest thing the menu offers was the one thing you had to scroll to reach. Every other sub-option menu in the product already leads with the entry that opens the tool itself — Mail with Compose / View My Emails, Docs with Manage My Docs, both in the first untitled section. Settings was the outlier, so it now matches rather than getting a placement of its own: Go To Settings and Customize Navigation share the leading section, then the main topics, then Enabled Apps. No strings change, and all three placements (bar popover, collapsed rail fly-out, expanded rail inline list) read from this one list.
· KlusterServices
64G swap on the dev machine, and catch access that fails intermittently
The blank white page was the VM out of memory. 16Gi of guest RAM with NO swap means "out of memory" goes straight to the OOM killer: applications die mid-work, and this time the whole guest became unresponsive — including the provisioning agent. That reaches further than the VM, because SecurityService preflights that agent before minting a KamoDesktop link, so a wedged VM turned every launch into a blank browser tab: the tab opens blank and only navigates once a link comes back, and no link ever came. More RAM is not available — k1m1 is already ~86% allocated, so the memory does not exist to give. A 64G swap file is, and it is the difference between "slow for a moment" and "your editor is gone". vm.swappiness=10, deliberately: this swap sits on a Longhorn volume replicated over the network, so it is much slower than local disk and must be a safety net rather than a routine tier. The default 60 would page out idle desktop applications to make room for cache, over the network, which is exactly the wrong trade here. The script refuses to build the file if the space is not there — filling the root filesystem to make swap would trade a memory problem for a disk-full one, which is worse and harder to undo. Verified idempotent by running it against the already-configured VM: it reported "already active at 64G" and changed nothing. Also adds KamoDesktopAccessFlapping. KamoDesktopAccessBroken needs ten UNBROKEN minutes of failure, and this outage never failed that long in one stretch — the agent answered some probes and not others — so access was badly broken for the person trying to use it while the alert stayed quiet. Intermittent is not fine; half a working desktop is a broken one.
· kamo-internal
Make smart folders reachable, and make clicking one work
A smart folder is a saved search that sits where a folder sits: name a question once ("unread, with an attachment") and it is one click away, current every time, across every folder. Nothing is moved and nothing is changed. All of it was built and none of it was wired. Three breaks, each enough on its own. SmartFolderModal had no importer, so there was no way to create one — and the sidebar section was gated on already having one, which left the only entry point behind the thing it created. Clicking one called onFolderSelect('__smart__' + id) into setSelectedFolder with no branch anywhere downstream, so loadMessages asked IMAP for a folder literally named __smart__<uuid> and errored. __label__ had the identical bug. And the endpoint returned raw index rows the list cannot render, fixed in EmailService alongside this. virtualFolders.ts now owns the encoding both sides share. loadMessages branches on it; search substitutes the inbox, because the mail server cannot be asked to search a saved query; and the three realtime refreshes reload on any folder's change while a saved query is open, since matching by name meant one never refreshed at all. A row's counterparty is judged by the row's own folder too — by the view's, every outgoing message in a smart folder was labelled with the reader's own address. Also: the icon a folder is saved with is now the icon it shows (the picker offered its names as literal text — buttons reading "inbox", "zap" — and the sidebar drew a funnel regardless); text conditions get the operator the server has always supported; and SmartFolderCondition stops borrowing the filter-rules field union, which is a different set of names and forced every condition through `as any`. Verified: npm test — 4202 pass, and the 13 failures are the same chat/@kamo failures origin/main has without this change. tsc and eslint byte-identical to baseline. Dictionary keys pushed first, at d91e6b66.
· MediaService
Send the quote with a reply, not just the pointer
media_objs_msg.parent_id has been there all along and this endpoint has accepted parentId on the way in since threading was modelled, but a reply crossed the wire carrying only the id of what it answered. That is enough to thread it and not enough to draw it: the quote above a reply names an author and shows a line of what they wrote. A client could resolve that from the messages it already holds, and for most replies it would — people answer what they can still see. It is the rest that decides this. A reply to something older than the loaded page, a conversation reopened onto its newest page, a support thread running to hundreds of messages: in all of those the parent is not on the client at all, and a quote that renders empty in exactly the cases the feature exists for is not worth having. So parentSenderName, parentMessage and parentIsRemoved travel with the reply. It is close to free. Both history queries already LEFT JOIN FETCH msg.parent, so the parent's body is in the persistence context before ChatReplyPreviewService is called. Only the AUTHOR needs fetching, and only for parents that are not themselves on the page — a page whose replies point within itself issues no query at all. That is the shape **************** already established. Three things the naive version gets wrong: The send response omitted parentId entirely. Accepted on the way in, echoed nowhere on the way out — so the one window guaranteed to be showing the message the instant it is sent was the one window with nothing to quote from. It now carries the same preview the live frame does, and both are built from one resolution so the two sides of the conversation cannot disagree. On a public chat the visitor's messages are stored under the org's SYSTEM member, which is usually the owner answering them. Naming a parent's author from that member would put the agent's name on the visitor's own words — the same trap the sender name is already guarded against, so the preview takes the same visitor override, on history and on the live frame. Truncation cuts on a code point. substring counts UTF-16 units, and every emoji is a surrogate pair; a boundary landing inside one yields a lone surrogate, which is not a character and does not survive JSON intact. findAllByIdIn deliberately does not filter isRemoved: a reply to a message that was later unsent still draws a quote, and the quote's whole job there is to say the original is gone.
· kamo-internal
Leave a group chat, and keep the line that records it
Leaving is offered at the foot of the roster popover, separated from the rows above it because it belongs to the member rather than to anyone in the list, and confirmed with a sentence rather than the two-word inline confirm removal uses: it is the only action here somebody cannot undo for themselves, and the wording has to say that you cannot get back in without being added. Its own action, not "remove" aimed at yourself — the server refuses that. The two acts keep their own confirmation and their own line in the transcript, and a member is never asked whether they want to remove themselves. Nothing to render on success: the conversation announces its own departure through the same event as being removed by somebody else, which already closes the window and drops the row from the list. The transcript line needed two fixes to be worth anything. The subject's name was resolved out of the session's member list — which is empty of that person in exactly the case a "removed" or "left" line exists for — so it rendered "Sage removed " with the name blank; the server now sends it and the list is only a fallback for older rows. And a departure is stored as the same transit row as a removal, told apart by the actor being the subject, so without that branch the data reads "Daniel removed Daniel". Needs @kamo/chat-core 0.1.13 and MediaService 549b29f, which is also where these lines stopped vanishing from history on reload.
· MediaService
Leave a group chat, and keep join/leave lines in the transcript
Leaving is DELETE /sessions/{guid}/membership, not removeMember aimed at yourself — that route refuses self-removal on purpose. Removal is done TO somebody and names the actor; leaving is something you do. Both write the same transit row (isJoin=false) and the clients tell them apart by whether the actor and the subject are the same person, which the row already records, so this needs no new column. Groups only, for the same reason removal is: leaving one side of a two-person chat would end it for that side while the other could still write into it. The larger fix is underneath it. A MediaObj carrying a transit has no MSG, so history serialized it as messageType MESSAGE with a null body — an empty bubble. Join and leave lines therefore existed ONLY live, on the NATS frame, and vanished on the next reload: "Sage added Daniel" was visible to whoever happened to be watching and to nobody after. Which made the removal and departure lines — the ones that exist to explain why somebody is no longer in the room — the least durable thing in the conversation. History now serves them as TRANSIT with the subject's id, name and direction. Both paths name the subject. On a removal or a departure that person is gone from the session by the time the frame lands, so every recipient's member list has already dropped them and there was nowhere to resolve the id: the line rendered with the name blank. Requires kamo-shared-library with transitMemberName.
· kamo-internal
Open on Chats, a Favourites section, and a group chat you can manage
Five requests from a member, all of them about the same gap: the Chats panel could show you conversations but gave you nothing to do with them. Chats is now the first tab and the one the panel opens on. The button that opens this panel is the Chat button — landing on the Directory made the common case, carrying on a conversation you are already having, a tab switch away every single time. A collapsible Favourites section sits above the list, per sub-tab, and it is fetched separately on purpose. The list pages by last activity, so a conversation starred precisely because it matters but quiet for a month is several pages down; a section assembled from whatever had already been scrolled into view would show that person some days and not others. The fold is remembered per pane instance, for the same reason preferenceKey is a prop — the Interaction Center and a maximized window's switcher column are two live mounts and would otherwise clobber each other. The star is offered only where the server can record one, which is CHAT: a social conversation has no membership row for the viewer at all. Group chats can be named. A name the members chose is checked BEFORE the derived one everywhere it is read — the row, the window title, the roster header — because otherwise "Some chat" reverts to "Sage, Claudius +1" the moment somebody is added, which is the case naming exists for. A blank name is not a name; it falls back rather than blanking the title. A roster button on a group window opens the popover that answers "who else is in here" — until now the title bar's "+1 more" was the whole answer and there was nowhere to expand it. Naming and removal live in there too, because both are things you decide while looking at the list of people. The window title already ellipsized; what was missing was the way to see past it. Somebody can be removed. The transcript line for it has existed since the beginning — SystemMessage has always rendered "{actor} removed {target}" for a transit with isJoin=false — and nothing had ever written one. Removal is confirmed inside the row rather than in a dialog: a modal over a popover is two layers of chrome for a two-word question, asked about a list it would cover up. Being removed yourself closes the window, because every request it could make from that point answers 403 and a chat that has silently stopped working is worse left open. Every rule about who may do what is the server's, and its wording is shown verbatim when it refuses. A rule stated generically reads as a bug. Needs @kamo/chat-core 0.1.12 and MediaService aed30b1.
· MediaService
Name a group chat, star a conversation, remove somebody from a room
Three routes, and each refusal is a rule somebody has to be able to tell apart from an outage — so each carries its own code rather than a shared 4xx. DELETE **************** Members only, groups only, never yourself. The middle rule is the one worth stating: a two-person chat with one person removed is not a smaller chat, it is a conversation one side can no longer see and the other can still write into. The last one is not a restriction but an admission — leaving a chat needs its own confirmation and its own wording in the transcript, so this refuses rather than half-implementing it. Anyone in the room may remove anyone else, which is the symmetry of the add route: every participant could already have brought that person in, and a conversation whose only manager is its creator becomes unmanageable the moment that person leaves the company. Removal publishes MEMBER_REMOVED (so the removed member's own client can close the window) and the leave transit (so it does not read, to everyone still there, like somebody who simply went quiet). PATCH /sessions/{guid}/title. Group chats only: a direct conversation is named after the person you are talking to, and a name laid over the top hides the one fact the row exists to tell you. Shared, not private — everyone sees it, and SESSION_RENAMED carries the new name so open windows change without asking. The response is built into a LinkedHashMap rather than Map.of because a cleared name is null, and Map.of would have thrown on the way out of a call that had already succeeded. PUT /sessions/{guid}/favorite. The viewer's own opinion, so the only gate is having a membership row — and the 404 when there is none is a real case, not padding: a social conversation has no member rows at all. Refusing beats reporting a success the member finds gone on their next reload. The chat list carries isFavorite and title, and `favorites=true` answers every A favourites section that had to be paged into view would put a starred conversation straight back behind the scroll it was starred to escape. Requires kamo-shared-library at 2352064a and the two columns it documents.
· kamo-shared-library
A chat can be named, starred, and have somebody removed from it
Three fields and one method, all of which the chat feature already had the shape for and none of which it could record. MEDIA_SESSION_CHAT gains TITLE. That table exists precisely to hold what is true of a chat and of nothing else, and until now held only its own foreign key. Null is not "untitled": it is the instruction to go on deriving the name from who is in the room, which is what every chat did before and what a two-person chat must keep doing forever — so clearing a name restores the derived one and no backfill is needed for the conversations that predate this. MEDIA_SESSION_MEMBERS gains IS_FAVORITE, beside IS_MUTE. Both are one member's private opinion of one session, invisible to everybody else in it. Only a CHAT session can carry one, and not because the feature was scoped down: a SOCIAL session has no rows in that table at all (it belongs to the organization), and a SUPPORT session can be visible to an agent through the ticket's assignee without one. A star offered on those rows would be a control with nowhere to write. removeMemberFromSession is the mirror of addMembersToSession, shaped the same way on purpose: the membership row goes and a MediaObjTransit with isJoin=false stays in the transcript forever. IS_JOIN has been on that table since the beginning and the clients have always rendered "{actor} removed {target}" for it — nothing had ever written one. The chat list queries carry both new fields, and a favourites query answers every starred conversation unpaged. That is the whole reason it is a separate query rather than a filter on the paged one: the list pages by last activity, so a conversation starred precisely because it matters but quiet for a month is several pages down, and a section assembled from whatever had already been scrolled into view would show that person some days and not others. Both columns need DDL by hand — InitializerService is not deployed: ALTER TABLE media_session_members ADD COLUMN is_favorite boolean NOT NULL DEFAULT false; ALTER TABLE media_session_chat ADD COLUMN title varchar(120);
· kamo-internal
Bulk add members and team members, with a CSV/XLS import
Two new screens beside the existing Add buttons on /account?tab=members and ?tab=team-members, at /settings/member/bulk-new and /settings/team-member/bulk-new. Both buttons take their wording from the org's own member/team-member labels. The grid is a spreadsheet: one row per person, fields as columns, a fresh blank row appended the moment the last one is typed into, and a remove control on every row but that trailing one. Above it sit the settings the whole batch is created with -- security level (fixed by which screen this is), timezone (the org's default), department, job title, membership status and the mailbox toggle, which starts on for team members and off for members. Nothing is created until "Finished -- Create ..." is pressed; the button then spins, and the grid goes inert until every record has been answered for. Partial success is the designed outcome. The endpoint creates each record in its own transaction and reports on each one, so rows that landed collapse away and rows that did not stay exactly where they were with the reason attached to the cell that caused it -- correct them and finish again, and only the remainder is attempted. Duplicated emails and usernames are caught in the browser too, so the admin sees them while still editing rather than after two thirds of a file has been created. A username left blank shows the name the server will most likely mint; one typed by hand is checked for availability as it is typed. "Import ... List" fills the grid from a CSV/XLS/XLSX and creates nobody. Column mapping reuses FieldMappingInterface -- the same drag-to-connect component the Import Leads wizard uses, which is already generic over its system-field list -- after an auto-map pass that matches the header names an HR export actually uses. The member-side coercions live in memberBulkFields.ts with tests: work location from "Work From Home" / "In Office" / "Hybrid - 3 days in office", prefixes and suffixes against the org's own configured lists, "Last, First" and "Dr. Jane Q. Public" split into parts, emails lowercased. Two fixes fall out of building on that component: FieldMappingInterface gains an optional onModifiersChange. Its split and value modifiers were purely internal, so a consumer received a mapping whose virtualFieldId named a virtual column but had no way to learn which split produced it -- every "Split Name" the user configured was silently dropped when the rows were read. The prop is additive; the leads wizard is untouched. The import always parses with hasHeaders: true and re-attaches row 1 itself when the admin says the file has none. parseFile's own false branch runs a heuristic that peels row 1 off anyway when it looks header-ish, and then reports hasHeaders: true because it synthesised "Field N" labels -- so echoing that value back into the toggle flips it on behind the admin and the next re-parse eats a row. On a member import that row is a person who never gets an account, with nothing on screen to notice.
· SecurityService
Bulk-create members and team members, one transaction per record
The two bulk-add screens need to create a spreadsheet of people in one submission and report on each of them individually. That could not be built on the existing endpoints, so the creation pipeline moves out of the controller. MemberCreationService now owns it, marked REQUIRES_NEW. Each record therefore commits or rolls back on its own, which is the whole premise of the screen: a failure at record 7 leaves 1-6 created, and only the rows that failed stay on the page to be corrected. Had the loop stayed in the controller, Spring's proxy would not have seen the call (self-invocation), every record would have shared one transaction, and the first caught failure would have marked it rollback-only and discarded the members that had already succeeded at commit time. MemberBulkCreator runs the batch and is deliberately NOT transactional for the same reason -- an outer transaction would be poisoned by exactly the exceptions it exists to absorb. It also refuses an email or username repeated within one submission, which would otherwise surface as "already a member" and send the admin looking for a member this very request had created a second earlier, and it releases those claims again when a row fails for some other reason. Both single-record endpoints now delegate to the same service, so the bulk path runs an identical sequence: reuse the User matching the personal email (case-insensitive, oldest wins), refuse a duplicate membership with 409, create through MemberService, mirror into the securityProvider org, re-materialize rights, then send the verification and WELCOME emails. Two other changes fall out of this: MemberUsernameGenerator fills in a blank username, in the order the product asked for -- first.last, f.last, first.l, first.m.last, then the same four numbered. It folds accents, respects the 20-character column, never ends on a separator and never mints a reserved handle. Availability is probed globally, because login resolves LOWER(u.username) across the whole table before it looks at any org. UsernameAvailabilityRepository is SecurityService-local so this ships without a shared-library version bump; the shared finder is exact-case and throws outright once two rows do collide. A hand-typed username that collides only by case is now refused rather than creating a second user. That pair is unrecoverable: both accounts become ambiguous at the sign-in form and findByUsername throws for each of them thereafter. The single-record form always sends a username it has already validated, so nothing regresses there. **************** learns extractOrgId, which is extractUserId's sibling and was missing only because the two controllers using it predate the pattern. Four baseline entries go with it.
· kamo-internal
Say the app is in beta, and that Apple devices aren't ready
Two facts a member needed before scanning anything: KamoMobile is beta, and there is no build for an iPhone or iPad yet. Both change whether someone should start at all — an Apple member has nothing to do on this page — so they go in a band ABOVE the install deck rather than as a footnote under a code that has already been scanned. Deliberately not a DeckPanel: the deck is the instructions, and an advisory in the same card shape as "Step 01" gets skimmed as step 00. One wide warm-tinted card, split — beta caveats on the left (they apply to someone who CAN install), Apple on the right (someone who cannot). The hero gains a Beta pill beside the kicker so the status is read at the same moment as the app's name. Its colour needs `&&&`: the hero wrapper forces `& .operator-label { color: #fff !important }` at (0,2,0), which beats a plain sx colour even with `!important` of its own. Deck entrance indices shift 0-3 → 1-4 so the cascade still reads top to bottom. Verified: tsc clean, `npm test` green (12 guards + 4078 tests), and a real `next build` completes.
· kamo-internal
A Receivables tab, and payment details on the printed quote
Settings → Account → Receivables is where an organization sets the bank account its customers pay into: the payee, the bank, the routing and account numbers, and whether they print on quotes. A quote raised from /leads/view/:id?tab=quotes then carries a remittance block, so a company receiving one knows where to send the money without telephoning to ask. The screen is built around one idea. Somebody here is deciding to publish a bank account to every company that receives a quote, so they should be looking at the block that will be published rather than at a description of it. The preview beside the form is the SAME component the quote sheet renders — QuoteRemittanceBlock, shared by both — so the two cannot drift. Three states per number field, and the difference is the whole interaction. Empty: type it. Stored: the server answers with the last four digits and nothing more, so the field shows "•••• 4417" and offers to replace. Replacing: live and empty, with a way back that sends nothing. An empty replacing field is NOT sent, which means this screen cannot clear a stored number — "I clicked Change, thought better of it, then saved something else" must not silently delete the deposit account. VIEW_RECEIVABLES and MANAGE_RECEIVABLES are grouped by NAME under Accounting & Finance in permissionGroups.ts. They carry no ServiceType, and a right with a ServiceType the organization has not enabled renders in no editor at all — which is how nineteen rights once became ungrantable however the org was licensed. The tab, the settings-menu link and the landing card each gate on the pair; the tab also admits the organization owner, matching the backend, because a brand-new right is granted to nobody on the day it deploys including whoever would grant it. The remittance block is resolved SERVER-side, even for the staff preview, and is absent unless printing is on and both numbers are stored. A previewed account number that differed from the sent one is the one error a customer finds before anybody else does.
· kamo-signer-monorepo
Print the seller's ACH details on the customer's quote
The page a customer opens from a share link now carries a remittance block: who the payment is made out to, the receiving bank, and the routing and account numbers, followed by the notice that the account takes credits only. It sits after the terms and before the agent. By that point the reader has agreed the numbers, and the next question is where to send them. Nothing is assembled here. The server emits the whole block or none of it — it is absent unless the organization publishes its account AND has both numbers stored — so a quote never carries half a payment instruction, which is the kind an accounts-payable clerk acts on before the transfer comes back with a fee attached. The markup and CSS mirror kamo-internal's QuoteRemittanceBlock class-for-class, as the rest of this document already does. The fraud-control notice keeps its own footing rather than being folded into the terms prose: it is a limit on what the account may be used for, and the person paying has to find it without reading the whole page. The legal-form sentence carries a plain-language line under it, since "ACH Credit Only" is not what makes somebody act correctly — "do not schedule a withdrawal against it" is. Grouped figures get tabular digits and a little tracking. They are transcribed by hand into a banking form, and that is what stops a 3 being read as an 8.
· SecurityService
Store the org's deposit account, and print it on quotes
`/api/security/receivables` is where Settings → Account → Receivables reads and writes. Two rights, not one: VIEW_RECEIVABLES reads the tab, MANAGE_RECEIVABLES changes it. Neither is a payout right — VIEW_ORG_FINANCES governs money leaving the organization, these govern the account it asks customers to pay INTO, and somebody who reviews commission batches has no business repointing it. The organization owner is an implicit holder, because a brand-new right is granted to nobody on the day it deploys including whoever would turn the feature on, and without that branch this ships dormant. The numbers go in but never come back. A read answers with the last four digits, which is what an administrator confirming the right account is on file actually needs; changing one means typing it again in full. Writes are partial — only the keys the body carries are applied — so the screen can save the printing switch without re-sending an account number it never displayed. OrgReceivablesService verifies the ABA check digit rather than just the digit count. A transposed pair is the commonest typing error there is and it passes a length check while naming a different bank or none, and the only cheap moment to catch it is while somebody is looking at the field. It also refuses to leave the organization printing an instruction it cannot complete, checked on the RESULT of the update: turning printing on without an account and clearing the account while printing is on are the same mistake seen from two sides. Changing where an organization's incoming payments land is refused while impersonating, matching CommissionController. The quote surfaces resolve the block through QuotePresentation, so the staff preview and the customer's page cannot disagree about the document — including about its absence. A decryption failure yields no block rather than an error: a quote that renders without payment details is recoverable by the customer calling the agent named on it; one that will not render at all is not.
· kamo-shared-library
The deposit account an organization asks customers to pay into
Six columns on `orgs` and the wire contract for printing them on a quote, so a company that receives one knows where to send the money without telephoning to ask. The numbers are AES-GCM encrypted at rest through SecretCipher, the same envelope the GoDaddy credentials use, and carry `@JsonIgnore` for a sharper reason than that: `GET **************** serialises this entity whole and needs no session. An organization that has not switched printing on has published nothing, and a whole-entity serialisation must not do it for them. The last four digits sit beside the ciphertext in the clear so a settings screen can say "•••• 4417" without holding a key. `ACH_ON_QUOTES` is a nullable Boolean, not `NOT NULL DEFAULT FALSE`. A NOT NULL add against a populated table is the one column shape Hibernate's ddl-auto cannot apply, and a column that silently failed to be created takes down every service that maps it. Null resolves to false everywhere, which is the safe default for a field that publishes a bank account. **************** owns the rule that the block prints whole or not at all: printing on, plus BOTH numbers. A payment instruction missing half its routing information is one an accounts-payable clerk still acts on, and the transfer comes back days later with a fee attached — silence sends them to the agent named further up the page instead. It takes the decrypted numbers as arguments rather than reading them, because the ciphertext needs a Spring bean and this class is deliberately the one static resolution the staff preview and the customer's page share. VIEW_RECEIVABLES (293) and MANAGE_RECEIVABLES (294) are a new root and its only child, NOT a branch of the payouts subtree. The two are opposite directions of money and opposite risks: VIEW_ORG_FINANCES reads what is leaving, and the harm from a substituted deposit account is every invoiced customer paying a stranger. A controller who reviews commission batches has no business editing it. Ids re-derived from origin/main; 288-292 stay burned.
· kamo-internal
Download the app from a URL that says KamoMobile
the phone showed while downloading was the old one. The same bytes now also sit at **************** and the QR points there. A copy, not a move: the old object still answers, so QR codes already printed or photographed keep working. that URL is compiled into the installed binary, so it can only change in a native rebuild, and the installed base is 0.1.9 and cannot self-update. Also records, where the next person will look, why the configure deep link still says kamocopilot:// and what has to happen before it can stop.
· kamo-internal
The rail's brand row reads "Back to Desktop"
Names the destination in the platform's own vocabulary rather than the generic "Go Home". The expanded row's aria-label follows the visible text, so the accessible name and the label a voice user speaks are the same string.
· kamo-internal
Say "Go Home" under the org name in the expanded rail
The expanded rail's brand row has always navigated home, but the org name alone reads as a label, so the destination was invisible. The name now sits above a second line — a small back arrow and "Go Home", regular weight — and both stack to the right of the logo. The row is already the link, so "Go Home" is plain text rather than a nested anchor: one destination, one interactive element.
· kamo-internal
Title the tab "{Org} - {role} Portal", painted before first frame
A member is named by the organization's own word for one — "Harmony - Borrower Portal" for a lender that calls them Borrowers. A team member is named by their DEPARTMENT — "Del Taco - Sales Portal" — which is the more specific fact and the one they identify with. Most team members have no department, so that falls back to the organization's team-member label rather than to a blank: the shape of the title stays the same for everyone, and an org that renamed the concept still sees its own vocabulary. Composed in ONE function. This surface has broken repeatedly from several writers each owning a fragment and whichever landed last winning, so composeTabTitle is what the loader, the sign-in seed, the pre-paint script (through THEME_TITLE_KEY) and the attention flasher all end up using. There is no second copy of the format. Painted before first frame, not after a round trip. The composed title goes into THEME_TITLE_KEY, which the script in the document head already applies before React exists, so every load after a tab's first is instant. The first entry is covered by seeding it during /validate — inside the pause that already precedes the redirect, so it costs no added latency — and it is best-effort, because the app composes the same title on arrival regardless. The stored title stays a MIRROR and never becomes the source of truth: every authoritative load recomposes and overwrites it. An update made in another tab, or by an administrator, therefore corrects itself within one load. That distinction is the difference between this and the two stale-cache faults fixed earlier today. The loader reads the session from userInfoStore directly rather than through useUserInfo, which also needs OrgContext — and this component is rendered above the providers. Until the session resolves the organization's name stands alone: an incomplete title is correct, where a guessed role would not be.
· SecurityService
Carry the team member's department in the session
The browser tab is being retitled "{Org} - {role} Portal", and a team member's role is their department — the specific thing they identify with day to day ("Sales"), rather than the generic word for staff. The department is on the TeamMember subtype, not on Member, so it needs the join this adds; the query already reached member_team_member for member_type, so it is one more LEFT JOIN rather than a second trip. Written into the session because that is where the client reads this kind of thing from: kamo-internal's /api/user-info serves the Redis session blob directly, so a field that is not in the blob costs every consumer a separate request for it. Omitted rather than stored as null when the member has no department, which is the COMMON case — 67 of 83 team-member rows in production have none. A consumer testing only for presence would otherwise render the absence as somebody's job title. The payload assembly is extracted to buildTeamMemberPayload so that one rule is testable without standing up Redis. Sessions minted before this carry no department and stay valid; the tab falls back to the organization's own team-member label, exactly as it does for the majority who have no department at all.
· InitializerService
Keep every live domain routing once verification becomes mandatory
Host to organization resolution is about to require ownership_verified on the top-level domain row, so that a domain several organizations have merely named routes to the one that proved control of its DNS. Applied to the estate as it stands, that filter would take every organization whose row predates the TXT challenge off the air at once — the flag was optional before, so most live domains simply do not carry it. This grants it to the ones that have already demonstrated they resolve here: DNS checked, a certificate confirmed or ever issued, or a *.kamocrm.com host, which is ours by construction and which the customer could not add a TXT record for anyway. A domain with none of that has never been shown to resolve here, so no traffic can be reaching it and leaving it unverified breaks nothing. That is exactly the case the new rule exists for. MUST RUN BEFORE the shared-library filter ships.

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