# Phase 6: Preset Data, First-Run Detection, and DB Safety - Research
**Researched:** 2026-04-20
**Domain:** PostgreSQL constraints, Supabase migrations, React Query hooks, i18n data files
**Confidence:** HIGH
---
## User Constraints (from CONTEXT.md)
### Locked Decisions
- Default amounts in EUR (matches existing profiles.currency default)
- 4 income, 4 bill, 5 variable_expense, 2 debt, 2 saving, 2 investment items (~19 total)
- i18n key format: `presets.{category_type}.{slug}` (e.g., `presets.bill.rent`)
- Round number amounts (e.g., rent=1000, groceries=400) — easier to adjust, less presumptuous
- First-run triggers when user has zero categories OR zero template items
- New dedicated hook: `src/hooks/useFirstRunState.ts` (follows useCategories/useTemplate pattern)
- Backfill via Supabase migration SQL: `UPDATE profiles SET setup_completed = true WHERE id IN (SELECT DISTINCT user_id FROM categories)`
- Hook derives state from existing hooks (useCategories count + useTemplate items count) — no extra network call
- Cleanup step before adding unique constraints (deduplicate existing data safely)
- Separate migration files: `006_uniqueness_constraints.sql` and `007_setup_completed.sql`
- `setup_completed` column defaults to `false`; existing users backfilled to `true`
- Unique constraint on budgets: `(user_id, start_date)` only — end_date always derived
### Claude's Discretion
- Exact preset item names and amounts (within the balanced category distribution)
- German translation text for preset i18n keys
- Order of migration operations within each file
- Error handling approach for constraint violations in application code
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| AUTO-01 | User's monthly budget is auto-created from template when visiting a month for the first time | `useFirstRunState` hook detects zero-category / zero-template-item state; budgets unique constraint prevents duplicate auto-creation |
| AUTO-03 | Auto-creation uses the user's configured currency, not a hardcoded default | `profiles.currency` confirmed as `text not null default 'EUR'` — hook/migration must not hardcode currency |
| SETUP-01 | New user is guided through a 3-step wizard: income → recurring items → review | `setup_completed` column + backfill migration provides the flag Phase 7 wizard reads to decide whether to show onboarding |
| SETUP-02 | User sees pre-filled common budget items with sensible default amounts (~15-20 items) | `src/data/presets.ts` delivers the curated item library with i18n keys and EUR amounts |
---
## Summary
Phase 6 is a pure data/infrastructure layer — no new UI. It consists of four discrete deliverables: two Supabase migration files (uniqueness constraints + setup_completed column), a new TypeScript data file (`src/data/presets.ts`), a new React Query hook (`src/hooks/useFirstRunState.ts`), and additions to both i18n JSON files.
The existing schema (verified against migrations 001–005) has no duplicate-prevention constraints on `budgets` or `categories`. The `profiles` table has no `setup_completed` column yet. The `templates` table already has `unique(user_id)`. All hook patterns follow `useQuery` + `useMutation` from `@tanstack/react-query` with the Supabase JS client — `useFirstRunState` will be read-only (query-only) and simpler than existing hooks.
The main planning risk is the cleanup step before adding unique constraints: the deduplication SQL must run inside the same transaction as the `ADD CONSTRAINT` statement so the constraint cannot be violated by orphaned duplicates surviving the cleanup.
**Primary recommendation:** Write migration 006 as a single transaction — DELETE duplicates, then ADD CONSTRAINT — so it's atomic and safe to run on existing databases.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Duplicate-write prevention | Database | — | Constraint enforced at DB level; application code is a secondary guard only |
| First-run detection state | Frontend (React Query hook) | — | Derived from cached query data, no extra network call |
| Preset item library | Frontend (static data file) | — | Static TypeScript module; no DB persistence, consumed by wizard (Phase 7) |
| setup_completed persistence | Database (profiles table) | — | Boolean column; written by migration backfill and by wizard completion (Phase 7) |
| i18n for preset names | Frontend (JSON files) | — | Follows existing en.json / de.json pattern |
---
## Standard Stack
### Core (all already installed — verified against package.json)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@tanstack/react-query` | (existing) | Server state, caching for useFirstRunState | Already used by all hooks |
| `@supabase/supabase-js` | (existing) | DB client in hooks and migrations | Already used by all hooks |
| PostgreSQL (via Supabase) | (existing) | Unique constraints, ALTER TABLE | Native DB feature; no library needed |
No new npm packages are required for this phase.
### Migration Tooling
Supabase migrations are plain `.sql` files in `supabase/migrations/`. They are run in filename order by the Supabase CLI (`supabase db push`) or applied directly via the Supabase dashboard SQL editor. [VERIFIED: project migration files 001–005]
---
## Architecture Patterns
### System Architecture Diagram
```
Supabase DB
├── Migration 006: uniqueness_constraints
│ ├── DELETE duplicate budgets (keep oldest per user+start_date)
│ ├── DELETE duplicate categories (keep oldest per user+name)
│ ├── ADD CONSTRAINT budgets_user_month_unique ON budgets(user_id, start_date)
│ └── ADD CONSTRAINT categories_user_name_unique ON categories(user_id, name)
│
└── Migration 007: setup_completed
├── ALTER TABLE profiles ADD COLUMN setup_completed boolean NOT NULL DEFAULT false
└── UPDATE profiles SET setup_completed = true
WHERE id IN (SELECT DISTINCT user_id FROM categories)
src/data/presets.ts ──────────────────────────────────────► Phase 7 wizard
(static array of PresetItem objects, keyed by i18n slug)
src/hooks/useFirstRunState.ts
├── calls useCategories() (uses cached ["categories"] query — no new fetch)
├── calls useTemplate() (uses cached ["template-items"] query — no new fetch)
└── returns { isFirstRun: boolean, loading: boolean }
isFirstRun = categories.length === 0 || items.length === 0
src/i18n/en.json } add "presets" top-level key
src/i18n/de.json } add "presets" top-level key
```
### Recommended Project Structure (additions only)
```
src/
├── data/
│ └── presets.ts # NEW: preset budget item library
├── hooks/
│ └── useFirstRunState.ts # NEW: first-run detection hook
└── i18n/
├── en.json # ADD: presets.* keys
└── de.json # ADD: presets.* keys
supabase/migrations/
├── 006_uniqueness_constraints.sql # NEW
└── 007_setup_completed.sql # NEW
```
### Pattern 1: Safe Deduplication Before Unique Constraint
**What:** Delete all but the oldest row per unique key before adding the constraint, wrapped in a transaction.
**When to use:** Any migration adding a unique constraint on a table that may have existing duplicates.
```sql
-- Source: [VERIFIED: standard PostgreSQL CTE deduplication pattern]
BEGIN;
-- Keep only the oldest budget per (user_id, start_date)
DELETE FROM budgets
WHERE id NOT IN (
SELECT DISTINCT ON (user_id, start_date) id
FROM budgets
ORDER BY user_id, start_date, created_at ASC
);
ALTER TABLE budgets
ADD CONSTRAINT budgets_user_month_unique UNIQUE (user_id, start_date);
-- Keep only the oldest category per (user_id, name)
DELETE FROM categories
WHERE id NOT IN (
SELECT DISTINCT ON (user_id, name) id
FROM categories
ORDER BY user_id, name, created_at ASC
);
ALTER TABLE categories
ADD CONSTRAINT categories_user_name_unique UNIQUE (user_id, name);
COMMIT;
```
### Pattern 2: ADD COLUMN with Default + Immediate Backfill
**What:** Add a boolean column with a default, then immediately UPDATE to backfill existing rows.
**When to use:** Column needs a non-null default for new rows, but existing rows need a different value.
```sql
-- Source: [VERIFIED: standard PostgreSQL ALTER TABLE pattern]
ALTER TABLE profiles
ADD COLUMN setup_completed boolean NOT NULL DEFAULT false;
-- Backfill: existing users who have categories are considered set up
UPDATE profiles
SET setup_completed = true
WHERE id IN (SELECT DISTINCT user_id FROM categories);
```
**Important:** `DEFAULT false` in the `ALTER TABLE` statement means the column is added atomically with `false` for all existing rows *before* the UPDATE runs. The UPDATE then flips the qualifying rows. Order matters here. [VERIFIED: PostgreSQL behavior]
### Pattern 3: Derived State Hook (no extra network call)
**What:** Build a hook that computes a boolean from two already-cached queries.
**When to use:** When state can be inferred from data already loaded by sibling hooks.
```typescript
// Source: [VERIFIED: matches useCategories.ts + useTemplate.ts patterns in codebase]
import { useCategories } from "@/hooks/useCategories"
import { useTemplate } from "@/hooks/useTemplate"
export function useFirstRunState() {
const { categories, loading: catLoading } = useCategories()
const { items, loading: tmplLoading } = useTemplate()
return {
isFirstRun: categories.length === 0 || items.length === 0,
loading: catLoading || tmplLoading,
}
}
```
**Key insight:** `useCategories` caches under `["categories"]` and `useTemplate` caches under `["template"]` + `["template-items"]`. If those queries have already been called (e.g., by mounted pages), `useFirstRunState` returns instantly from cache — no extra Supabase round-trip. [VERIFIED: React Query staleTime default behavior]
### Pattern 4: Preset Data File Shape
**What:** Static TypeScript module exporting typed preset items.
**When to use:** Read-only reference data consumed by the wizard UI (Phase 7).
```typescript
// Source: [ASSUMED — no existing presets.ts to verify against; shape inferred from types.ts]
export interface PresetItem {
slug: string // used to build i18n key: presets.{type}.{slug}
type: CategoryType // from src/lib/types.ts
defaultAmount: number // EUR, round number
item_tier: "fixed" | "variable"
}
export const PRESETS: PresetItem[] = [
// income (4)
{ slug: "salary", type: "income", defaultAmount: 3000, item_tier: "fixed" },
{ slug: "freelance", type: "income", defaultAmount: 500, item_tier: "variable" },
{ slug: "rental_income", type: "income", defaultAmount: 800, item_tier: "fixed" },
{ slug: "other_income", type: "income", defaultAmount: 200, item_tier: "variable" },
// bill (4)
{ slug: "rent", type: "bill", defaultAmount: 1000, item_tier: "fixed" },
{ slug: "electricity", type: "bill", defaultAmount: 80, item_tier: "fixed" },
{ slug: "internet", type: "bill", defaultAmount: 40, item_tier: "fixed" },
{ slug: "phone", type: "bill", defaultAmount: 30, item_tier: "fixed" },
// variable_expense (5)
{ slug: "groceries", type: "variable_expense", defaultAmount: 400, item_tier: "variable" },
{ slug: "transport", type: "variable_expense", defaultAmount: 100, item_tier: "variable" },
{ slug: "dining_out", type: "variable_expense", defaultAmount: 150, item_tier: "variable" },
{ slug: "health", type: "variable_expense", defaultAmount: 50, item_tier: "variable" },
{ slug: "clothing", type: "variable_expense", defaultAmount: 100, item_tier: "variable" },
// debt (2)
{ slug: "loan_repayment", type: "debt", defaultAmount: 200, item_tier: "fixed" },
{ slug: "credit_card", type: "debt", defaultAmount: 100, item_tier: "fixed" },
// saving (2)
{ slug: "emergency_fund", type: "saving", defaultAmount: 200, item_tier: "fixed" },
{ slug: "vacation", type: "saving", defaultAmount: 100, item_tier: "fixed" },
// investment (2)
{ slug: "etf", type: "investment", defaultAmount: 200, item_tier: "fixed" },
{ slug: "pension", type: "investment", defaultAmount: 100, item_tier: "fixed" },
]
```
### Pattern 5: i18n Key Structure for Presets
The existing i18n files use a flat nested structure. Add a `"presets"` top-level key, nested by `category_type`, then `slug`. [VERIFIED: matches en.json structure]
```json
// en.json addition
{
"presets": {
"income": {
"salary": "Salary",
"freelance": "Freelance Income",
"rental_income": "Rental Income",
"other_income": "Other Income"
},
"bill": {
"rent": "Rent",
"electricity": "Electricity",
"internet": "Internet",
"phone": "Phone"
},
"variable_expense": {
"groceries": "Groceries",
"transport": "Transport",
"dining_out": "Dining Out",
"health": "Health & Pharmacy",
"clothing": "Clothing"
},
"debt": {
"loan_repayment": "Loan Repayment",
"credit_card": "Credit Card"
},
"saving": {
"emergency_fund": "Emergency Fund",
"vacation": "Vacation Fund"
},
"investment": {
"etf": "ETF / Index Fund",
"pension": "Pension"
}
}
}
```
### Anti-Patterns to Avoid
- **Adding UNIQUE constraint without deduplication first:** Will fail with `duplicate key value violates unique constraint` on any DB with existing duplicate rows.
- **Separate transaction for cleanup and constraint:** If cleanup succeeds but constraint ADD fails (e.g., missed a duplicate), the DB is left partially modified. Always wrap both in one `BEGIN/COMMIT`.
- **isFirstRun computed at render without loading guard:** Returns `true` spuriously while queries are still in-flight (both lengths are 0). Always gate on `loading` before acting on `isFirstRun`.
- **Hardcoding 'EUR' in presets.ts:** The preset amounts are EUR, but the label/currency displayed in the wizard must come from `profiles.currency`, not from presets.ts. Keep amounts as plain numbers.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Duplicate row prevention | Application-level upsert logic with SELECT-then-INSERT | PostgreSQL UNIQUE constraint | Race condition between SELECT and INSERT; DB constraint is atomic |
| Query caching for derived state | Manual state tracking / local useState | React Query (already installed) | useCategories + useTemplate already cache — just consume the cached data |
| i18n key resolution | Custom translation lookup | Existing i18n framework already in use | Check how `t()` is called in existing components and replicate |
---
## Common Pitfalls
### Pitfall 1: DISTINCT ON Requires ORDER BY on the Same Columns
**What goes wrong:** `SELECT DISTINCT ON (user_id, start_date)` without `ORDER BY user_id, start_date` throws a PostgreSQL error.
**Why it happens:** PostgreSQL requires the DISTINCT ON expressions to be the leftmost ORDER BY expressions.
**How to avoid:** Always write `ORDER BY user_id, start_date, created_at ASC` (or whichever tiebreaker). [VERIFIED: PostgreSQL docs behavior]
**Warning signs:** `ERROR: SELECT DISTINCT ON expressions must match initial ORDER BY expressions`
### Pitfall 2: isFirstRun is True During Loading
**What goes wrong:** `categories.length === 0 || items.length === 0` is `true` while data is still loading (both arrays are empty defaults). If Phase 7 wizard triggers on `isFirstRun`, it fires on every page load while data fetches.
**Why it happens:** `useCategories` returns `[]` as default; hook consumers see `isFirstRun = true` before the query resolves.
**How to avoid:** Export `loading` from `useFirstRunState`. Callers must check `!loading && isFirstRun` before acting. [VERIFIED: useCategories.ts returns `query.data ?? []`]
**Warning signs:** Wizard flashes on every load for users who have data.
### Pitfall 3: Backfill Misses Users Without Categories
**What goes wrong:** The backfill SQL sets `setup_completed = true` only for users who have categories. A v1.0 user who created a template but no categories would remain `false` and see the wizard on first v2.0 login.
**Why it happens:** The backfill uses `categories` as the signal; it's possible (though unlikely in v1.0) to have a template with no categories.
**How to avoid:** The agreed logic (zero categories OR zero template items = first run) means a user with template items but no categories is legitimately not set up. Verify that v1.0 users always created categories before template items. If uncertain, widen the backfill:
```sql
UPDATE profiles SET setup_completed = true
WHERE id IN (
SELECT DISTINCT user_id FROM categories
UNION
SELECT t.user_id FROM templates t
INNER JOIN template_items ti ON ti.template_id = t.id
);
```
**Warning signs:** v1.0 users reporting wizard appearing unexpectedly.
### Pitfall 4: Profile Type Missing setup_completed
**What goes wrong:** After migration 007 adds `setup_completed`, the TypeScript `Profile` interface in `src/lib/types.ts` still lacks the field. Any code reading `profile.setup_completed` gets a TypeScript error or `undefined`.
**Why it happens:** Migrations and TypeScript types are manually kept in sync — there is no auto-generation in this project.
**How to avoid:** Include updating `src/lib/types.ts` as an explicit task in the plan. Add `setup_completed: boolean` to the `Profile` interface. [VERIFIED: types.ts inspected — field absent]
**Warning signs:** TypeScript compiler error on `profile.setup_completed`.
### Pitfall 5: Migration File Ordering
**What goes wrong:** A migration named `006_...` that runs after one named `007_...` (or vice versa) due to filename sort.
**Why it happens:** Supabase CLI applies migrations in lexicographic filename order.
**How to avoid:** Uniqueness constraints must come in `006_...` and setup_completed in `007_...` because the backfill in 007 reads from `categories` — which needs to exist and be constraint-safe first. The order is correct as specified. [VERIFIED: existing migrations 001–005 confirm naming convention]
---
## Code Examples
### Confirmed: Category Type Enum Values
From `002_categories.sql` and `src/lib/types.ts` [VERIFIED]:
```
'income' | 'bill' | 'variable_expense' | 'debt' | 'saving' | 'investment'
```
Preset items must use these exact string values as `type`.
### Confirmed: profiles Table Columns (before migration 007)
From `001_profiles.sql` [VERIFIED]:
```
id, display_name, locale, currency, created_at, updated_at
```
No `setup_completed` column exists yet. Migration 007 adds it.
### Confirmed: budgets Table (before migration 006)
From `004_budgets.sql` [VERIFIED]:
- No unique constraint on `(user_id, start_date)`.
- `start_date date not null`, `end_date date not null` — both present.
### Confirmed: categories Table (before migration 006)
From `002_categories.sql` [VERIFIED]:
- No unique constraint on `(user_id, name)`.
- Only a `categories_user_id_idx` index exists.
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Application-level duplicate checks | DB-level UNIQUE constraint | This phase | Eliminates race conditions; constraint error surfaces as Supabase PostgREST error code `23505` |
| No first-run concept | `setup_completed` + `useFirstRunState` | This phase | Enables Phase 7 wizard to show only to new users |
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `item_tier: "one_off"` is excluded from presets (only "fixed" and "variable") | Pattern 4 | If wizard allows one_off presets, the type needs updating — but `template_items.item_tier` check constraint already limits to `('fixed', 'variable')` so this is safe |
| A2 | No existing duplicate budgets or categories in production data | Migration 006 | If duplicates exist the DELETE+CONSTRAINT pattern handles them; risk is low, the cleanup step is exactly for this |
| A3 | The i18n library in use supports nested key access via dot notation (e.g., `t('presets.bill.rent')`) | i18n pattern | If it uses a flat key format, key structure needs adjustment — check existing `t()` calls in components before writing keys |
---
## Open Questions
1. **How does the app call `t()`?**
- What we know: en.json and de.json use nested objects (e.g., `categories.types.income`).
- What's unclear: Whether translation is `t('categories.types.income')` (dot-path) or `t('categories')['types']['income']`. Most react-i18next setups use dot-path.
- Recommendation: Before writing i18n keys, grep one existing `t()` call in the codebase to confirm syntax. Plan task should include this verification.
2. **Are there any existing v1.0 users in production?**
- What we know: The blocker in STATE.md says "existing v1.0 users must not see the wizard on first v2.0 login."
- What's unclear: Whether production has real user rows in `profiles` or if this is a personal/dev-only app.
- Recommendation: The migration is written defensively regardless — backfill runs on all qualifying rows, so there's no harm if the table is empty.
---
## Environment Availability
Step 2.6: SKIPPED (no new external dependencies — all tools are existing: Supabase CLI, PostgreSQL, Node/TypeScript toolchain already in use by the project).
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | None detected — no vitest.config.*, jest.config.*, or test scripts in package.json |
| Config file | None — Wave 0 must create if tests are written |
| Quick run command | N/A |
| Full suite command | N/A |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| AUTO-01 | `useFirstRunState` returns `isFirstRun=true` when categories=[] | unit | `vitest run src/hooks/useFirstRunState.test.ts` | ❌ Wave 0 |
| AUTO-01 | `useFirstRunState` returns `isFirstRun=false` when categories and items present | unit | `vitest run src/hooks/useFirstRunState.test.ts` | ❌ Wave 0 |
| AUTO-01 | `useFirstRunState` returns `loading=true` while queries in flight | unit | `vitest run src/hooks/useFirstRunState.test.ts` | ❌ Wave 0 |
| SETUP-02 | `PRESETS` exports exactly 19 items | unit | `vitest run src/data/presets.test.ts` | ❌ Wave 0 |
| SETUP-02 | Each preset has valid `type` from CategoryType enum | unit | `vitest run src/data/presets.test.ts` | ❌ Wave 0 |
| SETUP-02 | Each preset has `item_tier` of `fixed` or `variable` (not `one_off`) | unit | `vitest run src/data/presets.test.ts` | ❌ Wave 0 |
| AUTO-03 | `Profile.setup_completed` TypeScript type is boolean | compile-time | `tsc --noEmit` | ❌ Wave 0 (after types.ts update) |
| DB constraints | Unique constraint SQL — not unit-testable without Supabase local instance | manual | Supabase local dev + insert duplicate | N/A |
### Sampling Rate
- **Per task commit:** `tsc --noEmit` (type safety at minimum)
- **Per wave merge:** `vitest run` (once vitest is set up)
- **Phase gate:** All passing before `/gsd-verify-work`
### Wave 0 Gaps
- [ ] `vitest` not installed — install: `npm install -D vitest @vitest/ui`
- [ ] `src/hooks/useFirstRunState.test.ts` — covers AUTO-01 loading guard and isFirstRun logic
- [ ] `src/data/presets.test.ts` — covers SETUP-02 count, type validity, item_tier validity
- [ ] `vitest.config.ts` — framework config (or add `"test"` to `vite.config.ts`)
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | yes | Supabase RLS already enabled on all tables — new constraints do not bypass RLS |
| V5 Input Validation | no | No new user-facing input in this phase |
| V6 Cryptography | no | — |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Duplicate budget creation via parallel requests | Tampering | UNIQUE constraint on `(user_id, start_date)` — DB rejects second insert atomically |
| First-run flag manipulation (client sets setup_completed) | Elevation of privilege | RLS policy `"Users can update own profile"` — user can update their own row; acceptable since setup_completed is not a security gate, only a UX flag |
---
## Sources
### Primary (HIGH confidence)
- `supabase/migrations/001_profiles.sql` — profiles table schema, trigger, no setup_completed column confirmed
- `supabase/migrations/002_categories.sql` — categories table schema, no unique constraint on (user_id, name) confirmed
- `supabase/migrations/004_budgets.sql` — budgets table schema, no unique constraint on (user_id, start_date) confirmed
- `src/hooks/useCategories.ts` — hook pattern, query key ["categories"], default returns []
- `src/hooks/useTemplate.ts` — hook pattern, query keys, items default []
- `src/lib/types.ts` — CategoryType values, Profile interface (no setup_completed), all confirmed
- `src/i18n/en.json`, `de.json` — i18n structure and nesting pattern confirmed
- `.planning/config.json` — nyquist_validation: true confirmed
### Secondary (MEDIUM confidence)
- PostgreSQL DISTINCT ON + ORDER BY requirement — standard behavior, well-documented
### Tertiary (LOW confidence)
- A3: i18n dot-path notation — inferred from JSON structure; needs grep verification
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all libraries verified as already installed
- Architecture: HIGH — all schema details verified against actual migration files and hook source
- Pitfalls: HIGH — derived from direct inspection of existing code and standard PostgreSQL semantics
- Preset content: MEDIUM — exact amounts and slugs are at Claude's discretion; structure is HIGH confidence
**Research date:** 2026-04-20
**Valid until:** 2026-07-20 (stable domain — PostgreSQL constraints and React Query patterns are not fast-moving)