Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesSpace ContextSpace context — Code Map

Space context — Code Map

What S1 (TKN-460, context plumbing) and S3 (TKN-462, the switcher) built. The spec is README.md; section numbers below point there. Everything sits behind the per-org PostHog flag spaces (FeatureFlags.SPACES; the separate space-context flag was folded into it on 2026-10-18), default off. With the flag off, or in a business with 0–1 active spaces, the app behaves as it did before: no space parameter, no extra request, and the sidebar header renders OrgSwitcher unchanged.

Shared contracts (libs/shared)

  • src/lib/space-context.ts — WorkingContext { level: 'branch', orgId, space }, SpaceSelection, ALL_SPACES, spaceScopeSchema (all | assigned; always all until S2), membershipSpaceSchema, SpaceContextErrorCodes (invalid_space, space_not_found, space_inactive) and SpaceSwitchVia.
  • src/lib/analytics/space-events.ts — SpaceEvents, the PostHog event names for spaces and the space context (see the README’s Analytics section).
  • src/lib/constants/feature-flags.ts — SPACES: 'spaces' (space-context was folded into it on 2026-10-18 and is never reused).
  • src/lib/schemas/membership.schema.ts — membershipResponseSchema.spaces (the business’s active spaces in sort_order) and .spaceScope.
  • src/lib/schemas/analytics.schema.ts — spaceId on attendanceReportQuerySchema and dashboardQuerySchema; AttendanceReport.spaceId is echoed back when the report is scoped.
  • src/lib/agent-schemas/common.ts — agentPageContextSchema.spaceId: the space a Spotter turn is sent from. It is stored with the user turn.
  • src/lib/i18n/dictionaries/{en,he,ru}.json — context.* (README §11.1). The Spaces and Switch organization headings reuse settings.nav.locations and orgSwitcher.switchOrganization.

API

  • apps/api/src/locations/space-scope.service.ts — SpaceScopeService, the one server-side space decision:
    • resolve(orgId, clerkId, spaceId) returns the space id to filter by, or null for every space. The checks run in this order:
      1. No value: null, without reading the flag or the database.
      2. Flag off or unset: the value is ignored.
      3. The caller must be a member (403), checked before the space lookup.
      4. The value must be a UUID (400 invalid_space).
      5. It must be this org’s space (404 space_not_found).
      6. It must be active (404 space_inactive).
    • activeSpacesByOrg(orgIds) builds /users/me in one query.
    • forAgent(orgId, spaceId) names the space for Spotter, or returns null when the org has no space context.
    • SPACE_ID_API_QUERY is the Swagger descriptor every scoped read reuses.
  • apps/api/src/locations/space-scope.module.ts — imported by the ClassSessions, Bookings, Analytics, Courses, Tasks, Users and AI modules. MembershipsModule is imported through forwardRef, because UsersModule closes the cycle.
  • Scoped reads. The controller resolves spaceId and the service adds location_id = :space, so a session without a space matches only “every space” until S4:
    • class-sessions/class-sessions.controller.ts and .service.ts: GET /sessions (findByWeek) and GET /sessions/upcoming-today (findUpcomingToday).
    • bookings/bookings.controller.ts and .service.ts: GET /bookings/attendance-trend, GET /analytics/attendance/summary and GET /analytics/attendance/daily.
    • analytics/analytics.controller.ts, analytics.service.ts and analytics-reports.service.ts:
      • GET /analytics/dashboard scopes only classesThisWeek and attendance.
      • GET /analytics/attendance/report keeps members.activeMembers and .withoutVisit business-wide, so a visit in any space counts.
      • GET /analytics/classes/popular and GET /analytics/classes/utilization.
      • GET /analytics/export: a scoped export gets a spaceId column, and an unscoped one is unchanged.
    • courses/courses.controller.ts and course-sessions.service.ts: GET /courses/:programId/sessions. An online meeting matches no space.
    • tasks/tasks.controller.ts and dto/list-tasks-query.dto.ts: GET /tasks and GET /tasks/summary. The value is validated only, because no task has a space before S6.
  • users/users.controller.ts — /users/me returns spaces and spaceScope: 'all' on every membership.
  • Spotter:
    • ai/agent/agent.runtime.ts gains space.
    • ai/agent/agent.controller.ts resolves it from pageContext.spaceId on /messages, and from the latest user turn on pick and confirm (conversations.service.ts latestUserTurnSpaceId).
    • ai/agent/context-builder.service.ts adds Working space: … to <org_context>, and only for an org with the context.
  • bookings/bookings.service.ts getAttendanceSummary — the deleted-booking test now sits in the left join. A bare deleted_at IS NULL OR deleted_at IS NULL inside drizzle’s unparenthesised and() used to let every org’s bookings through. A separate hotfix PR owns the regression test.

