Skip to content
KAMOUniverse

Changelog

What's new.

Every improvement to the platform, as it ships.

  1. · BillingService

    One plan per organization, a 25-seat Enterprise ceiling, and a way to stop paying

    The plan is now an organization-level decision and nothing else can override it. Letting a self-payer or a group manager pick their own reads like a kindness and is a correctness bug: **************** answers "does this org have this app" by OR-ing across every live subscription targeting the org, so one person on one seat choosing a higher plan switched that plan's apps on for the entire organization. Pinning every payer to the organization's plan makes that OR harmless — the subscriptions all name the same plan, so whichever wins answers identically. OrgPlanService reads the plan from the same subscription the Plans & billing console calls "your plan", so the rule and the screen cannot disagree. Enterprise is required above 25 seats, counted across the organization and every billing group. Counting per-payer would have made splitting into groups a way to buy fifty seats twenty-five at a time. An edit excludes its own subscription so shrinking is measured against the new size, not the old. GET /self/alerts drives a home-page banner. Restricted access with no explanation is the worst version of this product — apps stop opening and nobody says why, or that the person reading is the one who can fix it. Only raised for bills this member is responsible for: their own seat and groups they manage. Telling someone their organization's card failed when they cannot pay it is noise, and noise is how banners stop being read. POST /self/seat/cancel stops the automatic payment on a seat someone bought themselves. Always at period end: they have paid for the current period, and cancelling immediately would take that access away while refunding nothing, since that path asks Stripe for no proration. Two bugs in cancel() found while wiring that up: - It was a silent no-op for COMP and TRIAL. The whole body sits behind a stripeSubscriptionId null check, so a comped or trialing subscription returned 200 with the row still ACTIVE — the caller told it worked when nothing changed. It now refuses and says why. - immediate=true remains dangerous and is deliberately not exposed: it sets CANCELED then calls fallbackToFree, which sets ACTIVE/FREE with seatCount 1, so a 25-seat org would keep one working person and tell 24 their seat was cut for a seat-count reason that never happened. entitlement.fail-open is set false here to match SecurityService, which serves /api/security/features and is what the Apps & features screen reads. Two services answering the same entitlement question differently is worse than either answer. Verified safe: all six active plans carry an explicit row for all 21 service types, so nothing previously granted is being denied.

  2. · SecurityService

    Approval-tier relay with role resolved from rights

    THE ROLE IS DECIDED HERE, from the caller's rights and their relationship to the subject -- never from the request body. A body that could name its own role would let anyone claim to be payroll. Precedence: PAYROLL (broadest, edits in any state), then MANAGER, then EMPLOYEE for one's own record. Being the subject's snapshotted manager counts on its own -- a line manager approving their own report is the ordinary case and should not need an org-wide right; the right exists for everyone else who reviews. isManagerOf fails CLOSED: an unresolvable reporting line is not authority.

  3. · kamo-shared-library

    APPROVE_TIMECARD_ADJUSTMENTS (197) and FINALIZE_PAY_PERIODS (198)

    Split so separation of duties is expressible: whoever approves a timecard is not automatically whoever may close a pay period over it. MANAGE_TIMECARDS (183) stays the module gate and is deliberately neither. Enrollment is still NOT a right -- it is a persisted employment fact. A right leaks ON through four shipped behaviours and vanishes from a TERMINATED member exactly when HR must produce their final timecard. Deliberately absent from NEW_RIGHT_SEEDS: that carries an EXISTING grant forward, and these gate a capability nobody had. Adding a right needs only a SecurityService restart -- no KamoInitializer pass, no DDL. Ids pinned by a test, because the id is what member_rights_applied stores.

  4. · kamo-shared-library

    The approval tier state machine

    Employee submits -> manager approves -> payroll closes, per the owner's decision. Four rules, enforced in a pure transition table and pinned by 25 tests: 1. A manager CANNOT approve until the employee has submitted. The submission is the employee's own assertion that the hours are theirs and complete; approving without it approves nothing anyone attested to. 2. Manager approval LOCKS the employee out of further edits. 3. A manager MAY act on behalf -- submit or approve -- and that is the only sanctioned route past rule 1. It requires a written reason, and the row records both the manager who clicked and the employee stood in for. 4. Payroll may edit in ANY state including locked, always with a reason. Corrections after close are routine and legally required; forbidding them just moves the edit out of the audit trail. Segregation of duties lives in the transition table, not in role naming: nobody approves their own timecard, and a manager is somebody's employee too. Unapproving cascades from PAYROLL_LOCKED all the way to SUBMITTED, because withdrawing an approval invalidates the lock built on it. Approvals are scoped to a DATE RANGE so a mid-period approval survives a later edit outside its scope instead of silently covering work it never saw.

  5. · kamo-internal

    Member Timecard tab and the HRS Timecards settings tab

    The member tab is SELF-SERVICE ONLY and gated on identity, exactly like legal-compliance: its endpoints take no member id at all, so it is structurally incapable of showing someone else's hours. checkRight is unusable here -- it returns true for everything under god mode, and the page's own canViewOtherMemberAccount ORs in unrelated rights including **************** HR reaching a specific person's timecard is a different surface at /hr/attendance, attributed to the HR actor. Placed before plans-billing so it stays in the left tab group -- anything after it inherits that tab's alignRight ml:'auto'. The HRS page gains its first real tab beside the ComingSoonTab, registered in SETTINGS_MENU as the coverage test requires in both directions. Both tabs pad themselves on every return path; SettingsView, TabPanel and the motion div all supply zero.

  6. · kamo-internal

    /hr/attendance roster and the NavTop clock control

    Replaces the HrTabPlaceholder body. The route, HR_TABS entry, MANAGE_TIMECARDS gate and tab i18n keys all already existed; nothing here registers anything. Roster hours are derived server-side from the immutable punch log on every read rather than kept as a running total, so a corrected punch shows immediately and a bug produces a wrong number once instead of compounding. The clock control is fixed chrome beside Legal & Compliance, not a NAV_REGISTRY entry: the registry cannot express a position between two fixed controls, its badgeKey is dead code, and every registry entry is draggable and unpinnable -- a compliance clock must not be something someone can hide from themselves. It gates on the PERSISTED enrollment, never checkRight, which returns true for every right under god mode and would show a clock in every org an operator visited. timecardControlApplies is shared by the control and its polling so both go quiet together -- two copies of that predicate drift, and the failure mode is a hidden button whose fetch runs forever. Clock state is a FOURTH independent signal: not presence (60s heartbeat, 300s TTL), and not availability_status, which is auto-set to AWAY on logout and gates lead and support routing. Poll-only for now with a 60s floor. The STOMP relay lands with the MediaService change; core NATS is unstreamed and lossy so the REST seed is mandatory anyway.

  7. · kamo-shared-library

    Database-clock accessor on the punch repository

    Every punch instant comes from the DB, never the JVM: pods restart and node clocks skew, and a wage record whose ordering depends on which pod answered is not a record. SQL-standard CAST, because a :: cast is silently mangled -- Hibernate reads ':' as a named-parameter prefix and turns now()::timestamp into :timestamp.

  8. · kamo-internal

    BFF route for /api/security/timecards

    TimecardService is ClusterIP with no ingress, so without this route the whole feature 404s in the browser while every service is healthy and every build is green -- the exact failure that has already happened twice on this platform. No authorization here, deliberately: this layer is transport, and a half-check would invite the belief that it is a control. forwardToApi owns the query string. A 204 is returned as 204 rather than being passed through .json(), which throws.

  9. · SecurityService

    SecurityService relay to TimecardService

    The authorization boundary. TimecardService holds the data and the engine but no session; this holds the session and decides who may do what, then relays. The engine can therefore never be reached without passing through here. SecurityService runs anyRequest().permitAll() with hand-rolled per-handler auth, so every method opens with a guard that returns non-null to refuse. Self-service endpoints take NO member id at all -- the member comes from the session, making them structurally incapable of acting on someone else. HR endpoints gate on MANAGE_TIMECARDS (183). Enrollment is answered from the persisted employment row rather than a right, so a god-mode operator does not appear enrolled in every org they look at. SUDO_MEMBER_ID is carried through: isSelf() upstream treats an impersonated session as the target member, so without it an impersonator's punch would be recorded as the employee's own. timecard.service.url is set EXPLICITLY in the configmap. commission.service.url is set nowhere and production depends on a literal in Java, which a Service rename would break at runtime with nothing in config to grep for.

  10. · kamo-shared-library

    Workday bucketing, stage 2 of the pipeline

    Resolved intervals become per-business-date seconds, ready for the allocator. An interval is SPLIT at every workday boundary it crosses: a 22:00-06:00 shift under a midnight workday is 2 hours on one date and 6 on the next. Attributing the whole span to its start date makes daily overtime and the seventh-day rule wrong for every night worker. This is deliberately a DIFFERENT attribution from the businessDate persisted on the punch. The punch carries one date -- for meal/rest evaluation, the exception queue and what the employee sees as their shift -- while overtime counting needs hours apportioned across the workdays and workweeks they actually fell in. Both are correct, both are needed, and conflating them is the trap. A shift crossing the WORKWEEK boundary likewise contributes to two weeks' overtime totals; counting all of it in either one is wrong for both. Accumulates in seconds throughout. Tests cover both DST transitions, a 50-hour span, and punches end-to-end through allocation for a California week.

  11. · InitializerService

    KI registration and schema migration

    Tables come from ddl-auto; this runner supplies only what Hibernate cannot -- the partial unique indexes on one open primary assignment and one open pay-rate window, and the REVOKE that is the database half of append-only. @Order(0) so it lands before any later runner's JPA SELECT, with a 40001 retry because Yugabyte does online schema change and a statement issued while ddl-auto is still settling loses the race. Registers com.kamo.z.shared.hr.timecard in @EnableJpaRepositories -- that list is an explicit array while @EntityScan is wildcard, and omitting a package aborts the context so NO runner runs while the build still reports success.

  12. · kamo-shared-library

    Workweek and workday boundaries

    The workweek is the FLSA overtime unit -- a fixed, regularly recurring 168-hour period -- and it is NOT the pay period. A biweekly period holds exactly two workweeks; a semi-monthly or monthly period always holds fractional ones, so computing overtime on a period total both underpays and overpays and is unfixable once periods are exported. The workday boundary is carried separately and is not necessarily midnight. A 06:00 start is the standard fix for night staff: it keeps a 22:00-06:00 shift on ONE workday instead of splitting it across two half-days, which is what makes daily overtime and the seventh-consecutive-day rule work for them at all. Boundaries are wall-clock times resolved through ZonedDateTime, so 06:00 stays 06:00 on a 23- or 25-hour day. Tests pin both transitions, and pin that one instant is a different business date in Los Angeles and Kolkata -- which is why the zone of record is persisted on the punch rather than derived later.

  13. · kamo-shared-library

    Interval resolution, stage 1 of the calculation pipeline

    Punches become resolved spans. Pure, total, and over value types rather than entities so every stage is fixture-testable with no persistence context. Durations come from Duration.between on instants, never wall-clock subtraction. That is the entire DST story and the tests pin it: a 22:00-06:00 shift is SEVEN hours across spring-forward and NINE across fall-back. Subtracting wall clocks computes 8 for both -- one underpaying, one overpaying, and in a daily-overtime jurisdiction the 9-hour night silently loses its overtime hour. Anything unpairable comes back as unresolved rather than guessed. An unclosed shift is an EXCEPTION: leaving it open accrues absurd hours, auto-closing at a scheduled end under-reports, and a fixed cap invents a number nobody witnessed. It must never finalize as zero -- that pays nothing for a day that was worked. Meals are punched, never auto-deducted. TRANSFER closes one span and opens the next without ending the shift, so meal timing and shift-length eligibility continue across a mid-shift move between cost centres.

  14. · kamo-shared-library

    Non-pyramiding overtime allocator with jurisdiction presets

    Rules are DATA on the RequirementRule shape already used in this library, not code branches, so California is a configuration row instead of a rewrite. Allocation order is load-bearing: daily rules highest-threshold-first (a 13-hour CA day is 1h at 2x and 4h at 1.5x, not 5h at 1.5x), then the seventh consecutive day, then weekly NET of hours already at a daily premium. That last step is what prevents pyramiding, and it is the one naive engines omit. Five 10-hour days in California is 10 premium hours, not 20 -- a naive engine pays daily AND weekly for the same ten hours, and EVERY individual rule test passes while it does. The allocator also asserts hour conservation on the way out and throws rather than returning a wrong paycheck. Presets: FLSA federal, California, Alaska, Nevada, Colorado.

  15. · kamo-internal

    Deliver the org timezone to the browser

    The client Organization model had no timezone field, useFormatters and next-intl are both configured with no timeZone, and timezoneService's backend call 404'd -- so every timestamp rendered in the browser's zone, and the server's during SSR. Mirrors all 27 TimezoneType values rather than a subset: a partial map is a silent half-mirror where an unmapped org falls through to the browser zone with no signal. GMT maps to Europe/London, not UTC -- it observes BST, so conflating them shifts a whole day's hours by an hour for half the year. The org zone is a DISPLAY fallback only. A production census found all 13 orgs on the untouched PACIFIC default while members span PACIFIC/EASTERN/ARIZONA/UTC/ INDIA, so the timecard zone of record comes from the work site instead.

  16. · SecurityService

    GET ****************

    timezoneService.ts has called this endpoint since it was written and it existed in no Java service; the client swallowed the 404 and resolved to UTC. Meanwhile useFormatters and next-intl are both configured with no timeZone, so every timestamp renders in the browser's zone -- and the *** fallback is the string 'GMT', which is Europe/London and observes BST. Identity comes from the session only: SecurityService runs permitAll with hand-rolled per-handler auth, so a handler that reads identity from the request has no control at all. Registers com.kamo.z.shared.hr.timecard in @EnableJpaRepositories. That list is an explicit array while @ComponentScan/@EntityScan are wildcard, and omitting a package is the documented cause of two prior CrashLooping deploys.

  17. · kamo-shared-library

    WorkSite, the jurisdiction anchor

    Named WorkSite, not WorkLocation: TeamMember.workLocation is an existing office/remote enum and a second symbol by that name would collide in every grep. zoneId is a free IANA string rather than TimezoneType because that enum's 27 values cannot cover a multi-state employer, and it is NOT NULL because it is the only trustworthy zone layer -- every org in production sits on the untouched PACIFIC default.

  18. · kamo-shared-library

    Employment and effective-dated assignment revisions

    Punches will reference an ASSIGNMENT, not a member. That is what makes concurrent positions, the 29 CFR 778.115 weighted-average regular rate, multi-site jurisdiction and job costing expressible at all. managerMemberId is snapshotted from TeamMember.manager at revision time so a reorganisation cannot rewrite who approved what. The roster finder is deliberately not built on member_rights_applied: that query is TeamMember-typed, so it omits grant-all org owners and returns nothing for a TERMINATED member whose applied rights were stripped -- precisely the person whose final timecard HR still has to produce.

  19. · kamo-shared-library

    Effective-dated, append-only pay rate

    No employee compensation existed anywhere on this platform before this entity. It is required: overtime is computed against the FLSA regular rate, CA requires the rate on the wage statement, and an export without rates gives payroll nothing to consume. A raise is a NEW ROW. A rate effective inside an exported period generates a retro adjustment, never a silent rewrite. Never exposed via /api/security/members/{id} -- that endpoint checks no right at all, so putting a rate in it would leak every salary in the company.

  20. · kamo-shared-library

    Append-only punch log and audit event

    Both served by NARROW Repository interfaces, not JpaRepository: deleteAllInBatch and bulk JPQL bypass entity callbacks, so the interface shape is what makes append-only real rather than advisory. Instant + IANA zone + business date are all persisted, so duration is computed from instants and DST is correct for free -- a 22:00-06:00 shift is 7 hours across spring-forward and 9 across fall-back, while wall-clock subtraction computes 8 for both. updatable=false is deliberately NOT used on the frozen punch columns: Hibernate would silently omit them from the UPDATE rather than failing.

  21. · kamo-shared-library

    Timezone-of-record resolver

    Authority order work site -> member -> org -> UTC. The work site wins because wage-and-hour rules follow where the work is PERFORMED. A production census this session found all 13 orgs on the untouched PACIFIC default and 38 of 63 members with no timezone at all, while the populated ones span PACIFIC, EASTERN, ARIZONA, UTC and INDIA -- a 13.5-hour spread. The org default is therefore not a usable source of truth, which is why WorkSite.zoneId is NOT NULL and carries the weight. Falls back to UTC rather than the *** 'GMT' string, which is Europe/London and observes BST. An unparseable site zone falls through rather than throwing: refusing to resolve a zone would refuse to record time actually worked.

  22. · kamo-shared-library

    Shared WORM refusal guard

    @Column(updatable=false) does NOT enforce immutability -- Hibernate silently omits the column from the UPDATE and the stale write looks like it succeeded. The only working guard is a @PreUpdate that throws.

  23. · kamo-shared-library

    Domain enums and point-in-time version resolver

    Enums are all @Enumerated(STRING) at the call site: ORDINAL emits a numeric range CHECK containing AND, which SchemaCleanupRunner's IN-list sweep deliberately skips and which must then be widened by hand on every new value. The resolver resolves by the window that CONTAINS the instant, never by latest-row, and keeps closed windows in the candidate set -- supersession closes every window but the current one, so an open-rows-only query returns nothing for any historical instant, breaking retro pay and audit reconstruction. Overlap throws rather than guessing.

  24. · kamo-internal

    Show members who pays for their seat, and stop offering a second one

    The coverage endpoint shipped with translation keys and no component, so the question it answers — who pays for my seat — still had nowhere to be asked. SeatCoveragePanel asks it, and the answer feeds the self-pay panel beside it: someone whose seat is already covered by their organization or a group is no longer offered the chance to buy another on their own card. That offer had no knowledge of existing coverage at all, so it was shown to everyone. A group's plan can also be cancelled now, which is what makes a group with a plan deletable — removing one that still points at a live subscription is refused so the charge is not orphaned. Adding someone the organization already pays for comes back refused rather than silently doing nothing, so the button explains itself.

  25. · BillingService

    Let a group's plan be cancelled, and stop double-paying for one person

    Three gaps in what shipped last: A group with a plan could never be removed. delete() refuses while a live subscription exists — correct, since the group row is the only thing pointing at the Stripe subscription — but there was no way to cancel, so the safeguard was a dead end. cancelPlan() releases the seats first, so the roster cannot go on claiming people hold seats that no longer exist. Adding someone the organization already buys a seat for is now refused unless takeOverFromOrg says otherwise. That is the second way to be charged twice for one person and nothing caught it: SubscriptionMember's uniques are per-subscription and AccountLicense declares none at all. Moving somebody from the organization's bill onto a group's is a real thing a manager does, so it is allowed — deliberately rather than by accident. BillingGroupScopeInterceptor binds every /api/billing/groups/{uuid}/** request to the caller's organization. The handlers already check, and this does not replace them; it exists because those checks are per-handler and a group now has sub-resources that spend money. The next route added inherits nothing unless something above it applies the rule. Deliberately narrower than the handlers: this settles which organization, not who within it may act, so the two cannot drift into disagreeing answers. An unknown group id passes through so the handler answers "not found" rather than confirming the id exists somewhere.

  26. · kamo-internal

    A group you can put people on and pay for

    Creating a group produced a name and nothing else — no way to add anyone, no way for its manager to choose a plan, nowhere to pay. Opening a group now shows who pays, who is on it, who is still waiting for a seat, and the plan controls that buy those seats. Creating one asks who pays, using the canonical member picker. It used to send the organization's account uid, which is exactly why every group was billed to the organization; the payer is now resolved server-side from the manager and accountUid is no longer on the wire at all. The roster distinguishes having a seat from waiting for one. A manager who puts eight people on five seats is told three colleagues have no access rather than finding out from them.

  27. · BillingService

    Make billing groups work end to end, and stop self-pay charging for nothing

    A group can now gather people, buy a plan on its manager's own card, and hand its seats out. None of that existed: the only operations were create, approve and delete, so an owner who made a group got a label and no way to use it. BillingGroupSeatService is where money moves and entitlement is issued, kept apart from the record-keeping so no new route can be added that skips the gates. subscribe() refuses before create() is called rather than after, because create() opens a live Stripe subscription — an unapproved group, a legacy one, a manager with no card or a seat cap breach all stop there. Two defects made the group bill the wrong person: - The payer was whatever accountUid the request body named, unchecked, and the console sent the organization's. A group billed to the organization is not a billing group; it is a second name for the organization's own subscription. The payer is now derived from the manager and the two are asserted to agree before anything is charged. Sending accountUid is rejected outright. - ensureAccountForMember reuses the member's oldest AccountMember link, which for anyone ever added to the organization's account is the organization's account. ensurePersonalAccountForMember identifies by primaryMember instead, which is the only field that says an account belongs to a person. Where an owner manages their own group, "their card" and "the organization's card" are genuinely the same Account row and the same Stripe customer, and no code can tell them apart. That is refused unless confirmed, rather than papered over. Separately, two live bugs found while in here: - /self/seat charged the member and never issued them a seat. It called create() and returned; there was no SubscriptionMember row and no AccountLicense, so the buyer stayed unlicensed while their card was billed every month. - @Transactional in AccountBillingService had drifted onto a private method, so Spring ignored it and ensureAccountForMember ran with no transaction at all. A failure between its two saves orphaned an Account the next lookup could not see, and the following call created a second one. /self/coverage is new: it answers "who pays for my seat", which nothing did. It is also what stops someone already covered being offered a second seat on their own card.

  28. · InitializerService

    Schema for groups that can actually pay

    Three runners: BillingGroupRosterMigration creates billing_pay_group_members — whom a group means to pay for, before it has bought anything. org_id is denormalised onto it so the database can enforce "a member is on at most one active billing group per organization" as a partial unique. Two groups paying for one person is two charges for one seat, and nothing else in the schema would catch it: SubscriptionMember's uniques are per-subscription and AccountLicense has none. **************** adds billing_group_uid to account_subscriptions and replaces the three-column unique with two partial uniques. Widening it to four columns would not work — NULLs are distinct inside a unique index here, so every non-group row would stop being constrained. BillingPayGroupColumnsMigration adds market_id, seat_limit and status, and marks existing groups LEGACY. Those were billed to whatever account uid the request body carried, and the console sent the organization's, so their account_uid is almost certainly wrong. There is no safe way to guess which card was meant, and guessing wrong bills the wrong person, so they are refused at the charge point instead of repaired. The first run showed why the retry matters: both replacement indexes failed with 40001 could not serialize access, the runner logged "skipped" and carried on, and the DROP of the old constraint then succeeded — leaving account_subscriptions with no uniqueness at all, which is strictly worse than the collision this fixes. DDL statements now retry on serialization failure, and the drop is skipped entirely unless both replacements are confirmed in place.

  29. · kamo-shared-library

    Let a billing group be its own payer

    A group could be named and nothing else. Its subscription was never set — nothing in the platform called setSubscription — so subscription_uid stayed null forever and the group could not buy or hold a single seat. Three things had to change in the model: BillingGroupMember is new. Seat truth stays where it was, on SubscriptionMember and AccountLicense, but SubscriptionMember.subscription is NOT NULL, so until a group buys a plan there is nowhere to record the people it exists to cover — and gathering people before buying seats for them is the whole point. This is intent; that is entitlement. AccountSubscription gains billingGroupUid, and the (account, market, target org) unique constraint moves out of the entity. A group bills its manager, on the manager's own account, targeting the same org in the same market as any seat that manager already bought for themselves — the same tuple. So a group's first purchase did not fail, it found the manager's personal subscription and rewrote it: plan, seats, Stripe id. No exception, no second row. The rule now lives as two partial unique indexes that JPA cannot express, created by KamoInitializer; the entity declaration had to go or ddl-auto would recreate the old constraint ahead of the runner that drops it. **************** is deleted rather than deprecated. It returns Optional on the strength of a constraint that no longer exists, so once one account holds both a personal and a group subscription it matches two rows and throws at all five call sites across three services. Leaving it to be found by autocomplete was the more dangerous option. Also: allowsGroups() now includes REQUIRES_APPROVAL. Excluding it made the approval path unreachable — creating a group was refused outright, while the code setting the approved flag asked requiresApproval() and so could only ever answer "already approved". An owner who chose oversight got no groups at all.

  30. · 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.

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