feat(t-paliad-182): models + service compat-read for unified rules

Phase 3 Slice 1 Go-side of mig 078–080. Compat-mode reads: the
service selects BOTH the legacy shape (is_mandatory, is_optional,
condition_flag, condition_rule_id) and the new shape (priority,
condition_expr, is_court_set, trigger_event_id,
spawn_proceeding_type_id, combine_op, lifecycle_state, draft_of,
published_at). Existing callers stay on the legacy fields until
Slice 4 cuts the calculator over.

Adds:
  - DeadlineRule field block for the nine Phase 3 columns. NULLable
    jsonb (condition_expr) uses NullableJSON to dodge the
    json.RawMessage NULL-scan trap (see Project.Metadata note from
    t-paliad-138 dogfood).
  - Project.InstanceLevel *string.
  - DeadlineRuleAudit row struct (id, rule_id, changed_by,
    changed_at, action, before_json, after_json, reason,
    migration_exported).
  - ruleColumns const extended to project every new column.

Test (TEST_DATABASE_URL-gated, mirrors audit_service_test.go):
  1. ruleColumns SELECT scans cleanly — every new column populates
     its Go field.
  2. Migration defaults land: priority='mandatory',
     is_court_set=false, lifecycle_state='published' on every
     pre-Slice-1 row.
  3. Audit trigger writes one row on UPDATE WITH paliad.audit_reason
     set, captures before+after JSON + reason.
  4. Audit trigger RAISES on UPDATE WITHOUT paliad.audit_reason —
     Slice 2 backfills fail loudly if they forget to set it.
  5. paliad.projects.instance_level accepts NULL + first/appeal/
     cassation, rejects 'final'.

Build clean, full test suite green (live DB test skipped locally).
This commit is contained in:
mAi
2026-05-15 00:19:49 +02:00
parent bd8ec42b80
commit 1f8230b264
3 changed files with 334 additions and 1 deletions

View File