Web

  • apps/web/src/lib/space-context.ts — pure helpers used on both server and client:
    • URL parameter space, API parameter spaceId, and the taikan_space cookie (a URL-encoded JSON map { orgId: spaceId | 'all' }).
    • parseSpaceCookie, selectionFromParam and withSpaceParam.
    • scopedApiPath, which builds the same query key for the server prefetch and the client.
  • apps/web/src/providers/working-context-provider.tsx — WorkingContextProvider / useWorkingContext():
    • The space is resolved in this order (§6.2–6.3): a pending switch, then ?space=, then this tab’s last space, then the cookie, then All spaces.
    • It keeps dashboard URLs in step with history.replaceState, so there is no history entry and no RSC refetch.
    • A link to another business’s space is a full navigation, with “Opened in {org}” announced on arrival.
    • It registers space_id as a PostHog super property, and switchSpace captures space_context_switched.
    • The polite live region carries data-testid="context-announcer".
    • Nothing waits for the flag. Until it answers, reads use today’s paths (plus a space the URL already names, which the server prefetched), and only the URL is left untouched.
  • apps/web/src/app/[lang]/(protected)/layout.tsx and components/protected-shell.tsx — the server reads the cookie and passes it to the provider, which sits inside UserProvider.
  • apps/web/src/hooks/use-scoped-api-query.ts — useApiQuery for space-bound reads. In one space the path gains spaceId, so the default key separates the cache by construction; an explicit key gets { spaceId } appended. Without a space the path and key are unchanged.
  • apps/web/src/hooks/use-org-locations.ts — the one key for an org’s spaces, [/organizations/:orgId/locations]. invalidateOrgLocations also refreshes /users/me. This fixes a stale-picker bug: the session sheets and the class-type dialog used to read ['locations', orgId].
  • Consumers:
    • components/overview/v2/today-panel.tsx scopes upcoming-today and classes/utilization; hydrated data shows at once, with no skeleton while the flag resolves.
    • dashboard/overview/page.tsx prefetches the same keys from ?space=.
    • components/overview/v2/overview-v2.tsx shows one WholeBusinessTag on the trend row and one on the coaching panel.
    • components/overview/sidebar-nav.tsx and dashboard-breadcrumb.tsx build their links with scopedHref.
    • providers/agent-provider.tsx sends pageContext.spaceId.
  • apps/web/src/components/shared/whole-business-tag.tsx — the quiet muted tag (§7.6). It renders only while one space is active.
  • apps/web/src/components/shared/context-switcher.tsx — ContextSwitcher (§7.1–7.5). It has two placements:
    • sidebar is the header, keeping org-switcher-trigger; with 2+ spaces it adds a second line, context-switcher-space.
    • topbar is context-switcher-topbar, at the start of the top bar.
    • Only one placement is interactive at a time: the sidebar while it is on screen, the top bar otherwise. Off-canvas, the sidebar header is a plain block.
    • Desktop menu: a Radix dropdown with dir. Its groups are labelled RadioGroups whose items are menuitemradio (space-switcher-item-all, space-switcher-item-<id>, org-switcher-item-<orgId>).
    • Phone menu: a bottom-sheet Drawer with a hidden description and a hand-rolled role="menu" with the same keyboard model.
    • On a phone, a switch from the sidebar closes the sheet and moves focus to the top-bar button.
    • A dirty form holds the switch behind “Discard unsaved changes?”.
    • With the flag off, the sidebar renders OrgSwitcher untouched and the top bar renders nothing.
  • apps/web/src/providers/unsaved-changes-provider.tsx — useUnsavedChanges(dirty, item, discard) is the registry the guard reads. It is mounted in dashboard/layout.tsx, and create-session-sheet.tsx registers itself once edited.
  • components/shared/org-switcher.tsx — kept for the member layout, which has no working context.

