// 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 + active 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. 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 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)) }