Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
38ebccc907 feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / m/paliad#93)
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
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.

* internal/services/dual_write.go (new) —
  - syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
    legal_sources + procedural_events + sequencing_rules from the
    just-written deadline_rules row. Pure SQL projection, no Go-side
    struct mapping. Synthetic-code mint expression is byte-identical
    to mig 136 ('null.' || first 8 hex of stripped uuid).
  - syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
    legacy rule_id back-link onto deadlines.procedural_event_id +
    sequencing_rule_id. Handles NULL rule_id naturally (collapses both
    new columns to NULL).
  - CheckDualWriteDrift(ctx, conn): nine read-only count queries +
    integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
    log routing.
  - StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
    that runs CheckDualWriteDrift every `interval` (default 6h) for
    the lifetime of ctx. Clean run logs at INFO; drift at WARN with
    full report.

* internal/services/rule_editor_service.go —
  - Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
    each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
    deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
    published draft AND the cloned-from peer it just archived as a
    cascade. The audit_reason already set via setAuditReasonTx
    propagates to the new-table writes (same TX, same session).

* internal/services/rule_editor_orphans.go —
  - ResolveOrphan calls syncDeadlineDualLinks after UPDATE
    paliad.deadlines SET rule_id = $1, so the parallel new columns
    follow the legacy back-link.

* internal/services/deadline_service.go —
  - DeadlineService.Update calls syncDeadlineDualLinks when
    input.RuleSet is true (auto/custom rule swap from t-paliad-258).

* cmd/server/main.go —
  - Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
    reminder scanner. Inherits bgCtx so the goroutine stops on
    SIGTERM. Interval 6h.

* internal/services/dual_write_test.go (new) —
  - TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
    → Archive, asserts the new tables mirror at each step. Final
    CheckDualWriteDrift returns zero drift.
  - TestDualWrite_SyntheticCodeForNullSubmission: rule created with
    submission_code=NULL gets a 'null.<8hex>' procedural_events row
    matching mig 136's mint expression byte-for-byte.

Scope decisions documented in the commit:

- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
  back to legacy" reads as "reads stay on legacy as the safety net
  while drift-check validates the new tables". B.3 swaps reads to
  new tables only AND stops writing to deadline_rules — that's a
  separate slice per the design's §5.2/§5.3 split.

- B.2 does NOT modify submission_drafts, projection_service, the
  Fristenrechner calculator, the SubmissionVarsService, the
  Schriftsätze list query, or any other reader. They keep reading
  deadline_rules unchanged. The new tables are populated in parallel
  for B.3's cutover.

- Audit triggers on deadline_rules continue to fire as before. The
  new tables have no audit triggers yet (a later slice can add
  parallel audit rows once the new tables are authoritative).

- Drift-check uses default 6h interval — short enough that a broken
  dual-write surfaces within the same business day, long enough that
  the count-COUNTs don't churn the pool. Override via the caller in
  cmd/server.

Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
  (existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
  call twice, safe to re-run after a partial failure.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:49:48 +02:00
18 changed files with 755 additions and 1281 deletions

View File

@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"syscall"
"time"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
@@ -221,8 +222,6 @@ func main() {
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
Scenario: services.NewScenarioService(pool, projectSvc, rules),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
@@ -339,6 +338,13 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
// Runs every 6 h while the new procedural_events / sequencing_rules /
// legal_sources tables shadow the legacy paliad.deadline_rules
// table. A clean run logs at INFO; drift logs at WARN with the
// full report so a broken dual-write surfaces before the next
// deploy.
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

View File

@@ -1,13 +0,0 @@
-- 145_scenarios — DOWN
--
-- Reverses mig 145. Drops the FK on paliad.projects, the table, the
-- trigger function, and the RLS policies (CASCADE on table drop kills
-- policies). Any data in paliad.scenarios is lost on down.
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS active_scenario_id;
DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios;
DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at();
DROP TABLE IF EXISTS paliad.scenarios CASCADE;

View File

@@ -1,170 +0,0 @@
-- 145_scenarios — Slice D, m/paliad#124 §5 (revised)
--
-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK.
-- A scenario is a named composition of existing proceedings + flags
-- + per-card choices + anchor dates the user can switch between for
-- a project (project_id NOT NULL) OR save as an abstract template on
-- /tools/verfahrensablauf (project_id IS NULL).
--
-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595):
-- Q1: composition shape → primary+spawned (v1); multi-proceeding
-- peer compose is the v2 goal. spec.jsonb
-- architected for N entries from day 1.
-- Q2: scope → per-project + abstract.
-- Q3: trigger dates → per-anchor overrides over one base date.
-- Q4: storage → NEW paliad.scenarios table with jsonb
-- spec (NOT a project_event_choices column
-- extension).
--
-- "users should not add their own rules" (m, t-paliad-301) — scenarios
-- compose existing rules, never author new ones. spec.proceedings[*].code
-- must resolve to an existing active paliad.proceeding_types row;
-- spec.proceedings[*].anchor_overrides keys must resolve to existing
-- submission_codes. Validation happens at the application layer
-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too
-- expensive to express in pure SQL).
--
-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's
-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer.
-- 145 is the next safe claim.
--
-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies.
-- Down drops everything. No backfill (zero existing scenarios on day 1).
--
-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the
-- design.
-- ---------------------------------------------------------------
-- 1. The scenarios table
-- ---------------------------------------------------------------
CREATE TABLE paliad.scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- project_id NULL = abstract scenario (saved Verfahrensablauf
-- template, no Akte). project_id NOT NULL = scenario attached to
-- a real Akte.
project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
name text NOT NULL,
description text NULL,
-- spec carries the full composition. Shape documented in the
-- design doc §5; the application validates structure before write.
spec jsonb NOT NULL,
created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Within a single project, scenario names are unique. Abstract
-- scenarios are unique per (created_by, name) so two users can
-- each keep a "with_ccr" template without colliding. NULLS NOT
-- DISTINCT means a single user can have one "name" per
-- (project_id, created_by) tuple, where NULL project_id +
-- NULL created_by is a single global namespace (used only by
-- seed / system scenarios — none today).
CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name),
-- Non-empty name.
CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0),
-- Non-empty spec — at least an object. The application checks
-- structure (version, proceedings[], base_trigger_date format).
CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object')
);
CREATE INDEX scenarios_project_id_idx
ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL;
CREATE INDEX scenarios_abstract_user_idx
ON paliad.scenarios(created_by) WHERE project_id IS NULL;
COMMENT ON TABLE paliad.scenarios IS
'Named compositions of existing proceedings + flags + per-card '
'choices + anchor dates. project_id NULL = abstract template; '
'project_id NOT NULL = attached to an Akte. Design: '
'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)';
COMMENT ON COLUMN paliad.scenarios.spec IS
'jsonb composition spec. Shape: {version: int, base_trigger_date: '
'ISO date, proceedings: [{code, role, flags[], per_card_choices, '
'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time '
'by ScenarioService.validateSpec.';
-- ---------------------------------------------------------------
-- 2. paliad.projects.active_scenario_id FK
--
-- NULL = use today's ad-hoc per-card choice state from
-- paliad.project_event_choices (pre-scenario behaviour preserved).
-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner
-- render reads from this scenario's spec instead.
-- ---------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN active_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
COMMENT ON COLUMN paliad.projects.active_scenario_id IS
'FK to paliad.scenarios. NULL = read choices from '
'paliad.project_event_choices (legacy). Non-NULL = read from the '
'pointed scenario.spec.';
-- ---------------------------------------------------------------
-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129).
--
-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility
-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL)
-- are private to created_by — only the author can read / write them.
-- ---------------------------------------------------------------
ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY;
-- Project-scoped: team visibility.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
-- Abstract: owner-only.
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- ---------------------------------------------------------------
-- 4. updated_at trigger (mirrors other paliad tables that carry
-- updated_at — keep it in lockstep with row mutations).
-- ---------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER scenarios_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenarios
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ---------------------------------------------------------------
-- 5. Informational NOTICE — schema-only migration, zero rows added.
-- ---------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)';
RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)';
END $$;