Kiosk door (S7)

  • libs/shared/src/lib/space-context.ts — KioskDoorSession, KioskDoorResponse (hallId, current, next, advancesAt) and SpaceKioskMode.
  • apps/api/src/locations/space-scope.service.ts — resolveDoor(orgId, clerkId, hallId): resolve, then 2+ active halls, else null (the controller answers 404); tags Sentry space.kiosk=door.
  • apps/api/src/class-sessions/utils/kiosk-door.utils.ts — pickKioskDoor, the now / next / advancesAt rule.
  • class-sessions.controller.ts GET /sessions/door/:hallId and class-sessions.service.ts findKioskDoor(orgId, clerkId, { hallId }): staff only, published sessions of that hall within 24 hours either side of now.
  • apps/web/src/app/[lang]/(protected)/kiosk/space/[hallId]/page.tsx and components/kiosk/kiosk-door.tsx — the door; kiosk-screen.tsx gains mode="door" (a “Now” label instead of the org name; no location line or exit, which the door’s header owns).
  • components/overview/schedule/session-detail-sheet.tsx openKiosk — the entry from one hall’s context.
  • WorkingContextValue.pending — the flag hasn’t answered for a business with 2+ halls, so the door shows a skeleton instead of a 404.

Tests

  • apps/api/test/e2e/spaces.e2e.spec.ts and space-context.e2e.spec.ts — end to end over HTTP, with spaces answered for the seeded org only (a spy on EventTrackingService.isFeatureEnabled). There is no web e2e for the switcher: web flags are bootstrapped build-wide from NEXT_PUBLIC_FEATURE_FLAGS, so a Playwright run cannot turn them on for one org without changing every other spec.
  • space-context.e2e.spec.ts › kiosk door — now and next from that hall only, the hand-over, the idle door, another business’s hall (404), a malformed id (400), members and outsiders (403), one active hall (404), flag off (404).
  • apps/api/src/class-sessions/utils/kiosk-door.utils.unit.spec.ts — window edges, ties, the empty hall.
  • apps/web/src/components/kiosk/kiosk-door.driver.tsx and .int.spec.tsx — the door moves on at advancesAt and from idle (with space_kiosk_advanced), the header names the hall, 403 drops the roster, 404 cases, the flag wait, and the single-session kiosk’s parity and space_kiosk_opened {mode: regular}.
  • session-detail-sheet.int.spec.tsx › opening the check-in kiosk — the door from one hall’s context; the session kiosk with the flag off, one hall, another hall, or All spaces.
  • apps/api/src/locations/space-scope.int.spec.ts (real Postgres) covers:
    • the resolve matrix: no value, flag off or unset, 400, cross-org 404, inactive 404, and 403 before the lookup;
    • activeSpacesByOrg order and default;
    • forAgent;
    • the week and today filters, including the NULL-location rule;
    • the attendance report’s scoped totals with business-wide withoutVisit.
  • apps/api/src/ai/agent/context-builder.unit.spec.ts — the Working space line appears only with the context.
  • apps/web/src/lib/space-context.unit.spec.ts — URL helpers, the selection check, and the cookie round trip and rejection.
  • apps/web/src/components/shared/context-switcher.driver.tsx and .int.spec.tsx cover:
    • flag off, one space, and one space with the sidebar collapsed render as today;
    • the one-switcher rule, open and collapsed;
    • the business-only top bar;
    • the menu order and checks;
    • a keyboard-only switch, with the URL, cookie, announcement, event and focus;
    • the business switch;
    • the discard guard, keep and discard.

Deferred

