feat(db): mig 153 — proceeding_types kind discriminator + ProjectService hardening
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

Adds a `kind` column to paliad.proceeding_types (proceeding / phase /
side_action / meta) so the Mode B R3 Fristenrechner wizard, the
projects.proceeding_type_id binding, and the pkg/litigationplanner
snapshot can filter to primary proceedings only.

Implements the ratified design from docs/design-proceeding-types-
taxonomy-2026-05-26.md (m greenlit 2026-05-27 09:57 after the 11-question
AskUserQuestion round-trip).

Mig 153 is purely additive — ADD COLUMN with a safe DEFAULT, UPDATEs
reclassify 23 non-primary rows (4 phase + 10 side_action + 9 meta), and
a BEFORE INSERT/UPDATE trigger on paliad.projects backstops the new
invariant. Pre-mig audit (Supabase MCP, 2026-05-27) confirmed zero
downstream pressure on the 23 reclassified rows.

- internal/db/migrations/153_proceeding_types_kind.up.sql + .down.sql
  - snapshot to paliad.proceeding_types_pre_153 in the same TX
  - set_config('paliad.audit_reason', …) defensively
  - DO-block asserts 23 reclassified rows before the trigger ships
  - Q9 carve-out: is_active=false on every phase/side_action/meta row
  - new trigger paliad.projects_proceeding_type_kind_check on
    paliad.projects.proceeding_type_id

- internal/services/project_service.go
  - extend validateProceedingTypeCategory to also enforce
    kind='proceeding' AND is_active=true; new typed error
    ErrInvalidProceedingTypeKind
  - single SELECT picks up category + kind + is_active

- internal/services/project_service_test.go
  - TestProjectService_ProceedingTypeKindGuard covers service-layer
    rejection, the active-but-non-proceeding edge, mig 153 trigger
    backstop, and the kind='proceeding' happy path

- cmd/gen-upc-snapshot/main.go
  - filter proceeding_types query to kind='proceeding' for forward-
    compat (the embedded UPC snapshot JSON regen requires DATABASE_URL
    access and will land in a follow-up; the current placeholder is
    already empty of non-primary rows)

t-paliad-325 / m/paliad#147
This commit is contained in:
mAi
2026-05-27 10:09:33 +02:00
parent 058a36976b
commit 9d688459e3
5 changed files with 458 additions and 16 deletions

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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