# User-authored checklists: authoring, sharing, admin-promotion

**Task:** t-paliad-225 — Gitea m/paliad#61
**Inventor:** dirac, 2026-05-20
**Branch:** `mai/dirac/user-checklists`
**Status:** DESIGN READY FOR REVIEW

## 1. Problem statement

Paliad ships a curated catalog of UPC / DE / EPA checklists today
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
on Akten and check items off; per-instance state lives in
`paliad.checklist_instances` and is gated by the parent project's
team-based visibility.

m wants three new capabilities (m 2026-05-20 14:14):

1. **User-authored templates** — any non-`global_admin` can create a
   checklist template they own (title, sections, items, references).
2. **Sharing** — author shares with specific colleagues, an Office, a
   Dezernat (partner-unit), a project team, or the whole firm.
3. **Admin promotion to global** — `global_admin` promotes an authored
   template into the firm-wide catalog so it appears alongside the
   curated UPC/DE/EPA templates for every user.

This design covers all three across three sequential slices.

## 2. Premises verified live (load-bearing findings)

The Gitea issue body says "Add `owner_id uuid NULL` to
`paliad.checklists`". That table **does not exist**. Verifying against
the live DB and the code corrected several premises:

- **`paliad.checklists` does NOT exist as a DB table.** Templates today
  are pure Go data in `internal/checklists/templates.go` (6 entries,
  ~310 lines), served by `internal/handlers/checklists.go` via
  `checklists.Summaries()` and `checklists.Find(slug)`. The DB has
  `paliad.checklist_instances` (per-user state) and
  `paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
  design has to introduce `paliad.checklists` from scratch.

- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
  validity is enforced in `ChecklistInstanceService.Create` against the
  static Go registry. This is what lets the design keep the static
  catalog as one source of truth and add the DB catalog as a parallel
  source: instance creation just resolves the slug against the merged
  view and snapshots the template body.

- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
  on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
  109 user_dashboard_layouts, 110 project_type_other, 111
  project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
  today). At inventor time the next free slot is **112**. The coder
  MUST re-verify with `ls internal/db/migrations/ | tail` at shift
  start — the slot can drift if other branches merge first.

- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
  migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
  STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
  branches on global_admin shortcut + project_teams responsibility =
  'admin'. **Used by this design** to gate the "Make global" button (we
  reuse the global_admin shortcut, not the project-admin branch — see
  §4.4) and as the precedent for any new STABLE SECURITY DEFINER
  predicates we add.

- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
  Columns: `event_type` (free-text), `actor_id`, `actor_email`,
  `scope` ∈ {org, project, personal}, `scope_root uuid`,
  `metadata jsonb`. RLS: self-read for the actor +
  global_admin read-all. **Pattern to follow:** insert event row at
  state transition (see `ExportService.WriteAuditRow` in
  `internal/services/export_service.go:1120` for the canonical shape).

- **`paliad.project_events`** is the project-timeline audit sink and is
  already wired for checklist instance lifecycle events
  (`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
  `_deleted`). We do NOT need to invent a new event_type for instance
  events; we'll add a few `_snapshot_taken` / template-level events to
  `system_audit_log` and keep instance events on `project_events`.

- **`paliad.users.office`** is `text` (CHECK against the office key
  list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
  hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
  have `additional_offices text[]`. Both are first-class columns; no
  separate `offices` table.

- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
  timestamps) is the Dezernat / practice-group table. Membership lives
  in `paliad.partner_unit_members`. Projects attach via
  `paliad.project_partner_units` (with derivation flags). All three
  are referenceable from a share recipient.

- **`paliad.users.global_role`** is `text`; values include
  `'global_admin'`. Used for the firm-wide promote/demote authority.

- **`paliad.project_teams`** (mig 111 just added) carries
  `responsibility` ∈ {admin, lead, member, observer, external}. We
  reuse `can_see_project` (visibility) for share-to-project recipients,
  NOT `effective_project_admin`. The semantic of "share with a project
  team" is "anyone on the matter sees it", not "anyone who can edit
  membership sees it".

