Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-shared-library
Bill the whole term up front, and say so on the quote
An org that charges up front saw the monthly rate on a three-year deal. The setting deciding that already existed — **************** — but it reached only QuoteConversionService, at the moment of conversion. The document itself was always priced as though the customer paid per period. applyOptionHeadlineTotals now branches on the basis: FULL_CONTRACT takes contractTotal (which already carries the setup fee, so it must not be added twice), FIRST_PERIOD_PLUS_SETUP keeps the first invoice. periodTotal and monthlyEquivalent are untouched under both — the savings badges compare against monthlyEquivalent, and moving it would change every yardstick on the page. The basis is STAMPED onto the quote when it is raised rather than resolved live. A quote states terms, and an administrator flipping the org switch next week must not restate them on a document a customer already holds. Reading the org setting at conversion time did exactly that: it re-priced quotes that were already accepted. Conversion now reads the quote's own stamp, and revise carries it across so a revision cannot silently re-base a live negotiation. The column is nullable because every existing row predates it; null resolves to FIRST_PERIOD_PLUS_SETUP, which is what those quotes were sent under, so nothing already in a customer's hands changes value.
· DaemonService
Tick SecurityService's scheduled-report sweep
Every five minutes. A schedule is set to the minute, so a finer tick buys nothing a member would notice and a coarser one makes an 8:00 report arrive at 8:15. DaemonService owns this for the same reason it owns the legal-package and lead-email sweeps: replicas: 1 with strategy Recreate means the old pod is gone before the new one starts, so a deploy window cannot double-fire. A @Scheduled in SecurityService, which rolls with surge, provably can — and there is no ShedLock anywhere in this fleet, so the pod count is the whole guarantee. @DisallowConcurrentExecution because a long sweep must not have a second one started on top of it: both would find the same schedules due, since nextRunAt is only advanced once the first has claimed them, and the same report would go out twice. Explicit timeouts for the same reason the sibling jobs have them — a default RestTemplate waits forever, and a pinned thread here means the sweep never runs again until the pod restarts, which is indistinguishable from "nothing was due". Nothing about a report crosses this process: the sweep returns counts, never rows.
· SecurityService
Run the reports that are due, and send them
One internal endpoint, X-Internal-Auth gated and constant-time compared, that DaemonService ticks. The cron stays in the daemon because it is replicas: 1 with strategy Recreate, so a deploy window cannot double-fire; a @Scheduled in a service that rolls with surge provably can, and there is no ShedLock anywhere in this fleet. For a job whose output is an organization's figures emailed to a list of addresses, "usually once" is not a property. Every schedule runs as its OWNER, with rights recomputed at run time from their stored grants — not as the daemon, which has none, and not as whoever last edited the schedule. So revoking somebody's access stops their schedules producing data they should no longer see at the very next run, rather than whenever a session expires. Without that, a schedule is a way to have the server read data on your behalf that you could not read yourself. nextRunAt is advanced BEFORE delivery is attempted. A crash mid-delivery then costs one missed send rather than an unbounded loop of duplicates, and a missed weekly report is a far cheaper failure than the same report arriving forty times. The rendered file goes as an attachment rather than a link, because these are routinely sent to a client or an accountant with no account. One bad address does not cost the other recipients their report, but a run where every address failed is recorded FAILED rather than delivered-to-nobody. An empty report is not sent unless the owner asked for it: a weekly email that always arrives stops being read.
· EmailService
Let a transactional send carry one attachment
Added for scheduled reports, which are a rendered PDF or workbook that has to reach people who may have no account — a link would not be a delivery for them. The SMTP layer has taken attachments since it was written; this path simply passed null. So nothing about how mail is built changes, the field is optional, and every existing caller takes exactly the route it always did. Base64 rather than multipart because this endpoint is JSON server-to-server and every caller already builds a JSON body. Oversized and malformed attachments are REFUSED rather than truncated: a bounced 40MB workbook costs the sending domain reputation, and a silently truncated one is a file the recipient cannot open and cannot explain. The throw is what lets the caller record a failed run instead of a delivered one.
· kamo-shared-library
The email a scheduled report arrives in
REPORT_SCHEDULED, registered in CANONICAL_KEYS — absent from that list a template is a permanent 404, whatever files exist beside it. The body states the data scope. A recipient who is not the schedule's owner has no other way to tell a whole-organization total from one covering only what the owner can see, and those are different documents. It also names the owner twice: once as who set the schedule up, and once as whose figures these are.
· SecurityService
The lists a share dialog picks from, and a share target that fits every id
GET **************** returns the organization's people, departments and roles as picker options, in one call and in one shape. Built on the reporting option providers rather than on the members, departments and roles endpoints: those each answer a different screen's question and return a different shape, and a share dialog consuming all three would break whenever any of them changed for reasons unrelated to reporting. Gated on SHARE_REPORTS — the list of everybody in the organization is not something to hand to a member with no reason to see it. A share target is now a string throughout. A member id is a 19-digit Long and a department or role id is a UUID; the Long column this started with could hold half the kinds, and the half it could not would have failed by silently matching nobody. The department and role checks compare as text and resolve by membership NOW rather than by what a session happened to hold when the share was made. Every one of the three membership checks fails closed. A share that cannot be evaluated does not grant.
· kamo-internal
Step through every search hit, not only the newest
The search at the top of a chat window marked every occurrence of the query and then scrolled to exactly one of them — the newest — with nothing to say how many others there were and no way to reach them. A member searching a conversation for an invoice number could see the word highlighted three screens up and had no control that would take them to it. It now behaves as the find bar it already resembles: a counter reading "3/12", an arrow toward the older messages and one toward the newer, Enter and Shift+Enter for the same two moves, and the hit being stood on drawn in solid amber against the yellow of the rest. Landing on the newest hit is unchanged, so a member who only ever typed a query and read the result sees what they always did, now with the count beside it. Two decisions underneath are worth spelling out. Hits are counted per occurrence rather than per message, and against the words the bubbles actually draw — the translation where a message has one, which the old search did not do. The counter and the marks are two views of one list produced by a single scan (findOccurrences, shared with the highlighter), so "12" is always exactly the number of marks on screen. Two scans that disagreed even slightly would leave arrows landing on nothing. paged: messages arrive at the bottom while a search is open and older pages land at the as "result 3" would quietly become a different message. It follows that "1 of 3" at the oldest hit does not mean there is nothing older — only that nothing older has been fetched — so stepping back from there asks for the previous page and lands on what it brings, the same move the list already makes when somebody scrolls to the top. It wraps to the newest only once the server has run out, or after five pages have arrived carrying no hits at all: one press of an arrow must not be able to walk a years-long conversation back to its first message. The toolbar's resting geometry is untouched. The counter and arrows appear with the query rather than with the field, because they cost about sixty pixels and an empty search box has nothing to count.
· kamo-shared-library
A service-work report, and a share target that fits every id
ServiceWorkReport is the tenth seed report and the one that makes row-level scope real rather than merely present. Commerce carries the platform's own/all pair — VIEW_OWN_SERVICE_WORK sits beside VIEW_SERVICE_WORK as a sibling — and a visit carries assignedMemberId, so a technician holding only the narrower right sees their own work while their supervisor sees the branch. One definition, two answers, and nothing invented for reporting. Without it SELF and TEAM would be untested paths in the part of this system it is least acceptable to leave untested. Rows are visits, not jobs: a job with three visits is three pieces of work, possibly by three people on three days, and collapsing them makes "who did what, when" unanswerable. ReportViewShare.targetId becomes target_key, a string. A member id is a 19-digit Long but a department and a role are UUIDs, so an INT8 column could hold half the share kinds — and the half it could not would have failed by silently matching nobody. Renamed rather than retyped because Hibernate's schema update adds a column and does not retype one; the old column was never written to. LeadPipelineConversionReport now applies the lead grid's own unassigned floor. Without it the report is a second, unaudited way to see the unassigned pool, and its group counts disagree with /leads for no visible reason.
· kamo-internal
Pin the well-known folders to the top of the sidebar
Inbox, Drafts, Sent, Junk, Trash and Archive stop moving. They hold that order at the top of the folder list, and everything the member made sits underneath under a "My Folders" heading, still freely reorderable. The pinned rows are not registered with dnd-kit at all rather than refusing the drop: a row that merely decoders to move is still a drop target, so dropping a folder onto the inbox would animate the whole list and then silently put it back. They keep the grip column as empty space, so both groups share one left edge instead of reading as two lists. Pinning is deliberately narrower than isSystemFolder, which matches the leaf at any depth so a member's own Clients.Archive draws the archive icon. That is the right call for an icon and the wrong one for a position, so pinnedRank looks at roots only — a folder someone filed under their own parent stays theirs to arrange, as do the subfolders of a pinned folder. Spam sits in Junk's slot. The backend normalises to Junk wherever it writes, so a server listing "Spam" is naming the same folder and should not fall in among the member's own. The rank lives in folderTargets, which already owns the shared folder vocabulary, so the move picker orders its roots the same way and the two surfaces cannot drift apart again. applyFolderOrder moves to lib/email beside it: vitest's include globs do not cover app/hooks, so ordering logic left there was collected by nothing and passed by never running. Members who dragged their inbox before this change still name it in their saved preference. Those entries are ignored, and the next drag writes them out.
· kamo-marketing
Rebuild the index as a map of the product
It was one recursive list of all three hundred articles, indented by depth. Everything was on the page and nothing was findable, and the page weighed 3.9 MB. The shape is borrowed from the product. A KamoCRM customer's front door is the Home launchpad -- a grid of areas you pick from -- so the help center's front door is the same gesture, and somebody arriving already knows how to read it. The tiles are a scale map rather than a card grid. Each lists what is actually inside it with real counts, so a tile is as tall as its area is big: Client Relations Manager has eight sections and forty-nine articles and looks it, next to Calendar with five. Equal-height cards with an icon apiece would be tidier and would misrepresent the product -- and sixteen outline glyphs differentiate sixteen areas far worse than sixteen tables of contents do. Areas that have no sections list their articles instead, so no tile says only its own name. Search is the primary action and now sits above everything, ranked (a hit at the start of a title beats one later, which beats one in the body) and grouped by area, because "which part of the product is this in" is most of the answer. Matches are marked in place. A starting-point strip for a first-time visitor, deliberately unnumbered: the three read as a sequence and are not one, since the third is an administrator's job most members will never do. Counts and eyebrows are set in the mono face already in the brand, and the beige secondary -- which the site had almost stopped using -- carries the structure. That keeps the page from being monochrome green without introducing anything that is not already KamoCRM. Category and section pages also stop being dead ends: "In this section" now renders the links the server was never sending, two-up with excerpts and a count where a section has one.
· kamo-internal
The Report Studio — a library, and one renderer for every report
/reports is a shelf, not a card grid: two hundred identical rounded boxes is a wall to scan, and a row gives a report's name room to be read. Saved views nest under the report they belong to, which is what teaches the distinction the whole subsystem rests on — there is no "New report" control anywhere, because a report is a Java class and what a member saves here is a configuration of one. The studio is three bands: what you are asking, one sentence saying what is on screen, and the answer. The middle band is load-bearing. A report screen fails when somebody can see figures and cannot say what they are figures OF, and every question in that category — how many rows, over what period, narrowed how, and covering how much of the organization — fits in a sentence. The data-scope note sits there verbatim, because a total produced under "your own records only" is a different document and nothing else on screen distinguishes them. Nothing in app/components/reports asks which report it is rendering. visibleStudioControls reads a definition's declared capabilities and draws the matching controls; ParameterControl dispatches on a parameter's type; every cell renders through its column's ReportFormat descriptor. A KPI-only report gets a period and a Run button and nothing else, without a line of code that knows it exists — and the day this stops being true, adding a report stops being a Java change. formatters.ts is the one place a value becomes a string, and its Java twin reads the same descriptor, so a figure on screen and the figure in a downloaded PDF cannot disagree. Dates go through app/lib/serverTime.ts rather than a second parser: Java writes LocalDateTime with no offset and a browser reads that as local time, which is hours out. The chart palette is fixed hex, not derived from --color-primary. Every other colour here is a tint of the organization's brand, which is right for chrome and wrong for a chart — a pale white-label primary would give a series palette nobody could tell apart. The order was searched for and validated rather than eyeballed: all eight pass the lightness band, chroma floor, CVD separation, normal-vision floor and contrast checks on this app's light surface, which is the only surface it has. --report-* follows the --legal-* and --training-* pattern exactly, and its seal is --accent-work, which the Instrument Panel palette already names as the accent for "tasks, calendar, reports". Reports takes its place in the nav, the AppType mirror, the Organization flags and the permission tree. Its five rights are ungrantable without that last one.
· SecurityService
Run, export and schedule the Report Studio's reports
The service half of the framework: the catalogue, the run, the pickers, four exporters, saved views and schedules. Authorization is explicit on every path, because this service runs permitAll at the filter chain. Two rights are always in play — VIEW_REPORTS opens the module, and the report's own domain right decides whether that report may be run and how much of the organization it covers. VIEW_REPORTS alone admits nobody to any data, which is what makes the app safe to switch on for a tenant. ReportScopeResolver reads the platform's existing OWN/ALL sibling rights and nothing invented for reporting. A report naming no narrower right is organization-wide and says so in a note the studio and every export print verbatim — a total produced under "your own records only" is not the same document as one produced over the whole organization, and a reader cannot tell them apart otherwise. sanitise() is the boundary that keeps a saved view from becoming a user-defined report: a request can only choose among what the definition already declared. It strips unknown columns, grouping levels and parameters, but deliberately leaves an unknown column in a FILTER for the query engine to refuse — dropping a hidden column changes the presentation, dropping a filter changes the answer. Exports read the same ReportResult the screen was drawn from and format through the same ReportFormat descriptor, so a figure in a PDF and the cell it came from cannot disagree. The workbook writes numbers as numbers with a format mask rather than as pre-formatted strings, which is the difference between a spreadsheet and a CSV with a different extension. The PDF carries the organization, the period, the data scope, who ran it and page numbers. A scheduled report runs as its OWNER, with rights recomputed at run time, so revoking access stops a schedule at its next run rather than whenever a session expires. nextRunAt advances before delivery: there is no ShedLock in this fleet and replicas: 1 is all that prevents a double send. pdfbox and poi-ooxml are added HERE and not to kamo-shared-library, which every service component-scans — they would otherwise ship into ~60 images that never render a report. kamo.reporting.enabled is set in this service's ConfigMap and nowhere else; that property is what stops a report bean being constructed in services that cannot satisfy it. Also renames a stale entry in **************** ec2ae5e renamed **************** to #syncLabels without renaming its baseline line, which failed the ratchet on origin/main in both directions at once. The handler does resolve a caller — getOrganizationFromRequest, 401 when null — so this is a rename, not a widening.
· kamo-shared-library
The Report Studio framework, and nine reports built on it
A report is now a Java class here and nothing anywhere else. ReportDefinition describes what a report is, what it may be asked, which columns it has and how each should be read; the frontend renders whatever that declares, so adding a report needs no endpoint, no page and no TypeScript. The pieces that carry the weight: - ReportCapability is why the frontend can be generic. A report that declares only FAVORITE and SCHEDULE gets no filter builder, no column manager and no grouping panel — ManagementOverviewReport exists partly as the canary for that. - ReportFormat is a descriptor, never a rendered string, so a currency in the table, the chart tooltip, the CSV and the PDF cannot disagree. - ReportSecurityScope comes from each report's OWN domain rights, never from a reporting right. A sales report widens on VIEW_ALL_LEADS exactly as the lead grid does, so reporting can never become a way around the security model. - ReportQuerySupport puts the scope predicate first, rejects an unknown column key instead of dropping it, and left-joins every association — an implicit join is an INNER one, and the rows with nothing on the other side are the ones somebody is looking for. - ReportAggregator keeps a null group as its own group, so subtotals still add up to the grand total. - Dates resolve in the MEMBER's timezone and are half open. At 03:30 UTC on 1 September a member in Los Angeles is still in August. Every report class carries @Component AND @ConditionalOnProperty. That is not decoration: every service scans com.kamo but declares its own explicit @EnableJpaRepositories list, so a bare @Component needing an EntityManager is constructed in all of them and CrashLoops the ones that cannot satisfy it. The property is set only in SecurityService, and ReportBeansAreConditionalTest fails the build if a report forgets it. Reports is an app (ServiceType id 32) with five rights (295-299). VIEW_REPORTS is the module door and grants no data by itself. The names VIEW_REPORTS and EXPORT_REPORTS existed at ids 1 and 2 until 2026-08-14, when they were removed along with the stub pages behind them; rights persist as getId(), so no grant made before then can resolve to these. PhiModule.REPORTING is PERMITTED — the engine reads in-cluster and stores no copy. Exporting and scheduling do leave the boundary and are governed by their own rights and by EMAIL.
· kamo-internal
The importer, and three things it taught us
Publishes the corpus by driving the real KBService API rather than writing rows -- a direct INSERT skips the version row, the kb.article.published event that is the only thing indexing an article for the AI Assistant, and the translation fan-out. An import that looked successful and left "Train AI With This Article" doing nothing on three hundred articles is the kind of failure nobody notices for a month. Three defects it surfaced, all fixed here: 19-digit ids must never touch a JavaScript number. Number('1168485648209608710') is 1168485648209608700. A session minted with that org id authenticates perfectly and resolves to an organization that does not exist -- the overview came back empty and read as "the knowledge base is empty". The same trap a second time on the article uid, where reorder answered 403 because the parent id had been rounded and matched nothing. Session ids are now written into the JSON as raw literals, and article ids are read out of the response text before JSON.parse can touch them. A category's null parent is the section, not the root. Sending null would have published sixteen sibling top-level articles, and the marketing site -- which asks for the tree below help-center -- would have shown an empty page. kubectl port-forward does not survive a few thousand requests. Transport failures now retry with backoff; HTTP errors do not, because repeating a 403 four times only takes longer to fail. The author is Sage, who already authors the Help Center article. An article's author and last editor come from the session that wrote it and are shown by name on every article in the console.
· kamo-shared-library
Persist a market's quote-contact rule, and refuse a half-stated one
The DTOs carried quoteContactMode and quoteContactMemberId but no writer read them, so the setting round-tripped through the form and was silently dropped. Both the create and update paths now apply it. The member is kept only under EXPLICIT and cleared otherwise, so a market that switches back does not leave a person behind to be reinstated by a later flip, and EXPLICIT naming nobody is rejected rather than quietly downgraded — the quiet version prints a different name on the next quote than whoever saved the form expected. The lookup is org-scoped: Member ids are globally unique, so an unscoped one would let a tenant name another tenant's staff on its own document.
· kamo-internal
Choose who a quote names as the customer's contact
The market dialog gains "Quote's Future Contact" under Enable quotes: the lead's assigned agent, a choice made per quote, or one explicit person picked with the same AgentSelector the /leads filter uses. Under Adjustable Per Quote the editor grows a Contact section, seeded from the snapshot the server took when the quote was raised — which is the lead's agent, so it opens on the person already there — and saves the change with the rest of the quote. The three client-side decisions moved out of two large components into app/lib/quotes/quoteContact.ts. They were asked in both screens and the screens disagreeing is invisible until a document prints the wrong person; as one module they are also testable, which inline ternaries in a 1300-line dialog were not. The preview shows a newly picked contact by name with the title, phone and email blank: those are resolved server-side from that member's mailbox and extension, so carrying the previous person's number under a new name would be a lie the unsaved-changes banner does not excuse.
· SecurityService
Honour a market's contact rule wherever a quote names somebody
The staff detail, the staff preview and the customer's own page now resolve the contact through one method, so they cannot disagree about who is printed on the same document. Under ASSIGNED_AGENT that is the lead's agent resolved per read, as before; under the other two modes it is the snapshot the quote carries, with the lead's agent as the fallback for a quote raised before the setting existed or one naming somebody who has since left. Create stamps the snapshot from the market rather than from the request body, and update applies a new contact only when the market runs ADJUSTABLE_PER_QUOTE and the member belongs to the org — a market that names one explicit contact means it, and a hand-made PATCH is how that promise would otherwise be broken. QuoteController's five handlers built customer/agent/adjustable separately; they now share one staffView, which is what keeps the three consistent.
· kamo-internal
The article corpus, and the pipeline that builds it
307 articles across 16 categories and 29 sections, from the 248 draft articles under Documentation/ plus 59 written here: every category and section landing page, a Getting Started set covering the onboarding the drafts never reached, a Platform category the source folder only ever had a README for, and the two member-creation articles that were simply missing. Four pieces: lib/markdown-to-lexical Markdown to the shape the knowledge base stores. The node set is a whitelist of what both renderers already handle, because the public renderer draws an unknown node as a bare div -- no error, no warning, a paragraph that has quietly lost its styling on a page nobody is watching. Lists were the hard part: a blank line must not end one (the drafts have screenshot gaps between steps, and ending there restarts the numbering at 1) and an indented item is a sub-list, not a sentence appended to the step above it. lib/clean-source What a draft must stop saying before it is published. Permission constants become the words on the screen; parentheticals naming a .tsx file or an entity field go entirely; the review checkbox and the draft-method callout go; and cross-references are re-titled rather than deleted, so "see How do I view my timecard?" points at the article's published name instead of one that no longer exists. lib/titles Question titles to published ones. How-tos take a rule; the 53 concepts are named by hand, because a rule that could turn "What's the difference between Status and Sub-Status?" into a page name would have more exceptions than cases. Slugs are checked for collisions across the whole tree before an import runs -- the database constrains them per organization, not per parent. build-relations The see-also graph, built undirected because links are symmetric in the product. 594 links, 3 to 6 per article. Seeded from the drafts' own related: front matter, which somebody chose while writing; topped up from position in the tree. Text similarity ranks candidates but never qualifies one on its own -- "Notes" and "How to Search and Filter Your Leads" share every word about searching and have nothing to do with each other. No page is related to its own parent or child: a section already lists its articles, and an article already has breadcrumbs. 57 tests, and vitest's include list grows a line so they actually run.
· kamo-shared-library
A market decides who a quote names as the customer's contact
QuoteContactMode gives a LeadMarket three answers: the lead's assigned agent (resolved per read, which is what every quote does today and what a null column means), one explicit person, or a choice made on each quote. The latter two are snapshotted onto Quote.contactMemberId when the quote is raised, so changing a market's mind never rewrites the contact a customer was already given, and a revision carries the snapshot forward — it is the same offer. QuoteService takes the resolved contact as an argument rather than reading it off the request body: this module reaches the lead only by soft id, and a value a client could set would let anyone be named on a document. Update honours it only when the caller passes one, which is how ADJUSTABLE_PER_QUOTE is enforced server-side rather than by the editor hiding a field. agentForMember is named rather than overloaded — agentFor(null, ...) would be ambiguous at any call site passing a literal null — and the lead-taking form now delegates to it, so the two cannot drift on a printed document.
· InitializerService
Columns for a market's quote contact and the per-quote snapshot
A market names its answer in QUOTE_CONTACT_MODE (+ QUOTE_CONTACT_MEMBER_ID when that answer is one explicit person), and each quote keeps the contact it was raised with in **************** Both nullable with no default: a null mode reads as the assigned agent and a null snapshot falls back to it, so every existing market and quote is unchanged until someone chooses otherwise.
· kamo-internal
Curate an article's related articles
KbRelatedPanel is one component in three places: it curates in the composer and the editor, and reads back on the article view. Adding an article here also adds this one to that article, and the panel says so -- otherwise the second half of the write looks like something editing one article should not have done. Two modes, because links can be chosen before the article exists. With a guid the panel owns its state and writes every change straight through: a reorder lost to a forgotten Save is a reorder nobody makes twice. Without one, the composer holds the selection and PUTs it after create, as a separate call for the same reason submit-for-review is one -- a link that fails to save should leave the written article behind rather than take it down with it. Suggestions come from the server, which ranks siblings ahead of articles that merely share vocabulary, and filters them through the same reader scope as any other read. The panel keeps the server's answer rather than the list it sent, so a guid the server dropped is not shown as saved.
· KBService
Related articles, and a public path that keeps the tree
Three things, all reaching the same page. Related articles. KbArticleRelationService owns the symmetric "see also" links: list, set, suggest, and a clear that runs on soft delete so a retired article does not linger in somebody else's list and silently shorten it. Reads go through KbReaderScope -- an unfiltered relation list names the titles of exactly the articles the audience flags hide, which is the oracle readable() answers 404 to avoid, arriving through a different door. The public read is narrower still: published and public-visible only, so a marketing page cannot link a visitor into member-only material. Public path. KbPublicArticleDTO now carries path and ancestors. path is the slug chain BELOW the section root, so /help-center/help-center is unrepresentable and a consumer can build a nested link instead of a flat one. This is not cosmetic: article lookup resolves on the last path segment, so a link built from the bare slug still renders the right page and the lost hierarchy shows up nowhere until somebody reads the sitemap. One read instead of four. buildPublicTreeIndex replaces the separate buildUidToGuidMap / buildPublicChildrenMap scans -- a single article page was making three full-table reads plus a fourth for its parent, all projections of the same list. Breadcrumbs and related cards now translate with the article rather than staying English underneath a translated body. 98 tests, 30 of them new: path shape and cycles in KbPublicPathTest, mirror maintenance and scope filtering in KbArticleRelationServiceTest.
· InitializerService
Migration for KB_ARTICLE_RELATIONS
Mirrors KbArticleRelation in kamo-shared-library. Written explicitly rather than left to ddl-auto so the UID and DATE_CREATED defaults live in the database, which is what lets a plain SQL insert work without naming them. The unique index is on the ordered pair, not the unordered one: (A,B) and (B,A) are both expected to exist, and constraining the unordered pair would reject the mirror row the service writes on every link. Already applied to production by running these statements as the kamo app user; they are idempotent, so a later full KI run is a no-op.
· kamo-shared-library
Article-to-article relations
KB_ARTICLE_RELATIONS holds the curated "see also" links between articles in one knowledge base. Rows are per direction and the pair is kept symmetric by KbArticleRelationService, which is the only thing permitted to write them -- a half-written pair shows the link on one article and not the other, and is invisible from the side that still has its row. Two rows rather than one so each direction carries its own SORT_ORDER: an article orders the list under it without disturbing the other end, and every read stays a single indexed lookup on ARTICLE_ID. The DDL is already landed **************** in KamoInitializerService, applied to Yugabyte as the kamo app user).
· SecurityService
Page and search the org directory, and stop two row builders drifting
The networks endpoints answer with the whole set in one body. That is fine for a member of a dozen orgs and impossible for an operator on an estate meant to reach to show twenty. Adds GET /org/network-directory, which matches, orders and windows in the database and returns one page. scope=all is gated on exactly the check all-networks applies — SYSTEM_USER through PlatformRightsService — so all three routes to the right keep working and the list can never be wider than the action behind it. The same answer rides back as canEnterAsSystemMember, so a client builds its rows and its buttons from one reply. my-networks and all-networks had a hand-written row builder each, and the copies had already drifted: all-networks never emitted primaryColor, logoUrl or logoSimpleUrl, so /network drew every org an operator was not a member of in the console default accent rather than the org's own. Nothing reported it — the keys were simply absent and the client's fallback did the rest. All three now share OrgNetworkRowBuilder.
· kamo-marketing
Starter base up $10 on every term
69 / 59 / 49, from 59 / 49 / 39. The commitment ladder is untouched by design — still exactly $10 off for a year and $20 for three — which is the standard every surface now states in dollars rather than as a percentage. Fallbacks in five files move with it, and check-price-tokens retires the superseded figures so nobody pastes an old Starter price back into a comparison page. The three metadata descriptions quoted "$39/seat/mo" as the from-price; the number a search result shows is a price claim like any other. The bundle comment in PricingClient keeps its $53/$59 figures but is now dated: they are the reason for a past decision, not a live quote, and an undated stale price in a comment reads as a bug to the next person. Verified against the live catalog rather than the fallbacks: Starter's three-year seat renders Was $49 / Now $32.83, and the homepage strip agrees with /pricing.
· kamo-internal
The running offer, on Plans & billing and on the launchpad
The console quoted list prices while kamocrm.com advertised a third off the same plan, because nothing in kamo-internal could see that an offer existed. It now reads the resolved offer from BillingService and shows what it does to the price: a banner above the plan picker with a countdown and what it saves on the order in hand, and each covered plan card struck through from its committed rate to the offered one. The estimate below already prices what the card shows, so the two cannot disagree. The launchpad gains a one-line pill beside the trial pill — to its LEFT, and shown whether or not the trial pill is up, since an organization past its trial with no subscription is exactly who the offer is for. It is gated on being able to act on it: a member who may buy their own seat, or an owner who may buy the organization's, and who is not already paying. That last test reads coverage.source, not coverage.paidBy — paidBy says WHO covers the seat, and somebody sitting on the organization's free-default subscription reads ORGANIZATION while being on no paid plan at all. Offering an upgrade to someone already paying is the fastest way to make a banner invisible. planOffer.ts holds the arithmetic so the pill and the console cannot drift, and because these are numbers people spend money on: the percentage comes off the rate for the term COMMITTED to, never the month-to-month list price, matching **************** 18 tests cover that ordering, the seat and term scaling, that an uncovered plan is left alone, and that the end date is the last day the code works rather than the first day it is not. check-server-timestamps caught the expiry parse: `new Date(\`${d}T23:59:59Z\`)` is explicit UTC and correct, but the guard cannot tell it from the naive-string bug it exists to catch, and a guard people learn to argue with is worse than the bug. It goes through parseServerDate now, which keeps a date-only value at UTC midnight — so the deadline is that plus a day, which is also clearer about why.
· BillingService
Serve the running offer, and say whether a seat is actually PAID for
Adds GET /markets/{id}/promotion. There was no way for an in-app buying surface to know an offer was running at all — the marketing site read promotions through the public catalog proxy, and kamo-internal had no equivalent, so the Plans & billing console quoted list prices while kamocrm.com advertised a third off the same plan. It returns the offer RESOLVED, not the raw row. Every consumer of the raw shape has had to re-derive the same three things — whether it is live today, which plans it covers by code rather than by UUID, and which cycles — and every one that did so got at least one wrong: the marketing site widened a two-plan code into an every-plan code by comparing UUIDs against plan codes, and its hero filtered on the discount TYPE and silently advertised nothing for months. Liveness comes from SubscriptionPromotions.check, the same predicate calculatePrice applies when it actually discounts an order, so a surface cannot advertise a code checkout will decoder. Plans come back in display order, because the surfaces write them into a sentence and "Business and Starter" reads as though the tiers ran downhill. /self/coverage now also returns `source`. `paidBy` names WHO covers a seat, which is a different question from whether anything is being paid for it: a member on the organization's free-default subscription reads paidBy=ORGANIZATION and is on no paid plan at all. Anything offering an upgrade has to be able to tell those apart, and paidBy cannot.
· kamo-marketing
The offer reaches the hero and the pricing strip
The hero had a countdown ribbon already, and it had been rendering NOTHING since the catalog moved to a percentage: `pickHeroOffer` filtered on FLAT_PER_SEAT because the banner's copy was "$18/seat every month for 12 months". A second, narrower definition of "what offer is running" was the whole problem, so it is gone — the hero, the pricing page, the offer page, the calculator and now the homepage strip all read the one `PagePromotion` resolvePromotion returns. The ribbon is re-keyed to the percentage, links to /pricing/offer, and counts down to the END of the last day (midnight ran it out a day early). It carries the ember accent the offer wears on every other surface, so it is recognisable as the promotion before a word of it is read; its CTA ink is measured against that gradient rather than left white, which is under 4.5:1 at both ends. The homepage strip now renders the same PlanPrice component /pricing does — was, now, and what you save — instead of its own hand-rolled ladder over annualPrice. Both open on HOME_TERM, named once in plan-pricing.ts because three surfaces were defaulting independently, and the strip's CTAs carry the code on the plans the offer covers. Plan ids on a resolved promotion now come back in CATALOG order. The API returns them in query order — Business before Starter — and the hero writes them into a sentence, so it read "on every seat of Business and Starter", as though the tiers ran downhill. check-i18n-keys could not see Hero.tsx AT ALL: it binds `const [t, slideshow] = await **************** ...])` and the guard only understood `const t = ...`. Three missing keys in the site's most-read component, invisible to the guard whose entire purpose is catching a raw key path rendering as visible text. It now understands the destructured form, and — more importantly — a file that clearly translates but yields no namespace is REPORTED rather than skipped, so the next new binding shape costs a guard update instead of shipping "home.heroOfferCta" into the hero. Coverage went from 1044 keys to 1073. check-plan-pricing grows to 55 checks, covering how an offer's plans are written out and resolvePromotion against the shape the API actually returns — including that a promotion whose plans fail to resolve is refused rather than widened. Verified against the live catalog rather than the fallback: homepage and /pricing both render $0 / $26.13 / $66.33 / Custom.
· kamo-marketing
A three-term commitment rail, and a promotion the page can prove
New prices: Starter $59/$49/$39 and Business $119/$109/$99 per seat per month on monthly, yearly and three-year terms. The rail replaces the monthly/annual pair and its "Save 18%" pill — a percentage was the wrong unit for a flat $10/$20 per seat, so the pill quoted whichever plan `find` happened to return first, and a toggle cannot express three options anyway. Its thumb is a padlock that closes as the term lengthens, which is the caption above it ("Lock in your rate today!") made literal. The offer band states the running promotion once, above the cards, and says it is applied automatically. That is true rather than decorative: the plan CTAs now carry ?code=, which RegisterFlow already reads into sp_checkout_code, and only on plans the offer actually covers — sending AUTUMN into checkout from the Free or Enterprise card would promise a discount the server then decoders. Starter and Business cards show Was / Now / what you save, and the promotion comes off the TERM rate, not the list price — commit first, then take the percentage off the already-reduced number, matching **************** Four surfaces were each doing this arithmetic themselves and disagreeing, so it moves into app/lib/plan-pricing.ts and they all read the same answer. The calculator's percentage promo was charging against add-ons, mailboxes, extensions and storage as well as seats, so its estimate came out below what the server bills; it is now scoped to the plan seats like the server's. /pricing/offer is rebuilt around whatever promotion the catalog carries. It had "18DOFF / $18 off Business" hardcoded in the client AND in its page fallback AND across nine dictionary strings, and the catalog had moved past all three. Also: package-lock.json was out of sync with package.json (@swc/helpers missing), so `npm ci` — which is what the Dockerfile runs — failed at main. The image build was broken independently of this work.

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