View File

@@ -120,11 +120,6 @@ type Services struct {
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
// per project or as abstract templates. Nil when DATABASE_URL is
// unset; the /api/scenarios routes return 503 in that case.
Scenario *services.ScenarioService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -189,7 +184,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
}
}
@@ -452,15 +446,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
// per project or as abstract templates on /tools/verfahrensablauf.
protected.HandleFunc("GET /api/scenarios", handleScenariosList)
protected.HandleFunc("GET /api/scenarios/{id}", handleScenarioGet)
protected.HandleFunc("POST /api/scenarios", handleScenarioCreate)
protected.HandleFunc("PATCH /api/scenarios/{id}", handleScenarioPatch)
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -71,9 +71,6 @@ type dbServices struct {
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
// Slice D — named scenario compositions (m/paliad#124 §5).
scenario *services.ScenarioService
}
var dbSvc *dbServices

View File

@@ -1,216 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// Slice D (m/paliad#124 §5, mig 145) — REST endpoints for paliad.scenarios.
//
// Routes (registered in handlers.go):
//
// GET /api/scenarios?project=<id> — list project's scenarios
// GET /api/scenarios?abstract=true — list caller's abstract scenarios
// GET /api/scenarios/{id} — fetch one
// POST /api/scenarios — create
// PATCH /api/scenarios/{id} — partial update
// PUT /api/projects/{id}/active-scenario — set/clear active scenario
// DELETE /api/scenarios/{id} — remove
//
// All endpoints require auth; visibility is enforced by
// ScenarioService.requireProjectVisible / requireVisible.
func requireScenarioService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenario == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Szenarien sind vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioErrorToStatus maps service errors to HTTP statuses. Mirrors
// the patterns in projects.go and event_choices.go.
func scenarioErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, lp.ErrUnknownScenario), errors.Is(err, services.ErrScenarioNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput), errors.Is(err, lp.ErrInvalidScenario), errors.Is(err, lp.ErrScenarioNoPrimary):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
// handleScenariosList — GET /api/scenarios?project=<uuid> OR ?abstract=true.
func handleScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
abstract := r.URL.Query().Get("abstract") == "true"
projectStr := r.URL.Query().Get("project")
switch {
case abstract:
out, err := dbSvc.scenario.ListAbstractForUser(r.Context(), uid)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
case projectStr != "":
pid, err := uuid.Parse(projectStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
return
}
out, err := dbSvc.scenario.ListForProject(r.Context(), uid, pid)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
default:
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "?project=<uuid> oder ?abstract=true erforderlich",
})
}
}
// handleScenarioGet — GET /api/scenarios/{id}.
func handleScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenario.Get(r.Context(), uid, id)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
}
// handleScenarioCreate — POST /api/scenarios.
func handleScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenario.Create(r.Context(), uid, input)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleScenarioPatch — PATCH /api/scenarios/{id}.
func handleScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenario.Patch(r.Context(), uid, id, input)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
}
// handleScenarioDelete — DELETE /api/scenarios/{id}.
func handleScenarioDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
if err := dbSvc.scenario.Delete(r.Context(), uid, id); err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSetActiveScenario — PUT /api/projects/{id}/active-scenario.
// Body: {"scenario_id": "<uuid>"} or {"scenario_id": null} to clear.
func handleSetActiveScenario(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
pid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
return
}
var body struct {
ScenarioID *uuid.UUID `json:"scenario_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
if err := dbSvc.scenario.SetActive(r.Context(), uid, pid, body.ScenarioID); err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -585,6 +585,16 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update deadline: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): if rule_id was in the
// patch (auto/custom swap from t-paliad-258), the parallel
// procedural_event_id + sequencing_rule_id columns must follow.
// Call unconditionally — it's a single UPDATE keyed on
// deadlineID and a no-op when rule_id is unchanged.
if input.RuleSet {
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
return nil, err
}
}
}
if input.EventTypeIDs != nil && s.eventTypes != nil {

View File

@@ -0,0 +1,392 @@
// Slice B.2 dual-write (t-paliad-305 / m/paliad#93) — keep paliad's
// new tables (procedural_events / sequencing_rules / legal_sources) in
// lock-step with the legacy paliad.deadline_rules table during the
// dual-write window. Mig 136 (Slice B.1) created the new tables and
// backfilled them once. This file keeps them in sync going forward.
//
// Contract:
//
// - Every RuleEditorService method that mutates paliad.deadline_rules
// calls syncDualWriteFromDeadlineRule(ctx, tx, id) inside the same
// transaction, AFTER the deadline_rules write, BEFORE tx.Commit.
// - The sync is idempotent (INSERT … ON CONFLICT … DO UPDATE) so the
// same call works for Create (new row), UpdateDraft (existing row),
// CloneAsDraft (new row referencing an old row), Publish (lifecycle
// flip), Archive/Restore (lifecycle flip), and the published-peer
// archive that Publish performs as a cascade.
// - The sync re-derives the new-table state from paliad.deadline_rules
// in pure SQL — no struct mapping in Go. The legacy table stays the
// source of truth during B.2 (B.3 flips reads, B.4 drops it).
// - Read paths still read deadline_rules in B.2. The new tables are a
// parallel projection kept consistent for B.3's read cutover; they
// are not yet authoritative.
//
// Why a per-row sync instead of a global trigger:
//
// - The deadline_rules audit trigger (mig 079) reads paliad.audit_reason
// to record the rationale on every change. Putting the new-table
// write in the same TX preserves that auditability — set_config is
// transactional and the new writes share the same reason.
// - A Postgres-side AFTER UPDATE trigger on deadline_rules would also
// work but it's harder to test in isolation and harder to revert
// when B.4 drops the source table. A Go-side sync is reversible
// with a code revert; an SQL trigger needs a follow-up migration.
//
// The drift-check job (CheckDualWriteDrift below) runs daily and
// alerts on mismatches. If the sync ever silently misses a row, the
// drift check surfaces it inside one day.
//
// See docs/design-procedural-events-model-2026-05-25.md §5.2 (dual-write
// phase) and docs/design-procedural-events-b0-findings-2026-05-26.md §7.
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// syncDualWriteFromDeadlineRule re-projects the deadline_rules row with
// the given id into legal_sources + procedural_events + sequencing_rules.
// Runs three UPSERT statements in the open transaction.
//
// Synthetic-code rule (for rows where deadline_rules.submission_code is
// NULL) mirrors mig 136's backfill: 'null.' || first 8 hex chars of the
// uuid (dashes stripped). This must stay byte-identical to the mig 136
// expression or the lookup join inside the sequencing_rules UPSERT
// misses.
func syncDualWriteFromDeadlineRule(ctx context.Context, tx *sqlx.Tx, id uuid.UUID) error {
// 1. legal_sources — UPSERT the citation (no-op if already present).
// jurisdiction is parsed from the first dot-separated segment;
// 'other' on empty (paranoid fallback, no live rows hit it).
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.legal_sources (citation, jurisdiction)
SELECT dr.legal_source,
COALESCE(NULLIF(split_part(dr.legal_source, '.', 1), ''), 'other')
FROM paliad.deadline_rules dr
WHERE dr.id = $1 AND dr.legal_source IS NOT NULL
ON CONFLICT (citation) DO NOTHING`, id); err != nil {
return fmt.Errorf("dual-write legal_sources for rule %s: %w", id, err)
}
// 2. procedural_events — UPSERT keyed by code. The code is the
// submission_code if present, else the synthetic 'null.<8hex>'
// minted from the deadline_rules row's id (matches mig 136).
// legal_source_id is resolved by JOIN on legal_sources.citation
// (NULL when the rule has no legal_source).
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.procedural_events
(code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id,
lifecycle_state, published_at, is_active)
SELECT
COALESCE(dr.submission_code,
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)),
dr.name, dr.name_en, dr.description, dr.event_type,
dr.primary_party, ls.id, dr.concept_id,
dr.lifecycle_state, dr.published_at, dr.is_active
FROM paliad.deadline_rules dr
LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source
WHERE dr.id = $1
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
name_en = EXCLUDED.name_en,
description = EXCLUDED.description,
event_kind = EXCLUDED.event_kind,
primary_party_default = EXCLUDED.primary_party_default,
legal_source_id = EXCLUDED.legal_source_id,
concept_id = EXCLUDED.concept_id,
lifecycle_state = EXCLUDED.lifecycle_state,
published_at = EXCLUDED.published_at,
is_active = EXCLUDED.is_active,
updated_at = now()`, id); err != nil {
return fmt.Errorf("dual-write procedural_events for rule %s: %w", id, err)
}
// 3. sequencing_rules — UPSERT keyed by id (1:1 inheritance from
// deadline_rules.id). procedural_event_id resolved by JOIN on
// the (real or synthetic) code. All hat-3 mechanics columns copy
// 1:1 from the deadline_rules row's post-write state.
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.sequencing_rules
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
combine_op, condition_expr, primary_party, sequence_order,
is_spawn, spawn_label, spawn_proceeding_type_id,
is_bilateral, is_court_set, priority,
rule_code, rule_codes, deadline_notes, deadline_notes_en,
choices_offered, applies_to_target,
lifecycle_state, draft_of, published_at, is_active,
created_at, updated_at)
SELECT
dr.id, pe.id,
dr.proceeding_type_id, dr.parent_id, dr.trigger_event_id,
dr.duration_value, dr.duration_unit, dr.timing,
dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt,
dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order,
dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id,
dr.is_bilateral, dr.is_court_set, dr.priority,
dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en,
dr.choices_offered, dr.applies_to_target,
dr.lifecycle_state, dr.draft_of, dr.published_at, dr.is_active,
dr.created_at, dr.updated_at
FROM paliad.deadline_rules dr
JOIN paliad.procedural_events pe
ON pe.code = COALESCE(dr.submission_code,
'null.' || substring(replace(dr.id::text, '-', ''), 1, 8))
WHERE dr.id = $1
ON CONFLICT (id) DO UPDATE SET
procedural_event_id = EXCLUDED.procedural_event_id,
proceeding_type_id = EXCLUDED.proceeding_type_id,
parent_id = EXCLUDED.parent_id,
trigger_event_id = EXCLUDED.trigger_event_id,
duration_value = EXCLUDED.duration_value,
duration_unit = EXCLUDED.duration_unit,
timing = EXCLUDED.timing,
alt_duration_value = EXCLUDED.alt_duration_value,
alt_duration_unit = EXCLUDED.alt_duration_unit,
alt_rule_code = EXCLUDED.alt_rule_code,
anchor_alt = EXCLUDED.anchor_alt,
combine_op = EXCLUDED.combine_op,
condition_expr = EXCLUDED.condition_expr,
primary_party = EXCLUDED.primary_party,
sequence_order = EXCLUDED.sequence_order,
is_spawn = EXCLUDED.is_spawn,
spawn_label = EXCLUDED.spawn_label,
spawn_proceeding_type_id = EXCLUDED.spawn_proceeding_type_id,
is_bilateral = EXCLUDED.is_bilateral,
is_court_set = EXCLUDED.is_court_set,
priority = EXCLUDED.priority,
rule_code = EXCLUDED.rule_code,
rule_codes = EXCLUDED.rule_codes,
deadline_notes = EXCLUDED.deadline_notes,
deadline_notes_en = EXCLUDED.deadline_notes_en,
choices_offered = EXCLUDED.choices_offered,
applies_to_target = EXCLUDED.applies_to_target,
lifecycle_state = EXCLUDED.lifecycle_state,
draft_of = EXCLUDED.draft_of,
published_at = EXCLUDED.published_at,
is_active = EXCLUDED.is_active,
updated_at = now()`, id); err != nil {
return fmt.Errorf("dual-write sequencing_rules for rule %s: %w", id, err)
}
return nil
}
// syncDeadlineDualLinks mirrors a deadline's legacy rule_id back-link
// onto the new procedural_event_id + sequencing_rule_id columns added
// by mig 136. Call this within an open transaction AFTER any UPDATE
// that mutates paliad.deadlines.rule_id (mig 122 introduced rule_id
// as the deadline→rule FK; today's writers are DeadlineService.Update
// and RuleEditorService.ResolveOrphan).
//
// Idempotent: NULL rule_id collapses both new columns to NULL by virtue
// of the subquery returning NULL. Slice B.2 (t-paliad-305).
func syncDeadlineDualLinks(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error {
if _, err := tx.ExecContext(ctx, `
UPDATE paliad.deadlines d
SET sequencing_rule_id = d.rule_id,
procedural_event_id = (
SELECT sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
)
WHERE d.id = $1`, deadlineID); err != nil {
return fmt.Errorf("sync deadline dual-links for %s: %w", deadlineID, err)
}
return nil
}
// DualWriteDriftReport summarises the comparison between the legacy
// paliad.deadline_rules table and the new procedural_events /
// sequencing_rules tables that B.2's dual-write is meant to keep in
// sync. A zero-drift report (every count delta zero, every join clean)
// is the steady state during the dual-write window; any non-zero field
// is the signal that a write path either bypassed
// syncDualWriteFromDeadlineRule or that an out-of-band mutation
// happened (e.g. raw SQL run by an operator).
type DualWriteDriftReport struct {
// Counts on the legacy and the projected side.
DeadlineRules int `json:"deadline_rules"`
SequencingRules int `json:"sequencing_rules"`
ProceduralEvents int `json:"procedural_events"`
LegalSources int `json:"legal_sources"`
// Expected (from the legacy side) vs observed (on the new side).
ExpectedPE int `json:"expected_procedural_events"`
ExpectedLegalSources int `json:"expected_legal_sources"`
// MissingSR — deadline_rules rows with no sequencing_rules row by id.
// OrphanedSR — sequencing_rules rows whose id doesn't exist in
// deadline_rules anymore (would only happen with a deletion path
// that bypasses dual-write).
MissingSR int `json:"missing_sequencing_rules"`
OrphanedSR int `json:"orphaned_sequencing_rules"`
// MismatchedLifecycle — rows where deadline_rules.lifecycle_state
// disagrees with sequencing_rules.lifecycle_state. Should always be
// zero during dual-write.
MismatchedLifecycle int `json:"mismatched_lifecycle"`
// MismatchedActive — same shape, for is_active.
MismatchedActive int `json:"mismatched_active"`
}
// HasDrift returns true if any field signals divergence between the
// legacy and projected sides. Used by the drift-check ticker to decide
// whether to log at WARN (drift) or INFO (clean).
func (r DualWriteDriftReport) HasDrift() bool {
if r.SequencingRules != r.DeadlineRules {
return true
}
if r.ProceduralEvents != r.ExpectedPE {
return true
}
if r.LegalSources != r.ExpectedLegalSources {
return true
}
if r.MissingSR != 0 || r.OrphanedSR != 0 {
return true
}
if r.MismatchedLifecycle != 0 || r.MismatchedActive != 0 {
return true
}
return false
}
// CheckDualWriteDrift compares the legacy paliad.deadline_rules table
// against the parallel new tables maintained by Slice B.2's dual-write.
// Returns a DualWriteDriftReport — caller decides what to do with
// non-zero drift (log, page, fail healthcheck, etc.).
//
// Read-only. Safe to run against prod. Single query per metric so the
// pool isn't held for a long time. No locks; tolerates concurrent
// writes (counts may shift by one or two during the read, but a
// persistent drift > 0 is the alarm signal).
func CheckDualWriteDrift(ctx context.Context, conn *sqlx.DB) (*DualWriteDriftReport, error) {
var r DualWriteDriftReport
q := func(label, sql string, dst *int) error {
if err := conn.GetContext(ctx, dst, sql); err != nil {
return fmt.Errorf("drift-check %s: %w", label, err)
}
return nil
}
if err := q("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &r.DeadlineRules); err != nil {
return nil, err
}
if err := q("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &r.SequencingRules); err != nil {
return nil, err
}
if err := q("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &r.ProceduralEvents); err != nil {
return nil, err
}
if err := q("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &r.LegalSources); err != nil {
return nil, err
}
if err := q("expected_pe", `
SELECT
(SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL)
+
(SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL)
`, &r.ExpectedPE); err != nil {
return nil, err
}
if err := q("expected_ls",
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
&r.ExpectedLegalSources); err != nil {
return nil, err
}
if err := q("missing_sr", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
LEFT JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE sr.id IS NULL`, &r.MissingSR); err != nil {
return nil, err
}
if err := q("orphaned_sr", `
SELECT COUNT(*) FROM paliad.sequencing_rules sr
LEFT JOIN paliad.deadline_rules dr ON dr.id = sr.id
WHERE dr.id IS NULL`, &r.OrphanedSR); err != nil {
return nil, err
}
if err := q("mismatched_lifecycle", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE dr.lifecycle_state <> sr.lifecycle_state`, &r.MismatchedLifecycle); err != nil {
return nil, err
}
if err := q("mismatched_active", `
SELECT COUNT(*) FROM paliad.deadline_rules dr
JOIN paliad.sequencing_rules sr ON sr.id = dr.id
WHERE dr.is_active <> sr.is_active`, &r.MismatchedActive); err != nil {
return nil, err
}
return &r, nil
}
// StartDualWriteDriftCheckLoop runs CheckDualWriteDrift on a fixed
// interval for the lifetime of ctx. A clean run logs at INFO level;
// drift logs at WARN level with the full report payload. The first
// check fires after `interval`, not immediately on Start — by the time
// the ticker first fires the process has finished booting and the
// initial backfill + dual-write writes have settled.
//
// Slice B.2 (t-paliad-305). interval should be short enough to surface
// drift before the next deploy (so a broken dual-write doesn't sit
// silent for a week) and long enough to avoid noise (the check holds
// no locks but it does run nine SELECT COUNTs).
//
// Recommended interval: 6h. Override via the caller (cmd/server picks
// the runtime value).
func StartDualWriteDriftCheckLoop(ctx context.Context, conn *sqlx.DB, interval time.Duration) {
if interval <= 0 {
interval = 6 * time.Hour
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
report, err := CheckDualWriteDrift(ctx, conn)
if err != nil {
log.Printf("dual-write drift-check: error: %v", err)
continue
}
if report.HasDrift() {
log.Printf("dual-write drift-check: DRIFT DETECTED — "+
"deadline_rules=%d sequencing_rules=%d "+
"procedural_events=%d (expected %d) "+
"legal_sources=%d (expected %d) "+
"missing_sr=%d orphaned_sr=%d "+
"mismatched_lifecycle=%d mismatched_active=%d",
report.DeadlineRules, report.SequencingRules,
report.ProceduralEvents, report.ExpectedPE,
report.LegalSources, report.ExpectedLegalSources,
report.MissingSR, report.OrphanedSR,
report.MismatchedLifecycle, report.MismatchedActive)
} else {
log.Printf("dual-write drift-check: OK — "+
"deadline_rules=%d sequencing_rules=%d "+
"procedural_events=%d legal_sources=%d",
report.DeadlineRules, report.SequencingRules,
report.ProceduralEvents, report.LegalSources)
}
}
}
}()
}

