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; alwaysalluntil S2),membershipSpaceSchema,SpaceContextErrorCodes(invalid_space,space_not_found,space_inactive) andSpaceSwitchVia.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-contextwas folded into it on 2026-10-18 and is never reused).src/lib/schemas/membership.schema.ts—membershipResponseSchema.spaces(the business’s active spaces insort_order) and.spaceScope.src/lib/schemas/analytics.schema.ts—spaceIdonattendanceReportQuerySchemaanddashboardQuerySchema;AttendanceReport.spaceIdis 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 reusesettings.nav.locationsandorgSwitcher.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:- No value: null, without reading the flag or the database.
- Flag off or unset: the value is ignored.
- The caller must be a member (403), checked before the space lookup.
- The value must be a UUID (400
invalid_space). - It must be this org’s space (404
space_not_found). - It must be active (404
space_inactive).
activeSpacesByOrg(orgIds)builds/users/mein one query.forAgent(orgId, spaceId)names the space for Spotter, or returns null when the org has no space context.SPACE_ID_API_QUERYis 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 throughforwardRef, because UsersModule closes the cycle.- Scoped reads. The controller resolves
spaceIdand the service addslocation_id = :space, so a session without a space matches only “every space” until S4:class-sessions/class-sessions.controller.tsand.service.ts:GET /sessions(findByWeek) andGET /sessions/upcoming-today(findUpcomingToday).bookings/bookings.controller.tsand.service.ts:GET /bookings/attendance-trend,GET /analytics/attendance/summaryandGET /analytics/attendance/daily.analytics/analytics.controller.ts,analytics.service.tsandanalytics-reports.service.ts:GET /analytics/dashboardscopes onlyclassesThisWeekandattendance.GET /analytics/attendance/reportkeepsmembers.activeMembersand.withoutVisitbusiness-wide, so a visit in any space counts.GET /analytics/classes/popularandGET /analytics/classes/utilization.GET /analytics/export: a scoped export gets aspaceIdcolumn, and an unscoped one is unchanged.
courses/courses.controller.tsandcourse-sessions.service.ts:GET /courses/:programId/sessions. An online meeting matches no space.tasks/tasks.controller.tsanddto/list-tasks-query.dto.ts:GET /tasksandGET /tasks/summary. The value is validated only, because no task has a space before S6.
users/users.controller.ts—/users/mereturnsspacesandspaceScope: 'all'on every membership.- Spotter:
ai/agent/agent.runtime.tsgainsspace.ai/agent/agent.controller.tsresolves it frompageContext.spaceIdon/messages, and from the latest user turn on pick and confirm (conversations.service.tslatestUserTurnSpaceId).ai/agent/context-builder.service.tsaddsWorking space: …to<org_context>, and only for an org with the context.
bookings/bookings.service.tsgetAttendanceSummary— the deleted-booking test now sits in the left join. A baredeleted_at IS NULL OR deleted_at IS NULLinside drizzle’s unparenthesisedand()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 parameterspaceId, and thetaikan_spacecookie (a URL-encoded JSON map{ orgId: spaceId | 'all' }). parseSpaceCookie,selectionFromParamandwithSpaceParam.scopedApiPath, which builds the same query key for the server prefetch and the client.
- URL parameter
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_idas a PostHog super property, andswitchSpacecapturesspace_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.
- The space is resolved in this order (§6.2–6.3): a pending switch, then
apps/web/src/app/[lang]/(protected)/layout.tsxandcomponents/protected-shell.tsx— the server reads the cookie and passes it to the provider, which sits insideUserProvider.apps/web/src/hooks/use-scoped-api-query.ts—useApiQueryfor space-bound reads. In one space the path gainsspaceId, 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].invalidateOrgLocationsalso 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.tsxscopesupcoming-todayandclasses/utilization; hydrated data shows at once, with no skeleton while the flag resolves.dashboard/overview/page.tsxprefetches the same keys from?space=.components/overview/v2/overview-v2.tsxshows oneWholeBusinessTagon the trend row and one on the coaching panel.components/overview/sidebar-nav.tsxanddashboard-breadcrumb.tsxbuild their links withscopedHref.providers/agent-provider.tsxsendspageContext.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:sidebaris the header, keepingorg-switcher-trigger; with 2+ spaces it adds a second line,context-switcher-space.topbariscontext-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 labelledRadioGroups whose items aremenuitemradio(space-switcher-item-all,space-switcher-item-<id>,org-switcher-item-<orgId>). - Phone menu: a bottom-sheet
Drawerwith a hidden description and a hand-rolledrole="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
OrgSwitcheruntouched 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 indashboard/layout.tsx, andcreate-session-sheet.tsxregisters 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) andSpaceKioskMode.apps/api/src/locations/space-scope.service.ts—resolveDoor(orgId, clerkId, hallId):resolve, then 2+ active halls, else null (the controller answers 404); tags Sentryspace.kiosk=door.apps/api/src/class-sessions/utils/kiosk-door.utils.ts—pickKioskDoor, the now / next /advancesAtrule.class-sessions.controller.tsGET /sessions/door/:hallIdandclass-sessions.service.tsfindKioskDoor(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.tsxandcomponents/kiosk/kiosk-door.tsx— the door;kiosk-screen.tsxgainsmode="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.tsxopenKiosk— 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.tsandspace-context.e2e.spec.ts— end to end over HTTP, withspacesanswered for the seeded org only (a spy onEventTrackingService.isFeatureEnabled). There is no web e2e for the switcher: web flags are bootstrapped build-wide fromNEXT_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.tsxand.int.spec.tsx— the door moves on atadvancesAtand from idle (withspace_kiosk_advanced), the header names the hall, 403 drops the roster, 404 cases, the flag wait, and the single-session kiosk’s parity andspace_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
resolvematrix: no value, flag off or unset, 400, cross-org 404, inactive 404, and 403 before the lookup; activeSpacesByOrgorder and default;forAgent;- the week and today filters, including the NULL-location rule;
- the attendance report’s scoped totals with business-wide
withoutVisit.
- the
apps/api/src/ai/agent/context-builder.unit.spec.ts— theWorking spaceline 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.tsxand.int.spec.tsxcover:- 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-insmodule (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 nospace_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,SpacesWeekReportand the windows/thresholds.SpaceScopeService.contextSpaces(orgId)(flag on and 2+ active spaces, else null) andgroupBySpaces(orgId, groupBy)(null without the context, 400invalid_group_byfor anything butspace, Sentry tagspace.group_by).ClassSessionsService.findUpcomingTodayBySpace: rows up to 100 and one grouped read of the same window forbySpace.AnalyticsService.getClassUtilization(..., spaces):bySpace.InsightsService.getOrgInsights({ spaceId, spaces, bySpace }): the space rules filter byspaceIdand lead the feed;operations.no_shows_recentandoperations.space_overlapsrun last, only withspaces(the space context implies thespacesflag, 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_idyet), 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,byMainSpaceadding up with aspaceId: null“no check-ins” row,activeInoverlapping),CheckInSignal, thegroupBy=spaceadditions (DashboardSpaceSplit,AttendanceSpaceSplit,CohortSpaceSplit) andSpacesReport(the week report generalised to a range;SpacesWeekReportkeepsweekFrom/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,getDashboardandgetMemberCohortstakespaces(groupBy=space); the headline queries are unchanged and the split runs as extra reads over every space.BookingsService.getAttendanceSummary/Daily/Trend(..., spaces):bySpaceon/analytics/attendance/summary|dailyand/bookings/attendance-trend.SpaceStatsService.getSpacesRangebehindGET /analytics/spaces/range(404space_context_offwithout the context);presence.activeInfeeds the “Checked in” column of plan coverage by space.- Export: attendance tabs with
groupBy=spacegive 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_viewedreports_spaces), and the space-awarestudio-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.tsxwithreports-spaces.driver.tsx. - Deferred: the Trials tile and column (S6,
lead_trial_appointmentshas no space yet); plan coverage columns (TKN-446); agroupBy=spaceexport 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(enumlocation_kind,locations.kind/parent_id/is_default/auto_created, the composite area → hall key, the checks, the partial unique indexlocations_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) and0176_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. Then0177_session_hall_required.sqlmakesclass_sessions.location_idNOT 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; alocationIdnaming an area is that area in its hall),visibleSpace/visibleSpaceRef(the hidden hall reads as no space) andsessionPlaceRef(the displaylocation: the area, else the visible hall, in session, booking, minisite and reminder responses).LocationsService.createwith the flag on adopts the hidden hall (same row, so its sessions and areas move with it) instead of adding a second hall, andfindAll/activeSpacesByOrgleave it out.- Writers that set a space (explicit → class type → default):
ClassSessionsService.create/update(clearing the space resolves it again) /createMany/copyWeek/replicateDay;ProgramTemplatesServiceapply (workingspaceId→ slot → class type → default;ScheduleOccurrenceInputandSessionInsertrequire a space at the type level);WorkoutAssignmentsServiceschedule;ArboxScheduleImportService(new mirrored classes); Spotter and MCP tools go throughClassSessionsService;TestingServiceseeds andscripts/seed-*use the default. Fizikal and courses write noclass_sessions. - Default space:
PATCH …/locations/:id { isDefault: true }(withspaceson); removing the default answers 409default_space_requiredwithspaceson while other spaces exist, and hands over silently to the next one with it off. - Class-type cascade:
POST …/sessions/space-cascade/previewandPOST …/sessions/space-cascade(ClassSessionsService.previewSpaceCascade/applySpaceCascade,spacesonly, 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 asclass_session.space_override), and sets the class type’s space in the same transaction. - Default context step 4:
/users/meaddsnextSessionSpaceIdper staff membership in a business with 2+ spaces (SpaceScopeService.nextCoachedSpaces);WorkingContextProvideruses it after the cookie. - Web: space sections in every view; no separate view.
space-sections.tsxis the shared layer:useSpaceSections(sections insort_order, pinned working space,COMPACT_ABOVE = 5folding and search, attention first, clash detection,space_sections_viewed),SpaceSections(section frames, folded per-day counts, inside the schedule’s drag layerschedule-drag.tsx:ScheduleDragProvider,ScheduleDropZone,ScheduleDraggable, shared with drag-to-copy, TKN-478),SpaceClashMarker(tooltip, opens the session),SessionRowMenuanduseSpaceCardTools(whatsession-card.tsxandweekly-condensed.tsxread inside a section).weekly-calendar.tsxrenders each view per section; the hour grid moved toexpanded-week-grid.tsx;daily-programming-view.tsxtakesshowHeading.use-session-space-move.ts(PATCH, override dialog, live region, Undo), the space control at the top ofsession-detail-sheet.tsx,space-cascade-dialog.tsxfromclass-type-dialog.tsx, default badge and “Make default” inlocations-tab.tsx,CreateSessionSheet.defaultLocationId(the section’s or working space), andapply-template-dialog.tsxsends the workingspaceId. - 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. Withspacesoff, 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 fieldsisDefaultandnextSessionSpaceId. - Tests:
apps/api/src/locations/default-space.int.spec.ts,apps/api/test/e2e/folded-schedule.e2e.spec.ts, webspace-sections.int.spec.tsxplus cascade/default/create cases in the class-type, locations-tab and create-session specs.class_sessions.location_idis NOT NULL, so a path or fixture that writes a session without a hall fails its spec (default-space.int.spec.tsguards the constraint). Spec fixtures usesrc/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), checkslocations_area_has_parent_chkandlocations_default_is_hall_chk;class_sessions.area_id,class_types.default_area_id;program-templates.tsprogram_template_slots.area_id. - Default rule: every hall and area is
sharedunless staff pickexclusive(column default,LocationsService,location-dialog.tsx; ADR-0024 D2 amended 2026-10-16). - Rules
ClassSessionsService.enforceSpaceRules: overlap anywhere in anexclusivehall, or in the sameexclusivearea; capacity at the tighter of area and hall; the 409s and the override audit carryareaId. Batch writers shareexclusivePlaceIds/shareExclusivePlace(program-templates/session-conflicts.ts);loadExclusiveSpacesreturns 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 asspaceIdisspace_not_found. - Locations API:
POST …/locations { parentId }adds an area; without it, a hall withspaceson (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, sharedcreateLocationSchema) overrides that:areawithoutparentIdis an area of the default hall, hidden or not;hallwithparentId, or with the flag off, is a 400.UpdateLocationDtoomitskind. Removing a hall deactivates its areas. Only halls can be the default. - Readers of
location_idthat now mean the hall: class-session, booking, minisite and reminder responses showsessionPlaceRef(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 theirspaceIdmeans. Mobile (session.location) needs no change: an area id sent aslocationIdis 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 onelocation_id(an area after the migration, display only). - Web
apps/web/src/lib/session-places.ts(splitPlaces,hasHallChoice,choosesHall,areasIn,sessionPlace,classTypePlace): withspacesoff 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: throughchoosesHallit gates the Hall field insession-fields.tsx(both modes), the required hall increate-session-sheet.tsxand the space control insession-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, sendingkind: '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.tsandlocations.int.spec.tsarea cases,session-conflicts.unit.spec.ts, thefolded-scheduleandspace-contexte2e 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 inlibs/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, withhidden: trueand no name;hallChoiceis true from two visible halls.locations.create(owner, admin; always confirmed): a hall or an area (kind,parentId). The tier’s 403 comes back asspace_limit_reached, not as a missing permission.locations.update(owner, admin; always confirmed): name, capacity (nullclears it), concurrency, sort order,isDefault: true.locations.deactivate(owner, admin; destructive):LocationsService.remove, so a hall takes its areas and the default answers 409default_space_required.
class-types.tools.ts:class_types.list_for_programreturns each class type’slocationId(null = the default hall) andareaId.class_types.space_preview(read) ispreviewSpaceCascade;class_types.set_space(always confirmed, the card counts the classes that move) runsapplySpaceCascadewhen the hall changes, then sets the area withClassTypesService.update. A wrong area is refused before anything moves. Both needclassTypes:manage;overrideSpaceRulesneedslocations:manage.class-sessions.tools.ts:create,create_many(per slot) andupdatetakeareaIdnext tolocationId(the hall);updatesendslocationId: nullthrough (back to the class type’s hall, no area).createandupdatetakeoverrideSpaceConflict/overrideSpaceCapacityand return the session’slocationIdandareaId. A 409space_conflictcomes back withconflictingSessions(class type, title, times, area; up to 5) and a 409 of either code withoverride: { field, allowed },allowedbeinglocations:managefor the caller.list_for_datereturnshall(name null for the hidden hall) andareaseparately.confirmation-target.service.ts: entitylocationnames 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 inhelp-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.locationSheetandschedule.sessionkeep the Location wording; each has aspaceWordinggroup the components merge in when the flag is on, andsettings.nav.spacesreplacessettings.nav.locations; session-card.tsxandweekly-condensed.tsxname the space only when on;class-type-dialog.tsxshows the Default space picker, and sendsdefaultLocationId, only when on; the API pre-fills a new session’s space from it (create, createMany) only when on;location-dialog.tsxshows and sends the sort order only when on.