Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · kamo-internal

    The HR course editor, assignments grid and member course player

    /hr/training replaces its placeholder with the two sub-tabs the brief asked for, and the member's Assigned Training grid gains the action column it was holding back for want of a player. COURSES. A catalog grid with publish, assign, duplicate, archive and delete inline. Delete is disabled once anybody holds an assignment and the tooltip points at Archive instead — deleting a course somebody was assigned orphans their evidence, since the assignment pins a version whose parent would be gone. "Nothing has changed since version N" is shown as INFO rather than an error: the author pressed Publish on something already published, which is a success they misread. EDITOR. Outline · canvas · inspector as a full-page nested route rather than a dialog — a slide needs a canvas beside an outline and an inspector, authoring is multi-session work that has to survive a refresh, and hrTabMatches was written so a nested route keeps the parent tab lit and MANAGE_TRAINING-gated with no new registration. Saves are per-ITEM and debounced; a whole-course PUT with replace-set semantics turns two admins editing two different units into one silently losing their work. After each flush the course is re-read, because saveItemContent recreates child rows and a stale local copy would send phantom ids on the next save. Reordering is arrow buttons driven by moveInOrder, which CLAMPS rather than wrapping — wrapping silently reorders a course the author did not mean to reorder, and the order IS the reading order. MEDIA comes from /hr/resources through a new LibraryPickerDialog, generalised out of MaterialPickerDialog by lifting its two hardcoded constants into props. assocType is the ORDINAL (13), never the enum id (15): the enum skips id 6, and sending an id lands on a different collection's rules. Alt text is required on any media slide and blocks publish — the authoring tool is the only enforcement point that actually holds, because a review checklist is run once and alt text is added never. PLAYER. Resumes at the first GAP, never the furthest point reached, and every gate unions the server's record with THIS session — reading only the server's row makes Next unreachable the moment a tick has not round-tripped, which is the exact bug the legal wizard shipped. Video completion is measured as WATCHED COVERAGE, not currentTime. /api/imaging/stream honours HTTP Range end to end, so a learner can seek to the last second in one request and any position-based gate is defeated by a single drag; accumulate() treats a jump larger than the tick gap as a seek and opens a new interval instead of crediting the span jumped over. videoCoverage.test.ts asserts "seek to the end yields under 5%" as its first case. Coverage is media time, so 2x playback still has to cover the timeline — which is why allowing it is fair rather than a loophole. A visible escape hatch appears once the video's full length has elapsed in wall-clock time and records skipReason on the progress row, because a gate with no escape becomes a support ticket whose answer is always "we'll mark it complete for you" — the same outcome, untracked. The test runner carries no correctness data at all: the payload is built from PlayerQuestionDto, which has no such field, and the server decides. Answers autosave so a closed laptop is not a lost attempt, and blanks are allowed but must be confirmed. npm test: 3 guards clean, 102 files, 1257 tests. next build compiles.

  2. · DaemonService

    The hourly training reminder tick

    HOURLY at :10, unlike the legal sweep's daily 13:00. Training reminds each org at its OWN local send hour, and Organization.timezone spans 21 hours (Pacific/Honolulu to Australia/Sydney) — a single daily UTC tick means "today" is wrong for most of the platform, which is exactly why the legal sweep tells a Sydney tenant "due today" on the wrong day. Each tick visits only the orgs whose local hour matches, typically one to three, so this scans LESS than a daily cross-org job rather than 24x more. The job lives here rather than as a @Scheduled in DocsService because this deployment is replicas: 1 with strategy: Recreate, so the old pod is gone before the new one starts and a deploy window cannot double-fire. DocsService is RollingUpdate with maxSurge: 1, so for the length of every rollout there are two pods and both believe it is 09:00 — and for a job whose output is mail to every overdue employee on the platform, "usually once" is not a property. Explicit finite timeouts, because a default RestTemplate waits forever and, combined with @DisallowConcurrentExecution, one wedged downstream stops the job running again until the pod restarts — silently, and indistinguishably from "nothing was due". Never rethrows at Quartz: an escaping exception may refire immediately, turning a skipped hour into a retry storm.

  3. · DocsService

    The complete HR Training backend

    Course authoring, publishing, assignment, the member player, server-side grading, WORM evidence and the reminder sweep — the whole module behind /api/docs/training. AUTHORING. Courses carry an ordered list of items where a TEST is a PEER of a UNIT, never a child: that one decision lets a single model express both "a test at the end of each training show" and "a final exam" without a second structure. Content saves are replace-set because the editor owns the whole ordered list; a per-row patch API needs a delete verb plus a reorder, which is three round trips and three chances for the outline the author sees to diverge from the one stored. PUBLISHING freezes an immutable version that assignments pin. Every rule a member is held to is copied onto it IN THE SAME STATEMENT that writes the row — the legal module declared those columns and left them to a later caller that never came, which is why every legal version in production carries a null attestation. TRFP1 is length-prefixed rather than delimiter-joined so a crafted value cannot imitate a field boundary, and isCorrect is INSIDE the hash: the legal module shipped LPFP1 without the equivalent field, publish answered "nothing has changed", and a corrected layout could never be frozen. Publish refuses with a 409 naming the offending item — including a media slide with no alt text, because the authoring tool is the only enforcement point that actually holds. GRADING IS SERVER-SIDE, and this is a security property rather than a feature. The player is served **************** which are different classes from the author's DTOs and simply have no correctness field to populate — a shared class with a nulled field comes back the first time somebody reuses a mapper, and the leak is silent while the compliance record keeps looking valid. Shuffle and the question draw are computed here and recorded on the attempt, so a resumed session sees the same questions; a client-side shuffle over a client-held answer key is theatre. Correct answers come back only when show-answers permits it for that attempt, so a member cannot fail deliberately to harvest the key. Partial credit is floored at zero (without it, selecting everything scores negative and drags down the rest of the test). Percent is rounded half-up exactly once so the results screen, the email, the certificate and the CSV cannot disagree about 79.5% against an 80% bar. A course with no tests passes on completion — possible == 0 must never divide and must never mean "failed". EVIDENCE. TRMF1 (what they saw) and TRQZ1 (what they answered) are stored as digests AND verbatim canonical strings, because a digest nobody can recompute from stored evidence is not evidence. The attestation is written for every terminal outcome, pass or fail: "they took it and did not pass" is exactly as much a compliance fact as passing. REMINDERS sweep ORG-MAJOR and fire each org at its OWN local hour. Organization.timezone spans 21 hours, so the legal sweep's ZoneId.systemDefault() tells a Sydney tenant "due today" on the wrong day. The bucket stays a pure function of (dueDate, today), which is what makes Extend Due Date free and the sweep replayable; catch-up sends only the most recent unsent rung after an outage and records the older ones as superseded. Idempotency is a real table with a unique claim index rather than a Redis SET NX, so "did we tell Ada on 4 March" survives a FLUSHDB and is queryable — the table legal's own javadoc says is right and deferred. A dry run claims NOTHING; claiming would burn every bucket it inspected and suppress the real run behind it. DocsInternalAuthFilter gains the training internal path. Without it the cross-org sweep is world-reachable, because ResourceServerConfig is anyRequest().permitAll() and OTKPreAuthFilter continues the chain when no OTK is present. 455 tests pass.

  4. · kamo-shared-library

    The four training email templates

    Same constant/dotted-key split as the HR legal family: the CONSTANT is the filename stem, the templateKey the platform SENDS is the dotted value inside each .json. Getting that wrong is the live QUOTE_SENT/commerce.quote_sent bug — every send 404s forever against a template that seeded perfectly — so HrTrainingTemplateTest pins both halves in both directions. ONE reminder template covers every rung of the ladder (T-14/7/3/1, due today, and every past-due period). Tone is carried by a pre-rendered eyebrow and duePhrase rather than by separate files, because a half-added canonical pair throws inside loadAll() — which is what seedForOrg iterates — and would break seeding for EVERY org on the platform rather than only its own. Each template is a 600px inline-styled card with a bulletproof VML + anchor CTA, because Outlook's Word engine ignores the anchor entirely. The test asserts ctaUrl appears exactly three times (VML href, anchor href, copy-paste fallback), that no template uses {{#if}} (the renderer is a flat regex replace and would render it as visible text), that no template pulls an external resource, and that each one carries the three variables EmailTemplateService injects so white-label branding works. Subjects use courseTitlePlain and bodies use courseTitle: the two render from ONE variable map, so an escaped value in a subject shows "Employee's Handbook" in the mail client's header while an unescaped one in a body ships live markup from the org's own DKIM-signed domain. No retake email is sent on a FINAL failure. Telling somebody by email that they have permanently failed a mandatory course, with no path forward and no human attached, is the wrong medium — the HR grid carries it instead. 1186 tests pass.

  5. · kamo-internal

    One member surface for both obligations, one badge for either

    The member's compliance tab had two sub-tabs, Pending and Completed, for one kind of obligation. Those are a STATE, not a kind — so they collapse into a status filter (with "all", which two tabs had nowhere to put, and which previously meant checking both to answer "was it this one I signed?"), and the tab strip is freed to separate the two things that are genuinely different: Legal Packages — instruments to sign, unchanged behaviour behind a filter Assigned Training — courses to take Each panel keeps its own filter in its own param. One shared ?filter= would carry a legal bucket into the training panel, where the same word means a different set of statuses, and switching tabs would silently re-filter the other list. A foreign ?sub= is normalised away on mount, because SettingsView.writeTabToUrl sets ?tab= and deletes ?configure but leaves ?sub= alone — the screen was already correct via the index fallback, but the URL was claiming a state this component never had, and the URL is what gets bookmarked and pasted into a support ticket. NavTop now shows ONE badge for BOTH. A member does not think of "packages to sign" and "courses to take" as two systems, they think of things they owe; two nearly identical marks side by side in fixed chrome would make them work out which one is lit. The count is the sum, and the icon gains a real attention state — two rings on ::before/::after of a wrapper, so the glyph itself never moves and never fights the Badge anchor. Motion is an ADDITIONAL signal, not the only one (the count and the error colour still carry it), which is why prefers-reduced-motion drops it with nothing lost. MemberAssignedTraining deliberately ships NO action column. trainingActionFor already decides start/resume/retake/certificate and is unit-tested, but the course player is not built — and a visibly disabled "Start" is a promise the product cannot keep, which reads as broken rather than unfinished. The column arrives with the player. trainingStatusesFor sends every status EXPLICITLY for "all" rather than omitting the param: the server's no-filter fallback exists to rescue a stale bookmark, not to be a calling convention. Statuses travel as repeated params, never CSV — a comma is double-encoded by the APIService gateway. check-i18n-keys caught the missing keys exactly as designed; hr.training.member.* was pushed to kamo-translation-dictionary first and verified on its origin/main before this commit. npm test: 3 guards clean, 98 files, 1221 tests passing.

  6. · DocsService

    The member-facing Training API

    Registers com.kamo.z.shared.hr.training in @EnableJpaRepositories — @EntityScan is broad so the entities are found either way, but repositories are not. Two authorization funnels, deliberately separate, mirroring the legal module: TrainingAccess gates /api/docs/training/** on MANAGE_TRAINING (RoleRightType 185) read AND write, which must stay exactly the right app/hr/hrTabs.ts gates the tab on. A server set narrower than the tab's leaves a member able to open the tab and collect a 403 over an empty grid; wider, and it is an endpoint reachable by somebody the UI never offered it to. Rights are read from member_rights_applied, never from the token, because the OTK session map carries no rights. TrainingAssignmentOwnership authorizes the member's own surface by IDENTITY. TrainingMemberController therefore holds no TrainingAccess field at all — a member taking a course they were assigned has no HR right, and routing them through the HR funnel would mean either locking every employee out of their own training or widening the HR right set until the catalog and audience roster open to the whole org. Ownership answers 404, never 403: a 403 confirms the uid is real and turns the endpoint into an oracle for who was assigned what, and an assessment record must not be enumerable by a peer. TrainingMemberSurfaceAuthTest pins all three properties by reflection, because the failure mode is somebody adding the field later "so the endpoint is secured too". GET /assignments and GET /assignments/pending-count. No mapping takes a member or org id — both come from the session. status is a REPEATABLE param, never CSV: APIService hands its upstream URL to RestTemplate as a String, which re-encodes %2C to %252C, so a comma arrives as one literal token matching no enum constant. An unrecognised filter means "no filter", never "match nothing", so a stale bookmark shows everything rather than an empty grid that reads as data loss. The paged read is a DocsService-local repository over the shared entity, sorted (deliveredAt DESC, uid DESC) — paging on a non-unique key duplicates and skips rows, and a 200-member assignment writes deliveredAt inside the same millisecond. pending-count returns the same JSON shape as the badge's pushed frame so one client parser serves both. Rows are mapped inside the transaction; course and version are LAZY. 413 tests pass.

  7. · InitializerService

    Create the HR Training schema

    Registers com.kamo.z.shared.hr.training in the explicit @EnableJpaRepositories list — @EntityScan is broad so the entities are found either way, but repositories are not, and without this the tables are never created. TrainingSchemaMigration (@Order(43); 42 is the legal one) adds what the entity mapping cannot express. Five UNIQUE indexes are treated as fatal because each is a correctness guarantee nothing else provides: - (course_uid, version_number): two concurrent publishes would otherwise mint the same number and every assignment pinned to "version 3" becomes ambiguous. - one attestation per assignment: the WORM guard blocks UPDATE and DELETE, not a duplicate INSERT. - the notification claim index IS the idempotency of the whole reminder pipeline — the claim is an INSERT against it, so without it a retried sweep re-mails every overdue employee of every tenant. - one recertification per (member, course, due date); one progress row per slide. The course-item CHECK is written as a two-column predicate on purpose: SchemaCleanupRunner @Order(1) drops every single-column IN-list CHECK on every run, because that is the shape Hibernate emits for @Enumerated(STRING). CockroachDB/Yugabyte have no ADD CONSTRAINT IF NOT EXISTS for CHECKs, so "already exists" is caught and treated as success. The test is reflection-only. mvn test in this repo MIGRATES PRODUCTION — DemoApplicationTests is a @SpringBootTest with no test profile and no src/test/resources — so it asserts the SQL constants against each entity's @Table name without starting a context, which is the failure that actually happens: a table rename leaving the hand-written SQL behind.

  8. · kamo-shared-library

    Stream document uploads and add the HR Training schema

    ImageService gains uploadDocumentStreaming, the streaming counterpart of uploadDocument. The buffered form cannot express a large upload at all: readInputStream allocates new byte[size], a Java array tops out at Integer.MAX_VALUE, and MinIOStorageService.upload then allocates a SECOND full copy for cross-node retry. Against a 2Gi container that made a few hundred megabytes the practical ceiling — which is why the imaging multipart cap sat at 500MB while the chat attachment path, which already streams, ran to 3 GiB. The new method hashes once (both digests off one buffer), sniffs the MIME type BEFORE the hash pass so a mislabelled 3 GiB file is rejected without spending a full read on it, and delegates storage to a new storeContentAddressed overload that accepts pre-computed digests so the source is read twice rather than three times. Deliberately not @Transactional: storing gigabytes takes minutes and a transaction held across it pins a pooled connection. Also adds com.kamo.z.shared.hr.training — 20 entities, 11 enums and 20 repositories for the HR Training module, mirroring com.kamo.z.shared.hr.legal's conventions: BaseUuidEntity, an explicit last_modified with touch() because BaseUuidEntity.dateUpdated is dead, pinned columnDefinition on every optional @JoinColumn, and @OnDelete(CASCADE) on the audience FKs because CascadeType.* emits nothing at DDL. Two departures from the legal blueprint, both deliberate: - TrainingAssignmentStatus splits FAILED into FAILED_RETAKE_ALLOWED (open) and FAILED_FINAL (closed), and REMINDABLE_STATES is strictly narrower than OPEN_STATES so a member awaiting grading is never chased. Pinned by TrainingAssignmentStatusTest. - hr_training_notification is a real table with a UNIQUE claim index rather than a Redis SET NX, so "did we tell them on this date" survives a FLUSHDB and is queryable. Answer and attestation repositories extend the bare Repository marker, not JpaRepository, so the batch delete methods that bypass @PreRemove do not exist on the interface. Requires a KamoInitializerService run before any service that reads these tables is deployed.

  9. · InitializerService

    Create billing_pay_groups explicitly, and undo the legacy collision

    Creating it in a migration rather than leaving it to ddl-auto, which produced a half-built table when the entity was pointed at the pre-existing billing_groups.

  10. · EmailService

    Size each mailbox against the organization's storage

    Dovecot now enforces a per-mailbox limit, and nothing was setting the column it reads. This writes it nightly, after the storage snapshot so the capacity being divided is today's figure rather than yesterday's. Each mailbox gets an equal share of the whole pool. The property that matters is that the shares sum to the capacity, so mail can never on its own take an organization past its ceiling. It is also the version an owner can predict — "your share of the organization's storage" — and it does not move when an unrelated domain grows, which giving each mailbox the remaining headroom would. Unlimited plans and organizations with no live subscription write 0, which the user_query turns into a NULL quota_rule: no limit. That direction is deliberate, because the alternative is refusing mail for an organization whose billing rows are missing or mid-migration.

  11. · KlusterServices

    Enforce storage quota on inbound delivery, and stop shipping the DB password

    Object storage already refuses content that would take an organization past its ceiling, which covers every domain going through the object store. Inbound mail was the gap: LMTP writes straight into the maildir with no Java in the path, so Dovecot's quota plugin is the only thing that can stop it. The plugin loads globally so LMTP delivery is checked — that is the path that matters — with imap_quota added for clients that ask. There is deliberately no default quota_rule: a global rule would apply to every mailbox the userdb does not answer for, which is the opposite of failing safe. The per-user rule from user_query is the only source of a limit, and it is NULL until quota_bytes is set, so every one of the 18 existing mailboxes is unlimited exactly as before. virtual_users.quota_bytes was added ahead of this, defaulting to 0, because the new user_query references it: shipping the config first would have failed every userdb lookup and taken mail auth down. The generated dovecot.conf was validated with doveconf against the live 2.3.20 before pushing; it resolves to "quota sieve" for LMTP and "quota imap_quota" for IMAP. Separately, the ConfigMap no longer carries the MySQL password. It shipped in plaintext, committed here and readable from `kubectl get cm -o yaml`. The ConfigMap now holds a placeholder and the credential comes from the existing mysql-secret through the environment; the container refuses to start if it is empty rather than running with an unusable userdb. Note this edits the -simple pair, which is what CI applies and what is actually running. dovecot-configmap.yaml and dovecot-deployment.yaml are applied by nothing and describe a different image entirely.

  12. · KlusterServices

    Enforce storage quota on inbound delivery, and stop shipping the DB password

    Object storage already refuses content that would take an organization past its ceiling, which covers every domain going through the object store. Inbound mail was the gap: LMTP writes straight into the maildir with no Java in the path, so Dovecot's quota plugin is the only thing that can stop it. The plugin loads globally so LMTP delivery is checked — that is the path that matters — with imap_quota added for clients that ask. There is deliberately no default quota_rule: a global rule would apply to every mailbox the userdb does not answer for, which is the opposite of failing safe. The per-user rule from user_query is the only source of a limit, and it is NULL until quota_bytes is set, so every one of the 18 existing mailboxes is unlimited exactly as before. virtual_users.quota_bytes was added ahead of this, defaulting to 0, because the new user_query references it: shipping the config first would have failed every userdb lookup and taken mail auth down. The generated dovecot.conf was validated with doveconf against the live 2.3.20 before pushing; it resolves to "quota sieve" for LMTP and "quota imap_quota" for IMAP. Separately, the ConfigMap no longer carries the MySQL password. It shipped in plaintext, committed here and readable from `kubectl get cm -o yaml`. The ConfigMap now holds a placeholder and the credential comes from the existing mysql-secret through the environment; the container refuses to start if it is empty rather than running with an unusable userdb. Note this edits the -simple pair, which is what CI applies and what is actually running. dovecot-configmap.yaml and dovecot-deployment.yaml are applied by nothing and describe a different image entirely.

  13. · kamo-internal

    Let a member license their own seat from the console

    The panel that closes the loop on self-pay. It appears only where the organization's policy permits it, and only for people who are not already buying on the organization's behalf — the server checks the same policy, so hiding it is a courtesy rather than the control. It says who gets billed before asking for a card, and under approval mode says plainly that nothing is charged until the owner agrees. Failing to read eligibility hides the option rather than showing one that would be refused. When the policy does not let self-payers choose a plan, they take the one the organization is on and the picker is not shown at all — an owner delegating cost is usually not delegating the product decision.

  14. · BillingService

    Tell people their payment failed, and stand the subscription down

    The dunning that did not exist. invoice.payment_failed wrote a row and returned: no email, no flag, no state change, and billing.email-service-url was configured and referenced by no Java code at all. The webhook now starts the delinquency clock on the first failure and clears it the moment an invoice is paid, so a recovered subscription begins fresh rather than carrying a stale clock into the next failure. Only the first failure starts it — a retry a week later must not restart grace and hand a non-paying subscription another week of service on every attempt. The sweep deliberately does not decide when entitlements stop. That happens on the clock, in isLive, so there is no window where a lapsed subscription still works because a job has not run. What the sweep owns is what a clock cannot do: saying so once, and cancelling after autoCancelAfterDays — thirty days by default, long after service stops. That gap is recoverable on purpose, because a customer who updates their card on day twenty gets everything back by paying, and a cancelled subscription is far harder to undo than a suspended one. Notification is best-effort by design: Stripe retries a webhook that answers non-2xx, and an unreachable mail service is not a reason to reprocess a payment event.

  15. · InitializerService

    Add the dunning clock columns

    past_due_since and suspension_notified_at, both nullable. Null means paying and not-yet-told respectively.

  16. · kamo-shared-library

    Make a failed payment mean something

    PAST_DUE counted as fully live in both entitlement resolvers, with no timestamp and no cap, so a customer whose card failed kept every feature for as long as Stripe left the subscription in that state — indefinitely in practice — and nobody, customer or operator, was told. A subscription now carries when collection first failed, and PAST_DUE is live only while inside its grace period. Computed on read, exactly like trial expiry, so service stops on the clock rather than waiting for a job to notice: a scheduled decision would leave a window where a lapsed subscription still worked because the sweep had not run yet. Two deliberate choices about being wrong. Grace defaults to a week rather than zero, because a failed payment is usually an expired card and not a decision to stop paying. And a past-due row with no stamp reads as inside grace, because every subscription predating the column has one — treating null as lapsed would have cut off customers the moment this shipped, for a delinquency nobody had measured.

  17. · BillingService

    Let a member buy their own seat, and close a body-parameter hole

    Self-pay had no route inside the console. Everything on the billing surface is account-shaped — you must already hold an AccountMember row before Plans & Billing shows you anything, and the only way to get one was an owner action — so a member who wanted to pay for themselves was told to go and ask. The one real self-pay path that existed, the consumer checkout used by white-label products, is unreachable from the console because the billing proxy rebuilds headers from scratch and never forwards the cookie its session resolver needs. The buyer is whoever the session says they are. X-Member-Id is set by the proxy from *** and never by the caller, so unlike the account routes there is no id in the path to swap — which is the whole reason this is its own route rather than another handler under /accounts/{accountUid}. The organization's policy is checked server-side, so hiding the option elsewhere is more than decoration, and the per-payer seat cap is enforced here rather than trusted from the client. Also closes a cross-tenant hole on POST /accounts. That route has no account id in its PATH, so AccountOrgScopeInterceptor — which keys on that segment — never saw it, and any member of any organization could name another organization's account in the BODY and rewrite its billing contact, redirecting its invoices and receipts. Naming an arbitrary memberId did the same from the other direction. Both are now required to belong to the caller's organization.

  18. · kamo-internal

    Let the owner decide who can pay, and manage the groups that do

    The owner had exactly one lever over self-payment and it was the wrong shape: an irreversible promote gesture, with no way to say in advance whether it was permitted and no way to undo it — while a separate consumer checkout path let a member self-subscribe regardless. This is the preventive control, and it is what the rest of the console hides itself on. Four modes, labelled by what happens to other people rather than by the enum: an owner picking a policy is deciding what their staff can see, so that is what each option describes. Groups only appear once the mode permits them, because a group is several people doing what one person may already do. Non-owners see the setting read-only rather than not at all. "The organization pays centrally" is the answer to why they have no purchasing options, and hiding it only turns that into a support question. A failure loading this section leaves the plan picker working — a policy the console cannot read should not take the buying surface down with it.

  19. · BillingService

    Org policy and billing group APIs

    Who inside an organization may pay Kamo is now answerable and settable. Both routes are org-scoped and therefore deliberately outside /api/billing/accounts/** and its interceptor: "may my staff buy their own seats" has to be answerable before a second account exists, which is exactly when an account-scoped setting cannot help. Reading the policy is open to any member, because the surfaces that hide self-pay controls need the answer and it reveals nothing actionable. Everything that changes who pays for what is owner-only, checked against the database rather than the caller-supplied header. Group creation refuses when the organization has not permitted groups — a group is several people doing what one person may already do, so an organization that has not allowed self-payment has not allowed this either. Under REQUIRES_APPROVAL a group starts unapproved; every other mode has already given blanket permission, so it starts approved. Deleting a group dissolves the naming and never the billing: cancelling what it pays for is a separate, deliberate act. The roster comes from the subscription's own SubscriptionMember rows rather than being duplicated on the group, so a group cannot disagree with the seats it is paying for.

  20. · kamo-internal

    A Plans & billing console an owner can actually find

    There was no way to buy a first subscription anywhere in the product. Plan lists were reachable only through a subscription you already held, so an organization with none saw an empty panel and a hint pointing at a different org. The one real billing screen lived on the member profile page, filed on the settings landing under a heading titled "Personal", and was hidden entirely for any org without a parent. The home administrator bar had no billing entry at all. The console answers the question an owner arrives with — what are we on, what does it cost, how do I change it — and prices every control as they move it, so the total is visible before anything is committed. Seats, add-ons and storage blocks each update a running figure, and an annual plan states what actually leaves the account once a year rather than leaving a per-month number to be multiplied in the reader's head. The arithmetic is planQuote, tested against the charge path, because both places the platform previously quoted a price disagreed with what Stripe billed. Reachable from the home admin bar and the settings menu, gated on the finance rights rather than CONFIGURE_SYSTEM: deciding what the organization pays for is a finance decision.

  21. · BillingService

    Default a new subscription's target org to the caller's

    A first purchase has no existing subscription to copy targetOrganizationId from, and requiring it unconditionally is what made creating one from the console impossible. An organization buying seats for itself is the common case; the sub-org arrangement still passes it explicitly.

  22. · kamo-internal

    Show the ceiling, not just the bytes

    The panel reported stored, added, removed and lifetime bytes against no reference at all — so an owner looking at 400 GB had no way to tell whether that was comfortable or about to refuse their next upload, and their first knowledge of a problem would have been a failed one. The plan allowance and the blocks bought are what turn those numbers into a position, so the capacity bar goes first and everything below reads against it. The bar stays green until it matters: amber at 90%, red at full. A bar that turns amber at 60% teaches people to ignore it. It also says plainly that reads are unaffected. "Storage is full" reads as "I have lost access to my files" unless you say otherwise, and the one thing filling your storage never does is take anything away. Block size and price come from the catalog rather than being hardcoded, so the "add 10 GB for $2.50 a month" line cannot drift from what is actually charged.

  23. · SecurityService

    Gate promotion on the owner's policy, make it idempotent, add its inverse

    Promoting a member to pay for themselves was unconditional, one-way and unsafe to repeat. It now refuses when the organization's policy says the organization pays for everyone — previously the only control over self-payment was the irreversibility of the gesture itself. It is idempotent. Promoting the same member twice inserted a second Account and a second AccountMember row, tripping the unique on member_id and answering 500; a double-click, a retry after a network blip, or an owner re-running the flow to move more licensees all hit it. It now reuses the account and subscription the member already has and carries on to the transfer. demoteBillingOwner is the inverse that never existed. An owner who promoted someone could not undo it, and if that member left, their personally-owned Account kept holding seats targeting the organization with no way to reclaim them. Licensees move back onto the organization's subscription and the self-payer's is stood down; their Account and payment methods are left alone, because reclaiming seats the organization depends on is the owner's business and deleting someone's billing history is not. Matching is on the shared User rather than the member id, since promotion mirrors the member onto the top-level org. Also fixes the endpoint ratchet, which was ALREADY FAILING on main before this work — so SecurityService's build was red and nothing was deploying. The scan could not see two real guards: extractUserId reads userID out of the *** session, and requireTopLevelOwner goes through CallerSessionResolver and further requires the caller own the top-level org. Teaching it both let 25 stale baseline entries be deleted, which is exactly what the file asks for — fixing an endpoint means removing its line, and that is what makes the number fall. runSweep is baselined with its real reason: internal-auth guarded through a header parameter the body-scan cannot reach.

  24. · InitializerService

    Drop the constraint that limited a member to one billing account

    Finds the constraint by shape rather than by name — Hibernate generated it and the name is not stable across environments, so any unique constraint on account_members whose column list is exactly member_id is the one to drop. The composite on (account_uid, member_id) has two columns and never matches.

  25. · kamo-shared-library

    Let members and groups pay for themselves, under the owner's rules

    Three things were missing and one was actively harmful. account_members carried a global unique on member_id — a member could administer exactly ONE billing account platform-wide. That is the ceiling that made every multi-payer arrangement impossible, and it was already doing damage rather than merely limiting: AccountMemberService.add() worked around it by silently DELETING the member's row on their previous account, so granting billing access to one revoked it from another with no warning; and promoteToBillingOwner threw a constraint violation on its second run, turning a double-click into a 500. The composite unique on (account_uid, member_id) already existed and expresses the real rule. Dropping it makes a derived findByMemberId returning Optional throw the moment anyone holds two rows, and nine callers rely on it. It is now a bounded query ordered by creation, so those callers keep the answer they had instead of depending on whatever the planner returns first; findAllByMemberId is there for code that wants all of them. OrgBillingPolicy is the preventive control the owner never had. The 2026-04-16 design specified billing_delegation_mode and implementation dropped it, leaving one irreversible promote gesture and no way to say in advance whether self-payment was permitted at all — while a separate consumer checkout path let members self-subscribe anyway. Where a mode forbids something the surfaces hide it rather than offering it and failing. BillingGroup names an arrangement the model already permitted: AccountSubscription is unique on (account, market, target org), so several accounts could always target one organization. It carries no roster of its own — who holds a seat is SubscriptionMember plus AccountLicense, the same answer as for anyone else, and a second roster would be a second truth to keep in step.

  26. · SecurityService

    Expose the fail-open impact to the operator

    The app catalog page already answers the org-independent question — does any plan mention this app — and raises a warning when none does. This answers the tenant-by-tenant one behind it, which is what actually gates flipping the valve.

  27. · kamo-shared-library

    Show what turning off fail-open would cost

    kamo.entitlement.fail-open grants any app the plan matrix never mentions. It is documented as a temporary valve for the catalog backfill — turn it off once the matrix is seeded — but nothing could answer the question that decides whether turning it off is safe: which tenants are holding which apps only because of it. Flipping it blind would have taken apps away from live organizations, with the first support ticket as the notification. failOpenGrants resolves each organization twice, once granting unmentioned apps and once denying them, and reports the difference. It does not read the live flag, so it gives the same answer before and after the flip. The re-resolution goes through resolvePlan rather than the plan id directly, mirroring the real path's fallback to a planCode lookup — otherwise a grant that would in fact survive gets reported as a loss.

  28. · kamo-internal

    Show whether someone is there, and their extension

    Every row now carries a presence dot and word beside the name, and its extension in the detail line — so choosing a person no longer means picking a name and hoping. This is the one people picker in the app, so it lands on the Manager field, e-sign signers, adding people to a chat and assigning support alike. Offline beats whatever was declared. availability_status only moves on sign-in, sign-out and a manual toggle, so somebody who never signs out reads AVAILABLE indefinitely — showing that as a green dot would be confidently wrong about the one thing the dot is for. Not-connected draws as an outline ring rather than a filled dot, so it reads as absence instead of as another status colour. A directory that never carried presence — an in-memory source behind `fetcher` — shows no indicator at all. Absent is unknown, and unknown should say nothing rather than claim everyone is offline.

  29. · SecurityService

    Carry availability, presence and extension on a directory row

    Picking a person is easier when the row says whether they are there and how to reach them, so each one now carries what they last declared, whether anything of theirs is actually connected, and their work extension when they have one. Availability and presence are both here because either alone misleads: availability_status only moves on sign-in, sign-out and a manual toggle, so a member who never signs out reads AVAILABLE indefinitely. Presence is read from the PRESENCE:{memberId} key MediaService writes — a cross-service read worth naming. The alternatives were an HTTP round trip to MediaService on every keystroke of a search, or reading presence in the browser, and the browser cannot: ToolProviders renders the tool dock as a sibling of the tree holding the presence providers, so a picker opened inside a support ticket window would report every colleague offline. Both services share the Redis host and database. A failed lookup reads as offline rather than failing the search.

  30. · BillingService

    Buy a block when an opted-in org runs out of room

    Runs shortly after the nightly snapshot sweep, because that is what produces the usage figure it decides on — expanding against yesterday's number would buy a block a day late every time. One block per run, never a catch-up burst, and it stops at the owner's ceiling. If an organization is far past that ceiling the right outcome is that writes stop and someone looks at it, not that the platform quietly buys its way out at their expense. Auto-expand on with no ceiling reads as disabled rather than unlimited, because an absent limit is far more likely to be an oversight than an intention.

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