Skip to content

ADR: Activity Model Simplification — types → [SLOT_BASED, SERVICE] + hasSeatSelection; merge CINEMA+SHOWS → EVENTS

ADR: Activity Model Simplification — types → [SLOT_BASED, SERVICE] + hasSeatSelection; merge CINEMA+SHOWS → EVENTS

Section titled “ADR: Activity Model Simplification — types → [SLOT_BASED, SERVICE] + hasSeatSelection; merge CINEMA+SHOWS → EVENTS”

Spec: specs/activity-model-simplification.md (ClickUp 869dpxbj6).

After dep 869dpa3zp merged, the live shape is:

  • activity_type PG enum = [SHOW, MOVIE, SLOT_BASED, SERVICE] (DINING dropped, the working-copy schema still mirrors the pre-merge state; current canonical is post-dep).
  • spheres.code rows = [SPORT, CINEMA, SHOWS, SERVICES] (DINING row deleted by 0048).
  • sphere_target_app PG enum = [GYM_APP, TICKETS_APP, SERVICES_APP] (locked — NOT touched by this ADR).

This ADR locks the three deltas required by the spec:

  • Collapse activity_type to [SLOT_BASED, SERVICE]; encode the seating axis as a new boolean activities.has_seat_selection (default false, NOT NULL, with CHECK (type != 'SERVICE' OR has_seat_selection = false)).
  • Migrate live rows: MOVIE(SLOT_BASED, has_seat_selection=true); SHOW(SLOT_BASED, has_seat_selection=false).
  • Merge CINEMA + SHOWS sphere rows into a single EVENTS row (TICKETS_APP, sort_order=1), backfill activities.sphere_id and categories.sphere_id, then delete CINEMA and SHOWS. Final sphere set = [SPORT, EVENTS, SERVICES].

The dep-merged canonical state (ActivityTypeEnum = [SHOW, MOVIE, SLOT_BASED, SERVICE] in commit 7608735 on origin/dev) is the starting point. The working copy on disk still shows [SHOW, MOVIE, SLOT_BASED, SERVICE] post-dep — confirmed by reading libs/shared/data-access-db/src/lib/schema/activities.schema.ts:39-44 (no DINING). All decisions below assume that starting point.

D1 — hasSeatSelection as a NOT NULL boolean column with table-level CHECK

Section titled “D1 — hasSeatSelection as a NOT NULL boolean column with table-level CHECK”

Decision. Add has_seat_selection boolean NOT NULL DEFAULT false on activities.activities, declared in Drizzle as:

hasSeatSelection: boolean('has_seat_selection').notNull().default(false),

Add a single table-level CHECK constraint expressed in Drizzle via the existing check() helper (already imported in activities.schema.ts:14):

hasSeatSelectionTypeCheck: check(
'activities_has_seat_selection_check',
sql`${activities.type} <> 'SERVICE' OR ${activities.hasSeatSelection} = false`,
),

Rationale.

  • NOT NULL + default false matches the precedent set by refundable (activities.schema.ts:222, shipped via activity-refundable-and-cancellation-window). ALTER TABLE … ADD COLUMN with a default rewrites existing rows in PG 11+, so the column lands populated.
  • A table-level CHECK is the right home for the cross-column invariant. Inline .check() on a column would not see type.
  • Naming activities_has_seat_selection_check aligns with PG conventions (<table>_<column>_check); AC-3 asserts this exact name.
  • Drives no FK behaviour and adds no index — the flag is read on the booking-write path (no hot-path filter use case yet).

Alternative rejected: model seat selection as a per-row JSON config on metadata. Hides the invariant from the DB, fails AC-3, and the booking-write path would still need to dereference the json. The boolean column is one PG type-check, one CHECK constraint, one DTO field.

D2 — PG enum rebuild for activity_type: data-migrate BEFORE rebuild

Section titled “D2 — PG enum rebuild for activity_type: data-migrate BEFORE rebuild”

Decision. The single Drizzle migration (next number 0049, name auto-assigned) MUST execute strictly:

  1. Pre-flight DO $$ block: assert count(*) FROM activities.activities WHERE type::text = 'DINING' = 0 and count(*) FROM activities.spheres WHERE code='DINING' = 0 and count(*) FROM activities.activities WHERE type::text NOT IN ('SHOW','MOVIE','SLOT_BASED','SERVICE') = 0. RAISE EXCEPTION on any non-zero.
  2. ALTER TABLE activities.activities ADD COLUMN has_seat_selection boolean NOT NULL DEFAULT false;
  3. Data migration with the enum still in its 4-value shape (UPDATE … SET type='SLOT_BASED', has_seat_selection=true WHERE type='MOVIE'; UPDATE … SET type='SLOT_BASED', has_seat_selection=false WHERE type='SHOW'). UPDATEs run against the OLD enum domain — both SLOT_BASED and MOVIE/SHOW are valid values, so the writes are accepted.
  4. PG enum rebuild — matches the 0048_clear_tattoo.sql template:
    • ALTER TABLE activities.activities ALTER COLUMN type SET DATA TYPE text;
    • ALTER TABLE activities.spheres ALTER COLUMN allowed_activity_types SET DATA TYPE text;
    • ALTER TABLE activities.spheres ALTER COLUMN default_activity_type SET DATA TYPE text;
    • DROP TYPE activities.activity_type;
    • CREATE TYPE activities.activity_type AS ENUM('SLOT_BASED', 'SERVICE');
    • ALTER TABLE activities.activities ALTER COLUMN type SET DATA TYPE activities.activity_type USING type::activities.activity_type;
    • ALTER TABLE activities.spheres ALTER COLUMN allowed_activity_types SET DATA TYPE activities.activity_type[] USING allowed_activity_types::activities.activity_type[];
    • ALTER TABLE activities.spheres ALTER COLUMN default_activity_type SET DATA TYPE activities.activity_type USING default_activity_type::activities.activity_type;
  5. ADD CONSTRAINT activities_has_seat_selection_check.
  6. Sphere consolidation (D3).
  7. UPDATE spheres.allowed_activity_types and default_activity_type for the remaining 3 rows (SPORT/EVENTS/SERVICES) to the post-collapse values per spec table.