View File

@@ -0,0 +1,300 @@
// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93).
//
// Asserts the parallel projection — paliad.procedural_events +
// paliad.sequencing_rules + paliad.legal_sources — stays in lock-step
// with paliad.deadline_rules through the full RuleEditorService
// lifecycle. Skipped when TEST_DATABASE_URL is unset.
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft →
// CloneAsDraft → Publish → Archive → Restore on RuleEditorService and
// after each operation asserts that paliad.sequencing_rules has the
// 1:1 mirror, paliad.procedural_events carries the projected identity,
// and paliad.legal_sources carries the citation.
func TestDualWrite_RuleEditorLifecycle(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()
rules := NewDeadlineRuleService(pool)
svc := NewRuleEditorService(pool, rules)
cleanup := func() {
pool.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`)
// Order matters: sequencing_rules → procedural_events → legal_sources
// (FK direction). deadline_rules cleanup last because mig 079 audit
// trigger captures the DELETE.
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'
)`)
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`)
pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources
WHERE citation LIKE 'SLICEB2.%'`)
pool.ExecContext(ctx,
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`)
pool.ExecContext(ctx,
`DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`)
}
cleanup()
defer cleanup()
var ptID int
if err := pool.GetContext(ctx, &ptID, `
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true)
RETURNING id`); err != nil {
t.Fatalf("seed proceeding_type: %v", err)
}
subCode := "sliceb2.create"
legalSrc := "SLICEB2.PatG.1"
// 1. Create — assert the parallel rows land.
created, err := svc.Create(ctx, CreateRuleInput{
Name: "SLICEB2_TEST_create",
NameEN: "SLICEB2_TEST_create_EN",
ProceedingTypeID: &ptID,
SubmissionCode: &subCode,
LegalSource: &legalSrc,
DurationValue: 30,
DurationUnit: "days",
Priority: "mandatory",
}, "B.2 dual-write create test")
if err != nil {
t.Fatalf("Create: %v", err)
}
// legal_sources should now carry SLICEB2.PatG.1
var lsCount int
if err := pool.GetContext(ctx, &lsCount,
`SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil {
t.Fatalf("query legal_sources: %v", err)
}
if lsCount != 1 {
t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc)
}
// procedural_events should carry the submission_code
var peName, peLifecycle string
if err := pool.GetContext(ctx, &peName,
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query procedural_events name: %v", err)
}
if peName != "SLICEB2_TEST_create" {
t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create")
}
if err := pool.GetContext(ctx, &peLifecycle,
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query procedural_events lifecycle: %v", err)
}
if peLifecycle != "draft" {
t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft")
}
// sequencing_rules should have id = created.id and link to PE
var srCount, srMatchPE int
if err := pool.GetContext(ctx, &srCount,
`SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sequencing_rules count: %v", err)
}
if srCount != 1 {
t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID)
}
if err := pool.GetContext(ctx, &srMatchPE, `
SELECT COUNT(*) FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil {
t.Fatalf("query sr→pe join: %v", err)
}
if srMatchPE != 1 {
t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE)
}
// 2. UpdateDraft — change name + legal_source. Assert propagation.
newName := "SLICEB2_TEST_updated"
newLegal := "SLICEB2.ZPO.2"
_, err = svc.UpdateDraft(ctx, created.ID, RulePatch{
Name: &newName,
LegalSource: &newLegal,
}, "B.2 dual-write update test")
if err != nil {
t.Fatalf("UpdateDraft: %v", err)
}
var afterName string
if err := pool.GetContext(ctx, &afterName,
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query pe.name post-update: %v", err)
}
if afterName != newName {
t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName)
}
// New citation must appear in legal_sources, and procedural_events.legal_source_id
// must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays).
var pePointsAtNewLegal int
if err := pool.GetContext(ctx, &pePointsAtNewLegal, `
SELECT COUNT(*) FROM paliad.procedural_events pe
JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil {
t.Fatalf("query pe→ls join: %v", err)
}
if pePointsAtNewLegal != 1 {
t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal)
}
// 3. Publish — flip to published. Assert lifecycle mirror.
_, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test")
if err != nil {
t.Fatalf("Publish: %v", err)
}
var srLifecycle, peLifecycleAfterPub string
if err := pool.GetContext(ctx, &srLifecycle,
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sr.lifecycle: %v", err)
}
if srLifecycle != "published" {
t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published")
}
if err := pool.GetContext(ctx, &peLifecycleAfterPub,
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query pe.lifecycle post-publish: %v", err)
}
if peLifecycleAfterPub != "published" {
t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published")
}
// 4. Archive — flip to archived. Assert mirror.
_, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test")
if err != nil {
t.Fatalf("Archive: %v", err)
}
var srLifecycleArchived string
if err := pool.GetContext(ctx, &srLifecycleArchived,
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sr.lifecycle post-archive: %v", err)
}
if srLifecycleArchived != "archived" {
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
}
// 5. Drift check should return zero drift right after the dance.
report, err := CheckDualWriteDrift(ctx, pool)
if err != nil {
t.Fatalf("CheckDualWriteDrift: %v", err)
}
if report.HasDrift() {
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
}
}
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule
// created with submission_code=NULL gets a synthetic 'null.<8hex>'
// procedural_events row matching mig 136's mint expression — so a new
// draft without a code participates in the dual-write contract without
// colliding with any code-bearing rule.
func TestDualWrite_SyntheticCodeForNullSubmission(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()
rules := NewDeadlineRuleService(pool)
svc := NewRuleEditorService(pool, rules)
cleanup := func() {
pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`)
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
)`)
// Synthetic PE rows are keyed off the rule's uuid; delete by name reference.
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
WHERE code IN (
SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
)`)
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`)
pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`)
}
cleanup()
defer cleanup()
var ptID int
if err := pool.GetContext(ctx, &ptID, `
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true)
RETURNING id`); err != nil {
t.Fatalf("seed proceeding_type: %v", err)
}
created, err := svc.Create(ctx, CreateRuleInput{
Name: "SLICEB2_TEST_nullcode",
NameEN: "SLICEB2_TEST_nullcode_EN",
ProceedingTypeID: &ptID,
// SubmissionCode intentionally NIL → tests the synthetic-code branch.
DurationValue: 5,
DurationUnit: "days",
Priority: "mandatory",
}, "B.2 dual-write null-code test")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Compute the expected synthetic code in the same way mig 136 / the
// dual-write helper do — keep the expression in lock-step with the
// SQL via this Go-side mirror.
var expectedCode string
if err := pool.GetContext(ctx, &expectedCode,
`SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("compute expected synthetic code: %v", err)
}
var actualCode string
if err := pool.GetContext(ctx, &actualCode, `
SELECT pe.code
FROM paliad.procedural_events pe
JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id
WHERE sr.id = $1`, created.ID); err != nil {
t.Fatalf("query procedural_events via sequencing_rules: %v", err)
}
if actualCode != expectedCode {
t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode)
}
if len(actualCode) != len("null.")+8 {
t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode))
}
}

View File

@@ -516,61 +516,6 @@ func computeDepths(
return depths
}
// LoadScenarios lists scenarios visible to the caller (Slice D,
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
// project-scoped rows require paliad.can_see_project(project_id);
// abstract rows require created_by = auth.uid(). The filter narrows
// the SELECT (project_id-bound, abstract-for-user, or all).
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
where := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if filter.ProjectID != nil {
add("project_id = $%d", *filter.ProjectID)
}
if filter.AbstractForUser != nil {
where = append(where, "project_id IS NULL")
add("created_by = $%d", *filter.AbstractForUser)
}
query := `SELECT id, project_id, name, description, spec,
created_by, created_at, updated_at
FROM paliad.scenarios`
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY created_at DESC"
var rows []lp.Scenario
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load scenarios: %w", err)
}
return rows, nil
}
// MatchScenario returns the scenario with the given id, or
// lp.ErrUnknownScenario if not visible / not found. RLS gates
// visibility; a not-found result could mean "doesn't exist" OR
// "exists but you can't see it" — either way the caller treats it
// as unknown.
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
var s lp.Scenario
err := c.rules.db.GetContext(ctx, &s,
`SELECT id, project_id, name, description, spec,
created_by, created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownScenario
}
if err != nil {
return nil, fmt.Errorf("match scenario %q: %w", id, err)
}
return &s, nil
}
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
var _ lp.Catalog = (*paliadCatalog)(nil)

