Changelog
What's new.
Every improvement to the platform, as it ships.
· kamo-internal
Confirm before a refresh takes a live call
The SIP session lives in the document and nothing on the server holds a handle to it, so a reload ends the call outright — with no BYE, leaving the far end on silence until the registrar times the contact out. One browser-native confirm is the entire defence a page has, and F5 is the most common way a call is lost. Also extracts isCallActive as the one definition of a live call, so the close veto, the activity heartbeat and the drop beacon cannot drift from it. A ringing inbound call counts, and so does the SDP-negotiation gap the member experiences as the call already happening.
· kamo-internal
Rebuild the availability editor around the day it describes
The editor was a stack of form rows that never showed what it was making. It now opens with the thing itself: a 24-hour rail with the block painted on it, in the same green it will be on the grid behind. That costs the top third of the dialog and earns it, because the question this surface provokes is not "which controls are these" but "what am I creating?" — and a picture of a day answers it in a way another paragraph does not. The rail animates its position and width as the hours change, and a block running past midnight draws as the two segments it really is and says so, rather than looking like a mistake. Days became a ribbon. A week is seven things and a person points at them, so they are seven chips instead of a dropdown hiding six behind a click — and when creating, several at once, because "Monday, Wednesday and Friday mornings" is one thought and three windows. The button then says how many blocks it will make, so the multiplication is never a surprise. Editing keeps it to one: a block must not become five behind a button that says Save changes. Days already carrying an identical block are dropped, reusing the tested daysNeedingWindow. Hours got the words people say out loud — Morning, Afternoon, All day — because "I am free mornings" is the actual thought and translating it into two time pickers was the friction worth removing. The effective range and note move behind More options, which opens already expanded when the window being edited uses them, so nothing previously set hides behind a disclosure. Copy asks a question and the primary action keeps the sidebar's verb, so the feature reads as one vocabulary rather than three. The entrance is three beats — header, rail, controls. Six staggered regions was the first instinct and read as a parade, drawing attention to the animation rather than to the day being described. All of it collapses to nothing under prefers-reduced-motion. The sidebar is 364px, 30% wider. The availability panel put real reading below the calendar list and at the old width the button that starts the whole thing sat below the fold; the grid beside it is fluid, so the column can simply take the space. The dialog's palette lives in availabilityTheme with the rest of the layer's colours: the block on the rail has to be the same green as the block on the grid, and a separate palette here would sever that the first time either moved.
· kamo-internal
One address for a conversation on any of five surfaces
chat/support/social share the chat: namespace because they share one unread snapshot; SMS and AI get their own, because a VoipConversation UUID and a media session GUID are unrelated ids that must not collide in one map.
· kamo-internal
Routes for the notification spine's counts and mark-reads
The PUT routes answer 204 and must not call .json() on it — that turns a successful mark-read into a 500.
· kamo-login
The workspace picker shows each org's logo
Two letters standing in for a logo was a workaround for the response not carrying one. It does now, resolved server-side from the folder the org's theme was provisioned into, so a member picking between five workspaces recognises them rather than decoding initials. The monogram stays as the fallback, and it is a real one rather than a formality: nothing records whether an org ever uploaded a logo, so the URL is where the file WOULD be and a 404 is an ordinary answer. Failures are remembered per org id — without that the browser retries the same missing image on every re-render and the avatar flickers between a broken frame and two letters. A logo sits on white so it reads the same on the selected row as on the others; a monogram keeps the tinted chip it has always had.
· SecurityService
The sign-in response carries each workspace's logo
The picker drew two letters per row because the response held a name and an alias and no image. A list of workspaces is read by mark long before it is read by name. Resolved here rather than in the browser. The URL has to name the folder the theme was provisioned into, and that rule already lives in OrgDomains.themeImageBase — rebuilding it in kamo-login would be a second copy to drift, and every row would request a file nothing ever wrote. simpleLogoUrl mirrors resolvePrimaryDomain for the same reason: DNS-verified first, then merely active, then {alias}.kamocrm.com for an org that has no domain at all. The domains ride on the existing sign-in query as two aggregates rather than a lookup per org — this is the sign-in path, and a member of a dozen workspaces would otherwise pay a query each. Two, because choosing between them is the preference above. Nothing records whether a logo was ever uploaded, so the URL says where the file WOULD be. A 404 is an ordinary outcome and the row falls back to its monogram. Verified against production: kamocrm.com, b11capital.com, breakbeat.store and the demo subdomain tenants all return the image at the URL these rules build — the subdomains from their own folder on the parent apex, not collapsed into the platform's.
· kamo-internal
Set up RingCentral with a JWT, and assign members their own number
Three screens, one change. The phone server's overview tab replaces "Connect with RingCentral" with the credential form the JWT flow actually needs — client id, secret, platform URL and a service-user JWT — built like the Teams one next to it. Platform URL is editable because a sandbox app's JWT is refused by production and vice versa, and pointing at the wrong host produces an invalid_grant that reads exactly like a bad credential. Secrets round-trip as "***", so editing the phone server's name does not mean re-pasting them. The platform Advanced tab loses its RingCentral card. There is no Kamo-wide RingCentral registration any more, and an operator filling one in would be entering credentials that nothing reads. Same reasoning as ADP_WFN. The member's phone-settings tab had a real bug: the provider dropdown listed only phone servers that already had a free synced extension, so a RingCentral server created minutes earlier was invisible — an empty provider list, nothing to assign, and no way to tell why. Every active phone server is listed now, and one with nothing loaded says so and offers to sync in place. Assignment also gains the number itself. Extensions carry their own DID, so the dropdown reads "101 — Ada Lovelace (+1 702 555 0134)" rather than a bare extension number, and the member's outbound caller id becomes their own line instead of the org's main number. And the RingCentral connect button becomes a JWT field: the server proves the credential before storing it and reads back which extension it resolves to, so a JWT created on a colleague's extension is flagged rather than silently registering the softphone as that colleague. The Teams card keeps its consent hop, and the softphone's prompt stops naming RingCentral at Teams users. The member card's page of hard-coded English is keyed while it is being rewritten.
· VOIPService
Authenticate RingCentral with a JWT, per phone server
RingCentral was the last provider whose credentials came from a Kamo-wide registration plus two browser consent hops — one for the org, one per member. Both are gone. An organization registers its own app in its own RingCentral developer console with the JWT auth flow enabled, and the client id, secret, platform URL and a service-user JWT live on the VoipProviderInstance that uses them, exactly as FreePBX and Teams credentials already do. One customer's credentials can no longer authorize a call against another customer's account, and there is no consent to obtain, no token to store on a browser's behalf, and no "reconnect" state to fall into. **************** mirrors TeamsInstanceConfigFactory, minus the platform fallback: a JWT is meaningless against anyone else's account, so a fallback could only let a half-configured instance borrow another org's app id and fail later as an opaque 401. require() names the missing field instead. RingCentralJwtTokenService mints access tokens from a JWT and caches them keyed by a digest of the credential, so rotating a JWT is self-evicting and a stale token can never be served for a credential that has been replaced. A 4xx means the credential is dead and asks for a new one; anything else stays retryable so an outage is never mistaken for a revoked JWT. RingCentralProvider is now extension-addressed. Built from the service-user credential it reaches a member at /account/~/extension/{id}/..., so their call history, voicemail, presence and call forwarding work the moment an admin assigns them an extension — previously a member with no OAuth connection got nothing at all. Built from the member's own JWT it addresses ~. Only client-info/sip-provision needs the latter, and asking for it with the account token would not error: it would hand back the SERVICE USER's SIP identity and register the member's softphone as that account. It refuses instead, in the shape the softphone already handles. listExtensions now reads the account's number inventory and carries each extension's own DirectNumber, so a member can be assigned their own line rather than everyone sharing the phone server's main number. Company and main-company numbers are deliberately excluded — treating those as personal would give half the org an identical "direct" number. The webhook provisioner is driven off instances rather than a token table: an org can run more than one RingCentral phone server, and each needs its own subscription against its own platform URL.
· InitializerService
Add the RingCentral JWT and direct-number columns
Order 0, because the shared-library entities map both columns from the moment this build starts: every runner that loads a PhoneExtension or a MemberVoipOAuthToken through JPA emits them in its SELECT, so a runner ordered later would never execute — an earlier one dies on "column ... does not exist" and takes the run down first. Nothing here reads an entity, so there is nothing for it to wait behind. Both statements are ADD COLUMN IF NOT EXISTS and both columns are nullable, so re-running is a no-op and no row is rewritten. The leftover OAuth rows are deliberately left in place: status is read from jwt_assertion now, so a stale row reports "not connected" rather than claiming a member is set up when they are not. The columns were also applied directly to Yugabyte ahead of the VOIPService deploy — pushing this repo ships nothing on its own.
· kamo-shared-library
Give a member a RingCentral JWT and an extension its own number
Two columns, both nullable so nothing is backfilled and no existing row changes meaning. **************** holds a member's own RingCentral JWT. RingCentral only provisions a SIP identity for the extension that owns the token presented, so a softphone still needs a per-person credential; the JWT is the long-lived thing that mints it, in the place the rotating refresh token used to sit. Teams keeps using the refresh token, so the table now holds whichever long-lived credential its provider issues. phone_extension.direct_number holds the extension's own inbound DID. Without it a member's outbound caller id could only ever be the phone server's main number, so "assign this member their own number" had nowhere to be recorded. Extensions that have no direct line, and providers that model inbound routing separately from extensions, leave it null and fall back exactly as before. **************** is marked retired rather than removed — ids there are persisted and the enum is append-only. Nothing reads it any more: a RingCentral phone server's credentials are issued inside the customer's own account, so they live on the instance that uses them.
· VOIPService
Missed-call and voicemail counts for the softphone indicator
Reported as separate lines rather than a total: the icon shows the sum and its dropdown shows the breakdown, so a pre-summed wire value would make the dropdown impossible. Voicemails are counted per instance, matching the set the voicemail tab lists, so the badge and the list it opens cannot disagree.
· AIService
Unreviewed-response counts and a mark-read
Keyed by session guid, which is what a tool window carries. Not audited as a disclosure: it returns counts and never a word of a transcript, and recording a badge refresh would bury the real disclosures.
· VOIPService
An unread snapshot, a mark-read, and a live frame
Its own NATS subject rather than the existing message one: the browser's handler for /topic/voip/message/ plays the arrival sound and opens a tool window for every frame it gets, so a badge refresh delivered there would chime and pop a window each time a count ticked.
· kamo-internal
Availability for presenters only, and a way to understand it
Three things, all about the same layer. WHO SEES IT. The availability toggle was shown to everybody. Declaring hours is only meaningful for somebody a webinar type actually lists as a presenter — nobody is one by default, and hours declared by anyone else could never be picked by a soul. The toggle, the sidebar panel and the grid gestures are now all withheld unless GET /api/webinar/presenting comes back non-empty, and the hook starts in a loading state so the control never appears on first paint and then vanishes. It fails closed: an error hides the layer rather than offering an editor whose hours may belong to no session. WHAT IT MEANS. A new sidebar panel lists every window with edit and delete, and carries the explanation. That copy is the point of the panel: availability is the rare surface where the control is easy and the CONCEPT is not, and somebody added as a presenter last week has no idea whether marking an hour commits them to a meeting, blocks their calendar, or cancels something. It is written for a person who has never seen this system — that marking hours schedules nothing, that a booking becomes a real meeting they are told about, that existing meetings block it automatically, and that removing hours never cancels anything already booked. It also names the sessions they can be booked for, because a member may not know what they were added to. DIRECT MANIPULATION. Drag down empty grid to mark hours; drag a block's top or bottom edge to change it. Shared by the day and week views through one hook so they cannot drift. Whole hours, because the grid's rows ARE hours — that is the contract it keeps with the now spine — so a create opens the editor pre-filled rather than saving silently, and half past stays the editor's job. A resize commits straight through: the member has just expressed the hours with their hands and can see the result. The row arithmetic is separated out and tested, because two of its three cases are off-by-ones that read as grid bugs and the third is a data trap: an end at or before its start is how this platform stores an OVERNIGHT window, so a gesture must never produce one. A drag to the bottom of the grid ends at 23:59, and dragging one edge past the other collapses rather than inverting. Also renames the settings tab to Scheduled Webinars, in the tab, the settings menu and the launchpad card. Its ?tab= value becomes scheduled-webinars and old links are rewritten rather than aliased, so the URL a member goes on to share is the current one — without that they would land on Topics with the address bar still claiming otherwise, which is the documented failure mode for changing a tab key here. The menu entry points at the tab's own title key so the two cannot drift apart across 22 locales.
· BillingService
Make a replayed Stripe event a no-op
Adds BillingWebhookEventClaimService on its own bean, with REQUIRES_NEW on every method, and wires it into processEvent ahead of the dispatch switch. A losing claim returns without dispatching, so Stripe's redelivery of a money event does nothing rather than writing twice. The claim goes through an injected bean, never this.claim(...): a self-invocation bypasses the Spring proxy and silently voids REQUIRES_NEW, so a handler rollback would roll the claim away and make every retry look like a first delivery. CommissionService already shipped that bug once. The account key falls back to the non-null PLATFORM sentinel, because a NULL component in a unique key conflicts with nothing in Postgres and would silently disable dedupe for exactly the events this endpoint receives today. Also stamps the dunning clock with **************** the bare now() read the pod's default zone while every other timestamp in the file is built at UTC. Squashed from two commits: the bean-only commit failed BillingWebhookClaimShapeTest in isolation, since the field wiring landed in the second. Nothing was pushed.
· MediaService
GET /api/webinar/presenting
The webinar types the CALLER is a presenter for. The calendar asks this before it offers an availability layer at all: hours declared by a member no type lists can never be booked by anyone, so for almost everybody the honest answer is an empty list and the whole control is withheld. Always about the caller, with no memberId parameter, unlike the availability endpoints beside it — whether a colleague presents something is a fact about the roster and already lives on the type. No right is required: it discloses only which of the organisation's own sessions name the person asking. Narrowed to PublicWebinarTypeDTO rather than WebinarTypeDTO, which carries the rest of the presenter roster including colleagues' email addresses. Title and length are what a 'you can be booked to host these' explanation needs.
· kamo-shared-library
Find the types a member presents, and parse-guard the queries
The calendar needs to answer one question before it offers an availability layer: is declaring hours meaningful for this person? Nobody is a presenter by default, and hours declared by a member no type lists can never be booked. Both record states are checked. A soft-deleted webinar type keeps its presenter rows, so testing only the presenter row would keep the layer alive for somebody whose only session was retired months ago. WebinarQueryParseTest is the other half. A JPQL string is not checked by the compiler and Spring Data validates an annotated query when it BUILDS THE REPOSITORY BEAN, so a path the mapping does not have compiles, ships, and then stops MediaService from starting at all — a crash-looping deploy rather than a query that returns nothing. The harness is the one ClinicalQueryParseTest already uses, pointed at the webinar package; metadata is built over every entity in the library because a presenter points at a TeamMember, which points at an Organization, and no database is touched. Verified it actually catches a bad path before relying on it: breaking one fails the build with the attribute and entity named.
· MediaService
Tell a member about a webinar booked on their behalf
Not gated on a webinar right, unlike the ticket counters: the case this exists for is one where the member took no action at all, so requiring the right to request a webinar would silence exactly the people who need telling. Also updates SupportControllerClaimTest's direct constructor call, which broke when SupportController gained the WebinarAttendeeRepository dependency.
· kamo-internal
The new organization opens in its own tab
Creating one signed THIS tab into it, so a member who created an organization on someone's behalf was silently moved out of the one they were working in — and had to sign back into it. The one-time key is spent in a new tab now and this one returns to /network, where the new organization is listed. Same sequence Enter already uses there: the tab is claimed synchronously inside the click, before any await, or Safari treats it as a popup the user never asked for; and its sessionStorage is cleared first, because a same-origin window.open clones this tab's, which holds the session for the org being left. Falls back to spending the key here when popups are blocked outright — better than stranding the member with one that expires in seconds — and closes the claimed tab on any path that has nothing to show in it.
· MediaService
Count unreviewed bug reports for platform admins
Zero for an ungated caller rather than a 403: the nav polls this on every page load for every member, and an error they must routinely swallow is how real errors get ignored.
· MediaService
Route unread snapshots to their own STOMP topic
Everything on voip.> went to one topic, whose browser subscriber plays the SMS arrival sound and opens a tool window for every frame. A badge refresh there would chime and pop a window each time a count ticked.
· MediaService
Refresh unread badges when a visitor message lands
persistInbound announces its own message (publishRealtimeEvent=false, so a visitor's reply does not render as the owner's), and in doing so also skipped the unread broadcast the ordinary save path performs. The count was right on a page load and then never moved.
· kamo-internal
Write, sign and amend a visit note
The composer changes shape once a note is signed, and that is the whole design. Before signature it is a text area that autosaves. After signature the text area is GONE and the only ways forward are an addendum or an amendment — because a signed note is a legal record, and an edit box on one invites exactly the thing that makes a chart indefensible: a record that cannot show what it used to say is not corrected, it is altered. The attestation is shown in full before Sign, not behind a tooltip. A signature on text the signer never read is worth nothing. The draft is flushed to the server first, because signing a note whose last four seconds never arrived attests to text the record does not have. The three amendment kinds are chosen explicitly with their consequence written beside each one. An addendum leaves the note standing; an amendment corrects it and keeps the original readable; a §164.526 patient amendment records a disagreement that enters the record whether or not the clinician agrees. They are different legal instruments and the UI says so at the moment of the choice rather than in a help page. Autosave writes a new VERSION rather than overwriting, including for drafts, and the version list shows the superseded ones — that is what lets a correction be shown as a correction. It also saves on unmount: a clinician who navigates away mid-sentence has lost that sentence otherwise, and they will not type it again the same way. The visits panel renders "chart open" and "chart closed" as different states, because they are different facts usually days apart. Everywhere else a finished visit and one whose documentation was never completed look identical, which is precisely why practices carry weeks of unclosed encounters without noticing. Closing refuses while a note is unsigned, and the server's refusal text is shown as-is: it names the reason, and a generic "could not close" would send the clinician looking in the wrong place. Timestamps render in the MEMBER's zone via useFormatters, not the browser's. A clinician covering a practice in another state sees that practice's clock everywhere else in this app, and a save time that disagrees with the note's own signed-at reads as a bug rather than a timezone. The visits tab mounts only when opened: it loads a patient's whole documentation history, and paying for that on every chart open makes the common case slow for the sake of the uncommon one.
· SecurityService
The visit and its documentation — the loop a practice runs on
Starting a visit is what grants the clinician the chart, and that is the design rather than a side effect. A clinician about to see a patient has no relationship with them until the visit begins, and requiring an administrator to grant one by hand would make the system unusable. So rooming a patient writes a time-bounded CareRelationship, and the record of WHY somebody could open a chart is the visit itself — a far better answer at audit than "an administrator granted it" with no date attached. Whoever rooms the patient gets it too: a medical assistant taking vitals is doing clinical work and has exactly as much business in the record, for exactly as long. And it EXPIRES, because the point of a relationship is that it ends — a clinician who saw someone once in 2019 is not still in their chart. Starting is idempotent. Two people clicking "start visit" is ordinary, and two encounters for one visit splits the note from the charge. Ending a visit and closing a chart are separate acts, usually days apart: the patient leaving is clinical, the chart being complete is administrative, and a practice with a week of open encounters has a workflow problem that nothing else surfaces. Closing REFUSES while a note is unsigned — a closed chart with an unsigned note is a visit that looks documented and is not, and it cannot be billed either, because the note is what substantiates the claim. Letting it close hides both behind a green tick. Notes are never edited. Every change writes a new version and the previous one stays renderable, including for unsigned drafts: "what did this say before the ambient scribe rewrote it" is a question people ask, and the cost of the answer is one row. Signing writes a new version rather than stamping the current one, because the version table's @PreUpdate throws. Three refusals encode who may do what. Only the author signs — signing somebody else's note attributes their clinical judgement to them under your credentials. Only the named supervisor countersigns. And an author cannot countersign their own note: that is not supervision, and under incident-to billing the supervision is what makes the claim valid, so a self-cosign is a false statement about who oversaw the care. Amendment kinds are not interchangeable and the API refuses a wrong one. An ADDENDUM adds and the note stands; an AMENDMENT corrects and needs a reason, with the original retained and pointed at; a PATIENT_AMENDMENT records a §164.526 disagreement. A record that cannot show what it used to say is not corrected, it is altered. 1269 tests green.
· kamo-shared-library
Add the Stripe webhook dedupe table, keyed by account and event
· SecurityService
The FHIR R4 endpoint, with the same access rules as the chart
An interoperability endpoint is where a system is most likely to acquire a quiet bypass: it is machine-to-machine, nobody watches it, and "the integration needs it" is a persuasive sentence. This one does not get a bypass. Every read goes through ClinicalAccessService exactly like the chart screen — a care relationship with the specific patient, not merely a session — and writes the same per-record audit rows. Three refusals are deliberate. `patient` is REQUIRED on every search but Patient itself. A search without one is a query across the practice's entire clinical record, which is an export: a different authorization, a different audit shape and a different conversation with a compliance officer. Bulk access belongs in $export where it can be treated as what it is. A denied read and a missing record answer the same 404, and a denied SEARCH answers an empty searchset rather than an error — otherwise the result count is an oracle a caller can walk the id space with, learning which patients a practice has. Compartment resources decide access on the OWNER of the record, never on the record itself. Deciding on the resource alone would let anybody who guesses an Observation id read one. $everything caps across ALL types with the safety-critical lists first — allergies, problems, medications, immunisations — so a patient with forty thousand observations cannot push their allergies out of the answer. That ordering is the difference between a truncated record that is still safe to prescribe against and one that is not. The CapabilityStatement is the single deliberately public handler, allowed BY NAME in EhrEndpointGuardTest rather than by a pattern, with two tests around the exemption: one asserting that handler touches no patient data, one asserting the allowlist names a handler that still exists — an exemption for a renamed handler is a hole waiting to be inherited by whatever takes its name. Errors are OperationOutcomes rather than bare JSON, because a FHIR client parses the body as a resource whatever the status code: handing it {"error": "..."} produces a parse failure on top of whatever went wrong, and the real reason never reaches a human. purpose of use defaults to TREATMENT here and only here. The chart screens require it, but a FHIR client is a machine that will not be updated to send a Kamo-specific parameter, and treatment is the conservative default — §164.528 excludes it from the accounting, so a mislabelled payment query under-reports rather than over-reporting to a patient about disclosures that never happened. 1243 tests green.
· kamo-internal
Convert any logo to vector, and say what the scan found per step
Three things, all reported from the wizard itself. THE COMPACT LOGO NO LONGER DEMANDS AN SVG. The slot can only store a vector -- the backend writes it to a fixed logo.svg key -- but the first cut turned that storage rule into the member's problem and hid a company's own logo from them because it happened to be a PNG. Most sites have no SVG anywhere, so the compact rail was empty on exactly the sites the scan worked best on. app/lib/logoVectorize.ts routes every file down the cheapest path that works, the way Behavior & Labeling already does: an SVG goes straight in; .eps/.ai/.pdf convert server-side with no dialog, since they already contain paths and asking a human about them would be theatre; a raster opens Kamo's ImageToSVGConverter, because tracing needs choices only a person can make. Uploads and scan suggestions take the same route. THE BANNER NOW SPEAKS TO ITS STEP. It used to recite the whole scan everywhere, so the Backgrounds step said "found 6 logos and brand colours" -- neither of which is on offer there -- while the one thing it is about went unmentioned and its empty grid read as a bug. Each step now reports its own finding, INCLUDING nothing: "couldn't find any images big enough for a full-screen background. Add your own below." It names the test the images failed, because a bare "nothing found" on a site the member knows is full of photographs reads as us not having looked. WHITE-LABEL LOGOS ARE HIDDEN WITHOUT A SOFTWARE NAME, as that section's own description always claimed. "Where each logo appears" goes too: every control in it is a Company-vs-White-label toggle, so without a brand each has one valid answer, and three dropdowns that cannot be meaningfully changed imply a decision the member does not have to make. Clearing the software name also pins those values back to COMPANY -- the step hides the controls in that state, so a stale SOFTWARE could never be undone. Also removes the deprecated AI background generation: the step's column, its handlers and blob-URL cleanup, generateBackgrounds, the aiPrompt field, and the /api/generate-background route.
· kamo-shared-library
Render the chart as US Core FHIR R4
The JSON builder is hand-rolled rather than a Jackson ObjectMapper, and that is a scar rather than a preference. This platform ships a global Long->String serializer on some mappers and not others; adding one to SecurityService's mapper produced 401s across the entire platform, and ids leaving quoted broke a bare-digit regex somewhere else entirely. A FHIR payload has exactly one correct shape, and it must not change because somebody registered a module for an unrelated reason three services away. Nulls are omitted, not emitted. FHIR has no element present-and-null: "birthDate": null is invalid against the specification, and a strict receiver rejects the whole resource over it. Control characters are escaped rather than dropped, because clinical free text arrives from scanners and faxes and silently deleting a byte from a clinician's note is worse than transmitting it awkwardly. Statuses carry their system. A receiver matches on the (system, code) pair, and "active" alone could be a condition's clinical status, an allergy's or a coverage's — three different value sets. A bare code is not partially useful: the receiver drops it or guesses. Three renderings encode facts that are easy to lose and impossible to recover on the far side: - "No known allergies" is a POSITIVE statement with its own SNOMED concept (716186003). Sending an empty allergy list instead says "nobody asked", which is a completely different clinical fact, and the receiver must then prescribe as if the question is open. - MedicationRequest.intent distinguishes a proposal from an order, and reportedBoolean says this practice did not write it — without which a receiver attributes every drug on the list to us, including the ones the patient merely told us about. - Immunization statusReason on a not-done dose is what distinguishes a patient who decoderd from a patient nobody asked. Only the second is a care gap, on their side as much as ours. Race and ethnicity render as repeating US Core extensions rather than one value, because a person can be more than one race and a single-string model erases exactly the multiracial patients the reporting requirement exists to count. An out-of-range administrative gender renders as "other" rather than as itself: an invalid code fails validation at the receiver and takes the WHOLE resource with it, so one unexpected value would cost the patient's entire record instead of one field. A merged patient still renders, with link.type = replaced-by — answering a legitimate holder of an old identifier that the record never existed turns a merge into data loss on their side. value[x] emits exactly one element. Emitting two makes the resource invalid and a strict receiver rejects all of it, so "send both and let them pick" costs the observation entirely. Errors are OperationOutcomes, and their diagnostics carry no PHI: an outcome is frequently logged by the receiver, and a patient's name in an error message is a disclosure to whoever reads their logs. 1890 tests green.
· kamo-internal
Workweek Days, Weekend Days and Every Day in the Day field
Declaring the same hours five times over was the only way to say 'I present on weekdays'. The Day field now offers the three groups above the individual days. A window stores exactly ONE day and there is no bulk endpoint, so a group is a composing convenience in the editor: it saves a window per day, and what lands in the table is ordinary per-day rows. That is deliberate rather than a new backend concept — 'weekdays, but not Wednesday next month' stays expressible, and every existing reader of a window is untouched. Three things the expansion has to get right, so the decision lives in app/lib/availabilityDays.ts where it is tested rather than in the form: - An edit must never expand. It is one row behind a button labelled Update, and turning it into a week of them would be the expensive mistake. Groups are withheld from the menu while editing, and daysToSave refuses to expand one regardless. - A group must not quietly collapse to one day, which is the same mistake in the other direction — it would declare a fraction of the availability asked for and look like it worked. - Days already carrying an identical window are dropped, so Every Day over an existing workweek adds the weekend rather than a second copy of the five already there. Only an exact match is skipped; overlapping windows at other times are legitimate and are left alone. The sentinels are prefixed so they can never be mistaken for a day name and posted as one — the drift the lead grid's agent filter learned about — and a test pins that. Saving several windows means partial failure is now possible, so the days that failed are named and the list is reloaded either way: the ones that did save are real, and a form still showing them as unsaved would invite a second attempt that duplicates them.
· kamo-internal
A healthcare market's commerce surface is its revenue cycle
The other verticals show orders and agreements because that is where their money is. A medical practice's money is in claims — and specifically in the ones nothing else will ever mention again. Rejected claims lead the screen and that ordering is its whole argument. A claim a clearinghouse rejected never reached the payer, so it is in no denial report; it was never paid, so it is in no payment report; its status changed once, weeks ago, and then nothing. Timely filing is second because it is the only absolute deadline in the cycle: past it the money is gone permanently with no appeal, and practices usually find out months later. Uncharged procedures are last and are the quietest of all, because the failure is a row that was never written. Appointments and encounters deliberately do NOT appear here. They live in /patients, where the care-relationship rules are — a schedule rendered on a commerce screen would be a chart surface without a chart's access control. Vocabulary mirrors the Java: an appointment is the INTENT, a slot someone holds and may not keep; the ENCOUNTER is the commitment, the visit that happened and the thing a claim is billed from. Calling both "appointments" loses the number every other number depends on. Timestamps in the patient formatters now go through parseServerDate. The clinical tables are TIMESTAMPTZ so those values do carry an offset, but the rest of this platform sends LocalDateTime with none — a parser that is right for both stays right when a payload changes shape underneath it. 3033 tests green, typecheck clean.

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