Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-internal
Floating notes become a second class of hex head window
Hex heads now back two kinds of window: the popup tool windows they always did, and floating notes. Minimize replaces the old roll-up — a minimized sticky leaves the overlay entirely and lands in the hex head dock wearing its own note colour, where the popover's open/close and throw-to-goal drive it exactly as they drive a tool window. The integration is the event contract, nothing more: heads are keyed by window id, so notes take a `floating-note:<id>` namespace inside that shared id space and answer `tool:focus` / `tool:close` for their own. `tool:requestClose` is deliberately not vetoed — that veto protects unsent drafts, and a sticky autosaves and flushes on unmount. Also: - The whole title bar drags. The title is a label rather than an input, which is what was stealing every pixel but the top-left corner; double-clicking it renames, double-clicking anywhere else maximizes. - Maximize to full screen, from the button or a double-click. - A note is live in one place or the other, never both. While floating, its card in /notes is dimmed and inert (only re-attach stays live) and detaching from the editor closes the editor. "Open in Notes" hands the note back and closes the sticky. - The deep-link latch was a single boolean, so a note handed back a second time never reopened. It is keyed per id now. - A sticky left minimized re-spawns its head after a reload, and a legacy `collapsed` sticky migrates to minimized rather than vanishing.
· kamo-internal
A control that actually opens what produced the event
The callback's description carried "Open the lead: /leads/view/123" as text, and text in a textarea cannot be clicked — the event dialog edits its description in one, so that path could never have been a link there. The destination has been on the event's deepLink since the column was added; this is the affordance that uses it. The dialog offers it beside the event's kind, the agenda view offers it under the description, and both go through the router rather than loading a document, because either can be open over any page and a navigation would tear down whatever else the member has going. Keyed off deepLink rather than off callbacks specifically, so whatever produces events next gets the control without touching this dialog. The label reads "Open lead" when the origin is a callback and "Open" otherwise.
· kamo-internal
Callbacks on the calendar, and a marker on the list
Two ways to see a call you promised to make without opening the lead. On the calendar, a callback gets its own line item rather than looking like any other appointment — a call you owe someone is a different kind of obligation from a meeting you agreed to attend, and a view that renders them identically makes you read every title to tell them apart. The month view collapses a day's calls into one line reading how many there are, because a cell fits about three chips and a busy morning of calls would otherwise push every meeting under a "+4 more". The week view names it "Lead Callback" and puts the lead in the hover, since the block is a few characters wide at that zoom. The day and agenda views have room for both and show both. On the leads list, a lead with an outstanding call carries a phone marker in the pinned id cell, coloured by how pressing it is: grey a day or more out, amber today, red once the time has passed with nobody marking it contacted. Overdue pulses, which is the state worth catching while scrolling. More than one call on a lead shows a count. Urgency is read in calendar days rather than elapsed hours, because that is what "today" means to the person reading the list: a 9am call tomorrow is not urgent at 5pm today though it is sixteen hours away, and a 9am call today is urgent at 8am though it is one hour away.
· SecurityService
Callback summary for the leads list marker
Answers which leads have a call still waiting, and when the soonest one is due, for the caller's organisation in one request. Its own endpoint rather than a field on the lead: callbacks change on their own schedule, so folding them into the lead payload would mean either a join on every lead query or a stale marker.
· kamo-shared-library
Pending callbacks for a whole organisation in one query
Feeds the leads list's per-row callback marker. The grid holds every lead the member can see, so asking per lead would be a request per row to render one column; this is the same question asked once. Only a repository method — no entity or column change, so nothing here is armed against the schema.
· kamo-internal
A notification centre, on every page
Reminders had nowhere to arrive. The calendar's popup was a plain alert in the top corner of whichever page happened to be open, it vanished on a timer, and anything raised while the member was elsewhere was simply lost — the socket publishes, and a publish nobody is subscribed to succeeds and disappears. So notifications are now stored as well as published, and the browser reads both through one context mounted above the whole tree. What arrives while you are here is pushed; what arrived while you were not is claimed on the next connection, once, so two tabs opening together do not both raise the same pile. The deck sits in a corner of the viewport, over whatever page is underneath. Cards stack rather than list — four reminders landing together, which is what a company event plus your own meeting does, would otherwise take the right-hand third of the screen — and hovering fans them out. Each card leads with the thing every notification in this product has in common: a clock. It counts down to the event, offers now, later or not at all, and drains a rail as its own welcome runs out, which stops while you are reading it. Swipe right to be rid of it. Everything else is behind the two menus: snooze by five, ten, fifteen, thirty, an hour, or until five minutes before the thing starts; mute this kind of notification outright; open the settings. Producer-supplied actions ride along, so a callback offers the lead's number and a meeting offers its join link. Links go through the router, never a document load — the deck can fire at any moment and a navigation here would tear down an unsent draft. The bell in the top nav is fixed chrome for the same reason the compliance clock is: a bell someone can unpin and then not be told anything is worse than none. Behind it is the history, filtered by kind or by unread, with do-not-disturb where a member notices the noise. The tab tells you too. TabAttention already flashed the title and badged the favicon for chat, leads and support; unread notifications join it, above chat because they are timed — a reminder that a call is due in five minutes stops being useful five minutes from now. Sounds are synthesised rather than loaded, so the first notification of a session is not the silent one on a slow connection. Desktop notifications are raised only when the tab is hidden, and permission is asked at the moment the member turns the switch on rather than on page load, which is the pattern browsers suppress and people reflexively deny.
· SecurityService
A callback that leads back to the lead
The callback already reached the member's calendar, as an event titled "Lead Callback" with their comment in the description. Nothing said whose call it was, nothing said what number to dial, and nothing led back to the lead — so a member looking at their calendar had to go and find the lead by hand, which is the search the calendar entry existed to save them. It now carries the lead's name in the title, their number in the location, and a site-relative deep link back to the lead on the event and in its description. The description repeats the contact details deliberately: a calendar is read where the CRM is not, and a callback you cannot act on without opening another application is a reminder to go and look something up. The lead page now composes a callback in the calendar's own event dialog, so the request can carry a whole event — title, guests, reminders, colour — and this passes all of it through. Every field but the instant is optional; a caller that sends only the original three still gets a properly titled, properly linked entry built from the lead. Resolving a callback withdraws its notification as well as cancelling its event. Otherwise a call made at two minutes past leaves a card still asking for it, and a snoozed one that comes back tomorrow to ask again.
· InitializerService
Member notification tables, and an event's origin columns
Two halves of one feature. A callback scheduled on a lead is mirrored onto the scheduling member's calendar, and the point of the mirror is to get back to the lead — so calendar_events carries source_type, source_id and deep_link, and the notification raised from it carries the same three onward. Order 0: CalendarEvent maps the three new columns from the moment this build starts, so every runner that loads an event emits them in its SELECT and ddl-auto cannot have added them yet. Ordered late it would never execute. Applied and verified against Yugabyte 2026-08-13.
· kamo-shared-library
Member notification centre, and an event's origin
Adds MemberNotification and MemberNotificationPreference: one row per member per notification, because read, dismissed and snoozed are per-person answers and a shared row has nowhere to put them. Preferences cover the two halves separately — mutes, quiet hours and do-not-disturb are applied when the notification is raised; sound, volume and desktop notifications are properties of the browser. CalendarEvent gains SOURCE_TYPE, SOURCE_ID and DEEP_LINK so an event made by a service can lead back to whatever it is about. A lead callback mirrored onto a calendar was previously titled "Lead Callback" and pointed nowhere, so a member looking at it had to go and find the lead by hand. CalendarEventReminderRepository gains per-event lookups and fetches the owning calendar with a due reminder, which is what lets the delivery fan-out ask whether an event belongs to the organisation without a query per reminder. Schema applied by KamoInitializer's MemberNotificationMigration and verified in Yugabyte before this landed.
· kamo-internal
Detach notes into draggable, resizable floating stickies
Each note in the list (and the editor) gets a detach control that pops it onto a screen-wide overlay. Any number can float at once; each can be dragged anywhere, resized from any edge or corner down to a 220x170 floor, rolled up to its title bar, recoloured, and edited in place. Placement, size, stacking and colour persist in the per-member DB-backed ui-preferences store under a `floatingNotes` key -- the same mechanism the nav layout uses. That store, not sessionStorage, is the point: stickies have to survive signing out and back in, and logout clears sessionStorage wholesale. Notes: - Sticky text is always near-black, so every selectable background is held above a 7:1 contrast ratio against it by a unit test. The palette cannot drift illegible later. - Inline editing is offered only for notes whose stored Lexical state carries no formatting; anything richer opens read-only rather than being flattened by a plain-text round trip. - note.color was previously a hex dropped into a className slot, where it rendered nothing at all. It is now a palette id resolved to an inline background, and a coloured card pins its text dark so dark mode cannot paint white on pastel. - The overlay is portalled to <body>; the shell's content box is overflow:auto and would otherwise clip it.
· EmailService
One integer for the mail badge, and count what the index path is worth
Three things, all about knowing rather than assuming. The nav-top unread badge fetched the entire folder tree and picked INBOX out of the result — the most expensive call the mail backend makes, to render a number. MailReader has carried getUnreadCount for exactly this since it was written (a single STATUS on the IMAP providers, an inline field on Graph) and nothing ever exposed it over HTTP. Now it does. This is a fallback path that only fires when the unread socket has failed, so it is rare and was expensive precisely when things were already going wrong. The index-backed listing now reports why it did or did not serve, as **************** and the actuator exposes metrics so the counters can be read. The ratio of served to modseq_moved is the number that says whether that path earns its complexity, and it is not answerable by reading the code: a folder invalidates its own checkpoint as soon as someone reads a message in it, so the hit rate is a fact about how members move through their mail. The proof it reports is now an enum rather than a boolean, so each refusal is attributed instead of lumped together, and the tests assert the specific reason — a test that accepted any refusal would let those buckets drift into nonsense. Also fixes a flaky test I watched fail three times this afternoon and which is not mine: ImapIdleRegistryTest polled a mock with Mockito's timeout() to see the async close land, and on a loaded machine that expires while the close is merely queued behind thread creation. It fails as "zero interactions", which reads like a broken registry and is really a busy CPU. It now waits on a latch that wakes the instant close() is entered, matching the sibling test two methods down, and the parked thread is interrupted in a finally block — a failing run used to leak one sleeping non-daemon thread for thirty seconds and slow down everything after it, including the next attempt at the same test. Three full suites run concurrently: 257 passed, three times over.
· kamo-internal
Brand chrome around a drafting board in the signing room
The desk was tinted with the org's colour and went dark in both themes, which put the brand under the document and left the chrome neutral — exactly backwards. Inverted: - CHROME is the org's primary on every side of the page: header, docket rail and dock. Every mark on it is white, through one token ladder (--legal-on-chrome / -dim / -faint / -line) so three regions cannot each invent their own idea of "secondary text on brand". - THE DESK is a drafting board — very light grey ruled with a 45-degree axonometric lattice, in pure CSS gradients (no asset, no request, scales to any viewport). background-attachment: local ties the lattice to the scrolled content so it travels with the pages. It is deliberately NOT derived from --color-primary: a white-label org with a pale primary would wash the surface the document lies on, and the page has to read as paper on a work surface, never as paper on the brand. The lattice is what separates the page edge from the background, so no border is drawn. - The page shadow is now two casts — a wide soft one for lift, a tight dark one for the sheet's edge. A single blur on a light surface reads as a smudge instead of a page. - A complete docket node is solid WHITE with the org's colour inside it, inverting node and rail exactly when the member banks the work. Green there would be a third colour fighting the brand for one meaning. - The attestation moved onto the same board as a white sheet with the same lift, because the statement is an instrument too and dropping to a plain panel read as having left the room. ONE DOCK, ALWAYS. A document step owns the bottom bar — only it knows whether it is showing a spinner, a consent gate, a signing surface or a captured signature — so the wizard hands its Back and Next controls DOWN instead of stacking a second bar beneath the first, and draws the dock itself only on the attestation step. GuideBar gained a `brand` tone and a start slot for that: on brand it paints the chrome, drops the border and shadow that would cut the room in half, inverts its ring and text to white, and its pill reads in the org's colour. Its allDone halo was a hardcoded signer-app blue, invisible on a coloured dock. The loading, failed-session, consent and step-up gates now keep the desk under them and the dock beneath, so the room never loses a wall mid-flow and Back is reachable from a screen the member otherwise cannot leave.
· EmailService
Prove a folder unchanged with HIGHESTMODSEQ, then serve it from the index
Turns the index-backed message list on, by fixing the thing that made it unsafe rather than by flipping the flag. The old check compared message counts and UIDNEXT. Both are blind to a flag moving, and nothing re-indexes a message when someone reads it on their phone, so a folder passing that check could be served with every message's read state frozen at the moment it arrived — an inbox showing read mail as unread. That is why it shipped disabled. CONDSTORE has the number that does move. HIGHESTMODSEQ is folder-wide and advances on anything at all: an arrival, an expunge, a flag set from any client anywhere. So "at modseq N the newest K messages were indexed" plus a fresh modseq of N is a proof that nothing has changed since, flags included. Verified on the live server before building on it — /usr/libexec/dovecot/imap (2.3.20) carries CONDSTORE, QRESYNC and LIST-STATUS. UIDVALIDITY is checked alongside, since a server that changes it has renumbered the folder and nothing indexed under the old value describes the same mail. Jakarta Mail's own STATUS only ever asks for the five classic items, so the extended form is issued directly and the reply parsed here — under test, against literal wire data, because that one number is the whole safety argument. The checkpoint lives in Redis, not a table. It is a sync marker whose correct behaviour on loss is "forget it and re-read", so a shared-library entity, a migration and a KamoInitializer run would be the wrong price for it. No backfill job, deliberately. When the proof fails the request reads from IMAP exactly as before and hands the result back to be indexed, so folders fill in as they are looked at — the same order in which being fast matters, and nothing that can run away across every mailbox on the platform. That also addresses why this would have been pointless before: the index held 31 rows in total. Two orderings matter and both fail in one direction only. The modseq stored is the one read before the fetch, never after, so a change landing mid-fetch leaves the checkpoint behind reality and costs one wasted fetch — the reverse would pair a current-looking modseq with stale mail. And rows already in the index have only their flags updated, never their body, because a row the full indexer wrote carries the text search is built from, and rebuilding it from an envelope would quietly drop messages out of search while making the list faster.
· kamo-shared-library
Map is_answered, now that the column exists
KamoInitializer added **************** on 2026-08-13 **************** verified in Yugabyte: boolean, default false, no nulls). Mapping it is safe now and was not before — fullTextSearch is a native SELECT * whose rows Hibernate hydrates into this class, so a column the table lacks breaks email search itself, not just writes. That is why this went out and came back a few hours ago. SearchIndexService records it from here on, so newly indexed messages carry all three IMAP flags rather than two.
· EmailService
Serve a folder listing from the index when it can prove it is current
The rows behind a message list are already in Postgres — the metadata index holds sender, subject, date, size, attachment and flags for every message it has seen. Reading a page from there is one query; reading it from IMAP is a SELECT plus a fifty-envelope FETCH and makes the server build the mailbox index to answer. What stopped anyone using it was not speed but trust. The index is filled by a push indexer that only runs while somebody has the mailbox open, so it is allowed to be arbitrarily stale, and nothing distinguished a current folder from a stale one. One STATUS distinguishes them. It is the cheapest question IMAP answers — no SELECT, no message data — and it returns the folder's message count and its next UID. The index is used only when both match what it holds: the count catches anything added or removed, and maxUid + 1 == UIDNEXT catches the case a count cannot see, where as many arrived as were deleted. UIDNEXT only moves forward, so an append the index missed leaves it stranded above the highest UID the index knows. Every other outcome falls back to IMAP: a missing folder, a server that decoders STATUS, an empty index, a database error. The worst available result is the behaviour that existed before this. A folder that fails the test heals itself when the next message arrives and is indexed. Off by default behind **************** because the reply marker depends on search_index.is_answered, which does not exist until KamoInitializer has run. Turning it on is then a deliberate act rather than a side effect of a deploy. The agreement predicate is where being wrong shows someone the wrong mail, so it is pure, package-private and tested from that direction — every case asks whether it can use the index when it must not.
· InitializerService
Add is_answered to the mail search index
Adds **************** so the index records all three IMAP flags rather than two. The message list draws a reply marker from this one, and the index-backed listing in EmailService cannot be turned on until the column exists. Order 0, for the usual reason: SearchIndexEntity maps the column from the moment the shared library carrying it is built, so every runner that loads that entity emits it in its SELECT and ddl-auto cannot add it for us. A late-ordered runner would never execute — an earlier one would die on "column does not exist" and take the run with it. Nullable with a FALSE default and a backfill of existing rows. Not read back from the mail servers: false is already right for the overwhelming majority of messages, and the read path refuses to serve any folder it cannot prove it agrees with the server about, so a row that is stale here is a row it will not use. Re-indexing corrects it.
· kamo-shared-library
Record whether a message was replied to in the mail index
email_metadata.search_index has carried is_read and is_starred since it was created but never the third IMAP flag, \Answered. That was harmless while the index only fed full-text search, which does not care about it. It stops being harmless now that the index can serve a page of a folder directly: the message list draws its reply marker from this flag, so a row served from an index without it would state, silently and wrongly, that the member had never replied to that message. The column is added by KamoInitializer's **************** and does not exist until that has run — which is why the read path that needs it is behind a flag that defaults to off.
· kamo-internal
Clock-out indicator wears the same pulse as legal & compliance
The punch clock breathed a red radial glow while the obligations badge three pixels to its left pulsed two expanding rings. Both controls are asking the member to go and do something, and two different animations that close together read as two unrelated systems — each one learned separately. One ring animation now serves both, so the keyframe is renamed off the obligations feature it started in. Reduced motion still degrades them differently, because they do not lose the same amount: the badge count and the error colour still say "you owe something", so those rings simply stop; nothing else on the screen says "you are off the clock", so that one holds a single ring open at a steady intensity and drops the trailing one.
· kamo-internal
On screen is not the same as read
A support conversation was marked read the instant its window loaded, so the one kind of window that must NOT count as read — the kind that raises itself, from an assignment, a reassignment, or an arriving reply — cleared the badge before the member had looked at anything. isFocused cannot tell those apart: it is derived from z-order, and a window that just opened is topmost by construction. The shell's mousedown and touchstart are the only signals in the system that mean a person did something, so the provider now announces them as tool:userFocus from the one place both land. From that, the rule the member described: opened deliberately read as soon as it loads raised by itself unread until touched touched read, both stamps reassigned to you unread again, no new message required Two stamps, because there are two ways to be unread and each has to be cleared: markAsRead advances the read pointer past the requestor's messages, and assignment-opened retires the handover. AssignedTicketOpener no longer stamps the handover when it raises a window — that was the bug in miniature, an acknowledgement that the agent had seen something they had not seen. Its per-tab guard carries the weight instead. TicketChat obeys the window rather than deciding for itself, including for messages that arrive while it is open: a reply landing in a conversation the member is reading is read, one landing behind them is not.
· MediaService
Unread means the requestor spoke, or the ticket just became yours
The badge counted "live tickets nobody has picked up, plus the ones I hold" — message-blind, and it only ever fell when a ticket reached a terminal status. So it sat at five over a list that marked nothing, and opening a conversation moved neither. It now counts what it says: conversations with something in them for this member to read. Both halves of the old predicate are gone. Unread is one native query shared with the ticket list, so the number and the marked rows are the same fact rather than two approximations that drifted twice. Retiring a handover now broadcasts the badge. It is one of the two ways a conversation stops being unread, and without the broadcast the number stayed put for the case with no new messages in it — which is precisely the case the handover stamp exists for. The badge's own tests move with it: they no longer stub two HQL halves but assert what the number means — a conversation the other side wrote in counts, one just handed over counts even with nothing new in it, and each ticket counts once however many ways it qualifies.
· kamo-shared-library
One definition of an unread support conversation
The navtop badge and the ticket list have twice drifted apart by answering different questions. findUnreadStateForViewer is the single answer both now read, so they cannot disagree again. Unread means the other side of the conversation spoke and you have not looked. For the assigned agent that is specifically the REQUESTOR speaking — a colleague's note on the same ticket is not something the agent owes a reply to, so it raises no flag. For the requestor it is anyone on the support side. Anyone else is not a party to the conversation and sees nothing, which is why the query is scoped by role rather than by org: a cross-org request is answered by an agent in a different org from the person who raised it, so neither side's org identifies both. handoverUnread is the second way to be unread, with no new message involved. Being handed a conversation is itself the thing you have not seen, so a reassignment lands unread however old its last message — otherwise a ticket moved to you in the morning stays invisible until the customer happens to speak again. assignmentOpenedAt already carried exactly that: cleared on every (re)assignment, stamped only once the agent has really put it on screen. RESOLVED stays eligible on purpose. A requestor replying to something an agent marked resolved is the case most worth getting through, and the one that would otherwise be silently dropped; only CLOSED and VOID end it.
· kamo-internal
Give the expanded rail a scrollbar instead of end arrows
The up/down arrows exist because a collapsed rail is 80px of tile with no room to spend on anything else. Expanded, the rail is 248px and the options read as a list — there the arrows are the worse instrument: they say only "more that way", where a scrollbar says how much more and where in it you already are, and they float on top of two rows instead of sitting beside them. So the scroller now takes an `affordance`, and NavPri asks for the scrollbar whenever the rail is wide (persistent expansion or a live hover-peek). Everything else about the strip is unchanged in both modes: grab-and-throw, the wheel, touch, and scrolling the current page's option into view. Two details the switch forces: - the soft edge stands down, because a mask paints an element's scrollbar along with its content, and a fade over the ends of the bar it is meant to accompany looks like a rendering fault; - grab-to-slide now ignores a press that lands on the gutter. Chrome delivers pointerdown there, so without it one drag of the thumb moved the strip twice — the browser scrolling it one way and the pan handler the other.
· kamo-internal
Use the real payroll vendor logos on the provider cards
Adds the ten vendor assets and renders them in place of the lettermark placeholders. These are WORDMARKS, not square icons like the email provider set, and their aspect ratios run from 0.72:1 (Dayforce, taller than wide) to 6.2:1 (Paychex). The email cards' 44px square tile would squash most of them and shrink the wide ones to an illegible smear, and a border drawn around a wordmark reads as a mistake rather than a frame. So the mark now renders at a fixed HEIGHT with automatic width and its own aspect ratio, capped so Paychex cannot dominate a card. One consistent height across ten different shapes is what makes the grid look deliberate. Each asset's intrinsic viewBox dimensions are recorded alongside its path because next/image needs them to reserve the correct box — handing it a square for a 6:1 asset reintroduces exactly the distortion this avoids. On the card the logo takes its own row above the name and status chip, rather than sitting beside them as an icon would. The logo is marked decorative wherever the provider name is rendered next to it, so a screen reader reads the name once rather than twice; the lettermark tile stays for GENERIC_CSV, which is a connection state rather than a vendor and has no asset.
· kamo-shared-library
Carry whether a ticket is one of the items the support badge counts
The navtop badge and the ticket list were answering different questions and nobody could tell. The badge counts items awaiting attention — overwhelmingly tickets nobody has picked up yet, which is a fact about the TICKET. The list marked unread chat messages, a fact about its CONVERSATION. So the badge read "5" over a list that marked nothing, and the two numbers had no visible relationship. needsAttention carries the badge's own membership test per row, so the list can mark exactly what the badge is counting. unseenCount stays: "somebody said something you have not read" is still worth showing, and it is not the same thing as "nobody is handling this".
· kamo-internal
Payroll Provider Setup, and map people to payroll on enrollment
Replaces the placeholder HRS Overview tab with the real thing. The tab is now "Provider Setup" and the screen is "Payroll Provider Setup", following the email provider screen it is modelled on — the tab key stays "overview" so the settings menu, the launchpad card and every existing bookmark keep working. Built so an HR administrator with no technical background can answer three questions without asking anyone: is this connected, did it sync, and is anything wrong right now. - Provider cards show what each vendor can actually do BEFORE the org commits, so choosing is a comparison rather than a guess, and an absent capability is visible up front instead of discovered on payroll day. - The credentials form is rendered from the BACKEND's field schema. Adding a field to a vendor is one Java change instead of a new React component — the email screen hand-writes one component per provider and has twelve. - Secrets are never sent back to the browser, only a "this is set" flag, and a blank box means unchanged rather than clear. - The health panel leads with the two ways hours silently fail to reach payroll — unmapped people and unmapped earning codes — because both are invisible at the time and only surface as a pay complaint. - A partial run is its own colour. "412 of 415 people" is not a success and not a failure, and rendering it as either hides three unpaid people. - Run detail names the person and the fix, not a stack trace. Issue codes come from the server as stable tokens and are localized here. Enrollment gains the payroll mapping column, because enrolling somebody and telling payroll who they are is one job. Auto-match proposes on exact work-email equality and a human confirms — a name is not an identity, and a wrong link sends one person's hours to another person's pay. An already-claimed provider record stays visible but unpickable. That screen also had no translations at all and rendered raw 19-digit member ids; both fixed, with the display-name lookup extracted to a hook since every timecard screen has the same gap. Platform advanced config gains the seven payroll vendors where Kamo holds one partner registration. Deliberately no card for Dayforce, Workday or UKG: they issue credentials per customer, and the screen renders a card for every enum constant, so one for them would be a form no operator could fill in. RoleRightType mirror gains 197, 198 and 199 — the first two existed server-side and were already missing here, so checkRight had nothing typed to reference.
· InitializerService
Index and REVOKE the payroll-provider tables
What Hibernate cannot express for the new hr_timecard_payroll_* tables. Two REVOKEs. PayrollDispatch and PayrollSyncIssue refuse updates and deletes in Java through @PreUpdate/@PreRemove, but entity callbacks fire only for rows loaded into a persistence context — bulk JPQL, deleteAllInBatch() and native SQL walk straight past them. A payroll transmission attempt is a money event, and evidence about a completed sync has to survive to be evidence. Two partial indexes, which the entity cannot carry because they have predicates: the sweep's work list (orgs with syncing enabled and a sync due, a small minority of rows) and per-member sync errors (ERROR rows, outnumbered by INFO roughly by the size of the roster). Applied and verified against Yugabyte.
· kamo-internal
Size an attachment by what the provider's API actually carries
The composer held every social DM to a flat 10 MB, which is Telegram's photo limit applied to four providers that do not share it. Each now gets its own ceiling, and within a provider each media type gets its own where the API splits them: Telegram 10 MB photo, 50 MB document/video/audio (sendPhoto vs sendDocument) Messenger 25 MB, the same for every kind Instagram 8 MB image, 25 MB video/audio/file Discord 8 MB (the bot upload ceiling) X no attachments at all unknown 8 MB, the tightest of the above These are the limits of the API each adapter posts to, NOT of the consumer app of the same name. The two differ and it matters: Messenger's app raised file sharing to 100 MB while its Send API is documented at 25 MB, and it is the API's number that decides whether the customer receives the file. Sizing to the app's figure would have the composer accept something Meta then refuses, long after the agent has been told it went out. X gets no paperclip rather than one that refuses everything: XAdapter's sendImage and sendMedia both fall through to sendText — media upload was out of v1's scope — so an attached file would go nowhere while the caption arrived alone. Its own ProviderCapabilities says the same. AttachmentPolicy grows `maxBytesByClass` for this, keyed by a new four-value AttachmentClass (image/video/audio/file) that matches how providers split their endpoints and how MediaService routes outbound media. SVG is a file in both.
· DaemonService
Nightly payroll-provider sync job
Drives TimecardService's **************** which syncs every organization whose next sync is due. Cron lives here rather than in TimecardService because that deployment is RollingUpdate with maxSurge 1, so two pods overlap on every rollout and a @Scheduled method fires twice — which for a payroll sync means two pushes of the same hours. DaemonService is replicas 1, strategy Recreate. Scheduled at 03:00, forty minutes after the timecard sweeps: those close open shifts and detect exceptions, and sending hours to a payroll provider before they have run would transmit numbers that are about to change. Refuses to run on a blank mlos.internal-auth-secret rather than logging a false success — a payroll sync that quietly never runs looks exactly like one that found nothing to do, and the difference is a pay period of hours that never reached the provider.
· SecurityService
The authorization boundary for payroll provider setup
Relays the new **************** surface and enforces who may reach it. TimecardService holds the engine and no session; this holds the session and decides. SecurityService runs anyRequest().permitAll() with hand-rolled per-handler auth, so a handler that forgets its guard is not weakly controlled — it is uncontrolled and reachable from the public internet. Mapped under the existing /api/security/payroll prefix on purpose: kamo-internal reaches it through a catch-all BFF route, and a new prefix would 404 in the browser while every service is healthy and every build is green. Four tiers: - MANAGE_PAYROLL_PROVIDER (199) writes credentials and connects or disconnects OAuth. This is the ability to decide WHERE an organization's hours are transmitted, which is why it is its own right. - MANAGE_HRS_SETTINGS (45) reads the connection and its health. - MANAGE_TIMECARDS (183) covers employee mapping and sync history. - FINALIZE_PAY_PERIODS (198) transmits a committed batch — the same tier as committing the export, because it is the same act. Test Connection sits with the write right rather than the read one: it is an outbound call made with stored credentials, and letting anyone who can view the page fire it hands them a way to probe the org's payroll vendor. The OAuth exchange does NOT forward an org id. TimecardService takes it out of the HMAC-signed state, so a code obtained for one tenant cannot be redeemed against another even by a caller holding the right in both. Adds put/delete to TimecardServiceClient, which had only get/post.
· kamo-shared-library
The payroll-provider domain behind the timecard engine
Adds the persistence spine for connecting an organization's timecards to a third-party payroll system: the connection and its encrypted per-org config, the OAuth grant, the member-to-provider-employee link, the earning-code map, and the two append-only records (dispatch attempts and sync issues) that make a sync explainable afterwards. Kept inside com.kamo.z.shared.hr.timecard rather than a new package on purpose: a new shared-lib package has to be added to three separate explicit @EnableJpaRepositories lists, and omitting one aborts a service context — or, in KamoInitializer, aborts the run while still reporting BUILD SUCCESS. Notes on the shape: - PayrollProviderType reuses the token vocabulary already documented on **************** (ADP_WFN, PAYCHEX, GUSTO, ...). A second spelling for the same vendor would split export history with no error anywhere. ADP_RUN and QUICKBOOKS are kept because the payroll export screen has offered them since it shipped, so live rows may carry them. - Employee links are keyed by provider, not by connection, so switching provider and switching back does not throw away the mapping work. TWO unique constraints: one stops a member holding two links, the other stops two members claiming one provider employee — that one prevents a double-pay. - PayrollDispatch and PayrollSyncIssue are WORM. A payroll transmission attempt is a money event; "we sent it, then we sent it again" is the fact a reconciliation needs. - Credentials live in ONE encrypted config_json blob rather than a column per field, so adding a vendor is not a schema change that re-arms every service. RoleRightType gains MANAGE_PAYROLL_PROVIDER (199) — deliberately NOT the legacy MANAGE_PAYROLL (40), which gates nothing today and is already held by unknown numbers of members; reusing it would hand third-party payroll credentials to every rate-viewing right holder on the boot that shipped it. PlatformOAuthProviderType gains the seven payroll vendors where Kamo holds one partner registration (ids 11-17). Dayforce, Workday and UKG issue credentials per customer and deliberately get no constant: the platform screen renders a card for every constant, so one for them would be a form no operator could fill in. Also fixes a stale assertion in BillingDelegationModeTest: commit 9276487 deliberately made REQUIRES_APPROVAL allow groups (the exclusion made the approval path unreachable) but left the test asserting the old behaviour, so the suite has been red on main since. Schema applied and verified in Yugabyte before this push.

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