These were agreed with the plan owner for this slice:

  • The /check-ins module (coaching check-in forms) stays unscoped. Attendance check-ins are scoped through the booking and analytics attendance reads.
  • A business switch still lands on the role home (§7.4 keeps today’s behavior).
  • An unknown or closed space in the URL falls back silently to the tab, cookie or All spaces. The quiet §6.7 notices come later.
  • Server-side PostHog events (EventTrackingService.track) carry no space_id; only client events do, through the super property.
  • Spotter read tools do not yet default to the working space. The model is told the space through <org_context>.
  • §7.5 sheets opening below the top bar.
  • Only the Today panel follows the space in S1. The ActionCenter follows it since S5b and Reports since S5; Pending tasks are S6.

S5b: the Overview by space (TKN-467)

  • libs/shared/src/lib/space-stats.ts — SPACE_GROUP_BY, the split rows (TodaySpaceSummary, UtilizationSpaceRow, InsightSpaceCount), SPACE_INSIGHT_IDS, SpacesWeekReport and the windows/thresholds.
  • SpaceScopeService.contextSpaces(orgId) (flag on and 2+ active spaces, else null) and groupBySpaces(orgId, groupBy) (null without the context, 400 invalid_group_by for anything but space, Sentry tag space.group_by).
  • ClassSessionsService.findUpcomingTodayBySpace: rows up to 100 and one grouped read of the same window for bySpace.
  • AnalyticsService.getClassUtilization(..., spaces): bySpace.
  • InsightsService.getOrgInsights({ spaceId, spaces, bySpace }): the space rules filter by spaceId and lead the feed; operations.no_shows_recent and operations.space_overlaps run last, only with spaces (the space context implies the spaces flag, so overlaps and overrides always read).
  • analytics/space-stats.controller.ts + space-stats.service.ts: GET /analytics/spaces/week?weekStartsOn= (owner/admin).
  • Web: today-panel.tsx (split summary, rows per space, “of {n} classes today”), action-center.tsx (scoped, no tag), spaces-week-panel.tsx (space_panel_viewed), shared/share-bar.tsx.
  • Tests: apps/api/test/e2e/space-overview.e2e.spec.ts (flag on/off, one space), spaces-week-panel.int.spec.tsx (panel and Today split).
  • Deferred to S6: pending tasks by space (no tasks.location_id yet), the Members ribbon, Schedule strip and member-detail lines (member read model).

S5: Reports by space (TKN-464)

  • libs/shared/src/lib/space-stats.ts — SPACE_SERIES_LIMIT (5 stacked series, then “Other”), SpacePeopleSplit (total, byMainSpace adding up with a spaceId: null “no check-ins” row, activeIn overlapping), CheckInSignal, the groupBy=space additions (DashboardSpaceSplit, AttendanceSpaceSplit, CohortSpaceSplit) and SpacesReport (the week report generalised to a range; SpacesWeekReport keeps weekFrom/weekTo).
  • analytics/space-people.ts — spacePeopleSplit (main space = most check-ins in the 60 days before each person’s reference time, ties to the latest; derived on read, never stored), checkInSignal, splitSpaceOf.
  • AnalyticsReportsService: getAttendanceReport, getDashboard and getMemberCohorts take spaces (groupBy=space); the headline queries are unchanged and the split runs as extra reads over every space.
  • BookingsService.getAttendanceSummary/Daily/Trend(..., spaces): bySpace on /analytics/attendance/summary|daily and /bookings/attendance-trend.
  • SpaceStatsService.getSpacesRange behind GET /analytics/spaces/range (404 space_context_off without the context); presence.activeIn feeds the “Checked in” column of plan coverage by space.
  • Export: attendance tabs with groupBy=space give one row per class and space, and a scoped export names its space (spaceId, space).
  • Web (components/analytics/): space-breakdown.tsx (useReportSpaces, SpaceRows, PeopleBreakdown, PeopleViewToggle, FewCheckInsNote, spaceSeries), report-spaces-section.tsx (Spaces section and plan coverage, space_panel_viewed reports_spaces), and the space-aware studio-kpi-snapshot.tsx, report-highlights.tsx, report-results.tsx, report-charts.tsx, reports-screen.tsx (space_report_grouped).
  • Tests: apps/api/test/e2e/space-reports.e2e.spec.ts (flag on with two spaces, one space, flag off), reports-spaces.int.spec.tsx with reports-spaces.driver.tsx.
  • Deferred: the Trials tile and column (S6, lead_trial_appointments has no space yet); plan coverage columns (TKN-446); a groupBy=space export for retention.

