Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-shared-library
FHIR resource repositories, with the 40001-safe version claim
claimNextVersion is an UPDATE ... SET current_version_id = current_version_id + 1 and must be the FIRST statement of a write transaction. The obvious alternative — read the current version, add one, write — is a read-after-write inside one transaction, which YugabyteDB aborts with SQLSTATE 40001 under concurrency. It fails intermittently, so it presents as a random deadlock, and a retry loop appears to fix it while fixing nothing: the retry has the same shape and just wins the race more often. The UPDATE also takes the lock and reads in one round trip, which is one fewer than what it replaces. findClaimedVersion reads back in the same transaction and is safe despite that warning, because the UPDATE already took the row's lock — Yugabyte's read-restart problem is about rows another transaction may move under you. The resource lookup deliberately returns DELETED resources. A caller has to tell "never existed" (404) from "deleted" (410 Gone), and FHIR requires a deleted resource to keep answering vread and _history; filtering them here would make that impossible one layer up. FhirResourceVersionRepository declares NO delete and NO update, and a test enforces that it never gains one. The entity refuses both through @PreRemove/@PreUpdate — but those are ENTITY CALLBACKS, and a Spring Data derived delete compiles to bulk JPQL that bypasses them completely. Such a method would erase clinical history silently while the entity's own protection never fired. Not declaring one is the actual protection, and that is a decision no compiler enforces. History and reprojection reads are both paged, because an actively amended chart accumulates versions and an unbounded read is unbounded exactly where it hurts most. SharedLibBeanSafetyTest still passes: repositories are interfaces, and no stereotype bean was added to the library. 1655 tests green.
· kamo-shared-library
The encounter spine (SP5)
The entity everything clinical hangs off — a note, an order, a diagnosis and a charge all point at the encounter they arose in. Planned and actual times are separate column pairs, which is both a forward-compatibility decision and just true. R4 gives an Encounter a single period; R5 and R6 split planned from actual, so modelling one now would mean rewriting every encounter row and every scheduling query when that lands. It is also how a practice measures its own lateness — the gap between what was booked and what happened is exactly the thing collapsing them discards. classCode is stored as a plain code plus its system rather than an embedded Coding, because R4 types it as a Coding and R6 changes it; columns survive both. It is not cosmetic either: it drives the place-of-service code on the claim, and a telehealth visit billed as in-person is a false claim. reason is kept apart from the diagnosis. What the patient said and what the clinician concluded are different facts, and a chart that conflates them loses the presenting complaint entirely. EncounterParticipant is a child table because the supervision case cannot be flattened: the billing provider on a claim is computed from the supervision record rather than typed by a biller, and under incident-to rules the role decides whether the visit is billable at all. physicallyPresent is its own field because a supervising physician may be available without being in the room, and inferring presence from the role would silently assert something about a claim nobody checked. EncounterDiagnosis.rank is load-bearing and looks cosmetic. Service lines on a professional claim point at diagnoses BY POSITION — the pointer is an index, not a code — so a stored order that does not match the submitted order misdirects every line, and the claim is either denied or paid against a condition the patient does not have. addressedAtEncounter exists because not every listed diagnosis was actually evaluated, and the E/M level is computed from the ones that were; counting mere presence inflates it. Encounters are never deleted — one created in error becomes entered-in-error and stays, because orders, notes and charges already reference it and a dangling reference is worse than a visibly wrong record. 1651 tests green.
· kamo-internal
Tell people a web alias is taken while they are typing it
The create wizard is eleven steps long and the alias was only validated for FORMAT locally — whether anyone already held it was discovered when the form was submitted, at the end, on an organization that could not be created. Now the field says so while it is still on screen. Debounced at 400ms, so typing a word is one request. Each check carries a ticket and a stale reply is dropped: typing "acme" then "acme-2" must never settle on the verdict for "acme". The local rules — all digits, and the [a-z0-9-]{1,63} format — are checked before any request, and are deliberately the same rules SecurityService enforces on create. If they drift, the field says "available" for something create then refuses, which is the failure this removes. They are pure functions so a test can pin them, and the hook runs the tested ones. An unreachable check reads as "could not check" and lets the member carry on, rather than failing open to "available" and putting them back where they started. "Taken" names no organization: the wizard has no business telling one customer about another's. Both alias inputs are covered — the web-alias path and the plain one. 2943 tests and all 8 guards pass, i18n included. Copy landed in kamo-translation-dictionary first.
· InitializerService
Make orgs.alias unique platform-wide
The column was never constrained. Survivable while an alias was only resolved as (security_provider_id, alias) behind a host, since two orgs under different providers could share one without ambiguity. Once an org can be named directly by its alias with ?org=, a repeat makes the reference ambiguous — the resolver refuses it rather than guessing, leaving BOTH orgs unreachable by alias. Applied to the live database, after renaming the six rows that collided (four sharing "acme-corp", two sharing "kia-kaha", all seeded the same day) with a numeric suffix. This is what gives a fresh environment the same index; the service has no CI, so pushing it ships nothing on its own. On lower(alias), because the alias is stored verbatim and Postgres equality is not case-insensitive: "Acme-Corp" and "acme-corp" resolve the same reference and must collide. Partial, because NULL or empty means "no alias" rather than a name being claimed, and any number of orgs may have none. Logs and continues if it cannot be created rather than failing the run — that means duplicates exist, and the create/update checks refuse new ones regardless.
· SecurityService
Answer whether a web alias is still free
The org-creation wizard is eleven steps long and the alias was only checked when the form was submitted, so someone who picked a name another organization already held was told at the very end, on an org that could not be created. GET /org/alias-available answers the same question the write paths enforce — taken globally and case-insensitively, or all digits — so the wizard and the server cannot disagree. If they did, the field would say "available" for something create then refuses, which is the failure this removes. Session-guarded. The answer is one boolean about a name the caller typed, which create would tell them anyway, but there is no reason to hand an unauthenticated caller a way to enumerate which aliases are in use. Advisory, not a reservation: two people can be typing the same alias at once and both be told it is free. The unique index and the create check settle that; this only narrows the window to something that does not matter in practice. 964 tests pass.
· kamo-shared-library
The Patient entity and its US Core satellites (SP5)
A new entity rather than a reuse, and each rejection is concrete rather than stylistic. Not Member: GET /api/security/members/names returns every member of an org with NO right gate and the by-id endpoint returns name parts plus date of birth on a same-org check alone, so any authenticated session — including another patient's — would enumerate the roster. Not Lead: it is @Inheritance(JOINED), and a fetch graph into one of those took /leads down on the Hibernate version much of the fleet still runs. Not Contact: those belong to a User's personal address book, which is the opposite of a patient belonging to the practice. The same human is a DIFFERENT Patient in each practice. That is the tenancy boundary, not a compromise — each covered entity holds its own record, MRN and consent posture, and cross-practice identity is a matching problem for the MPI rather than a shared row. Three sex/gender concepts are separate columns because they answer different questions and one column cannot serve all three. Administrative gender is what appears on paperwork; birth sex is certification-tested and searchable; sexForClinicalUse is the one that drives reference ranges and dosing. Conflating them is how a trans patient gets the wrong reference range on a lab result. birthDate is a LocalDate deliberately — a birth date has no time and no zone, and giving it one shifts it across the date line for anyone who travels. This is why the discipline harness bans LocalDateTime specifically rather than all local types. birthDatePrecision is there because a patient who knows only their birth year is common, and storing that as 1 January then matching on the exact date silently fails to find them. Race and ethnicity are a child table because a person can report several and a single column forces someone of mixed heritage into whichever box the code happened to keep. They feed quality measures and disparity reporting, so they are not demographic decoration — and selfReported defaults true because they are recorded from what a patient said, not inferred. Language carries interpreterRequired as an independent fact: a patient may prefer Spanish and decoder an interpreter, and inferring one from the other is wrong in both directions. Section 1557 turns on both. active means "this record is in use", NOT soft-deleted, and mergedInto plus mergedAt exist because the platform's only existing merge is one-way with no unmerge — and merging the wrong two patients is among the most damaging mistakes a practice can make. contactId keeps the CRM link so acquisition attribution follows a person from first enquiry into the chart. No standalone EHR can do that, because it has no person before it has a patient. 1651 tests green.
· kamo-shared-library
The disclosure ledger and patient restrictions (SP2)
DisclosureLedger is separate from phi_access_log on purpose. The access log records every READ, and most reads are a clinician opening a chart; deriving an accounting from it hands the patient a document listing thousands of internal views, which is both wrong and alarming. §164.528 accounts for DISCLOSURES — PHI leaving the covered entity — not internal use. The two also cover different periods and different purposes, which is why purposeOfUse is stamped at access time and carried here rather than inferred later. hipaaAccountable is stored rather than derived from the purpose on read, because the rule has exceptions the purpose alone does not capture — a disclosure the patient authorized is accounted differently from one made under a treatment exception — and recomputing it later means re-deriving those exceptions from data that no longer exists. PatientRestriction carries the §164.522 obligation a covered entity may NOT refuse: a patient who pays out of pocket in full can require that the service not be disclosed to their plan. That is the one usually implemented wrong, because hiding the encounter on a screen does not comply. It has to stay out of CLAIM SUBMISSION and out of ELIGIBILITY ENQUIRIES, since both tell the plan the service happened. A system that hides it in the chart and bills it anyway has disclosed exactly what the patient paid to prevent, and they find out from their explanation of benefits. So suppressFromClaims is its own flag checked by the billing path, not inferred from a display rule — display rules get relaxed for usability and a billing suppression must not travel with them. suppressFromEligibility is separate again, because a 270 asking a plan about a service signals the service is contemplated; suppressing the claim alone leaks the same fact more quietly. Both tables close rows rather than deleting them. A lifted restriction still explains why a service went unbilled for six months, and the ledger is append-only because an accounting that can be edited is not an accounting. Temporal boundaries are exclusive at the end instant and the tests pin both directions, including that an incomplete row denies rather than throwing — this predicate sits on the billing path. 1653 tests green.
· kamo-shared-library
Care relationships and break-glass, the facts behind the access basis (SP2)
PhiAccessBasis gained CARE_TEAM and BREAK_GLASS last commit; without these two tables those values are decorative. Nothing on the platform modelled a provider-patient relationship at all — the survey found no such join anywhere — so authorization could only answer "may this person read charts", which every clinician in a practice can. The question minimum-necessary turns on is whether they may read THIS one. CareRelationship is a persisted FACT, deliberately not a RoleRightType, and each reason bites on its own. Rights are snapshotted into *** at login, so a revocation would not reach a live session — an ended relationship has to stop granting access now. A new right silently arrives GRANTED for grant-all administrator roles, which is catastrophic for something meant to be per-patient. And checkRight() returns true for everything under god mode, whereas a relationship either exists or it does not. Rows are closed with validTo, never deleted. "Was this person on the care team when they opened the chart last March" cannot be answered by today's care team, and that is exactly what an access review asks. BreakGlassGrant exists because refusing to build it is not the safe choice. A patient arrives unconscious and the treating clinician is on no care team yet; a system that says no is one people work around, and the workaround is a shared login. The control is not prevention — it is that the access is narrow, stated, time-boxed and READ AFTERWARDS. The reason is free text and required, because its value is that a human wrote it and another human reads it; a dropdown gets clicked without reading. Deliberately NOT god mode: that is platform-staff break-glass, server-owned. Routing a covered entity's clinical emergencies through Kamo's vendor backdoor is the one control a BAA reviewer will not move past. Both temporal predicates treat their end instant as EXCLUSIVE, and the tests pin the boundary in both directions — an inclusive end silently extends every ended relationship and every expired override by a tick, and neither failure raises anything. 1644 tests green.
· InitializerService
Put tenant_id back at the front of the clinical resource keys
ddl-auto built PRIMARY KEY (logical_id, resource_type, tenant_id) on fhir_resource_current and fhir_resource_version, because Hibernate emits @EmbeddedId columns alphabetically rather than in declaration order. On YugabyteDB the FIRST primary-key column decides the hash shard, so leading with a UUID distributes one practice's entire chart across every tablet and turns each read into a cross-node fan-out — the exact opposite of why the composite key exists. Recreates rather than ALTERs: changing a primary key is a table rewrite and Yugabyte's ALTER ... ADD PRIMARY KEY support is limited. Both tables are new and empty so this is exact and cheap, and the runner REFUSES to touch a table holding rows — at that point the right move is a planned migration with a data copy, and finding that out from a log line beats finding it out from missing charts. Idempotent, and it verifies against pg_constraint rather than assuming: once the key is right it inspects, agrees and does nothing. Exceptions are caught per table, because a throw escaping a runner silently withholds every runner ordered after it.
· kamo-internal
Add the System Bugs tab
Members report defects in the platform with screenshots and the sites they the dev machine. A tenant sees only its own organization's bugs and the platform organization sees every tenant's — decided server-side, so the console never has a scope of its own to get wrong. MANAGE_SYSTEM_BUGS is a platform right and gates an ACTION rather than a Configuration tab, which is what the surface-coverage test now records. The screenshot dropzone takes a paste as well as a drag or a click, because a screenshot is almost always already on the clipboard by the time somebody sits down to report one. Send To AI checks whether the dev machine can take a dispatch BEFORE the operator writes anything, and keeps their text on a failure rather than making them write it twice. Model and effort are sent as names; the console never builds a flag.
· kamo-shared-library
DS4P security labels, applied at write time (SP2)
The indexed projection of a resource's meta.security labels, and the thing that has to exist before the first clinical read path rather than after the first behavioural-health customer. Applied at WRITE time, never computed at read time, for a mechanical reason: a label derived at read time has to be derived by every read path independently — FHIR search, the portal, C-CDA generation, the TEFCA responder, Bulk export, claim submission — and the one that forgets is the one that leaks. Written once beside the resource, it is a single predicate every path shares. Indexed rather than living only inside the resource JSON, because segmentation is a FILTER on every clinical query. A label the database cannot see means filtering in application code after the rows are loaded, which is both slow and exactly the shape that leaks under pagination. labelKind exists because the four kinds are enforced differently and must not be compared as one bag. A confidentiality level is a ceiling on who may read; a sensitivity code says which consent applies; a refrain policy is an instruction to the RECIPIENT that travels with a disclosure. Treating a refrain code as a read filter blocks nothing; treating a sensitivity code as one blocks everything. patientId is denormalised because a consent check asks what is restricted for THIS patient, and walking from every labelled resource back to its subject would be a join per row on the hot path of every clinical read. manuallyAssigned is there so an automated relabel cannot silently overwrite a clinician's explicit restriction, and assignedReason so "who decided this note is Part 2 data" has an answer that is not "the system did" — needed both to defend a restriction and to lift one applied in error. Urgent rather than theoretical: 42 CFR Part 2's compliance date passed on 16 February 2026 and OCR opened civil enforcement on the 13th. The 2024 rule relaxed physical segregation but kept the redisclosure notice and the bar on use in legal proceedings, so Part 2 provenance must still be labelled and must survive downstream.
· kamo-shared-library
Extend the audit spine for clinical accounting (SP2)
Four fields the clinical read paths cannot work without, and none of which can be backfilled — each records something only knowable at the moment of access. purposeOfUse is the one that matters most, because TWO different legal reports are built from the same rows and they disagree. §164.528 — HIPAA, six-year lookback — EXCLUDES treatment, payment and operations. 42 CFR §2.25 — Part 2, three-year lookback through an EHR, live since 16 February 2026 — INCLUDES them. A row with no purpose belongs in neither and cannot be assigned to one later; deriving it from the request path afterwards is guesswork, and the output is a legal document handed to a patient. Over-reporting alarms them about disclosures that never happened, under-reporting is a violation, and neither raises an exception. patientCompartmentId is NOT resourceId. The sensitive record is usually an Observation or a note whose id says nothing about whose chart it is, so without it the literal §164.528 question — everything ever seen about this patient — has no predicate and becomes a full scan of the largest table on the platform. It gets an index, and that is a stated trade rather than a free win: on Yugabyte each secondary index is a separate distributed table, so an audit insert becomes five distributed writes rather than four. The batched writer absorbs it; a full scan against a 60-day legal clock does not absorb anything. accessBasis records on what AUTHORITY the caller reached the record, as distinct from why they wanted it. "Opened this chart for treatment" is unremarkable; "opened this chart for treatment and is not on the care team" is the finding an insider-threat review exists to surface. Whether someone was on a care team six months ago is not answerable from today's care team table, so it is stamped now or it is gone. Nineteen clinical resource types appended, named after their FHIR resource types so an audit row, an AuditEvent projection and a TEFCA disclosure record all say the same word. Safe to append with no migration: the column is VARCHAR(40) with no CHECK — and a test pins that every name still fits, because an oversized name fails at insert, which for a fail-closed kind means refusing the read itself. Every field is nullable, so all existing rows and every non-clinical caller are unaffected. 1636 tests green.
· kamo-shared-library
The eight typed search-index tables (SP3)
Search runs over explicit typed tables, not JSONB with GIN. That is forced by the database, not chosen: Yugabyte's own docs state a GIN index cannot use more than one required scan entry, multi-column GIN is unsupported, only IndexScan is available, and UPDATE is DELETE + INSERT. The single query US Core mandates most — Observation by patient AND category — is two required scan entries, so a JSONB-primary server would be slowest at exactly what certification tests. Each table exists because its type has a failure mode the others do not: - SpidxDate stores an INTERVAL, because a FHIR date IS one. 2026, 2026-08 and 2026-08-24 are different values and eq2026 means the whole year. A single instant collapses them and then silently fails to match, so a birthdate recorded as a year is not found by a search for that year. A null high means STILL RUNNING, not missing. - SpidxQuantity indexes ONLY the UCUM-canonical value, and deliberately has no column for the entered pair. Comparing entered units lets a query for 7500 ug miss a stored 7.5 mg, and the miss is silent — an empty result reads exactly like the patient having no such observation. A quantity that cannot be canonicalised is not indexed at all, which is the honest outcome. - SpidxComposite holds both halves on ONE row. Joining a token table to a quantity table on the resource cannot express "same element": a blood pressure has systolic and diastolic components, and that join would match the systolic code against the diastolic value and report a hypertensive reading that was never taken. - SpidxString keeps normalized and raw separately, because matching must not change what is displayed. :contains needs trigram matching Yugabyte does not do well; the CapabilityStatement must decoder it rather than offer a modifier that degrades to a scan. - SpidxReference is separate from fhir_reference_link on purpose. The link table is the GRAPH that _include and chaining walk; this is the INDEX a plain search filters on. Merging them makes every ordinary search pay for the graph's wider rows and double indexing. hashIdentity is a 64-bit hash of (resourceType, paramName) so the leading key stays narrow and fixed-width. Collisions are harmless because both strings are also stored — treating the hash as authoritative would be the bug. FhirSearchIndexShapeTest pins the invariants whose violation is silent, and is mutation-proven: re-adding the entered value to SpidxQuantity and dropping the reindex index from SpidxToken each failed with their own explanation before the revert. ClinicalEntityDisciplineTest's field scan now walks the class hierarchy too, so a violation parked in a @MappedSuperclass — which is exactly where shared columns live — can no longer hide from it. 1630 tests green.
· SecurityService
Send To AI, gated on MANAGE_SYSTEM_BUGS
The class carries @RequirePlatformRight, so the interceptor resolves the caller and refuses before any handler runs — a per-method check would have been one forgotten annotation away from a hole, and the endpoint reaches a prompts. The access check IS the defence. Model and effort are mapped from a fixed vocabulary, so a request body can express a different model and nothing else — never a flag. The audit row is written and COMMITTED before the dev machine is called, from its own bean: REQUIRES_NEW through a self-invocation is inert, and a row that only appeared on success would be missing the dispatches that hung. SessionPlatformFacts now comes from the shared library, so MediaService and this service read godModeActive the same way.
· MediaService
Bug CRUD, screenshots and the one visibility rule
A system bug crosses the tenancy line in one direction: the platform organization sees every tenant's, and no tenant sees another's. That rule lives in **************** and nowhere else, decided inside the query rather than by filtering a fetched page, and written long-hand because Optional.map treats a null result as EMPTY — as map(...).orElse(-1L) it fell through and the platform org saw nothing at all. Screenshots are Img rows under the new SYSTEM_BUG_SCREENSHOT association, validated by magic bytes rather than by the content type the browser claims. MANAGE_SYSTEM_BUGS is resolved through the shared PlatformRightsResolver and the shared SessionPlatformFacts, never off the session's tenant rights list: the tenant helper treats GD alone as a bypass, while a platform right needs an OPEN god-mode window.
· kamo-shared-library
FHIR resource versioning and the reference-link backbone (SP3)
Three tables that between them make read, vread, _history, ETag and every form of _include work. fhir_resource_current is a pointer, one row per resource forever. It exists so a GET is a single primary-key lookup: finding the highest version_id instead would scan a resource's whole history on every read, so the most amended charts — the sick patients — would be the slowest to open. It is also what makes the version counter safe, because claiming the next version must be an UPDATE ... RETURNING as the FIRST statement of the write. Reading then writing is a read-after-write in one transaction, which Yugabyte aborts with 40001 under load — intermittently, so it presents as a random deadlock that a retry loop appears to fix without fixing. fhir_resource_version is append-only and holds the canonical JSON as a DERIVED read cache, not the system of record. projectorVersion is on it because that cache goes stale the moment the mapper or profile changes, and without it a US Core bump silently keeps serving last year's shape until certification testing finds out. The append-only guard is a throwing @PreUpdate/@PreRemove, NOT @Column(updatable = false): Hibernate silently omits such a column from the UPDATE, so the write appears to succeed and the value just does not change. The database half is a REVOKE in the migration, because entity callbacks are bypassed by bulk JPQL and native SQL. fhir_reference_link is the single backbone for _include, _revinclude, :iterate, chained search and _has, indexed in BOTH directions because _include and _revinclude are opposite scans and neither index serves the other. sourcePath keeps two references to the same target apart — an Encounter naming one practitioner as both participant and attending is two different clinical claims. Deleted resources keep their rows. FHIR requires a deleted resource to keep answering vread and _history, and removing the row would free the logical id for reuse, silently reattaching a new resource to an old audit trail. The discipline harness needed one rule rewritten. everyClinicalEntityExtends TheClinicalBase tested the MECHANISM; it is now everyClinicalEntityIsTenant Scoped and tests the PROPERTY, accepting either a ClinicalBaseEntity subclass or an @EmbeddedId whose first field is tenantId. The infrastructure tables need the second shape because on Yugabyte the primary key is also the co-location key. Both new rules are mutation-proven: stripping the append-only annotations and reordering the composite key each failed with their own explanation before the revert. 1624 tests green.
· kamo-internal
Show a member's photo on the scoreboard, not their initials
The face now comes from the same directory index every other member list on the platform reads (useMemberProfiles), so a person's picture on the scoreboard is identical to their picture in the pickers and the attendance console rather than merely similar. Initials stay as the fallback, with the hue still derived from the member id so somebody with no photo keeps a stable identity across periods. The avatar URL is deliberately NOT added to the scoreboard endpoint. Building one is MemberAvatarUrlService's job and it lives in SecurityService: it needs the avatar repository, a JDBC read for the photo extension (a lazily-loaded Avatar is a Hibernate proxy, so instanceof AvatarPhoto is false often enough to serve a 404) and the forwarded host. Its own javadoc says not to add another copy, and a second implementation in MediaService would have been exactly that. MUI's Avatar rather than an img or next/image, specifically for its fallback: it renders the children when the image fails to LOAD, not merely when the src is absent. That is the case that actually happens — an avatar row can outlive the object behind it, so a well-formed URL 404s and a hand-rolled img would leave a broken-image glyph in the middle of the board. The 'sm' variant, matching every other small disc: ten originals downloaded to be drawn at 20px is the kind of waste that only shows up on someone else's connection. getAvatarSizedUrl leaves a generated SVG avatar alone. The directory is walked only once a board exists and only for the ids on it, and the index is a module-scoped session cache shared with those other screens, so in practice this is paid once if at all.
· SecurityService
Turn the workspace picker on
A member of several organizations now chooses which one to open after their password, instead of it being chosen for them by the hostname. This is the switch that makes an organization without a domain of its own reachable through the front door rather than only from /network. Reached only on the platform host. A custom domain names its organization on its own and skips the picker entirely, and a member of exactly one organization is signed straight in with no extra screen — 17 of 53 users have more than one membership, so most sign-ins are unchanged. Turned on once before and reverted the same hour: the redirect was relative and resolved to login.<apex>/validate, kamo-login's own validate page, which spends the one-time key and returns to the sign-in screen. That would have broken every sign-in on the platform host, not only multi-org ones. Both completion paths now name internal.<apex> explicitly (a26b480) and tests pin the destination. Everything it depends on is live: the split auth and session/select with trial-start and availability parity (604dee2), kamo-login's picker (59e1a7a), kamo-internal taking its organization from the session rather than the host (97cd4a0b), and the redirect fix.
· SecurityService
Turn the workspace picker on
A member of several organizations now chooses which one to open after their password, instead of having it chosen for them by the hostname. This is the switch that makes an organization without a domain of its own reachable through the front door rather than only from /network. Reached only on the platform host. A custom domain names its organization on its own and skips the picker entirely, and a member of exactly one organization is signed straight in with no extra screen — 17 of 53 users have more than one membership, so most sign-ins are unchanged. Held off until every piece was live: SecurityService's split auth and session/select (604dee2, plus the trial-start and availability parity in the same commit), kamo-login's picker (59e1a7a), and kamo-internal taking its organization from the session rather than the host (97cd4a0b). Turning it on before kamo-login could render a workspace list would have shown a sign-in returning no session, which that client reports as a failed login.
· kamo-internal
Show Google's OAuth callback URL on its registration card
An operator filling in the Google Workspace registration had nothing telling them what to put in the Google Cloud console's Authorized redirect URIs, and the Redirect URI field on the card is not the answer — EmailService replays its own configured redirect and never reads that column, so whatever is typed there changes nothing. Show the real one instead: read-only, copyable, above the credential fields, and prefilled into the stored redirect when the row is empty so the record agrees with the screen. ProviderDef carries it only for providers whose callback is fixed in code; the rest keep a redirect that genuinely is whatever the operator types.
· SecurityService
Accept an explicit org, not only the host
Registration bound the account to whatever organization the host resolved to. On register.kamocrm.com — the signup page for every organization with no register host of its own — that is the platform organization, so every one of those signups created the account in the wrong place. An org named in the body now wins, resolved through OrgResolutionService by id or alias, falling back to the host lookup exactly as before when none is given. Same shape as the sign-in hint, and the same rule: it decides which organization the account is created under, as the host did, and every gate below it is untouched. Verified on a clean clone: 953 tests pass. (The working tree carries another session's in-flight PHI work, which fails 63 unrelated tests there.)
· APIService
Forward /api/email/oauth/callback to EmailService
Google's OAuth redirect target. Same shape as the email and meet provider callbacks beside it: public and sessionless, because the popup lands on this gateway origin with no tenant session and the org travels in ?state. The exact path outranks the /api/email/** wildcard on pattern specificity, so the auth-stripping callback forward runs rather than the ordinary one.
· EmailService
Serve Google's OAuth callback on /api/email/oauth/callback
The platform console needs an address to show an operator setting up the Google Workspace registration, the way VOIP has /api/voip/oauth/callback. The only email callback that existed was **************** shared by Microsoft 365, Google and Zoho through one configured redirect. Give Google its own, on the shorter path, and leave the other two where they are: a redirect URI lives in a vendor console, so repointing all three would have meant re-registering Entra and Zoho to move one. EmailOAuthCallbackController is a second door onto ProviderController's callback, not a second implementation — the result page, the state decoding and the token exchange stay in one place. The redirect is now chosen per provider, and because the token exchange has to replay whatever the authorization request sent, the callback reads the provider back out of the state it was handed.
· kamo-register
Brand and scope signup with ?org=
register.kamocrm.com is the signup page for every organization that has no register host of its own, so the hostname cannot say which one a visitor is signing up to. It resolved all of them to the platform organization — which painted the wrong branding, and created the account in the wrong place. ?org= names the organization by id or alias. It selects the branding and travels with the registration as the organization the account is created under. A hint, not an authorisation: the server re-resolves it and every gate on registration is unchanged. Branding comes from SecurityService's /org/public/{ref}, relayed rather than reimplemented so the rule about what a guessable reference may expose stays in one place. The theme base then follows the organization, used as given — dropping its first label the way the host path does would turn acme.kamocrm.com into kamocrm.com and paint a tenant as the platform. themePath is memoised, and the first render necessarily runs before the reference resolves, so it is recomputed once branding lands. A visit naming no organization clears any reference a previous visit recorded in this tab. Theme.tsx here is CRLF and was edited byte-wise, so the diff is the addition and nothing else.
· kamo-shared-library
The FHIR datatype layer and its discipline harness (SP3)
The foundation every clinical entity reuses, and the build gate that stops it drifting. Both land together on purpose: a harness written before there is anything to police passes vacuously, which is a failure mode this codebase has been bitten by before. ClinicalBaseEntity is deliberately NOT BaseUuidEntity. That one uses LocalDateTime and TIMESTAMP, and FHIR instant requires an offset — this platform already shipped a bug where a LocalDateTime reached the wire with none and a 3:33 AM message rendered as 10:33 AM. On a clinical timestamp that is a charting error. It also carries tenantId, which leads every index because on Yugabyte the leading column decides the tablet AND is the isolation guarantee: the same human is a different patient in each practice, so the patient is not the boundary. The datatypes carry the decisions worth not re-litigating: - FhirConcept inlines the PRIMARY coding and sends the rest to fhir_coding. A pure child table would force a join on every Observation and Condition read, which is most of the chart. - FhirQuantity stores the UCUM-canonical pair separately, because quantity search compares magnitudes. Without it a query for 7500 ug misses a stored 7.5 mg and returns an empty result that reads exactly like the patient having no such observation. A silent miss on a lab value is the worst failure mode in the schema. - FhirPeriod keeps precision, because a FHIR date is an INTERVAL: 2026, 2026-08 and 2026-08-24 are different values and eq2026 means the year. - FhirReference is a discriminator plus an id, never Hibernate @Any, which cannot be joined in HQL or usefully indexed on a distributed store. - Names, addresses and contact points are child tables holding entered, normalized and (for addresses) USPS-standardized forms separately. Matching must not change what is displayed, and display must not decide what matches. The existing EmbNameFull, EmbAddress and EmbPhoneNumber cannot express this and cannot be widened without breaking CRM callers. The harness enforces by reflection over compiled classes rather than a source regex, because every rule is about an annotation VALUE. It is mutation-proven: four simultaneous violations were injected and all four rules fired with their own explanations before the revert. 1623 tests green.
· SecurityService
Refuse the AI training corpus exports for PHI tenants
ANALYTICS_EXPORT is blocked because exports leave the boundary with no de-identification. Both training exports already had a fail-closed audit — there IS a record — but every line carries the extracted CONTENTS of a document and the export exists to train a model on them. A covered entity needs a Safe Harbor or Expert Determination path first, and neither exists. QcController.trainingExport is per-org. SystemConfigurationController's platform corpus deliberately crosses every tenant, so it uses assertPlatformWidePermitted: one covered entity anywhere on the deployment is enough to make an undifferentiated dump of everyone's document contents a disclosure. Both refusals land ahead of the repository read and the audit write. HMDA is deliberately NOT guarded here — it is a mortgage regulatory filing under MLOS, not an analytics export. 953 tests green, including two new refusal cases.
· MCPGatewayService
Refuse MCP tool execution for a tenant that handles PHI
executeTool forwards operator-supplied arguments to an operator-typed URL and can spawn a stdio process, with no allowlist, no SSRF guard and no destination audit — so the set of processors it can reach is unbounded and unknowable. That is why MCP_GATEWAY is BLOCKED_NO_BAA rather than pending work, and it had no enforcement point until now. The guard sits in McpServerManager.executeTool rather than the controller, ahead of the tenancy check and before any transport is touched. Note: **************** was already failing before this change and is unrelated.
· MediaService
Guard social messaging and Canva at their credential chokepoints
SOCIAL_MESSAGING is enforced in SocialCredsResolver.resolve — the narrowest point covering every adapter and both directions. No credentials means no Telegram, Meta, Instagram, X or Discord call can be made at all, inbound webhook verification included, since that HMACs with the same secret. None of those four will sign a BAA at any price, so no engineering moves it. CANVA is enforced on getConnection AND saveConnection. Guarding only the connect would leave an org that was already connected when it became a PHI tenant still able to use it, and the read is what every API call goes through to obtain a token. 213 tests green.
· kamo-shared-library
Guard the last four unenforced capabilities; the baseline is now empty
SOCIAL_MESSAGING, CANVA, MCP_GATEWAY and ANALYTICS_EXPORT were each declared outside the compliance boundary, bound to no ServiceType, and checked by nothing — the declaration was the entire control. They were baselined when PhiCapabilityGuardCallSiteTest first found them; all four now have real call sites and the baseline is empty. PhiTenantResolver removes the three lines every service was about to duplicate — load the org, map the flag, ask the guard — where getting the middle one wrong is silent: a missing row must resolve to UNKNOWN, and spelling it false turns an unresolvable tenant into a permitted one. assertPlatformWidePermitted exists for surfaces with no single org, like a corpus export that deliberately crosses every tenant. Asking the per-org question there would deny on UNKNOWN for the wrong reason and read as a bug; the question that actually applies is whether ANY tenant handles PHI. KNOWN_UNGUARDED is kept as an empty set rather than deleted, because the mechanism is the point: adding a name back is possible but deliberate and visible in review. 1597 tests green.
· kamo-shared-library
Rights, entities, repositories and DTOs
MANAGE_SYSTEM_BUGS is a PlatformRightType and must stay one: the dispatch it a tenant administrator can edit their own organization's security models. Screenshots need no table — an Img row already carries an assoc type plus an assoc object id, and imgs has no CHECK constraint on assoc_id. The new association is claimed by its own StorageDomain, so the coverage test that fails the build on an uncounted upload stays satisfied. SessionPlatformFacts moves here from SecurityService: MediaService now resolves platform rights too, and two readers of godModeActive is precisely the failure that class warns about.

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