# 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 · " (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.