Changelog
What's new.
Every improvement to the platform, as it ships.
· SecurityService
An owner's storage position, carried on a payload that already exists
The launchpad is getting a storage meter, and the obvious way to feed it is a request of its own on every page load. The numbers do not deserve one: "used" comes from a snapshot the nightly sweep took last night, so a dedicated endpoint would be a round trip to re-fetch a figure that cannot have changed since. /api/security/entitlements/me is already one request per tab, shared by eleven consumers and cached fifteen minutes in Redis behind a ~60s browser cache. Riding it costs the launchpad nothing at all. And nothing had to be wired to do it: SecurityService already lists both com.kamo.z.shared.storage.usage and com.kamo.z.shared.commerce on its @EnableJpaRepositories, so StorageQuotaService has been live here the whole time — quota, trend and the purchasable block are five small indexed reads on a cache miss, and only for an owner. The field is computed ONLY for an organization owner, in both build paths. Withholding it on the server rather than hiding it in the browser is the same choice /self/plan makes about seats and renewal: a member who opens the network tab learns nothing about what the organization is holding or paying for. `asOf` is nullable and that is load-bearing. Null means the sweep has never run for this org, so usage is zero because nobody has looked — a client that reads it as an honest zero draws an empty bar for an organization that may be full.
· kamo-shared-library
How fast an organization's storage is growing
The snapshot table has recorded a daily position per organization since metering began, and nothing read two rows of it together. So the only question storage could answer was "how full are you", never "when do you run out" — and the first is a warning that arrives on the day it is already too late. StorageTrend differences the current snapshot against the oldest one inside a seven-day window and reports bytes per day. Two small indexed reads against rows the nightly sweep has already written; never a pass over the files themselves. Everything it REFUSES to answer matters more than the arithmetic. A negative rate is not a forecast that storage will empty itself — an org that deleted a folder last Tuesday has not established a trend, and a signed rate puts "full in -40 days" on a launchpad. One snapshot is not a span. A sweep that missed four nights widens the span rather than cramming five days of growth into one. And growth smaller than the span floors to zero bytes a day, which is why isKnown() exists rather than leaving a caller to divide by it and print "full in Infinity days".
· MediaService
An approval settles the member's achievement
Approving a submission now tells SecurityService to recount that member's SPREAD_THE_WORD standing, so the trophy lands in the same second as the decision rather than on the member's next page load. `GrowthAchievementClient` mirrors `MemberNotificationClient`: this service owns the Growth Hub, SecurityService owns the achievement spine, and the producer asks the owner rather than writing into it. It sends WHO, never how many — the other end recounts the approved rows itself, so nothing here can overstate a member's standing. Two things about it are load-bearing. It fires from afterCommit. The recount runs in SecurityService's own transaction against the same database, so a call made from inside the approving transaction would count the rows as they were BEFORE the approval, settle the member one mission short, and answer 200 — there would be no failure to find afterwards. `GrowthAchievementClientTest` overrides the POST to pin exactly that ordering, and pins that a rolled-back approval tells nobody anything. And it stamps **************** NOT internal.auth.secret. Two cluster secrets exist and they hold different values: SecurityService validates the public-chat one (both services mount that Secret), while this service wires internal.auth.secret to INTERNAL_AUTH_SECRET from mlos-internal-auth, which is what the notification client sends to EmailService. Stamping that one here 403s every call, and does it silently — the response is discarded on purpose. Best effort throughout: a failure here costs the instant popup and nothing more, because every achievements read recounts from the same rows.
· SecurityService
Award SPREAD_THE_WORD from the Growth Hub
**************** counts a member's APPROVED Growth Hub submissions and settles their SPREAD_THE_WORD level against that count. It RECOUNTS rather than being told a number. The count is read here on every call — including the one MediaService makes when an admin approves — because a total supplied by a caller is a number this service cannot check, on an endpoint reachable by anything holding the cluster secret. One indexed aggregate makes a member's standing exactly as true as the Growth Hub's own tables. Called from BOTH the approval seam and every achievements read, the same shape `ensureBaseline` already uses and for the same two reasons. The approval seam is what puts the celebration in the same moment as the decision; the read is what covers every member who completed missions before this achievement existed, every approval whose cross-service call was dropped, and any future path that approves a submission without knowing this rule exists. There is no backfill and there is nothing to run. `AchievementInternalController` is that seam: POST **************** X-Internal-Auth compared in constant time against **************** fail-closed when the secret is unset — the same model as **************** It takes {orgId, memberId} and nothing else, so the worst a caller can do with it is cause a member to be credited for work that member has actually had approved. Idempotent, because award() SETS the running total rather than adding to it; and safe to lose, because the read path recounts anyway. The pending queue now orders by level within a timestamp — see the shared-library commit. With a nine-level track in the catalogue, a member catching up several levels in one recount would otherwise have been congratulated in an arbitrary order.
· kamo-shared-library
SPREAD_THE_WORD, a nine-level Growth Hub track
The second entry in the catalogue, and the first that is a track rather than an event. One level per stretch of Growth Hub missions a member has had APPROVED: 1, 5, 15, 50, 100, 500, 1000, 2000, 5000 -> I..IX. APPROVED is the Growth Hub's own definition of done — a member's standing on a request is derived from their latest submission, and only APPROVED takes it out of their queue for good. Counting what was merely SENT would pay a level out for a screenshot an admin then rejects, on a running total nothing walks back down: the level is committed and its popup has been read. RARE rather than EPIC, and that is a judgement about the whole track. Rarity is fixed per achievement and drives the celebration on EVERY level-up, so an EPIC screen-shake for a member's first mission would be as wrong as a COMMON shimmer for their five-thousandth. `rocket` is already in the front end's sprite map, so the page needs no change to draw it. Also here: - **************** — the aggregate SecurityService reads on every achievements request. Counted in the database rather than by loading the rows, because the only caller wants the number and a member's history only grows. - The pending-unlock finder now tie-breaks on `levelReached` ascending. Every level one award crosses is written with the SAME `unlockedAt` — one timestamp per award, not per level — so until now a member who vaulted from nothing to level V in one recount had five rows the database was free to return in any order. Walking somebody backwards through their own progress is the exact failure that finder's "oldest first" was written to avoid; with a nine-level track in the catalogue it stops being hypothetical.
· kamo-internal
Tear the page down and rebuild it as an arcade cabinet
Two things were wrong and the first one is a rule, not a preference. FULL WIDTH. The page shipped with `maxWidth: 1240, margin: '0 auto'` inside a content area the shell had already sized, which throws that space away and turns a wide monitor into a phone with expensive margins. That pairing is gone and must not come back. The cabinet grid is now `auto-fill` against a minimum plate width rather than a breakpoint ladder — a ladder has a top rung and the top rung IS the cap, so a fixed 7 columns leaves a 3440px display with the same gutters. Every HUD readout is a `1fr` that stretches, and the type is sized in `clamp(…, vw, …)` so it grows with the screen instead of leaving the extra width empty. THE ERA, without hedging. This surface now throws out the design system on purpose: no border radius anywhere, no soft shadows, no blur, hard 4px bevels, scanlines, a phosphor mask, a curved-tube vignette, tube flicker, chromatic aberration on the score, a scrolling marquee, a blinking INSERT COIN footer, and cabinet buttons whose bevel inverts and MOVES when pressed. - Press Start 2P, self-hosted (12KB Latin subset, SIL OFL), NOT preloaded — one route uses it and every other page should not pay for it. - Hand-drawn 16×16 sprites rendered as crisp SVG, not MUI glyphs. SVG rather than the usual box-shadow trick because the grid is fluid and box-shadow art is fixed at the size it was built for. Runs are merged horizontally: thirty sprites on a wide monitor is 7,680 rects otherwise, and that is a scroll that stutters. - The leaderboard is a HIGH SCORES table — rank, three initials, score — which is the one format nobody needs a header to read. - LEGENDARY plates blink their outline. A hue-rotate on the sprite was tried first and is wrong: it drifts the art off the plate's own gold ring so the two disagree at almost every moment, and it reads as a rendering fault rather than as a rare item. - The two achievement tones become square waves. This is the one place in the palette where the waveform is the point — a square wave IS the sound of the two pulse channels an era console had, and a triangle renders a tasteful chime over an 8-bit card. THE PALETTE IS FIXED AND DELIBERATELY DOES NOT DERIVE FROM --color-primary, which reverses the rule every other surface follows. An NES palette tinted toward a tenant's brand is not an NES palette, it is a muddy gradient. The org's colour still lights the marquee and the XP bar, so a white-label tenant owns the cabinet without the palette underneath being repainted. Three defects caught by actually looking at the render rather than declaring it done: the medal read as a cyclops (a white ring around a dark centre is an eye, and two tails below are legs), the flame read as a potion, and two-word names collapsed to a repeated letter — Ada Lovelace as ALL, Alan Turing as ATT — because the second part IS the last part. A sprite is a wall of string literals, the one kind of data edited by counting characters, so pixelArt.test.ts fails the build on any sprite that is not exactly 16×16, uses a character outside the palette, or draws nothing.
· kamo-internal
An animation at the point of contact, six of them, one for each way a press can happen
Left down, left up, middle down, middle up, touch down and touch up each get their own animation around the point of contact. They are one family rather than six ideas: a press pulls energy IN and a release lets it OUT, so an ordinary click reads as a single gesture — the ring that collapses onto alone, because a press without a release (a drag) and a release whose press was never seen both happen. Right-click stays silent: it opens a menu that is about to cover the animation anyway. left down Converge a hairline ring snaps inward onto a core dot left up Bloom the core launches: two rings and six sparks, seeded per click middle down Aperture close four arc segments rotate and close, mechanical rather than organic middle up Aperture burst the segments counter-spin outward, narrowing into dashes touch down Pool a soft edgeless halo, because a finger covers the point itself touch up Ripple two concentric rings decelerating outward, wider than the mouse Colour is mixed from --color-primary, so a white-label organisation recolours the whole system without a line of code, and #696969 stands in when a tenant has never set one. This runs in a tool people keep open all day, so idle cost was the design constraint. The rAF loop is started by the first impulse and returns the moment the last one dies. The backing store — tens of megabytes of GPU memory at DPR 2, 33 MB on a 4K display — is allocated with the loop and released with it, so an idle shell pays for two listeners and nothing else. Each frame clears only the union of live bounds rather than the framebuffer, which is why every kind declares the reach it paints within and why that reach is asserted at ten points across its life: a ring that outgrew it would leave an arc on screen that the next clear is too small to erase, and nothing else would notice. Listeners are pointer events rather than mouse plus touch, which is a correctness point and not a preference: a touch device also synthesises mousedown/mouseup after a tap, so the two-family version paints every tap twice. They are in the capture phase because the shell calls stopPropagation in a great many places and a bubbling listener on window would never hear those clicks. Under prefers-reduced-motion all six collapse to one halo that fades without moving at all. The engine is pure and holds no DOM, so the whole drawing layer is tested in node against a recording context — 28 tests, including that no kind paints outside its reach, that each of the six is genuinely a different animation rather than one reused, and that the sparks vary with their seed. Two defects were found by rendering it rather than by reasoning about it. The sparks were mixed toward white, which on this forced-light-mode shell is the direction of the background, and the bloom's one flourish was all but invisible; they are mixed toward black now. And allocation was inferred from `canvas.width === 0`, which is never true for a canvas that has not been sized — it reports the intrinsic 300x150 — so the first impulse of every page load skipped allocation and smeared into a 300x150 store stretched across the viewport. It is an explicit flag now.
· kamo-internal
Growth Hub — one thing at a time, beside the App Shelf
The launchpad pane a member works their organisation's growth requests through, and the Marketing settings tab an admin writes and reviews them from. `WidgetDefinition.anchored` becomes `anchorSlot`, and that is the change worth reading. Growth Hub has to sit immediately right of the App Shelf — column 2, row 1 — whether or not the organisation lets the member move it, and the launchpad could not express that: `anchored` meant "first cell, always", and an unmovable widget with no anchor fell into the pinned band ABOVE the grid. A fixed Growth Hub would have jumped out of the grid entirely and its position would have depended on a right, which is the sort of thing nobody can hold in their head. A slot now applies only when the policy says the widget cannot move, so both halves fall out of one mechanism: movable, it leads the ordering and mergeCanonicalOrder puts it in that cell for a member who arranged their launchpad last year as well as a new one; fixed, it is the anchored band's second child with nothing able to be dropped in front of it. The App Shelf is slot 1 and nothing about it changes — a system widget was never movable. THE ONE BOLD THING is the pane's EDGE: while a request is waiting, a light travels the border on a slow circuit, and nothing else on the pane animates by itself. A launchpad is a wall of glass panes and most ways to make one shout make the whole wall worse — a pulsing card is a heartbeat in the corner of somebody's eye all day. A line travelling an edge reads across a room, sits still where the reading happens, and STOPS: the moment the queue empties the circuit ends. It is not decoration that is always on, it IS the unread state, which is why it is asserted from both sides. Under reduced motion it becomes a static ring — dropping it would take the signal away from exactly the members who asked for less movement. The `?` does the glance and the read from one control: the org's explanation as a tooltip on hover, and a polished sheet on click for the rest of a paragraph. It is absent entirely when nothing was written, because a `?` that opens an empty dialog is worse than no `?` and is the sort of control that stays on a pane for years. Images are fetched and handed to the DOM as blobs, never assigned to a `src`: a browser-issued request carries no X-***-Token, 401s on an idle tab, and paints a broken image with nothing in the console. useGrowthImage owns that lifecycle, and compares the loaded path during render rather than clearing state from an effect — otherwise the previous task's artwork appears above the next task's title for a frame. VIEW_GROWTH_HUB is a root right and MANAGE_GROWTH_HUB hangs off VIEW_MARKETING; the settings-menu link is gated on the latter, because the page does not render the tab without it and an ungated link would open Marketing on some other tab while the address bar claimed otherwise.
· MediaService
The queue a member works through, and the queue an admin reviews
MediaService's Growth Hub surface at /api/media/marketing/growth. It owns this for the same reason it owns tracking, the scoreboard, materials and Canva — and because APIApp already forwards /api/media/**, so there is no gateway work. Submitting moves the member on IMMEDIATELY: the pending request leaves their queue and only comes back if it is rejected. That is what stops one slow reviewer blocking every member's whole queue, and it is why the queue is DERIVED from each member's latest submission rather than stored. Nine tests pin it from every side, because each way of getting it wrong renders a perfectly convincing widget: reading the first submission keeps handing somebody work they have already redone; treating PENDING as "in the queue" is the blockage this design exists to avoid; treating REJECTED as done loses the correction and the member never learns anything came back. The uploader is raster-only and SVG is refused, which is the assertion worth reading twice. An SVG is an XML document that can carry script, and a request's artwork is served back inline to every member of the organisation on their home page — so accepting one would turn "upload an image" into a way to run code on every colleague's launchpad. normalizeUrl refuses javascript:/data:/vbscript: for the same reason at the other end: that field becomes a link every member is invited to click. Evidence is readable by a reviewer or by the member who uploaded it, and by nobody else — a colleague's screenshot is a picture of their account on someone else's site. Anyone else gets 404 rather than 403, so the refusal is not an oracle for which submission ids exist. PATCH merges the raw JSON body, never a bound entity, which would silently reset every field the dialog did not send. create_growth_hub.sql is the DDL, applied by hand — InitializerService is not deployed, so a new @Entity creates nothing and the service fails at the first query. It records the pg_constraint check for the two appended ImageAssocType ordinals too, because "we checked" is the part that gets skipped.
· kamo-shared-library
The requests an org asks of its people, and the evidence back
Three entities, seven rights and two enum appends behind Growth Hub: a short ordered list of things an organisation asks of everybody — leave a review, share the launch post — handed out one at a time and proved with a screenshot an admin approves. The one rule everything is read from: a member's standing on a request comes from their LATEST submission and from nothing else. There is no `completed` column anywhere in this feature, deliberately — two facts about the same thing eventually disagree, and the day they did a member would either lose credit for work they had done or be asked to do it twice. GrowthSubmissionStatus carries that argument. VIEW_GROWTH_HUB is a ROOT right, which is the call worth defending. Every other marketing right hangs off VIEW_MARKETING, but taking part is something an organisation asks of everybody — the receptionist, the engineers, people who will never open the Marketing app — and parenting it there would grant that whole app to every member merely being asked to leave a review. MANAGE_GROWTH_HUB is the half that does hang off VIEW_MARKETING, because it gates a tab on that settings page. The five widget rights hang off VIEW_GROWTH_HUB: the widget shows a member their own queue, so the right to take part is the right that gates its data. Ids 311-317; 304-310 belong to the gamification block that landed alongside. Two ImageAssocType constants, appended so no ordinal shifts. GROWTH_REQUEST_IMAGE allows delete and overwrite — it is org artwork an admin curates. GROWTH_SUBMISSION_ EVIDENCE allows neither: it is the evidence behind an approval, and a member who could replace it afterwards could have work approved on one screenshot and leave a different one in its place. Checked pg_constraint across all five tables carrying assoc_id before shipping — none has a frozen CHECK, so there is nothing to drop.
· kamo-internal
A trophy cabinet, and a celebration that finds the member
Adds the Achievements surface: the trophy in the rail, /achievements, the launchpad leaderboard, and the unlock popup that arrives over whatever page a member happens to be on. THE POPUP IS PUSHED, NOT FOUND, and the queue is what makes that reliable. Two sources land in one order — the pending rows read on mount, and live frames off **************** The REST read is the one that MATTERS and the socket is the optimisation; the other way round, a member whose laptop was shut when they earned something would simply never be told. Cards are confirmed one at a time, oldest first, and merged by unlock id so the frame and the fetch describing the same row do not produce two identical cards. The host is mounted beside NotificationToastHost and INSIDE NotificationProvider. Outside it the hooks fail soft rather than throwing, and the symptom is indistinguishable from a member who has nothing to celebrate — the trap NewEmailNotifier's own comment records. "Awesome!" dismisses and stays put; "View My Achievements" dismisses and navigates. The big obvious control must never take a member off the page they were on. Escape confirms rather than hiding — there is no "close without reading" state, because the row is either acknowledged or it comes back. RARITY DECIDES HOW LOUD. Common shimmers; Legendary gets rays, a screen shake, two hundred pieces of hand-rolled canvas confetti and a longer hold. Without that, the fanfare stops meaning anything by the third one. Reduced motion gets the same content and none of the movement. The page is the one DARK surface in the product, because a cabinet has to make what is in it glow and a glow on white is a smudge. Every token is mixed from --color-primary so a white-label org gets its own neon — except rarity, which carries fixed hues for the reason the affiliate scoreboard hard-codes its podium: an org whose brand is amber must not render Common, Rare and Legendary as three shades of itself. The leaderboard widget is a close copy of the affiliate scoreboard — same 1x2 footprint, same podium, same bar measured against the leader, same self-line pinned below, same three-minute poll paused while hidden — because the two panes answer the same shape of question and a member should not learn two idioms for it. The row chrome is now literally the same code: RankBadge, MemberFace and CountUp move to components/common/BoardChrome rather than being copied. It drops the scoreboard's per-selector board cache, which exists for four metrics over four windows and has nothing to do here, and writes the score in full — the digits ARE the comparison, so two members forty points apart must not both read "2.4k". Also fixes ServiceType.ts, which was missing REPORTS entirely: the mirror parity guard reports an app Java offers that the client cannot see, and adding an app beside it would have left that failing.
· MediaService
Relay an unlock to the member it happened to, and to nobody else
SecurityService publishes **************** on core NATS; this puts it on **************** Same arrangement as the training badge, and for the same underlying reason: SecurityService owns the data and has no WebSocket of its own. THE PAYLOAD IS THE WHOLE CARD, not a cue to fetch one. Every other per-member relay here carries a count and lets the client re-read; this one cannot, because the entire point is that the celebration lands in the same second as the deed. A round trip inserted between the two is the one cost this feature cannot absorb. A dropped frame costs nothing: the unlock is committed to member_achievement_unlocks before it is published and the browser reads its pending rows on mount, so the worst case is being congratulated on the next page load rather than instantly. THE SEND GUARD IS THE INTERESTING HALF. The simple broker fans a frame out by destination regardless of which command carried it, so guarding SUBSCRIBE alone is not a guard. And because this topic's payload IS the popup — icon, wording, rarity, level, points — a client permitted to publish here could put an arbitrary full-screen message, in the platform's own congratulatory voice, in front of any colleague. It is the most convincing surface in the application for something that is not true. Not even the member the topic belongs to may write it.
· SecurityService
Award Join The Team to everybody, without a backfill
SecurityService owns the member, so it owns the achievement. It awards, reads, acknowledges and publishes; MediaService only relays. EVERY MEMBER GETS JOIN THE TEAM AND NOTHING HAD TO BE RUN. There are several paths that create a member and tens of thousands of members who predate the feature entirely; hooking creation covers the first group and a one-off script covers the second, and neither survives somebody adding a seventh creation path. So ensureBaseline is idempotent and called from BOTH — from MemberCreationService when a member is made, and from every read here. A member who predates the feature is awarded on the first request their browser makes, which is the same moment they could first have seen it. The award is BACKDATED to the day they joined. The achievement is a statement about the member; dating it to a deploy would make it a statement about us, and "held since March" is the sentence a trophy cabinet exists to be able to say. award() SETS the count rather than adding to it. A service that says "+1" is correct only if it is called exactly once per event forever, and the first retry or double-submit makes a member's record permanently wrong with nothing to compare it against. The publish is deferred to afterCommit, the way LeadEmailBackfillClient defers its back-fill and for a sharper reason: a frame sent from inside the awarding transaction congratulates a member on something a rollback then takes away, and they have already read it and pressed the button. Points arithmetic is read BEFORE any save, so it does not depend on when Hibernate happens to flush — a figure that is right in production and wrong under test is not a figure anyone can reason about. Repeat awards are caught by reading the recorded levels, not by catching the unique constraint: a violation marks the whole persistence context rollback-only, and the caller still has levels to write. Nothing here takes a member id from the caller. "Read somebody else's cabinet" is not a request that can be expressed, which is stronger than a right check because a later endpoint cannot forget to make it. The board sends memberId as a STRING. unique_rowid() is ~1.17e18, about 130x Number.MAX_SAFE_INTEGER, so a JSON number is rounded by JSON.parse before any browser code runs and every face on the board resolves to the wrong person. SCHEMA: **************** is applied by hand — ddl-auto is none and initializerservice is not deployed. Already applied to the cluster.
· kamo-shared-library
The achievement a member holds, and the record that proves it
Adds the Gamification app and the domain behind it: a catalogue of achievements in code, a member's standing in data, and a log of every unlock so that nothing a member earns can be lost between earning it and being told. THE SPLIT THAT MATTERS is member_achievements (state) against member_achievement_unlocks (history). A single table with an `acknowledged` flag cannot answer "they went from I to III while their laptop was shut, so they are owed two popups" — the flag has one value and the member sees one card for two things they did. One row per member per achievement always says where they are now; one row per event, acknowledged on its own, is the queue. The catalogue is CODE and never a table. An achievement that could be edited in a database is one that can be taken away or quietly redefined under the people who earned it, and "you have held this since March" has to stay true. Two rights, and the split is deliberate rather than symmetric. VIEW_ACHIEVEMENTS opens a member's own record; VIEW_ACHIEVEMENT_LEADERBOARD opens the comparison against colleagues, which publishes how much each of them has done, to all of them. That is a disclosure an organization decides to make. Neither is seeded: there is no lineage to carry, and no "everybody" to seed from, so they arrive off and an administrator switches them on. GAMIFICATION is id 33, COMPLETED and enabled, so FeatureController provisions it for every organization exactly as it does CRM and Reports. That changes what an administrator CAN grant, not what anybody can currently see. PhiModule.GAMIFICATION is not optional bookkeeping: PhiServiceTypeMappingTest asserts every ServiceType maps to a module, and an unmapped one fails closed.
· kamo-internal
The board every org can read, and a vote on every improvement
The organization column is on for everybody now, because every viewer sees every organization's reports. What varies is whether a given ROW can be named, and that is the server's answer per row and per viewer — identityDisclosed — never re-derived here. A second copy of a disclosure rule is how a name appears on a card the server withheld it from. A withheld row says "Undisclosed KamoCRM Customer" and draws no organization chip. Both halves are deliberate. An em dash would read as missing data and invite somebody to put the name back; printing the same phrase twice, once as a person and once as a company, reads as a rendering fault rather than as a withheld name. The drawer numbers the customer where the list does not, because that is the one place the reporter is read beside the thread they speak in and "#1 filed it" has to visibly be "#1 wrote this". The vote control is optimistic and then corrected, and its guess is arithmetic on the viewer's own delta rather than a +1: reversing an upvote is a swing of two, and a control that showed one would be visibly wrong for the length of every request. Pressing the arrow you already pressed withdraws, which is what the rest of the internet has trained people to expect. Purple rather than green-and-red — up and down here are "I want this" and "not for me", not right and wrong, and a red arrow beside a feature request reads as a fault. A vote's answer is folded back into the row rather than triggering a reload. The list is deliberately not re-sorted on the press: a card that jumped out from under new order arrives on the next load where the reader expects it. Six existing fixtures gained the four new fields, and one new test file pins the withheld rendering and the vote control. The selectors there are `button[aria-label]` and not `[aria-label]` on purpose: MUI's Tooltip clones its wrapper span and copies the label onto it, so the bare selector matches a span no click ever reaches — which is a passing-looking test that exercises nothing.
· MediaService
Open the board to every org, withhold who filed each report
The tenancy check is gone from visibleOr404 and from all four queries, so every organization can read, comment on and vote on every report. SystemBugIdentityPolicy is what replaces it, and it is three ORed terms: a TeamMember of the platform org sees everything; a report filed BY the platform org names itself to everybody (which is how "KamoCRM Inc. + the member who posted it" falls out with nothing hardcoded — the top-level org's own title is already that string); and your own organization is never anonymous to you, which is exactly what a tenant sees today. The policy never asks the SUBJECT's member type, only their organization, and that is deliberate: SystemBug.getReporter() is a lazy association, so `proxy instanceof TeamMember` answers false for every row in the estate and a rule written that way would have redacted everything, consistently enough to look intentional. The viewer's type is safe to test because requireMember resolves them through findById. Redaction is cosmetic unless it reaches every surface a name travels on, and there were four. The row's senderName; the actorName baked into a stored status-event payload, which SystemMessage renders directly; the parentSenderName on a reply quote, read from a different row than the message it sits on; and the raw member ids in a reaction row, which nobody reads as identity. All four are handled, and withheld speakers are numbered per report — the reporter pinned to #1, the rest by first message, from the whole thread rather than the requested page so asking for page 2 cannot renumber anybody. The thread's NATS frame loses senderName too, because one payload fanned out to every subscriber cannot be redacted per viewer and /topic/chat/session is not among StompDestinationAuthz's guarded prefixes. It costs nothing — the console re-reads the page over REST on any frame. The member id and body deliberately STAY: ChatEmailNotifier consumes the same subject and drops an event missing either, so stripping them would silently stop the mail telling somebody their report was answered. Voting is improvements-only and refuses a defect, a voided report, and anything but 1/-1/0. Filing an improvement casts the reporter's upvote as a real row, so they can change their mind later without anything special-casing them. create_system_bug_vote.sql is the hand-applied DDL — ddl-auto is none and initializerservice is not deployed. Already applied to the live database: table, column, indexes, and 30 backfilled reporter upvotes.
· kamo-shared-library
Every org sees every report, and improvements can be voted on
The tenancy predicate is gone from all four queries, and its absence is the feature: a defect in the platform is not one tenant's private business, and a request for an improvement is worth more the more organizations can weigh in on it. What used to be protected by not returning the row is now protected by withholding WHO filed it — the reporter fields on SystemBugDTO are null together or populated together, with identityDisclosed saying which shape a row is, so the console never infers withheld from missing. SystemBugVote is one row per member per report, enforced by a unique index rather than by a read-then-write: changing an upvote to a downvote rewrites that row and clearing deletes it. There is no stored zero, because "has not voted" and "voted neutral" would sum identically and read differently. SystemBug.voteScore is a cache of SUM(vote), recomputed on every write and never incremented — a counter nudged as votes arrive is right only while every path that touches one remembers to nudge it. It exists so ordering can be a plain column sort: the listing is a SELECT DISTINCT over a LEFT JOIN, and an aggregate in ORDER BY would force a GROUP BY that breaks both the distinct-entity paging and the hand-written count beside it. The table and column are NOT created by this commit. ddl-auto is none and initializerservice is not deployed, so **************** has to be applied by hand — it has been, against the live database: 30 existing improvements carry their reporter's backfilled upvote.
· kamo-internal
A way onto a paid plan that is not hidden under a profile
An owner deciding whether to pay had to know the answer lived under their own profile, three clicks from the page they open every morning, signposted only by a pill in the masthead. My Plan is that offer standing in the open: a full-width pane on the first band of the launchpad, showing what each plan costs on the term they pick, for exactly as long as there is something to sell. It takes itself off. The registry gate reads the ORGANIZATION'S COMMERCIAL STATE rather than its apps — the first widget that does — and answers no the moment the subscription is PAID, or COMP, where the plan was given rather than sold. Nobody has to remember to remove it, and there is no state in which a customer is being sold what they already bought. The widget asks the same question again of its own fresh payload, because entitlements are cached for a minute and an owner who has just paid should not watch a grid of plans for the rest of it. `system: true` is what makes "they cannot remove it" true without asking an administrator to grant anything: a rights-gated widget is only immovable when an organization grants _REQUIRED, and RoleRightType's own comment forbids a deploy seeding that for anybody. No `custom`, so there is no resize handle either — a member who could shrink this could shrink it to nothing, which is removing it by other means. Unmovable puts it in page.tsx's existing fixed band, where nothing can be dropped in front of it; that band had no occupant until now. Every price goes through `offeredRate` with the live promotion, per plan AND per term, because `offerApplies` restricts by both: an offer covering only Starter, or only annual, discounts only the cards it actually covers, and no card can advertise a rate checkout will not honour. The list price stays struck through beside it, the term is named on every figure, and the $99-a-seat startup fee is never omitted. `offerIsLive` is re-checked on a minute tick — the launchpad is the tab people leave open overnight, and a struck price for an offer that ended at midnight is a number nobody will be charged. A failed catalogue read quotes nothing at all and links to /myplan. The arithmetic of "what would this plan change" moves out of PlanUpgradeDelta into planDelta, and both surfaces read it. The widget has room for the count and four names; /myplan lists them all. A launchpad claiming a plan keeps 35 things beside a page listing 34 is worse than saying nothing. Three registry tests assumed "system widget" meant "the App Shelf", since it was the only one. `has unique rights` folded every system widget's undefined right into one Set entry and compared 10 to 11; the rule it means to state is about gated widgets. `system implies no gate` conflated an organization's authority over who sees a pane with whether the pane has anything to show. And the rightless launchpad now asserts the stronger thing: a system widget with no gate would be on every member's home page forever.
· kamo-internal
Give a member their text numbers, and let them choose which one sends
Two screens, one idea: a phone line usually is not one person's. MEMBER SETTINGS → PHONE A card modelled on the Email tab's mailbox list, because the two screens answer the same question about different things and nobody should have to learn a second mental model. What it does NOT copy is the word "primary". A mailbox belongs to a person; a support line is answered by four, and the singular fact is not ownership but where an incoming text lands — so the column is "Replies", the state is "Comes here", and the action is "Send replies here". Two facts a flat list cannot show are given their own columns: whether the line can text at all — the state that has been silently blocking sends — and how many other people hold it. The empty state distinguishes "your org has numbers, pick one" from "your org has none", and only the second sends anybody to another screen. THE COMPOSE WINDOW A "Send from" selector that renders NOTHING unless the member holds more than one line. A control that is always present and usually answers itself makes the common case worse to serve the rare one, so a one-line member sees the window exactly as it has always been. When it does appear, each option says what is actually being weighed: whether replies come back to you, and who else is on that line. A line that cannot text is shown but disabled — hiding it would leave somebody hunting for a number they know they have. No choice is sent unless the member made one. The server picks their default otherwise, which is what every one-line member already had, and a number they do not hold is refused rather than substituted — lines are shared, so quietly sending from a different one puts the reply in a thread nobody is watching. SmsWindowIdentity's E.164 assertion now checks the first argument rather than the whole call, so the wire format and the send-from line stay separable concerns.
· VOIPService
A number can be shared by several members, and one of them receives it
A single OWNER_MEMBER_ID could say "this number belongs to Sarah" and nothing else. Real desks are not shaped like that: a support line is answered by four people, and a salesperson carries both their direct line and the shared main number. One owner column forces an administrator to choose between "nobody can see it" and "it belongs to the wrong person". MEMBER_PHONE_NUMBER makes assignment many-to-many and puts the one genuinely singular fact on the join row. IS_PRIMARY is per NUMBER, not per member. It answers the question with exactly one correct value — when a text arrives on this line, whose thread does it land in? VoipNumberOwnershipService used to answer that by refusing whenever two members claimed a number, which was correct and useless: the customer's reply went nowhere. It is deliberately NOT "this member's default number"; a member can be primary on several, and which one they SEND from is a per-message choice, because conflating the two would give away inbound ownership every time somebody changed their outgoing number. Both constraints live in the database, because application-level uniqueness loses a race: unique (MEMBER_ID, ORG_PHONE_NUMBER_ID), and a PARTIAL unique index on (ORG_PHONE_NUMBER_ID) WHERE IS_PRIMARY. Partial, so a line nobody is on call for yet is legal and files its inbound against the organization. Assign promotes the first member automatically — an administrator putting one person on one number plainly means them to receive it. Unassigning the primary promotes the longest-standing remaining holder rather than leaving the line unowned; somebody is still answering it. OWNER_MEMBER_ID survives as a projection of who is primary, maintained here, because inbound routing still derives from it. /capability now returns every line a member may send from, and conversation creation accepts one — validated against the member's own numbers, never substituted. Numbers are shared, so quietly falling back to a different one would send from a line the member may not be on and put the reply in a thread nobody expected. DDL applied by hand, including a backfill from the column it replaces.
· kamo-internal
The repeat interval goes down to one second
Asked for on 2026-09-06. The floor was five; it is one everywhere now — the control, the clamp the browser applies, and the clamp the service applies. Zero is still refused, because that is not a fast repeat but a tone playing over itself with no gap at all. One second only works if the tone is allowed to sound that often, and it was not: playEventSound throttles each event to stop a burst of arrivals machine-gunning the same chime, and lead.callback.due throttles at exactly 1000ms — so a one-second repeat would have lost about half its beats, silently, depending on which kind the card happened to be. A deliberate repeat is the opposite of a burst, so it now opts out with bypassThrottle. Nothing else passes it. Also pins the half of the feature that had no test of its own: the CARD honouring holdUntilDismissed, rather than the deck deciding to pass it. Writing it caught a harness trap worth keeping — the countdown is a chain of 100ms timeouts where each one is only scheduled after React re-renders, so advancing thirty seconds inside a single act() fires exactly one tick and "it never retired" is true of every card, held or not.
· kamo-internal
One checkbox on a callback, to tell the lead
The callback dialog hid the invite controls, and the note under it said "The lead is not notified." That was true, and it was the right call for the control being hidden: that one asks about a guest LIST — who is on it, and by which of two routes each of them hears. A callback has one person on the other end of it, and offering three controls to answer a one-answer question is why nobody asked for it. So the guest section stays hidden and this asks the single question instead, in the lead's own terms, with the address it would go to written out beneath it. Ticked, the lead gets the organisation's branded letter with an .ics attached, sent from the member's own address so a reply reaches whoever is going to ring them. Off by default even when the lead has an address. Writing to a customer is something a member asks for, not something that happens because they opened a dialog. The address is shown rather than implied, because "email the lead" hides exactly the mistake that is invisible until it has been made: a lead whose address was corrected last week, or one who never had a usable one. Where there is no address the box renders disabled and says so, in the same amber the guest section uses when a route is closed — a member who believes they invited somebody and did not is the failure this control exists to prevent. Reopening a callback shows the state it is actually in, read off the event's own guest rows rather than a stored flag. Un-ticking is a real instruction and travels as one: the payload always carries notifyLead, true or false, because the service reads an absent value as false and acts on it — a payload that omitted the field when the box was off would be indistinguishable from a client that never learned to send it, and un-ticking would silently do nothing while appearing to work.
· SecurityService
A callback can invite the lead it is with
Scheduling a callback told the lead nothing. The dialog said so outright — "The lead is not notified" — and the only way to get a customer the time in writing was to leave the CRM and write the email by hand. With the box ticked, the lead becomes a guest of the callback's event. That is what the rest of the platform already means by "this person is coming": the ATTENDEE line is what lets their acceptance come back, the guest row is what records that they were told and at which SEQUENCE, and that same row disappearing is what retracts the call from their calendar when a member changes their mind. No column, no second copy of the truth. Three lifecycle points, because two of them are the ones a customer cannot recover from on their own. Booking writes to them. Moving the call writes again, at the next sequence, so their calendar replaces the entry rather than gaining a second one. Cancelling the callback retracts it — but marking one CONTACTED does not, because that call happened, and telling somebody you have just spoken to that it is cancelled is a lie their calendar would then act on. A lead with no address on file is never invited however the box was left: a guest with an empty address on the event would report an invitation that could not be sent. The lead is appended to whoever else is on the call rather than replacing them, and skipped when the composer already named them, so nobody is invited twice. The timeline records it, and deliberately as what was asked for rather than what was delivered — delivery is asynchronous and best-effort, and a note claiming a letter arrived would be worse than no note. But a colleague picking the lead up next has to be able to see that the customer already holds this time, or they ring them cold about a call they think was never mentioned.
· EmailService
A guest a service put on an event is actually told about it
InternalCalendarService wrote the attendee rows and stopped. It never set sendInvites and never called EventInviteService, so a guest arriving through the service-to-service path — the whole of the lead-callback feature — was stored on the event and heard nothing, ever. Adding a checkbox to the lead page on top of that would have shipped a control that did nothing. It now honours the invite flags, syncs the guest list on update through MemberCalendarService rather than ignoring it, raises the SEQUENCE when the meeting actually moves, and retracts the event from anybody it had already told who is then taken off it. Every one of those is something the session-scoped path has always done; this one had none of them, so a rescheduled call could not reach the guest holding the old invitation — an iCalendar client ignores an update arriving at a sequence it has. Delivery now branches on the ATTENDEE rather than the event. The guest an event is ABOUT — named by the new subjectEmail field, marked SUBJECT_SOURCE_KIND on the row — gets the organisation's own branded letter through LeadCallbackInviteMailer; a colleague on the same call keeps the plain invitation unchanged. Writing to a colleague as though they were the customer being rung is not something anybody would file a bug about; they would quietly stop using the checkbox. That mark does a second job: it keeps the lead on the guest list when the event is edited from the calendar page, which knows nothing about leads and sends an audience that cannot contain one. Without it, an unrelated edit takes a customer off their own call, silently, with their calendar still holding the time. The letter goes out from the booking member's own mailbox when they have one — so the address in the lead's inbox is a person's, a reply reaches whoever is calling them, and the copy in Sent is what puts it on the lead's Communications timeline. A member with no provisioned mailbox now falls back to the organisation's transactional address with Reply-To pointed at them. The behaviour it replaces was a warning in a log: a member who ticked the box, and a lead who was never told. EmailTemplateService.sendToUser is split into render and deliver so the branded body can be built without being sent, and so the From address, the SPF decision and the platform fallback are still decided in exactly one place. Rendered carries the merged orgName rather than re-reading the organization: several callers override it, and the From's "on behalf of" must not say something the body contradicts.
· kamo-shared-library
The three letters a lead gets about a callback
A callback has always been private to the organisation. The member who books one gets a calendar entry and a reminder; the person on the other end of the call is told nothing, and the dialog said so in as many words. These are the letters that change that: booked, moved, and cancelled. They go in the catalogue like any other transactional template, so an administrator can edit the wording, and they carry the organisation's own logo and colour — the recipient has never heard of Kamo and is deciding whether to talk to this company at all. Three templates rather than one with a headline variable, because TemplateRenderer substitutes {{key}} and cannot branch. They do not want to be one letter anyway: the cancellation says nothing about length or location, and the update has to be explicit that it replaces a time the reader has already written down. LeadCallbackInviteEmail builds the variables, so the note block, the contact rows and the location row are finished HTML that collapses to "" rather than leaving a label with nothing after it. It states nothing about the lead's record — not their status, not their owner, not that they are a "lead" at all — and the test pins that.
· kamo-internal
Tell me about new mail and unread chat, and keep saying it
Two more tiles under "What you are told about", and the two things behind them. New email is raised off the inbox socket the app already holds open. Unread chat gives a message five seconds to be noticed and raises only what is still unlooked-at when they run out — a card about the message somebody is in the middle of reading is the interruption that teaches people to mute a feature. "Looked at" is asked of the CONVERSATION through the existing sound surfaces, not of the browser tab: the tab answers yes for every conversation a member owns for as long as they are at their desk. Both are told once and not again until the card is dismissed. Reading is not dismissing — someone who glanced at the bell is still looking at it — and without the rule a lunch hour costs one card per message and turns the centre into a worse copy of the inbox it points at. The centre is consulted before asking the server, which already applies the same rule; the server settles races between tabs, the local copy saves forty requests answered "no". Repeat sound, off for everybody until they ask. It changes three things that only make sense together: the front card's tone plays again on an interval, no card retires on a timer, and the deck shows one card at a time — oldest first, so a new arrival cannot swap out the card the member is reaching to dismiss while an alarm sounds over it. Both notifiers are mounted INSIDE NotificationProvider deliberately. useNotifications() answers with an inert empty value outside it rather than throwing, so a copy mounted a little higher reads an empty centre, believes nothing is outstanding, and raises on every message with nothing reporting it. The chat arrival reaches them over a window event because onIncomingNotification holds exactly one callback — a second subscriber does not join the first, it replaces it, and what stops working is the chat window opening itself.
· InitializerService
The repeat-sound columns, for a fresh environment
EmailService runs ddl-auto: none, so neither column can appear on its own — the entity declares them and Hibernate reads the table happily without them, then fails at the first query rather than at startup. Applied to the live database by hand today; this is what a fresh environment gets instead.
· EmailService
Let a browser report the two conditions only it can see
Every notification so far is raised by the service that watched something happen. These two cannot be: whether a member looked at a conversation within five seconds of a message landing in it is a fact about a browser tab, and no server has it. So POST /api/notifications/self takes the CONDITION from the browser and the service still makes the decision — entitlement, mutes, quiet hours, do-not-disturb and the once-until-dismissed rule are applied exactly as they are for a calendar reminder, and a decoder is a 204 rather than an error, because "you have muted this" is an ordinary answer. Restricted to NEW_EMAIL and CHAT_MESSAGE. A member spamming their own centre is not worth defending against, but a browser that could raise any kind could raise a LEAD_CALLBACK — and a card claiming to come from the leads system when it does not is a lie the member has no way to see through. sourceType and sourceId are refused for the same reason: they drive the "already told" guard and the withdraw-by-source endpoint, so a client-chosen pair would let one tab suppress or withdraw another tab's notification. The once-until-dismissed rule is a property of the KIND, not a flag on the request. Every producer of one of these wants it, and a rule each caller has to remember is one a caller eventually forgets — the browser already raises both from two separate listeners.
· kamo-shared-library
New-email and unread-chat kinds, and a repeating tone
Two kinds a member can be told about and mute like any other. Both announce a condition that stays true rather than a moment that passed, so both are raised once and not again until the member dismisses the card — the query behind that rule is hasOutstandingOfKind, and dismissal rather than reading is the reset, because a member who has glanced at the bell is still looking at the card. The preference row grows two columns for the repeating tone. FALSE by default and it has to stay that way: a tone that repeats on its own is the loudest thing the application does, and switching it on for everybody who already has a settings row would make the product unusable for the people who never asked.
· kamo-internal
Show the URL a carrier has to be told to post to
Adding a text provider covers sending. Receiving needs the carrier told where to deliver, which happens in the carrier's own console — and the form never said what to paste there. Skipping it has no symptom on the side anybody tests first: messages send normally and every reply is dropped at the edge. The card appears on both text-provider screens: the new-provider form, where an administrator is already looking at the field for the key that verifies those same posts, and the existing provider's overview, so it can be checked after the fact. Fetched, never hardcoded. It is derived from a deployment property and is the same string the server writes into Telnyx's messaging profile; a second copy here is a copy that can be wrong, which is why RingCentral's redirect URI is served the same way. A failed lookup hides the card rather than showing a guess — half a setup instruction is worse than none, because somebody would paste whatever was on screen and believe they were finished. It also warns when a carrier's inbound is not wired at all, rather than handing over a URL that would then refuse every message. Reuses CopyableIdField, which already existed for the phone-server id.

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