Order matters. Doing the data UPDATE step 3 BEFORE the cast-to-text in step 4 means the enum-typed column is still itself when the conditional WHERE type='MOVIE' runs — Postgres does not have to coerce literals against a dropped type. Doing the data UPDATE AFTER the cast-to-text but BEFORE the new enum is created is also valid; the chosen order (enum-typed UPDATE first) is the one drizzle-generated migrations exhibit because schema edits drive the migration shape, and the spec’s stated step order (§Data Migration steps 1-10) maps cleanly to this layout.

Rationale.

  • Postgres has no ALTER TYPE … DROP VALUE. The rename-and-rebuild dance is the only path (see ADR sphere-targetapp-enum-cleanup-drop-dining-add-services §4 and migration 0048).
  • Pre-flight DO block is the spec’s AC-15 mitigation against unguarded CASCADE / unknown enum values mid-cast.
  • All three columns that reference the enum (activities.type, spheres.allowed_activity_types[], spheres.default_activity_type) must be cast through text and back — same recipe as 0048.
  • Step ordering: ADD COLUMN before data UPDATEs (column must exist), data UPDATEs before enum rebuild (we cast to text first), CHECK constraint after the rebuild (constraint is evaluated against the new enum domain).

Alternative considered. Rename MOVIE and SHOW via ALTER TYPE … RENAME VALUE 'MOVIE' TO 'SLOT_BASED' then DROP VALUE 'SHOW'. Rejected — PG only supports RENAME VALUE (not DROP VALUE), and the rename would collide with the existing SLOT_BASED value. The rebuild is the only safe path.

D3 — Sphere row consolidation: INSERT EVENTS, backfill, then DELETE

Section titled “D3 — Sphere row consolidation: INSERT EVENTS, backfill, then DELETE”

Decision. Steps 6-9 of the migration:

-- 6. INSERT EVENTS sphere (idempotent guard via ON CONFLICT DO NOTHING).
INSERT INTO activities.spheres
(code, name, icon, target_app, allowed_activity_types, default_activity_type, sort_order)
VALUES
('EVENTS',
'{"uk":"Події","en":"Events","ru":"События","de":"Events","fr":"Événements"}'::jsonb,
NULL, 'TICKETS_APP',
ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[],
'SLOT_BASED'::activities.activity_type,
1)
ON CONFLICT (code) DO NOTHING;
-- 7. Backfill activities pointing at CINEMA or SHOWS → EVENTS.
UPDATE activities.activities a
SET sphere_id = (SELECT id FROM activities.spheres WHERE code='EVENTS')
WHERE a.sphere_id IN (SELECT id FROM activities.spheres WHERE code IN ('CINEMA','SHOWS'));
-- 8. Backfill categories pointing at CINEMA or SHOWS → EVENTS.
UPDATE activities.categories c
SET sphere_id = (SELECT id FROM activities.spheres WHERE code='EVENTS')
WHERE c.sphere_id IN (SELECT id FROM activities.spheres WHERE code IN ('CINEMA','SHOWS'));
-- 9. Delete CINEMA + SHOWS (now safe — no rows reference them).
DELETE FROM activities.spheres WHERE code IN ('CINEMA','SHOWS');
-- 10. Re-seed the three remaining rows to the canonical post-collapse values,
-- INCLUDING sort_order to satisfy AC-6 (contiguous 0/1/2 sequence).
-- Pre-this-MR state: SPORT=0, CINEMA=1, SHOWS=2, SERVICES=3 (DINING=4 already deleted by 0048).
-- Target state: SPORT=0, EVENTS=1, SERVICES=2.
-- CINEMA/SHOWS get DELETEd in step 9 above, so their sort_order rows vanish.
-- EVENTS was INSERTed at sort_order=1 in step 6.
-- SERVICES must be re-numbered 3 → 2 here.
UPDATE activities.spheres
SET allowed_activity_types = ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[],
default_activity_type = 'SLOT_BASED'::activities.activity_type,
sort_order = 0
WHERE code = 'SPORT';
UPDATE activities.spheres
SET allowed_activity_types = ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[],
default_activity_type = 'SLOT_BASED'::activities.activity_type,
sort_order = 1
WHERE code = 'EVENTS';
UPDATE activities.spheres
SET allowed_activity_types = ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[],
default_activity_type = 'SERVICE'::activities.activity_type,
sort_order = 2
WHERE code = 'SERVICES';

AC-6 cross-reference. Spec AC-6 asserts the final sphere set is [SPORT, EVENTS, SERVICES] with sort_order 0/1/2 contiguous. The pre-this-MR state (verified by reading migration 0037_activity_spheres.sql:42-58 and confirming 0048_clear_tattoo.sql only deletes DINING) is SPORT=0, CINEMA=1, SHOWS=2, SERVICES=3. Without the explicit sort_order = 2 re-seed on SERVICES, post-migration ordering would be SPORT=0, EVENTS=1, SERVICES=3 — non-contiguous and contradicting AC-6. The step 10 re-seed block above includes the SPORT/EVENTS values defensively (they are no-ops on a happy path) so the final sort_order column is fully deterministic for the three remaining rows.

FK behaviour. Both activities.sphere_id and categories.sphere_id are declared ON DELETE no action (0037_activity_spheres.sql:94-95). Backfill MUST run before DELETE — otherwise the DELETE fails on the FK constraint. The migration’s DELETE FROM activities.spheres WHERE code IN ('CINEMA','SHOWS') is the gate; running it before steps 7-8 finish is a guaranteed abort.

Idempotency guards.

  • INSERT uses ON CONFLICT (code) DO NOTHING — re-running on a partially applied DB does not double-insert.
  • The two UPDATE backfills are naturally idempotent (subqueries return empty after deletion, UPDATEs become no-ops).
  • DELETE is naturally idempotent (no rows match on second run).
  • The 3 re-seed UPDATEs are naturally idempotent (overwriting with same values is a no-op).

Rationale.

  • Per memory feedback_drizzle_migrations: schema edit + pnpm run db:generate produces the SQL file; data SQL (INSERT, UPDATE, DELETE) is hand-added to the generated file BEFORE apply. Drizzle never emits data migrations.
  • Strict order (INSERT → UPDATE → UPDATE → DELETE → re-seed) is the only sequence that satisfies the FK and produces the AC-6/AC-7/AC-8 final state.
  • The re-seed at step 10 uses three explicit UPDATEs (not a CASE expression) to keep the SQL diff-readable when reviewers grep it.

