diff --git a/.cursor/rules/config-propagation.mdc b/.cursor/rules/config-propagation.mdc
new file mode 100644
index 000000000..a61499aeb
--- /dev/null
+++ b/.cursor/rules/config-propagation.mdc
@@ -0,0 +1,23 @@
+---
+description: Configuration propagation — update all config consumers when changing probod config
+globs: pkg/probod/*.go,pkg/bootstrap/builder.go,cfg/dev.yaml,e2e/console/testdata/config.yaml,contrib/lima/provision.sh,contrib/helm/charts/probo/values.yaml,contrib/helm/charts/probo/values-production.yaml.example,contrib/helm/charts/probo/templates/deployment.yaml,contrib/helm/charts/probo/templates/secret.yaml
+alwaysApply: false
+---
+
+# Config Propagation
+
+When adding, renaming, or removing a config field, update **all** of these:
+
+1. `pkg/probod/*.go` — struct definition (source of truth)
+2. `pkg/probod/probod.go` `New()` — default value
+3. `pkg/bootstrap/builder.go` — env-var mapping + validation
+4. `pkg/bootstrap/builder_test.go` — test coverage for new env var
+5. `cfg/dev.yaml` — local dev config
+6. `e2e/console/testdata/config.yaml` — e2e test config
+7. `contrib/lima/provision.sh` — sandbox env vars (only if default is wrong for sandbox)
+8. `contrib/helm/charts/probo/values.yaml` — Helm defaults
+9. `contrib/helm/charts/probo/values-production.yaml.example` — production template
+10. `contrib/helm/charts/probo/templates/deployment.yaml` — values → env vars
+11. `contrib/helm/charts/probo/templates/secret.yaml` — sensitive values via secretKeyRef
+
+See `contrib/claude/config.md` for full details and conventions.
diff --git a/.cursor/rules/go-coredata-load-naming.mdc b/.cursor/rules/go-coredata-load-naming.mdc
new file mode 100644
index 000000000..a20241da0
--- /dev/null
+++ b/.cursor/rules/go-coredata-load-naming.mdc
@@ -0,0 +1,83 @@
+---
+description: Coredata Load vs LoadAll naming and no cross-entity JOINs
+globs: "pkg/coredata/**/*.go"
+alwaysApply: false
+---
+
+# Coredata Load vs LoadAll naming
+
+The method name signals whether the result set is bounded:
+
+- **`LoadBy*` with a `cursor` param** — paginated list; the cursor provides limit and ordering.
+- **`Load` / `LoadBy*` with a `limit int` param** — filtered list with explicit limit, when cursor pagination is not needed but the caller controls the result count.
+- **`LoadAllBy*`** — returns all matching rows, no limit or cursor.
+- **`LoadAll`** — same as `LoadAllBy*` but without a parent key; returns all rows matching a filter.
+
+`LoadAll*` methods must **never** accept a cursor or limit parameter — `All` means the entire matching set is returned. The codebase has some legacy `LoadAllBy*` methods that accept a cursor; do not follow that pattern — new code must use `LoadBy*` for paginated queries.
+
+```go
+// GOOD — explicit limit, named Load
+func (ds *Things) Load(
+ ctx context.Context,
+ conn pg.Querier,
+ limit int,
+ filter *ThingFilter,
+) error {
+
+// GOOD — no limit, named LoadAll
+func (ds *Things) LoadAll(
+ ctx context.Context,
+ conn pg.Querier,
+ filter *ThingFilter,
+) error {
+
+// BAD — LoadAll with a limit
+func (ds *Things) LoadAll(
+ ctx context.Context,
+ conn pg.Querier,
+ limit int,
+ filter *ThingFilter,
+) error {
+
+// BAD — LoadAllBy with a cursor (legacy pattern, do not use)
+func (ds *Things) LoadAllByParentID(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ parentID gid.GID,
+ cursor *page.Cursor[ThingOrderField],
+) error {
+
+// GOOD — paginated query uses LoadBy, not LoadAll
+func (ds *Things) LoadByParentID(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ parentID gid.GID,
+ cursor *page.Cursor[ThingOrderField],
+) error {
+```
+
+# No cross-entity JOINs
+
+Each entity file in `pkg/coredata` queries only its own table. When data from multiple entities is needed, the caller orchestrates separate calls.
+
+- Never JOIN two entity tables inside an entity method.
+- Never return a raw ID belonging to a different entity — return the full entity and let the caller read the foreign key field.
+
+```go
+// BAD — cross-entity JOIN inside DetectedTrackers
+q := `
+SELECT ctpd.common_third_party_id
+FROM detected_trackers dt
+JOIN common_third_party_domains ctpd ON ctpd.domain = dt.initiator_domain
+WHERE dt.tracker_pattern_id = @tracker_pattern_id
+`
+
+// GOOD — caller orchestrates two entity calls
+domains, err := trackers.LoadInitiatorDomainsByTrackerPatternID(ctx, conn, patternID, 10)
+filter := coredata.NewCommonThirdPartyDomainFilter(domains)
+var matched coredata.CommonThirdPartyDomains
+err = matched.Load(ctx, conn, 1, filter)
+thirdPartyID := matched[0].CommonThirdPartyID
+```
diff --git a/.cursor/rules/go-declarations.mdc b/.cursor/rules/go-declarations.mdc
new file mode 100644
index 000000000..601cd68a9
--- /dev/null
+++ b/.cursor/rules/go-declarations.mdc
@@ -0,0 +1,59 @@
+---
+description: Go declarations — group related types/consts/vars, no iota for string enums
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go grouped declarations
+
+Group related declarations using `type ()`, `const ()`, and `var ()` blocks.
+
+```go
+// GOOD — grouped type block
+type (
+ CreateFooRequest struct {
+ Name string
+ Active bool
+ }
+
+ UpdateFooRequest struct {
+ ID gid.GID
+ Name *string
+ Active *bool
+ }
+)
+
+// GOOD — grouped const block
+const (
+ NameMaxLength = 100
+ ContentMaxLength = 5000
+)
+
+// GOOD — interface satisfaction checks
+var (
+ _ Reader = (*FileReader)(nil)
+ _ Writer = (*FileWriter)(nil)
+)
+```
+
+## String enums
+
+Use explicit typed string values, **not** `iota`:
+
+```go
+// GOOD
+type Status string
+
+const (
+ StatusActive Status = "active"
+ StatusInactive Status = "inactive"
+)
+
+// BAD — iota for string-like enums
+type Status int
+
+const (
+ StatusActive Status = iota
+ StatusInactive
+)
+```
diff --git a/.cursor/rules/go-delete-no-rows-check.mdc b/.cursor/rules/go-delete-no-rows-check.mdc
new file mode 100644
index 000000000..6ac17a99d
--- /dev/null
+++ b/.cursor/rules/go-delete-no-rows-check.mdc
@@ -0,0 +1,32 @@
+---
+description: Delete methods must not check RowsAffected — deletes are idempotent
+globs: "pkg/coredata/**/*.go"
+alwaysApply: false
+---
+
+# Delete must not check RowsAffected
+
+In `Delete` methods, do **not** check `result.RowsAffected() == 0`. A DELETE that affects zero rows is not an error — the resource may have already been deleted. Deletes are idempotent.
+
+Discard the result with `_`:
+
+```go
+// GOOD — Delete ignores RowsAffected
+_, err := conn.Exec(ctx, q, args)
+if err != nil {
+ return fmt.Errorf("cannot delete foo: %w", err)
+}
+return nil
+
+// BAD — Delete checks RowsAffected
+result, err := conn.Exec(ctx, q, args)
+if err != nil {
+ return fmt.Errorf("cannot delete foo: %w", err)
+}
+if result.RowsAffected() == 0 {
+ return ErrResourceNotFound
+}
+return nil
+```
+
+This rule applies only to `Delete`. `Update` methods **must** still check `RowsAffected() == 0` and return `ErrResourceNotFound`.
diff --git a/.cursor/rules/go-error-handling.mdc b/.cursor/rules/go-error-handling.mdc
new file mode 100644
index 000000000..8ba66b382
--- /dev/null
+++ b/.cursor/rules/go-error-handling.mdc
@@ -0,0 +1,46 @@
+---
+description: Go error handling — wrap with fmt.Errorf, always name err, use errors.AsType
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go error handling
+
+## Wrapping
+
+- Error variables are always named `err`
+- When a function returns errors from multiple call sites, each must be wrapped
+- Wrap with `fmt.Errorf("cannot ...: %w", err)` — lowercase, starts with `cannot`
+- Each wrap message in a function must be distinct
+
+```go
+// GOOD
+foo, err := s.loadFoo(ctx)
+if err != nil {
+ return fmt.Errorf("cannot load foo: %w", err)
+}
+
+bar, err := s.loadBar(ctx, foo.ID)
+if err != nil {
+ return fmt.Errorf("cannot load bar: %w", err)
+}
+
+// BAD — bare return without wrapping
+foo, err := s.loadFoo(ctx)
+if err != nil {
+ return err
+}
+```
+
+## Type assertions on errors
+
+Use `errors.AsType[T](err)` (generic form), not `errors.As(err, &ptr)`:
+
+```go
+// GOOD
+if e, ok := errors.AsType[*ValidationError](err); ok { ... }
+
+// BAD
+var ve *ValidationError
+if errors.As(err, &ve) { ... }
+```
diff --git a/.cursor/rules/go-imports.mdc b/.cursor/rules/go-imports.mdc
new file mode 100644
index 000000000..489593325
--- /dev/null
+++ b/.cursor/rules/go-imports.mdc
@@ -0,0 +1,41 @@
+---
+description: Go import ordering — stdlib first, then third-party and internal together
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go import ordering
+
+Two groups separated by a blank line:
+1. Standard library
+2. Everything else (third-party and internal sorted together alphabetically)
+
+```go
+// GOOD
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/go-chi/chi/v5"
+ "go.gearno.de/kit/log"
+ "go.probo.inc/probo/pkg/iam"
+)
+
+// BAD — three groups (stdlib / third-party / internal separated)
+import (
+ "context"
+ "fmt"
+
+ "github.com/go-chi/chi/v5"
+
+ "go.probo.inc/probo/pkg/iam"
+)
+
+// BAD — no separation at all
+import (
+ "context"
+ "github.com/go-chi/chi/v5"
+ "go.probo.inc/probo/pkg/iam"
+)
+```
diff --git a/.cursor/rules/go-logging.mdc b/.cursor/rules/go-logging.mdc
new file mode 100644
index 000000000..fdf25ef3e
--- /dev/null
+++ b/.cursor/rules/go-logging.mdc
@@ -0,0 +1,33 @@
+---
+description: Go logging — structured, PII-free, use go.gearno.de/kit/log
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go logging
+
+Use `go.gearno.de/kit/log` — named, context-aware structured logging with typed fields.
+
+## Rules
+
+- **Never log PII, PHI, or sensitive data** (emails, names, passwords, tokens, health records)
+- Log opaque identifiers instead (IDs, request IDs)
+- Use typed field helpers: `log.String`, `log.Int`, `log.Error`, etc.
+- Use `*Ctx` variants for context-aware logging
+
+```go
+// GOOD
+l.InfoCtx(
+ ctx,
+ "HTTP request to trust center custom domain, redirecting to HTTPS",
+ log.String("domain", domain),
+ log.String("path", r.URL.Path),
+ log.String("request_id", reqID),
+)
+
+// BAD — logs PII
+l.InfoCtx(ctx, "user login", log.String("email", user.Email))
+
+// BAD — unstructured
+log.Printf("processing request for %s", userID)
+```
diff --git a/.cursor/rules/go-multiline-params.mdc b/.cursor/rules/go-multiline-params.mdc
new file mode 100644
index 000000000..32045acf4
--- /dev/null
+++ b/.cursor/rules/go-multiline-params.mdc
@@ -0,0 +1,77 @@
+---
+description: Enforce single-line-or-multiline formatting for Go parameter and argument lists
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go multiline parameter and argument lists
+
+Parameter/argument lists are either **all on one line** or **each on its own line** — never mixed.
+
+## Function/method definitions
+
+```go
+// GOOD — fits on one line
+func (s *Service) GetFoo(ctx context.Context, id gid.GID) (*Foo, error) {
+
+// GOOD — multiline: one param per line, closing ) on its own line
+func (s *Service) CreateFoo(
+ ctx context.Context,
+ tenantID gid.TenantID,
+ req CreateFooRequest,
+) (*Foo, error) {
+
+// BAD — mixed
+func (s *Service) CreateFoo(ctx context.Context, tenantID gid.TenantID,
+ req CreateFooRequest) (*Foo, error) {
+
+// BAD — closing ) stuck on last param line
+func (s *Service) CreateFoo(
+ ctx context.Context,
+ req CreateFooRequest) (*Foo, error) {
+```
+
+## Call expressions
+
+```go
+// GOOD — one line
+id := gid.New(tenantID, "Foo")
+
+// GOOD — multiline: one arg per line, trailing comma, closing ) alone
+svc, err := foo.NewService(
+ ctx,
+ db,
+ logger,
+)
+
+// BAD — some args on the call line, rest below
+svc, err := foo.NewService(ctx, db,
+ logger,
+)
+
+// BAD — first args on the callee line with multiline composite literal
+svc, err := foo.NewService(ctx, db, foo.Config{
+ MaxRetry: 3,
+})
+
+// BAD — single multiline argument starts on the opening ( line
+body, err := json.Marshal(firecrawlRequest{
+ Query: query,
+ Limit: maxResults,
+})
+
+// GOOD — single multiline argument: break after (, trailing comma, ) alone
+body, err := json.Marshal(
+ firecrawlRequest{
+ Query: query,
+ Limit: maxResults,
+ },
+)
+```
+
+## Quick checklist when writing or reviewing Go code
+
+1. Does the signature/call fit on one line? → keep it on one line.
+2. Doesn't fit? → break after `(`, one item per line, `)` on its own line.
+3. Never place some items on the opening line and others below.
+4. Even a single argument that spans multiple lines must break after `(`.
diff --git a/.cursor/rules/go-naming-conventions.mdc b/.cursor/rules/go-naming-conventions.mdc
new file mode 100644
index 000000000..b9e6cbb6d
--- /dev/null
+++ b/.cursor/rules/go-naming-conventions.mdc
@@ -0,0 +1,58 @@
+---
+description: Go naming and conventions — constructors, configs, receivers, context, interfaces
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go naming and conventions
+
+## Naming patterns
+
+- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
+- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`)
+- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
+- Unexported internal types: lowercase (e.g. `thirdPartyInfo`, `ctxKey`)
+
+## Receiver names
+
+Short, usually single-letter matching the type:
+- `s` for Service, `c` for Client, `p` for Provider, `w` for Worker
+
+## Context
+
+Always the first parameter. Use private struct keys for context values:
+
+```go
+type ctxKey struct{ name string }
+var trustCenterIDKey = &ctxKey{name: "trust_center_id"}
+```
+
+## Interfaces
+
+- Define in the consumer package, not the provider
+- Keep them small
+- Verify satisfaction at compile time:
+
+```go
+var (
+ _ unit.Configurable = (*Implm)(nil)
+ _ unit.Runnable = (*Implm)(nil)
+)
+```
+
+## Functional options
+
+Use `Config` structs for required params. Use `With*` functions for optional config:
+
+```go
+type Option func(*Bridge)
+
+func WithDryRun(dryRun bool) Option {
+ return func(s *Bridge) { s.dryRun = dryRun }
+}
+```
+
+## Pointers
+
+Go 1.26: use `new(expr)` for pointer-to-value (e.g. `new(1)`, `new("foo")`, `new(time.Now())`).
+Use `go.gearno.de/x/ref` only for dereference helpers (`ref.UnrefOrZero`).
diff --git a/.cursor/rules/go-pg-constraint-check.mdc b/.cursor/rules/go-pg-constraint-check.mdc
new file mode 100644
index 000000000..5fb593f6d
--- /dev/null
+++ b/.cursor/rules/go-pg-constraint-check.mdc
@@ -0,0 +1,40 @@
+---
+description: PostgreSQL constraint error handling — always check both error code and constraint name
+globs: "pkg/coredata/**/*.go"
+alwaysApply: false
+---
+
+# PostgreSQL constraint error mapping
+
+When mapping `*pgconn.PgError` to sentinel errors, always check **both** `pgErr.Code` and `pgErr.ConstraintName`. A table may have multiple unique constraints; a code-only check silently maps unrelated violations to the wrong sentinel.
+
+```go
+// GOOD — checks both code and constraint name
+if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
+ if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
+ return ErrResourceAlreadyExists
+ }
+}
+
+// GOOD — multiple constraints on the same table
+if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
+ if pgErr.Code == "23505" {
+ switch pgErr.ConstraintName {
+ case "document_versions_document_id_major_minor_key",
+ "document_one_active_version_idx":
+ return ErrResourceAlreadyExists
+ }
+ }
+}
+
+// BAD — code-only check
+if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
+ if pgErr.Code == "23505" {
+ return ErrResourceAlreadyExists
+ }
+}
+```
+
+This applies to all PostgreSQL error codes mapped to sentinel errors:
+- `"23505"` (unique violation) → `ErrResourceAlreadyExists`
+- `"23503"` (foreign key violation) → `ErrResourceInUse`
diff --git a/.cursor/rules/go-upsert-returning-id.mdc b/.cursor/rules/go-upsert-returning-id.mdc
new file mode 100644
index 000000000..0f639059c
--- /dev/null
+++ b/.cursor/rules/go-upsert-returning-id.mdc
@@ -0,0 +1,52 @@
+---
+description: Upsert must RETURNING full row into pointer receiver, not use xmax
+globs: "pkg/coredata/**/*.go"
+alwaysApply: false
+---
+
+# Upsert: RETURNING full row into receiver
+
+Upsert methods use a **pointer receiver** and `RETURNING` all struct columns to sync the receiver with the actual DB state. Save the original ID before the query; compare it with the returned ID to detect insert vs update.
+
+Do **not** use `RETURNING (xmax = 0) AS inserted` — `xmax` is a PostgreSQL internal system column and is fragile.
+
+```go
+// GOOD — RETURNING full row, sync receiver
+func (t *Thing) Upsert(ctx context.Context, conn pg.Tx) (inserted bool, err error) {
+ q := `
+INSERT INTO things (id, name, created_at, updated_at)
+VALUES (@id, @name, @created_at, @updated_at)
+ON CONFLICT (name) DO UPDATE
+SET
+ name = EXCLUDED.name,
+ updated_at = EXCLUDED.updated_at
+RETURNING
+ id,
+ name,
+ created_at,
+ updated_at
+`
+ originalID := t.ID
+ // ...args...
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return false, fmt.Errorf("cannot upsert thing: %w", err)
+ }
+ defer rows.Close()
+
+ row, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Thing])
+ if err != nil {
+ return false, fmt.Errorf("cannot collect upsert result: %w", err)
+ }
+
+ *t = row
+ return originalID == t.ID, nil
+}
+
+// BAD — xmax trick
+RETURNING (xmax = 0) AS inserted
+
+// BAD — RETURNING only id without syncing receiver
+RETURNING id
+```
diff --git a/.cursor/rules/go-url-construction.mdc b/.cursor/rules/go-url-construction.mdc
new file mode 100644
index 000000000..cfd97ad5a
--- /dev/null
+++ b/.cursor/rules/go-url-construction.mdc
@@ -0,0 +1,38 @@
+---
+description: Go URL construction — never use fmt.Sprintf or concatenation for URLs
+globs: "**/*.go"
+alwaysApply: false
+---
+
+# Go URL and query parameter construction
+
+**Never** build URLs with `fmt.Sprintf`, string concatenation, or any string formatting.
+
+Use `net/url` package or `pkg/baseurl.URLBuilder`.
+
+```go
+// BAD
+endpoint := fmt.Sprintf("https://api.example.com/users/%s?active=%t", userID, active)
+endpoint := "https://api.example.com/orgs/" + orgID + "/members"
+raw := baseEndpoint + "?domain=" + domain + "&limit=100"
+
+// GOOD — url.JoinPath + url.Values
+u, err := url.JoinPath("https://api.example.com", "users", userID)
+if err != nil {
+ return fmt.Errorf("cannot build URL: %w", err)
+}
+
+parsed, err := url.Parse(u)
+if err != nil {
+ return fmt.Errorf("cannot parse URL: %w", err)
+}
+
+q := parsed.Query()
+q.Set("active", strconv.FormatBool(active))
+parsed.RawQuery = q.Encode()
+
+// GOOD — URLBuilder from pkg/baseurl
+u, err := baseURL.URL("/users", userID).
+ Query("active", strconv.FormatBool(active)).
+ Build()
+```
diff --git a/.cursor/rules/no-outlet-context-data.mdc b/.cursor/rules/no-outlet-context-data.mdc
new file mode 100644
index 000000000..3c6106aef
--- /dev/null
+++ b/.cursor/rules/no-outlet-context-data.mdc
@@ -0,0 +1,33 @@
+---
+description: Never pass domain data through React Router Outlet context — use page loaders with their own queries
+globs: "**/*.tsx"
+alwaysApply: false
+---
+
+# No domain data via Outlet context
+
+Child routes under a layout MUST NOT receive domain data through `useOutletContext`.
+Each page that needs GraphQL data must follow the Loader + Page pattern with its
+own query, just like sibling pages.
+
+```tsx
+// BAD — layout passes data through Outlet context
+
+
+// child page
+const { showBranding } = useOutletContext<{ showBranding: boolean }>();
+
+// GOOD — child page has its own Loader + Query
+// SnippetPageLoader.tsx:
+const [queryRef, loadQuery] = useQueryLoader(snippetPageQuery);
+useEffect(() => { loadQuery({ cookieBannerId }); }, [loadQuery, cookieBannerId]);
+
+// SnippetPage.tsx:
+export const snippetPageQuery = graphql`
+ query SnippetPageQuery($cookieBannerId: ID!) {
+ node(id: $cookieBannerId) {
+ ... on CookieBanner { ...ThemePreview_cookieBanner }
+ }
+ }
+`;
+```
diff --git a/.cursor/rules/relay-fragments-not-data-props.mdc b/.cursor/rules/relay-fragments-not-data-props.mdc
new file mode 100644
index 000000000..43fa5566f
--- /dev/null
+++ b/.cursor/rules/relay-fragments-not-data-props.mdc
@@ -0,0 +1,41 @@
+---
+description: Enforce Relay fragments instead of passing fetched data as props
+globs: "**/*.tsx"
+alwaysApply: false
+---
+
+# Never pass fetched data as props — use Relay fragments
+
+When a child component needs data from a GraphQL node, it MUST define its own
+colocated fragment and receive a **fragment key** (`SomeFragment$key`), never
+a plain object or individual fields extracted from the parent's query/fragment.
+
+This applies even when the child only uses the data to seed local state (e.g.
+an edit form that copies fields into `useState`).
+
+```tsx
+// BAD — parent extracts fields and passes a plain object
+
+
+// GOOD — child owns its fragment, parent spreads it and passes the key
+// In EditCookieRow.tsx:
+export const editCookieRowFragment = graphql`
+ fragment EditCookieRowFragment on Cookie { name duration description }
+`;
+interface EditCookieRowProps {
+ cookieKey: EditCookieRowFragment$key;
+ onSave: (cookie: CookieEntry) => void;
+}
+
+// In parent fragment:
+// ...EditCookieRowFragment (spread on the Cookie node)
+
+// In parent JSX:
+
+```
+
+Callback props (`onSave`, `onCancel`) and configuration props (`isUpdating`,
+`variant`) are fine — only **domain data** must come from fragments.
diff --git a/.cursor/rules/template-files.mdc b/.cursor/rules/template-files.mdc
new file mode 100644
index 000000000..6e94a8646
--- /dev/null
+++ b/.cursor/rules/template-files.mdc
@@ -0,0 +1,29 @@
+---
+description: Template file naming and conventions
+globs: "**/*.tmpl"
+alwaysApply: false
+---
+
+# Template files
+
+See full guide: `contrib/claude/file-naming.md`
+
+## Naming convention
+
+Template files use the extension pattern `..tmpl`:
+
+```
+pkg/trust/sitemap.xml.tmpl
+pkg/cookiebanner/prompts/tracker_identification.txt.tmpl
+pkg/probo/templates/risk_list.json.tmpl
+```
+
+## Dynamic values over hardcoded lists
+
+Never hardcode enum values or source-of-truth lists in template text. Use a placeholder and substitute at runtime:
+
+```
+Use one of: {{.Categories}}.
+```
+
+Use `strings.Replace` (single placeholder) or `text/template` (multiple).
diff --git a/.gitignore b/.gitignore
index e2eb715c2..2981171e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@ bin/
node_modules/
.turbo
.vscode
+.cursor/*
+!.cursor/rules/
dist/
.react-email/
sbom.json