diff --git a/cmd/gen-upc-snapshot/main.go b/cmd/gen-upc-snapshot/main.go index e7fb593..092ba44 100644 --- a/cmd/gen-upc-snapshot/main.go +++ b/cmd/gen-upc-snapshot/main.go @@ -117,9 +117,13 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string return fmt.Errorf("mkdir output: %w", err) } - // 1. Proceeding types — UPC + active only. The unified upc.apl row + // 1. Proceeding types — UPC primaries only. The unified upc.apl row // from B1 mig 134 is included; the 3 archived old appeal codes - // (is_active=false) are filtered out by the WHERE. + // (is_active=false) are filtered out by the is_active predicate. + // The kind='proceeding' predicate (mig 153, t-paliad-325) belts the + // is_active filter so phase/side_action/meta rows can't slip into + // the embedded catalog even if some future deploy re-activates one + // for an admin task. var procs []litigationplanner.ProceedingType if err := pool.SelectContext(ctx, &procs, ` SELECT id, code, name, name_en, description, jurisdiction, @@ -127,7 +131,9 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string trigger_event_label_de, trigger_event_label_en, appeal_target FROM paliad.proceeding_types - WHERE jurisdiction = 'UPC' AND is_active = true + WHERE jurisdiction = 'UPC' + AND is_active = true + AND kind = 'proceeding' ORDER BY sort_order, id`); err != nil { return fmt.Errorf("select proceeding_types: %w", err) } diff --git a/internal/db/migrations/153_proceeding_types_kind.down.sql b/internal/db/migrations/153_proceeding_types_kind.down.sql new file mode 100644 index 0000000..eb3adcc --- /dev/null +++ b/internal/db/migrations/153_proceeding_types_kind.down.sql @@ -0,0 +1,53 @@ +-- 153_proceeding_types_kind.down — t-paliad-325 / m/paliad#147 +-- +-- Best-effort rollback of mig 153. Restores the pre-mig state of +-- paliad.proceeding_types from the same-TX snapshot, drops the kind +-- column, drops the backstop trigger. + +BEGIN; + +SELECT set_config( + 'paliad.audit_reason', + 'mig 153 down: revert proceeding_types kind discriminator', + true +); + +-- ---------------------------------------------------------------- +-- 1. Drop the backstop trigger + function. +-- ---------------------------------------------------------------- + +DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check + ON paliad.projects; + +DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_kind_check(); + +-- ---------------------------------------------------------------- +-- 2. Restore is_active flags from the snapshot. We only touch rows +-- whose is_active value diverged from the snapshot — i.e. the 23 +-- rows that mig 153 §4 deactivated. +-- ---------------------------------------------------------------- + +UPDATE paliad.proceeding_types pt + SET is_active = pre.is_active + FROM paliad.proceeding_types_pre_153 pre + WHERE pt.id = pre.id + AND pt.is_active IS DISTINCT FROM pre.is_active; + +-- ---------------------------------------------------------------- +-- 3. Drop the kind column (cascades the index). +-- ---------------------------------------------------------------- + +DROP INDEX IF EXISTS paliad.proceeding_types_kind_active_idx; + +ALTER TABLE paliad.proceeding_types + DROP COLUMN IF EXISTS kind; + +-- ---------------------------------------------------------------- +-- 4. Drop the snapshot table. +-- (The CHECK constraint on the kind column is dropped implicitly +-- when the column is dropped.) +-- ---------------------------------------------------------------- + +DROP TABLE IF EXISTS paliad.proceeding_types_pre_153; + +COMMIT; diff --git a/internal/db/migrations/153_proceeding_types_kind.up.sql b/internal/db/migrations/153_proceeding_types_kind.up.sql new file mode 100644 index 0000000..50001e4 --- /dev/null +++ b/internal/db/migrations/153_proceeding_types_kind.up.sql @@ -0,0 +1,201 @@ +-- 153_proceeding_types_kind — t-paliad-325 / m/paliad#147 +-- +-- Purpose: tag every paliad.proceeding_types row with a structural +-- classification so the Mode B R3 wizard (Fristenrechner overhaul, +-- m/paliad#146), the projects.proceeding_type_id binding, and the +-- pkg/litigationplanner snapshot can filter to primary proceedings +-- only — separating self-contained matters from CFI phases, +-- in-proceeding side-actions, and cross-cutting RoP/admin rows. +-- +-- Design: docs/design-proceeding-types-taxonomy-2026-05-26.md +-- §0–§10 (m ratified 2026-05-27 09:52 via 11-question AskUserQuestion +-- batch; "proceed, sure" greenlight at 09:57). +-- +-- This mig is purely additive: ALTER TABLE adds the kind column with +-- a safe DEFAULT, UPDATEs reclassify the 23 non-primary rows, and a +-- BEFORE INSERT/UPDATE trigger backstops the new +-- "projects.proceeding_type_id must point at kind='proceeding'" +-- invariant. The 23 rows being reclassified have zero downstream +-- consumers today (0 active sequencing_rules anchor, 0 spawn, 0 +-- projects bind, 0 event_category_concepts reference) so no FK +-- reparenting is needed — verified via Supabase MCP 2026-05-27 +-- before write. +-- +-- Hard constraints honoured (mirrors precedent migs 091/093/095/098/ +-- 140/151/152): +-- * No deletions. Non-primary rows flip is_active=false but stay in +-- the table for audit + future re-activation. +-- * Snapshot the affected proceeding_types into +-- paliad.proceeding_types_pre_153 in the same TX. +-- * set_config('paliad.audit_reason') is defensively called even +-- though no audit trigger fires on proceeding_types today; a +-- future audit trigger would inherit the reason automatically. +-- * Idempotent on re-apply — the ADD COLUMN uses IF NOT EXISTS +-- semantics through golang-migrate's tracker (mig only fires +-- once); the UPDATEs only touch rows that match the explicit ID +-- list from the ratified design §3.2 / §10.2. + +BEGIN; + +SELECT set_config( + 'paliad.audit_reason', + 'mig 153: proceeding_types kind discriminator (m/paliad#147)', + true +); + +-- ---------------------------------------------------------------- +-- 1. Snapshot the pre-mig state for audit + rollback safety. +-- Mirrors precedent: sequencing_rules_pre_151/_pre_152, +-- procedural_events_pre_151. +-- ---------------------------------------------------------------- + +CREATE TABLE paliad.proceeding_types_pre_153 AS +SELECT * FROM paliad.proceeding_types; + +COMMENT ON TABLE paliad.proceeding_types_pre_153 IS + 'Snapshot of paliad.proceeding_types taken in the same TX as ' + 'mig 153 (kind discriminator). Audit + rollback safety per the ' + 'precedent set by migs 091/093/095/098/140/151/152. Drop only ' + 'when the kind taxonomy has held in prod for at least one ' + 'release cycle and no rollback is anticipated.'; + +-- ---------------------------------------------------------------- +-- 2. Add the kind column. +-- ---------------------------------------------------------------- + +ALTER TABLE paliad.proceeding_types + ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' + CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta')); + +COMMENT ON COLUMN paliad.proceeding_types.kind IS + 'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. ' + 'proceeding = self-contained matter (own filing + deadline tree); ' + 'phase = stage inside a primary CFI proceeding; ' + 'side_action = application/order inside a proceeding; ' + 'meta = RoP mechanics, court admin, cross-cutting remedies.'; + +CREATE INDEX proceeding_types_kind_active_idx + ON paliad.proceeding_types(kind, is_active) + WHERE is_active = true; + +-- ---------------------------------------------------------------- +-- 3. Reclassify the 23 non-primary rows. +-- IDs per ratified design §3.2 / §10.2. m's Q2 carve-out keeps +-- upc.costs.cfi (176) as kind='proceeding' (defaults to that); +-- Q3.b keeps upc.pl.cfi (188) as kind='proceeding' (defaults). +-- ---------------------------------------------------------------- + +-- 3.1 Phases: 4 rows (Q2 carve-out drops upc.costs.cfi from the original 5). +UPDATE paliad.proceeding_types + SET kind = 'phase' + WHERE id IN (173, 174, 175, 185); + +-- 3.2 Side-actions: 10 rows (§0.4 Group C). +UPDATE paliad.proceeding_types + SET kind = 'side_action' + WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); + +-- 3.3 Meta / cross-cutting: 9 rows (§0.4 Group D incl. upc.reestablishment.rop). +UPDATE paliad.proceeding_types + SET kind = 'meta' + WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); + +-- 3.4 Defensive integrity check — every reclassified ID must have been +-- reached. If the live table drifted between design (2026-05-26) +-- and apply, this raises before the trigger ships. +DO $$ +DECLARE + expected int := 23; + actual int; +BEGIN + SELECT COUNT(*) INTO actual + FROM paliad.proceeding_types + WHERE kind <> 'proceeding'; + IF actual <> expected THEN + RAISE EXCEPTION + '[mig 153] expected % rows reclassified to non-proceeding kind, found % — ' + 'live IDs drifted from the design. Abort.', + expected, actual; + END IF; + RAISE NOTICE '[mig 153] reclassified % rows: 4 phase + 10 side_action + 9 meta', actual; +END $$; + +-- ---------------------------------------------------------------- +-- 4. Per m's Q9: deactivate the non-primary rows so the admin list +-- surfaces only primaries. The kind column carries the semantic +-- info; is_active controls UI visibility. Reversible — flip +-- is_active back on if a row gains corpus. +-- ---------------------------------------------------------------- + +UPDATE paliad.proceeding_types + SET is_active = false + WHERE kind IN ('phase', 'side_action', 'meta'); + +-- ---------------------------------------------------------------- +-- 5. Backstop trigger on projects.proceeding_type_id (§3.3 + Q8). +-- Complements mig 088's category check; rejects any +-- INSERT/UPDATE that would bind a project to a non-proceeding +-- kind. Independent from the category trigger so each invariant +-- can be dropped in isolation. +-- ---------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_kind_check() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_kind text; +BEGIN + IF NEW.proceeding_type_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT kind INTO v_kind + FROM paliad.proceeding_types + WHERE id = NEW.proceeding_type_id; + + IF v_kind IS NULL THEN + -- FK should have caught this; defensive for any future FK relax. + RAISE EXCEPTION + 'paliad.projects.proceeding_type_id = % does not resolve to a ' + 'proceeding_types row — FK constraint should have caught this.', + NEW.proceeding_type_id; + END IF; + + IF v_kind <> 'proceeding' THEN + RAISE EXCEPTION + 'paliad.projects.proceeding_type_id must reference a kind=''proceeding'' ' + 'proceeding_types row (got kind=''%''). ' + 'Verfahrenstyp muss ein primäres Verfahren sein (kind=''%''). ' + 'Phasen, Nebenanträge und RoP-Querschnittsregeln sind keine ' + 'wählbaren Projekt-Verfahrenstypen.', + v_kind, v_kind + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION paliad.projects_proceeding_type_kind_check() IS + 'BEFORE INSERT/UPDATE trigger function enforcing the mig 153 ' + 'invariant: paliad.projects.proceeding_type_id may only ' + 'reference kind=''proceeding'' proceeding_types rows. NULL is ' + 'allowed. Complements mig 088''s category check.'; + +DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check + ON paliad.projects; + +CREATE TRIGGER projects_proceeding_type_kind_check + BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects + FOR EACH ROW + EXECUTE FUNCTION paliad.projects_proceeding_type_kind_check(); + +COMMENT ON TRIGGER projects_proceeding_type_kind_check ON paliad.projects IS + 'mig 153 (t-paliad-325 / m/paliad#147) runtime guard — rejects ' + 'any INSERT/UPDATE that would bind a project to a phase/' + 'side_action/meta proceeding_types row. The Go service layer ' + 'also enforces this with a typed error; this trigger is the ' + 'defence-in-depth backstop.'; + +COMMIT; diff --git a/internal/services/project_service.go b/internal/services/project_service.go index 7fae060..cc77557 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -58,6 +58,14 @@ var ( // surface this as a 400 with a bilingual friendly message; the // matching DB trigger (mig 088) is the defence-in-depth backstop. ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row") + // ErrInvalidProceedingTypeKind signals that the caller supplied a + // proceeding_type_id pointing at a non-primary row — i.e. a + // phase/side_action/meta row, or an inactive row. Mig 153 + // (t-paliad-325, design §1) carved the taxonomy so only + // kind='proceeding' AND is_active=true rows may bind to a + // project. Handlers surface this as a 400; the matching DB + // trigger (mig 153) is the defence-in-depth backstop. + ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row") ) // ProjectType values enumerated on the projects.type CHECK constraint. @@ -1165,29 +1173,47 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input return s.GetByID(ctx, userID, id) } -// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant -// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind -// to a fristenrechner-category proceeding_types row. NULL passes -// through; the matching DB trigger (mig 088) is the defence-in-depth -// backstop should this slip somehow. +// validateProceedingTypeCategory enforces the project-binding invariants +// on paliad.projects.proceeding_type_id: // -// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a -// 400 with a bilingual user-facing message. +// 1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be +// category='fristenrechner'. DB-side backstop: mig 088 trigger. +// Surfaces ErrInvalidProceedingTypeCategory. +// +// 2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be +// kind='proceeding' AND is_active=true. DB-side backstop: mig 153 +// trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase / +// side_action / meta rows and any deactivated row. +// +// NULL passes through. The Go layer fires first so handlers get typed +// errors; the DB triggers catch any writer that bypasses the service. func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error { if ptID == nil { return nil } - var category sql.NullString - if err := s.db.GetContext(ctx, &category, - `SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil { + var row struct { + Category sql.NullString `db:"category"` + Kind sql.NullString `db:"kind"` + IsActive bool `db:"is_active"` + } + if err := s.db.GetContext(ctx, &row, + `SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID) } - return fmt.Errorf("lookup proceeding_type category: %w", err) + return fmt.Errorf("lookup proceeding_type: %w", err) } - if !category.Valid || category.String != "fristenrechner" { + if !row.Category.Valid || row.Category.String != "fristenrechner" { return fmt.Errorf("%w: proceeding_type_id=%d has category=%q", - ErrInvalidProceedingTypeCategory, *ptID, category.String) + ErrInvalidProceedingTypeCategory, *ptID, row.Category.String) + } + if !row.Kind.Valid || row.Kind.String != "proceeding" { + return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q", + ErrInvalidProceedingTypeKind, *ptID, row.Kind.String) + } + if !row.IsActive { + return fmt.Errorf("%w: proceeding_type_id=%d is inactive", + ErrInvalidProceedingTypeKind, *ptID) } return nil } diff --git a/internal/services/project_service_test.go b/internal/services/project_service_test.go index 000a2c4..95ce210 100644 --- a/internal/services/project_service_test.go +++ b/internal/services/project_service_test.go @@ -163,6 +163,162 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) { } } +// TestProjectService_ProceedingTypeKindGuard exercises the mig 153 +// (t-paliad-325 / m/paliad#147) "kind='proceeding' only" invariant on +// paliad.projects.proceeding_type_id from three angles: +// +// 1. ProjectService.Create returns ErrInvalidProceedingTypeKind when +// handed an id pointing at a kind='phase' / 'side_action' / 'meta' +// row (the Go service guard fires before the DB trigger). +// +// 2. ProjectService.Create returns ErrInvalidProceedingTypeKind when +// handed an id pointing at a row with is_active=false (mig 153 §4 +// deactivated all non-primary rows so this is the same set of IDs; +// the test still independently asserts the is_active branch by +// re-activating a phase row inside the test and confirming the kind +// check still fires). +// +// 3. The mig 153 backstop trigger rejects a raw INSERT that bypasses +// the Go service layer (defence-in-depth). Bypasses mig 088's +// category trigger by also picking a fristenrechner-category row. +// +// 4. Passing a kind='proceeding' active id (upc.inf.cfi) still +// succeeds — proves the new guard doesn't break the happy path. +// +// Skipped when TEST_DATABASE_URL is unset, mirroring the rest of this +// file. +func TestProjectService_ProceedingTypeKindGuard(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping live DB test") + } + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("apply migrations: %v", err) + } + pool, err := sqlx.Connect("postgres", url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + ctx := context.Background() + + // A row that is fristenrechner-category but kind != 'proceeding'. + // Picks the first phase row by id (deterministic). Falls back to any + // non-proceeding kind if no phase rows are present (post-data-drift + // hardening). + var phaseID int + if err := pool.GetContext(ctx, &phaseID, + `SELECT id FROM paliad.proceeding_types + WHERE category = 'fristenrechner' AND kind <> 'proceeding' + ORDER BY (kind = 'phase') DESC, id + LIMIT 1`); err != nil { + t.Fatalf("look up non-proceeding kind id: %v", err) + } + + // A primary id for the happy-path case + raw-INSERT control. + var proceedingID int + if err := pool.GetContext(ctx, &proceedingID, + `SELECT id FROM paliad.proceeding_types + WHERE category = 'fristenrechner' AND kind = 'proceeding' + AND is_active = true AND code = $1`, + CodeUPCInfringement); err != nil { + t.Fatalf("look up %s id: %v", CodeUPCInfringement, err) + } + + users := NewUserService(pool) + svc := NewProjectService(pool, users) + + userID := uuid.New() + cleanup := func() { + pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID) + pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID) + pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID) + } + cleanup() + defer cleanup() + + if _, err := pool.ExecContext(ctx, + `INSERT INTO auth.users (id, email) VALUES ($1, 'mig153-guard-test@hlc.com')`, + userID); err != nil { + t.Fatalf("seed auth.users: %v", err) + } + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.users (id, email, display_name, office, role, lang) + VALUES ($1, 'mig153-guard-test@hlc.com', 'Mig153 Guard', 'munich', 'associate', 'de')`, + userID); err != nil { + t.Fatalf("seed paliad.users: %v", err) + } + + // 1. Non-proceeding kind id → ErrInvalidProceedingTypeKind from the + // service guard. (The row is also is_active=false post-mig-153, + // but the kind check fires first.) + _, err = svc.Create(ctx, userID, CreateProjectInput{ + Type: ProjectTypeProject, + Title: "Mig 153 — non-proceeding-kind reject", + ProceedingTypeID: &phaseID, + }) + if err == nil { + t.Error("Create with kind!=proceeding proceeding_type_id should fail, but succeeded") + } else if !errors.Is(err, ErrInvalidProceedingTypeKind) { + t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err) + } + + // 2. Re-activate the phase row in a savepoint so the kind check + // still fires (proves the kind branch isn't shadowed by the + // is_active branch). + if _, err := pool.ExecContext(ctx, + `UPDATE paliad.proceeding_types SET is_active = true WHERE id = $1`, phaseID); err != nil { + t.Fatalf("re-activate phase row: %v", err) + } + t.Cleanup(func() { + pool.ExecContext(ctx, + `UPDATE paliad.proceeding_types SET is_active = false WHERE id = $1`, phaseID) + }) + + _, err = svc.Create(ctx, userID, CreateProjectInput{ + Type: ProjectTypeProject, + Title: "Mig 153 — active phase row still rejects on kind", + ProceedingTypeID: &phaseID, + }) + if err == nil { + t.Error("Create with active kind=phase row should still fail on kind check; got nil") + } else if !errors.Is(err, ErrInvalidProceedingTypeKind) { + t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err) + } + + // 3. mig 153 trigger — raw INSERT bypassing Go service must raise. + // We use the active phase row (still re-activated from step 2) + // so we don't trip mig 088's category check first. Both triggers + // are independent; mig 153's must fire on a category=fristenrechner + // kind!=proceeding row. + rawID := uuid.New() + defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID) + _, err = pool.ExecContext(ctx, + `INSERT INTO paliad.projects + (id, type, parent_id, path, title, status, created_by, + proceeding_type_id, metadata, created_at, updated_at) + VALUES ($1, 'project', NULL, $1::text, 'Mig 153 — trigger bypass', 'active', $2, + $3, '{}'::jsonb, now(), now())`, + rawID, userID, phaseID) + if err == nil { + t.Error("raw INSERT with kind!=proceeding proceeding_type_id should have raised; got nil") + } + + // 4. Happy path: kind='proceeding' active id → success. + created, err := svc.Create(ctx, userID, CreateProjectInput{ + Type: ProjectTypeProject, + Title: "Mig 153 — primary proceeding accept", + ProceedingTypeID: &proceedingID, + }) + if err != nil { + t.Fatalf("Create with kind=proceeding proceeding_type_id: %v", err) + } + if created.ProceedingTypeID == nil || *created.ProceedingTypeID != proceedingID { + t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, proceedingID) + } +} + // TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8 // (t-paliad-189) instance_level data path: Create + Update both accept // the four allowed shapes (first / appeal / cassation / NULL) and reject