- **No precedent for entity-level sharing in paliad.** The personal-
  sidecar tables (`user_views`, `user_dashboard_layouts`,
  `user_pinned_projects`, `user_card_layouts`) are owner-only with no
  share columns. Existing visibility predicates
  (`paliad.can_see_project`) walk the project tree, not arbitrary
  entities. This design introduces the first multi-axis share pattern
  in the codebase (§3.2).

## 3. Architecture: hybrid templates + share table

### 3.1 Two template sources, one read layer

**KEEP** the static Go template registry as the firm's curated catalog.
It's version-controlled, code-reviewed, immutable at runtime, and the
right substrate for legally-curated content (RoP citations, EPC rule
references). Migrating those into DB rows would lose the git review
trail for content that requires lawyer eyes.

**ADD** `paliad.checklists` as the DB catalog for user-authored content.
Same Template shape (slug, titles, regime, court, groups[], items[])
but stored as JSONB so the schema doesn't have to chase content
evolution.

A `ChecklistCatalogService` unifies the two at read time:
- `ListVisible(user)` → static templates ∪ DB rows the user can see
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
  rejected if they collide with a static slug).

Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
their JSON shape — they just delegate to the catalog service instead of
the bare static registry.

### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred

The task brief asks for a "modular / abstract" solution. I considered a
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
recipient_*)` table that could later carry shares for views, dashboards,
saved searches, project templates, etc.

**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
v1.** Reasons:

1. There is NO second entity in paliad that requests sharing today —
   `user_views`, `user_dashboard_layouts`, `user_card_layouts`,
   `user_pinned_projects` are all explicitly owner-only by design (see
   migration comments). The "future reuse" is hypothetical.
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
   needs its own deletion trigger. That complexity is real, the
   reusability gain is not.
3. The CORRECT abstraction emerges by extracting *after* the second use
   case shows up. Right now we don't know whether dashboards want the
   same recipient axes (user / office / partner-unit / project) or a
   different set (e.g. dashboards probably want "everyone on a project"
   not "the whole firm").

The design IS modular in the sense that the recipient resolution logic
(below) is centralized in one SQL predicate (§4.3) which a future
polymorphic refactor can lift verbatim.

If the second entity asks for sharing within ~3 months, refactor to
`paliad.entity_shares` as a single-mig follow-up. Until then,
`paliad.checklist_shares` keeps the schema honest.

### 3.3 Visibility states

`paliad.checklists.visibility text` (CHECK enum):

| state     | who sees                                           | who edits           |
|-----------|----------------------------------------------------|---------------------|
| `private` | owner only                                         | owner               |
| `shared`  | owner + explicit recipients in checklist_shares    | owner               |
| `firm`    | owner + every authenticated paliad user            | owner               |
| `global`  | owner + every authenticated paliad user + catalog  | owner + global_admin|

`firm` vs `global` distinction:
- `firm` = author self-published. Author can flip back to private/shared
  any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
  in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
- `global` = admin-promoted into the firm catalog. Appears in the main
  Vorlagen tab alongside the static templates. Author retains edit
  authority by default; only `global_admin` can demote.

Demotion target: `global → firm` (preserves visibility for users who
already started instances). Author can subsequently narrow further.

### 3.4 Template snapshot on instance create

m's brief calls this out as a design decision: when an author edits a
template, do existing instances pick up the changes (propagate) or stay
on the version they were created from (snapshot)?

**Pick: snapshot.** Inventor pick (R). Rationale:

1. **Data integrity.** Instances are working artefacts. A user halfway
   through a Klageerwiderung instance shouldn't have items disappear or
   reorder under them because the author edited the template.
2. **Audit story.** The completed instance shows exactly what the
   author saw when they started. Reconstruction without git-blame on
   the template.
3. **Visibility narrowing safe by construction.** If author unshares
   from a colleague who already has an instance, the instance survives
   because the snapshot is local.
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
   exceed a few per user per template. Even 10× the row size of today
   is fine.

Schema cost: one nullable `template_snapshot jsonb` column on
`paliad.checklist_instances`. Backfilled lazily — existing instances
keep `NULL`, service falls back to looking the slug up in the catalog;
new instances always get a snapshot. Slice C can backfill the column
for already-existing rows via a one-off `UPDATE` if we want strict
consistency.

## 4. Schema (migration 112 — verify slot at coder shift)

Single migration file `internal/db/migrations/112_user_checklists.up.sql`
+ matching `.down.sql`. Idempotent throughout
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).

> Slot caveat: at design time, latest disk = 111, live tracker = 106
> (mig 107-111 pending deploy). Coder MUST re-verify
> `ls internal/db/migrations/ | tail` at shift start. If a higher
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
> 112), bump to the next free slot.

### 4.1 `paliad.checklists` — authored template catalog

```sql
CREATE TABLE paliad.checklists (
    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    slug         text NOT NULL UNIQUE,
    -- Authoring metadata
    owner_id     uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
    title        text NOT NULL,
    description  text NOT NULL DEFAULT '',
    regime       text NOT NULL DEFAULT 'OTHER',   -- UPC | DE | EPA | OTHER
    court        text NOT NULL DEFAULT '',
    reference    text NOT NULL DEFAULT '',
    deadline     text NOT NULL DEFAULT '',
    lang         text NOT NULL DEFAULT 'de',      -- 'de' | 'en' — author's primary language
    -- Body
    body         jsonb NOT NULL,                  -- { groups: [{ title, items: [{ label, note, rule }] }] }
    -- Lifecycle
    visibility   text NOT NULL DEFAULT 'private'
                  CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
    promoted_at  timestamptz,                     -- set on transition to 'global'
    promoted_by  uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
    -- Timestamps
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX checklists_owner_idx      ON paliad.checklists (owner_id);
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
CREATE INDEX checklists_regime_idx     ON paliad.checklists (regime);
```

**Slug-collision safety net:** application layer validates that the
chosen slug doesn't collide with a static template slug. The static
list is loaded into a `map[string]bool` at boot. New authored slugs
auto-prefixed with `u-` so collisions with static slugs are structurally
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).

### 4.2 `paliad.checklist_shares` — explicit grants

```sql
CREATE TABLE paliad.checklist_shares (
    id                       uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    checklist_id             uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
    recipient_kind           text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
    recipient_user_id        uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
    recipient_office         text,
    recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
    recipient_project_id     uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
    granted_by               uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
    granted_at               timestamptz NOT NULL DEFAULT now(),

    -- XOR check: exactly one recipient_* column populated per kind
    CONSTRAINT checklist_shares_recipient_xor CHECK (
        (recipient_kind = 'user'         AND recipient_user_id        IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
     OR (recipient_kind = 'office'       AND recipient_office         IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
     OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
     OR (recipient_kind = 'project'      AND recipient_project_id     IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
    )
);

-- Avoid duplicates per recipient
CREATE UNIQUE INDEX checklist_shares_user_uniq         ON paliad.checklist_shares (checklist_id, recipient_user_id)         WHERE recipient_kind = 'user';
CREATE UNIQUE INDEX checklist_shares_office_uniq       ON paliad.checklist_shares (checklist_id, recipient_office)          WHERE recipient_kind = 'office';
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
CREATE UNIQUE INDEX checklist_shares_project_uniq      ON paliad.checklist_shares (checklist_id, recipient_project_id)      WHERE recipient_kind = 'project';

-- Hot-path index for the visibility predicate
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
```

### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate

```sql
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $$
    -- Owner can always see
    SELECT EXISTS (
        SELECT 1 FROM paliad.checklists c
         WHERE c.id = _checklist_id
           AND c.owner_id = _user_id
    )
    -- 'firm' / 'global' visible to all authenticated users
    OR EXISTS (
        SELECT 1 FROM paliad.checklists c
         WHERE c.id = _checklist_id
           AND c.visibility IN ('firm', 'global')
    )
    -- Explicit share: user
    OR EXISTS (
        SELECT 1 FROM paliad.checklist_shares s
         WHERE s.checklist_id = _checklist_id
           AND s.recipient_kind = 'user'
           AND s.recipient_user_id = _user_id
    )
    -- Explicit share: office (matches user.office OR additional_offices)
    OR EXISTS (
        SELECT 1
          FROM paliad.checklist_shares s
          JOIN paliad.users u ON u.id = _user_id
         WHERE s.checklist_id = _checklist_id
           AND s.recipient_kind = 'office'
           AND (s.recipient_office = u.office
                OR s.recipient_office = ANY(u.additional_offices))
    )
    -- Explicit share: partner_unit (caller is a member)
    OR EXISTS (
        SELECT 1
          FROM paliad.checklist_shares s
          JOIN paliad.partner_unit_members pum
            ON pum.partner_unit_id = s.recipient_partner_unit_id
           AND pum.user_id = _user_id
         WHERE s.checklist_id = _checklist_id
           AND s.recipient_kind = 'partner_unit'
    )
    -- Explicit share: project (caller can see the project via existing predicate)
    OR EXISTS (
        SELECT 1 FROM paliad.checklist_shares s
         WHERE s.checklist_id = _checklist_id
           AND s.recipient_kind = 'project'
           AND paliad.can_see_project(s.recipient_project_id)  -- reuses ltree walk
    );
$$;
```

> Note on `can_see_project` self-reference: that function reads
> `auth.uid()` internally — when called from inside another SECURITY
> DEFINER body it picks up the caller's uid via search_path inheritance
> (same pattern as `effective_project_admin` reuse in mig 111).

### 4.4 RLS on `paliad.checklists`

```sql
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;

-- SELECT: owner OR visible via can_see_checklist
CREATE POLICY checklists_select
    ON paliad.checklists FOR SELECT TO authenticated
    USING (paliad.can_see_checklist(auth.uid(), id));

-- INSERT: caller can only create templates owned by themselves
CREATE POLICY checklists_insert
    ON paliad.checklists FOR INSERT TO authenticated
    WITH CHECK (owner_id = auth.uid());

-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
CREATE POLICY checklists_update
    ON paliad.checklists FOR UPDATE TO authenticated
    USING (
        owner_id = auth.uid()
        OR EXISTS (
            SELECT 1 FROM paliad.users u
             WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
        )
    )
    WITH CHECK (
        owner_id = auth.uid()
        OR EXISTS (
            SELECT 1 FROM paliad.users u
             WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
        )
    );

-- DELETE: owner OR global_admin
CREATE POLICY checklists_delete
    ON paliad.checklists FOR DELETE TO authenticated
    USING (
        owner_id = auth.uid()
        OR EXISTS (
            SELECT 1 FROM paliad.users u
             WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
        )
    );
```

### 4.5 RLS on `paliad.checklist_shares`

```sql
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;

-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
CREATE POLICY checklist_shares_select
    ON paliad.checklist_shares FOR SELECT TO authenticated
    USING (
        EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
        OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
        OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
    );

-- INSERT: only the checklist owner can grant
CREATE POLICY checklist_shares_insert
    ON paliad.checklist_shares FOR INSERT TO authenticated
    WITH CHECK (
        EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
        AND granted_by = auth.uid()
    );

-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
CREATE POLICY checklist_shares_delete
    ON paliad.checklist_shares FOR DELETE TO authenticated
    USING (
        EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
        OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
    );
```

### 4.6 `paliad.checklist_instances.template_snapshot jsonb`

```sql
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
ALTER TABLE paliad.checklist_instances
    ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
```

Existing RLS on `checklist_instances` untouched.

## 5. Service layer

### 5.1 `internal/services/checklist_catalog_service.go` (new)

Unified read facade over static + DB templates.

```go
type ChecklistCatalogService struct {
    db *sqlx.DB
}

type CatalogEntry struct {
    Slug          string          // matches checklists.Template.Slug or paliad.checklists.slug
    Origin        string          // "static" | "authored"
    OwnerID       *uuid.UUID      // nil for static
    OwnerName     string          // empty for static
    Visibility    string          // "static" | "private" | "shared" | "firm" | "global"
    Template      checklists.Template
}

// ListVisible returns every catalog entry the caller can see.
// Static entries are always returned. DB entries pass through RLS.
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)

// Find returns one entry by slug (static lookup first, then DB).
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)

// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
```

### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)

CRUD on `paliad.checklists`.

```go
type ChecklistTemplateService struct {
    db    *sqlx.DB
    users *UserService
}

type CreateTemplateInput struct {
    Title       string
    Description string
    Regime      string
    Court       string
    Reference   string
    Deadline    string
    Lang        string
    Body        checklists.Template   // unmarshalled to body jsonb minus slug/titles/etc
}

func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error  // private/firm only
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
```

Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
suffix (collision retry up to 3x). Validator enforces
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
`internal/checklists/checklists.go` Templates rejected at write time.

### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)

```go
type ChecklistShareService struct { db *sqlx.DB }

type ShareGrantInput struct {
    RecipientKind string
    UserID        *uuid.UUID
    Office        string
    PartnerUnitID *uuid.UUID
    ProjectID     *uuid.UUID
}

func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
```

### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)

`global_admin`-only operations.

```go
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }

func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
```

Promote: assert caller.global_role = 'global_admin' → UPDATE visibility =
'global', promoted_at = now(), promoted_by = caller → audit row
`event_type='checklist.promoted_global'`.

Demote: assert caller is global_admin → UPDATE visibility = target
(default 'firm') → audit row `event_type='checklist.demoted'`.

### 5.5 Wire instance create to take snapshot

`ChecklistInstanceService.Create` extends to capture
`template_snapshot` at insert time via
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
(NULL snapshot, fallback path in read layer).

### 5.6 Endpoints

| Method | Path | Slice | Purpose |
|--------|------|-------|---------|
| `GET`    | `/api/checklists`                       | (existing)| Merged catalog list (static + visible DB) |
| `GET`    | `/api/checklists/{slug}`                | (existing)| Single template (static or DB) |
| `POST`   | `/api/checklists/templates`             | A | Create authored template |
| `GET`    | `/api/checklists/templates/mine`        | A | List own authored templates |
| `PATCH`  | `/api/checklists/templates/{slug}`      | A | Edit authored template |
| `DELETE` | `/api/checklists/templates/{slug}`      | A | Delete authored template |
| `PATCH`  | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
| `GET`    | `/api/checklists/templates/{slug}/shares`     | B | List grants |
| `POST`   | `/api/checklists/templates/{slug}/shares`     | B | Grant share |
| `DELETE` | `/api/checklists/shares/{id}`           | B | Revoke share |
| `POST`   | `/api/admin/checklists/{slug}/promote`  | B | Admin promote to global |
| `POST`   | `/api/admin/checklists/{slug}/demote`   | B | Admin demote |
| `GET`    | `/api/checklists/gallery`               | C | Browse all firm + global templates |

## 6. Instance snapshot lifecycle

**On Create (`ChecklistInstanceService.Create`):**
1. Resolve slug via `catalog.Find(userID, slug)` — enforces visibility.
2. `snapshot = catalog.SnapshotBody(userID, slug)` — captures the
   template body (groups + items) at this moment, as JSONB.
3. Insert into `checklist_instances` with
   `template_snapshot = snapshot`, `template_slug = slug`,
   `state = '{}'::jsonb`.

**On Read (`ChecklistInstanceService.GetByID`):**
- Return the instance with `template_snapshot` if non-null.
- If NULL (legacy row created before mig 112), fall back to
  `catalog.Find(slug)`. Logged at INFO; not a fatal path.

**On Template Edit (Slice A):**
- Owner edits template via PATCH → DB row mutated → `checklists.updated_at`
  bumped → no propagation. Existing instances continue rendering their
  snapshot. New instances pick up the edit.
- Audit row `event_type='checklist.edited'`,
  `metadata={ checklist_id, slug, changes:[...] }`.

**On Template Delete:**
- DB row deleted. Instances that snapshotted survive (snapshot is
  local). Instances that DIDN'T snapshot (NULL) gracefully degrade —
  service detects "template not found in catalog" and returns the
  instance with a sentinel "template withdrawn" body (renders a small
  banner client-side; checkboxes still work because `state` is the
  source of truth, not the template).

**On Visibility Narrow (firm → shared → private):**
- Existing instances unaffected (snapshot is local; visibility check is
  on the template, not instance).
- New instance attempts fail with `ErrNotVisible` (the user can no
  longer see the template to instantiate it).

## 7. Frontend (concise sketch — coder owns the detail)

### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"

Add a third tab between "Vorlagen" and "Vorhandene Instanzen":

```
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
```

- **Vorlagen** (existing): static catalog + global-promoted DB
  templates, grouped by Regime, filter pills (UPC/DE/EPA).
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
  Vorlage" CTA. Each card shows title, description, visibility chip,
  Aktions-Buttons (Bearbeiten / Teilen / Löschen).
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
  optionally render an "📌 Snapshot" badge when `template_snapshot` is
  non-null (Slice A backfill marker).

Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
templates not yet promoted — discovery surface).

### 7.2 `/checklists/new` (NEW — Slice A)

Authoring wizard. Three steps:
1. Metadata — title, description, regime (UPC/DE/EPA/OTHER), court,
   reference, deadline.
2. Sections + items — repeating editor (group title → items[] of
   {label, note, rule}).
3. Visibility — radio: privat / firm-weit. (Sharing flow comes in
   Slice B.)

Save → POST `/api/checklists/templates` → redirect to
`/checklists/{slug}` detail.

### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)

Same wizard, prefilled. Owner-only (404 otherwise).

### 7.4 `/checklists/{slug}` detail page

Existing detail page renders the template (static OR authored).
Additions:
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
  entfernen" button (Slice B).
- Provenance line under the title: "Erstellt von <author> · <date>"
  (only for DB templates).

### 7.5 Share modal (Slice B)

Triggered by "Teilen" on owner's detail page. Four pickers stacked:
- Kollegen (user-picker, multi-select)
- Office (chip-select from `offices.All`)
- Dezernat (chip-select from `partner_units`)
- Projekt (autocomplete from owner-visible projects)

Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
"firm-weit" greys out the picker (firm-weit doesn't need grants).

Apply → POST grants individually → audit emits one
`event_type='checklist.shared'` per grant with
`metadata={ recipient_kind, recipient_id, checklist_id }`.

### 7.6 i18n keys

~28 new keys (DE+EN) under `checklisten.authoring.*`,
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
matches existing `checklisten.tab.*` / `checklisten.instances.*`.

## 8. Audit events

Org-scope (`paliad.system_audit_log` via a small new helper
`SystemAuditLogService.WriteChecklistEvent`):

| event_type                       | actor       | metadata keys                                      |
|----------------------------------|-------------|----------------------------------------------------|
| `checklist.authored`             | owner       | checklist_id, slug, visibility                     |
| `checklist.edited`               | owner       | checklist_id, slug, changed_fields[]               |
| `checklist.visibility_changed`   | owner       | checklist_id, slug, from, to                       |
| `checklist.shared`               | owner       | checklist_id, slug, recipient_kind, recipient_id   |
| `checklist.unshared`             | owner       | checklist_id, slug, recipient_kind, recipient_id   |
| `checklist.promoted_global`      | global_admin| checklist_id, slug, owner_id                       |
| `checklist.demoted`              | global_admin| checklist_id, slug, target_visibility              |
| `checklist.deleted`              | owner OR ga | checklist_id, slug, was_visibility                 |

Project-scope (`paliad.project_events` — existing helper
`insertProjectEventWithMeta`): existing checklist-instance events
unchanged. NO new project_events types for templates — templates are
not project-scoped.

`AuditService.ListEntries` already reads from `system_audit_log` via
the UNION ALL branch added in t-paliad-214 — no changes needed there;
new event_types surface automatically in the audit log UI.

## 9. Slice plan

### Slice A — Foundation (~700 LoC)

**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
no share table yet; visibility limited to private/firm.

**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
`SystemAuditLogService.WriteChecklistEvent` helper.

**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.

**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
`/checklists/{slug}/edit`, owner controls on detail page.

**Test pass:** unit tests for slug validation, snapshot capture,
visibility predicate (without share rows), audit emit, fallback to
catalog when snapshot NULL.

**No share, no admin promote, no gallery.** Ships immediately useful
for solo authoring + firm-wide publishing.

### Slice B — Sharing + Promotion (~600 LoC)

**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
sub-enum (Slice A schema already includes 'shared' as valid value —
just no grants point at it yet).

**Service:** `ChecklistShareService`, `ChecklistPromotionService`.

**Endpoints:** shares endpoints + admin promote/demote.

**Frontend:** Share modal, "Make global" admin button on detail page,
share-grant chip list on detail page (owner-only).

**Audit:** new event_types (shared, unshared, promoted_global, demoted).

### Slice C — Discoverability + UX polish (~400 LoC)

**Gallery page** `/checklists/gallery`: browses every template the user
can see that's NOT their own, grouped by Regime / Author / Recency.
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.

**Backfill** existing `checklist_instances` with `template_snapshot`
via a one-off migration (mig 114) — pure data move, no schema change.
After backfill, the catalog-fallback path can be removed (deferred to
Slice D / cleanup).

**Optional**:
- "Vorlage kopieren" action — clone an existing template (static OR
  authored) into the caller's "Meine Vorlagen" for personal adaptation.
- Per-template instance counter ("12 Kollegen haben diese Vorlage
  benutzt") — surfaced from `checklist_instances` group-by.

## 10. Trade-offs flagged

1. **Hybrid catalog (static + DB).** Two sources of truth means two
   slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
   reserved-list rejection. Refactoring all static templates into DB
   loses the git review trail; the hybrid is the right cost.
2. **Polymorphism deferred.** A future second sharable entity will need
   to either copy the `checklist_shares` pattern (cheap but duplicative)
   or refactor to `entity_shares` (one mig). The refactor is small;
   premature abstraction now would pay complexity for no current
   benefit.
3. **Snapshot semantics may surprise.** A user who edits their template
   expecting downstream instances to update will be confused.
   Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
   neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
   detail page that re-snapshots from the current template (preserves
   the user's checkbox state to the extent items still match).
4. **Office membership is set-membership, not hierarchy.** Sharing to
   "munich" reaches every user with `office='munich'` OR
   `'munich' = ANY(additional_offices)`. There's no concept of "Munich
   plus its sub-teams" because offices don't nest in paliad. Fine.
5. **Partner-unit membership join is N+1 on the predicate.** Each
   visibility check touches `partner_unit_members` if any partner-unit
   share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
   already exist (per mig 027 lineage); the join is single-row.
6. **Share-to-project recipient resolution uses
   `can_see_project(s.recipient_project_id)`.** That predicate reads
   `auth.uid()` from the session, so it works correctly inside our
   SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
   in `paliad.can_see_project` source — same pattern that
   `effective_project_admin` uses in mig 111.
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
   Means a global_admin can edit content of any user's template, not
   just visibility. This is intentional for catalog hygiene
   (correcting typos, removing inflammatory content) but should be used
   sparingly and audited. The audit log captures every
   global_admin-attributed edit via `checklist.edited` with actor_id.
8. **Instance snapshot fallback path lives indefinitely.** Existing
   pre-mig-112 instances stay NULL until Slice C backfills. The
   fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
   no hot-path concern — but it's "dead code" once the backfill runs.
   Acceptable until Slice C.
9. **Cascade on owner deletion.** If an authored template's owner is
   removed (`paliad.users.id` cascades), the template is wiped along
   with all its shares. Existing instances survive via snapshot. The
   alternative (transfer ownership to global_admin on user-delete) is
   more polite but introduces governance questions ("which admin?")
   that aren't worth Slice A complexity. Flag for Slice C if it bites.
10. **Slug uniqueness across origins enforced application-side.**
    The static catalog is in-memory at boot. If a deploy adds a static
    slug that collides with an existing DB slug, the deploy boots
    cleanly but the DB row becomes unreachable via the catalog read
    layer (static wins on slug lookup). Mitigation: a boot-time
    integrity check in `cmd/server/main.go` logs WARN if collision
    detected. Owner can rename their template manually via the edit UI.

## 11. m's decisions ledger (all defaulted to (R) per task brief)

Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
material." I have not escalated; all picks below default to (R).

| # | Question                                                | (R) pick                                  |
|---|---------------------------------------------------------|-------------------------------------------|
| 1 | Storage model for authored templates                    | Hybrid: keep static catalog + new `paliad.checklists` DB table |
| 2 | Instance lifecycle on template edit                     | **Snapshot** at instance create (NOT propagate) |
| 3 | Visibility enum values                                  | `private`, `shared`, `firm`, `global`     |
| 4 | Share recipients                                        | user, office, partner_unit, project (4 axes) |
| 5 | Share-to-project resolution                             | Reuse `can_see_project` (visibility, not just team rows) |
| 6 | Promotion authority                                     | `global_admin` only (no per-project admin promote in v1) |
| 7 | Demotion target                                         | `global → firm` (preserves visibility for in-flight instances) |
| 8 | Slug strategy                                           | `u-` prefix on authored, application-side collision check vs static |
| 9 | Polymorphic share table (`entity_shares`) vs scoped     | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
| 10| Authoring i18n                                          | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
| 11| Audit sink for template lifecycle                       | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
| 12| Slice ordering                                          | A (foundation) → B (share + promote) → C (gallery + backfill) |

Material escalation list: empty. If m disagrees with any of the above,
amend §11 in the next inventor shift; the schema is designed to be
forward-compatible with most reversals (e.g. flipping snapshot →
propagate is a service-layer change, not a schema change).

## 12. Acceptance criteria — Slice A

1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
   on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
   `paliad` schema).
2. **`/api/checklists` returns merged catalog** — static templates
   plus DB templates the caller can see (visibility ∈ {firm, global}
   OR owner = caller).
3. **POST `/api/checklists/templates`** creates a row, returns the
   created template with auto-generated `u-…` slug, emits
   `checklist.authored` audit row.
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
   fields, rejects 403 from non-owner non-admin, emits
   `checklist.edited`.
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
   private↔firm; rejects `shared` and `global` in Slice A (those land
   in Slice B); emits `checklist.visibility_changed`.
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
   existing instances survive via snapshot.
7. **Instance create snapshots the template body** —
   `template_snapshot` non-null on every new instance row.
8. **Legacy instances (NULL snapshot) still render** via catalog
   fallback (covered by a regression test).
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
   CTA navigates to `/checklists/new`; wizard saves successfully.
10. **`go build ./... && go vet ./... && go test ./internal/...`
    clean.** `bun run build` clean (i18n key count incremented by ~20).
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
    template; setting visibility to `firm` makes it visible to a second
    tester account; deleting the template doesn't break existing
    instances.

## 13. Recommended implementer

Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
directive 2026-05-06). Substrate is well-trodden:

- Migration shape mirrors mig 111 (gauss) for the predicate function +
  policy replacement pattern.
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
  emit + visibility check.
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
- Frontend tab pattern mirrors the existing
  `entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.

Novel pieces:
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
  prototype before committing to the full slice. Pure function; easy
  to unit-test.
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
  into a STABLE SECURITY DEFINER function; pattern matches mig 111
  exactly.

Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
or one branch with three commits — coder's call. Each slice ends with
acceptance criteria; head merges between slices for fast feedback.

## 14. Out of scope (explicitly)

- Importing checklists from external sources (Notion, Trello, .docx).
- Approval-policy gating on checklist edits (admin pre-publish review).
- Cross-firm template marketplace.
- Translation workflow (de↔en) for authored templates — Slice A
  ships single-language; if firm appetite shows up post-launch, file
  a follow-up.
- Static-catalog editor UI (the static templates remain code-only).
- Versioning UI ("show me the version this instance was created from")
  — snapshot is captured; surfacing it is Slice C polish.

---

**Inventor parked per gate protocol.** No auto-shift to coder. Head
decides: same worker as `/mai-coder` with this brief, fresh coder, or
rescope. Slice ordering A → B → C is independent enough that the head
can also greenlight Slice A alone and re-design B/C after Slice A
ships.
