மாற்று பதிவேடு
என்ன புதிது.
அது கப்பல்கள் போல், தளத்தில் ஒவ்வொரு மேம்பாடு.
· SecurityService
Change a personal address by proving it, and administer accounts
Two surfaces on the USER account, which until now had no server of its own — the account's fields were reachable only through MemberController, an endpoint scoped to "the org in your session", which is the wrong shape for data every membership shares. UserAccountController — the member's own personal address. USERS.EMAIL is the second of the three identifiers sign-in accepts and the address password recovery mails a reset link to. Writing it from a PUT would mean anybody reaching an unattended signed-in browser could re-point recovery at a mailbox they own, walk away, and take the account over later — with the real owner still able to sign in, and therefore with nothing to notice. So the new address is parked, a code and a link go to it, and only an answer moves it. The password is re-demanded although the caller is signed in: a session proves this browser was signed in at some point, the password proves who is at it now, and this is exactly the act where those differ. Only the organization named by security_provider_id may write, and the refusal is here rather than in the UI — it carries that organization's name and its sign-in URL, because a 403 that only says no leaves the member with nowhere to go, and this rule is invisible until it bites. Reads are NOT restricted that way: seeing your own address from another workspace is what makes the refusal comprehensible. The link is deliberately unauthenticated. It goes to a personal mailbox, usually read on a phone that has never signed into the workspace; demanding a session would make it work only for people who did not need it. Possession is the proof — mailed to the claimed address alone, stored as a hash, single-use, expiring in a day, and finite in wrong guesses. The address being left behind is told, although nobody asked for that. It is the only signal that reaches somebody whose session was stolen. UserAdminController — the platform Users tab, guarded by MANAGE_USERS. The one read on the platform that enumerates accounts across every tenant instead of within one organization; the top-level-org precondition arrives with the right, since PlatformRightsResolver returns nothing to a session in a tenant. It deliberately cannot set a password (self-or-god stays where it is; restoring access goes through a reset letter, which proves control of the mailbox rather than bypassing it), cannot delete an account (seven satellite tables and every org it owns — the reversible answer is the synthetic flag, which finally has a UI here), and cannot touch isGod or isSystemUser. The coverage test learns /platform-users, so the next controller added under that prefix must carry a guard rather than inherit one by being overlooked.
· kamo-shared-library
The two reads the platform Users list is built on
A paged, searched finder over USERS, and a batched membership count. The finder is unfiltered by isFake on purpose. Reviewing and clearing that flag is part of what the Users tab is for, so a list that hid flagged rows would hide the accounts an operator came to look at; the projection marks them instead. COALESCE on every searched column is load-bearing — LIKE against NULL is NULL rather than false, so an account with no surname would otherwise match nothing at all, including the bare % that means "show me everyone". The count exists because the alternative is a query per row. The list shows a page of accounts and how many organizations each belongs to; asking findByUser fifty times is fifty round trips to draw one screen, and the count is all the list needs — the detail view loads the memberships themselves for the one account it is showing.
· EmailService
Buzz a member's phone when mail arrives
MessageIndexer is already a durable consumer of email.inbox.> and already resolves the mailbox OWNER, so it is where this belongs — one notification per genuinely new message, surviving restarts, with the member in hand. Raised BEFORE the IMAP fetch below it. Everything a notification needs is already in the event, and making the member wait on a round trip that exists to build a search index would be the wrong way round; it also means a mailbox whose credentials have gone stale still notifies, which is exactly when it matters. Three things it will not do. It only notifies for INBOX — a rule filing something into Archive is not news, and pushing for it teaches people to ignore the notification. It ignores anything older than thirty minutes, because some IMAP servers replay messagesAdded on reconnect and a phone that buzzes eleven times after a pod restart is a phone with notifications turned off. And it decides nothing about presence or devices: MediaService already suppresses a push for a member with a live socket, and a second copy of that rule is how the two would drift. The kill switch is in the ConfigMap as well as application.yml, because push auto-deploys and turning a misbehaving notification off must not need a rebuild. Note the merge into the EXISTING kamo: block — a second top-level kamo: key parses and then silently drops the first, taking sender-avatars and the security subtree with it.
· MediaService
An EMAIL type, and every push now names its org
EmailService raises the notification off IMAP IDLE and asks MediaService to deliver it through the internal dispatch endpoint that exists for exactly this — event flows that do not terminate here. EMAIL is an ordinary push: collapsed, rate limited, presence gated, delivered late rather than dropped. Separately, and the larger fix: no sender has ever put orgId into a push's DATA. The mobile app keeps every org a member belongs to live at once and switches to data.orgId before following a deep link, so that branch has never once run — a multi-org member tapping a chat or SMS notification opened it under whichever org happened to be on screen. The dispatcher stamps it now, which is the one place the org is already resolved and therefore fixes every push type at once. putIfAbsent, not put: a push about an event in a different org from the member row's own — a support session, an operator acting across tenants — knows better than this resolution does.
· EmailService
Preview variables for the two email-change letters
Both new canonical templates render in the console the moment this service is rebuilt onto the shared library that carries them; without a sampleVariables arm they render with the placeholders showing, which reads as a broken template rather than as a preview. Both name BOTH addresses, because the letters do. A verify letter that cannot show which address is being claimed is a letter the recipient cannot check, and a notice that names only the new address does not tell its reader what they have lost.
· kamo-shared-library
A right for user accounts, and a parked address for changing one
Three additions, all of them the shared half of work that lands in SecurityService and kamo-internal. PlatformRightType.MANAGE_USERS, appended. A user account is the person; a member is one organization's relationship with that person. Every other member-facing list on the platform is scoped to one organization by construction and the Users tab deliberately is not, so it reaches every tenant at once — a strictly larger blast radius than the org estate, and therefore not MANAGE_ORGANIZATIONS' job. Appended rather than inserted because getSortOrder() is the ordinal. UserEmailChangeRequest, with its repository. USERS.EMAIL is the second of the three sign-in identifiers and the address password recovery writes to, so it cannot be written from a form: whoever reaches a signed-in browser for thirty seconds would otherwise be able to point account recovery at a mailbox they own. The new address is parked here until a code and a link prove delivery. Its own table rather than columns on email_verification_tokens — that table is NOT NULL throughout and populated, and a new non-nullable column on a populated table is silently never created without a DEFAULT. USER_EMAIL_CHANGE_VERIFY and USER_EMAIL_CHANGED. The first goes to the address being claimed and is what makes the change take effect at all. The second goes to the address being left behind and is the only notice somebody whose session was stolen will ever get, which is why it is sent although nobody asked for it. Both pinned by a test: a half-added canonical pair throws inside loadAll() and breaks seeding for every organization on the platform, not just its own.
· kamo-internal
Public rides the SEND TO AI row, and arms the hand-off for god
The Publish control was a bordered block of its own further down the form, carrying two lines of hint under the switch. It is now a compact toggle on the heading's row, immediately left of SEND TO AI, labelled "Public" — and the hint it used to print moved into a tooltip, which is the one place it costs no room at all. The tooltip stays state-dependent: "off" is the consequential position here, and somebody hovering wants to know which way it currently points, not what the control is called. Both switches sit in one trailing group rather than carrying an `ml: 'auto'` each. Flexbox splits free space between every auto margin, so two of them would have pushed a gap between the pair instead of keeping them together; with the group, SEND TO AI still ends hard right when Public is absent, exactly as before it existed. Public reads left of SEND TO AI because it is about the report and SEND TO AI is about what happens next — they read in the order they take effect. The label is "Public" while the payload field, the flag and the column all stay `publish`: the label says what the report WILL BE, the field says what you are doing to it. That needed a NEW dictionary key — translate.ts only visits keys it has not seen, so editing the English under `form.publish` would have left 21 locales saying "Publish". SEND TO AI now also arms itself for a god-eligible operator filing a new report. That is a deliberate reversal of the old comment: off was "the only defensible default" because the switch starts a session on a machine holding cluster credentials, and it stays off for every ordinary reporter. It is applied in an effect and guarded by a ref, because useState reads its argument on the first render — when rights are still loading and the answer is always "not god" — and because a re-run would turn the switch back on after somebody had deliberately turned it off, leaving no way to file without a hand-off. Gated on maySendToAi too, so the state can never be on while the control is invisible. The requirement itself is pinned rather than described: the new test asserts the two switches' accessible names in DOM order and that they share one group with exactly two checkboxes in it. An sx tweak that moved either control past the other fails it.
· kamo-shared-library
Gate Publish on god ELIGIBILITY, not an open window
The Publish flag shipped gated on godModeActive — GD plus a deliberately opened 30-minute break-glass window. Every god-eligible operator then found the switch missing, because holding GD and having activated it are different facts and only the second starts a countdown. A report an operator cannot see is a report they cannot release, so requiring a window put the flag out of reach of exactly the people it is for. So SessionPlatformFacts gains godEligible alongside godModeActive rather than anyone reading the session a second time — that record exists because two readers of these flags is how one of them ends up subtly wrong, and the same argument applies to a caller wanting only half of what it already computes. The two components are strictly ordered: godEligible is a WIDER set than godModeActive, so reading one where the other was meant is the exact failure this record was written to prevent. That is why they are two named accessors rather than one flag plus a boolean argument, why the javadoc on godEligible says never to pass it to PlatformRightsResolver — doing so would grant every platform right to every god-eligible operator, permanently — and why SessionPlatformFactsTest now pins both halves independently in both directions. The query parameter is renamed :godMode -> :godEligible across all five predicates for the same reason. A parameter named godMode that deliberately ignores the window is how somebody later "fixes" it back.
· kamo-internal
Ending a terminal takes its window and hexhead with it
Ending a terminal from the Interaction Center killed the shell and left the window behind, showing a red `closed` status over something that no longer exists. The member's next move was to close it by hand — and if they had put the window away first, the hexhead sat there pointing at nothing. Nothing linked a session id back to a window. Every close in the app names a window id, because every close starts at the window: the X on the chrome, the X on a head, a conversation closing itself from inside. This one starts in a list, where the member has a session id and no way to learn a window id. So `closeTerminal(sessionGuid)` joins `openTerminal` in the same module, and dispatches a new `tool:closeMatching`. The dock resolves it against the store on toolId AND props — never props alone, since `sessionGuid` is also chat's dedupe key and matching on the bare prop would let ending a terminal close a conversation — then routes each hit through the existing `onClose`, so `tool:closed` still fires and the exit animation still runs. A separate event rather than an extension of `tool:close`: that one deliberately no-ops on a detail carrying a toolId and no windowId, and ChatBox depends on it. Teaching it to match on props would quietly turn that no-op into "close every chat window". The head is taken down explicitly. `store.close()` only marks the window exiting and never emits `removeHexhead`, so a window closed while minimized strands its head — the gap HexHeadPopover already works around by hand, and this follows it exactly, `animate: true` included so the head spins away instead of blinking out. An empty match matches nothing, not everything: a caller reaches one by passing an id that came back undefined, and the honest answer to "close the window for `undefined`" is none of them. Closes before the DELETE is sent, for the same reason the row disappears optimistically — ending a terminal is a confirmed act, and a screen that waits for the round trip reads as a button that did nothing. Scoped to the Interaction Center's End button. The identical button in the maximized terminal's navigator is untouched, as is a shell that exits on its own or is ended from another tab: those still leave the window up, honestly reporting `closed`.
· SecurityService
Read a domain's live SPF record and say what it actually needs
GET /api/security/domains/{id}/spf, for the SPF step on /setup/dns. The setup page cannot give one set of SPF instructions to everybody, because the right instruction depends on what the domain already publishes and the wrong one is destructive. A domain with no SPF record needs one created; a domain that already has one — most of them, since Google, Microsoft and every marketing tool publish one — needs that single record edited. "Add this record" given to the second group leaves two v=spf1 records on one name, which is a permerror: receivers ignore BOTH, so the customer's own senders stop being authenticated too. So this resolves the record first and returns the exact string to save, merged with whatever is there. The merge inserts our mechanism immediately before the terminating `all` (or a `redirect=`), because everything after those is never evaluated — appending it would produce a record that looks fixed and does nothing. Everything else about the record is preserved verbatim. It also refuses to answer where no safe answer exists, so the page has something to refuse with rather than improvising: - several SPF records already published -> MANUAL; merging them is a judgement call about senders we know nothing about - the merge would breach RFC 7208's limit of 10 DNS lookups -> flagged, and the page shows no record to paste, because saving it would invalidate the whole record including the customer's own senders Counting those lookups means following include/redirect chains as a receiver does, bounded by depth and a query budget. A record already authorizing us by ip4 or by our corporate domain counts as done — no point spending one of the ten on a mechanism that changes nothing. TXT character-strings are concatenated before parsing (RFC 7208 §3.3): real records from Google and HubSpot arrive split across several, and parsing them separately would miss the record entirely.
· kamo-internal
Tell each domain what its SPF record actually needs
/setup/dns walked a customer through ownership, subdomains and SSL and then said nothing about the record that decides whether the mail we send for them is believed. Without it, a password reset we send as NoReply@their-domain is unauthenticated mail claiming to be from a domain we do not send for, so it is filtered to spam — the workspace looks finished while its e-mail quietly is not. Generic instructions could not be written for this, because the correct one is different per customer and the wrong one is destructive. A domain with no SPF record needs one created. A domain that already has one — which is most, since Google, Microsoft and every marketing tool publish one — needs that single record edited. Telling the second group to "add this record" leaves two v=spf1 records on the same name, which is a permerror: receivers ignore BOTH, so the customer's own senders stop being authenticated too. The page would have caused the outage it was trying to prevent. So the card reads the domain's live record first and then shows one outcome, with only the steps that apply to it: - no record -> "Add one new record", with the full value - record exists -> "Edit the record you already have", the current value to match by eye, the merged value to replace it with, and a red callout that a second record must never be created - already listed -> nothing to do - Kamo-hosted -> nothing to do; we manage it Two cases deliberately show no record to paste at all, because there is no value we could offer that is safe: a domain already publishing several SPF records (already broken, and merging them is a judgement call about senders we do not know), and a merge that would breach SPF's limit of 10 DNS lookups — saving that would invalidate the whole record, the customer's own senders included. Both ask them to talk to us instead. The step is last and never gates Enter Workspace: the workspace genuinely does work without it.
· kamo-internal
Copy your referral link from any public role
Referrals are the highest-converting channel a job board has, and what stops them here is friction rather than unwillingness: the careers site answers on a different host, so nobody can compose the URL from memory. The server now sends it ready-made on every member listing, tagged with the viewer, and the card offers a copy button — only for roles that are actually published externally, so a share control can never circulate one the org chose to keep internal. The tick appears only when the clipboard write RESOLVED. navigator.clipboard is undefined outside a secure context and rejects when the document is not focused, both silently; a confirmation after a failed copy is worse than none, because the member pastes nothing and does not know why. Also fixes a type error in **************** that was failing `tsc` on main: its `create` mock declared no parameters, so `calls[0]` typed as the empty tuple and reaching into the recorded payload did not compile. That fails `next build`, not just the test file.
· DocsService
The CV reader, employee referral links, and the form shape on the public listing
Three things the public careers site needed from this service. The form shape now rides on the public listing (the twelve ask* settings, the additional-info prompt, and whether the role asks where an education came from). Without it the careers site would have to render the union of every possible question — asking a warehouse applicant for a portfolio URL — and would have no way to know which answers are required, so the first the candidate hears of a missing one is a rejected submission. Written through one adapter over the existing applyFormShape rather than a second list of twelve setters. ResumeScanService reads a CV well enough to pre-fill the contact block, and stores NOTHING: bytes in, five regexes over the first few thousand characters, suggestions out. That is the whole safety argument for accepting a file on an unauthenticated endpoint — there is no state to corrupt, and the application is still filed from whatever the applicant submits. PDF and plain text only (POI is not on the classpath), file type decided by magic bytes rather than the caller-supplied name or content type, and every unreadable file comes back as an empty map rather than a status code. The name guess is deliberately timid: a wrong name pre-filled is worse than a blank box, because the applicant may not re-read a field that already looks answered. MemberJobListingDto gains referralUrl — the public advert tagged with the viewing member — composed here because the workspace cannot compose it: a tenant's careers host is its verified root domain and internal.<apex> has no way to know it. Null for a role that is not published externally, so a share control can never circulate a role the org chose not to publish. Also: the alert digest now builds escaped HTML rows instead of a newline-joined string. TemplateRenderer is a flat regex replace with no escaping, so "\n" collapsed to one run-on line in every mail client and a title containing & or < would have injected markup into a letter going to an address a stranger typed into a public form. The receipt gained the same **************** split the HR package templates use, and both senders stopped passing organizationName — EmailService injects {{orgName}} from the same row.
· kamo-shared-library
Three public transactional templates, and the org patch keys behind them
The careers site now writes to people who have no account, which is new for this platform: every other transactional template goes to a member. Three canonical templates cover it — the application receipt (which carries the single-use claim link, and is the ONLY copy of it), the talent-alert double opt-in, and the new roles digest. Both alert letters carry an unsubscribe link that works with no sign-in, because the recipient has nothing to sign in to. Also two names added to **************** careersSiteEnabled and careersNetworkOptIn. That method is an explicit allow-list, so a key it does not name is accepted with a 200 and never stored — the settings switch would have flipped back on its next load with nothing anywhere reporting a failure. HrCareersTemplateTest pins the constant/dotted-key/sender triple the way HrPackageTemplateTest does, plus the unsubscribe link, the escaped-body versus header-safe-subject split for the job title, and the two-image ceiling — a third <img> in a letter to a stranger is a tracking pixel.
· kamo-register
Carry the application claim token across the register handoff
A candidate can now apply from the public careers site without an account. The application is stored against the email address they typed, and the receipt carries a single-use claim token that attaches it to an account they create afterwards. That account is created here, and registration ends on a different host, so the token is parked in the same apex-scoped cookie the job uid already uses — the one thing that survives the trip. Written separately from the uid because the two do not always travel together: /team?jobId=... reached straight off an advert has no application to claim. Skip this and the application is stranded — HR keeps an anonymous copy, the member sees nothing to track, and applying again from the workspace files a duplicate.
· kamo-internal
Benefits, the public funnel, the network opt-in, and claiming an application
Five changes, all of them the workspace half of something the public careers site now does. - Benefits & perks on the posting editor. It reaches the public advert and the Indeed/LinkedIn feeds, both of which have a slot for it that was going out empty. - A pay-transparency warning. CO/CA/NY/WA require a range to be disclosed; the editor now says so when the role is located in one of them and compensation is hidden. It warns rather than refuses — the rules turn on facts the posting does not model (headcount, whether the role can be performed in the state), so this is a prompt to a human, not a gate. - A funnel readout on the posting card: board impressions, advert views, apply clicks and the apply rate. Shown only for an externally published posting with at least ten impressions, because a rate computed from three views is noise presented as a measurement. - Two org switches under Essential settings. The first decides whether careers.<domain> answers at all; the second whether the org's externally published roles also appear on the platform's own board beside other tenants'. The network switch is off by default and is disabled — and sent as false — while the site itself is off, so opting into the shared board cannot become a way around having switched your own off. - Claiming an application. Someone can now apply from the public site with no account; the receipt carries a single-use token, kamo-register parks it in the apex-scoped cookie beside the job uid, and the Careers tab redeems it once on arrival. The claim runs BEFORE the advert opens so the claimed application is already loaded when the tab decides whether to show the form pre-filled or the advert read-only — the other order shows a candidate a blank form for a job they have already applied to. Both cookies are cleared whether or not they matched.
· kamo-internal
A Publish switch on a god-mode bug report
An operator with an open god-mode window filing a bug or enhancement now gets a Publish switch. It is off when the window opens and does not remember its last position: the flag decides whether the organization a report is filed against can see it, and a control that remembered would eventually publish something nobody meant to publish — or, worse, quietly withhold one. The drawer gets a matching toggle so a report already filed can be released later, and both card and drawer mark an unpublished report so an operator can see at a glance which reports the tenant is not watching. Gated on god mode, NOT on MANAGE_SYSTEM_BUGS — the SEND TO AI switch sitting beside it in the same form is gated on the standing platform right, and this one deliberately is not. A platform administrator triages every report whether or not they have broken glass; deciding who can SEE one takes the open window. The form reads the browser's own god-mode state, which fails CLOSED: the worst case is an operator not being offered the control and filing a published report, which is what happened before this existed. It decides only whether the switch is DRAWN — the server ignores `publish` from anyone without an open window, so nothing here grants anything. The drawer uses the server's canPublish instead, because it has a detail DTO to read it from. `publish` is omitted from the payload entirely when the switch was not drawn, rather than sent as true: the server reads an absent value as "the client did not ask", which for an ordinary member is the same as every other answer. That is what keeps a member who never sees the control from filing their own report invisible. Filing only. An edit window never shows the switch, because update does not carry the flag and a switch that silently did nothing would be worse than no switch.
· MediaService
Let a god-mode operator file a system bug privately
Posting a report now offers a Publish switch to an operator with an open god-mode window. It defaults to off: the report stays with god mode until somebody releases it, and POST /{id}/published releases or withholds one afterwards. The write rule is four lines and unspoofable. A client's `publish` is read ONLY from a caller with an open god-mode window; for everybody else it is discarded rather than refused, because the field is not offered to them and a value arriving from an ordinary member is either a stale build or hand-written JSON — neither is a reason to fail a bug report at the moment somebody is trying to tell us something is broken. For a god-mode caller a null and an explicit false land in the same place: a flag whose purpose is to keep a report away from the tenant must not turn itself on because a field went missing. Gated on god mode, NOT on MANAGE_SYSTEM_BUGS, and the difference is the point. A platform administrator holds that right continuously and may triage every report; deciding who can SEE one takes the deliberate act of breaking glass. So godMode() reads **************** which is the one place GD (permanent eligibility) is combined with an open window — reading either alone would grant this to those operators at all times, or to nobody. The read rule is applied twice on purpose and neither is redundant. The query stops an unpublished report appearing in a list; maySeeUnpublished stops it being fetched by id, including by a manager who holds the right but has not broken glass. changeStatus gets the same check: those callers have no way to learn the id, but "they could not have found it" is not an access rule, and it would otherwise be the one endpoint where a private report can be acted on by somebody it is hidden from. 404 rather than 403 throughout, matching the tenancy check — a 403 confirms the id exists. Publishing is announced like a status change, because it is the same kind of event for the badge: a report becoming visible joins the unread count of every manager who could not see it a moment ago. Skipped when nothing changed, so re-pressing the toggle does not wake every console.
· kamo-shared-library
The Publish flag on a system bug, and the query rule behind it
A system bug already crossed the tenancy line in one direction. This adds the second visibility rule on top of it: a report a god-mode operator filed privately is visible to a caller with an OPEN god-mode window, to the member who filed it, and to nobody else. Written as three ORed terms rather than a nullable filter parameter, because there is no value of godMode that means "do not apply this" — the whole point is that it always applies. It goes into all four queries verbatim, beside the tenancy predicate that is already duplicated across them for the reason the repository spells out: a count computed under a different rule than the list it opens is a bug report waiting to happen. A badge counting reports the list will not show reads as a badge that refuses to clear. b.reporter.id is safe as an implicit join for the same reason b.reporterOrg.id is — the association is optional = false, so it cannot silently drop rows the way an implicit join on a nullable one does. A null viewerMemberId is the safe answer here: no row's reporter id equals null, so an unresolvable viewer falls back on `published` alone rather than matching every private report. published defaults to TRUE on the entity, in the builder and in the @PrePersist guard, matching the column default. A null written as NULL would be read back by the predicate as "not published", which would hide a report from the tenant that filed it because a builder omitted a field. canPublish on the detail DTO is deliberately not canManage. MANAGE_SYSTEM_BUGS is held continuously by platform administrators; this answers whether a break-glass window is open. Folding the two together would hand the whole point of the flag to the wrong set of people.
· InitializerService
System_bug.published, defaulting to TRUE
The column behind the Publish flag: a god-mode operator can now file a report that stays with god mode until somebody releases it. DEFAULT TRUE is the whole reason this needs no backfill, and it is the one statement in this file that can empty a page rather than break one. Every report that already exists was visible to the organization that filed it, and so is every report an ordinary member files after this — the flag can only ever be turned off by a god-mode operator on a report they are filing themselves. A DEFAULT of FALSE would, at the instant this ran, have hidden every report ever filed from the people who filed them, with nothing in a log to notice it by. The queue would simply be empty. NOT NULL for the same reason status_name is: a third state meaning "nobody has said" would have to be interpreted by every query that reads this, forever, and the two readings differ by whether a tenant can see the row.
· DocsService
Apply-first, the job network, alerts, translation and retention
Eight things, all behind the anonymous careers surface, and all sharing the rules the single-org board already enforces rather than growing their own. APPLY-FIRST. PublicApplyService files an application before its author has an account and emails a claim link. The posting is loaded through the same public predicate — an archived or internal-only req answers 404, never 403 — the answers go through the SAME validator as a member's, and the résumé through the same store, because a second copy of those rules is a second way in. Duplicates are caught on email, which is the only identity a public applicant has. THE NETWORK. A cross-organization board, gated twice: it is refused unless the request arrives on the platform's own careers host (read from the forwarded host, failing closed), and an organization must have opted in. It reuses PublicCareersService's own mapper and assembler, so a rule about what the public may see cannot be weaker there than on a tenant's own board. Every card links to the advert on the EMPLOYER's domain — the network sends traffic, it does not capture it. RELEVANCE. Free-text search was "every term appears somewhere", so a warehouse advert mentioning engineers in passing ranked level with the Staff Engineer role. It is now field-weighted with whole-word bonuses, and relevance leads the order only when somebody actually searched. THE FUNNEL. Impressions, opens and apply clicks, buffered in memory and flushed every thirty seconds. A row per event would be the platform's largest table within a month and would answer nothing the counters do not; an UPDATE per view would be a write transaction per page load on an anonymous site. The cost is honest: a pod restart loses up to thirty seconds. TALENT ALERTS with double opt-in, a never-expiring unsubscribe token, an eighteen-month expiry, and a sweep that deletes addresses nobody ever confirmed. The subscribe endpoint answers the same way whatever the address's history is — distinguishing them would be an oracle for who is watching a company's jobs. TRANSLATION on publish, on a separate bean so @Async is not bypassed by a self-invocation, REQUIRES_NEW per locale so one failure does not discard twenty, and fingerprinted so an edit invalidates every language at once. INDEXING via Google's JobPosting-specific API — off and logged until a service account exists, working the moment one does. URL_DELETED when a role closes, which is what stops a board accumulating results that lead nowhere. RETENTION: a nightly batched sweep, plus an HR erasure endpoint for a subject request. 21 new tests.
· InitializerService
Make hr_job_application.member_id nullable
ddl-auto adds columns; it never relaxes constraints. Flipping optional=false to true on the entity changes what the ORM believes and nothing about what the database enforces — the mapping says nullable, the column stays NOT NULL, and the first public application fails on insert inside a @Transactional method whose rollback removes the evidence. Also backfills purge_after for every application that predates the retention policy. Null there means "keep forever", so without this the sweep would only ever cover applications filed after the feature shipped, which is not a retention policy. The (posting, member) unique constraint is deliberately left alone: once member_id can be null it stops constraining public rows, which is what we want — Postgres treats NULLs as distinct, and public applicants are deduplicated on email in the service, where two people sharing an inbox get a message rather than a constraint violation.
· kamo-shared-library
The schema behind apply-first, the network, alerts and retention
member_id on hr_job_application becomes NULLABLE, which is the whole of apply-first: five steps stood between pressing Apply and answering the first question, and the two steepest — create an account, verify an email — came before we had learned anything at all. An application can now exist before its author does, with a claim token that attaches an account later. Every query joining that column must be a LEFT join. An inner one drops exactly the rows this adds, hiding public applications from the Applicants tab while failing nowhere; findForOrgWithPostingAndMember is fixed and says why. Two org columns: careers_site_enabled (an org that wants no public board should not answer on that host at all) and careers_network_opt_in, default FALSE — publishing your roles beside every other tenant's is a decision, not a default. Three counters on a posting — impressions, detail views, apply clicks — reset with listedAt like the applicant count, incremented with UPDATE ... SET c = c+n so concurrent pods do not lose each other's writes. And jobBenefits, which has a home in schema.org that body prose does not. Two new entities. JobPostingTranslation carries a fingerprint of the text it was made from, so an edit invalidates twenty-one languages the same second rather than leaving them describing an old salary. CareersTalentAlert carries the whole consent lifecycle — double opt-in, a never-expiring unsubscribe token, and an expiry — because it is a public form that stores a stranger's address. Plus purge_after: applications hold a name, an email and a CV, and keeping them forever is not a neutral default.
· kamo-internal
Force KamoMobile Logout on a member's account activity
Force Logout deletes sessions, which ends a browser. It does not end a phone: KamoMobile holds a durable device token and mints a fresh session from it immediately, so pressing Force Logout on a lost handset looks like it worked and changes nothing. The new button revokes the devices themselves. It matters more now that KamoMobile can open a fully interactive terminal on the member's dev machine: whoever is holding an unlocked handset has a shell on a box with a cluster-admin kubeconfig until this runs. Also allow-lists the forwarded action. It lands in the upstream PATH, so an unchecked value carrying '..' segments could aim an authenticated POST at a different SecurityService endpoint. Those all authorize themselves, so this is a second line rather than the only one — but the first line should not be 'the HTTP client happens to normalize that'.
· SecurityService
Force KamoMobile Logout — revoke a member's mobile devices
Force-logout deletes *** sessions, which ends a browser. It does NOT end a phone: KamoMobile holds a durable device token and mints a fresh *** from it the moment it needs one, so force-logout ends the phone's current session and the phone silently starts another. Revoking the device tokens is the only thing that locks a handset out. This is the control that makes a lost phone survivable now that KamoMobile can open a fully interactive terminal on the member's dev machine — a root-equivalent shell on a box holding a cluster-admin kubeconfig. Whoever is holding an unlocked handset has that until this runs. Devices are revoked BEFORE the sessions are deleted; the other order leaves a gap in which the app can re-mint from a token that is still valid and come back signed in. Push registrations go with the device, or the phone keeps buzzing for an account it can no longer open. Logged as FORCE_LOGOUT rather than a new event type: appending to an ordinal-persisted enum is a schema change, and the message says which kind it was. Same MANAGE_MEMBER_SECURITY gate as its siblings.
· kamo-internal
A switch per platform registration, and bring-your-own on the org side
Each OAuth2 card on Platform Configuration → Advanced gets an enable/disable switch in its top-right corner. It writes only the enabled flag, is unavailable until credentials exist behind it, and reverts if the write fails — an enable/disable that shows the state the operator asked for rather than the one the server kept would be worse than no switch. Its tooltip says what switching off actually does, since the effect lands on other people's screens. The org side is the other half. OrgOAuthAppSection is one component, dropped into every settings screen that connects through one of these providers: email mailboxes, meetings, Teams phone, social, Canva and payroll. It asks one question — Kamo's application, or one you registered yourself — and renders the credential form for the second. When the platform registration is switched off the question disappears rather than becoming a disabled radio: there is nothing to choose between, and the credential form is the only way forward. That case is the point of the feature, so it gets the plain path, not the greyed-out one. The provider field schema moves to a shared catalog both sides import. They describe the same registration; a field added for the platform and not for the org would be a credential an org could never supply, and it would surface only as a provider rejecting the token request. The registrations load through one shared, invalidate-on-write cache — the email screen alone renders three sections, and each fetching for itself would issue the same call three times per visit.
· VOIPService
Teams Phone falls back to the org's own Entra app
The per-phone-server credentials still win — that is the most specific source. What changes is the fallback underneath them: the org's own MICROSOFT_TEAMS registration if it has one, and only then Kamo's multi-tenant app. The redirect URI is org-scoped for the same reason it is registration data at all: an org on its own Entra app registered its own, and Microsoft requires the value replayed on the token exchange to match the authorization byte-for-byte. So the callback resolves it under the same org it authorized under.
· MediaService
Resolve Canva, Meta, X and meeting credentials per organization
Every flow here now reads whichever app the org is on — its own registration if it brought one, otherwise Kamo's platform app. The Canva and Meta entry points load the connection or intent BEFORE resolving the client, because which app an authorization runs against depends on the org and the org id lives in the thing being loaded. An authorization started against one app cannot be completed against another. SocialCredsResolver has to follow the org for the same reason in reverse: a Meta webhook is signed by whichever app owns the page, so verifying it with the platform secret would fail every HMAC check for a page connected through the org's own app. Canva's return-navigation callback stays platform-only and is commented as such: it arrives with a correlation JWT and no organization, so an org on its own app falls through to the manual import path that already exists for it. The meetings screen gains orgOAuthClientConfigured and oauthClientAvailable, so it can offer "Kamo's app" and "our own app" independently instead of hiding one-click connect the moment a platform operator switches a provider off.
· EmailService
Resolve mailbox OAuth credentials per organization
Google, Microsoft 365 and Zoho mailbox connects now read whichever app the org is on — its own registration if it brought one, otherwise Kamo's platform app. The org is threaded through the refresh as well as the connect. A grant issued to an org's own app can only be refreshed with that app's client id and secret, so reading the platform pair there would have started failing the moment an org stopped using Kamo's app — a day or an hour after connecting, not immediately, which is the worst kind of failure to diagnose. The Microsoft tenant follows the same rule: an org on its own Entra app usually wants its own tenant rather than the multi-tenant "common".
· SecurityService
Org-side CRUD for an organization's own OAuth2 client
The org-scoped twin of PlatformOAuthClientController, behind **************** Same DTO shape, same encrypt-on-write, same never-return-a-secret rule, plus platformEnabled — which is what every integration settings screen reads to decide whether "use Kamo's app" is on offer at all. There is no single "manage integrations" right, so each provider is gated on the settings right of the feature that consumes it: the same right that opens the screen the registration is edited from. Microsoft, which is both the mailbox app and the Teams-meetings app, accepts either. CONFIGURE_SYSTEM passes everywhere, as it does elsewhere in settings. A provider with no org-side screen is not addressable here at all, so a typo cannot create a registration nothing would ever read: MeridianLink's secret is per market, and RingCentral and ADP are per-instance credentials with no platform app to replace.

உங்கள் உலகம் காத்திருக்கிறது.
நிமிடங்களில் இலவசமாகத் தொடங்கவும். நீங்கள் தயாராக இருக்கும் போது பொருட்களை சேர்க்கவும் - எந்த மாற்றமும் இல்லை, புதிய உள்நுழைவுகள் இல்லை.
எப்பொழுதும் இலவசம் — கடன் அட்டை தேவையில்லை