Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-internal
Add the Policies tab, and stop offering chats the backend refuses
Four views over one draft ruleset, because there are four different questions and no single layout answers them all. Rules is what each rule does, Matrix is what the organization looks like, Simulator is what happens to two named people, Activity is what the policy is doing to everyone else. A rule is a sentence, not a form. An administrator assembling meaning out of six labelled dropdowns will misread their own policy; one reading "Sales may not chat with everyone in a different department" will not. Every editable phrase is a chip, and the sentence functions live in app/lib so they can be unit-tested -- the vocabulary crosses that boundary as data rather than as a translator, since vitest runs them in the node environment. The two ends of a one-way rule are said separately on purpose. "Sales may start chats with Engineering, who can reply only while that window stays open" describes what the engine does; any single symmetric phrasing would describe a rule that does not exist. Ordering is what first-match-wins costs, so the board pays it down: the impact of the draft is checked continuously against what is actually live, shadowed and duplicate rules are named under the rule they affect, and publishing shows how many pairs it blocks before the button. New rules and every template arrive in audit mode, where they record what they would have blocked and block nothing. The roster is annotated, not filtered. A blocked member keeps their name, photo and presence, and their chat button explains itself instead of disappearing. Also creates /api/members/directory, which ChatBox's add-members picker has been fetching since it was written -- the route never existed, so the request 404'd and the dialog rendered an empty list with nothing reporting a problem.
· InitializerService
Create the chat policy schema
Six tables: the versioned ruleset, its rules and their clauses, the reply grants behind "reply only while their window is open", and the trail of what the policy refused. Every statement is written to be complete and idempotent, because this runner is applied by hand as often as it is run. Pushing this repo deploys nothing -- there is no CI and no workload -- while MediaService and SecurityService both run ddl-auto:none, so nothing creates these tables at their startup either. If the DDL has not landed first, those services deploy cleanly and then throw on every policy read. The DDL for this change was applied to Yugabyte ahead of both service pushes, and verified by running the engine's own two snapshot queries against it rather than by checking the catalogue. The accompanying test compares the entities to these statements by reflection. Inside the initializer Hibernate creates the tables from the entities before any runner executes, so a column missing here is invisible on this side and fatal on the other.
· SecurityService
Annotate the member roster with its chat policy verdict
SecurityService serves the roster the chat UI is built from, so it has to answer with the same verdicts MediaService enforces with -- one shared evaluator, or the product offers conversations the backend then refuses and it reads as a broken chat rather than as a policy. The roster is annotated, never filtered. A member the policy blocks keeps their name, photo and presence and loses only the chat affordance, which then carries the organization's own explanation. Hiding them would have been less work and would have made the roster lie about who works here. Verdicts for the whole roster resolve in one pass off one cached snapshot. Asking per member would have put an evaluation -- and on a cold cache, a query -- inside a loop that already runs once per colleague. A policy read that fails leaves every row unannotated rather than failing the roster: the send path is still the authority, so the cost is a confusing affordance, not an unenforced policy.
· MediaService
Enforce the org chat policy, and close two holes it sat on
Applies the policy at the three places a conversation can start or continue: session creation, member adds, and the send path. A room is not policed pair by pair -- the invite gate IS the direct-message gate, so you may bring someone into a room exactly when you could have messaged them, and a room that shrinks back to two blocked members is re-read as a direct message. Two pre-existing holes were directly under this work and are fixed with it. The send endpoint never checked that the sender belonged to the session, so any authenticated member could post into any session guid they could guess -- a policy gate would have been decorative without it. And typing indicators were published to a bare /topic/chat/typing that every connected client in the organization subscribed to; scoping them per session stops the leak and stops a separated pair from signalling to each other. Reply grants back "reply only while their window is open": the browser reports what it can see and the server decides, with a grace that outlives a missed beat and not a closed laptop. Their broadcast is per member and guarded, since a forged grant would unlock a composer the policy locked. The admin write is its own bean and its own transaction. On YugabyteDB a read after a write in one transaction raises 40001 and aborts everything around it, and a self-invoked @Transactional is not a boundary at all -- so the save reads before, writes, and reads again only once it has committed.
· kamo-shared-library
Add the org chat policy engine
Decides whether one member may open a conversation with another, and whether they may send into one that exists. Rules are an ordered list over ordered pairs and the first match decides, so every verdict names exactly one rule -- which is what lets the settings UI explain itself instead of asking an administrator to trust it. The orientation rule is the load-bearing part. A rule written SUBJECT -> TARGET is evaluated from both ends: forward the actor is the side it is written from, reverse the side it is written about. Without that, "Sales may message Engineering one-way" would match when a salesperson acts and fall through to whatever sits below when an engineer does -- doing nothing in the only direction it exists to restrict. Absence is never agreement. A department, title or manager clause cannot match a plain Member, and inapplicability short-circuits ahead of negation, so "not in Sales" does not sweep in every subscriber who has no department at all. The evaluator holds no repository and opens no session: two queries build a per-org snapshot, and every verdict after that is set arithmetic. Resolving a manager chain through JPA would have been a query per link per member at unbounded depth, on a path that runs whenever a cache goes cold.
· kamo-internal
Reorder the tabs and paint Binders and Uploads
The strip reads Originals | Modified | Common | Binders | Uploads, which puts the three views of the documents together and the two destinations after them. Binders and Uploads now carry that reading visually: brown and blue backgrounds with white labels and icons. The `&&&` in paintedTab is load-bearing. The parent Tabs styles its children through `& .MuiTab-root`, a descendant selector that outranks a plain per-tab sx, so a single `&` loses the cascade and the label stays grey on the new background.
· kamo-internal
Say when a selected document could not be added
The Selected Docs binder skips a document it cannot legally hold rather than failing the whole sync, which is right — but it did so silently, so a binder holding fewer documents than the member checked read as the feature being broken, and the only place the reason existed was the DocsService log.
· kamo-internal
Fold the Modify tab into Binders
The strip is now Originals | Binders | Modified | Common | Uploads. Modify is gone: everything it did — arrange the checked documents, preview them, merge them into one PDF or a ZIP — the organizer already did better, and it did it for a list you could keep. What connects them is a pinned "Selected Docs" entry at the top of the binder list. It holds whatever is checked in the grid and is rewritten on every selection change, so a member checks documents in Originals, opens Binders, and finds them there in order. It cannot be renamed, deleted or shared; the button that saves it, "Save As Binder", is the same duplicate the editor already had, relabelled — on any other binder it reads "Duplicate This Binder", and both make a new binder of the member's own holding everything the open one holds. Downloading now offers ZIP alongside the merged PDF, which is the last thing the Modify tab could do that a binder could not. Modified drops PublishedWithChanges for Difference, two documents side by side, and the toolbar's dead "Prepare & Download" button now opens the binder its label was always describing.
· DocsService
Serve and sync the Selected Docs binder
POST /api/docs/binders/scratch fetches the caller's Selected Docs binder for one collection, creating it on first use, and replaces its documents with the grid selection in the same round trip — the organizer calls this on every selection change, and two calls per checkbox is the difference between a binder that keeps up with the grid and one that visibly lags it. The sync holds each still-selected document at the position the member arranged it into and appends the new ones; separators and generated pages are never touched, because they are the member's own additions rather than a reflection of the grid. A document that cannot legally join the binder is skipped rather than failing the whole sync — a grid selection is not curated, so one bad row must not stop the other twenty from arriving. The ordering rules are a pure function so they can be tested without a database. Rename, delete, share and template promotion are refused for it, and the rules for what may become a DOC item now live in one helper so adding by hand and syncing cannot drift apart on what they accept.
· InitializerService
Add binders.is_scratch
Four statements rather than one ALTER: binders is populated, and Yugabyte rejects the whole ADD COLUMN … NOT NULL statement rather than the clause, so the column arrives nullable, is backfilled, gains its DEFAULT, and only then is tightened. @Order(0) per the column-add convention — the shared-lib entity now maps is_scratch, so any runner that loads a Binder before this one dies on a column that does not exist yet.
· kamo-shared-library
Mark the member's auto-managed Selected Docs binder
binders.is_scratch flags the one binder per (owner, collection) whose documents are the DocManager grid selection rather than a curated list. It is an ordinary binder in every other respect, so the list query has to exclude it by hand: the organizer pins it above the list, and leaving it in would put it there twice and — worse — offer it in every "add to binder" picker. The lookup returns a list rather than an Optional because two tabs opening the organizer together can both miss and both insert; the caller takes the oldest.
· kamo-internal
Assemble the lead record instead of holding it behind a spinner
The Details tab's loading state was one card of fourteen grey MUI pulse rows — a shape the pane never takes, on a per-element clock, replaced wholesale by a layout it looks nothing like. And it waited on `loading.insights`: a record that had already arrived sat behind placeholders until an unrelated communications count came back. The pane now shows its own layout while it waits — four stat tiles, the callbacks strip, two rows of three cards, at the real widths — under one band of brand light crossing the page on a diagonal, and the real cards hand off from their ghosts on that same order, so the record resolves along the path the light travelled. Only the record gates the pane; the counts and the task list resolve inside their own tiles, and the market's panes extend the entrance order when its configuration lands. The masthead joins the same sequence: a name-shaped ghost instead of the word "Loading", and a three-segment rail on its bottom edge — record, activity, configuration — where a segment fills white the moment its fetch lands. Real arrivals, not a timer. It is absolutely positioned so the tab underline stays flush with the content it labels. A skeleton that never resolves is the worst end state a loading design has, so a failed record now gives way to what happened and a Retry that re-runs the opening fetches, on the Details and Mortgage tabs both. The `kmail-*` choreography in globals.css becomes the house `kload-*` family — same rules, same reduced-motion contract, plus `kload-settle` for the handoff and the rail's segments. /messages, /notes and /network move over unchanged. No new i18n keys.
· SecurityService
Ask EmailService to back-fill a lead's past e-mail
Implements the shared library's LeadEmailBackfillTrigger, so ticking "Associate Past E-Mails w/ Lead" on a lead save reaches the only service that can read mailboxes. Two hazards handled here rather than in LeadService. It waits for the commit: the back-fill matches against the contact point the save is still writing, and fired inline it would run against a row no other connection can see yet, find nothing, and report success. And it never makes the member wait: even accepting the job is a hop to another service that may be down, so it goes out on a background thread with short timeouts. The worst outcome available to the member is a lead that saved normally.
· EmailService
Scan the org's mailboxes for a lead's past e-mail on request
Serves the "Associate Past E-Mails w/ Lead" tick-box. Given a lead and the address just recorded on it, read INBOX and Sent of every mailbox in that lead's organisation back a year and attach anything carrying the address. Every guard the automatic path applies still applies, because both paths now share one classify(): an org whose addresses cannot be classified is refused, a purely internal thread is refused, and a company address is never a match candidate — so ticking the box on a lead whose e-mail is noreply@<org-domain> still associates nothing rather than handing that lead the company's whole mail stream. On top of those, the lead's own address has to actually be on the message. Runs off the request thread and single-threaded: a whole organisation's mailboxes take far longer to read than a lead save may block for, and the mail server allows only a handful of connections per user for the entire pod, so concurrency here would starve the live sweep rather than go faster. The endpoint answers 202 "queued", never "found something" — the member watches the Emails tab fill in. Folder selection moves to MailboxFolderScope so the sweep and the back-fill cannot drift on where mail lives; a timeline whose contents depended on which path found a message first would be worse than either.
· kamo-shared-library
Let a lead claim mail that predates its e-mail address
Matching is point-in-time by design — a contact point only claims traffic inside its window, so editing a lead cannot retroactively rewrite history. The cost is that a lead entered after the conversation started can never show that conversation, and the member has no way to say "this address was already theirs". linkHistoric is that way. It widens exactly one thing, the window, and nothing else: the address must still be a contact point of that exact lead, in that exact org, in some window. It cannot invent a match, cross a tenant, or reach a lead that never held the address — the tests pin each of those, because all three fail silently, as mail attached to the wrong lead reads as a lead who really did write to us. LeadService gains the seam that carries the member's intent (an optional LeadEmailBackfillTrigger, absent in services with no mail access) and both request DTOs gain the flag. The flag is deliberately not persisted: it describes one save, and every existing caller — imports, intake endpoints, MeridianLink patches, the lock sweeper's auto-save — sends nothing and so acquires no mailbox scan it never asked for.
· kamo-internal
Offer to associate past e-mail when a lead's address is set
Mail is matched to a lead strictly point-in-time: an address only claims traffic from the moment it is recorded, so that editing a lead today cannot rewrite whose mail last week was. That is right for the automatic path and wrong for the case a member actually hits — entering a lead they have already been e-mailing, then finding the Emails tab empty and the thread apparently lost. So both places a lead's own address can be set — the contact pane on /leads/view/{id} and the new-lead form — now show an unticked "Associate Past E-Mails w/ Lead" box beside it. Ticking it asks the server to go looking; leaving it alone changes nothing. The box appears only once an address has been typed, and never remembers being ticked: it describes one save, not the lead, so it is not persisted and the whitelist builder omits it entirely when false rather than sending a false the lock sweeper's auto-save would replay as a choice.
· kamo-internal
One agent picker for assignment, reservations and the filter
The Assigned Agent control on a lead's assignment pane was a local component in LeadViewClient. Two other places wanted exactly it, so it moves to **************** and takes the picker dialog with it. The assignment pane is unchanged in look and behaviour — it just no longer owns the code. Reserve Opportunity — a member holding EDIT_OPPORTUNITIES, the right that gates managing opportunities on /leads/opportunities/reserved, now gets a Sales agent field naming who the reservation is for. It opens on themselves, so filing their own request costs nothing extra. Unlike the lead's assigned agent it cannot be emptied: a reservation is somebody's claim, and an unowned one is not a state the record can hold. That is the whole of `clearable={false}` — no clear button on the field, and no Remove in the picker. The /leads Agent filter was a Select fed by a fetch of the entire staff directory, which is where its options came from and why it could only ever offer one page of an org that may run to five figures of people. It moves onto the same picker, which searches a page at a time, so the filter can now reach every name — and the whole-directory fetch, its module cache and its ACTIVE filter are gone with it, the same cleanup the assignment pane already did. That filter's useful answers include three that are not people, so MemberPickerDialog grows `quickOptions`: rows pinned above the results, here Myself first, then All Leads, then Unassigned Leads for a member allowed to see those. Myself leads because it is the answer wanted most often and the one a directory search makes hardest to reach — otherwise you type your own name and pick yourself out of the namesakes. The **************** sentinels move into leadGridQuery beside the mapping that reads them. They were string literals in both places, and a drift between the two would not fail to compile: the query would treat the unrecognised sentinel as a member id, filter on a member nobody is, and show an empty grid — which reads as "you have no leads" rather than as a bug. The session carries a member id and an email but no display name, so seeding the reserve form with "you" resolves it once through the directory search. The match is on the member id, never on the email that produced the page: a shared mailbox returns several people, and taking the first would file an opportunity for a colleague who never asked for it. Also drops getAgentFilterMemberId, which had no callers and parseInt'd a 19-digit member id.
· SecurityService
Accept a reservation raised for another agent
POST **************** now honours onBehalfOfMemberId, gated on EDIT_OPPORTUNITIES — the right that gates managing opportunities on the reserved list, and already the right needed to move an opportunity between agents after the fact. CREATE_OPPORTUNITIES alone still only files your own. A caller without it that names an agent is REFUSED rather than having the field dropped. Silently recording the reservation under their own name would attribute a claim to somebody who never made it, and would show the manager a request they believe they filed for someone else. The named agent is validated against commissionEligibleMemberIds — the same VIEW_LEADS pool applyPrimaryAgent uses and the same pool the picker offers. Parking a reservation on somebody who cannot open the lead it reserves is unworkable for them. The history row's actor stays the person who acted; the note says who it was for. And because REQUEST_SUBMITTED goes to approvers, the agent it was filed for is added explicitly — they are not an approver, so otherwise the first they would hear of a claim in their name is its approval or denial. Requires the KamoInitializerService migration adding CREATED_BY_MEMBER_ID to have run first, or the first read of any reservation throws.
· InitializerService
Add ****************
Records who filed a reservation when that is not the agent it names, so somebody managing opportunities can raise one on a sales agent's behalf without the record having to claim the agent did it themselves. Nullable with no backfill, for the reason PRIMARY_AGENT_MEMBER_ID took none: null means "the requester filed it themselves", every row predating this column genuinely was self-filed, and copying the requester in would invent a fact and put a "Filed by" line on history nobody performed. No index — nothing filters or sorts on it, so one would cost every write and buy no read. This must be deployed and run BEFORE the SecurityService change that maps the column: services run ddl-auto:none, and Hibernate names every mapped column in its SELECT, so the service would start healthy and then throw on the first read of any reservation.
· kamo-shared-library
Let a manager raise a reservation for an agent
Somebody who manages opportunities can now file a reservation request on a sales agent's behalf. CreateReservationRequest carries onBehalfOfMemberId for it. The named agent becomes REQUESTED_BY_MEMBER_ID, not the person who filed it. Every downstream rule keys off that column — who may ask for an extension, who the commission follows, what the reserved list's Agent filter matches — so the claim has to sit where those rules look, and it genuinely is the agent's claim. Who sat at the keyboard goes in a new CREATED_BY_MEMBER_ID rather than being folded into the same column, so neither fact has to be inferred from the other. Nullable, and null keeps its own meaning: the requester filed it themselves. PRIMARY_AGENT_MEMBER_ID is deliberately left null on this path. It means "an approver moved this off the agent who asked", and the reserved list renders an amber "Reassigned from" line for it — but a request filed for an agent was never moved off anyone, so `reassigned` stays false and `onBehalfOf` says the true thing instead.
· kamo-internal
Hide the balance controls that free-for-all cannot use
Credit Balance, Daily Allotment and Max Balance all act on a granted balance, and an FFA product has none — acceptance there mints and spends its own credit. Leaving them editable invited a manager to set numbers that could never bind. They now render as a muted chip with a tooltip saying so. Max Daily Spend stays editable and is the real lever over an FFA product: the backend now counts an FFA acceptance as a spend, so the cap applies there too.
· kamo-shared-library
Make free-for-all acceptance spendable, so it can be capped
FFA bypassed the credit system outright, which left a manager with no lever over it: Max Daily Spend sat behind if (!ffa) and could not cap an FFA product, and "accepted today" had no rows to count because nothing was ever spent. An FFA acceptance now mints a credit and spends it on the lead in the same block as the claim, so the spend record always exists and can never drift from the assignment. Max Daily Spend moves out of the credit-only branch and applies to both paths — free-for-all means the member was not granted a balance to pay from, not that a manager cannot bound how many they take in a day. Deliberately still credit-only: the org-wide credit switch and the balance check. FFA has no balance to be short of. The cap is checked AFTER those, so a credit member who is both broke and capped keeps reporting INSUFFICIENT_CREDITS. Note AcceptResult.creditSpent is now true on the FFA path (it was documented as always false). It is accurate — a credit really is consumed — and it reaches the accept response and the publishAssigned NATS payload. LeadAssignmentType still records AUTO_ASSIGN for FFA, so history keeps the two apart.
· kamo-internal
Name the requestor, move the org line, shorten the Open Org button
Three adjustments to the support surfaces. "Open org as System User" is now "Open Org", sitting immediately after assign/reassign rather than at the end of the row — it belongs to the same question those answer: this is the other thing you do when a ticket lands on your desk and the answer is inside the customer's tenant. The tooltip still spells out that the tab opens as the System User, which the short label drops. It keeps its weight; it is a door into somebody else's business and should not read like a sibling of the priority dropdown. The organization moves out of the ticket header and onto the left of the "+ New Ticket" row, which leaves the header for the ticket itself. With several tabs open the tab bar carries the new-ticket control instead, so the row renders for the organization alone — each tab can be a different customer, and that is exactly when it is worth saying. The window's own title now reads "{person} - {org}", built in one place because the visitor-rename path pushes a title too and a second expression is how the org quietly falls off the moment an anonymous visitor gives their name. Chats → Support rows are titled after the person who ASKED, always. Opening a ticket joins its chat, so a busy one accumulates agents, and titling from the other participants renamed the row every time a colleague looked at it — the same conversation showing up as a different person's. The requestor's name now agrees with the face already on the row.
· kamo-internal
Say which clock the event dialog is on, and show the start time
Four things the composer got wrong, on /calendar and on the lead page alike. The zone was the browser's. That is a fact about the laptop, not about the member: someone whose profile says Eastern and who is working from Denver still books their day in Eastern, and the dialog seeded "3pm Mountain" with nothing on screen to reveal it. New events now compose in the member's own profile zone, resolved through SecurityService's timezone/effective — which has existed since August and had no Next route in front of it, so every call 404'd inside Next and fell back to UTC. The zone is named on the value, under the picker, and beside the date in the header, on the offset actually in force on the event's date rather than today's. The start and end fields sat side by side with an arrow between them and no labels at all, two unannounced boxes sharing the card's width — at that width a datetime-local input has less room than its own contents need, so the reading was clipped. With the card headed "Start time" and the only visible words being the Today / Tomorrow / All-day buttons beside it, nothing on screen said a start time was there to be adjusted. They are stacked full width now, each named, and the card is headed "When". A new event started at the top of the current hour, which is in the past for fifty-nine minutes out of every sixty; the lead page's callback added an hour and kept the odd minutes, offering 4:37pm. Both now use defaultEventStart — an hour out, rounded up to the quarter hour. The default calendar was whatever the service listed first, and the service lists the organisation's shared calendar first on purpose, because that is the right reading order in the sidebar where it is a filter. Here the first entry is also the default, so a new event was published org-wide unless the member noticed and opted out of a decision they never made. The member's own primary calendar leads now, and the shared one carries an "org-wide" badge. The lead callback composer no longer seeds the lead's zone silently; it offers it as a labelled one-tap switch beside the member's own. Its composer context is memoised, which is load-bearing rather than tidy — built inline it was a fresh object every render, so calendars arriving from the network mid-typing reset the dialog and discarded what the member had entered.
· SecurityService
Publish Hibernate statement counts to Prometheus
/actuator/prometheus already carried jdbc_* and hikaricp_* but no hibernate_*, because Spring Boot 3 only auto-configures those metrics when **************** is on the classpath. Added, plus hibernate.generate_statistics. The reason is concrete. Auditing this service for N+1 queries had to be done by grepping for association navigations, and that method is wrong in both directions: it over-counts, because an association name like 'vendor' or 'user' is lazy on some entities and eager on others (9 of 67 reported sites turned out to be eager and needed nothing), and it under-counts badly, because it sees .getX().getY() but not iteration over a lazy collection — which is most of the real surface. hibernate_statements_total answers the same question by measurement. An endpoint issuing 200 statements per call is visible whatever shape the code takes, and open-in-view being on means every one of those lazy loads is otherwise silent. Costs a few percent. Worth it in a service where the last audit found four separate O(rows) endpoints.
· kamo-internal
Show the requestor's photo on each support chat row
Every row in Chats → Support wore the same blue support glyph, which is no help at all when the list is what you scan to decide what to pick up. The face is now the person who asked. The glyph survives for exactly one case, and it is a fact from the server rather than a guess: an anonymous web-chat visitor has no member record, so the ticket names the org's SYSTEM member — the owner — as its requestor. Showing that photo would put the wrong person on the conversation, and on the platform's own marketing widget it would be the face of whoever is reading the list. The viewer's org directory is still consulted first, because it is the live copy — a member who changed their photo a moment ago is already right there — and the server's URL is the fallback that covers requestors in other organizations, which that directory has never heard of. Initials deliberately do NOT fall back to the row's title: on a support row the title is the counterpart, so a requestor the server could not name would be given the support agent's initials, on the avatar that exists to say who is asking. With neither a name nor a photo there is no person to draw, and the glyph is honest.
· MediaService
Put the requestor's face and name on each support chat row
A Chats → Support row showed the same support glyph on every line, so a queue of them was a wall of identical blue circles. The face people actually scan for is whoever ASKED, so the row now carries the requestor's id, display name and photo. The photo is resolved here rather than left to the client, which is the whole difficulty: every other avatar in this feed is backfilled from the viewer's own organization directory, and a support requestor is — on the platform's desk — always somebody else's member, so that directory returns nothing for exactly the rows this is for. Two batched reads cover a page, in the same spirit as the visitor pre-fetch alongside them. The avatar row is re-read by id instead of being taken off member.getAvatar(): Avatar is a JOINED hierarchy and the association is lazy, so what that returns is a proxy of the BASE type. instanceof AvatarPhoto is false through it even after initialization, and every uploaded photo would be looked for at the Avatar Creator vector path. Loading by id gives the concrete subclass and its file extension. requestorIsAmbiguous is the case the glyph survives for, and it is one specific case rather than a hedge: an anonymous web-chat visitor has no member record, so the ticket names the org's SYSTEM member — the owner — as its requestor. Their photo would put the wrong person on the conversation, and on the platform's own marketing widget it would be the face of whoever is reading the list.
· kamo-internal
Pool count on the product header, accepted-today per member
Leads Available was rendered in every member row of a product group, but it is a property of the (product, market) pool — the same number repeated down the column for as many members as hold rules. It moves to the group header, once, where it belongs. The freed column now shows what was actually missing: how many leads each member has accepted so far today, rendered as "3 / 10" against their Max Daily Spend so remaining headroom is readable at a glance. The count comes from /manage, which resolves "today" the same way the cap does — the member's own timezone.
· kamo-shared-library
Resolve a member's photo URL from the shared library
The Chats → Support list is about to show the requestor's face, and a support requestor is — on the platform's own desk — always somebody else's member. Every other avatar in that feed is backfilled on the client from the VIEWER'S org directory, which has never heard of them, so this one has to be resolved server-side by MediaService. That means a second service naming an avatar object, and the object path is exactly the thing that must not exist twice: it used to be prefixed with whatever host the request arrived on, so a photo uploaded on one org's domain 404'd when read from another, degrading to initials and looking like "no photo" rather than like a bug. So AvatarObjectPaths moves here and SecurityService's copy becomes a delegating alias holding no strings of its own. MemberAvatarUrls adds the rest of the resolution: member-override before user default, photo vs Avatar Creator decided by the DISCRIMINATOR before instanceof — a lazily loaded avatar is a proxy of the base type, so instanceof would send every uploaded photo to the vector path — and host → theme domain. A host that is not a domain yields no URL rather than a guess: no image degrades to initials, a broken one does not. Also adds REQUESTOR_ID to the two support chat-feed projections.
· SecurityService
Return each member's accepted-today count per product
Manage-Credits shows Max Daily Spend but nothing about how much of it is used, so a manager could not tell whether a member was one lead from their cap or had not started. /manage now returns acceptedToday keyed memberId::vendorProductId. "Today" is resolved exactly as CreditLeadAssignmentService resolves it when enforcing the cap — the MEMBER's own timezone, start-of-day to now, counting the same LeadCredit.dateSpent rows — so the figure is literally the number the cap is tested against rather than a second opinion about what a day is. One grouped query per member, not one per member x product. Note FFA products report 0: acceptance there spends no credit, and the cap does not apply to them either (the whole credit block is behind if (!ffa)), so the two columns stay consistent.

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