Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-internal

    Draw departments as an org chart with a parent picker

    The departments tab was a flat list, so an org with Operations > Customer Success > Onboarding had no way to say so and no way to see it. Add/edit gets a Parent department select, placed with the name because parentage is structural rather than operational. It offers the tree in render order and disables the department itself plus everything under it -- those are exactly the choices the server rejects, and offering a choice that always fails is worse than not offering it. The list becomes an org-chart spine. One rail column per level, each exactly one icon chip wide, so a row's elbow lands on the centre of its parent's chip instead of near it; a last child's rail stops at the corner rather than running on, which is what makes the end of a branch visible without counting indents. Cells carry no vertical padding -- the rails have to reach both edges or the line breaks at every row. Depth reads through tint and type weight, not chip size, since uniform chips are what make the rail geometry exact. Column headers become Operator Labels on a frosted bar, replacing hardcoded blue-50/indigo-50/gray-900 that ignored the brand token. Tree building lives in departmentTree.ts so the awkward cases have somewhere to be tested: a parent deleted out from under a child renders at top level rather than vanishing, and a cycle that reached the table renders instead of hanging the page. Failed saves now report the server's own explanation. The 4xx body was being dropped in favour of the status line, which would have turned "a department cannot be its own parent" into "Bad Request".

  2. · kamo-shared-library

    Let a department sit under a parent department

    Departments were a flat list, so an org chart with Operations > Customer Success > Onboarding had no way to say so. Department gains an optional self-referencing parent; null still means top level, which is what every existing row stays. The FK column pins columnDefinition = "UUID". Left off, Hibernate copies the referenced primary key's definition -- UUID DEFAULT gen_random_uuid() -- onto the FK, so a ddl-auto run over a populated DEPTS stamps every row with a random parent matching no department and the ADD CONSTRAINT that follows fails. Parent assignment is validated in the service, not just the picker: self-parent, cycles (walking up from the proposed parent must not reach the department being moved), a parent in another organization, and an unknown id are all refused. Deleting a department promotes its children to its own parent rather than stranding them behind a dangling pointer. **************** now take their request DTO instead of eight positional arguments, which is what made adding a ninth worth avoiding.

  3. · kamo-internal

    Grow the To/Cc/Bcc fields to three rows, then scroll

    The recipient fields in the compose tool window were a single nowrap row that had to be dragged sideways to read, and the window is 360px wide and not resizable, so a third address was already out of sight. They now wrap: the field grows a row at a time as chips fill the width, stops at three rows, and scrolls vertically past that with a slim scrollbar. Adding a chip follows the input down so the caret stays visible. The row cap is a pixel max-height, computed from the measured chip height so it always cuts on a row boundary — a maximized composer and a scaled browser font both move it. The label and the contacts button pin to the first row rather than floating in the middle of a three-row block. Drops the now-meaningless `mode` prop and the horizontal drag-to-scroll it existed for.

  4. · kamo-internal

    Make org creation survivable, and offer every branding option up front

    The wizard could fail a customer in ways nothing on screen explained, and it asked for a fraction of what Settings → Behavior & Labeling exposes. Creation reliability: - Logo and background uploads went to /api/imaging/upload with assocType "ORG_LOGO" — not an ImageAssocType constant, on an endpoint whose assocType is a numeric ordinal. The parser swallowed the failure and filed them as MY_DOCS, so branding silently never existed. They now use the real org branding endpoints, one call per logo slot. - The creator's phone/email PUT could only ever 403: that endpoint requires session-org to equal the member's org, and the session is still on the parent. Moved onto the create payload. - Post-create warnings were collected, passed to /setup/dns as a query param, and read by nothing. /setup/dns now renders them. - Submit was never re-validated, so clearing the name after reaching Review created an org with a NULL title and alias. - deriveConfigType read the branch type's ALLOWED age ranges instead of the selected one, stamping KIDS_CLUB on adult businesses — which disables email and phone account recovery. - Colour variants were never derived, phone inputs were unbounded against VARCHAR(20), a stale age range 403'd on submit, and a taken domain surfaced as a bare 500. Behavior & Labeling parity — everything except the two mortgage identifiers: website, company contact email, white-label identity, all seven social profiles, the eight custom labels with their inherit-from-parent flags, default language and currency, private-org visibility, background watermark, the three brand-source selectors, and all four logo slots. Two new steps (Labels, Preferences); timezone moves to Preferences beside its siblings. All of it rides on the create payload, not follow-up calls: PATCH /org/{id}/branch-titles has the same session-org check that broke the member update, and one transaction means the answers either all land or none do. Guidance and skippability: Every input has help text grounded in what the value actually does. Only Type and Basics carry required fields, and that is now stated on the step header, in the stepper, on the nav bar, and in the sidebar — with "Skip for now" on every optional step. Also: settings no longer destroys org phone extensions on save, /api/generate-background is no longer an unauthenticated billable proxy, and the org-by-id proxy stops corrupting int64 ids.

  5. · kamo-internal

    Send the editor's back button where the member came from

    The wallpaper editor was a dead end — no way out but the nav rail. Both entry points (the home top-bar chip and the member profile header shortcut) now stamp their origin on the href, and the editor resolves that into a Back pill in the eyebrow row: home returns to the dashboard, a profile returns to the member whose page it was, using the stamped id rather than assuming it is the wallpaper's owner. A bare URL with no stamp falls back to this member's profile. fromId is checked against the opaque-id shape so a crafted URL cannot turn the button into an arbitrary redirect. Reading it needs useSearchParams, so the page gains the Suspense boundary the App Router requires.

  6. · kamo-internal

    Show a primary mailbox under its member's profile name

    A mailbox held as somebody's primary is that person's address, so suggesting it as "Sales Desk" — whatever it happened to be labelled when it was provisioned — names the mailbox instead of the person. The autocomplete row and the recipient card both read the same field, so both were wrong together. The directory now takes primaryMemberName ahead of the mailbox's displayName, and assignedMemberName for a personal mailbox, whose single assignee is its owner. Aliases and shared mailboxes belong to no one in particular and keep their own name. The preference is a per-source list, so a backend that has not yet shipped the field simply falls back to displayName.

  7. · EmailService

    Name a primary mailbox after the member who holds it

    A mailbox someone holds as their primary is that person's address, so presenting it under whatever the mailbox was labelled at provisioning time — "Sales Desk" for a box that is simply Jane Doe's — misnames the person in the compose autocomplete and on the recipient card. listMailboxes now carries **************** for the member with the primary assignment, which the org address directory prefers over the mailbox's own displayName. Only the primary holder counts: a mailbox somebody merely has access to is not theirs and keeps its own name, and the settings table still renders displayName either way. The name resolution moves out of AccessibleMailboxService into a shared helper so the two surfaces cannot disagree about what a member is called.

  8. · kamo-internal

    Register the commission pages in the settings nav and landing page

    The tabs existed but nothing linked to them. **************** is the cautionary example already in this repo: a real page with no card, no tab and no nav entry, reachable only by typing the URL. Accounting & Finance is its own section and its own nav topic rather than two more entries under My Account. Sub-options carry no gate of their own, so folding these in would have forced the account gate open to finance-only holders and shown them four links they cannot use — the same reasoning that keeps phi-compliance out of the security topic. With the finance tabs in their own section, the My Account gate goes back to CONFIGURE_SYSTEM alone. The personal Commissions card is conditioned on the viewer being a team member, mirroring the tab's own gate. A card that opens a tab which will not render is worse than no card.

  9. · kamo-internal

    Member commissions tab and the two finance account tabs

    Member tab (self-only, team members only): balance + ledger, commissioned opportunities, payout setup, and withdrawals. Account settings gains Organization Stripe and Commission Processor. The member tab is gated on isSelf and the member TYPE, never on a right. checkRight returns true for everything under active god mode, and the page's canViewOtherMemberAccount ORs in unrelated rights including **************** — so a right-based gate here would show one member's balance and bank details to anyone holding any of them. 'My Payouts', not 'My Bank'. Members almost never have a Stripe account and cannot attach one if they do: OAuth read_write refuses accounts controlled by another platform. Their employer's platform creates a payout-only recipient account and they verify their identity. The panel states what Stripe collects, that Stripe has no support relationship with them, and that money takes roughly 6-8 calendar days to arrive - none of which they should have to discover afterwards. Organization Stripe is a guided checklist, not a form, because Stripe has no API for becoming a Connect platform. Step 6 carries a full warning rather than a hint: an org whose Stripe balance pays out automatically loses the commission float before the release job runs, and every transfer then fails for insufficient funds with no Stripe event to explain it. Money is a decimal STRING end to end and is never Number()-ed except to choose a colour. Negative amounts are real - a voided commission becomes a draw - and the codebase had no convention for showing one, so they render in the error tone with an explicit minus and a Draw chip. Sub-tabs sync to ?sub=; SettingsView owns ?tab= and rewrites it on every tab change. The BFF route exists because CommissionService is ClusterIP-only - without it the whole feature 404s while every service is healthy and every build is green.

  10. · kamo-internal

    Folders you can act on, mail you can select

    Right-click any folder for its actions. The menu header is not chrome — it names the folder and shows its live unread/total, so Mark all read and Empty folder can be judged before they are clicked rather than after. Actions that cannot apply are shown disabled with the reason (nothing unread, folder empty, built-in folders keep their names) instead of being Empty and Delete route through a confirmation built for the mail chrome. The app-wide MUI ConfirmationDialog demands a typed challenge word every time; a challenge on a recoverable action only trains people to type through warnings. So the gate is reserved for the two that destroy mail outright — deleting a folder and emptying Trash — and both name the consequence exactly: everything else moves to Trash and says so. Folders reorder by drag, per member, stored in the shared ui-preferences table so the arrangement follows them across devices. The order is one flat list of folder names: siblings are only compared against each other, so one list orders every level, and a folder that gets renamed or moved falls back to its natural position instead of corrupting a tree. Drops across levels are ignored — that would be a reparent on the mail server, not a sort. Selecting mail now works the way selection works everywhere else: shift-click for a range from the anchor, cmd/ctrl-click to pick one out, and the avatar turns into a checkbox on hover rather than taxing every row with a permanent checkbox column. The actions ride in a bar that floats over the list and leaves with the selection, instead of a strip wedged into the chrome that shoved the list down on every tick. Also: the Manage Folders footer is gone — the button beside the Folders heading already creates folders — and the refresh button is finally square. CSS could not do that on its own: in a flex row the main size resolves before align-self:stretch gives the item its height, so aspect-square had no height to derive a width from. It measures the row instead.

  11. · EmailService

    Act on a whole folder, not just the page you scrolled

    Mark all read and Empty folder are folder-wide operations the client cannot express by batching uids: it only ever holds the page it has scrolled, so a folder with 4,000 unread would take 80 round trips and still miss everything it never loaded. FolderManager gains setFolderSeen and emptyFolder. The IMAP path flips flags in one server-side call and only touches messages that actually disagree with the target state — a blanket setFlags rewrites flags the server already has and makes every connected client resync for nothing. Graph has no bulk equivalent, so it walks the folder and PATCHes; the filter shrinks as it goes, so it re-reads the first page instead of paging with $skip, which would step over items the previous batch just flipped out of the result set. Its sweep ceiling is logged, never silent. Emptying keeps Trash special: Trash is expunged for real, every other folder moves its contents to Trash and stays recoverable. Graph's DELETE already behaves that way, so no folder-name special-casing there. Both endpoints return how many messages moved, so the UI can tell "marked 240 read" from "nothing to do", and only publish a change event when something actually changed.

  12. · SecurityService

    Browser-facing relay for the commission feature

    Resolves the session, authorizes, and relays to CommissionService with the org and member stamped from that session — never from anything the caller sent. This is the only place rights are checked: SecurityService runs anyRequest().permitAll() with hand-rolled per-handler auth, so an endpoint that forgets its check fails OPEN and is reachable through the public API relay. Three rights rather than one, so separation of duties is expressible: with a single grant, whoever issues a commission could also release the payout and cancel the hold that exists to catch them. Money-moving and payout-destination actions hard-refuse under impersonation. Impersonation mints a full session as an arbitrary member, so without that refusal a god admin could enter as somebody, point payouts at their own bank, withdraw, and cancel the hold. The organization owner is an implicit holder. A brand-new right is granted to nobody on the day it deploys — including to anyone who could turn the feature on — so without an owner branch the feature ships dormant and unreachable. /me/opportunities is served here rather than relayed, because SecurityService owns the opportunities model; registering those repositories in CommissionService too would give one shared-library model two writers. It unions the member's split rows with the reservations where they are the primary agent, because the agent's own commission produces no split row and a query checking only one side silently hides half a member's earnings.

  13. · kamo-shared-library

    Find a member's commission splits for their own commissions view

    Org-scoped rather than keyed on the member alone: ORG_ID is duplicated onto this table so a row can never be read cross-tenant by id, and a finder that ignored it would quietly undo that. Only half of "opportunities I earn on" — the reservation-level commission belongs to the primary agent and produces no split row at all, so callers must union this with that agent's own reservations. Documented on the method so the next caller does not ship a view that silently hides half a member's earnings.

  14. · kamo-internal

    Make the member surface a real kamo data grid

    The member's packages were a card list with no motion, which is not how the rest of kamo looks. This is now the house AG Grid: Pending and Completed sub-tabs synced to ?sub=, server-side paging end to end (page/size/status reach DocsService rather than slicing a 200-row cap), filling the pane instead of sizing to its rows, and no title — the settings tab already names it. Status reads at a glance rather than as a grey chip: a due date carries its own urgency, and waiting-on-others and content-changed are legible states instead of hidden ones. Motion is restrained and gated on useReducedMotion.

  15. · kamo-internal

    Show the member's name without the "Hi," prefix

  16. · DocsService

    Server-side paging for the member assignment listing

    The member surface asked for MAX_SIZE unconditionally and sliced the result in the browser, so a grid that showed a page had already downloaded a whole employment history to build it. Both member readers now take page/size and the listing pages in the database. Spec.status (one enum) becomes Spec.statuses (a Set) and the wire parameter stays `status`, now accepting a CSV. That is the point: the member grid asks for a BUCKET — the three OPEN_STATES that still owe work, or the four terminal ones — and a single-valued filter cannot express either without three round trips per page. One token parses to a one-element set, so the HR grid's existing status=EXECUTED is byte-identical on the wire and needed no client change. An unknown token in a list is a 400 naming the token, never a silently dropped filter: a compliance grid that claims to be filtered while showing everything is the worst available outcome. clampPage/clampSize are exposed so the member readers clamp through the SAME rule as the HR grid instead of passing MAX_SIZE literally, which is how a "paged" endpoint quietly becomes an unbounded one. listMine forwards its paging without re-deriving it, so the clamp exists once. Authorization is untouched and the tests pin it: member endpoints stay on memberGuarded() + LegalAssignmentOwnership, HR endpoints stay on guarded() + LegalPackageAccess, and the three new @RequestParams are asserted by name to be exactly status/page/size — the whole reason this surface is safe is that nothing on it names a person. No schema change. No new entity. mvn -o test: 391 tests, 0 failures, 0 errors (was 379).

  17. · kamo-internal

    Personal Email Notifications card replaces the Notifications tab

    Both switches move onto My Profile in the same shape as the password card: one card, an icon tile and help text per row, and a tinted inset that appears only when quiet hours are on. No header Edit button — the switches save themselves, debounced, exactly as the tab did. The card leads with where the mail goes, because that is the part members get wrong: notices are sent to the address on their USER account, which the card names, and never to the mailbox this organization assigned them — a mailbox they would have to sign in to read, which is the very thing the notice is telling them to do. SecurityService returns that address only to the member it belongs to, so the note falls back to an unnamed form if it is absent. TeamMembers only: the notifier serves internal chat and support tickets, which a plain Member does not work in. Self-only besides, since the preference route is me-scoped and on someone else's profile these switches would edit the admin's own quiet hours.

  18. · SecurityService

    Expose the user-account email on your own member profile

    The profile's Personal Email Notifications card has to name the address missed-message notices actually reach, and that address is the user account's — not the `email` field beside it, which is the mailbox this organization assigned the member. Returned only when the session is reading its own member row. A personal address is not something an admin should collect off a colleague's profile, and no other caller has a reason to want it.

  19. · kamo-internal

    Accounting & Finance permission group and finance-right access to account settings

    The Access Security and Administration groups existed only in SecurityRoleManager as inline name lists, so JobTitleManager, DepartmentManager and RolesPermissionsTab lumped every serviceType-less right into General. Rather than add a fourth copy, the group definitions move to permissionGroups.ts and all four editors read them. isGeneralPermission is the reason that matters: filtering on !serviceType alone would render a carved-out right twice, once in General and once in its own section, giving one setting two independent toggles. MembershipPermissionsTab is deliberately untouched - its General Permissions list is a hardcoded read/write/delete/admin mock, not real rights data. /settings/account and its landing section now also admit the finance rights. A controller holding only MANAGE_ORG_FINANCES would otherwise be redirected straight back to /settings; widening only one of the two would leave the page reachable by URL but invisible in the nav.

  20. · InitializerService

    Migration for the commission ledger and Stripe payout tables

    Mirrors com.kamo.z.shared.commission. ddl-auto would create these from the annotations anyway; the explicit DDL is the house convention and is what makes the shape reviewable in one place rather than across a dozen entity classes. COMMISSION_STRIPE_WEBHOOK_EVENT is keyed on (ORG_ID, STRIPE_EVENT_ID), not on the event id alone. Every organization runs its own Stripe Connect platform, so event ids only have to be unique within one account - LOAN_STRIPE_WEBHOOK_EVENTS keys on the bare id and is safe only because that feature has a single Stripe account. Applied against prod; all nine tables and every unique guard verified present.

  21. · SecurityService

    Seed the finance rights onto every branch model's ADMINISTRATOR template

    The three commission rights gate a feature that did not previously exist, so NEW_RIGHT_SEEDS deliberately says nothing about them - that mechanism carries an existing grant forward so a deploy never silently REMOVES access, and using it here would hand money-moving power to people who never had it. What they need instead is a role-type default, which did not exist. Two parts: - templateSeedSettingFor gains a DefaultRoleType overload granting the finance rights on ADMINISTRATOR templates only. GRANTED, not FORCE_GRANTED: an org must stay able to revoke a money right from one administrator on their member profile, and FORCE_GRANTED overrides every later layer of the waterfall. - ensureRoleTypeTemplates creates the ADMINISTRATOR row for models that lack one. Default-role rows are lazy, created only when someone saves a model's defaults, and syncSecurityModelRoleRights iterates that table - so without this the default would apply to every branch model except the never-customised ones, silently, and those are the ones most likely to be relied on as-is.

  22. · kamo-shared-library

    Team-member commission ledger and Stripe payout domain

    Adds com.kamo.z.shared.commission: an append-only signed ledger, a per-member balance row that exists purely to serialize concurrent withdrawals, the Commission Processor batch/line-item model, and the per-org Stripe Connect payout entities. Three decisions worth keeping: - The ledger is append-only and enforces it in Java (@PreUpdate/@PreRemove throw). @Column(updatable=false) only makes Hibernate omit a column from the UPDATE, it does not make the write fail, so a stale correction would look like it succeeded. Corrections are reversing rows. - Available vs pending is a query over EFFECTIVE_AT, not a state machine. A commission issued today with next month's maturity moves to available on its own, so no sweep exists that could fail and strand money as pending. - COMMISSION_MEMBER_BALANCE is redundant with the ledger by construction. It exists because a balance is an aggregate and SELECT FOR UPDATE cannot lock an aggregate; without one row to take PESSIMISTIC_WRITE on, two concurrent withdrawals both pass their own sufficiency check. Money is NUMERIC(19,4) throughout, org ids are plain INT8 columns rather than associations so financial records outlive member churn, and ORG_ID is duplicated onto child rows so no single-row fetch can read cross-tenant. Also adds three rights (187-189) for the finance surface. They are split so separation of duties is expressible: with one right the person who issues a commission could also release the payout and cancel the hold that exists to catch them. serviceType is null deliberately - a new ServiceType would group them automatically but render them in no editor at all, since app sections only appear when the org has that app enabled. Schema applied via KamoInitializer; all nine tables verified present in prod with ALL privileges for the kamo app user.

  23. · kamo-internal

    Fold MFA into the password card and drop the Sign-in Security tab

    Two places asked one question — how do I get into this account — so the second factor now lives beside the password on My Profile, in a card retitled "Password & Multi Factor Authentication". The enrollment flow moves out of the tab into **************** chrome-free so it renders as a row rather than a page: status pill, numbered QR/secret and code steps in one inset, and the same undismissable recovery-code dialog. The card header carries no Edit button now that it owns two independent actions — the password row has its own "Change password", the MFA row its own "Turn on". MFA stays self-only for the reason the tab was: SecurityService's MFA endpoints resolve the user from the caller's session and take no user parameter, so an admin on someone else's profile would be enrolling their own authenticator. Password change is broader (MANAGE_MEMBER_SECURITY, god), so that row alone renders there. Password state moves to PasswordSecurityCard, which fixes a dead branch on the way: the old card compared a *translated* passwordError against English literals, so "Current password is incorrect" never reached the field it belonged to. The error now carries its field.

  24. · kamo-internal

    Suggest the org's own mailboxes, aliases and shared mailboxes

    The recipient autocomplete only knew contacts and addresses scraped out of the Sent folder, so the organisation's own addresses — everything managed on **************** — had to be typed from memory. New GET /api/email/directory fans out to the four EmailService sources behind that page and returns one deduped address list. Aggregated server-side because each of those calls consumes its own one-time key; from the browser it would be four OTK round trips every time a composer opens. Each source degrades on its own, so an org whose provider has no provisioning still gets its aliases. The list is cached in memory and matched locally — orgs have tens of these, not thousands, and a query parameter per keystroke would put a network round trip in front of suggestions the browser can already answer. Org matches therefore paint immediately while the contact and Sent-folder lookups are still in flight, and they lead the dropdown: it is a small exact set, and an internal address that also sits in a contact book should read as the mailbox, not the contact card. Editing mailboxes in Settings drops the cache, so a new address is suggestable without a reload. EmailService's base-URL and forward helpers move out of the catch-all route so the new route mints its OTK the same way instead of keeping a second copy.

  25. · kamo-internal

    One mailbox view, and a column that is all list

    The Conversational/Classic toggle is gone and every member gets the classic list. Conversation view carried its own list, its own thread pane and its own API surface for a second way of reading the same mail — all of it deleted, along with the localStorage mode it remembered per browser. The list column's header went with it. The folder name, its icon, the description under it and the refresh button were six rems of chrome above a list that wanted the height, and the folder tree already says which folder is open. Search and the filter panel move to the folder sidebar, above the Folders label, so the list column is nothing but list. The refresh button returns beside the mailbox switcher as a square: items-stretch on the row and aspect-square on the button make its height track the switcher and its width track its height. It sits beside the single-mailbox card too, so a member without a dropdown does not lose refresh. Filters drop to one column — two selects side by side do not fit a 320px sidebar. An SMTP-only provider has no folder sidebar, so there the search panel stays above the list rather than disappearing. Strings for the removed surfaces are scrubbed from all 22 locales in kamo-translation-dictionary (61fdfcf, pushed first). threadDetail from/to/cc/noContent stay — the lead communications tab renders them.

  26. · SecurityService

    Expose contact email in the by-department roster

    The Interaction Center needs a member's address to pre-fill a composer, and the only endpoint that carried one was the single-member detail fetch — one request per visible row. EmbEmail is embedded on the member row the roster already loads, so serving it here costs nothing and removes the N+1.

  27. · kamo-internal

    Email a member straight from their row

    The row's action dock could chat, meet, dial and open a profile — the one thing it could not do was start an email, so reaching a colleague by mail meant leaving the panel, opening the composer and retyping their address. Adds a mail button alongside the others that opens the composer tool window with the member already in the To field. The address rides along in the by-department payload (EmbEmail is on the member row, so it costs no extra query) rather than a per-row detail fetch, and the module/right gate is resolved once in NavTopPanel instead of per row.

  28. · DaemonService

    Daily legal-package reminder tick

    DaemonService owns the tick because DocsService cannot. DaemonService is strategy:Recreate — the old pod is gone before the new one starts, so a deploy window cannot double-fire. DocsService is RollingUpdate maxSurge:1, so a @Scheduled there provably can: for the length of every rollout there are two pods and both believe it is 13:00. For a job whose output is mail to every overdue employee on the platform, "usually once" is not a property. It also gives us @DisallowConcurrentExecution, JobTriggerController's manual trigger and a cron tunable without redeploying DocsService. Modelled on LeadEmailSweepJob, including the part that is load-bearing rather than stylistic: a default RestTemplate waits FOREVER, so a wedged DocsService would pin this thread and — under @DisallowConcurrentExecution — stop the sweep until the pod restarts, silently and indefinitely, looking exactly like "no reminders were due". The timeouts are explicit. Stamps internal.auth.secret (public-chat), the value DocsInternalAuthFilter validates — NOT the MLOS internal secret, which is a different value and 403s every call, the correction StewardNotifier and VoipGlobalSyncJob both needed. Daily, not hourly: the bucket is a function of the day, so an hourly tick is 24x the cross-org scanning for at most one extra send and 24x the blast radius. No configmap change — the docs base URL and the cron both carry working defaults.

  29. · DocsService

    Due-date reminder ladder and the batched, read-only sweep

    The bucket is a pure function of (dueDate, today) and never of "when we last sent": UPCOMING_D{n} for the configured offsets, DUE_TODAY, then PAST_DUE_P{floor(daysOverdue / interval)}. Running the sweep five times on day 9 therefore yields one bucket, and the ledger claim is what deduplicates it — no state is remembered anywhere. The offsets are data; nothing in the class knows the number 3. past-due-max-periods defaults to 0 meaning UNLIMITED, because decision #4 says the past-due reminder recurs and a silent cap looks exactly like a working sweep while abandoning the member most in need of chasing. The cutoff is today + max(upcomingDays) + 1, not now(): the repository predicate is dueAt BEFORE, so a now() cutoff would find only overdue rows and the entire upcoming half of the ladder would never fire. One short read-only transaction per Slice, fully materialising each batch into plain records — Organization and Member are LAZY, so the fqdn, the label, the name and the address are resolved inside it — and the transaction is CLOSED before the first send. EmailService's send is blocking HTTP into blocking SMTP, and a batch held open pins a Hikari connection for the length of every round-trip in it, in a service also serving WOPI. The sweep performs ZERO writes. It cannot serialization-retry, cannot deadlock against the wizard, and paging is stable because it is not mutating the rows it is paging over. dryRun resolves and counts and claims nothing — a dry run that claimed would burn every bucket it inspected and silently suppress the real run. The recorder gains dispatchAllCounted beside the unchanged dispatchAll, because LegalReminderSweepResultDto already carries claimed and failed and a single sent count cannot fill them: on a re-run "claimed 0 of 400" is the correct, healthy answer and "claimed 400, sent 0" is an outage, and the daemon log has to be able to tell those apart.

  30. · DocsService

    Tell the member — assigned, voided and completed

    ONE hr.package_assigned per member per package, never one per document: that is what suppressInvite exists for, and 8 documents x 200 members is 1,600 envelopes for 200 people. The send is one contiguous, transaction-free phase AFTER the assignment rows and the envelopes, because EmailService's send is blocking HTTP into blocking SMTP and interleaving 200 of those with envelope minting stretches a delivery HR is watching a progress state for. Deliver's repair pass and a repeat Deliver both rebuild the same notices and both are refused at the ledger claim rather than by an if — the repair pass exists precisely to re-walk members who were already assigned, so an in-code condition would have to encode "already assigned but possibly not yet emailed", which is the state the ledger already knows. hr.package_voided goes out only for the VOIDED outcome. SKIPPED_EXECUTED must not email — that member finished, and telling them their completed package was withdrawn is both wrong and alarming — and NOT_FOUND must not either, since the id was never this org's and the void service already refuses to resolve a name for it so it cannot become a cross-tenant oracle. Dispatch runs through LegalAfterCommit onto the delivery pool: a serialization retry that rolled the void back would otherwise have already told 200 people their package was cancelled, and there is no unsend. hr.package_completed is a receipt to the member carrying attestedAt and the manifest digest. Called out deliberately: the scope sentence lists assigned and voided, but a canonical template nothing sends is dead weight, and until the filed artifact is downloadable this receipt is the only record the member holds.

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