D4 — hasSeatSelection-only-with-SLOT_BASED enforced at DTO + DB layer

Section titled “D4 — hasSeatSelection-only-with-SLOT_BASED enforced at DTO + DB layer”

Decision. Two layers, both required:

  • DB: the CHECK constraint from D1 (type <> 'SERVICE' OR has_seat_selection = false). This is the source of truth — bypassing the DTO (raw SQL, internal tools, future bulk imports) still hits the constraint and gets 23514.
  • DTO: a small custom class-validator decorator @HasSeatSelectionRequiresSlotBased() on CreateActivityDto and UpdateActivityDto. The decorator runs at the class level (not field level) so it can see both type and hasSeatSelection in one pass. It returns false (validation error) iff type === 'SERVICE' && hasSeatSelection === true. All other shapes (including undefined/missing fields) pass.

Reasoning on PATCH partial-update handling:

  • If only hasSeatSelection: true is in the PATCH body and the current activity has type='SERVICE', the DTO validator alone cannot catch it — it sees type === undefined. The service layer (in activities.service.ts update method) MUST therefore merge the partial against the loaded row and re-run the invariant before write. The DB CHECK is the ultimate backstop. The spec’s AC-12 lists this exact scenario as a 400, so the service-layer merge + re-validate path is mandatory.
  • If only type: 'SERVICE' is in the PATCH body and the current activity has hasSeatSelection=true, the service layer MUST also re-validate and either (a) reject with 400 or (b) implicitly reset has_seat_selection to false in the same UPDATE. Per spec AC-12 (rejecting hasSeatSelection=true on a SERVICE row), the safer path is (a): reject with 400 + error message errors.activity.has_seat_selection_invalid_for_service. Implicit resets violate the principle of least surprise. Locking option (a) here.

Rationale.

  • Two-layer enforcement matches the precedent in sphere-column-type-safety (DTO @IsIn(ActivityTypeEnum.enumValues) + DB enum type cast both reject BANANA).
  • A class-level decorator (vs. field-level @ValidateIf) is one place to read for the invariant — class-validator’s registerDecorator with validate(object) receiving the whole instance is the right tool.
  • The service-layer merge step is a small code addition in libs/features/activities/src/lib/services/activities.service.ts (existing file — verified via ls). Phase B owns wiring it.

D5 — Contract layout: per-surface hasSeatSelection field; ActivityType enum diff applied identically to all three contracts

Section titled “D5 — Contract layout: per-surface hasSeatSelection field; ActivityType enum diff applied identically to all three contracts”

Decision.

Schema changes per surface (auto-emitted by pnpm run sync:contracts after backend lands):

contracts/client.openapi.yaml (canonical line numbers from current snapshot)

  • components.schemas.ActivityType.enum line 217-221: [SHOW, MOVIE, SLOT_BASED, SERVICE][SLOT_BASED, SERVICE].
  • ActivityPreviewClientResponseDto.type (currently bare string — verified activity-client.response.dto.ts:72): leave as bare string (the consumer renders it as a badge); no change. The enum domain narrows because the only legal values on the wire narrow.
  • ActivityPreviewClientResponseDto: add hasSeatSelection: boolean (required). Picked option (a) from the architect-critic review: the field lands on the preview shape, which is structurally a plain DTO class (no shared search-index dependency, no OmitType truncation) — verified by reading activity-client.response.dto.ts:67-110. Justifications: (1) ActivityDetailClientResponseDto is declared as extends OmitType(ActivityPreviewClientResponseDto, ['matchedByContributor']) (activity-client.response.dto.ts:117-122), so adding the field on the preview makes the detail inherit it for free — no double declaration. (2) A near-term follow-up filter ticket (“events with reserved seating”) needs the field on list/search responses; deferring it forces another contract diff. (3) Wire cost is one boolean per row — negligible. The “saves wire bytes” rationale in the original §D5 does not outweigh the future filter cost.
  • ActivityDetailClientResponseDto: inherits hasSeatSelection: boolean automatically via the OmitType(ActivityPreviewClientResponseDto, ['matchedByContributor']) base. No separate declaration needed.
  • FavoriteActivityDto.type (currently bare stringfavorite.dto.ts:40-43): picked option (i) — promote to a named-enum reference via @ApiProperty({ enum: ActivityType, enumName: 'ActivityType' }). Justification: (1) the current @ApiProperty.description literally embeds the dead enum names SHOW, MOVIE (verified favorite.dto.ts:41), so a doc-only edit still leaves a typed-vs-described mismatch on the wire. (2) CLAUDE.md OpenAPI authoring rule “Every named enum carries enumName: 'X' alongside enum: X” applies — without it, downstream codegens lose the named type for this field. (3) The diff is one decorator change in one file; no patch-spheres-swagger.js workaround needed because Nest’s swagger plugin will emit a $ref to the already-named ActivityType schema (the schema is enumName-registered by activity.response.dto.ts:97 and sphere.dto.ts:22). The type!: string TS type stays bare-string (the runtime value comes from the DB and is already constrained by the activity column).

contracts/business.openapi.yaml

  • components.schemas.ActivityType.enum line 235-240: same enum diff.
  • ActivityResponseDto.type (already $refs ActivityType via enumName: 'ActivityType' at activity.response.dto.ts:97): enum narrows automatically.
  • ActivityResponseDto: add hasSeatSelection: boolean (required).
  • CreateActivityDto: add hasSeatSelection?: boolean (optional, defaults to false).
  • UpdateActivityDto (= PartialType(CreateActivityDto), update-activity.dto.ts:4): inherits hasSeatSelection?: boolean automatically.
  • SphereAdminDto.defaultActivityType and allowedActivityTypes (already $ref: ActivityType): enum narrows automatically.

contracts/super-admin.openapi.yaml

  • components.schemas.ActivityType.enum line 4-8: same enum diff.
  • CreateSphereDto.allowedActivityTypes / defaultActivityType and UpdateSphereDto.allowedActivityTypes / defaultActivityType: $ref: ActivityType already; enum narrows automatically.
  • The super-admin surface does NOT expose hasSeatSelection on any DTO it owns — super-admin doesn’t CRUD activities. Verified — no super-admin activity controller. So the only super-admin diff is the ActivityType enum shrink.

