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).
43 KiB
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):
- User-authored templates — any non-
global_admincan create a checklist template they own (title, sections, items, references). - Sharing — author shares with specific colleagues, an Office, a Dezernat (partner-unit), a project team, or the whole firm.
- Admin promotion to global —
global_adminpromotes 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.checklistsdoes NOT exist as a DB table. Templates today are pure Go data ininternal/checklists/templates.go(6 entries, ~310 lines), served byinternal/handlers/checklists.goviachecklists.Summaries()andchecklists.Find(slug). The DB haspaliad.checklist_instances(per-user state) andpaliad.checklist_feedback(a thumbs-up/down sink). That's it. The design has to introducepaliad.checklistsfrom scratch. -
paliad.checklist_instances.template_slugistextwith no FK — validity is enforced inChecklistInstanceService.Createagainst 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/ | tailat 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). Mirrorscan_see_project's shape: STABLE SECURITY DEFINER, ltree ancestor walk againstprojects.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 (seeExportService.WriteAuditRowininternal/services/export_service.go:1120for the canonical shape). -
paliad.project_eventsis 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 tosystem_audit_logand keep instance events onproject_events. -
paliad.users.officeistext(CHECK against the office key list ininternal/offices/offices.go— 8 keys: munich, duesseldorf, hamburg, amsterdam, london, paris, milan, madrid). Multi-office users haveadditional_offices text[]. Both are first-class columns; no separateofficestable. -
paliad.partner_units(cols: id, name, lead_user_id, office, timestamps) is the Dezernat / practice-group table. Membership lives inpaliad.partner_unit_members. Projects attach viapaliad.project_partner_units(with derivation flags). All three are referenceable from a share recipient. -
paliad.users.global_roleistext; values include'global_admin'. Used for the firm-wide promote/demote authority. -
paliad.project_teams(mig 111 just added) carriesresponsibility∈ {admin, lead, member, observer, external}. We reusecan_see_project(visibility) for share-to-project recipients, NOTeffective_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 seeFind(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:
- There is NO second entity in paliad that requests sharing today —
user_views,user_dashboard_layouts,user_card_layouts,user_pinned_projectsare all explicitly owner-only by design (see migration comments). The "future reuse" is hypothetical. - Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind needs its own deletion trigger. That complexity is real, the reusability gain is not.
- 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/checklistsVorlagen 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; onlyglobal_admincan 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:
- 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.
- Audit story. The completed instance shows exactly what the author saw when they started. Reconstruction without git-blame on the template.
- Visibility narrowing safe by construction. If author unshares from a colleague who already has an instance, the instance survives because the snapshot is local.
- 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 $$ … EXCEPTIONguards).
Slot caveat: at design time, latest disk = 111, live tracker = 106 (mig 107-111 pending deploy). Coder MUST re-verify
ls internal/db/migrations/ | tailat 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_projectself-reference: that function readsauth.uid()internally — when called from inside another SECURITY DEFINER body it picks up the caller's uid via search_path inheritance (same pattern aseffective_project_adminreuse 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):
- Resolve slug via
catalog.Find(userID, slug)— enforces visibility. snapshot = catalog.SnapshotBody(userID, slug)— captures the template body (groups + items) at this moment, as JSONB.- Insert into
checklist_instanceswithtemplate_snapshot = snapshot,template_slug = slug,state = '{}'::jsonb.
On Read (ChecklistInstanceService.GetByID):
- Return the instance with
template_snapshotif 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_atbumped → 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
stateis 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_snapshotis 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:
- Metadata — title, description, regime (UPC/DE/EPA/OTHER), court, reference, deadline.
- Sections + items — repeating editor (group title → items[] of {label, note, rule}).
- 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_instancesgroup-by.
10. Trade-offs flagged
- 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. - Polymorphism deferred. A future second sharable entity will need
to either copy the
checklist_sharespattern (cheap but duplicative) or refactor toentity_shares(one mig). The refactor is small; premature abstraction now would pay complexity for no current benefit. - 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).
- 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. - Partner-unit membership join is N+1 on the predicate. Each
visibility check touches
partner_unit_membersif any partner-unit share exists. Indexes onpartner_unit_members(user_id, partner_unit_id)already exist (per mig 027 lineage); the join is single-row. - Share-to-project recipient resolution uses
can_see_project(s.recipient_project_id). That predicate readsauth.uid()from the session, so it works correctly inside our SECURITY DEFINER body. Confirmed by readingcan_see_project's body inpaliad.can_see_projectsource — same pattern thateffective_project_adminuses in mig 111. global_adminUPDATE RLS onpaliad.checklistsis 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 viachecklist.editedwith actor_id.- Instance snapshot fallback path lives indefinitely. Existing
pre-mig-112 instances stay NULL until Slice C backfills. The
fallback code in
ChecklistInstanceService.GetByIDis ~10 LoC and no hot-path concern — but it's "dead code" once the backfill runs. Acceptable until Slice C. - Cascade on owner deletion. If an authored template's owner is
removed (
paliad.users.idcascades), 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. - 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.gologs 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
- Migration 112 applies cleanly on a fresh DB and is idempotent
on re-apply (verified via
BEGIN…ROLLBACKdry-run against the livepaliadschema). /api/checklistsreturns merged catalog — static templates plus DB templates the caller can see (visibility ∈ {firm, global} OR owner = caller).- POST
/api/checklists/templatescreates a row, returns the created template with auto-generatedu-…slug, emitschecklist.authoredaudit row. - PATCH
/api/checklists/templates/{slug}updates owner-only fields, rejects 403 from non-owner non-admin, emitschecklist.edited. - PATCH
/api/checklists/templates/{slug}/visibilitytoggles private↔firm; rejectssharedandglobalin Slice A (those land in Slice B); emitschecklist.visibility_changed. - DELETE
/api/checklists/templates/{slug}removes the row; existing instances survive via snapshot. - Instance create snapshots the template body —
template_snapshotnon-null on every new instance row. - Legacy instances (NULL snapshot) still render via catalog fallback (covered by a regression test).
- "Meine Vorlagen" tab lists owner's templates; "Neue Vorlage"
CTA navigates to
/checklists/new; wizard saves successfully. go build ./... && go vet ./... && go test ./internal/...clean.bun run buildclean (i18n key count incremented by ~20).- Live smoke: tester@hlc.de can create + edit + delete a private
template; setting visibility to
firmmakes 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
ChecklistInstanceServicefor CRUD + audit emit + visibility check. - Endpoint shape mirrors
internal/handlers/checklist_instances.go. - Frontend tab pattern mirrors the existing
entity-tabs/entity-tab-panelsubstrate inchecklists.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.