S4: every session has a space, the folded board (TKN-463)

  • Migrations (rewritten for TKN-479, see the next section) libs/db/drizzle/0174_areas_and_default_hall.sql (enum location_kind, locations.kind / parent_id / is_default / auto_created, the composite area → hall key, the checks, the partial unique index locations_one_default_per_org_idx, class_sessions.area_id, class_types.default_area_id, program_template_slots.area_id), 0175_default_halls_and_areas.sql (one hidden, shared, default hall per business, named in its locale; every existing location becomes one of its areas, shared) and 0176_session_halls_and_areas.sql (each session’s location becomes its area and its hall the business’s; class types and template slots move their location to the area column). All idempotent. Then 0177_session_hall_required.sql makes class_sessions.location_id NOT NULL.
  • apps/api/src/locations/default-space.ts — the one rule every writer shares: defaultSpaceId (stored default hall; else promote the first active hall; else create the hidden “Main space”, racing writers settle on the partial unique index), placementResolver / resolveSessionPlacement (hall and area from what the writer names, else the class type’s, else the default hall; a locationId naming an area is that area in its hall), visibleSpace / visibleSpaceRef (the hidden hall reads as no space) and sessionPlaceRef (the display location: the area, else the visible hall, in session, booking, minisite and reminder responses). LocationsService.create with the flag on adopts the hidden hall (same row, so its sessions and areas move with it) instead of adding a second hall, and findAll / activeSpacesByOrg leave it out.
  • Writers that set a space (explicit → class type → default): ClassSessionsService.create / update (clearing the space resolves it again) / createMany / copyWeek / replicateDay; ProgramTemplatesService apply (working spaceId → slot → class type → default; ScheduleOccurrenceInput and SessionInsert require a space at the type level); WorkoutAssignmentsService schedule; ArboxScheduleImportService (new mirrored classes); Spotter and MCP tools go through ClassSessionsService; TestingService seeds and scripts/seed-* use the default. Fizikal and courses write no class_sessions.
  • Default space: PATCH …/locations/:id { isDefault: true } (with spaces on); removing the default answers 409 default_space_required with spaces on while other spaces exist, and hands over silently to the next one with it off.
  • Class-type cascade: POST …/sessions/space-cascade/preview and POST …/sessions/space-cascade (ClassSessionsService.previewSpaceCascade / applySpaceCascade, spaces only, 404 off). Moves future live sessions of the class type still in its previous space, lists overlap/capacity clashes (first 20), keeps them unless an owner/admin overrides with one reason (audited per session as class_session.space_override), and sets the class type’s space in the same transaction.
  • Default context step 4: /users/me adds nextSessionSpaceId per staff membership in a business with 2+ spaces (SpaceScopeService.nextCoachedSpaces); WorkingContextProvider uses it after the cookie.
  • Web: space sections in every view; no separate view. space-sections.tsx is the shared layer: useSpaceSections (sections in sort_order, pinned working space, COMPACT_ABOVE = 5 folding and search, attention first, clash detection, space_sections_viewed), SpaceSections (section frames, folded per-day counts, inside the schedule’s drag layer schedule-drag.tsx: ScheduleDragProvider, ScheduleDropZone, ScheduleDraggable, shared with drag-to-copy, TKN-478), SpaceClashMarker (tooltip, opens the session), SessionRowMenu and useSpaceCardTools (what session-card.tsx and weekly-condensed.tsx read inside a section). weekly-calendar.tsx renders each view per section; the hour grid moved to expanded-week-grid.tsx; daily-programming-view.tsx takes showHeading. use-session-space-move.ts (PATCH, override dialog, live region, Undo), the space control at the top of session-detail-sheet.tsx, space-cascade-dialog.tsx from class-type-dialog.tsx, default badge and “Make default” in locations-tab.tsx, CreateSessionSheet.defaultLocationId (the section’s or working space), and apply-template-dialog.tsx sends the working spaceId.
  • Gating: the space sections, moves, the sheet’s space control, the cascade, the default controls and the “Space” class-type label are spaces + 2 spaces; pinning a row and the template’s working space need 2+ spaces. With spaces off, or one space, the schedule is today’s. Ungated: every new session gets a space (invisible for a business without one: the hidden “Main space”; a business with one space now sees it on sessions that had none), a removed default hands over, and the additive fields isDefault and nextSessionSpaceId.
  • Tests: apps/api/src/locations/default-space.int.spec.ts, apps/api/test/e2e/folded-schedule.e2e.spec.ts, web space-sections.int.spec.tsx plus cascade/default/create cases in the class-type, locations-tab and create-session specs. class_sessions.location_id is NOT NULL, so a path or fixture that writes a session without a hall fails its spec (default-space.int.spec.ts guards the constraint). Spec fixtures use src/testing/session-space.fixture.ts.
  • Deferred: deactivating a space with future sessions (§8.4 last bullet: the same preview to move them to the default) and restricted callers’ rows (S2).