Per-surface field-visibility rationale.

  • /api/client: surfaces hasSeatSelection on both preview and detail DTOs (the detail inherits via OmitType). Rationale: (1) the near-term “events with reserved seating” filter ticket needs the field on list responses; (2) one boolean per preview row is negligible wire cost; (3) FavoriteActivityDto — a slim 6-field shape used by gym_app favorites — does NOT receive hasSeatSelection (the favorites card does not render the seat affordance, and the shape is explicitly minimal per favorite.dto.ts:26-36). The FavoriteActivityDto.type field is promoted to enumName: 'ActivityType' so the generated mobile client gets the named enum.
  • /api/business: surfaces it on CreateActivityDto (write), UpdateActivityDto (write), and ActivityResponseDto (read). Business admin is the ONLY surface that mutates hasSeatSelection.
  • /api/superadmin: nothing. Super-admin only edits spheres, not individual activities.

Per the workspace rule “never share types between surfaces — duplicate intentionally”: the ActivityType enum domain is genuinely identical on all three contracts because there is one PG enum backing it. The CONTRACTS are still emitted independently (one per Swagger doc, via pnpm run sync:contracts), but the value set on each is the same. Duplication is achieved by separate emissions, not by sharing a single file. This is consistent with the precedent in ADR sphere-targetapp-enum-cleanup-drop-dining-add-services §8.

Rationale.

  • The named ActivityType schema already exists in all three contracts (verified — contracts/client.openapi.yaml:216, contracts/business.openapi.yaml:234, contracts/super-admin.openapi.yaml:3). The diff is purely the enum array shrink + the additive hasSeatSelection field where it’s exposed.
  • enumName: 'ActivityType' on the existing decorators (verified — activity.response.dto.ts:97, sphere.dto.ts:22/29/41/77/88/127/138) means Nest auto-emits the named schema and $refs it. We do NOT need to add enumName anywhere new.

D6 — Migration safety: pre-flight DO block + single-transaction + idempotency

Section titled “D6 — Migration safety: pre-flight DO block + single-transaction + idempotency”

Decision. The single generated .sql file in libs/shared/data-access-db/migrations/0049_<auto-name>.sql runs as one statement-breakpoint script. Pre-flight DO block at the top:

DO $$
BEGIN
IF (SELECT count(*) FROM activities.activities WHERE type::text = 'DINING') > 0 THEN
RAISE EXCEPTION 'activity-model-simplification: DINING rows present — 869dpa3zp incomplete';
END IF;
IF (SELECT count(*) FROM activities.spheres WHERE code = 'DINING') > 0 THEN
RAISE EXCEPTION 'activity-model-simplification: DINING sphere row present — 869dpa3zp incomplete';
END IF;
IF (SELECT count(*) FROM activities.activities
WHERE type::text NOT IN ('SHOW','MOVIE','SLOT_BASED','SERVICE')) > 0 THEN
RAISE EXCEPTION 'activity-model-simplification: unexpected activity_type values present';
END IF;
END $$;

Transaction boundaries. Drizzle’s migration runner wraps each .sql file in a single transaction by default. Verified in apps/api/src/migrate.ts:2 — the backend imports migrate from drizzle-orm/node-postgres/migrator, which executes each migration file inside its own BEGIN/COMMIT block (the statement-breakpoint markers split the file into individual client.query calls within that one transaction). Failure at any step (pre-flight RAISE, FK abort on DELETE, CHECK violation, cast failure) rolls back cleanly. Drizzle’s __drizzle_migrations ledger only records the file hash on COMMIT, so a rollback leaves the DB indistinguishable from its pre-attempt state.

Idempotency / re-runnability. Because each migration file is one atomic transaction, the re-run question collapses:

  • Failed-mid-flight scenario: the transaction rolls back; nothing was applied; the next attempt starts from the same pre-state as the first.
  • Already-applied scenario: the migration ledger has the file hash; drizzle-kit skips it. The SQL is never re-executed.

There is therefore no need for hand-edited IF EXISTS (… pg_enum … 'MOVIE') guards around the enum-rebuild block, nor for an ALTER TABLE … ADD COLUMN IF NOT EXISTS (drizzle-kit emits the bare ADD COLUMN form — verified at migrations/0047_rapid_molly_hayes.sql:1). The guards we DO keep are the ones that protect against bad starting states (not re-runs):

  • Pre-flight DO block — RAISEs if a DINING row or non-canonical activity_type value is present. Catches “migration is being applied against an unexpected DB state” (e.g. dep 869dpa3zp was reverted), not “migration is being re-applied”.
  • ON CONFLICT (code) DO NOTHING on the EVENTS INSERT — defends against the case where a developer manually pre-seeded the row out-of-band; it is not a re-run defence.

Rationale. Drizzle’s single-transaction wrapping is documented behaviour of drizzle-orm/node-postgres/migrator and is the model 0048_clear_tattoo.sql (the same DROP-TYPE-and-rebuild precedent for the dep ticket) already relies on without idempotency guards. Mirroring that pattern keeps the SQL diff-readable and consistent with the in-tree precedent.

All paths relative to tktspace-backend/. Line numbers verified via Read 2026-06-16.