View File

@@ -221,6 +221,12 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
); err != nil {
return fmt.Errorf("set deadline rule_id: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto
// the parallel deadlines.procedural_event_id + sequencing_rule_id
// columns so they don't drift from rule_id.
if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rule_backfill_orphans
SET resolved_at = $1,

View File

@@ -209,6 +209,14 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
return nil, fmt.Errorf("insert rule: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): project the new row into
// legal_sources / procedural_events / sequencing_rules in the same
// transaction so the parallel tables stay in lock-step with
// deadline_rules through the B.3 read-cutover window.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
}
@@ -276,6 +284,10 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update rule draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
@@ -336,6 +348,14 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): new draft gets its own
// procedural_events + sequencing_rules row. The synthetic-code
// branch fires here when the source rule had NULL submission_code
// (the clone inherits the NULL and mints a fresh 'null.<8hex>'
// derived from newID).
if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
@@ -392,6 +412,18 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
}
}
// Slice B.2 dual-write (t-paliad-305): sync both sides — the newly
// published draft AND the cloned-from peer that just flipped to
// archived (if any).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if current.DraftOf != nil {
if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
}
@@ -459,6 +491,12 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
}
// Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip
// onto sequencing_rules + procedural_events.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
}

View File

@@ -1,347 +0,0 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// ScenarioService reads + writes paliad.scenarios — named compositions
// of existing proceedings + flags + per-card choices + anchor dates,
// switchable per project or saved as abstract templates on
// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145.
//
// Visibility:
// - Project-scoped scenarios (project_id NOT NULL): require
// can_see_project on the bound project (mirrors
// EventChoiceService.requireProjectVisible).
// - Abstract scenarios (project_id IS NULL): owner-only. Only
// created_by can read / mutate.
//
// The service applies these checks in application code; paliad.scenarios
// also has RLS policies (mig 145) as defense-in-depth for callers that
// connect through Supabase Auth's auth.uid() session.
type ScenarioService struct {
db *sqlx.DB
projects *ProjectService
rules *DeadlineRuleService
}
// NewScenarioService wires the service to its dependencies.
func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService {
return &ScenarioService{db: db, projects: projects, rules: rules}
}
// Sentinel errors. Mirrors EventChoiceService + the lp package errors
// so handlers can map cleanly to HTTP statuses.
var (
ErrScenarioNotVisible = errors.New("scenario not visible to caller")
)
// CreateScenarioInput is the payload for POST /api/scenarios. project_id
// nil = abstract scenario (saved Verfahrensablauf template).
type CreateScenarioInput struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Spec json.RawMessage `json:"spec"`
}
// Create inserts a new scenario after validating the spec.
func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) {
if input.Name == "" {
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
}
if err := s.validateSpec(ctx, input.Spec); err != nil {
return nil, err
}
if input.ProjectID != nil {
if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
}
var out lp.Scenario
err := s.db.GetContext(ctx, &out,
`INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, description, spec, created_by,
created_at, updated_at`,
input.ProjectID, input.Name, input.Description,
[]byte(input.Spec), userID)
if err != nil {
return nil, fmt.Errorf("create scenario: %w", err)
}
return &out, nil
}
// Get returns one scenario by id after a visibility check.
func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) {
var sc lp.Scenario
err := s.db.GetContext(ctx, &sc,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownScenario
}
if err != nil {
return nil, fmt.Errorf("get scenario: %w", err)
}
if err := s.requireVisible(ctx, userID, &sc); err != nil {
return nil, err
}
return &sc, nil
}
// ListForProject returns scenarios attached to one project, ordered by
// created_at desc.
func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []lp.Scenario{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE project_id = $1
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, fmt.Errorf("list scenarios for project: %w", err)
}
return out, nil
}
// ListAbstractForUser returns the calling user's abstract scenarios.
func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) {
out := []lp.Scenario{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE project_id IS NULL AND created_by = $1
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("list abstract scenarios: %w", err)
}
return out, nil
}
// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any
// field nil means "don't change". Spec replacement re-runs validation.
type PatchScenarioInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Spec json.RawMessage `json:"spec,omitempty"`
}
// Patch updates one or more scenario fields. Visibility check fires
// first (the caller must already see the scenario to mutate it).
func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) {
current, err := s.Get(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if len(input.Spec) > 0 {
if err := s.validateSpec(ctx, input.Spec); err != nil {
return nil, err
}
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.Name != nil {
add("name = $%d", *input.Name)
}
if input.Description != nil {
add("description = $%d", *input.Description)
}
if len(input.Spec) > 0 {
add("spec = $%d", []byte(input.Spec))
}
if len(sets) == 0 {
return current, nil
}
args = append(args, scenarioID)
query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
WHERE id = $%d
RETURNING id, project_id, name, description, spec, created_by,
created_at, updated_at`, joinSets(sets), len(args))
var out lp.Scenario
if err := s.db.GetContext(ctx, &out, query, args...); err != nil {
return nil, fmt.Errorf("patch scenario: %w", err)
}
return &out, nil
}
// SetActive points a project at one of its scenarios. Pass nil to
// clear (revert to ad-hoc per-card choice state).
func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if scenarioID != nil {
// Ensure scenario exists + belongs to this project. A scenario
// from a different project (or an abstract one) can't be the
// active scenario on this project.
sc, err := s.Get(ctx, userID, *scenarioID)
if err != nil {
return err
}
if sc.ProjectID == nil || *sc.ProjectID != projectID {
return fmt.Errorf("%w: scenario %s is not attached to project %s",
ErrInvalidInput, *scenarioID, projectID)
}
}
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`,
scenarioID, projectID)
if err != nil {
return fmt.Errorf("set active scenario: %w", err)
}
return nil
}
// Delete removes a scenario. Project's active_scenario_id is cleared
// automatically via the FK's ON DELETE SET NULL.
func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error {
// Visibility check via Get — also resolves the existence question.
if _, err := s.Get(ctx, userID, scenarioID); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil {
return fmt.Errorf("delete scenario: %w", err)
}
return nil
}
// requireVisible enforces the per-row visibility rule:
// - project_id NOT NULL → caller must see the project
// - project_id IS NULL → caller must be the row's created_by
func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error {
if sc.ProjectID != nil {
return s.requireProjectVisible(ctx, userID, *sc.ProjectID)
}
if sc.CreatedBy == nil || *sc.CreatedBy != userID {
return ErrScenarioNotVisible
}
return nil
}
// requireProjectVisible mirrors EventChoiceService.requireProjectVisible
// (visibility via can_see_project). Cheap re-implementation — keeps the
// call-graph small + avoids a cross-service dep.
func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
var visible bool
err := s.db.GetContext(ctx, &visible,
`SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $1 AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.projects p
JOIN paliad.project_teams pt ON pt.project_id = ANY(
string_to_array(p.path, '.')::uuid[]
)
WHERE p.id = $2 AND pt.user_id = $1
)`, userID, projectID)
if err != nil {
return fmt.Errorf("check project visibility: %w", err)
}
if !visible {
return ErrScenarioNotVisible
}
return nil
}
// validateSpec checks the jsonb spec is well-formed, has the right
// version, and that every referenced proceeding code + submission code
// resolves to an active row in the live catalog. Surfaces friendly
// errors wrapping ErrInvalidInput so the handler can map to a 400.
func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error {
if len(raw) == 0 {
return fmt.Errorf("%w: spec is required", ErrInvalidInput)
}
parsed, err := lp.ParseSpec(lp.NullableJSON(raw))
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if _, err := parsed.PrimaryProceeding(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if parsed.BaseTriggerDate != "" {
if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil {
return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate)
}
}
for i, p := range parsed.Proceedings {
if p.Code == "" {
return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i)
}
if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer {
return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'",
ErrInvalidInput, i, p.Role)
}
if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) {
return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v",
ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets)
}
if p.TriggerDateOverride != "" {
if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil {
return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD",
ErrInvalidInput, i, p.TriggerDateOverride)
}
}
for code, dateStr := range p.AnchorOverrides {
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD",
ErrInvalidInput, i, code, dateStr)
}
}
// Resolve code against active proceedings.
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true)`,
p.Code); err != nil {
return fmt.Errorf("validate spec proceedings[%d]: %w", i, err)
}
if !exists {
return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type",
ErrInvalidInput, i, p.Code)
}
}
return nil
}
// joinSets joins SET clauses with ", ". Tiny utility, kept here to
// avoid cross-package strings.Join indirection.
func joinSets(sets []string) string {
out := ""
for i, s := range sets {
if i > 0 {
out += ", "
}
out += s
}
return out
}
// Suppress unused-import diagnostic when models isn't referenced
// (kept for future shape-evolution; canonical scenario row lives in lp).
var _ = models.NullableJSON(nil)

View File

@@ -67,12 +67,6 @@ func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
return nil, nil
}
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
return nil, nil
}
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
return nil, ErrUnknownScenario
}
// noOpHolidays never adjusts dates — the test fixture doesn't care about
// weekends or holidays, only about which base date the engine resolves.

View File

@@ -1,10 +1,6 @@
package litigationplanner
import (
"context"
"github.com/google/uuid"
)
import "context"
// Catalog supplies proceeding-type metadata + rules for the calculator.
//
@@ -63,17 +59,4 @@ type Catalog interface {
// (proceeding_type_id, sequence_order) so the frontend can render
// without re-sorting.
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
// LoadScenarios lists scenarios visible to the caller, narrowed by
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
// (NOT an error) when no scenarios match. paliad-side impl applies
// RLS (paliad.can_see_project for project-scoped, created_by for
// abstract); snapshot-backed catalogs return an empty list.
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
// MatchScenario returns the scenario with the given id, or
// ErrUnknownScenario if not found / not visible. The engine adapter
// (CalculateFromScenario) calls this to fetch a scenario by id and
// then unpacks its spec via ParseSpec.
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
}

View File

@@ -292,20 +292,6 @@ func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxe
return out, nil
}
// LoadScenarios returns an empty slice. The snapshot catalog has no
// scenarios — youpc.org (the consumer today) doesn't carry a project /
// user model. Future snapshot variants could ship demo scenarios, but
// v1 returns nothing.
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
return []lp.Scenario{}, nil
}
// MatchScenario always returns ErrUnknownScenario — the snapshot has
// no scenarios to match against.
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
return nil, lp.ErrUnknownScenario
}
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
var _ lp.Catalog = (*SnapshotCatalog)(nil)

View File

@@ -1,215 +0,0 @@
package litigationplanner
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
//
// A Scenario is a named composition of existing proceedings + flags +
// per-card choices + anchor dates. v1 ships with one primary proceeding
// per scenario; the spec.proceedings[] array is architected to absorb
// multi-peer compose (v2) without a schema migration.
//
// "users should not add their own rules" (m, t-paliad-301) — the spec
// references existing rules by submission_code; it never creates new
// ones. ValidateSpec checks every code/submission resolves against the
// current catalog before a save is accepted.
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
// API request/response payload for /api/scenarios.
type Scenario struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
// Spec carries the jsonb composition. Stored raw so we can ship
// shape evolutions without schema churn; ParseSpec gives the
// structured view.
Spec NullableJSON `db:"spec" json:"spec"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
// Future shape changes bump the version; ParseSpec rejects unknown
// versions so an old client doesn't silently misread a future-shape
// scenario.
type ScenarioSpec struct {
Version int `json:"version"`
BaseTriggerDate string `json:"base_trigger_date"`
Proceedings []ScenarioProceeding `json:"proceedings"`
}
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
// exactly one with role="primary" (additional entries with role="peer"
// are reserved for v2 multi-proceeding compose and silently ignored
// by the engine today).
type ScenarioProceeding struct {
Code string `json:"code"`
Role string `json:"role"` // "primary" | "peer" (v2)
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
Flags []string `json:"flags,omitempty"`
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
SkipRules []string `json:"skip_rules,omitempty"`
AppealTarget string `json:"appeal_target,omitempty"`
}
// ScenarioCardChoice is one entry under
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
// kinds; not every kind is populated on every card.
type ScenarioCardChoice struct {
Appellant string `json:"appellant,omitempty"`
IncludeCCR *bool `json:"include_ccr,omitempty"`
Skip *bool `json:"skip,omitempty"`
}
// Spec version constant.
const ScenarioSpecVersion = 1
// Sentinel errors for scenarios.
var (
ErrUnknownScenario = errors.New("unknown scenario")
ErrInvalidScenario = errors.New("invalid scenario spec")
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
)
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
const (
ScenarioRolePrimary = "primary"
ScenarioRolePeer = "peer"
)
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
// by the engine adapter + the rule-editor preview. Surfaces a friendly
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
// so the handler can map to a 400.
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
}
var s ScenarioSpec
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
}
if s.Version != ScenarioSpecVersion {
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
}
return &s, nil
}
// PrimaryProceeding returns the entry from spec.proceedings[] with
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
// must carry exactly one primary entry. (Multiple primaries are also
// rejected: the engine consumes one.)
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
var primary *ScenarioProceeding
for i := range s.Proceedings {
if s.Proceedings[i].Role == ScenarioRolePrimary {
if primary != nil {
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
}
primary = &s.Proceedings[i]
}
}
if primary == nil {
return nil, ErrScenarioNoPrimary
}
return primary, nil
}
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
// entry. The caller still needs the proceeding code + the trigger date,
// both returned alongside.
//
// v1: only the primary entry is honoured. v2 will iterate over peer
// entries; the multi-peer merge lives in the paliad-side
// ProjectionService (one Calculate call per entry, merged + sorted by
// date).
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
primary, err := s.PrimaryProceeding()
if err != nil {
return "", "", CalcOptions{}, err
}
td := s.BaseTriggerDate
if primary.TriggerDateOverride != "" {
td = primary.TriggerDateOverride
}
if td == "" {
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
}
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
skipRules := make(map[string]struct{}, len(primary.SkipRules))
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
for code, choice := range primary.PerCardChoices {
if choice.Appellant != "" {
perCardAppellant[code] = choice.Appellant
}
if choice.IncludeCCR != nil && *choice.IncludeCCR {
includeCCRFor[code] = struct{}{}
}
if choice.Skip != nil && *choice.Skip {
skipRules[code] = struct{}{}
}
}
for _, code := range primary.SkipRules {
skipRules[code] = struct{}{}
}
return primary.Code, td, CalcOptions{
Flags: primary.Flags,
AnchorOverrides: primary.AnchorOverrides,
AppealTarget: primary.AppealTarget,
PerCardAppellant: perCardAppellant,
SkipRules: skipRules,
IncludeCCRFor: includeCCRFor,
}, nil
}
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
//
// - ProjectID non-nil: only scenarios attached to that project
// (project_id = filter.ProjectID).
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
// NULL) created by that user.
// - Both nil: list every scenario the caller can see (RLS-gated).
type ScenarioFilter struct {
ProjectID *uuid.UUID
AbstractForUser *uuid.UUID
}
// CalculateFromScenario is the high-level engine entry for scenario-
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
// to Calculate.
//
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
// expansion lives on the paliad-side ProjectionService (per-entry
// Calculate + client-side merge); the package doesn't own that
// orchestration.
func CalculateFromScenario(
ctx context.Context,
scenario *Scenario,
catalog Catalog,
holidays HolidayCalendar,
courts CourtRegistry,
) (*Timeline, error) {
spec, err := ParseSpec(scenario.Spec)
if err != nil {
return nil, err
}
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
if err != nil {
return nil, err
}
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
}