Areas inside a hall (TKN-479, README §8.5, ADR-0024 D13)

  • Schema libs/db/src/lib/schema/scheduling.ts: locations.kind (hall | area), parent_id (composite key (parent_id, organization_id) → locations_id_org_unique), checks locations_area_has_parent_chk and locations_default_is_hall_chk; class_sessions.area_id, class_types.default_area_id; program-templates.ts program_template_slots.area_id.
  • Default rule: every hall and area is shared unless staff pick exclusive (column default, LocationsService, location-dialog.tsx; ADR-0024 D2 amended 2026-10-16).
  • Rules ClassSessionsService.enforceSpaceRules: overlap anywhere in an exclusive hall, or in the same exclusive area; capacity at the tighter of area and hall; the 409s and the override audit carry areaId. Batch writers share exclusivePlaceIds / shareExclusivePlace (program-templates/session-conflicts.ts); loadExclusiveSpaces returns exclusive halls and areas. The Overview overlap insight and Reports’ Spaces section count pairs by the same rule.
  • Halls only: SpaceScopeService (spaceId, the switcher, groupBy), the cascade (it clears the moved sessions’ and the class type’s area) and every event. An area id as spaceId is space_not_found.
  • Locations API: POST …/locations { parentId } adds an area; without it, a hall with spaces on (the first adopts the hidden hall; halls count toward the tier), else an area of the default hall (counted against today’s Location limit). kind (CreateLocationDto, shared createLocationSchema) overrides that: area without parentId is an area of the default hall, hidden or not; hall with parentId, or with the flag off, is a 400. UpdateLocationDto omits kind. Removing a hall deactivates its areas. Only halls can be the default.
  • Readers of location_id that now mean the hall: class-session, booking, minisite and reminder responses show sessionPlaceRef (the area, else the visible hall), so today’s location names render where they did; GPS check-in uses the area’s coordinates, else the hall’s; analytics, insights, programs stats, space stats and space people filter and group by hall (location_id), which is what their spaceId means. Mobile (session.location) needs no change: an area id sent as locationId is read as that area in its hall. Spotter, and MCP through the same tools, name halls and areas separately (next section). Course sessions keep their one location_id (an area after the migration, display only).
  • Web apps/web/src/lib/session-places.ts (splitPlaces, hasHallChoice, choosesHall, areasIn, sessionPlace, classTypePlace): with spaces off the one Location list is every listed row and a session’s area is its Location; on, halls, then the chosen hall’s areas. hasHallChoice (2+ listed halls) is the one hall-choice rule: through choosesHall it gates the Hall field in session-fields.tsx (both modes), the required hall in create-session-sheet.tsx and the space control in session-detail-sheet.tsx; with fewer halls only the default hall’s areas are offered. components/booking-policy/use-scope-options.ts (showHallPicker) gates the hall picker of booking rules and plan quotas on it too. session-fields.tsx (Area field, the class type’s area on a new hall), class-type-dialog.tsx (Area field, sent with the hall), locations-tab.tsx + location-dialog.tsx (halls with their areas, “Add area” per hall, and in the header while no hall is listed, sending kind: 'area'; areas waiting for the first hall), space-sections.tsx (AreaFilter, the area clash rule), weekly-calendar.tsx (the chip), session-card.tsx / weekly-condensed.tsx (area label inside a section), use-session-space-move.ts (Undo restores the area).
  • Tests: apps/api/src/database/migration-0175-halls.int.spec.ts (businesses with 0/1/2 locations, a re-run changes nothing), default-space.int.spec.ts, space-rules.int.spec.ts and locations.int.spec.ts area cases, session-conflicts.unit.spec.ts, the folded-schedule and space-context e2e specs, spaces.e2e.spec.ts (kind); web area cases in the create-session, session-detail, class-type, locations-tab and space-sections specs (the Hall field for 0/1/2 halls, the header “Add area”).