FileChange
libs/shared/data-access-db/src/lib/schema/activities.schema.ts:39-44ActivityTypeEnum['SLOT_BASED', 'SERVICE'].
libs/shared/data-access-db/src/lib/schema/activities.schema.ts (activities table, around line 198 next to type)Add hasSeatSelection: boolean('has_seat_selection').notNull().default(false). Append a 3rd argument (table-builder fn) to expose the CHECK; the current (t) => ({ sphereIdx, titleTrgmIdx }) block at line 243 gains a new hasSeatSelectionTypeCheck: check(…) entry.
libs/shared/data-access-db/src/lib/schema/activities.schema.ts:79Comment // SPORT | CINEMA | SHOWS | SERVICES// SPORT | EVENTS | SERVICES.
libs/shared/data-access-db/migrations/0049_<auto>.sql (new)Generated by pnpm run db:generate from the schema edits; data SQL hand-added per D2/D3/D6 BEFORE pnpm run db:migrate.
libs/features/activities/src/lib/dto/create-activity.dto.ts:19-24TS enum ActivityType → drop SHOW, MOVIE. Two remaining members: SLOT_BASED, SERVICE.
libs/features/activities/src/lib/dto/create-activity.dto.ts (new field on CreateActivityDto)Add @ApiProperty({ required: false, default: false, description: 'Seat-map driven flag. Valid only when type === SLOT_BASED.' }) @IsBoolean() @IsOptional() hasSeatSelection?: boolean; and a class-level @HasSeatSelectionRequiresSlotBased() decorator.
libs/features/activities/src/lib/dto/update-activity.dto.ts:4extends PartialType(CreateActivityDto) inherits hasSeatSelection?: boolean and the class-level validator automatically. No edit unless the validator needs separate wiring on the partial.
libs/features/activities/src/lib/dto/activity.response.dto.ts:97@ApiProperty({ enum: ActivityType, enumName: 'ActivityType' }) — enum domain shrinks via the TS enum edit; no decorator change.
libs/features/activities/src/lib/dto/activity.response.dto.ts (new field)Add @ApiProperty() hasSeatSelection!: boolean; on ActivityResponseDto.
libs/features/activities/src/lib/dto/activity-client.response.dto.ts:67-110 (ActivityPreviewClientResponseDto)Add @ApiProperty() hasSeatSelection!: boolean; to the preview DTO. ActivityDetailClientResponseDto inherits via the OmitType(ActivityPreviewClientResponseDto, ['matchedByContributor']) base at line 117-122 — no separate declaration on detail (D5 rationale, option (a)).
libs/features/activities/src/lib/dto/favorite.dto.ts:40-43 (FavoriteActivityDto.type)Promote bare @ApiProperty({ description: '…' }) to @ApiProperty({ enum: ActivityType, enumName: 'ActivityType', description: 'Activity type code. Rendered as a badge on the favorites card.' }). Import ActivityType from ./create-activity.dto. The narrowed enum (post-D2) auto-flows into the generated $ref; no dead SHOW, MOVIE in the description prose. TS field type type!: string stays bare-string.
libs/features/activities/src/lib/services/activities.service.ts (verify file exists; expected per Phase B)UPDATE method: after merging PATCH partial with loaded row, re-validate (type, hasSeatSelection) invariant; throw 400 errors.activity.has_seat_selection_invalid_for_service if violated. CREATE method: validator at DTO catches direct violations; pass through to DB which has CHECK backstop.
libs/features/activities/src/lib/dto/find-activities-client.dto.ts:79 (if referenced)enumName: 'ActivityType' decorator unchanged; enum shrinks automatically.
libs/features/activities/src/lib/validators/has-seat-selection-requires-slot-based.decorator.ts (NEW)New file. Class-level class-validator decorator. ~30 lines.
libs/features/spheres/src/lib/services/spheres.service.ts:32type ActivityTypeValue = 'SHOW' | 'MOVIE' | 'SLOT_BASED' | 'SERVICE''SLOT_BASED' | 'SERVICE'.
libs/features/spheres/src/lib/dto/sphere.dto.ts:31,80,129example: ['SERVICE'] — already shape-correct; verify no MOVIE/SHOW examples linger.

3.2 tktspace-backend — tests/fixtures (e2e + unit)

Section titled “3.2 tktspace-backend — tests/fixtures (e2e + unit)”

Grep results (grep -rn "'MOVIE'\|'SHOW'\|CINEMA\|SHOWS" libs apps --include="*.ts" excluding generated SQL/meta):

