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
308 lines
10 KiB
Go
308 lines
10 KiB
Go
// Command gen-upc-snapshot reads paliad's live deadline corpus and
|
|
// writes the UPC subset as JSON files under
|
|
// pkg/litigationplanner/embedded/upc/. The package's embedded
|
|
// catalog/holiday/court implementations then serve this data without
|
|
// any DB roundtrip — letting youpc.org (or any future consumer) run
|
|
// the litigationplanner engine against the canonical UPC rule set.
|
|
//
|
|
// Slice C (m/paliad#124 §19). See docs/design-litigation-planner-2026-05-26.md
|
|
// §19 for the full design.
|
|
//
|
|
// Usage:
|
|
//
|
|
// DATABASE_URL=postgres://... go run ./cmd/gen-upc-snapshot \
|
|
// [-output ./pkg/litigationplanner/embedded/upc] \
|
|
// [-version 2026-05-26-1] \
|
|
// [-source-label paliad-dev-supabase]
|
|
//
|
|
// The generator applies migrations against DATABASE_URL before
|
|
// SELECTing (so the snapshot always matches schema HEAD). Idempotent —
|
|
// running twice with the same DB state produces the same JSON.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
"mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
|
)
|
|
|
|
const (
|
|
defaultOutput = "./pkg/litigationplanner/embedded/upc"
|
|
defaultSourceLabel = ""
|
|
)
|
|
|
|
// Meta is the version block written to meta.json. The embedded sub-
|
|
// package re-defines this type so consumers can decode it without
|
|
// importing the cmd; the cmd holds the canonical write shape.
|
|
type Meta struct {
|
|
Version string `json:"version"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
PaliadCommit string `json:"paliad_commit,omitempty"`
|
|
SourceDBLabel string `json:"source_db_label,omitempty"`
|
|
RuleCount int `json:"rule_count"`
|
|
ProceedingCount int `json:"proceeding_count"`
|
|
TriggerEventCount int `json:"trigger_event_count"`
|
|
HolidayCount int `json:"holiday_count"`
|
|
CourtCount int `json:"court_count"`
|
|
}
|
|
|
|
// EmbeddedHoliday is the holiday row shape the embedded snapshot
|
|
// stores. JSON tags mirror paliad.holidays so the generator's SELECT
|
|
// scans onto it directly + the embedded HolidayCalendar reads the
|
|
// same tag.
|
|
type EmbeddedHoliday struct {
|
|
Date string `db:"date_iso" json:"date"`
|
|
Name string `db:"name" json:"name"`
|
|
Country *string `db:"country" json:"country,omitempty"`
|
|
Regime *string `db:"regime" json:"regime,omitempty"`
|
|
State *string `db:"state" json:"state,omitempty"`
|
|
HolidayType string `db:"holiday_type" json:"holiday_type"`
|
|
}
|
|
|
|
// EmbeddedCourt is the court row shape the embedded snapshot stores.
|
|
type EmbeddedCourt struct {
|
|
ID string `db:"id" json:"id"`
|
|
Code string `db:"code" json:"code"`
|
|
NameDE string `db:"name_de" json:"name_de"`
|
|
NameEN string `db:"name_en" json:"name_en"`
|
|
Country string `db:"country" json:"country"`
|
|
Regime *string `db:"regime" json:"regime,omitempty"`
|
|
CourtType string `db:"court_type" json:"court_type"`
|
|
ParentID *string `db:"parent_id" json:"parent_id,omitempty"`
|
|
SortOrder int `db:"sort_order" json:"sort_order"`
|
|
}
|
|
|
|
func main() {
|
|
output := flag.String("output", defaultOutput, "directory to write JSON files into")
|
|
version := flag.String("version", "", "explicit snapshot version (auto-derived if empty)")
|
|
sourceLabel := flag.String("source-label", defaultSourceLabel, "label for source_db in meta.json")
|
|
flag.Parse()
|
|
|
|
url := os.Getenv("DATABASE_URL")
|
|
if url == "" {
|
|
log.Fatal("DATABASE_URL must be set")
|
|
}
|
|
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
log.Fatalf("apply migrations: %v", err)
|
|
}
|
|
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
log.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
if err := run(ctx, pool, *output, *version, *sourceLabel); err != nil {
|
|
log.Fatalf("snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string) error {
|
|
if err := os.MkdirAll(output, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir output: %w", err)
|
|
}
|
|
|
|
// 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 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,
|
|
category, default_color, sort_order, is_active,
|
|
trigger_event_label_de, trigger_event_label_en,
|
|
appeal_target
|
|
FROM paliad.proceeding_types
|
|
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)
|
|
}
|
|
|
|
if len(procs) == 0 {
|
|
return fmt.Errorf("no active UPC proceeding_types — refusing to write empty snapshot")
|
|
}
|
|
|
|
procIDs := make([]int, 0, len(procs))
|
|
for _, p := range procs {
|
|
procIDs = append(procIDs, p.ID)
|
|
}
|
|
|
|
// 2. Deadline rules — published + active rules for those proceedings.
|
|
const ruleCols = `id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
|
description, primary_party, event_type, duration_value,
|
|
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
|
alt_duration_value, alt_duration_unit, alt_rule_code,
|
|
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
|
|
created_at, updated_at,
|
|
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
|
priority, is_court_set, lifecycle_state, draft_of, published_at,
|
|
choices_offered, applies_to_target`
|
|
|
|
q, args, err := sqlx.In(`
|
|
SELECT `+ruleCols+`
|
|
FROM paliad.deadline_rules
|
|
WHERE proceeding_type_id IN (?)
|
|
AND is_active = true
|
|
AND lifecycle_state = 'published'
|
|
ORDER BY proceeding_type_id, sequence_order`, procIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("build rules IN: %w", err)
|
|
}
|
|
q = pool.Rebind(q)
|
|
var rules []litigationplanner.Rule
|
|
if err := pool.SelectContext(ctx, &rules, q, args...); err != nil {
|
|
return fmt.Errorf("select rules: %w", err)
|
|
}
|
|
|
|
// 3. Trigger events referenced by any UPC rule's trigger_event_id.
|
|
triggerIDSet := make(map[int64]struct{})
|
|
for _, r := range rules {
|
|
if r.TriggerEventID != nil {
|
|
triggerIDSet[*r.TriggerEventID] = struct{}{}
|
|
}
|
|
}
|
|
var triggers []litigationplanner.TriggerEvent
|
|
if len(triggerIDSet) > 0 {
|
|
triggerIDs := make([]int64, 0, len(triggerIDSet))
|
|
for id := range triggerIDSet {
|
|
triggerIDs = append(triggerIDs, id)
|
|
}
|
|
q, args, err := sqlx.In(`
|
|
SELECT id, code, name, name_de, description, is_active, created_at
|
|
FROM paliad.trigger_events
|
|
WHERE id IN (?)
|
|
ORDER BY id`, triggerIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("build triggers IN: %w", err)
|
|
}
|
|
q = pool.Rebind(q)
|
|
if err := pool.SelectContext(ctx, &triggers, q, args...); err != nil {
|
|
return fmt.Errorf("select trigger_events: %w", err)
|
|
}
|
|
}
|
|
|
|
// 4. Holidays — DE national + UPC regime entries. The embedded
|
|
// calendar serves UPC computations so both axes matter.
|
|
var holidays []EmbeddedHoliday
|
|
if err := pool.SelectContext(ctx, &holidays, `
|
|
SELECT to_char(date, 'YYYY-MM-DD') AS date_iso,
|
|
name, country, regime, state, holiday_type
|
|
FROM paliad.holidays
|
|
WHERE country = 'DE' OR regime = 'UPC'
|
|
ORDER BY date, name`); err != nil {
|
|
return fmt.Errorf("select holidays: %w", err)
|
|
}
|
|
|
|
// 5. Courts — UPC subset.
|
|
var courts []EmbeddedCourt
|
|
if err := pool.SelectContext(ctx, &courts, `
|
|
SELECT id, code, name_de, name_en, country, regime, court_type, parent_id, sort_order
|
|
FROM paliad.courts
|
|
WHERE is_active = true
|
|
AND (regime = 'UPC' OR court_type LIKE 'upc%')
|
|
ORDER BY sort_order, id`); err != nil {
|
|
return fmt.Errorf("select courts: %w", err)
|
|
}
|
|
|
|
// 6. Compose meta.
|
|
meta := Meta{
|
|
Version: resolveVersion(version, output),
|
|
GeneratedAt: time.Now().UTC().Truncate(time.Second),
|
|
PaliadCommit: gitCommitShort(),
|
|
SourceDBLabel: sourceLabel,
|
|
RuleCount: len(rules),
|
|
ProceedingCount: len(procs),
|
|
TriggerEventCount: len(triggers),
|
|
HolidayCount: len(holidays),
|
|
CourtCount: len(courts),
|
|
}
|
|
|
|
// 7. Write each file.
|
|
files := []struct {
|
|
name string
|
|
data any
|
|
}{
|
|
{"proceeding_types.json", procs},
|
|
{"rules.json", rules},
|
|
{"trigger_events.json", triggers},
|
|
{"holidays.json", holidays},
|
|
{"courts.json", courts},
|
|
{"meta.json", meta},
|
|
}
|
|
for _, f := range files {
|
|
path := filepath.Join(output, f.name)
|
|
buf, err := json.MarshalIndent(f.data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal %s: %w", f.name, err)
|
|
}
|
|
buf = append(buf, '\n')
|
|
if err := os.WriteFile(path, buf, 0o644); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
}
|
|
|
|
log.Printf("snapshot written: version=%s rules=%d proceedings=%d triggers=%d holidays=%d courts=%d → %s",
|
|
meta.Version, meta.RuleCount, meta.ProceedingCount,
|
|
meta.TriggerEventCount, meta.HolidayCount, meta.CourtCount, output)
|
|
return nil
|
|
}
|
|
|
|
// resolveVersion picks a date-stamped version slug, bumping the suffix
|
|
// past any pre-existing same-day version found in the existing
|
|
// meta.json. If the caller passed -version, that wins.
|
|
func resolveVersion(explicit, output string) string {
|
|
if explicit != "" {
|
|
return explicit
|
|
}
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
// Read prior meta to detect same-day collisions.
|
|
prior, err := os.ReadFile(filepath.Join(output, "meta.json"))
|
|
if err != nil {
|
|
return today + "-1"
|
|
}
|
|
var pm Meta
|
|
if err := json.Unmarshal(prior, &pm); err != nil {
|
|
return today + "-1"
|
|
}
|
|
if !strings.HasPrefix(pm.Version, today+"-") {
|
|
return today + "-1"
|
|
}
|
|
// Same day: bump the suffix.
|
|
suffix := pm.Version[len(today)+1:]
|
|
var n int
|
|
if _, err := fmt.Sscanf(suffix, "%d", &n); err != nil {
|
|
return today + "-1"
|
|
}
|
|
return fmt.Sprintf("%s-%d", today, n+1)
|
|
}
|
|
|
|
// gitCommitShort returns the short SHA of the paliad checkout. Best-
|
|
// effort — empty string when we're not in a git checkout.
|
|
func gitCommitShort() string {
|
|
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|