Spotter tools (halls and areas)

No flag of their own: the tools are always registered and call the services, which keep enforcing what they enforce. MCP serves the ones that never confirm.

  • ai/agent/tools/leaves/locations.tools.ts (LocationsAgentTools, schemas in libs/shared/src/lib/agent-schemas/locations.ts):
    • locations.list (owner, admin, coach; read): halls in display order, each with its areas (name, concurrency, capacity, sort order, default). The hidden default hall is listed only when areas sit in it, with hidden: true and no name; hallChoice is true from two visible halls.
    • locations.create (owner, admin; always confirmed): a hall or an area (kind, parentId). The tier’s 403 comes back as space_limit_reached, not as a missing permission.
    • locations.update (owner, admin; always confirmed): name, capacity (null clears it), concurrency, sort order, isDefault: true.
    • locations.deactivate (owner, admin; destructive): LocationsService.remove, so a hall takes its areas and the default answers 409 default_space_required.
  • class-types.tools.ts: class_types.list_for_program returns each class type’s locationId (null = the default hall) and areaId. class_types.space_preview (read) is previewSpaceCascade; class_types.set_space (always confirmed, the card counts the classes that move) runs applySpaceCascade when the hall changes, then sets the area with ClassTypesService.update. A wrong area is refused before anything moves. Both need classTypes:manage; overrideSpaceRules needs locations:manage.
  • class-sessions.tools.ts: create, create_many (per slot) and update take areaId next to locationId (the hall); update sends locationId: null through (back to the class type’s hall, no area). create and update take overrideSpaceConflict / overrideSpaceCapacity and return the session’s locationId and areaId. A 409 space_conflict comes back with conflictingSessions (class type, title, times, area; up to 5) and a 409 of either code with override: { field, allowed }, allowed being locations:manage for the caller. list_for_date returns hall (name null for the hidden hall) and area separately.
  • confirmation-target.service.ts: entity location names the approval card; the hidden hall has no name.
  • Help (ai/agent/help/help-content.ts): halls-and-areas, booking-rules, plan-entry-quotas.
  • Tests: locations.tools.unit.spec.ts, class-sessions.spaces.tools.unit.spec.ts, class-types.tools.unit.spec.ts, and the help ranking cases in help-content.unit.spec.ts.

Gated by spaces (TKN-444 UI)

With the spaces flag off the space UI is today’s Location UI:

  • copy: schedule.locationsTab, schedule.locationSheet and schedule.session keep the Location wording; each has a spaceWording group the components merge in when the flag is on, and settings.nav.spaces replaces settings.nav.locations;
  • session-card.tsx and weekly-condensed.tsx name the space only when on;
  • class-type-dialog.tsx shows the Default space picker, and sends defaultLocationId, only when on; the API pre-fills a new session’s space from it (create, createMany) only when on;
  • location-dialog.tsx shows and sends the sort order only when on.