Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · VOIPService

    Serve the inbound webhook URL, from one definition

    An administrator adding a text provider has to paste Kamo's inbound URL into the carrier's own console, and nothing told them what it was. Missing that step has no symptom on the side anybody tests first: messages send perfectly and every reply is dropped. GET **************** answers it. Served rather than hardcoded in the browser for the reason RingCentral's redirect URI is served: the value comes from a deployment property, and a copy in the frontend keeps confidently displaying the old host after the real one moves. The URL now has ONE definition. TelnyxProvisioner WRITES it into the customer's messaging profile and the settings screen SHOWS it to somebody pasting it by hand; when those were two strings nothing would have failed if they drifted — an administrator would simply have been instructed to configure a URL nothing listens on. InboundWebhookUrls is that definition, and its test asserts that every slug it builds resolves back through InboundSmsNormalizer, so the two sides agree by construction rather than by a table somebody must remember to update. The endpoint also reports whether a carrier's inbound is genuinely wired — a signature check AND a parser. Handing somebody a URL for a carrier whose payloads nothing can read would be an instruction to configure something that then refuses every message, which is worse than saying plainly that it is not supported yet.

  2. · kamo-internal

    One setting that covers every number on a phone system

    The approved routing design had three tiers and only two of them were reachable. A number could name its own text provider, and failing that the phone server was asked to text itself — but the middle tier, "unless a number says otherwise, our texts go through Telnyx", was implemented in TextRouter and settable nowhere. An organization with two hundred DIDs on one FreePBX had to edit two hundred rows to express one fact, which is the data-entry job the Numbers screen was supposed to avoid. It is now a dropdown on the phone server itself. Three things worth naming: - The merge runs LAST in save(). Every provider branch above it ASSIGNS configJson rather than merging into it, so the first version of this was silently dropped on Telnyx servers — the one provider whose branch happens to come after where it originally sat. - "Nothing — set each number individually" rather than a blank option, because an empty dropdown reads as unset rather than as a decision. - The key is one string in two languages in two repositories, and drift is SILENT: the setting saves, the dropdown still shows the provider that was picked, and every number relying on the fallback quietly falls through to texting via the phone server — which for FreePBX means not texting at all. So the form imports DEFAULT_TEXT_INSTANCE_KEY, and textRouteContract.test.ts pins it against TextRouter.java the way billingCycles.test.ts pins the billing enum. That guard is deliberately honest about what it proves. The cross-repo half skips whenever the sibling checkout is absent or stale — which is most of the time — so it is named "when that source is available" and a pass there means "not contradicted", never "verified". The assertion that always runs is the one that caught a rename when the guard was probed by breaking it on purpose. Design doc updated: the two limits it listed as deferred — a carrier-only organization unable to thread a reply, and MMS through Telnyx — are both closed in voipservice 8eaebfb.

  3. · kamo-internal

    React to a status change, not only to a comment

    "🎉" on a Fixed event is the single most natural thing anybody wants to say in a bug thread, and offering reactions only on comments meant typing one to say it. Centred under the event pill, and hidden until the row is hovered, like the chips on a comment.

  4. · kamo-internal

    Live thread updates, and reactions that are actually reachable

    Two halves of the thread were built and then left unconnected. The server has published every comment and status event to `chat.session.{guid}` since the feature landed, and nothing subscribed — so the transcript only moved when the reader posted something themselves, and two people triaging the same report saw different histories until one of them reloaded. It now subscribes through the same context `TicketChat` uses, after the first load has settled so a frame arriving mid-fetch is not overwritten by the page already in flight. It re-reads the page rather than appending the frame, which is the opposite of what chat does and deliberate: a chat runs to hundreds of messages, a bug thread is a handful, and the frame carries neither the reply quote nor the reaction list — an appended row would render without its quote until something else forced a reload. The reactions endpoint was worse: server, service and DTO field all shipped, and no client could reach any of it. There was no route, no api method and nothing drawn. It has all three now, and `isReacted` travels as the state being ASKED FOR rather than a delta, so a racing double-click converges instead of flipping twice; the server's answer replaces the row's list rather than merging into it. Four emoji, offered inline. A bug thread is not a chat — the useful reactions are "I hit this too", "looking at it" — and a full picker would be more control than the conversation has uses for. Unused chips are drawn only on hover of their comment, so ten comments are not forty grey buttons. The tests fake the websocket context on purpose: the real one's DEFAULT is a no-op that only warns, so a test without a provider in scope would have passed while the subscription was absent entirely.

  5. · MediaService

    The thread page names its reader, and drops an unused helper

    `viewerMemberId` on the thread page, so the console can tell the viewer's own reactions from everybody else's without a second call to /api/user-info. A ref filled in by a second async fetch is empty for the first render, and a reaction chip that starts un-filled and corrects itself a moment later reads as a failed click. A string, for the reason every id in this feature is one: a 19-digit int64 loses precision through JSON.parse. `none()` is gone — written as a convenience for the empty case and never called.

  6. · VOIPService

    Actually create the Telnyx account setup, carry MMS, and let a carrier-only org hold threads

    Three gaps, and the first was the feature not working at all. TELNYX PROVISIONING WAS DEFINED AND CALLED BY NOTHING TelnyxProvider.provision(), ensureMemberCredential() and **************** all existed and had no callers. So an administrator pasted an API key, saved, was told the phone server was created, and nothing whatsoever was created in their Telnyx account — after which the provider reported voiceCalls=false and sms=false with a remedy telling them to press Save, which would have done nothing for ever. TelnyxSetupService now runs on create and on every update, idempotently, the way JustCall's webhook registration already does. It writes the connection and messaging-profile ids back into configJson, which is why it is a service and not a line in the provider: a provider is built FROM that config and has no repository. Assigning a number now mints that member's SIP credential and attaches the number to the messaging profile — the most-missed manual step, and the one with no symptom, because an unattached number sends perfectly and never receives. MMS Telnyx takes media_urls, never bytes. JustCall had already solved exactly this with a Redis-backed public link; it was simply named for JustCall. It now extends a neutral OutboundMediaHandoff, so one bean serves both and no JustCall call site or test changed. Both Telnyx rails carry media, and every refusal is stated — no MMS on the carrier, no handoff configured, or a file the publisher rejects — so a member never watches a text arrive with the picture silently missing. A CARRIER-ONLY ORG CAN NOW HOLD A CONVERSATION This is the limit the previous commit documented rather than fixed. VOIP_CONVERSATIONS.INSTANCE_ID is nullable (kamo-shared-library ae95b01e), so an org with numbers and a carrier and no phone server can thread a reply instead of having it dropped. Two things that had to move with it: - The dedupe. SQL treats NULLs as DISTINCT, so the existing unique index stops covering exactly the rows this introduces. A partial unique index on (ORG_ID, MEMBER_ID, EXTERNAL_PHONE_NUMBER) WHERE INSTANCE_ID IS NULL covers it. - Two NPEs waiting for the first null. The NATS event was built with Map.of, which throws on a null VALUE — caught by the handler below and logged at warn, so the member's SMS window would simply never update. SmsController called .toString() on it outright. DDL for both is in docs/sql/ and applied by hand; InitializerService is not deployed in this cluster.

  7. · kamo-shared-library

    A text conversation can exist without a phone server

    VOIP_CONVERSATIONS.INSTANCE_ID was NOT NULL, which quietly decided that every text thread belongs to a PBX. An organization that buys numbers and messaging from a carrier and answers no calls at all — texting-only, no phone server anywhere — has no VoipProviderInstance for a thread to belong to. Those orgs could SEND (a number's own text route needs no phone server) and had every reply dropped, because ingest could not build a conversation to file it under. Null is now a real state and the javadoc says what it means, including the thing it must never be: a BulkTextProviderInstance id. Both tables are UUID-keyed and handing an id to the wrong factory fails as "no such instance" rather than failing loudly. Dedupe survives the null, but not for free — SQL treats NULLs as distinct, so the existing unique index stops covering these rows. The DDL beside VOIPService adds a partial unique index on (ORG_ID, MEMBER_ID, EXTERNAL_PHONE_NUMBER) WHERE INSTANCE_ID IS NULL for exactly that case.

  8. · kamo-internal

    The plan you picked travels to the page that sells it

    "Move to Business" opened the billing console with nothing selected. The choice the person had just made was dropped on the floor and they were asked to make it again, on a screen listing four plans and opening on none — the one moment on this journey where they had already decided. The code now rides the same query string as the sub-tab, built and read by planLinks.ts for the reason the sub-tab is: an unrecognised value is ignored by design, so a typo produces a link that works, lands somewhere plausible and is not where it said it was going. One module owning both halves is what lets a test catch that. Upper-cased on the way in, because plan codes are compared by equality against the catalogue everywhere else and a link is no place to invent a second spelling. Not validated here — this module does not know what plans exist, so a stale bookmark hands its code over and the console ignores what it cannot match, rather than erroring at someone who followed a link they were given. The console side is landing separately, in a concurrent session's own pass over that file.

  9. · kamo-shared-library

    The two letters a customer gets when their card fails

    BillingService has wanted to send these since DunningNotifier was written. It posted them to **************** — an endpoint that has never existed on EmailService, where the only controller under that prefix serves directory photos — and swallowed the 404 by design, because a mail failure must not make Stripe retry a webhook. So a customer whose card expired got a database row and silence: no letter, no banner, nothing. The first they knew of it was apps refusing to open a week later, when the grace period ran out. Two canonical templates, following the constant/dotted-key split the HR and e-signature families already use — billing.payment_failed and billing.service_suspended, seeded per-org like every other one. Both go to the BILLING address on the account rather than to a member, because the person who pays is routinely not a person who signs in, and both link straight to Stripe's hosted invoice: somebody whose access has just paused should not have to get into the product to pay for the product. The wording does the work the surfaces could not. The first says what is outstanding, what stops, and the exact day it stops. The second leads with "Nothing has been deleted", because that is the fear at that moment and no screen was answering it. The BillingService half — pointing DunningNotifier at /api/email/templates/send, which does exist and is behind InternalAuthFilter — lands separately.

  10. · kamo-internal

    A plan card says what it adds to the one below it

    The picker gave a price, a seat ceiling and one sentence. Choosing between $46 and $80 a seat on that is a decision with one obvious answer, and for most teams it is the wrong one — the catalogue has carried the whole capability matrix all along and the buying page never showed a line of it. Each card now names what its plan switches on that the previous one does not, four at a time with a count for the rest. Derived, not written down: planAddsOver reads the same feature rows /myplan renders, so a catalogue change moves both and neither can go stale. Names only — a feature the catalogue has not named is skipped rather than shown as SHARED_MAILBOXES to somebody who has never seen this product before.

  11. · kamo-internal

    One way to reach sales, and a seat control that knows the plan's limit

    ContactSalesDialog holds KamoCRM Inc.'s own details — Sales@KamoCRM.com and (949) 298-9960 x1 — as constants, deliberately. Every other address on this platform belongs to the tenant, and resolving this one the same way would send a customer asking about a KamoCRM plan back to their own organization's sales line. Both are live links as well as text, with copy buttons; the phone href carries the extension as ;ext=1 so a tap dials it. One component, used by the plan page and the console, because a phone number nobody notices is stale is exactly what two copies produce. The console's plan cards were wrong in three ways for the page that takes on new clients. Enterprise printed a firm $99 per seat — its plan_type is CUSTOM and kamocrm.com says "Custom", so one company was telling a prospect two different things — and it wore a "33% off applied" badge under a price it did not have. It now reads Priced on application, carries no figure, and offers the only thing that can actually move it forward: a conversation. Starter and Business never mentioned the one-time $99-per-seat startup fee, which is $495 on a five-seat order — the whole first invoice — quoted as if it were nothing. The seat stepper ran to 9,999 whatever plan was selected. **************** has always enforced the real ceilings, so someone could build a forty-seat Starter order, watch a total add up for it, and be refused at save with nothing pointing at the number that was wrong. It now stops at the plan's limit and says which plan set it and what carries more; it is disabled until a plan is chosen, since the ceiling is a property of that choice; and picking a smaller plan brings the count down with it rather than pricing five seats for twelve people until the server objects. clampSeatsToPlan is shared with the picker and tested, because the same rule has to hold when the SEATS move and when the PLAN does. Also: the primary CTA was MUI's default blue — on a white-label console, the one colour on the page belonging to nobody, on the single most important button.

  12. · kamo-internal

    A Numbers screen, so calls and texts can come from different companies

    An organization buys its numbers from one company and answers the calls with another. That is ordinary, and Kamo could not describe it: texting was a property of a member's PHONE SERVER, so an org whose DIDs and SMS came from Telnyx and whose calls were answered by their own FreePBX could not text at all. Settings → Phone → Numbers is where that is now said, and it sits between Providers and Extensions because that is the order the work happens in. A row reads as a sentence — "+1 (949) 555-1212 — Sarah Chen · Calls ring on Office PBX · Texts go through Telnyx" — not a grid of provider ids. The number and the person come first, because those are the two things an administrator actually knows. The decision worth defending, and the one NumbersTab.test.tsx exists to protect: a phone server that cannot text is OFFERED in the Texts dropdown, disabled, with the reason beside it. Filtering it out is the obvious implementation and it is the one that leaves somebody stuck — an administrator who cannot find their phone system in that list concludes the screen is broken, while one who reads "FreePBX cannot send text messages" knows to add a text provider instead. The reasons come from the server, where each provider writes its own. "Find my numbers" imports what the providers already know, because typing thirty DIDs correctly is not a thing to ask of anybody and a mistyped one fails silently. Telnyx joins both pickers. Its card spends most of its words on the webhook public key: it is on a different page of the Telnyx portal from the API key, and missing it has no symptom on sending — texts go out perfectly and every reply is refused. The card also says up front what Telnyx does not have, so that is a decision somebody makes before they commit rather than a discovery afterwards. Kamo ships no Telnyx logo; a tinted initial is honest about being ours, where something drawn to look like their mark would not be. Two things found while building it: - NumbersTab's reload depended on `t`, and useTranslations does not promise a stable identity — so the effect re-fired on every render and the screen never finished loading. Not an error, just a permanent spinner. The test caught it. - The settings menu and the provider label map both have coverage tests that demanded maintenance for a new tab and a new provider. Both were right.

  13. · VOIPService

    Route texts by NUMBER, and add Telnyx on both sides of it

    An organization can buy its DIDs and its texting from one company and answer the calls with another. Kamo could not express that: a member's texting was a property of their PHONE SERVER, so an org whose numbers came from Telnyx and whose calls were answered by their own FreePBX could not text at all — FreePBX declares sms(false), the gateway refused before any provider was reached, and the advice "add an SMS provider" led to a rail that could not open a conversation. So the question moved from the provider to the number. ORG_PHONE_NUMBER says who owns a number, what answers its calls and what carries its texts. TEXT_ROUTE_KIND sits beside the instance id rather than being inferred from it, because the two candidate tables are both UUID-keyed and handing an id to the wrong factory does not fail loudly — it fails as "no such instance", and the text is never sent. InboundRail already documented that hazard. TextRouter resolves in three tiers: the number's own route, then a default named by the phone server (so 200 DIDs on one PBX is one setting, not 200 rows), then the phone server itself — which is exactly today's behaviour, so an org that never opens the Numbers screen keeps working having configured nothing. The bulk rail finally inherits the send contract. TextSender has two adapters and SmsGateway talks to it, so BulkTextProvider.sendText — whose errorMessage is always some carrier's e.getMessage() — can no longer put a SocketTimeoutException in front of somebody trying to text a customer. And BulkTextInboundController now hands its messages to VoipMessageService.ingest instead of recording a keyword and stopping, so a reply on a carrier number becomes a conversation. Keyword handling did not move out; it moved DOWN, into the sequence every other rail already shares. VoipNumberOwnership gains SOURCE_ORG_NUMBER at highest precedence: the other two sources infer ownership from a column that happens to hold a number, while this one reads a row whose whole purpose is to say who owns it. Telnyx arrives as both a phone server and a carrier. As a phone server it is the first one honest about not being a PBX: no voicemail, no devices, no queues, no agent states — and every one of those false flags now carries a sentence a member can read, because a capability that is false with no written reason renders as a working-looking empty screen. That is what CapabilityLimit and PhoneCapability are for; SMS has had this treatment since smsUnavailableReason(), and the rest of the surface now has it too. Its softphone credential is minted with the ORG's own API key, so unlike RingCentral nobody has to onboard each member into a carrier portal. Two things worth knowing: - OrgPhoneNumberService takes PhoneProviderFactory @Lazy. The cycle is real and legitimate, and Spring Boot has forbidden circular references by default since 2.6 — without it the context does not start, and no unit test here would catch that, because they all wire their Docstors by hand. - A text-only org (numbers and a carrier, no phone server) can send but cannot thread a reply: VOIP_CONVERSATIONS.INSTANCE_ID is NOT NULL and part of the dedupe index. That drop is now an ERROR naming the number instead of a warn. ORG_PHONE_NUMBER's DDL is applied by hand — docs/sql/. InitializerService is not deployed in this cluster.

  14. · kamo-shared-library

    Telnyx, the first provider that is both a phone server and a carrier

    Telnyx sells SIP trunking, DIDs and messaging, and an organization can buy those halves independently. The case this is for: DIDs and texts bought from Telnyx, with the calls answered by the customer's own FreePBX. Collapsing it into one entry would force those to be the same decision, so TELNYX is appended to both VoipProviderType and BulkTextProviderType. Both enums are EnumType.ORDINAL, so both appends are at the end. The bulk-text one had that property documented in its javadoc and enforced by nothing; it now has the same ordinal test the phone enum has, asserted where somebody adding a provider is already being made to stop and think. Telnyx bills THIRD_PARTY: the org holds its own Telnyx account and pays Telnyx per minute and per message, so what Kamo bills for is the integration. An unassigned Telnyx line costs nothing, which is right — a telephony credential with no member behind it is one we are doing nothing with. No version bump. 17 services pin 1.6.0 and every service Dockerfile rebuilds this library from main, so a bump would leave all of them resolving a version that exists nowhere. Recent additions to types/ shipped the same way.

  15. · kamo-internal

    The shelf replaces the per-app cards, and four fixes to its rows

    The launchpad was a wall of one card per app. The App Shelf lists every one of those apps in a single pane, with what each does and everything inside it, so the grid of tiles was saying the same names twice. The cards are gone; what is left on the launchpad is the shelf, the widgets and the member's pinned notes. Re-pinning an unpinned app moved with them — that was the card's corner control, and the shelf's own pin is it now. HomeCardPinControl and appPanes went with the cards; LEAF_TOOL_KEYS was the one thing in appPanes that outlived them and now sits in navRegistry, beside the options it describes. Four fixes to the shelf itself: - The chevron comes BEFORE the pin, so every pin sits on the same vertical line whether or not its row opens onto anything. Verified at 8 rows, one x. - The opened half now grows: it is a flex ITEM of a row, so its main axis is the width and stretch only ever reached its height — which is why its rows stopped short of the column they had been given. - A lone Open App button is centred on both axes, and sized to its label rather than stretched across the panel. The list's 8px lead-in is dropped with it, or it is 8px of one-sided offset that stops the centring being centred. - That button now reads "Open App".

  16. · BillingService

    A plan's startup fee, and the live offer, on the wire

    PlanDto carried only the recurring rate, so every surface quoting a plan quoted a price the customer would not be charged: KamoCRM's startup fee is $99 a seat, which is $495 on a five-seat Starter order before a single month is billed. The public pricing page has always shown it; the in-app surfaces had no way to. The plan payload also now carries the offer running in the market, resolved by the same predicate that actually discounts an order. A page quoting list price beside a live 33%-off promotion is wrong in the customer's disfavour and one quoting the discount after it lapses is wrong in ours — neither is a number a page may decide for itself.

  17. · kamo-internal

    The report's thread in the drawer, shelving, and Issue Persists

    `SystemBugThread` renders everything anybody has said about a report and every status it has passed through, under the current-status callout: the callout answers "why is it here now", which is what most readers came for, and the transcript answers "how did it get here", which is what the fourth attempt at a fix needs. It reads **************** and not the media session endpoints every other transcript in the console uses. Those authorize by session membership; a bug thread is readable by everybody the REPORT is visible to, which is a far wider set than the people who have posted on it. One dialog now collects the comment for all three statuses that carry one. They differ in the wording and in whether the field may be left empty, and both are read off the status — so shelving and Issue Persists refuse a blank and a denial still does not. The heading follows suit: a denial explains a decision, a shelving explains a delay, and a failed fix reports what is still broken, and heading all three "Reason for denial" made the last one read as an apology. Issue Persists is its own button beside Fix Verified and its own endpoint, drawn from `canReportIssuePersists` — the server's answer, never derived. Its audience is wider than Fix Verified's and its departure statuses are different, so a client inferring it from `canManage` or from ownership would hide it from exactly the colleague it exists for. It is disabled in the triage dropdown for the same reason: reopening a report there would skip the reason the status exists to carry. `SystemBugDrawerTriage` gained a scoping fix worth naming — the thread's composer is a textarea on the same page and comes first in document order, and `[role="dialog"]` does not tell the two apart because the drawer carries that role too. An unscoped lookup typed the denial reason into the comment box and sent the denial out blank.

  18. · EmailService

    Preview variables for SYSTEM_BUG_COMMENT

    Without them the template editor renders the new letter with holes where the report number, the sender and the button go, which reads as a broken template rather than as a missing preview. No comment body among them, deliberately: the letter does not quote one. A report's thread is readable by everybody the report is visible to, but this letter is addressed to one person and forwards onward like any other mail.

  19. · InitializerService

    System_bug.media_session_guid, and its lookup index

    The column behind a report's comment thread, matching **************** — VARCHAR(36), because MEDIA_SESSIONS.GUID is itself a 36-character string column and a type mismatch on the join is found at query time by whoever is unlucky. Nullable, and no backfill. Seeding a session per existing report means writing four tables in the right order with the right guids, which belongs in the service that already knows how — `SystemBugService.ensureThread` does it on first touch. A null here means "not opened yet", never an error.

  20. · SecurityService

    Send To AI tells everybody on the thread, not just the reporter

    The dispatch sends the same letter MediaService does, so its audience has to move the same way: every participant in the report's thread minus whoever acted, each with their own greeting and their own timezone, rather than the reporter alone. The participant walk is duplicated here rather than shared, and the duplication is deliberate — this service has no MediaService client, and adding one so a dispatch can address an email would put an HTTP call on the path of the status write. The rule it repeats is two lines, and both sides go through the same `SystemBugStatusEmail` predicates.

  21. · MediaService

    The thread behind a report, and the two statuses that need one

    `SystemBugThreadService` is the whole feature: a report's MediaSession, the status events written into it with the reason their author gave, the comments anybody who can see the report may leave, replies, reactions and per-member mute. Every entry point is SystemBugController, never MediaController. That is not tidiness — the generic media endpoints authorize by session membership, and a bug thread is readable by everybody the REPORT is visible to, which is a far wider set than the people who have posted on it. Membership here means "has taken part" and exists to decide who is written to. Routing the thread through the bug's own visibility rule is what keeps that rule in the one place that owns it. Threads are created lazily and seeded, not backfilled. Reports existed before threads did, so `media_session_guid` is nullable and `ensureThread` fills it on first touch — writing a CREATED event stamped with the row's own creation time, and moving any `denial_reason` the row still carries into the transcript before clearing the column. Without that seeding, replacing the column with a thread would have silently thrown away the one sentence per report the old design did manage to keep. `thread()` is the writable read that does it; `detail()` stays read-only because it is the hot path. `reportIssuePersists` is the widest action on a report and the only capability that does not narrow by viewer: anybody who can see it may say a fix failed, because the person who walks into a broken fix is very often not the person who filed the report. It narrows by STATUS instead — a report that never claimed to be fixed has nothing to contradict. The status email now plans one letter per participant. `plans()` takes the audience as a parameter so the rule stays testable without a Spring context, and free-standing comments ride ChatEmailNoticeService's existing ledger under their own template key — one letter per recipient until read, digested, mute respected.

  22. · kamo-shared-library

    Shelving, failed fixes, and a report's whole comment history

    A report kept exactly one sentence. `system_bug.denial_reason` was overwritten by every subsequent status move, so a bug that was denied, explained, reopened, fixed and found still broken ended with one reason out of four and no way to read the rest. The obvious fix is a `system_bug_history` table with a note column. That table then has to grow threading, edit history, reactions, attachments, per-member mute and unread state — all of which the media-stream tables behind every chat and every support ticket already have. So a report gets a MediaSession instead, under a new `SYSTEM_BUG` type, exactly as `SupportTicket` already does: status changes are written in as system messages carrying the reason their author gave, comments as ordinary ones. Two statuses come with it. SHELVED is parked-not-refused, which `WONT_FIX` could not say — one is a queue position and the other is a decision, and a reporter told "we decided not to" about work that is merely waiting stops filing reports. ISSUE_PERSISTS is its counterweight: a shipped fix that did not hold. Both require a reason; a denial deliberately still does not, because forcing one there only teaches people to type a full stop. Membership on a bug thread does not mean what it means on a chat. The thread is readable by everybody the REPORT is visible to; `MediaSessionMember` rows mean "has taken part", and decide who is emailed and who has muted. That is why nothing here is served through MediaController, whose access rule IS membership. The status letter's audience follows: every participant minus the actor, rather than the reporter alone. The old "the reporter moved it, so send nothing" veto would have made Issue Persists notify nobody at all — least of all whoever wrote the fix that just failed.

  23. · kamo-internal

    A document outline, and a skeleton in the page's own shape

    Only the hero was a real heading. Everything else — the ledger's nine categories, the account panels, the covenant, each upgrade option — was a styled div, so a screen-reader user had one landmark on the longest page in the settings area and no way to move between categories. They are h2 and h3 now, and the ledger follows the ARIA accordion pattern with the heading WRAPPING its toggle: nested the other way the category is announced as part of the control and stops being somewhere you can jump to. The loading state was a centred spinner, which is the wrong instrument here. One request fills the whole screen, so the spinner sat in the middle of nothing and the finished layout then arrived at once and shifted everything under the reader. The skeleton holds the real proportions — the hero band with its rail, the long ledger column, the shorter column of panels beside it — so arrival is a fill rather than a jump. It carries no motion of its own beyond MUI's wave; the page has exactly one deliberate animation and it should not have to compete with what was on screen a moment earlier. Tests pin the outline and the accordion nesting, plus two things the ledger gets right and would silently stop getting right: the catalogue's "100.0000" rendered as 100, and a boolean feature never described as Unlimited.

  24. · kamo-internal

    Draw the god rules the server now holds

    The Users tab locked every god row for everybody, and both halves were wrong. Too loose, because the lock lived only in this component — the endpoints behind it answered a direct call from anyone holding MANAGE_USERS. Too tight, because it also locked out the one caller entitled to the edit. GodAccountGuard is the authority now, and the drawer reads callerIsGod from the same response as the row: locked for everyone else, live for a peer, and said out loud in the warning tone when it is live, because an editable god row looks ordinary. The synthetic flag was the one control that stayed live on a god row, and it is the heaviest on the screen — it stops the account authenticating at all. It moves onto the same gate as the rest. Impersonate is withdrawn on a god target. The server refuses it on both doors, and a button that is offered and then refuses reads as a fault rather than as the rule it is. accountIsGod rides up from the profile the way memberType already does, and the server tells only a god-eligible reader.

  25. · SecurityService

    Only a god may administer a god, and no god is impersonable

    MANAGE_USERS reached the break-glass identity. UserAdminController guarded only the System User, so a non-god holder could disable a god account and revoke its live sessions, rewrite its sign-in identifiers, flag it synthetic, or mail a password reset for it — every one of them a way to disarm the people who would otherwise undo it. The console already drew the row as untouchable and said so in a comment; nothing behind the screen agreed. GodAccountGuard applies the peerage rule PlatformAdministratorController already held over a god's platform RIGHTS to the account those rights hang on. The account was the weaker of the two doors: taking somebody's password is a superset of taking their rights. It guards all five write paths, and UserAccountEditPolicy's PLATFORM_ADMIN arm, which is the same right's other surface — the member profile's identity update and both personal-address endpoints. Reads stay open, so the console can explain the refusal. The target fails closed and the caller fails open, deliberately: a row that cannot be read is god, a row that does not exist is not, and a caller who cannot be resolved is not. Only ever lifting a restriction is what decides which way each side goes. Impersonation gets the same target refused, with no peerage at all. The cross-org door has always refused a god **************** the same-org door behind the settings page never did, which made it the cheaper of the two ways in. Impersonating a god hands the caller the platform's last way back in, so it admits no exception — not even for another god. Responses now carry callerIsGod, and the member payload carries accountIsGod for a god-eligible reader. Without them the console has to lock every god row for everybody, including the one caller entitled to the edit.

  26. · kamo-internal

    Staff can write the sentence the customer reads

    The customer-visible note had a column, a DTO field and a plan page ready to render it, and no way for anyone to set it — so Harmony's was the only one that would ever exist, and only because it was seeded by hand. It is a second box rather than a "show this to the customer" switch on the internal note. A switch invites reusing a sentence written for colleagues, and the internal note is where staff record what was conceded and to whom; it can only stay candid if publishing something is a separate act of writing. Empty stays the normal answer — an agreement with no customer note still renders in full from its structured terms. Both notes are now labelled in the list, because the difference between them is the whole point and an unlabelled second paragraph reads as more of the first.

  27. · kamo-internal

    The softphone gets an inbox, and an SMS window is one thread

    The SMS tool window opened on a twelve-key dialpad and then became a three-tab window -- Chat / Templates / CRM -- titled "Conversation #a3f1c8de-...". None of that is how anybody uses text messages, and two of the three tabs were not real: SmsCrmTab was DUMMY_CONTACT and DUMMY_ACTIVITY ("John Smith, Acme Corp", hardcoded, shown to every member about every customer) while a genuine reverse lookup already existed in SoftphoneCRM, and SmsTemplatesTab read a five-item constant while VOIPService's SmsTemplateController went uncalled by anything. There was also nowhere to SEE your texts: the window showed one conversation and could not reach another, so the only routes to a thread were an incoming notification popping a window at you, or the Interaction Center. The window - Two states, no tabs: a recipient picker, or a thread. Templates moved to the composer's bolt button on the real API; contact detail to the header's info pane rendering SoftphoneCRM. SmsCrmTab, SmsTemplatesTab, smsTemplateData and SmsNewConversation are deleted. - Compose is recipient-first and has NO message box. That is what makes the close-and-focus case safe: a window that closes in favour of one already open closes empty by construction, so "a tool window must never lose what a member typed" holds without any careful handling. - The contact pane swaps with `display`, never a conditional render -- that would destroy the composer every time somebody glanced at it, which is the failure the old tab bar kept producing. One conversation, one window The tool store has always deduped on props.conversationId -- un-minimize, raise, glow. It could not see an SMS window opened by PHONE NUMBER, because SmsTool resolved the id into local state and never told the store, so opening that conversation from anywhere else built a second window on top of the first. SmsTool now patches the id onto its own props (as ChatTool does for its sessionGuid) and titles the window with the contact. Picking a recipient settles three ways: create here, adopt here, or raise the window that has it and close this one. The softphone A fifth badged TEXTS tab, composed from what already existed for the Interaction Center -- useConversationRows' text bucket, ConversationRow, unreadForRow, openConversation -- plus search and a new-message button. The badge reads UnreadContext.bySurface.sms, already live over the socket, so it costs no fetch. Contact names ConversationRow admitted "there is no server-side reverse lookup from a number to a contact name, so a text row is titled by its number". New **************** mirrors mailboxDirectory's shape and indexes contacts by canonicalPhone, in the browser because the recipient search loads them anyway. One index re-titles the header, the TEXTS list and the navigator. Also fixed: SmsNotificationListener stamped every socket frame INBOUND and ignored the direction the event has always carried, so a member's own outbound text came back rendered as the customer's and raised a window at them for something they had just done. The window now gates the whole texting surface on GET /sms/capability instead of letting a Teams or FreePBX org discover it by pressing send into silence, and a refused send shows the provider's own sentence with the message marked Not delivered. Requires the voipservice commit that adds /sms/capability and the send outcome.

  28. · kamo-internal

    A Free Plan pill, and a page at /myplan behind both pills

    The trial pill lived twice — the nav rail and the launchpad — with the countdown, the member-type gate and the styling copied between them. Both gated on a live trial, so an organization whose trial had just ended saw nothing at all: no pill, no explanation, no sign that the product had become smaller. And neither pill went anywhere, so a member who understood the countdown had no next step. Now both states are defined once, in PlanPill, which keeps the rail's two shapes and the launchpad's third while deciding the MEANING in one place. It shows to every team member and LINKS only for someone who can act on the plan; for everyone else the tooltip is the whole feature, which is why every state has a read-only twin naming who to ask instead. /myplan answers where the organization stands, how long it has, what it can use, what a bigger plan adds, and the terms it negotiated. Three things worth keeping: The term rail is the hero, and it is one device with three meanings — a trial draining to its deadline, a paid plan moving through its billing period, an expired trial at its end. It is laid out at its true width and animated with a keyframe rather than from state, so a slow script or a failed hydration still shows the real figure instead of an empty bar. The ledger prints values, not ticks. The catalogue carries the real numbers — 100 contacts, two pipelines, 4 GB a seat, unlimited — and those are what an owner decides on. Excluded rows stay, faint and sorted below: they are the argument for upgrading, and hiding them would make the Free plan look complete. Upgrades are a difference, not a price grid. The reader already has a plan; their question is what they get that they do not have. Ordered by price — ordering by how many rows a plan lights up looks equivalent and is not, since an overlapping bigger plan can add fewer. Buying still happens in BillingConsole, which this deep-links into by sub-tab. Rebuilding checkout here would be a second implementation of the one thing that must not have two. The palette follows the house convention — a --plan-* ramp mixed from --color-primary — with one exception: the state colours are fixed, because an organization whose brand is amber would otherwise wear a trial badge in its paid colour, and the one thing this page exists to say would be the thing it could not say.

  29. · BillingService

    One endpoint behind the customer's own plan page

    GET /api/billing/self/plan answers everything /myplan renders: the plan, what the organization can actually use, the catalogue to compare against, seats and their roster, storage, renewal, and any negotiated agreement. One request rather than six, for a reason beyond the loading states: the access tier cannot honestly be decided in the browser. Deciding it there means listing the Account's members, and a member may only list them if they already hold the access being decided. So it is decided here, twice — the client gate decides what to OFFER, this decides what may be read — and a SELF_PAY caller receives a payload the seat roster, organization totals and agreement were never put into. Withholding beats hiding: someone reading the network tab of a page they are allowed to open should learn nothing they were not allowed to be told. The trial is the trap worth naming. A trial subscription sits on plan_code = FREE, because TrialAdminService converts the free-default row in place, while EntitlementService grants the entire catalogue unlimited. Serving the plan's own feature rows during a trial would tell a customer they do not have AI, e-signatures or SSO while they are in the middle of using all three — in the week they are deciding whether to buy. So an active trial reports the union of the catalogue as included. A PENDING trial deliberately does not: its clock has not started, and promising otherwise is a lie the product contradicts on first click. The agreement is returned derived, never transcribed. The prose is customerVisibleNote and only that; internal notes never leave the service. And the next seat's cost is a number from SetupFees.forAddedSeats — the same code that raises the charge — rather than a sentence describing the rule, because a sentence is a second implementation that will eventually disagree with the first.

  30. · SecurityService

    Say which plan an organization is on, and who may change it

    The console could tell that a trial was running and nothing else. entitlementSource is "TRIAL" or null, so free, expired-trial, paid and comped organizations were all the same value — which is why an organization whose trial had just ended saw no pill, no explanation, and no sign that the product had become smaller. planMode names the state and planAccessTier names who may act on it. Both ride the entitlement payload every page already loads, so the pills that read them cost no extra request on any route — which matters, because the nav rail renders on all of them. entitlementSource is deliberately untouched. It has two values today and is read by two pill sites plus, pre-authentication, **************** which paints the login and register screens; widening it would change what all three mean at once. Precedence is PAID > COMP > TRIAL_ACTIVE > TRIAL_PENDING > TRIAL_EXPIRED > FREE over subscriptions filtered by the shared isLive rule — the same one BillingService's EntitlementModeResolver uses, so the two cannot disagree about a PAST_DUE account inside its grace period. It is load-bearing: a FREE_DEFAULT row is seeded at signup and is never removed when a plan is bought, so "the first row found" reports FREE for most paying customers. The tier is resolved here rather than in the browser because the browser cannot resolve it honestly: deciding it from account-membership rows means listing members, which already requires the access being decided. A delegate counts only on an Account funding THIS organization — platform staff routinely administer Accounts targeting elsewhere.

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