Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.
Generator (cmd/gen-upc-snapshot):
- Reads paliad's live DB (DATABASE_URL), applies pending migrations
to match schema HEAD, SELECTs the UPC subset
(proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
deadline_rules WHERE lifecycle_state='published' AND is_active=true
on those proceedings, referenced trigger_events, DE+UPC holidays,
UPC courts).
- Writes pretty-printed JSON to
pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
trigger_events, holidays, courts, meta}.json.
- Idempotent — same DB state → same output (modulo
meta.generated_at + auto-versioned suffix).
- Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
- Operator runbook in cmd/gen-upc-snapshot/README.md.
Embedded subpackage (pkg/litigationplanner/embedded/upc/):
- embed.go — //go:embed *.json + LoadMeta()
- snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
/ LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
- holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
(IsNonWorkingDay / Adjust* with structured AdjustmentReason).
- courts.go — SnapshotCourtRegistry implementing lp.CourtRegistry.
- Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
interface drift.
Wire-up for consumers:
cat, _ := upc.NewCatalog()
hc, _ := upc.NewHolidayCalendar()
cr, _ := upc.NewCourtRegistry()
timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
lp.CalcOptions{}, cat, hc, cr)
Tests (snapshot_test.go, all DB-free):
- meta parses cleanly, non-zero counts
- LoadProceeding(upc.inf.cfi) returns expected proc + rules
- LoadProceeding(unknown) returns ErrUnknownProceedingType
- LookupEvents(Jurisdiction:UPC, all-following) covers corpus
- LookupEvents(party=defendant, next) scopes anchors correctly
- engine end-to-end via lp.Calculate against the embedded snapshot
- holiday calendar (weekends, DE closures, UPC vacation block)
- court registry (empty courtID fallback, known + unknown court)
Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.
Makefile target:
make snapshot-upc — wraps the generator + reruns the snapshot tests
Design (§19 of docs/design-litigation-planner-2026-05-26.md):
- Embedding format: go:embed JSON (diff-friendly, no compile coupling)
- Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
- Versioning: meta.json carries semver + generated_at + paliad_commit
- Regeneration: manual via Make target or `go generate`; no CI cron in v1
- Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
diff tooling
Acceptance:
- go build clean, go test all green (incl. 6 new tests in
pkg/litigationplanner/embedded/upc, all DB-free)
- SnapshotCatalog passes the compile-time lp.Catalog assertion
- Generator binary builds + runs (Idempotence verified by re-running
against the same source data)
302 lines
9.9 KiB
Go
302 lines
9.9 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 + 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))
|
|
}
|