Changelog
What's new.
Every improvement to the platform, as it ships.
· DocsService
Package attestation on finish, and the EXECUTED/AWAITING_OTHERS split
Writes the WORM LegalPackageAttestation: attestationTextSha256 from the FROZEN version (the package's draft wording is mutable and may already have been reworded), manifestSha256 over the ordered [{documentUid, contentSha256, orderIndex}] actually presented, attestedAt application-set, sourceIp from a right-to-left X-Forwarded-For walk, proofBlob verbatim so the digest can be recomputed later. The client is not trusted for completion. Before anything is written, each signable document's envelope is re-read from ESigService; my recipient must be SIGNED, and PARTIALLY_SIGNED is NOT collapsed the way DisclosureEnvelopeSyncService collapses it, so a pending counter-signature yields AWAITING_OTHERS rather than a package that claims to be executed. A failed read refuses the finish — a timed-out call is not evidence that anybody signed. A package with zero signable documents creates no envelope, calls ESigService not at all, and the attestation is its only execution evidence. A second finish returns the existing row: the entity's @PreUpdate throws by design and the migration's UNIQUE index on assignment_uid is the real serializer.
· DocsService
Identity-authorized member read surface for assignments
LegalAssignmentOwnership is a SEPARATE guard from LegalPackageAccess on purpose: a member executing their own agreement holds no HR right, and widening the HR read set to admit them would hand every member the templates grid and the audience preview's full roster with emails. Everything that is not the caller's own assignment answers an identical 404 — a 403 would confirm the uid is real. detail() does the first-view bookkeeping on the HR row (firstViewedAt, PENDING -> IN_PROGRESS) rather than in ESigService, so the recipient-status machine is untouched and the badge stays honest. The attestation wording comes from the FROZEN version, never from the package's mutable draft. documentPdf serves ACKNOWLEDGE-ONLY documents and verifies the served ImgDat's content-address digest against the frozen contentSha256 before it serves a byte; saveImgContent repoints img.setDat on every Docs save, so drift is live. Signable documents are refused with a 409 naming the esign route — those bytes must come from the service that flattens them. KToken's orgID/memberID are primitive long, so the plan's "== null" identity check is 0 instead; extractKToken leaves 0 behind for an absent session field.
· DocsService
Manifest canonical form and a spoof-resistant client IP
manifestSha256 hashes the ordered [{documentUid, contentSha256, orderIndex}] the member was actually shown, length-prefixed so a crafted uid cannot imitate a field boundary and make two different document sets hash the same. proofJson is stored verbatim and round-trips back to the same digest — a proof blob that cannot regenerate its own hash proves nothing. LegalClientIp walks X-Forwarded-For RIGHT-to-LEFT and takes the first public hop. DocumentAccessAuditor reads it left-to-right, which is client-controlled; that pre-existing defect is not fixed here, but it must not be the source of an evidentiary sourceIp.
· ESigService
Staff-authenticated member signing surface at /api/esig/member/**
OTKAuthFilter bypasses only /actuator, /api/esig/public and /api/esig/internal, so this prefix is behind staff OTK auth with orgId/memberId already on the request. The recipient is never a path variable: it is resolved by matching EsignEnvelopeRecipient.memberId to the OTK member id, so there is no parameter a member can change to reach a colleague's row. Not-found, not-your-org and not-on-this-envelope all answer an identical 404. No /delegate — it hands a compliance document to an arbitrary external email with no approval and no disable flag; excluded by omission, not by a flag. No /decoder and no /stepup either: recipient-scoped decoder belongs to HR's void path, and step-up is keyed on a Redis signer session this surface does not have. signerIpAddress comes from a RIGHT-to-LEFT X-Forwarded-For walk. EsignPublicController.clientIp reads left-to-right, which is client-controlled and therefore spoofable, and that value lands on an evidentiary field.
· kamo-internal
Assigned Packages sub-tab and deliver/redeliver/void actions
AG Grid Community over a MUI-paginated fetch — Community has no server-side row model, so the server pages and the grid renders exactly what it was handed. The Executed column is derived server-side from the same status the chip renders, so the two cannot disagree, and a signable document with no envelope raises a warning rather than sitting as "pending" forever. The right-hand filter column is headed by the org's own plural team-member term, from data, with a standalone "All" row — deliberately not one interpolated "All {Team Members}" string, because the only such message in the dictionary has a broken placeholder set in 12 of 22 locales and new keys cannot be invented here. The roster reuses the existing audience-preview endpoint rather than writing a thirteenth copy of the members-subscribers + team-members-contractors dual fetch, which returns PROSPECT, CANDIDATE, TERMINATED and RESIGNED members. The paginator's "1-25 / 100" is built from data rather than from common.table.of, whose value is an EMPTY STRING in pl.json — the key guard only proves a key exists in en. Void uses a fixed VOID challenge word: it cancels live legal instruments org-wide.
· kamo-internal
Legal package deliver/redeliver/void proxies and assignment client
Thin 1:1 forwardToApi routes — one upstream request each, because OTKAuthFilter does getAndDelete and a per-member loop in a Next route would need one OTK per member. The listing passes a PATH ONLY; forwardToApi merges the browser's query string itself. The status-to-i18n mapping is the real guard, not check-i18n-keys.mjs: that script only proves a key exists in en, and messages/ is gitignored, so a locally added key passes and then fails the Docker build. Every key here was verified across all 22 locales for presence and placeholder-set equality, and a test pins that none of the four known damaged keys is ever returned. An empty void selection sends no teamMemberIds key at all — that absence is what selects the org-wide branch; an empty array would void nothing and report nothing. RedeliverResult does NOT extend DeliverResult: RedeliverResultDto has no versionNumber field, only **************** Inheriting it would declare a property the backend never sends, so deliverSummaryParts reads newVersionNumber for a redelivery instead of reporting v0.
· DocsService
Org-scoped, paged, filterable legal package assignment listing
Built on the EntityManager rather than a shared-lib repository method: the filter set is dynamic and adding it to kamo-shared-library would force a shared-artifact rebuild on every consumer for a query only DocsService issues. The organization predicate is unconditional and first, and a test proves it survives every filter combination — the endpoint takes no org parameter, so a dropped clause would list another tenant's assignments. Paging carries a uid tie-break because a 200-member delivery writes deliveredAt inside one millisecond. Document counts are two GROUP BY queries, not 25 per-row lookups. An unknown status is a 400, never a silently ignored filter on a compliance grid.
· DocsService
Void legal package assignments, org-wide or for named members
Per-assignment outcomes — voided, skipped-executed, not-found — because an admin voiding a package for six named people needs to know which had already executed (their evidence stands) and which were never assigned (a stale roster id). A bare 200 cannot say that. Assignment transitions are synchronous: the assignment row, not the e-sign status, is what gates the member and drives the badge. Envelope voids go off-thread and are pre-filtered on SIGNED/VOIDED, because voidEnvelope 409s a completed envelope and an acknowledge-only document has no envelope at all. One member can hold several rows after a redelivery, so every row gets its own decision.
· DocsService
Redeliver a changed package, superseding the open assignments
Refused unless the live fingerprint differs from the published one. Binder.dateUpdated is dead and Img has no revision counter, so that fingerprint is the only change signal there is — without the gate, Redeliver is a button that silently voids everyone's in-progress work and reissues an identical instrument. Publishes through the existing publish path (the one writer of version rows), marks every OPEN assignment SUPERSEDED, queues their non-terminal envelopes for voiding, and reissues to the superseded member set plus anyone newly matching — re-resolved through findAudience so a member terminated since the first delivery gets no replacement. Executed assignments are untouched; they are the evidence.
· DocsService
Deliver a legal package to its audience, off the request thread
Plan on the request thread (two cheap queries, so the response is exact about who is included and who was skipped and why), write off it: 200 members x 8 documents is 1,600 rows plus as many ESigService calls, and DocsService serves WOPI. The pool is NOT a Spring Executor bean. applicationTaskExecutor is **************** and DocsService already has bare @Async on KSessionService and DocumentService -- publishing an Executor bean would silently move the WOPI session touch onto this small bounded pool. Envelopes are created outside any transaction; a 20s read timeout while holding a Hikari connection is how a pool of ten starves. A signable document whose envelope failed keeps a null envelope_uid, which is the only signal the grid has. The redeliver/void/assignment-grid mappings land here too, so the controller has ONE authorization funnel written and proved once rather than a second one bolted on later; their services answer 501 until Tasks 4-6 fill the bodies.
· DocsService
Delivery planner — incremental targeting, email pre-filter, due dates
Deliver is the new-hire path and is meant to be re-run, so members who already hold an assignment are subtracted rather than given a second copy of the same instrument. Members with no email are counted and skipped up front, because EsignEnvelopeService 400s a signer without one from inside the loop, after earlier members already have live envelopes. The audience query ORs three predicates, so duplicates are collapsed. An acknowledge-only document deliberately gets no envelope at all.
· DocsService
ESigService internal client for legal package delivery
DocsService's first outbound service-to-service client. Posts to **************** (origin MANUAL, never metered) with suppressInvite=true — HR sends one notification, not one per document per member. Timeouts are explicit: EsignEmailClient builds a bare RestTemplate with none, and an untimed read on a wedged pod pins a worker thread until restart. Nothing throws on an upstream error, because the fan-out issues one call per signable document per member and must not abort the batch over one bad template. Void maps 409 to a reportable skipped-signed rather than an error. Response records ignore unknown fields — the context ObjectMapper still has FAIL_ON_UNKNOWN_PROPERTIES on.
· kamo-internal
Package Templates sub-tab for Legal & Compliance
Replaces the HrTabPlaceholder body only — the route, the tab entry and the gate already exist in hrTabs.ts. AG Grid Community list plus a create/edit dialog with an ordered binder picker scoped to HR_RESOURCE (ordinal 13, not id 15), up/down reorder, and Department/Job Type multi-selects that the All-Team-Members toggle disables. The toggle's label is the org's own plural team-member term, never a hardcoded string. No new i18n keys: every label reuses a key the dictionary already defines, and counts render from data.
· kamo-internal
Legal package proxy routes, client api and pure helpers
Thin 1:1 forwardToApi routes under /api/docs/legal — one upstream request each, because OTKAuthFilter does getAndDelete and a fan-out loop in a Next route would need one OTK per member. Ids stay strings end to end (unique_rowid). Reorder is clamped rather than wrapping, and the audience lists are sent empty when All Team Members is on so a stale narrower audience cannot come back.
· DocsService
Audience resolution preview
Resolves a saved or draft audience through TeamMemberRepo.findAudience, which is the only reader carrying the mandatory ACTIVE + recordState eligibility predicate — the team-members-contractors endpoint returns PROSPECT, CANDIDATE, TERMINATED and RESIGNED. Flags members with no email (EsignEnvelopeService hard-400s without one) and members below TEAM_MEMBER security (ImagingController silently skips the filed copy). An audience targeting nothing resolves to nobody, and empty id lists become sentinels so the OR'd IN clauses stay valid SQL.
· DocsService
Publish freezes a version with byte-pinned documents
Pins img_dat_id plus a content hash, not img_id: saveImgContent repoints the same Img at new bytes on every Docs save, and resolveSource walks to the dat live at session build and flatten. Snapshots the frozen design instead of letting resolveDesignJson fall back to the newest one, refuses a document with no converted PDF, and refuses a publish whose fingerprint has not moved.
· DocsService
Legal package CRUD at /api/docs/legal/packages
List, create, get, update and delete, with ordered binder refs and audience rows replaced as a set. Org comes from the session on every path — DocsService is permitAll and OTKPreAuthFilter continues the chain without a token, so the controller's extractKToken IS the authentication. Cross-org department, job title, member and binder ids 400 explicitly rather than silently writing nothing.
· DocsService
Legal package wire contract and request normalization
Ids cross as strings (unique_rowid exceeds MAX_SAFE_INTEGER). Binder order is the list order, de-duplicated, and a non-numeric entry 400s rather than being skipped — silent skipping is how a reorder loses a binder. Empty audience lists collapse to a sentinel so the OR'd IN clauses in findAudience stay valid SQL.
· DocsService
Content fingerprint for legal packages
Binder.dateUpdated never moves after INSERT, so a package's content has no change signal to compare. Adds a SHA-256 over the canonical ordered binder/item tuple — including imgDatId, because saveImgContent repoints the same Img at new bytes on every Docs save — plus the attestation text hash. Encoding is length-prefixed so a crafted configJson cannot forge a different layout. Also adds com.kamo.z.shared.hr.legal to the repository scan.
· SecurityService
Push platform-admin state changes instead of being polled for them
Every platform screen backed by this service refreshed on a timer, because this service has no WebSocket and therefore no way to tell anyone anything: the closed-loan trainer tab re-read every 4 seconds while a run was live, DNS provisioning every 15, commerce sync logs and VOIP recording stats every 30, and the domain setup page asked verify-dns once a minute per open tab. PlatformEventPublisher sends these to core NATS and MediaService's new PlatformStompRelayController puts them on STOMP. Core NATS rather than JetStream, matching ThemeProvisionPublisher: this service's stream is CHAT_MESSAGES/chat.>, so a JetStream publish to security.* would fail expected-stream validation and be dropped. At-most-once is the right trade — every consumer also loads over REST when it mounts, so a lost event costs a refresh, never correctness. Wired at the points where the state actually moves: - ClosedLoanTrainerService on each progress write, on claim, and on every terminal state, so the monitor advances exactly when the walk does. - DnsProvisioningService as each record settles, rather than the panel re-reading the whole log hoping something landed. - RetailSyncLogBroadcaster on each sync-log row, via the new shared-lib listener seam, keyed by provider config so an org syncing several providers does not redraw all of them because one moved. Two counters have no such moment, and are handled honestly rather than pretended into events: - The VOIP recording backlog is written by another service entirely, so PlatformStatsWatcher samples it — but on the server, once, and it publishes only when the numbers actually change, so a quiet backlog produces no traffic where the old arrangement re-sent an identical payload to every viewer twice a minute forever. trigger-sync publishes immediately, since it knows the backlog is about to move. - Diagnostics is live JVM/host/database readings where essentially every field differs second to second, so there is nothing discrete to push. It gets a bare tick: one server timer deciding when "now" is worth re-reading, instead of one per open tab each pulling the full snapshot. DomainVerificationWatcher replaces the setup page's polling. DNS propagation and certificate issuance are genuinely external state with no event to subscribe to, so the checking has to happen somewhere — but not in every browser. One sweep covers every waiting domain, takes the 25 least-recently-checked so a backlog of misconfigured domains rotates instead of being re-queried in full, only probes HTTPS once DNS resolves, and publishes only when a domain actually advances. Two existing tests construct these classes directly and were updated for the new constructor parameters. Note: **************** fails on main already (unset EntityManager in the test's hand-built service) and is untouched by this change — verified against a clean HEAD worktree.
· EmailService
Publish mailbox-change events so the mail views stop polling
The folder tree, conversation list and message list each ran their own 30-second timer, reloading on the chance that something had moved. The WebSocket already told them when mail arrived, but that is only one of the ways a mailbox changes — reading a message in Roundcube or on a phone, moving it, deleting it, or expunging it all left the UI wrong until the next tick. EmailMailboxChanged says a folder is no longer what you are showing, and carries the reason so a consumer can ignore kinds it does not care about. Published on email.changed.<addr> and relayed to **************** from the IDLE listeners (arrival, expunge, flag change) and from the mark-read, flags, move, delete, spam, send and draft endpoints. Deliberately not folded into EmailUnreadPublisher: that one owns a count, so it has to reach the provider and coalesce bursts to stay affordable. This carries no count and reads nothing, so it is cheap enough to send on every change and must not wait behind a window a count needs. A move reports no folder because it touches two and the source is not always known to the caller — consumers treat that as "reload whatever you are showing".
· MediaService
Relay SecurityService platform events onto STOMP
SecurityService owns the platform-admin surface — system configuration, DNS provisioning, commerce sync — but has no WebSocket endpoint, so every screen backed by it had no way to be told anything and refreshed on a timer instead: the closed-loan trainer tab every 4 seconds, DNS provisioning every 15, commerce sync logs and VOIP recording stats every 30. It publishes to core NATS; this puts those messages on STOMP, the same arrangement SupportStompRelayController and **************** already use. Subject to destination is mechanical — drop the leading "security." and swap dots for slashes, so security.dns.log.42 arrives on /topic/dns/log/42 — which means a new event type needs a publisher and a subscriber and nothing here. Prefixes are bound explicitly rather than as security.>: a catch-all would double-deliver security.theme.provision.*, which ThemeProvisionStompRelay already relays to the same topic shape.
· kamo-shared-library
Let sync progress and domain state be announced instead of polled
Two hooks for the push conversion in SecurityService, where several platform screens refreshed on timers because there was no way to tell them anything. RetailSyncLogListener is an optional notification seam on RetailSyncOrchestrator, called when a sync run writes its IN_PROGRESS row and again when it writes the terminal one. The orchestrator stays unaware of how the notification travels, and with no implementation registered it behaves exactly as before. The provider sync dashboard reloaded its whole log list every 30 seconds to watch a run it had just started itself. **************** lists top-level domains that have not finished coming up — DNS unverified, or verified with no certificate confirmed. It exists for a server-side verification watcher: the domain setup page polled verify-dns once a minute per open tab while waiting on public DNS propagation and cert issuance. Ordered least-recently-checked first and taken with a Pageable limit so one sweep cannot grow with the number of permanently-unverified domains.
· ESigService
Allow a sender to suppress the per-envelope invite email
Creating an envelope unconditionally emailed an invite, so a caller batching many envelopes behind one notification had no way to stay quiet. Null preserves the existing behaviour; the access token is still issued either way.
· EmailService
Push INBOX unread counts over NATS/WebSocket instead of polling
The nav-top mail badge in kamo-internal refreshed itself by calling /api/email/folders every 30 seconds, per browser tab. That endpoint walks the entire folder tree and opens every folder read-only to answer, so the cost of one integer was a full mailbox summary, repeated indefinitely. The WebSocket that already existed only announced that mail had arrived and carried no count, so its one job was to trigger another poll. Publish the count itself: - EmailUnreadSnapshot on NATS email.unread.<addr>, relayed to STOMP /topic/email/unread/<memberId>. NATS is in the path because the pod that observes a change is routinely not the pod holding the member's WebSocket. - EmailUnreadPublisher coalesces per mailbox, so a ten-message IDLE delivery or a page of mail marked read is one IMAP round trip and one publish, off the caller's thread. - MailReader.getUnreadCount reads one folder. Graph answers it from the folder list it already returns; IMAP-backed providers override it with a single STATUS (UNSEEN). - A snapshot is published on subscribe, on IDLE messagesAdded/messagesRemoved, on IMAP flag changes, and from the mark-read / flags / move / delete / spam endpoints. Three gaps this also closes: - Flag changes were invisible. Reading mail in Roundcube or on a phone sets \Seen without adding or removing a message, so the badge only ever fell when the browser next polled. IDLE now carries a MessageChangedListener. - Cloud mailboxes got no push at all. EmailWebSocketController resolved credentials through the KamoMail virtual_users table, which has no row for a member on Microsoft 365 or Google Workspace, so subscribe() logged a warning and returned. It now resolves through MailProviderResolver, and IDLE connects with the credential spec (host/port/TLS/XOAUTH2) via a shared ImapStoreFactory.connect rather than assuming the KamoMail host and a password. - Sessions leaked. A closed tab never sends /email/unsubscribe, so mailboxes are now reference counted per STOMP session and released on SessionDisconnectEvent. Mailboxes with no live IDLE listener — Graph, past the 50-connection cap, or a dead IDLE thread — are swept server-side every 60s and published to the same topic. That replaces per-tab polling rather than push: one sweep serves every tab a member has open, membership is re-tested each pass so a dead listener is picked up, and the browser still receives the count over the WebSocket. MessageIndexer binds email.> and deserialized everything on it as a message, so it now skips subjects outside email.inbox. — filtered in the handler rather than by narrowing the subscription, because email-metadata-indexer is a durable consumer and changing its filter subject would reject the subscribe on every pod until the durable was deleted by hand.
· InitializerService
Land the legal package schema
Adds com.kamo.z.shared.hr.legal to the repository scan list and ensures the uniqueness and lookup indexes the entity mapping cannot express safely.
· kamo-shared-library
Record package attestation as append-only evidence
The package-level 'I have read and agree' is a different legal instrument from the E-SIGN consent to transact and from any per-document signature, and it is the ONLY evidence for a package with no signable documents. Guards refuse update and delete the way PhiAccessLog does.
· kamo-shared-library
Add per-member assignments and per-document progress
The document row is the join that makes N e-sign envelopes read as one package. The badge count comes off the assignment status, not e-sign status: recipient status has no VOIDED or EXPIRED, void never touches recipient rows, and an acknowledge-only package has no envelopes at all.
· kamo-shared-library
Freeze package content into immutable versions
Pins the ImgDat blob id and its SHA-256, not just imgId — a Docs save repoints the same Img at new bytes, so imgId alone would let two members sign different documents under identical ids. Snapshots the design too, so execution never depends on resolveDesignJson's fall-back to the newest layout.
· kamo-shared-library
Target a package at departments, job types or named members
Shaped on SystemUserAccess. Adds the audience resolver to TeamMemberRepo, which was an empty interface; the ACTIVE + recordState predicate is what keeps terminated staff and unhired candidates out of a delivery.

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