FileChange
libs/features/spheres/src/lib/__tests__/enum-cleanup.spec.ts:16,51,52Update doc + expected enum array to ['SLOT_BASED','SERVICE'].
libs/features/spheres/src/lib/dto/sphere.dto.spec.ts:30,31,41,61Fixture ['SHOW', 'MOVIE']['SLOT_BASED', 'SERVICE']; defaultActivityType = 'SHOW''SLOT_BASED'. Bad-value fixture ['SHOW', 'BANANA']['SLOT_BASED','BANANA'] (preserves the negative-path semantic).
libs/features/activities/src/lib/spec/activities-client.target-app.spec.ts:20,25,139,156,196,217,223,241,246,267,322,346,365,391,441Mass rename of doc comments and sphere lookups from SHOWS/SHOW to EVENTS/SLOT_BASED. Test seeds an “events activity” instead of a “shows activity”; verify the wallet-target-app assertion holds (the TICKETS_APP sphere is now EVENTS). Activity type: 'SHOW'type: 'SLOT_BASED', hasSeatSelection: false.
apps/api-e2e/src/spheres/spheres-business.e2e-spec.ts:359-412Replace the CINEMA-only-allows-MOVIE narrative entirely. Post-collapse, every sphere allows [SLOT_BASED, SERVICE], so the “type not in allowedActivityTypes” assertion needs a fresh negative path — e.g., temporarily shrink SERVICES.allowedActivityTypes to ['SERVICE'] via super-admin PATCH, then try to create a SLOT_BASED activity under it. Semantic preserved. Spec AC-16 grep clean depends on this.
apps/api-e2e/src/spheres/spheres-enum-cleanup.e2e-spec.ts:11Doc string: 'SHOW','MOVIE','SLOT_BASED','SERVICE''SLOT_BASED','SERVICE'.
apps/api-e2e/src/spheres/spheres-superadmin.e2e-spec.ts:227,228Fixture rewrite: ['SHOW', 'BANANA']['SLOT_BASED','BANANA']; defaultActivityType: 'SHOW''SLOT_BASED'.
apps/api-e2e/src/spheres/spheres-schema.e2e-spec.ts:46expect(codes).toEqual(['SPORT','CINEMA','SHOWS','SERVICES'])['SPORT','EVENTS','SERVICES']. Also any toHaveLength(4)toHaveLength(3).
apps/api-e2e/src/admin/sessions.spec.ts:40Seed type: 'SHOW'type: 'SLOT_BASED', hasSeatSelection: false.
apps/api-e2e/src/admin/bookings.spec.ts:41Same.
apps/api-e2e/src/admin/activities.spec.ts:78Same.
apps/api-e2e/src/client/wallet-target-app.e2e-spec.ts:12,35,146,165,171Replace SHOWS sphere lookup with EVENTS; activity type: 'SHOW'type: 'SLOT_BASED'.
apps/api-e2e/src/spheres/sphere-type-safety-0046.spec.ts:137-191Hardcoded enum-domain assertions for activity_type (currently ['SHOW','MOVIE','SLOT_BASED','SERVICE']) → ['SLOT_BASED','SERVICE']. Also any toHaveLength(4) over the enum value array → toHaveLength(2). The test still verifies the type-safety regression guard from migration 0046; only the expected enum domain narrows.
FileChange
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.ts:102-107activityTypes: ActivityResponseDto['type'][] = ['SHOW','MOVIE','SLOT_BASED','SERVICE']['SLOT_BASED','SERVICE'].
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.ts (controls)Add a hasSeatSelection FormControl (boolean) to the form group. Reactive effect: when type changes to 'SERVICE', set hasSeatSelection to false and disable the control; when type changes to 'SLOT_BASED', enable.
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.htmlAdd <tui-switch> (or <tui-checkbox>) labelled “Has seat selection” / i18n#activities.has_seat_selection. Hidden when type === 'SERVICE' (or disabled — UX choice; spec leaves open, “hidden when type === ‘SERVICE’” matches AC-18).
client/src/app/features/dashboard/activities/forms/activity.form.ts:58-74Add 'hasSeatSelection' to editableFields. Add hasSeatSelection: false to defaults (Partial). Verify toCreateDto() returns the field (it will via getRawValue()).
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.enum-cleanup.spec.ts:10,13,173,176-177Rewrite expected enum lists.
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.spec.ts (lines 12, 40, 55-61, 201, 239-250, 312, 314, 342, 364-383, 411-460)Mass refactor: CINEMA_UUID constant → EVENTS_UUID; code: 'CINEMA'code: 'EVENTS'; allowedActivityTypes: ['MOVIE']['SLOT_BASED','SERVICE']; type: 'MOVIE'type: 'SLOT_BASED', hasSeatSelection: true. AC-8.2 “changing sphere to CINEMA clears SLOT_BASED type” no longer holds (all spheres allow SLOT_BASED) — replace with a “changing hasSeatSelection toggle to true while type is SERVICE is rejected” case. Sphere display string 'Cinema''Events'.
client/src/app/core/api/models/activity-type-code.ts (regenerated)Auto-regenerates to ['SLOT_BASED','SERVICE'].
client/src/app/core/api/models/activity-type.ts (regenerated)Same.
client/src/app/core/api/models/sphere-code.ts (regenerated)Auto-regenerates to ['SPORT','EVENTS','SERVICES'].
client/src/app/core/api/models/create-activity-dto.ts (regenerated)Auto-emits hasSeatSelection?: boolean.
client/src/app/core/api/models/activity-response-dto.ts (regenerated)Auto-emits hasSeatSelection: boolean.
tools/gen/swagger-api.json (hand-curated snapshot)Edit components.schemas.ActivityType.enum, components.schemas.ActivityTypeCode.enum (= patch-spheres-swagger.js produces this; see below), components.schemas.SphereCode.enum. Add hasSeatSelection to CreateActivityDto, UpdateActivityDto, ActivityResponseDto.
tools/gen/patch-spheres-swagger.js:43,49,55SphereCode.enum: ['SPORT','CINEMA','SHOWS','SERVICES']['SPORT','EVENTS','SERVICES']. ActivityTypeCode.enum: ['SHOW','MOVIE','SLOT_BASED','SERVICE']['SLOT_BASED','SERVICE']. SphereTargetApp unchanged.
FileChange
src/app/pages/explore/explore.page.ts:113-118SPHERE_FALLBACK_ICONS: drop CINEMA and SHOWS entries; add an EVENTS entry with a placeholder icon. Final EVENTS sphere icon decision deferred to Phase C — spec doesn’t lock the glyph, and the in-DB spheres.icon column is NULL for EVENTS per migration step 6. Phase C dev picks the icon and confirms tickets_app mobile sphere-icon-fallback behaviour renders cleanly when the column stays NULL (the Flutter sphere chip widget should already fall back; mobile dev to verify in apps/tickets_app sphere-list screen during the Phase C smoke).
src/app/core/api/models/activity-type.ts (regenerated by npm run generate)Auto-narrows enum.
src/app/core/api/models/activity-detail-client-response-dto.ts (regenerated)Auto-emits hasSeatSelection: boolean.
Web detail page (location TBD — Phase C grep)If the detail template wants to render a “Has seat selection” badge / hint, wire it. Out of scope per spec — “verify no MOVIE/SHOW hand-written refs”; the field is forward-compatible and consumers can opt in later.

3.5 tktspace-mobile-app — CRITICAL ESCALATION beyond spec text

Section titled “3.5 tktspace-mobile-app — CRITICAL ESCALATION beyond spec text”

The spec §“tktspace-mobile-app” claims “Verify gym_app has no MOVIE/SHOW references (low risk)”. This is incorrect. Grep at 2026-06-16 found:

apps/gym_app/lib/pages/home/search_page.dart:22: (value: ActivityType.show, label: 'search#filter_show'.tr()),
apps/gym_app/lib/pages/home/search_page.dart:23: (value: ActivityType.movie, label: 'search#filter_movie'.tr()),
apps/gym_app/lib/pages/home/search_page.dart:24: (value: ActivityType.slotBased, label: 'search#filter_slot'.tr()),
apps/gym_app/lib/pages/home/search_page.dart:25: (value: ActivityType.dining, label: 'search#filter_dining'.tr()), // ALREADY broken post-869dpa3zp
apps/gym_app/lib/pages/home/search_page.dart:26: (value: ActivityType.service, label: 'search#filter_service'.tr()),

After the mobile API regen narrows ActivityType to [slotBased, service], ActivityType.show / ActivityType.movie / ActivityType.dining will no longer compile. The search-page type-filter dropdown must shrink. Phase C mobile dev MUST:

