Files
paliad/docs/design-user-checklists-2026-05-20.md
mAi 1c8cdd3079 docs(checklists): t-paliad-225 inventor design — user-authored checklists (#61)
918-line design doc covering all three capabilities from m/paliad#61:
authoring, multi-axis sharing, admin-promotion to global.

Load-bearing premise correction: the issue body claims `paliad.checklists`
is an existing table that gets new columns. It is NOT — checklists today
are static Go data in `internal/checklists/templates.go`. Design
introduces `paliad.checklists` from scratch and keeps the static catalog
as a parallel source via a hybrid catalog read layer.

Schema (mig 112): `paliad.checklists` (owner + visibility enum), `paliad.checklist_shares`
(polymorphic recipient: user/office/partner_unit/project),
`paliad.can_see_checklist` predicate, `paliad.checklist_instances.template_snapshot`
column for instance integrity under template edits.

12 decisions ledgered, all defaulted to (R) per task brief (no AskUserQuestion).
Three slices (A foundation, B sharing+promotion, C gallery+backfill).
2026-05-20 15:24:06 +02:00

43 KiB
Raw Permalink Blame History

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 globalglobal_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

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

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

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

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

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

-- 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.

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.

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)

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.

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 bodytemplate_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.

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.