feat(t-paliad-174): SmartTimeline Slice 3 — counterclaim sub-project schema + service

Migration 077 adds paliad.projects.counterclaim_of (nullable FK ON DELETE
SET NULL) plus a partial index. A trigger function rejects two-level CCR
chains: a project with counterclaim_of NOT NULL cannot be the target of
another CCR — UPC practice has no CCR-of-a-CCR shape, so reject it at
the schema level rather than defending in the application layer.

ProjectService gains LoadCounterclaimChildrenVisible (list visible CCR
sub-projects against a parent) and CreateCounterclaim (atomic: project
row + creator-as-lead team membership + audit rows on parent AND child).
The CCR child is placed as a sibling under the same patent (§4.4), our
side flips claimant↔defendant by default with a "Stimmt nicht?" override
for the R.49.2.b CCI edge case, and the proceeding type defaults to
UPC_REV. Title auto-suggests from the patent ancestor's patent_number
when available.

Tracker advances 76 → 77.
This commit is contained in:
m
2026-05-09 16:07:17 +02:00
parent 196f3f74a6
commit 306bb11618
4 changed files with 371 additions and 3 deletions

View File

@@ -0,0 +1,9 @@
-- t-paliad-174 — revert SmartTimeline Slice 3 schema.
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
DROP FUNCTION IF EXISTS paliad.projects_no_two_level_ccr();
DROP INDEX IF EXISTS paliad.projects_counterclaim_of_idx;
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS counterclaim_of;

View File