View File

@@ -1,207 +0,0 @@
package litigationplanner
import (
"strings"
"testing"
)
// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed
// jsonb with version=1 parses; unknown versions and malformed JSON
// surface ErrInvalidScenario.
func TestParseSpec_Roundtrip(t *testing.T) {
cases := []struct {
name string
spec string
wantErr bool
}{
{
"v1 primary-only",
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`,
false,
},
{
"v1 with full primary entry",
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[
{"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"],
"anchor_overrides":{"inf.reply":"2026-08-15"},
"skip_rules":["inf.r30_amend"]}
]}`,
false,
},
{
"v2 spec rejected — unknown version",
`{"version":2,"proceedings":[]}`,
true,
},
{
"empty spec",
``,
true,
},
{
"malformed json",
`{"version":1,"proceedings":[}`,
true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseSpec(NullableJSON(c.spec))
if c.wantErr && err == nil {
t.Errorf("ParseSpec(%s): want error, got nil", c.spec)
}
if !c.wantErr && err != nil {
t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err)
}
})
}
}
// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary"
// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario.
func TestScenarioSpec_PrimaryProceeding(t *testing.T) {
t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
},
}
_, err := s.PrimaryProceeding()
if err != ErrScenarioNoPrimary {
t.Errorf("want ErrScenarioNoPrimary, got %v", err)
}
})
t.Run("two primaries rejected", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePrimary},
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary},
},
}
_, err := s.PrimaryProceeding()
if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") {
t.Errorf("want multi-primary error, got %v", err)
}
})
t.Run("single primary picked", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}},
},
}
p, err := s.PrimaryProceeding()
if err != nil {
t.Fatalf("PrimaryProceeding: %v", err)
}
if p.Code != "upc.rev.cfi" {
t.Errorf("primary code = %q, want upc.rev.cfi", p.Code)
}
if len(p.Flags) != 1 || p.Flags[0] != "with_amend" {
t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags)
}
})
}
// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec
// jsonb into the CalcOptions the engine consumes. Pins:
// - base_trigger_date used when no per-proceeding override
// - trigger_date_override wins when set
// - flags + anchor_overrides + appeal_target passed through verbatim
// - per_card_choices unpacked into PerCardAppellant / SkipRules /
// IncludeCCRFor maps
func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) {
includeTrue := true
skipTrue := true
s := &ScenarioSpec{
Version: 1,
BaseTriggerDate: "2026-05-26",
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
Flags: []string{"with_ccr"},
AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"},
AppealTarget: "endentscheidung",
SkipRules: []string{"explicit_skip_code"},
PerCardChoices: map[string]ScenarioCardChoice{
"inf.r30_amend": {Appellant: "claimant"},
"inf.rejoin": {IncludeCCR: &includeTrue},
"inf.amend_other": {Skip: &skipTrue},
},
}},
}
code, td, opts, err := s.CalcOptionsFromSpec()
if err != nil {
t.Fatalf("CalcOptionsFromSpec: %v", err)
}
if code != "upc.inf.cfi" {
t.Errorf("code = %q, want upc.inf.cfi", code)
}
if td != "2026-05-26" {
t.Errorf("triggerDate = %q, want 2026-05-26", td)
}
if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" {
t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags)
}
if opts.AppealTarget != "endentscheidung" {
t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget)
}
if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" {
t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got)
}
if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" {
t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got)
}
if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok {
t.Error("opts.IncludeCCRFor missing inf.rejoin")
}
if _, ok := opts.SkipRules["inf.amend_other"]; !ok {
t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)")
}
if _, ok := opts.SkipRules["explicit_skip_code"]; !ok {
t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])")
}
}
// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override
// path (v2-ready — primary entry honours trigger_date_override too).
func TestScenarioSpec_TriggerDateOverride(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
BaseTriggerDate: "2026-05-26",
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
TriggerDateOverride: "2026-12-01",
}},
}
_, td, _, err := s.CalcOptionsFromSpec()
if err != nil {
t.Fatalf("CalcOptionsFromSpec: %v", err)
}
if td != "2026-12-01" {
t.Errorf("triggerDate = %q, want override 2026-12-01", td)
}
}
// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec
// without base_trigger_date AND without per-proceeding override
// surfaces ErrInvalidScenario (the engine can't render without a date).
func TestScenarioSpec_NoBaseTrigger(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
}},
}
_, _, _, err := s.CalcOptionsFromSpec()
if err == nil {
t.Fatal("want ErrInvalidScenario, got nil")
}
}