@@ -171,6 +171,16 @@ type Project struct {
// sibling under the same patent (§4.4 of the design doc).
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
// InstanceLevel is the procedural instance the project sits at:
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
// proceeding code + jurisdiction by FristenrechnerService to pick
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
// NULL = unset / not applicable; the calculator treats NULL as
// 'first'. Backfill happens via the project-detail picker UI
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
// service rewrite (mig 080, t-paliad-182).
InstanceLevel *string `db:"instance_level" json:"instance_level,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -500,6 +510,100 @@ type DeadlineRule struct {
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// ---------------------------------------------------------------
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
// Populated by Slice 2 backfill; readers are compat-mode (read
// both shapes) until Slice 4 cuts the calculator over and Slice 9
// drops the legacy columns above (IsMandatory, IsOptional,
// ConditionFlag, ConditionRuleID).
// ---------------------------------------------------------------
// TriggerEventID points at paliad.trigger_events when this rule is
// event-rooted (Pipeline C unification, design §2.5). NULL on
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
// trigger_event_id) is set after Slice 3.
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
// SpawnProceedingTypeID is the cross-proceeding spawn target —
// when is_spawn=true and this is non-NULL, the calculator follows
// the FK and emits the target proceeding's root rule chain. Slice
// 7 backfills the 8 live is_spawn=true rows.
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
// CombineOp is 'max' or 'min' for composite-rule arithmetic
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
// NULL = single-anchor arithmetic.
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
// ConditionExpr is the jsonb gating expression replacing
// ConditionFlag (design §2.4). Grammar:
// {"flag": "<name>"}
// {"op":"and"|"or", "args":[<node>, ...]}
// {"op":"not", "args":[<node>]}
// NULL or {} = unconditional. NullableJSON so a NULL column scans
// cleanly (the row mishap that hid approval rows from the inbox
// must not recur on rule rows).
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
// Priority is the 4-way unified enum replacing
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
// 'recommended', 'optional', 'informational'. Backfilled in
// Slice 2; legacy callers read IsMandatory + IsOptional until
// Slice 4 cuts them over.
Priority string `db:"priority" json:"priority"`
// IsCourtSet replaces the runtime heuristic
// (primary_party='court' OR event_type IN ('hearing','decision',
// 'order')). Backfilled in Slice 2; legacy callers read the
// heuristic until Slice 4.
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
// LifecycleState drives the rule-editor flow (design §4.2):
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
// visible) | 'archived' (historical, retained for audit). Every
// pre-Slice-1 row defaults to 'published' via the migration.
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
// DraftOf points at the published rule this draft will replace on
// publish. NULL on published / archived rows. NULL also on net-
// new drafts that have no prior published peer.
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
// PublishedAt records when the row entered LifecycleState='published'.
// NULL while draft, set on publish, retained through archive.
// Distinct from UpdatedAt (moves on every edit).
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
// append-only audit log for every change to paliad.deadline_rules.
// Written by the AFTER-trigger (raw create / update / delete) and by
// the Go rule-editor service (semantic publish / archive / restore).
// See migration 079 and design-fristen-phase2-2026-05-15.md §2.8.
type DeadlineRuleAudit struct {
ID uuid.UUID `db:"id" json:"id"`
RuleID uuid.UUID `db:"rule_id" json:"rule_id"`
ChangedBy *uuid.UUID `db:"changed_by" json:"changed_by,omitempty"`
ChangedAt time.Time `db:"changed_at" json:"changed_at"`
// Action is one of: create | update | delete (trigger-written) |
// publish | archive | restore (Go-written by the rule editor).
Action string `db:"action" json:"action"`
// BeforeJSON is the row state pre-change (NULL on 'create').
// AfterJSON is the row state post-change (NULL on 'delete').
BeforeJSON NullableJSON `db:"before_json" json:"before_json,omitempty"`
AfterJSON NullableJSON `db:"after_json" json:"after_json,omitempty"`
// Reason is required on update / delete (the trigger raises if
// paliad.audit_reason is unset). On create the trigger defaults
// to 'create' so seed migrations don't need to bother.
Reason string `db:"reason" json:"reason"`
// MigrationExported flips to true once the Slice 11b export
// endpoint folds this delta into a checked-in .up.sql.
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
}
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter

View File

@@ -21,12 +21,25 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}
}
// ruleColumns lists every column scanned into models.DeadlineRule.
//
// Compat-mode (t-paliad-182 Phase 3 Slice 1): the SELECT reads BOTH
// the legacy shape (is_mandatory, is_optional, condition_flag,
// condition_rule_id) and the unified Phase 3 shape (trigger_event_id,
// spawn_proceeding_type_id, combine_op, condition_expr, priority,
// is_court_set, lifecycle_state, draft_of, published_at). Existing
// callers stay on the legacy fields; the new fields are NULL or carry
// their migration default until Slice 2 backfills them. Slice 4 cuts
// the calculator over to the new fields, Slice 9 drops the legacy
// columns.
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
description, primary_party, event_type, is_mandatory, duration_value,
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
created_at, updated_at`
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`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`

View File

@@ -0,0 +1,216 @@
package services
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3
// Slice 1 (mig 078080, t-paliad-182) additive-schema landing.
//
// What it validates:
//
// 1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id,
// combine_op, condition_expr, priority, is_court_set,
// lifecycle_state, draft_of, published_at) is present on
// paliad.deadline_rules after migrations apply and scans cleanly
// into models.DeadlineRule.
//
// 2. The default migration values land: priority='mandatory',
// is_court_set=false, lifecycle_state='published' on every pre-
// Slice-1 row. New rows default the same way.
//
// 3. The audit trigger fires on UPDATE — exactly one
// paliad.deadline_rule_audit row is written for an UPDATE that
// supplies a reason via SET LOCAL paliad.audit_reason.
//
// 4. The audit trigger raises when paliad.audit_reason is unset on
// UPDATE — Slice 2 backfills MUST set the reason or they fail
// loudly.
//
// 5. paliad.projects.instance_level (mig 080) accepts NULL and the
// three CHECK-allowed values, and rejects anything else.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
func TestDeadlineRuleService_UnifiedColumns_CompatRead(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()
svc := NewDeadlineRuleService(pool)
// -------------------------------------------------------------------
// 1. SELECT every column via the service's ruleColumns list. The list
// must end the test green even though it now includes the Phase 3
// columns; if a scan error pops up we know a column name or Go
// type slipped.
// -------------------------------------------------------------------
rules, err := svc.List(ctx, nil)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rules) == 0 {
t.Fatal("no rules returned; seed-data missing?")
}
// 2. Every pre-Slice-1 row should carry the migration defaults.
for _, r := range rules {
if r.Priority != "mandatory" {
t.Errorf("rule %s: priority=%q, want default 'mandatory'", r.ID, r.Priority)
}
if r.IsCourtSet {
t.Errorf("rule %s: is_court_set=true, want default false", r.ID)
}
if r.LifecycleState != "published" {
t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState)
}
}
// -------------------------------------------------------------------
// 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx
// so SET LOCAL is scoped to this test.
// -------------------------------------------------------------------
// Pick any existing rule; we'll UPDATE its updated_at field with a
// no-op-equivalent change (twice — once with reason, once without).
target := rules[0]
// Count the audit rows for this rule before we touch it.
var beforeCount int
if err := pool.GetContext(ctx, &beforeCount,
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
t.Fatalf("count audit rows pre-update: %v", err)
}
// 3a. UPDATE WITH reason set — should succeed and write one audit row.
tx, err := pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil {
tx.Rollback()
t.Fatalf("set audit reason: %v", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil {
tx.Rollback()
t.Fatalf("update with reason: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit update-with-reason tx: %v", err)
}
var afterCount int
if err := pool.GetContext(ctx, &afterCount,
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
t.Fatalf("count audit rows post-update: %v", err)
}
if afterCount != beforeCount+1 {
t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount)
}
// Look up the audit row we just wrote: latest by changed_at, action='update'.
var (
auditAction string
auditReason string
auditBefore json.RawMessage
auditAfter json.RawMessage
)
if err := pool.QueryRowxContext(ctx,
`SELECT action, reason, before_json, after_json
FROM paliad.deadline_rule_audit
WHERE rule_id = $1
ORDER BY changed_at DESC
LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil {
t.Fatalf("read latest audit row: %v", err)
}
if auditAction != "update" {
t.Errorf("audit action=%q, want 'update'", auditAction)
}
if auditReason != "test: compat-read audit smoke" {
t.Errorf("audit reason=%q, want the set_config value", auditReason)
}
if len(auditBefore) == 0 || len(auditAfter) == 0 {
t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter)
}
// 4. UPDATE WITHOUT reason — trigger must raise.
tx2, err := pool.BeginTxx(ctx, nil)
if err != nil {
t.Fatalf("begin tx2: %v", err)
}
_, err = tx2.ExecContext(ctx,
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID)
tx2.Rollback()
if err == nil {
t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded")
}
// -------------------------------------------------------------------
// 5. paliad.projects.instance_level CHECK.
// -------------------------------------------------------------------
userID := uuid.New()
projectID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
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, 'instance-level-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, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level)
VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`,
projectID, userID); err != nil {
t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err)
}
// Update to each allowed value should succeed; bogus value must fail.
for _, lvl := range []string{"first", "cassation", "appeal"} {
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil {
t.Errorf("update instance_level=%q: %v", lvl, err)
}
}
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil {
t.Error("instance_level='final' should violate CHECK constraint, but succeeded")
}
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil {
t.Errorf("NULL instance_level should be allowed: %v", err)
}
}