@@ -0,0 +1,89 @@
-- t-paliad-174 — SmartTimeline Slice 3.
-- Two structural additions for the counterclaim sub-project shape
-- (§4 of docs/design-smart-timeline-2026-05-08.md):
--
-- 1. paliad.projects.counterclaim_of — nullable FK referencing
-- paliad.projects(id) ON DELETE SET NULL. When non-NULL the row
-- represents the CCR (counterclaim) sub-project filed against the
-- target row. Standard parent_id keeps governing the project tree;
-- counterclaim_of is the *additional* relation describing the CCR
-- link. parent_id of the CCR child is set to the target's parent
-- (sibling-under-patent placement, §4.4) — that placement is owned
-- by ProjectService.CreateCounterclaim, not the schema.
--
-- 2. Two-level-CCR rejection trigger — UPC practice does NOT have
-- counterclaim-of-a-counterclaim chains. Reject the malformed shape
-- at the schema level so the application can never write it. CHECK
-- can't reference other rows; trigger function raises explicitly.
--
-- Idempotent: re-applying is a no-op. Tracker advances 76 → 77.
-- 1. paliad.projects.counterclaim_of ---------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS counterclaim_of uuid NULL
REFERENCES paliad.projects(id) ON DELETE SET NULL;
COMMENT ON COLUMN paliad.projects.counterclaim_of IS
'When non-NULL this project is the CCR (counterclaim) filed against '
'the referenced parent project. parent_id continues to govern the '
'project tree (CCR is placed as a sibling under the same patent — '
'see ProjectService.CreateCounterclaim). ON DELETE SET NULL keeps '
'the CCR row alive when the parent is hard-deleted (rare; default '
'is archival) so the audit trail survives.';
CREATE INDEX IF NOT EXISTS projects_counterclaim_of_idx
ON paliad.projects (counterclaim_of)
WHERE counterclaim_of IS NOT NULL;
-- 2. Two-level-CCR rejection trigger ---------------------------------------
CREATE OR REPLACE FUNCTION paliad.projects_no_two_level_ccr() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- A project that is itself a CCR may NOT be the target of another CCR.
-- Two cases to reject:
--
-- (a) NEW row points at a parent that is itself a CCR:
-- NEW.counterclaim_of -> some row with counterclaim_of NOT NULL.
--
-- (b) NEW row claims to be a CCR (NEW.counterclaim_of IS NOT NULL)
-- but already has another CCR pointing AT it (NEW.id is the
-- target of some other row's counterclaim_of). The cleaner
-- phrasing: "no row may simultaneously have a CCR child AND
-- a CCR parent".
IF NEW.counterclaim_of IS NOT NULL THEN
IF EXISTS (
SELECT 1 FROM paliad.projects p
WHERE p.id = NEW.counterclaim_of
AND p.counterclaim_of IS NOT NULL
) THEN
RAISE EXCEPTION
'two-level counterclaim chains are not allowed: parent project % is itself a counterclaim',
NEW.counterclaim_of;
END IF;
IF EXISTS (
SELECT 1 FROM paliad.projects p
WHERE p.counterclaim_of = NEW.id
) THEN
RAISE EXCEPTION
'project % already has a counterclaim child and cannot itself be a counterclaim',
NEW.id;
END IF;
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION paliad.projects_no_two_level_ccr() IS
'Rejects two-level counterclaim chains. UPC practice does not have '
'CCR-of-a-CCR; reject the malformed shape at write time so the app '
'layer never has to defend against it. See migration 077.';
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
CREATE TRIGGER projects_no_two_level_ccr
BEFORE INSERT OR UPDATE OF counterclaim_of ON paliad.projects
FOR EACH ROW
EXECUTE FUNCTION paliad.projects_no_two_level_ccr();

View File

@@ -163,6 +163,14 @@ type Project struct {
// claimant, defendant, court, both.
OurSide *string `db:"our_side" json:"our_side,omitempty"`
// CounterclaimOf is the parent project this row is a counterclaim
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
// regular projects; non-NULL rows are CCR sub-projects rendered as
// the parallel right-track on the parent's SmartTimeline. parent_id
// keeps governing the project tree — the CCR child is placed as a
// sibling under the same patent (§4.4 of the design doc).
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,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"`

View File

@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number, matter_number,
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
proceeding_type_id, our_side, counterclaim_of, metadata, ai_summary, created_at, updated_at`
// CreateProjectInput is the payload for Create.
type CreateProjectInput struct {
@@ -122,6 +122,13 @@ type CreateProjectInput struct {
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
// CounterclaimOf marks this project as a CCR sub-project filed
// against the referenced parent project (t-paliad-174 Slice 3).
// Set by ProjectService.CreateCounterclaim — direct callers of
// Create rarely need it. The two-level-CCR rejection trigger
// (migration 077) will reject malformed shapes regardless.
CounterclaimOf *uuid.UUID `json:"counterclaim_of,omitempty"`
}
// UpdateProjectInput is the partial-update payload.
@@ -831,9 +838,10 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
(id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number,
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
$14, $15, $16, $17, $18, $19, $20, $21, $22, '{}'::jsonb, $23, $23)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
@@ -842,6 +850,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
nullableOurSide(input.OurSide),
input.CounterclaimOf,
now,
); err != nil {
return nil, fmt.Errorf("insert project: %w", err)
@@ -1096,6 +1105,259 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
return tx.Commit()
}
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
// to the design defaults: proceeding_type_id = UPC_REV, our_side = inverted
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
// patent reference is resolvable, else "<parent title> — Widerklage".
//
// FlipOurSide is a tri-state via *bool to distinguish "default-flip" (nil)
// from the explicit "Stimmt nicht?" override (false = keep parent's side,
// true = flip explicitly). The R.49.2.b CCI edge case is the reason this
// override exists (see docs/design-smart-timeline-2026-05-08.md §11 Q2).
type CounterclaimOpts struct {
ProceedingTypeID *int
FlipOurSide *bool
Title *string
CaseNumber *string
}
// LoadCounterclaimChildrenVisible returns the CCR sub-projects filed
// against parentID that the caller can see. Each row is a normal
// paliad.projects row with counterclaim_of=parentID. Used by the
// SmartTimeline to render parallel right-tracks (t-paliad-174 §4.5).
func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, userID, parentID uuid.UUID) ([]models.Project, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.Project{}, nil
}
rows := []models.Project{}
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
WHERE p.counterclaim_of = $1
AND ` + visibilityPredicatePositional("p", 2) + `
ORDER BY p.created_at ASC, p.id ASC`
if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
return nil, fmt.Errorf("load counterclaim children: %w", err)
}
return rows, nil
}
// CreateCounterclaim creates a CCR sub-project against parentID. Atomic:
// project + creator-as-lead team membership + audit rows on parent AND
// child are all written in a single transaction.
//
// Placement (§4.4): the CCR child is a sibling under the same patent —
// child.parent_id = parent.parent_id. When the parent has no parent_id
// (root case at the top of its tree) we fall back to parent.id as the
// CCR child's parent so the row remains in the same subtree.
//
// our_side flip (§11 Q2): default-inverts claimant↔defendant; "court"
// and "both" pass through unchanged. The opts.FlipOurSide override
// supports the rare R.49.2.b CCI shape where flipping is wrong.
//
// proceeding_type_id default (§4.4): UPC_REV for the standard CCR-on-
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
// explicitly when they want it.
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
}
parent, err := s.GetByID(ctx, userID, parentID)
if err != nil {
return nil, err
}
if parent.CounterclaimOf != nil {
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
}
// Resolve proceeding_type_id default to UPC_REV when caller didn't
// override. The DB row is required because the projection layer
// dereferences it (paliad.proceeding_types.code).
procTypeID := 0
if opts.ProceedingTypeID != nil {
procTypeID = *opts.ProceedingTypeID
} else {
err := s.db.GetContext(ctx, &procTypeID,
`SELECT id FROM paliad.proceeding_types
WHERE code = 'UPC_REV' AND is_active = true`)
if err != nil {
return nil, fmt.Errorf("resolve default UPC_REV proceeding type: %w", err)
}
}
childOurSide := derivedCounterclaimOurSide(parent.OurSide, opts.FlipOurSide)
childParentID := parent.ParentID
if childParentID == nil {
// Parent has no parent_id (root case at the top of its tree).
// Fall back to parent.id so the CCR child stays in the same
// subtree rather than becoming a new root. The visibility
// predicate inherits cleanly either way.
fallback := parent.ID
childParentID = &fallback
}
// Resolve the best patent reference for the suggested title — when
// parent is a case, the patent_number lives on its patent ancestor.
patentRef := s.resolvePatentReferenceForTitle(ctx, userID, parent)
title := derivedCounterclaimTitle(parent, patentRef, opts.Title)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, 'case', $2, $1::text, $3, 'active', $4,
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
id, childParentID, title, userID,
parent.Court, opts.CaseNumber, procTypeID,
nullableOurSide(&childOurSide), parentID, now,
); err != nil {
return nil, fmt.Errorf("insert counterclaim project: %w", err)
}
// Auto-add creator as team lead on the new CCR row so RLS lets the
// caller see the project they just made. Mirrors Create.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
return nil, fmt.Errorf("insert creator team row: %w", err)
}
// Audit rows on both parent and child for symmetric trail. Both rows
// opt into the SmartTimeline via timeline_kind='milestone'.
if err := insertCounterclaimEvent(ctx, tx, id, userID,
"Widerklage (CCR) angelegt",
map[string]any{"counterclaim_of": parentID.String()},
); err != nil {
return nil, err
}
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
"Widerklage (CCR) angelegt",
map[string]any{"counterclaim_id": id.String()},
); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create counterclaim: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// insertCounterclaimEvent writes a paliad.project_events row with
// event_type='counterclaim_created' AND timeline_kind='milestone' so
// the audit row surfaces on the SmartTimeline by default. Matches the
// pattern Slice 1 established for opt-in milestones (§2.2).
func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, title string, meta map[string]any) error {
now := time.Now().UTC()
metaJSON := json.RawMessage(`{}`)
if len(meta) > 0 {
b, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("marshal counterclaim_created metadata: %w", err)
}
metaJSON = b
}
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'counterclaim_created', $3, NULL, $4, $5, $6, $4, $4, 'milestone')`,
uuid.New(), projectID, title, now, userID, metaJSON)
if err != nil {
return fmt.Errorf("insert counterclaim_created event: %w", err)
}
return nil
}
// derivedCounterclaimOurSide computes the child's our_side from the
// parent's our_side and the opts.FlipOurSide override.
//
// Default (override nil OR override=true): claimant ↔ defendant, court
// and both pass through unchanged. NULL parent yields NULL child — the
// flip is meaningless without a known starting side.
//
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
// edge case where the CCR sub-project shares the parent's perspective.
func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
if parentSide == nil {
return ""
}
side := strings.TrimSpace(*parentSide)
flip := true
if override != nil {
flip = *override
}
if !flip {
return side
}
switch side {
case "claimant":
return "defendant"
case "defendant":
return "claimant"
default:
return side
}
}
// resolvePatentReferenceForTitle returns the closest patent_number /
// reference to use as the CCR title prefix. Parent is usually a case
// row (no patent_number on it) — walks up ancestors to find the patent
// hub. Best-effort: returns empty when no patent ancestor is visible.
func (s *ProjectService) resolvePatentReferenceForTitle(ctx context.Context, userID uuid.UUID, parent *models.Project) string {
if parent.PatentNumber != nil && strings.TrimSpace(*parent.PatentNumber) != "" {
return strings.TrimSpace(*parent.PatentNumber)
}
ancestors, err := s.ListAncestors(ctx, userID, parent.ID)
if err != nil || len(ancestors) == 0 {
return ""
}
for i := len(ancestors) - 1; i >= 0; i-- {
a := ancestors[i]
if a.PatentNumber != nil && strings.TrimSpace(*a.PatentNumber) != "" {
return strings.TrimSpace(*a.PatentNumber)
}
}
return ""
}
// derivedCounterclaimTitle picks the auto-suggested title for the CCR
// child. Override wins when supplied; otherwise prefers the patent
// reference, then parent.reference, then parent.title — each yields
// "<ref> — Widerklage (CCR)".
func derivedCounterclaimTitle(parent *models.Project, patentRef string, override *string) string {
if override != nil {
v := strings.TrimSpace(*override)
if v != "" {
return v
}
}
suffix := " — Widerklage (CCR)"
if patentRef != "" {
return patentRef + suffix
}
if parent.Reference != nil && strings.TrimSpace(*parent.Reference) != "" {
return strings.TrimSpace(*parent.Reference) + suffix
}
return strings.TrimSpace(parent.Title) + suffix
}
// MaxEventsPageLimit caps ListEvents page size.
const MaxEventsPageLimit = 200