FileChange
apps/gym_app/lib/pages/home/search_page.dart:21-27Drop .show, .movie, .dining entries. Final dropdown: (ActivityType.slotBased, 'search#filter_slot'), (ActivityType.service, 'search#filter_service'). Out-of-scope decision needed: do we still need a “has-seat-selection” sub-filter? Spec scope says “No UI changes” — recommend dropping the search type filter entries cleanly and deferring the seat-selection filter UI to a follow-up.
packages/favorites/lib/src/favorites_repository.dart:50Docstring: drop MOVIE, SHOW, DINING references; replace with 'e.g. SLOT_BASED, SERVICE'.
packages/favorites/lib/src/favorites_page.dart:238Same docstring fix.
packages/favorites/test/favorites_page_widget_test.dart:141Test fixture type: 'SHOW'type: 'SLOT_BASED'.
apps/gym_app/integration_test/sphere_filter_test.dart:590,596Comments mention ActivityType.slotBased — no change (correct post-collapse). Verify on Phase C re-grep.
packages/api/swagger-api.json (regenerated by melos run sync:spec)Auto.
packages/api/lib/src/generated/swagger_api.enums.swagger.dart (regenerated by melos run generate:api)Auto-emits enum ActivityType { slotBased, service }.
Mobile i18n keys search#filter_show, search#filter_movie, search#filter_diningDead keys post-edit. Recommend dropping from the i18n CSV emit at task end (per memory feedback_i18n_csv_output).

Spec scope acknowledgement (explicit, in-scope). Spec line 207 lists apps/gym_app/lib/pages/home/search_page.dart enum prune as “Verify gym_app has no MOVIE/SHOW references (low risk)” — the verification surfaced 3 dead enum entries (ActivityType.show, .movie, .dining) on lines 22-25. This ADR treats the 3-line prune as in-scope for this ticket without amending the spec, on the following reasoning: (a) total code edit is 3 lines + dropdown shrink, well below the spec-amendment threshold for scope creep; (b) the post-codegen Dart enum domain narrows to [slotBased, service], so leaving ActivityType.show/movie/dining referenced is a guaranteed flutter build break — i.e. AC-20 (flutter analyze clean) cannot be satisfied without this edit; (c) the scope line “no UI changes” was intended for “no NEW UI added” — pruning compile-broken references is removal of dead code, not new UI. Phase B/C must NOT add a new “Has seat selection” mobile filter — that is a separate ticket.

No code changes — grep clean (grep -rn "MOVIE\|'SHOW'\|CINEMA\|SHOWS" src returned 0 results). i18n strings live in Google Sheets; no Astro-page string changes.

4. Migration outline (preview only — real SQL emitted by drizzle-kit generate)

Section titled “4. Migration outline (preview only — real SQL emitted by drizzle-kit generate)”

The migration is generated by pnpm run db:generate AFTER the schema edits in D1 land. drizzle-kit emits:

  • ALTER TABLE … ADD COLUMN has_seat_selection boolean NOT NULL DEFAULT false;
  • Enum rebuild block (text-cast → DROP TYPE → CREATE TYPE → cast-back), matching 0048_clear_tattoo.sql shape.
  • ALTER TABLE activities.activities ADD CONSTRAINT activities_has_seat_selection_check CHECK (…); (drizzle emits CHECK constraints from check() helper — verified via contributors_roles_not_empty in 0038/0039).

The dev MUST hand-add the following BEFORE running pnpm run db:migrate:

-- ============================================================================
-- Activity model simplification — ticket 869dpxbj6
-- Generated SQL ABOVE this comment block; data SQL hand-added per ADR §D2/D3/D6.
-- ============================================================================
-- Step 1 — pre-flight (hand-added; drizzle never emits guards).
DO $$
BEGIN
IF (SELECT count(*) FROM activities.activities WHERE type::text = 'DINING') > 0 THEN
RAISE EXCEPTION 'activity-model-simplification: DINING rows present';
END IF;
IF (SELECT count(*) FROM activities.spheres WHERE code = 'DINING') > 0 THEN
RAISE EXCEPTION 'activity-model-simplification: DINING sphere row present';
END IF;
IF (SELECT count(*) FROM activities.activities
WHERE type::text NOT IN ('SHOW','MOVIE','SLOT_BASED','SERVICE')) > 0 THEN
RAISE EXCEPTION 'activity-model-simplification: unexpected activity_type values present';
END IF;
END $$;
--> statement-breakpoint
-- Step 2 — drizzle-emitted ADD COLUMN (verify present above this comment).
-- Step 3 — hand-added data UPDATE (BEFORE the enum rebuild).
UPDATE "activities"."activities"
SET "type" = 'SLOT_BASED', "has_seat_selection" = true
WHERE "type" = 'MOVIE';
--> statement-breakpoint
UPDATE "activities"."activities"
SET "type" = 'SLOT_BASED', "has_seat_selection" = false
WHERE "type" = 'SHOW';
--> statement-breakpoint
-- Step 4 — drizzle-emitted enum rebuild (text-cast / DROP TYPE / CREATE / cast-back).
-- Step 5 — drizzle-emitted ADD CONSTRAINT (verify present below the rebuild).
-- Step 6 — hand-added: INSERT EVENTS sphere.
INSERT INTO "activities"."spheres"
("code","name","icon","target_app",
"allowed_activity_types","default_activity_type","sort_order")
VALUES
('EVENTS',
'{"uk":"Події","en":"Events","ru":"События","de":"Events","fr":"Événements"}'::jsonb,
NULL, 'TICKETS_APP',
ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[],
'SLOT_BASED'::"activities"."activity_type",
1)
ON CONFLICT ("code") DO NOTHING;
--> statement-breakpoint
-- Step 7 — hand-added: backfill activities.
UPDATE "activities"."activities"
SET "sphere_id" = (SELECT "id" FROM "activities"."spheres" WHERE "code"='EVENTS')
WHERE "sphere_id" IN
(SELECT "id" FROM "activities"."spheres" WHERE "code" IN ('CINEMA','SHOWS'));
--> statement-breakpoint
-- Step 8 — hand-added: backfill categories.
UPDATE "activities"."categories"
SET "sphere_id" = (SELECT "id" FROM "activities"."spheres" WHERE "code"='EVENTS')
WHERE "sphere_id" IN
(SELECT "id" FROM "activities"."spheres" WHERE "code" IN ('CINEMA','SHOWS'));
--> statement-breakpoint
-- Step 9 — hand-added: delete obsolete sphere rows.
DELETE FROM "activities"."spheres" WHERE "code" IN ('CINEMA','SHOWS');
--> statement-breakpoint
-- Step 10 — hand-added: re-seed final 3 sphere rows (allowed types + default + sort_order).
-- See ADR §D3 AC-6 cross-reference: SERVICES.sort_order moves 3 → 2 to keep the
-- final ordering contiguous (0/1/2).
UPDATE "activities"."spheres"
SET "allowed_activity_types" = ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[],
"default_activity_type" = 'SLOT_BASED'::"activities"."activity_type",
"sort_order" = 0
WHERE "code" = 'SPORT';
--> statement-breakpoint
UPDATE "activities"."spheres"
SET "allowed_activity_types" = ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[],
"default_activity_type" = 'SLOT_BASED'::"activities"."activity_type",
"sort_order" = 1
WHERE "code" = 'EVENTS';
--> statement-breakpoint
UPDATE "activities"."spheres"
SET "allowed_activity_types" = ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[],
"default_activity_type" = 'SERVICE'::"activities"."activity_type",
"sort_order" = 2
WHERE "code" = 'SERVICES';

