Adds a new "procedural" tree type for linear step-by-step project workflows (domain controller setup, M365 onboarding, VPN config, etc). Includes intake form builder, two-panel step navigation, variable resolution, procedural exports, 3 seed templates, and UI rename from "Trees" to "Flows". Also archives 19 implemented plan docs and creates deferred features backlog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
30 KiB
Lessons Learned
Purpose: This file documents bugs, fixes, and gotchas encountered during development. For Claude Code: Read this file at the start of each session to avoid repeating past mistakes. Last Updated: February 12, 2026
Environment Setup (New Machine)
Database Name Mismatch After Fresh Clone
Problem: After cloning the repo and running docker-compose up -d, Alembic migrations fail with database "decision_tree" does not exist.
Cause: The .env file contains the old database name (decision_tree) but docker-compose.yml creates a database called patherly.
Solution: Update .env to use the correct database name:
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/patherly
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly
Files affected: backend/.env
Missing @/lib/utils (cn function)
Problem: Frontend fails to compile with error: Failed to resolve import "@/lib/utils" from "src/pages/SessionDetailPage.tsx". Does the file exist?
Cause: The src/lib/utils.ts file wasn't committed to the repo or was missed during setup. This file provides the cn() utility function used for combining Tailwind classes.
Solution: Create frontend/src/lib/utils.ts:
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Dependencies required: clsx and tailwind-merge (already in package.json)
Files affected: frontend/src/lib/utils.ts
pip install in venv doesn't need --break-system-packages
Problem: Confusion about whether to use --break-system-packages flag.
Clarification: The --break-system-packages flag is only needed when installing packages outside of a virtual environment. When your venv is active (you see (venv) prefix), you don't need this flag.
New Machine Setup Checklist
When setting up development on a new machine:
- Clone repo:
git clone <repo-url> - Start Docker Desktop
- Start database:
cd backend && docker-compose up -d - Fix .env database name if it says
decision_tree→ change topatherly - Create venv:
python -m venv venv - Activate venv:
.\venv\Scripts\Activate - Install backend deps:
pip install -r requirements.txt - Run migrations:
alembic upgrade head - Start backend:
uvicorn app.main:app --reload - Install frontend deps:
cd ../frontend && npm install - Create lib/utils.ts if missing (see above)
- Start frontend:
npm run dev
Python / Backend
DateTime Timezone Handling ⚠️ CRITICAL
Problem: SQLAlchemy DateTime fields caused Internal Server Errors when mixing timezone-aware and timezone-naive datetimes.
Error: can't subtract offset-naive and offset-aware datetimes
Solution:
- Always use
DateTime(timezone=True)in SQLAlchemy models - Always use
datetime.now(timezone.utc)for defaults and assignments - Never use
datetime.utcnow()(deprecated and timezone-naive)
Correct pattern:
from datetime import datetime, timezone
from sqlalchemy import DateTime
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
Files affected: All models with timestamp fields (user.py, tree.py, session.py, team.py, attachment.py)
pytest-asyncio Version Compatibility
Problem: Tests fail with AttributeError: 'Package' object has no attribute 'obj'
Cause: Incompatibility between pytest 8.0.0 and pytest-asyncio 0.23.3
Solution: Upgrade pytest-asyncio to 0.24.0
pip install pytest-asyncio==0.24.0 --upgrade
bcrypt / passlib Compatibility
Problem: Password hashing fails with newer bcrypt versions.
Solution: Pin bcrypt version in requirements.txt:
bcrypt==4.0.1
passlib[bcrypt]==1.7.4
Virtual Environment Best Practices
Problem: OneDrive sync causes conflicts with venv, __pycache__, and lock files.
Solution:
- Keep project in
C:\Dev\Projects\, NOT in OneDrive-synced folders - Add to
.gitignore:venv/,__pycache__/,*.pyc
Installing Packages in venv
Problem: pip install sometimes installs globally instead of in venv.
Solution:
- Always verify venv is active: look for
(venv)prefix in terminal - If VS Code prompts to create a new venv when one exists, click "Don't show again"
- Use
pip install --break-system-packagesflag if needed (Python 3.12+)
Alembic Migrations
Problem: Model changes not reflected in database.
Solution: Always run migrations after model changes:
cd backend
alembic revision --autogenerate -m "Description of change"
alembic upgrade head
TypeScript / Frontend
React State: Don't Store Object Snapshots for Editing ⚠️ CRITICAL
Problem: Form inputs don't update when typing - characters appear then immediately disappear.
Cause: Storing a full object from a Zustand store in local state creates a snapshot. When the store updates, the local state still holds the old reference, causing the input's value to revert.
Broken pattern:
// BAD: Stores a snapshot that won't update
const [editingNode, setEditingNode] = useState<TreeStructure | null>(null)
// When modal opens:
setEditingNode(node) // snapshot captured here
// In modal form:
<input value={editingNode.question} onChange={...} /> // always shows old value
Solution: Store only the ID, then fetch the current object from the store on each render:
// GOOD: Store only the ID
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const editingNode = editingNodeId ? findNode(editingNodeId) : null
// When modal opens:
setEditingNodeId(node.id)
// In modal form - now gets fresh data each render:
<input value={editingNode.question} onChange={...} />
Why it works: By calling findNode() on each render, the component always gets the current state from the store after updates.
Files affected: Any component that opens a modal/form to edit store data (NodeList.tsx)
Modal Draft State: Don't Overwrite Store-Managed Fields ⚠️ CRITICAL
Problem: Child nodes created while a modal is open disappear when clicking "Done". Tree validation fails with errors about non-existent nodes.
Cause: When using local draft state for Cancel/Done functionality in a modal:
- Modal opens and clones the entire node (including
children: []) into local state - User creates new child nodes via a dropdown (e.g., NodePicker) - these are added to the store
- User clicks "Done" → modal saves draft back to store
- The draft's stale
children: []overwrites the store's actual children array - Newly created nodes are deleted
Symptoms:
- Child nodes don't appear in the tree after closing modal
- Validation errors: "Option references non-existent node"
- Tree won't save due to validation failures
Broken pattern:
// Modal with local draft state
const [draft, setDraft] = useState(() => structuredClone(node))
const handleSave = () => {
// BAD: This overwrites children with stale snapshot!
updateNode(node.id, draft)
onClose()
}
Solution: Exclude fields that are managed by other store actions (like children managed by addNode/deleteNode):
const handleSave = () => {
// GOOD: Exclude 'children' - it's managed separately by addNode/deleteNode
const { children, ...draftWithoutChildren } = draft
updateNode(node.id, draftWithoutChildren)
onClose()
}
General rule: When a modal uses local draft state, identify which fields are:
- Editable by the form → include in draft save
- Managed by other actions (addNode, deleteNode, etc.) → exclude from draft save
Files affected: NodeEditorModal.tsx, any modal that edits objects with nested collections
Modal Scroll/Overflow with Fixed Header and Footer
Problem: Modal content extends beyond screen when there's too much content, pushing close button and action buttons off-screen.
Solution: Use flex layout with fixed header/footer and scrollable body:
// Modal structure
<div className="flex max-h-[85vh] w-full flex-col">
{/* Header - fixed at top */}
<div className="flex-shrink-0 border-b px-6 py-4">
<h2>{title}</h2>
<button>X</button>
</div>
{/* Body - scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{children}
</div>
{/* Footer - fixed at bottom */}
{footer && (
<div className="flex-shrink-0 border-t px-6 py-4">
{footer}
</div>
)}
</div>
Key points:
max-h-[85vh]constrains total modal heightflex-colenables vertical flex layoutflex-shrink-0on header/footer prevents them from shrinkingflex-1 overflow-y-autoon body makes it fill remaining space and scroll
Files affected: Modal.tsx, any component using modals for forms
Lucide React Icons: No Title Prop
Problem: TypeScript error when trying to add title prop to Lucide icons.
Error: Property 'title' does not exist on type 'LucideProps'
Broken pattern:
<CheckCircle title="Has solution endpoint" /> // ❌ Error
Solution: Wrap the icon in a span with the title:
<span title="Has solution endpoint">
<CheckCircle className="h-4 w-4" />
</span>
Tree Traversal: Preventing Infinite Loops with Visited Set
Problem: When traversing a tree structure that has cross-references (like next_node_id pointing to nodes elsewhere in the tree), you can get infinite loops.
Solution: Use a visited Set to track already-processed nodes:
function hasSolutionInSubtree(
node: TreeStructure,
findNode: (id: string) => TreeStructure | null,
visited: Set<string> = new Set()
): boolean {
// Prevent infinite loops
if (visited.has(node.id)) return false
visited.add(node.id)
if (node.type === 'solution') return true
// Check children array
if (node.children) {
for (const child of node.children) {
if (hasSolutionInSubtree(child, findNode, visited)) return true
}
}
// Check next_node_id reference (could point anywhere in tree)
if (node.next_node_id) {
const nextNode = findNode(node.next_node_id)
if (nextNode && hasSolutionInSubtree(nextNode, findNode, visited)) {
return true
}
}
return false
}
Why it matters: Decision trees can have shared nodes where multiple paths converge on the same target. Without loop detection, recursive traversal will hang.
SharedLinksMap Pattern for Tracking Cross-References
Problem: Need to know which nodes link to the same target node (for showing "shared by X nodes" indicators).
Solution: Build a map at the parent component level, pass down to children:
// Type definition
type SharedLinksMap = Map<string, Array<{ id: string; label: string }>>
// Build the map by traversing tree once
function buildSharedLinksMap(
node: TreeStructure,
map: SharedLinksMap = new Map()
): SharedLinksMap {
const nodeLabel = node.type === 'decision' ? node.question : node.title
// Record decision option targets
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id) {
const existing = map.get(opt.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(opt.next_node_id, existing)
}
}
}
// Record action next_node_id targets
if (node.type === 'action' && node.next_node_id) {
const existing = map.get(node.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(node.next_node_id, existing)
}
// Recurse
if (node.children) {
for (const child of node.children) {
buildSharedLinksMap(child, map)
}
}
return map
}
// Use in parent with useMemo
const sharedLinksMap = useMemo(() => {
if (!treeStructure) return new Map()
return buildSharedLinksMap(treeStructure)
}, [treeStructure])
Usage in child: Check sharedLinksMap.get(node.id)?.length > 1 to see if node is shared.
tsconfig.json Strict Mode
Problem: VS Code shows warnings about missing compiler options.
Solution: Add to compilerOptions in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true
}
}
Why it matters: forceConsistentCasingInFileNames prevents issues when deploying from Windows (case-insensitive) to Linux (case-sensitive).
Tailwind Dark Mode
Pattern: Use Tailwind's dark: variant for dark mode styling.
Setup: In tailwind.config.js:
module.exports = {
darkMode: 'class', // or 'media' for system preference only
// ...
}
Usage:
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
Docker / PostgreSQL
Accessing PostgreSQL Without Local psql
Problem: psql command not recognized on Windows (not installed locally).
Solution: Use Docker exec to run psql inside the container:
# Single command
docker exec -it patherly_postgres psql -U postgres -c "SELECT * FROM users;"
# Interactive session
docker exec -it patherly_postgres psql -U postgres
# Create database
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
Docker Container Not Running
Problem: Database connection errors.
Solution: Check and start the container:
docker ps # See running containers
docker start patherly_postgres # Start if stopped
Git / Version Control
git add from Wrong Directory
Problem: git add . doesn't stage files in parent directories.
Solution: Always run git commands from project root:
cd C:\Dev\Projects\patherly
git add .
git commit -m "Your message"
git push origin main
Untracked .claude/ Folder
Problem: .claude/ folder appears in untracked files.
Solution: Either:
- Add to
.gitignoreif you don't want to track it - Or
git add .claude/if you want Claude Code settings in repo
Environment-Specific Notes
Windows Path Handling
- Python and Node handle forward slashes
/fine on Windows - Use
os.path.join()orpathlib.Pathfor cross-platform compatibility - Avoid hardcoding backslashes
\in code
PowerShell vs CMD
- Some Node.js/Python tools work better in CMD than PowerShell
- If a command fails in PowerShell, try CMD or add to Desktop Commander config:
defaultShell: "cmd"
API / Endpoint Patterns
JSONB Fields with Timestamps
Problem: Storing datetime objects directly in JSONB fields causes serialization errors.
Solution: Convert to ISO string before storing:
decision = {
"node_id": node_id,
"answer": answer,
"timestamp": datetime.now(timezone.utc).isoformat() # String, not datetime
}
Soft Delete Pattern
Pattern: Use is_active boolean instead of actually deleting records.
Note: Our schema uses is_active, not is_deleted (documentation was corrected on Jan 28, 2026).
Testing
Test Database Setup
Requirement: Tests need a separate database.
One-time setup:
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
Run tests:
cd backend
pytest
Common Mistakes to Avoid
| Mistake | Correct Approach |
|---|---|
Using datetime.utcnow() |
Use datetime.now(timezone.utc) |
| Running git from subdirectory | Always cd to project root first |
| Forgetting to activate venv | Check for (venv) prefix |
| Editing files in OneDrive folder | Use C:\Dev\Projects\ |
Using psql directly on Windows |
Use docker exec instead |
| Storing datetime in JSON | Convert to .isoformat() string |
Seed Scripts
httpx Not Installed
Problem: Running python -m scripts.seed_trees fails with ModuleNotFoundError: No module named 'httpx'
Cause: The seed script uses httpx for async HTTP requests, which isn't in the base requirements.
Solution: Install httpx before running seed scripts:
pip install httpx
Email Validation Rejects .local Domains
Problem: Seed script fails with email validation error when using .local domain for seed user email.
Error: value is not a valid email address: The part after the @-sign is a special-use or reserved name that cannot be used with email.
Cause: The email-validator library (used by Pydantic) correctly rejects .local as it's a reserved TLD per RFC 6761.
Solution: Use a standard domain like example.com for seed/test users:
# BAD
"email": "seed.admin@patherly.local"
# GOOD
"email": "seed.admin@example.com"
Files affected: backend/scripts/seed_trees.py, any test fixtures with email addresses
Railway Deployment
DATABASE_URL_SYNC Must Derive from DATABASE_URL ⚠️ CRITICAL
Problem: Alembic migrations run but connect to localhost instead of Railway's Postgres. Database tables never get created.
Cause: DATABASE_URL_SYNC was a separate pydantic field with a default value pointing to localhost:5432. Railway only provides DATABASE_URL, so the sync version wasn't being set correctly.
Solution: Make DATABASE_URL_SYNC a property that derives from DATABASE_URL:
# BAD: Separate field with default
DATABASE_URL_SYNC: str = "postgresql://postgres:postgres@localhost:5432/patherly"
# GOOD: Property that derives from DATABASE_URL
@property
def DATABASE_URL_SYNC(self) -> str:
"""Get sync URL by removing asyncpg prefix from DATABASE_URL."""
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://", 1)
Files affected: backend/app/core/config.py
Railway releaseCommand May Not Execute
Problem: Migrations in railway.toml releaseCommand don't run. Deploy logs show the app starting but no migration output.
Cause: Railway's releaseCommand feature may not work reliably with all configurations, especially with Dockerfile builds.
Solution: Run migrations in the Docker CMD instead:
# Instead of relying on railway.toml releaseCommand:
CMD alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
Files affected: backend/Dockerfile, backend/railway.toml (keep releaseCommand as backup)
Alembic env.py Must Import All Models
Problem: Migration runs but table isn't created. Or migration fails with model-related errors.
Cause: Alembic's env.py only discovers models that are imported. If you add a new model (like InviteCode), you must add it to the imports.
Solution: Add new models to the import statement in alembic/env.py:
# BEFORE: Missing InviteCode
from app.models import User, Team, Tree, Session, Attachment
# AFTER: All models imported
from app.models import User, Team, Tree, Session, Attachment, InviteCode
Files affected: backend/alembic/env.py
New Users Default to "engineer" Role, Not Admin
Problem: After registering, user cannot access admin-only endpoints (like creating invite codes). Returns 403 Forbidden.
Cause: The User model defaults to role="engineer". The first user isn't automatically made admin.
Solution: Manually promote user to admin via Railway's Postgres Query tab:
UPDATE users SET role = 'admin' WHERE email = 'your@email.com';
Then log out and back in to get a new JWT with the updated role.
Files affected: Database (manual update required for first admin)
Frontend VITE_API_URL Must Use HTTPS, No Port
Problem: Frontend gets "Network Error" when trying to call API on Railway.
Cause: Common mistakes:
- Using
http://instead ofhttps:// - Including port number (
:8080) when Railway handles routing - Typos in variable names (e.g.,
FRONTED_URLvsFRONTEND_URL)
Solution: Ensure correct format:
# WRONG
VITE_API_URL=http://api.patherly.com:8080
VITE_API_URL=http://api.patherly.com
# CORRECT
VITE_API_URL=https://api.patherly.com
Note: This is a build-time variable. After changing it, you must trigger a new frontend build/deploy.
Swagger OAuth2 Password Flow Returns 401
Problem: Clicking "Authorize" in Swagger UI with username/password returns 401, even with correct credentials.
Workaround: Use the login endpoint directly instead:
- Call
POST /api/v1/auth/loginwith your credentials - Copy the
access_tokenfrom the response - Click "Authorize" button
- Paste just the token (no "Bearer" prefix) in the authorization field
Files affected: This is a Swagger UI / FastAPI OAuth2 configuration quirk
Seed Script: Use --email/--password Instead of Creating User
Problem: Seed script fails when REQUIRE_INVITE_CODE=true because it tries to register a new seed user without an invite code.
Solution: Modified seed script to accept admin credentials via CLI arguments:
python -m scripts.seed_trees \
--api-url https://api.patherly.com/api/v1 \
--email admin@patherly.com \
--password "your-password"
Files affected: backend/scripts/seed_trees.py
Seed Script: Use venv Python, Not System Python
Problem: Running python -m scripts.seed_trees fails with ModuleNotFoundError: No module named 'httpx' even after installing httpx.
Cause: Installing with system Python (pip install httpx) but running with venv Python, or vice versa.
Solution: Always use the venv's Python explicitly:
# Use venv Python directly
./venv/Scripts/python -m scripts.seed_trees --api-url ... --email ... --password ...
# Or ensure venv is activated first
.\venv\Scripts\Activate
python -m scripts.seed_trees ...
Files affected: Any script in backend/scripts/
Railway Environment Variable Naming
Problem: Environment variable isn't being read by the application.
Checklist:
- Case-sensitive:
FRONTEND_URL≠frontend_url≠Frontend_Url - No typos:
FRONTEND_URL≠FRONTED_URL - Empty string vs not set: Setting a variable to empty string is different from not setting it
- Delete stale variables: If you changed a variable from a field to a property (like
DATABASE_URL_SYNC), delete the Railway variable
Files affected: Railway dashboard → Service → Variables tab
SQLAlchemy / Database
Two Foreign Keys to Same Table Require foreign_keys= ⚠️ CRITICAL
Problem: SQLAlchemy raises AmbiguousForeignKeysError when a model has two ForeignKey columns pointing to the same table (e.g., deleted_by and author_id both referencing users.id).
Cause: SQLAlchemy can't figure out which FK to use for each relationship.
Solution: Add foreign_keys= parameter on BOTH relationship sides:
# In User model
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
# In Tree model with two user FKs
author: Mapped["User"] = relationship("User", foreign_keys=[author_id])
deleted_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[deleted_by])
Files affected: Any model with multiple FK references to the same table (User, Tree)
Circular FK Between Tables Requires Nullable FK
Problem: Account.owner_id → users.id and User.account_id → accounts.id creates a circular dependency. CREATE TABLE fails because neither table can be created first.
Cause: Both tables reference each other, so neither can exist before the other.
Solution: Make one FK nullable and set it after both records exist:
# Create Account first (owner_id=NULL)
account = Account(name="My Account")
db.add(account)
await db.flush()
# Create User with account_id
user = User(account_id=account.id, ...)
db.add(user)
await db.flush()
# Now set owner_id
account.owner_id = user.id
await db.commit()
Files affected: backend/app/models/account.py, registration endpoint
Authentication / Security
Backend Enforcement of must_change_password via Dependency Injection
Problem: Need to force users to change their password before accessing any endpoint, but some endpoints (like the change-password endpoint itself) must be exempt.
Solution: Add a check in get_current_active_user dependency with a route allowlist:
async def get_current_active_user(request: Request, ...):
user = ... # existing auth logic
# Check must_change_password (with route allowlist)
if user.must_change_password:
allowed = ["/auth/password/change", "/auth/logout", "/auth/me"]
if not any(request.url.path.endswith(p) for p in allowed):
raise HTTPException(403, detail="password_change_required")
return user
Key insight: This requires adding request: Request parameter to the dependency. The frontend checks the 403 detail string and redirects to /change-password.
Files affected: backend/app/api/deps.py, frontend/src/components/layout/ProtectedRoute.tsx
Password Reset Tokens: DB-Backed Single-Use via Hashed JTI
Pattern: Password reset tokens use JWT for transport but are tracked in the database for single-use enforcement.
How it works:
- Generate JWT with
type: "password_reset",jti: uuid4(),exp: 30min - Store
SHA-256(jti)inpassword_reset_tokenstable - On reset: decode JWT → hash jti → look up in DB → check
used_at IS NULL - After use: set
used_at = now()to prevent reuse
Why not just JWT? JWTs are stateless — you can't revoke them. DB tracking ensures each token is used exactly once.
Files affected: backend/app/models/password_reset_token.py, backend/app/core/security.py
Anti-Enumeration on Forgot Password Endpoint
Pattern: The POST /auth/password/forgot endpoint always returns the same generic success message, regardless of whether the email exists.
Why: If the endpoint returned "email not found" for non-existent users, an attacker could use it to enumerate valid email addresses.
# Always return success, even if email doesn't exist
return {"message": "If an account exists with that email, a reset link has been sent."}
Files affected: backend/app/api/endpoints/auth.py
Seed Scripts
Seed Tree Data Missing Required Validation Fields
Problem: All 18 seed trees fail to create with validation errors: "Action nodes must have a non-empty action" and "Solution nodes must have a non-empty solution".
Cause: The tree validation (added after the seed scripts were written) requires:
- Action nodes:
actionfield (not justdescription) - Solution nodes:
solutionfield (not justdescription) - Decision nodes with children: at least 2 branches
The seed data uses title and description but not the specific action/solution fields.
Solution: Add a recursive _fix_node_fields() function in the seeder that patches nodes before sending to the API:
def _fix_node_fields(node):
if node["type"] == "action" and not node.get("action"):
node["action"] = node.get("title") or node.get("description") or "Action"
elif node["type"] == "solution" and not node.get("solution"):
node["solution"] = node.get("title") or node.get("description") or "Solution"
elif node["type"] == "decision":
children = node.get("children", [])
if len(children) == 1: # Add fallback branch
children.append({"id": "..._fallback", "type": "solution", ...})
for child in node.get("children", []):
_fix_node_fields(child)
Files affected: backend/scripts/seed_trees_v2.py
Admin Features
Two-Step Hard Delete Pattern
Pattern: Hard-deleting a user requires two API calls — a precheck followed by the actual delete.
GET /admin/users/{id}/hard-delete-check→ returns{can_delete: bool, blockers: {owned_accounts: 3, sessions: 12, ...}}DELETE /admin/users/{id}/hard-delete→ only succeeds if user is archived AND precheck passes
Pre-conditions enforced:
- User must be archived (
deleted_at IS NOT NULL) before hard delete - User must have no blockers (owned accounts, authored trees, sessions, audit logs, invite codes)
- Cannot delete yourself or other super admins
- Audit log entry created BEFORE the delete (since user won't exist after)
Files affected: backend/app/api/endpoints/admin.py
Admin User Creation with Temp Password (M365-Style)
Pattern: Admin creates a user → gets a one-time temp password → user must change on first login.
Key design decisions:
- Temp password is generated server-side (
secrets-based, 16 chars, guaranteed complexity) - Temp password is returned once in the response, never stored as plaintext
must_change_password=Trueis set on the user, enforced at the dependency level- Welcome email with temp password is best-effort (never blocks user creation)
- Two modes: "existing account" (join with role) or "personal" (new account created)
Files affected: backend/app/api/endpoints/admin.py, backend/app/core/security.py
Export / Redaction
CORS expose_headers Required for Custom Response Headers
Problem: Frontend reads custom response headers (e.g. X-Redaction-Summary) but gets undefined — the header exists in the response but Axios can't access it.
Cause: Browsers enforce CORS restrictions on response headers. Only "CORS-safelisted" headers (Cache-Control, Content-Type, etc.) are accessible by default. Custom headers require explicit exposure.
Solution: Add expose_headers to CORS middleware in main.py (both branches):
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
expose_headers=["X-Redaction-Mode", "X-Redaction-Summary"],
...
)
Files affected: backend/app/main.py (both CORS middleware branches)
Redaction Must Run AFTER Variable Resolution
Problem: Sensitive data injected via session variables (e.g. {{client_ip}} → 192.168.1.1) would bypass redaction if it ran before variable substitution.
Solution: Export pipeline order matters:
- Generate export by format (markdown/html/text/psa)
- Resolve session variables
- Apply redaction (if
redaction_mode == "mask")
Key file: backend/app/api/endpoints/sessions.py — the redaction block is placed after both generation and variable resolution, with fail-closed error handling (500 on failure, never return unredacted content).
Adding New Lessons
When you encounter and fix a bug, add it here with:
- Problem: What error/symptom occurred
- Cause: Why it happened (if known)
- Solution: How to fix it
- Files affected: Where to look (if applicable)