A preview-only mirror lands in drafts/migration-activity-model-simplification.sql (gitignored) for the migration-safety-check skill to review before apply.

Rollout. Single MR per consumer repo (backend, business, mobile). No feature flag — the migration is the deploy gate.

  1. Backend MR lands first: schema + DTOs + service + tests + generated 0049_<auto>.sql + 3 refreshed YAMLs in _workflow/contracts/.
  2. Business MR rebuilds against refreshed business.openapi.yaml via the manual recipe (Step A-E from ADR sphere-targetapp-enum-cleanup-drop-dining-add-services §6.2 — same gotcha applies because tools/gen/sync-business-contract.js is still absent).
  3. Web MR runs npm run generate against refreshed client.openapi.yaml; manual icon-map fix in explore.page.ts.
  4. Mobile MR runs melos run sync:spec && melos run generate:api; manual fix in search_page.dart:21-27 + favorites docstrings.

Runtime smoke (per memory feedback_runtime_smoke). After pnpm run db:migrate, npx nx serve api MUST boot; Swagger UI at http://localhost:5005/api/business/docs MUST show ActivityType: [SLOT_BASED, SERVICE] and ActivityResponseDto.hasSeatSelection: boolean; GET /api/business/spheres MUST return exactly [SPORT, EVENTS, SERVICES] (AC-21).

Rollback. Destructive.

  • MOVIE rows are recoverable from (type='SLOT_BASED', has_seat_selection=true) deterministically.
  • SHOW rows are NOT recoverable from (type='SLOT_BASED', has_seat_selection=false) because pre-existing SLOT_BASED rows (gym classes, swimming pool slots) also satisfy has_seat_selection=false. Distinguishing requires a backup or audit log dump.
  • Mitigation: take a Postgres dump (pg_dump --schema=activities) BEFORE pnpm run db:migrate lands on production. The dump is the rollback artefact.
  • No automated down.sql provided — drizzle-kit only generates forward migrations; restoration is dump-restore on a failure event.

Production gating. Per memory feedback_drizzle_migrations, this MR ships ONLY after:

  • migration-safety-check skill runs clean.
  • pnpm run sync:contracts:check returns 0.
  • Backend e2e (pnpm exec jest --testPathPattern=spheres, pnpm exec jest --testPathPattern=activities) green.
  • Smoke per feedback_runtime_smoke.
  • PRE-MIGRATION DUMP TAKENpg_dump --schema=activities $DATABASE_URL > pre-869dpxbj6.sql archived off-box. Per §5 Rollback above, SHOW rows are not deterministically distinguishable from pre-existing SLOT_BASED, has_seat_selection=false rows; the dump is the only rollback path. Required checkbox on the production deploy ticket.

Phase D documentation (out of MR scope). Per AC-23, Phase D docs-writer adds glossary entries for hasSeatSelection, the post-collapse [SLOT_BASED, SERVICE] activity type set, and the EVENTS sphere (replacing the old CINEMA/SHOWS pair). These edits live in the docs repo / generated CHANGELOG, not in the backend / business / web / mobile MRs of this ticket.

  1. activity_form.page.spec.ts:201,239-250 semantic change. The “AC-8.2: changing sphere to CINEMA clears SLOT_BASED type” scenario depended on a sphere with a restrictive allowedActivityTypes. Post-collapse all 3 spheres allow [SLOT_BASED, SERVICE], so the test loses its negative path. Phase C dev MUST replace with a different negative-path scenario (e.g., the new hasSeatSelection-vs-SERVICE conflict from AC-12) rather than just deleting. If the test gets simply dropped, AC-16 grep stays clean but test coverage shrinks. Action: spec already lists this in AC-12; verify Phase B test author wires it.

  2. SERVICES sphere defaultActivityType flip. Spec §“spheres.allowedActivityTypes update” table sets SERVICES.defaultActivityType = 'SERVICE'. Current sphere row (post-0045_rebucket_service_activities_from_sport_to_services) likely already has this, but the explicit re-seed in step 10 makes it deterministic. Pre-migration dev DB inspection (SELECT code, default_activity_type FROM activities.spheres) recommended for the migration-safety-check skill.

  3. None re. mobile UI scope creep. The search-page filter pruning is the minimum required to keep flutter build green; the spec line “no UI changes” was wrong on this point and is overridden by AC-20 + the Dart compile-break analysis in §3.5. Document in MR description.

  4. Sphere name translations are DB-resident JSONB, not i18n keys. The EVENTS sphere row’s name column is seeded with a 5-locale JSONB ({"uk":"Події","en":"Events","ru":"События","de":"Events","fr":"Évé­nements"}) directly in the migration step 6. No CSV emit needed at task end per memory feedback_i18n_csv_output — that workflow applies to mobile .arb / web/business messages.*.json keys, not to DB JSONB columns. (Mobile i18n keys search#filter_show, search#filter_movie, search#filter_dining listed in §3.5 ARE in-scope for the CSV emit because they live in .arb.)

  5. sphere_audit_log.sphere_id ON DELETE SET NULL — join-loss, not data-loss. Historical audit rows referencing the deleted CINEMA and SHOWS sphere ids will have their sphere_id column NULLed after step 9’s DELETE FROM activities.spheres WHERE code IN ('CINEMA','SHOWS'). The companion sphere_code text column on sphere_audit_log preserves the readable history ('CINEMA' / 'SHOWS' strings remain queryable). Audit queries that JOIN through sphere_id will return NULL for those rows; queries that group by sphere_code continue to work. This is expected behaviour given the FK is declared ON DELETE SET NULL, not data loss.

STATUS: READY_FOR_REVIEW